diff --git a/core/views.py b/core/views.py
index afb8c5b..8e32400 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,3 +1,4 @@
+import re
from decimal import Decimal
from django.contrib import messages
@@ -28,6 +29,268 @@ def _case_title_from_description(description):
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",
@@ -245,63 +508,43 @@ def _write_analysis_records(problem, financial_impact, cause_profiles, options,
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,
- "Budget perlu menutup tuition, biaya hidup, visa, tiket, asuransi, dan dana darurat; jadi pilihan negara/kampus harus disaring dari batas biaya dulu.",
- ),
- (
- "Target Lulus 3 Tahun",
- 92,
- "Target selesai cepat hanya realistis jika program, credit transfer, kalender akademik, dan syarat kelulusan cocok sejak awal.",
- ),
- (
- "Biaya Hidup Negara",
- 88,
- "Perbandingan Singapura vs Jepang harus dihitung dari total cost of study, bukan hanya uang kuliah; kota, tempat tinggal, dan transport sangat menentukan.",
- ),
- (
- "Bahasa & Admission",
- 76,
- "Risiko gagal masuk atau molor muncul jika syarat IELTS/JLPT, dokumen, deadline, dan kesiapan bahasa belum dipetakan.",
- ),
+ ("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": "Lebih masuk akal untuk budget ketat bila shortlist difokuskan ke kampus/program yang punya beasiswa, kota yang lebih hemat, dan opsi kerja paruh waktu sesuai aturan visa.",
- },
- {
- "title": "Singapura Hanya Jika Ada Scholarship/Subsidy Besar",
- "impact": 82,
- "efficiency": 58,
- "speed": 86,
- "low_risk": 55,
- "success_rate": 66,
- "rationale": "Singapura bisa unggul untuk akses industri dan durasi cepat, tetapi budget 200 juta berisiko tidak cukup tanpa bantuan biaya yang jelas sejak awal.",
- },
- {
- "title": "Pathway Hemat: Mulai Lokal lalu Transfer/Credit Recognition",
- "impact": 76,
- "efficiency": 88,
- "speed": 68,
- "low_risk": 86,
- "success_rate": 80,
- "rationale": "Menekan cash-out awal sambil mengejar beasiswa dan kesiapan bahasa, tetapi harus dipastikan kredit bisa diakui agar target total 3 tahun tidak mundur.",
- },
+ {"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", "Pecah dana 200 juta menjadi tuition, living cost, visa, tiket, asuransi, dan dana darurat; coret opsi yang melewati batas aman."),
- (2, "Shortlist program 3 tahun", "Cari minimal 10 program Jepang dan 3 program Singapura yang durasinya cocok, lalu tandai syarat bahasa, deadline, dan total biaya."),
- (3, "Kejar funding", "Daftar beasiswa, tuition waiver, atau sponsor; jangan memilih Singapura kecuali kekurangan biaya sudah tertutup jelas."),
+ (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 Jepang jika total biaya paling aman; pilih Singapura hanya jika scholarship/subsidy membuat biaya 3 tahun masuk budget."),
+ (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)
@@ -310,16 +553,12 @@ 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}. Penyebab ini perlu divalidasi karena dapat langsung memengaruhi target, batasan, dan keputusan berikutnya.",
- )
+ (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({
@@ -329,11 +568,10 @@ def _build_category_analysis(problem, category):
"speed": _clamp(84 - index * 5),
"low_risk": _clamp(74 + index * 4),
"success_rate": _clamp(82 - index * 3),
- "rationale": f"Solusi ini cocok untuk kategori {kategori} karena langsung menargetkan penyebab utama: {causes[min(index, len(causes) - 1)]}.",
+ "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."),
+ (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."),
@@ -439,9 +677,12 @@ def case_detail(request, pk):
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, dan action plan untuk {problem.title}.",
+ "meta_description": f"Analisis prioritas, akar masalah, solusi berskor, kalkulasi data, dan action plan untuk {problem.title}.",
})