OPTEMA AI

This commit is contained in:
Flatlogic Bot 2026-06-19 15:42:30 +00:00
parent 65e988640d
commit 60f601a85b
3 changed files with 352 additions and 62 deletions

View File

@ -38,6 +38,55 @@
</div>
</div>
{% if case_insights %}
<div class="analysis-card mb-4">
<div class="section-kicker">Data & Kalkulasi</div>
<h2>1B. Data yang Terdeteksi dari Pertanyaan</h2>
<p class="formula-note">Bagian ini dibuat dari angka/konteks yang Anda tulis, supaya jawaban tidak generik dan bisa dicek hitungannya.</p>
<div class="metric-grid mb-4">
{% for item in case_insights.detected %}
<div class="metric-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</div>
{% endfor %}
</div>
{% if case_insights.calculations %}
<div class="table-responsive mb-4">
<table class="table align-middle solution-table">
<thead><tr><th>Kalkulasi</th><th>Rumus</th><th>Hasil</th></tr></thead>
<tbody>
{% for calc in case_insights.calculations %}
<tr><td><strong>{{ calc.label }}</strong></td><td>{{ calc.formula }}</td><td>{{ calc.result }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if case_insights.comparisons %}
<h3 class="h5 mb-3">Perbandingan Opsi Berdasarkan Data</h3>
<div class="table-responsive mb-4">
<table class="table align-middle solution-table">
<thead><tr><th>Opsi</th><th>Estimasi Total</th><th>Kecocokan Budget</th><th>Catatan</th></tr></thead>
<tbody>
{% for row in case_insights.comparisons %}
<tr><td><strong>{{ row.option }}</strong></td><td>{{ row.estimate }}</td><td>{{ row.budget_fit }}</td><td>{{ row.note }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="formula-note mb-4">{{ case_insights.assumption_note }}</p>
{% endif %}
{% if case_insights.recommendation %}
<div class="alert alert-info mb-3" role="status">{{ case_insights.recommendation }}</div>
{% endif %}
{% if case_insights.missing %}
<div class="alert alert-warning mb-0" role="status"><strong>Data tambahan yang akan membuat jawaban lebih akurat:</strong><ul class="mb-0 mt-2">{% for item in case_insights.missing %}<li>{{ item }}</li>{% endfor %}</ul></div>
{% endif %}
</div>
{% endif %}
<div class="row g-4">
<div class="col-lg-5">
<div class="analysis-card sticky-lg-top">

View File

@ -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}.",
})