689 lines
31 KiB
Python
689 lines
31 KiB
Python
import re
|
||
from decimal import Decimal
|
||
|
||
from django.contrib import messages
|
||
from django.db import transaction
|
||
from django.db.models import Prefetch
|
||
from django.shortcuts import get_object_or_404, redirect, render
|
||
|
||
from .forms import ProblemCaseForm
|
||
from .models import ActionPlanStep, ProblemCase, RootCause, SolutionOption
|
||
|
||
|
||
def _clamp(value, minimum=1, maximum=100):
|
||
return max(minimum, min(maximum, int(value)))
|
||
|
||
|
||
def _has_any(text, keywords):
|
||
return any(keyword in text for keyword in keywords)
|
||
|
||
|
||
def _decision_score(impact, efficiency, speed, low_risk):
|
||
return Decimal(str(round((impact * 0.40) + (efficiency * 0.30) + (speed * 0.20) + (low_risk * 0.10), 2)))
|
||
|
||
|
||
def _case_title_from_description(description):
|
||
first_line = " ".join(description.strip().split())
|
||
if len(first_line) <= 78:
|
||
return first_line
|
||
return f"{first_line[:75].rstrip()}..."
|
||
|
||
|
||
MONEY_UNITS = {
|
||
"ribu": 1_000,
|
||
"rb": 1_000,
|
||
"juta": 1_000_000,
|
||
"jt": 1_000_000,
|
||
"miliar": 1_000_000_000,
|
||
"milyar": 1_000_000_000,
|
||
}
|
||
|
||
NUMBER_WORDS = {
|
||
"satu": 1,
|
||
"dua": 2,
|
||
"tiga": 3,
|
||
"empat": 4,
|
||
"lima": 5,
|
||
"enam": 6,
|
||
"tujuh": 7,
|
||
"delapan": 8,
|
||
"sembilan": 9,
|
||
"sepuluh": 10,
|
||
}
|
||
|
||
COUNTRY_KEYWORDS = {
|
||
"Singapura": ["singapura", "singapore"],
|
||
"Jepang": ["jepang", "japan"],
|
||
"Indonesia": ["indonesia", "lokal", "dalam negeri"],
|
||
"Malaysia": ["malaysia"],
|
||
"Australia": ["australia"],
|
||
"Korea Selatan": ["korea", "korea selatan"],
|
||
}
|
||
|
||
EDUCATION_COST_ASSUMPTIONS = {
|
||
"Jepang": {
|
||
"tuition_year": (60_000_000, 130_000_000),
|
||
"living_month": (10_000_000, 18_000_000),
|
||
"setup": 25_000_000,
|
||
"note": "lebih realistis bila digabung beasiswa, kota hemat, dan kerja paruh waktu sesuai aturan visa",
|
||
},
|
||
"Singapura": {
|
||
"tuition_year": (180_000_000, 450_000_000),
|
||
"living_month": (18_000_000, 35_000_000),
|
||
"setup": 35_000_000,
|
||
"note": "biasanya perlu scholarship/subsidy besar agar masuk budget ketat",
|
||
},
|
||
}
|
||
|
||
|
||
def _format_idr(amount):
|
||
if amount is None:
|
||
return "Belum terdeteksi"
|
||
return f"Rp{int(round(amount)):,.0f}".replace(",", ".")
|
||
|
||
|
||
def _format_idr_range(low, high):
|
||
return f"{_format_idr(low)} – {_format_idr(high)}"
|
||
|
||
|
||
def _number_value(raw_number):
|
||
value = raw_number.lower().replace("rp", "").replace("idr", "").replace(" ", "").strip()
|
||
if "," in value and "." in value:
|
||
value = value.replace(".", "").replace(",", ".") if value.rfind(",") > value.rfind(".") else value.replace(",", "")
|
||
elif "," in value:
|
||
value = value.replace(",", ".")
|
||
elif "." in value:
|
||
parts = value.split(".")
|
||
if len(parts) > 1 and len(parts[-1]) == 3 and all(part.isdigit() for part in parts):
|
||
value = "".join(parts)
|
||
try:
|
||
return float(value)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _money_to_idr(raw_number, unit=None):
|
||
number = _number_value(raw_number)
|
||
if number is None:
|
||
return None
|
||
unit = (unit or "").lower()
|
||
if unit in MONEY_UNITS:
|
||
return int(number * MONEY_UNITS[unit])
|
||
return int(number) if number >= 10_000 else None
|
||
|
||
|
||
def _extract_money_amounts(text):
|
||
patterns = [
|
||
r"(?:rp\.?|idr)\s*([0-9][0-9.,]*)\s*(miliar|milyar|juta|jt|ribu|rb)?",
|
||
r"(?:uang|dana|budget|anggaran|biaya|modal|tabungan|cash|kas|hutang|utang|gaji)\s*(?:hanya|ada|sekitar|kurang lebih|maksimal|max|sebesar|:|=)?\s*(?:rp\.?|idr)?\s*([0-9][0-9.,]*)\s*(miliar|milyar|juta|jt|ribu|rb)?",
|
||
r"([0-9][0-9.,]*)\s*(miliar|milyar|juta|jt|ribu|rb)\b",
|
||
]
|
||
amounts = []
|
||
for pattern in patterns:
|
||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||
amount = _money_to_idr(match.group(1), match.group(2) if len(match.groups()) > 1 else None)
|
||
if amount:
|
||
amounts.append(amount)
|
||
return sorted(set(amounts))
|
||
|
||
|
||
def _extract_duration_years(text):
|
||
match = re.search(r"([0-9]+(?:[,.][0-9]+)?)\s*(tahun|thn|year|years|yr|bulan|bln|month|months|semester)", text, flags=re.IGNORECASE)
|
||
if match:
|
||
value = _number_value(match.group(1))
|
||
unit = match.group(2).lower()
|
||
if value:
|
||
if unit in {"bulan", "bln", "month", "months"}:
|
||
return value / 12
|
||
if unit == "semester":
|
||
return value / 2
|
||
return round(value, 2)
|
||
word_pattern = r"(" + "|".join(NUMBER_WORDS.keys()) + r")\s*(tahun|thn|year|years|bulan|bln|semester)"
|
||
match = re.search(word_pattern, text, flags=re.IGNORECASE)
|
||
if match:
|
||
value = NUMBER_WORDS[match.group(1).lower()]
|
||
unit = match.group(2).lower()
|
||
if unit in {"bulan", "bln"}:
|
||
return value / 12
|
||
if unit == "semester":
|
||
return value / 2
|
||
return float(value)
|
||
return None
|
||
|
||
|
||
def _format_years(years):
|
||
if years is None:
|
||
return "Belum terdeteksi"
|
||
if years < 1:
|
||
months = max(1, int(round(years * 12)))
|
||
return f"{months} bulan"
|
||
return f"{int(years)} tahun" if float(years).is_integer() else f"{years:.1f} tahun"
|
||
|
||
|
||
def _extract_countries(text):
|
||
return [country for country, keywords in COUNTRY_KEYWORDS.items() if any(keyword in text for keyword in keywords)]
|
||
|
||
|
||
def _extract_percentages(text):
|
||
percentages = []
|
||
for match in re.finditer(r"([0-9]+(?:[,.][0-9]+)?)\s*(%|persen|percent)", text, flags=re.IGNORECASE):
|
||
value = _number_value(match.group(1))
|
||
if value is not None:
|
||
percentages.append(value)
|
||
return sorted(set(percentages))
|
||
|
||
|
||
def _extract_case_data(description):
|
||
text = description.lower()
|
||
money_amounts = _extract_money_amounts(text)
|
||
return {
|
||
"budget": max(money_amounts) if money_amounts else None,
|
||
"money_amounts": money_amounts,
|
||
"duration_years": _extract_duration_years(text),
|
||
"countries": _extract_countries(text),
|
||
"percentages": _extract_percentages(text),
|
||
}
|
||
|
||
|
||
def _case_constraint_note(case_data):
|
||
parts = []
|
||
if case_data.get("budget"):
|
||
parts.append(f"dana {_format_idr(case_data['budget'])}")
|
||
if case_data.get("duration_years"):
|
||
parts.append(f"target {_format_years(case_data['duration_years'])}")
|
||
if case_data.get("countries"):
|
||
parts.append(f"opsi {', '.join(case_data['countries'])}")
|
||
return ", ".join(parts) if parts else "data yang tertulis di pertanyaan"
|
||
|
||
|
||
def _education_cost_rows(case_data, fallback_years=None):
|
||
years = case_data.get("duration_years") or fallback_years
|
||
if not years:
|
||
return []
|
||
rows = []
|
||
for country in [c for c in case_data.get("countries", []) if c in EDUCATION_COST_ASSUMPTIONS]:
|
||
assumption = EDUCATION_COST_ASSUMPTIONS[country]
|
||
tuition_low, tuition_high = assumption["tuition_year"]
|
||
living_low, living_high = assumption["living_month"]
|
||
months = years * 12
|
||
total_low = int(round(tuition_low * years + living_low * months + assumption["setup"]))
|
||
total_high = int(round(tuition_high * years + living_high * months + assumption["setup"]))
|
||
budget = case_data.get("budget")
|
||
rows.append({
|
||
"country": country,
|
||
"total_low": total_low,
|
||
"total_high": total_high,
|
||
"coverage": round((budget / total_low) * 100, 1) if budget else None,
|
||
"shortfall_low": max(0, total_low - budget) if budget else None,
|
||
"surplus_low": max(0, budget - total_low) if budget else None,
|
||
"note": assumption["note"],
|
||
})
|
||
return rows
|
||
|
||
|
||
def _build_case_insights(description, category=None):
|
||
case_data = _extract_case_data(description)
|
||
budget = case_data.get("budget")
|
||
years = case_data.get("duration_years")
|
||
countries = case_data.get("countries")
|
||
percentages = case_data.get("percentages") or []
|
||
detected = [
|
||
{"label": "Dana/budget", "value": _format_idr(budget) if budget else "Belum disebut", "note": "angka terbesar yang terdeteksi sebagai uang" if budget else "tambahkan nominal agar kalkulasi lebih presisi"},
|
||
{"label": "Target waktu", "value": _format_years(years) if years else "Belum disebut", "note": "diambil dari kata tahun/bulan/semester" if years else "tambahkan deadline atau durasi target"},
|
||
{"label": "Persentase/rasio", "value": ", ".join(f"{value:g}%" for value in percentages) if percentages else "Belum disebut", "note": "persen yang muncul di pertanyaan" if percentages else "tambahkan rasio seperti turun 30% atau target naik 20%"},
|
||
{"label": "Opsi/negara", "value": ", ".join(countries) if countries else "Belum spesifik", "note": "opsi yang muncul di pertanyaan" if countries else "tambahkan opsi yang ingin dibandingkan"},
|
||
]
|
||
calculations = []
|
||
if budget and years:
|
||
months = years * 12
|
||
reserve = budget * 0.10
|
||
usable = budget - reserve
|
||
if years >= 1:
|
||
calculations.append({"label": "Budget per tahun", "formula": f"{_format_idr(budget)} ÷ {_format_years(years)}", "result": f"≈ {_format_idr(budget / years)} / tahun"})
|
||
else:
|
||
calculations.append({"label": "Budget untuk periode target", "formula": f"{_format_idr(budget)} untuk {_format_years(years)}", "result": f"≈ {_format_idr(budget)} total periode"})
|
||
calculations.extend([
|
||
{"label": "Budget per bulan", "formula": f"{_format_idr(budget)} ÷ {int(round(months))} bulan", "result": f"≈ {_format_idr(budget / months)} / bulan"},
|
||
{"label": "Dana aman setelah cadangan 10%", "formula": f"{_format_idr(budget)} − 10% cadangan ({_format_idr(reserve)})", "result": f"≈ {_format_idr(usable)} total atau {_format_idr(usable / months)} / bulan"},
|
||
])
|
||
if percentages:
|
||
calculations.append({"label": "Persentase dari pertanyaan", "formula": f"Angka rasio terdeteksi: {', '.join(f'{value:g}%' for value in percentages)}", "result": "Tambahkan baseline nominal agar dampak rupiahnya bisa dihitung."})
|
||
elif budget:
|
||
calculations.append({"label": "Cadangan minimum 10%", "formula": f"{_format_idr(budget)} × 10%", "result": f"≈ {_format_idr(budget * 0.10)} disisihkan sebagai buffer risiko"})
|
||
elif years:
|
||
calculations.append({"label": "Target waktu terdeteksi", "formula": f"Durasi target = {_format_years(years)}", "result": "Masukkan nominal dana/biaya agar bisa dihitung budget per bulan dan gap biaya."})
|
||
|
||
comparisons = []
|
||
if category == "Pendidikan":
|
||
for row in _education_cost_rows(case_data, fallback_years=years):
|
||
if budget:
|
||
fit = f"Budget menutup ±{row['coverage']}% dari estimasi minimum; gap minimum {_format_idr(row['shortfall_low'])}." if row["shortfall_low"] else f"Budget melewati estimasi minimum; sisa aman sekitar {_format_idr(row['surplus_low'])}."
|
||
else:
|
||
fit = "Budget belum disebut, jadi gap belum bisa dihitung."
|
||
comparisons.append({"option": row["country"], "estimate": _format_idr_range(row["total_low"], row["total_high"]), "budget_fit": fit, "note": row["note"]})
|
||
|
||
recommendation = ""
|
||
if category == "Pendidikan" and comparisons and budget:
|
||
rows = _education_cost_rows(case_data, fallback_years=years)
|
||
best = min(rows, key=lambda item: item["total_low"])
|
||
recommendation = (
|
||
f"Secara self-funded, {_format_idr(budget)} belum menutup estimasi minimum {best['country']} ({_format_idr(best['total_low'])}). Opsi paling dekat tetap {best['country']}, tetapi harus dikunci dengan beasiswa/tuition waiver, kota hemat, atau income legal."
|
||
if best["shortfall_low"] else
|
||
f"Opsi paling aman secara angka awal adalah {best['country']} karena estimasi minimumnya masih masuk budget."
|
||
)
|
||
elif calculations:
|
||
recommendation = f"Analisis disesuaikan dengan {_case_constraint_note(case_data)}; gunakan angka ini sebagai batas saat memilih solusi."
|
||
|
||
missing = []
|
||
if not budget:
|
||
missing.append("Nominal dana/biaya/budget belum jelas.")
|
||
if not years:
|
||
missing.append("Target durasi atau deadline belum jelas.")
|
||
if category == "Pendidikan" and not countries:
|
||
missing.append("Negara/kampus pembanding belum disebut.")
|
||
return {
|
||
"detected": detected,
|
||
"calculations": calculations,
|
||
"comparisons": comparisons,
|
||
"recommendation": recommendation,
|
||
"missing": missing,
|
||
"assumption_note": "Estimasi biaya pendidikan adalah asumsi awal untuk screening; tetap verifikasi biaya resmi kampus, visa, kurs, beasiswa, dan aturan kerja terbaru.",
|
||
}
|
||
|
||
|
||
ANALYSIS_DATABASE = [
|
||
{
|
||
"kategori": "Pendidikan",
|
||
"keyword": [
|
||
"kuliah", "studi", "study", "kampus", "universitas", "university",
|
||
"beasiswa", "scholarship", "s1", "s2", "sarjana", "bachelor",
|
||
"master", "singapura", "singapore", "jepang", "japan", "jlpt", "ielts",
|
||
],
|
||
"penyebab": [
|
||
"Budget belum cukup untuk total biaya studi",
|
||
"Pilihan negara/kampus belum dibandingkan dari total cost of study",
|
||
"Target lulus 3 tahun butuh validasi kurikulum, bahasa, dan admission",
|
||
],
|
||
"solusi": [
|
||
"Prioritaskan Jepang dengan beasiswa dan kota hemat",
|
||
"Pilih Singapura hanya jika ada scholarship/subsidy besar",
|
||
"Buat shortlist program 3 tahun dan simulasi biaya lengkap",
|
||
],
|
||
"impact_label": "Budget ketat",
|
||
"priority": 50,
|
||
},
|
||
{
|
||
"kategori": "Bisnis",
|
||
"keyword": ["jualan", "penjualan", "produk", "usaha", "bisnis", "toko"],
|
||
"penyebab": [
|
||
"Target pasar tidak tepat",
|
||
"Promosi kurang efektif",
|
||
"Produk belum sesuai kebutuhan pasar",
|
||
],
|
||
"solusi": [
|
||
"Riset pelanggan",
|
||
"Perbaiki produk",
|
||
"Optimasi pemasaran",
|
||
"Bangun channel penjualan",
|
||
],
|
||
"impact_label": "Besar",
|
||
"priority": 10,
|
||
},
|
||
{
|
||
"kategori": "Keuangan",
|
||
"keyword": ["uang", "hutang", "utang", "gaji", "tabungan", "biaya"],
|
||
"penyebab": [
|
||
"Tidak ada kontrol keuangan",
|
||
"Pengeluaran terlalu besar",
|
||
"Pemasukan kurang optimal",
|
||
],
|
||
"solusi": [
|
||
"Buat anggaran",
|
||
"Kurangi biaya tidak penting",
|
||
"Cari sumber pemasukan baru",
|
||
],
|
||
"impact_label": "Cashflow/budget",
|
||
"priority": 10,
|
||
},
|
||
{
|
||
"kategori": "Karier",
|
||
"keyword": ["kerja", "pekerjaan", "cv", "skill", "karir", "karier"],
|
||
"penyebab": [
|
||
"Skill tidak sesuai kebutuhan",
|
||
"Kurang pengalaman",
|
||
"Portofolio belum kuat",
|
||
],
|
||
"solusi": [
|
||
"Upgrade skill",
|
||
"Buat portofolio",
|
||
"Cari peluang kerja sesuai kemampuan",
|
||
],
|
||
"impact_label": "Dampak karier",
|
||
"priority": 10,
|
||
},
|
||
{
|
||
"kategori": "Logistik",
|
||
"keyword": ["kirim", "pengiriman", "barang", "gudang", "rute"],
|
||
"penyebab": [
|
||
"Rute tidak optimal",
|
||
"Monitoring kurang",
|
||
"Proses distribusi lambat",
|
||
],
|
||
"solusi": [
|
||
"Optimasi rute",
|
||
"Tracking barang",
|
||
"Perbaiki sistem distribusi",
|
||
],
|
||
"impact_label": "Operasional",
|
||
"priority": 10,
|
||
},
|
||
{
|
||
"kategori": "Teknologi",
|
||
"keyword": ["aplikasi", "website", "coding", "program", "error", "bug"],
|
||
"penyebab": [
|
||
"Sistem belum optimal",
|
||
"Arsitektur kurang tepat",
|
||
"Bug perangkat lunak",
|
||
],
|
||
"solusi": [
|
||
"Audit sistem",
|
||
"Perbaiki kode",
|
||
"Optimasi teknologi",
|
||
],
|
||
"impact_label": "Teknis/operasional",
|
||
"priority": 10,
|
||
},
|
||
]
|
||
|
||
|
||
def _detect_problem_category(description):
|
||
text = description.lower()
|
||
best_item = None
|
||
best_score = -1
|
||
|
||
for item in ANALYSIS_DATABASE:
|
||
matches = sum(1 for keyword in item["keyword"] if keyword in text)
|
||
if matches == 0:
|
||
continue
|
||
|
||
score = matches * 10 + item.get("priority", 0)
|
||
if item["kategori"] == "Pendidikan" and _has_any(text, ["kuliah", "studi", "kampus", "universitas"]):
|
||
score += 35
|
||
if item["kategori"] == "Pendidikan" and _has_any(text, ["singapura", "singapore", "jepang", "japan"]):
|
||
score += 20
|
||
|
||
if score > best_score:
|
||
best_item = item
|
||
best_score = score
|
||
|
||
return best_item or next(item for item in ANALYSIS_DATABASE if item["kategori"] == "Bisnis")
|
||
|
||
|
||
def _is_education_decision(description):
|
||
text = description.lower()
|
||
education_keywords = [
|
||
"kuliah", "studi", "study", "kampus", "universitas", "university",
|
||
"beasiswa", "scholarship", "s1", "s2", "sarjana", "bachelor",
|
||
"master", "singapura", "singapore", "jepang", "japan", "jlpt", "ielts",
|
||
]
|
||
constraint_keywords = [
|
||
"dana", "budget", "biaya", "uang", "200 juta", "200jt", "3 tahun",
|
||
"tiga tahun", "kelar", "lulus", "visa",
|
||
]
|
||
return _has_any(text, education_keywords) and _has_any(text, constraint_keywords)
|
||
|
||
|
||
def _infer_business_area(description):
|
||
text = description.lower()
|
||
kategori = _detect_problem_category(description)["kategori"]
|
||
|
||
if kategori == "Pendidikan":
|
||
return ProblemCase.AREA_OTHER
|
||
if kategori == "Keuangan":
|
||
return ProblemCase.AREA_FINANCE
|
||
if kategori == "Karier":
|
||
return ProblemCase.AREA_PEOPLE
|
||
if kategori in {"Logistik", "Teknologi"}:
|
||
return ProblemCase.AREA_OPERATIONS
|
||
if _has_any(text, ["iklan", "marketing", "konten", "campaign", "kampanye", "promosi"]):
|
||
return ProblemCase.AREA_MARKETING
|
||
if _has_any(text, ["produk", "layanan", "fitur", "kualitas"]):
|
||
return ProblemCase.AREA_PRODUCT
|
||
return ProblemCase.AREA_SALES
|
||
|
||
|
||
def _write_analysis_records(problem, financial_impact, cause_profiles, options, steps):
|
||
problem.root_causes.all().delete()
|
||
problem.solutions.all().delete()
|
||
|
||
problem.priority_score = _clamp(80 + problem.urgency * 3)
|
||
problem.financial_impact = financial_impact
|
||
problem.status = ProblemCase.STATUS_ANALYZED
|
||
problem.save(update_fields=["priority_score", "financial_impact", "status", "updated_at"])
|
||
|
||
RootCause.objects.bulk_create([
|
||
RootCause(
|
||
problem=problem,
|
||
factor=factor,
|
||
contribution_score=_clamp(score),
|
||
why_chain=why_chain,
|
||
)
|
||
for factor, score, why_chain in cause_profiles
|
||
])
|
||
|
||
scored = []
|
||
for option in options:
|
||
scored.append({
|
||
**option,
|
||
"decision_score": _decision_score(
|
||
option["impact"],
|
||
option["efficiency"],
|
||
option["speed"],
|
||
option["low_risk"],
|
||
),
|
||
})
|
||
scored.sort(key=lambda item: item["decision_score"], reverse=True)
|
||
|
||
created_solutions = SolutionOption.objects.bulk_create([
|
||
SolutionOption(
|
||
problem=problem,
|
||
title=option["title"],
|
||
impact=option["impact"],
|
||
efficiency=option["efficiency"],
|
||
speed=option["speed"],
|
||
low_risk=option["low_risk"],
|
||
decision_score=option["decision_score"],
|
||
success_rate=option["success_rate"],
|
||
rank=rank,
|
||
rationale=option["rationale"],
|
||
)
|
||
for rank, option in enumerate(scored, start=1)
|
||
])
|
||
|
||
top_solution = created_solutions[0]
|
||
ActionPlanStep.objects.bulk_create([
|
||
ActionPlanStep(solution=top_solution, day_index=day, title=title, task=task)
|
||
for day, title, task in steps
|
||
])
|
||
|
||
|
||
def _build_education_analysis(problem):
|
||
case_data = _extract_case_data(problem.description)
|
||
constraint_note = _case_constraint_note(case_data)
|
||
budget = case_data.get("budget")
|
||
years = case_data.get("duration_years") or 3
|
||
budget_text = _format_idr(budget) if budget else "budget yang tersedia"
|
||
years_text = _format_years(years)
|
||
row_by_country = {row["country"]: row for row in _education_cost_rows(case_data, fallback_years=years)}
|
||
japan_gap_text = ""
|
||
singapore_gap_text = ""
|
||
if budget and row_by_country.get("Jepang"):
|
||
gap = row_by_country["Jepang"].get("shortfall_low") or 0
|
||
japan_gap_text = f" Estimasi minimum Jepang masih butuh tambahan sekitar {_format_idr(gap)} bila tanpa beasiswa." if gap else " Estimasi minimum Jepang masuk batas budget awal."
|
||
if budget and row_by_country.get("Singapura"):
|
||
gap = row_by_country["Singapura"].get("shortfall_low") or 0
|
||
singapore_gap_text = f" Estimasi minimum Singapura butuh tambahan sekitar {_format_idr(gap)} bila tanpa scholarship besar." if gap else " Estimasi minimum Singapura masuk batas budget awal."
|
||
if budget:
|
||
budget_math = f"Dengan {budget_text} untuk {years_text}, batas kasar adalah {_format_idr(budget / years)}/tahun atau {_format_idr(budget / (years * 12))}/bulan sebelum cadangan."
|
||
else:
|
||
budget_math = "Nominal budget belum terbaca, jadi kalkulasi gap harus dilengkapi dengan angka dana/biaya."
|
||
|
||
cause_profiles = [
|
||
("Keterbatasan Dana", 96, f"Pertanyaan menyebut {constraint_note}. {budget_math} Karena itu, keputusan harus dimulai dari total biaya studi, bukan dari nama negara saja."),
|
||
("Target Lulus Tepat Waktu", 92, f"Target {years_text} hanya realistis jika program, credit transfer, kalender akademik, dan syarat kelulusan cocok sejak awal."),
|
||
("Gap Biaya Negara", 88, f"Perbandingan perlu memakai total tuition + living cost + setup cost. {japan_gap_text}{singapore_gap_text}".strip()),
|
||
("Bahasa & Admission", 76, "Risiko gagal masuk atau molor muncul jika syarat IELTS/JLPT, dokumen, deadline, dan kesiapan bahasa belum dipetakan sebelum memilih negara."),
|
||
]
|
||
options = [
|
||
{"title": "Prioritaskan Jepang + Beasiswa/Part-time Legal", "impact": 88, "efficiency": 86, "speed": 72, "low_risk": 78, "success_rate": 84, "rationale": f"Paling dekat dengan batas {budget_text} karena gap estimasi minimum Jepang biasanya lebih kecil daripada Singapura. Tetap wajib cari beasiswa/tuition waiver dan kota hemat; jangan asumsi {budget_text} cukup untuk self-funded penuh."},
|
||
{"title": "Pathway Hemat: Mulai Lokal lalu Transfer/Credit Recognition", "impact": 76, "efficiency": 88, "speed": 68, "low_risk": 86, "success_rate": 80, "rationale": f"Menekan cash-out awal sambil mengejar beasiswa dan kesiapan bahasa. Cocok jika hitungan {budget_text} per {years_text} tidak menutup total biaya studi luar negeri penuh."},
|
||
{"title": "Singapura Hanya Jika Ada Scholarship/Subsidy Besar", "impact": 82, "efficiency": 58, "speed": 86, "low_risk": 55, "success_rate": 66, "rationale": f"Singapura bisa unggul untuk akses industri dan durasi cepat, tetapi {budget_text} berisiko tidak cukup tanpa bantuan biaya yang jelas sejak awal.{singapore_gap_text}"},
|
||
]
|
||
steps = [
|
||
(1, "Buat batas biaya final", f"Pecah {budget_text} menjadi tuition, living cost, visa, tiket, asuransi, dan dana darurat 10%; coret opsi yang melewati batas aman."),
|
||
(2, "Shortlist program sesuai durasi", f"Cari minimal 10 program Jepang dan 3 program Singapura yang durasinya mendekati {years_text}, lalu catat syarat bahasa, deadline, dan total biaya."),
|
||
(3, "Hitung gap funding", "Bandingkan estimasi total biaya dengan dana tersedia; targetkan beasiswa, tuition waiver, sponsor, atau income legal untuk menutup gap."),
|
||
(4, "Validasi aturan visa", "Cek izin kerja paruh waktu, syarat dokumen finansial, serta risiko jika kurs/biaya hidup naik."),
|
||
(5, "Decision gate", "Pilih opsi dengan gap terkecil dan bukti funding paling jelas; Singapura hanya layak jika scholarship/subsidy membuat biaya total masuk budget."),
|
||
]
|
||
_write_analysis_records(problem, "Budget ketat", cause_profiles, options, steps)
|
||
|
||
|
||
def _build_category_analysis(problem, category):
|
||
kategori = category["kategori"]
|
||
causes = category["penyebab"]
|
||
solutions = category["solusi"]
|
||
case_data = _extract_case_data(problem.description)
|
||
constraint_note = _case_constraint_note(case_data)
|
||
cause_profiles = [
|
||
(cause, _clamp(92 - index * 8), f"Input terdeteksi sebagai kategori {kategori} dengan konteks: {constraint_note}. Penyebab ini perlu divalidasi karena dapat langsung memengaruhi target, batasan, dan keputusan berikutnya.")
|
||
for index, cause in enumerate(causes)
|
||
]
|
||
options = []
|
||
for index, solution in enumerate(solutions):
|
||
options.append({
|
||
"title": solution,
|
||
"impact": _clamp(88 - index * 4),
|
||
"efficiency": _clamp(82 + (index % 2) * 5 - index * 2),
|
||
"speed": _clamp(84 - index * 5),
|
||
"low_risk": _clamp(74 + index * 4),
|
||
"success_rate": _clamp(82 - index * 3),
|
||
"rationale": f"Solusi ini dipilih karena sesuai kategori {kategori}, menargetkan penyebab '{causes[min(index, len(causes) - 1)]}', dan tetap mengikuti batasan pertanyaan: {constraint_note}.",
|
||
})
|
||
steps = [
|
||
(1, "Kunci tujuan dan batasan", f"Kategori utama: {kategori}. Tulis target akhir, batasan dana/waktu, dan indikator sukses yang terukur dari konteks: {constraint_note}."),
|
||
(2, "Validasi penyebab utama", f"Cek apakah penyebab paling dominan adalah: {causes[0]}. Kumpulkan bukti sederhana sebelum memilih solusi."),
|
||
(3, "Bandingkan opsi solusi", f"Bandingkan opsi: {', '.join(solutions[:3])}. Pilih yang paling sesuai dengan budget, waktu, dan risiko."),
|
||
(4, "Jalankan langkah kecil", "Mulai dari eksperimen paling kecil selama 1-3 hari agar cepat terlihat apakah arah solusi benar."),
|
||
(5, "Evaluasi dan putuskan", "Bandingkan hasil dengan indikator sukses, lalu pilih lanjutkan, ubah strategi, atau hentikan opsi yang tidak efektif."),
|
||
]
|
||
_write_analysis_records(problem, category.get("impact_label", "Sedang"), cause_profiles, options, steps)
|
||
|
||
|
||
def _build_business_analysis(problem):
|
||
cause_profiles = [
|
||
("Marketing", 95, "Akuisisi dan pesan penjualan belum cukup tajam untuk menjangkau segmen yang paling siap membeli."),
|
||
("Kompetitor", 88, "Penawaran kompetitor terlihat lebih kuat sehingga pelanggan membandingkan harga, bonus, dan bukti hasil."),
|
||
("Harga", 85, "Persepsi value belum seimbang dengan harga; bundling, bonus, atau bukti manfaat perlu diperjelas."),
|
||
("Produk", 70, "Produk/layanan perlu validasi ulang dari feedback pelanggan agar lebih relevan dengan kebutuhan saat ini."),
|
||
]
|
||
options = [
|
||
{
|
||
"title": "Optimasi Iklan Digital",
|
||
"impact": 90,
|
||
"efficiency": 70,
|
||
"speed": 85,
|
||
"low_risk": 60,
|
||
"success_rate": 85,
|
||
"rationale": "Perbaiki targeting, materi iklan, dan pesan promosi agar trafik yang masuk lebih relevan dan cepat terukur.",
|
||
},
|
||
{
|
||
"title": "Program Reseller & Agen",
|
||
"impact": 85,
|
||
"efficiency": 80,
|
||
"speed": 60,
|
||
"low_risk": 75,
|
||
"success_rate": 92,
|
||
"rationale": "Bangun kanal distribusi rendah biaya melalui mitra yang sudah punya relasi dan kepercayaan dengan calon pembeli.",
|
||
},
|
||
{
|
||
"title": "Diskon Bundling Akhir Bulan",
|
||
"impact": 70,
|
||
"efficiency": 60,
|
||
"speed": 95,
|
||
"low_risk": 90,
|
||
"success_rate": 70,
|
||
"rationale": "Dorong keputusan pembelian cepat dengan paket bernilai tinggi, periode terbatas, dan risiko operasional rendah.",
|
||
},
|
||
]
|
||
steps = [
|
||
(1, "Audit data cepat", "Kumpulkan angka penjualan, biaya iklan, channel, produk terlaris, dan keluhan pelanggan 90 hari terakhir."),
|
||
(2, "Validasi akar masalah", "Bandingkan temuan dengan faktor Marketing, Kompetitor, Harga, dan Produk; pilih 1–2 penyebab paling dominan."),
|
||
(3, "Luncurkan eksperimen", "Jalankan eksperimen kecil untuk solusi teratas dengan target metrik, owner, dan batas anggaran yang jelas."),
|
||
(4, "Pantau respon pasar", "Review metrik harian: leads, conversion rate, biaya per transaksi, repeat order, dan risiko operasional."),
|
||
(5, "Putuskan scale/stop", "Bandingkan hasil dengan baseline lalu pilih: scale, iterasi pesan/offer, atau hentikan eksperimen."),
|
||
]
|
||
_write_analysis_records(problem, "Besar", cause_profiles, options, steps)
|
||
|
||
|
||
def build_case_analysis(problem):
|
||
"""Create deterministic MVP analysis records with category-aware rules."""
|
||
category = _detect_problem_category(problem.description)
|
||
|
||
if category["kategori"] == "Pendidikan" and _is_education_decision(problem.description):
|
||
_build_education_analysis(problem)
|
||
return
|
||
|
||
_build_category_analysis(problem, category)
|
||
|
||
def home(request):
|
||
form = ProblemCaseForm(request.POST or None)
|
||
if request.method == "POST":
|
||
if form.is_valid():
|
||
with transaction.atomic():
|
||
problem = form.save(commit=False)
|
||
problem.title = _case_title_from_description(problem.description)
|
||
problem.business_area = _infer_business_area(problem.description)
|
||
problem.status = ProblemCase.STATUS_DRAFT
|
||
problem.save()
|
||
build_case_analysis(problem)
|
||
messages.success(request, "✅ Analisis berhasil disimulasikan dan disimpan sebagai case.")
|
||
return redirect(problem.get_absolute_url())
|
||
messages.error(request, "Mohon periksa input. Deskripsi masalah perlu cukup lengkap agar analisis akurat.")
|
||
|
||
recent_cases = ProblemCase.objects.all()[:4]
|
||
context = {
|
||
"form": form,
|
||
"recent_cases": recent_cases,
|
||
"page_title": "OPTEMA AI — MVP Decision Intelligence",
|
||
"meta_description": "OPTEMA AI membantu mengubah masalah bisnis, keuangan, karier, logistik, teknologi, dan pendidikan menjadi problem detection, root-cause analysis, decision scoring, dan action plan.",
|
||
}
|
||
return render(request, "core/index.html", context)
|
||
|
||
|
||
def case_list(request):
|
||
cases = ProblemCase.objects.all()
|
||
return render(request, "core/case_list.html", {
|
||
"cases": cases,
|
||
"page_title": "Daftar Kasus — OPTEMA AI",
|
||
"meta_description": "Lihat histori kasus yang sudah dianalisis oleh OPTEMA AI.",
|
||
})
|
||
|
||
|
||
def case_detail(request, pk):
|
||
action_steps = Prefetch("solutions__action_steps", queryset=ActionPlanStep.objects.all())
|
||
problem = get_object_or_404(
|
||
ProblemCase.objects.prefetch_related("root_causes", "solutions", action_steps),
|
||
pk=pk,
|
||
)
|
||
top_solution = problem.solutions.first()
|
||
category = _detect_problem_category(problem.description)
|
||
case_insights = _build_case_insights(problem.description, category["kategori"])
|
||
return render(request, "core/case_detail.html", {
|
||
"problem": problem,
|
||
"top_solution": top_solution,
|
||
"case_insights": case_insights,
|
||
"page_title": f"{problem.title} — Analisis OPTEMA AI",
|
||
"meta_description": f"Analisis prioritas, akar masalah, solusi berskor, kalkulasi data, dan action plan untuk {problem.title}.",
|
||
})
|