diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 733da46..2512f3d 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/templates/core/case_detail.html b/core/templates/core/case_detail.html index 8bb6269..2da5b3e 100644 --- a/core/templates/core/case_detail.html +++ b/core/templates/core/case_detail.html @@ -38,6 +38,55 @@ + {% if case_insights %} +
+
Data & Kalkulasi
+

1B. Data yang Terdeteksi dari Pertanyaan

+

Bagian ini dibuat dari angka/konteks yang Anda tulis, supaya jawaban tidak generik dan bisa dicek hitungannya.

+
+ {% for item in case_insights.detected %} +
+ {{ item.label }} + {{ item.value }} + {{ item.note }} +
+ {% endfor %} +
+ {% if case_insights.calculations %} +
+ + + + {% for calc in case_insights.calculations %} + + {% endfor %} + +
KalkulasiRumusHasil
{{ calc.label }}{{ calc.formula }}{{ calc.result }}
+
+ {% endif %} + {% if case_insights.comparisons %} +

Perbandingan Opsi Berdasarkan Data

+
+ + + + {% for row in case_insights.comparisons %} + + {% endfor %} + +
OpsiEstimasi TotalKecocokan BudgetCatatan
{{ row.option }}{{ row.estimate }}{{ row.budget_fit }}{{ row.note }}
+
+

{{ case_insights.assumption_note }}

+ {% endif %} + {% if case_insights.recommendation %} +
{{ case_insights.recommendation }}
+ {% endif %} + {% if case_insights.missing %} +
Data tambahan yang akan membuat jawaban lebih akurat:
+ {% endif %} +
+ {% endif %} +
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}.", })