import json import logging import re from decimal import Decimal from html.parser import HTMLParser from urllib.parse import urlparse 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 import requests try: from bs4 import BeautifulSoup except ImportError: # pragma: no cover - production can use requirements.txt BeautifulSoup = None from .forms import ProblemCaseForm, WebIntelligenceForm from .models import ActionPlanStep, ProblemCase, RootCause, SolutionOption logger = logging.getLogger(__name__) WEB_INTELLIGENCE_OPENAI_URL = "https://api.openai.com/v1/chat/completions" WEB_INTELLIGENCE_MODEL = "gpt-4o-mini" WEB_INTELLIGENCE_DISPLAY_CHARS = 5000 WEB_INTELLIGENCE_PROMPT_CHARS = 10000 class _ParagraphHTMLParser(HTMLParser): def __init__(self): super().__init__() self.title_parts = [] self.paragraphs = [] self._tag_stack = [] self._current = [] self._capture_title = False self._capture_paragraph = False self._skip_depth = 0 def handle_starttag(self, tag, attrs): tag = tag.lower() if tag in {"script", "style", "noscript", "svg"}: self._skip_depth += 1 return if self._skip_depth: return if tag == "title": self._capture_title = True elif tag in {"p", "li"}: self._capture_paragraph = True self._current = [] self._tag_stack.append(tag) def handle_endtag(self, tag): tag = tag.lower() if tag in {"script", "style", "noscript", "svg"} and self._skip_depth: self._skip_depth -= 1 return if tag == "title": self._capture_title = False elif self._capture_paragraph and self._tag_stack and tag == self._tag_stack[-1]: paragraph = " ".join(" ".join(self._current).split()) if len(paragraph) > 30: self.paragraphs.append(paragraph) self._current = [] self._tag_stack.pop() self._capture_paragraph = bool(self._tag_stack) def handle_data(self, data): if self._skip_depth: return clean = " ".join((data or "").split()) if not clean: return if self._capture_title: self.title_parts.append(clean) if self._capture_paragraph: self._current.append(clean) def _is_blocked_source_url(source_url): parsed = urlparse(source_url) hostname = (parsed.hostname or "").lower() if parsed.scheme not in {"http", "https"} or not hostname: return True return hostname in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} or hostname.endswith(".local") def _extract_html_content(html): if BeautifulSoup is not None: soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "noscript", "svg", "form", "nav", "footer"]): tag.decompose() title = soup.title.get_text(" ", strip=True) if soup.title else "" paragraphs = [ node.get_text(" ", strip=True) for node in soup.find_all(["p", "li"]) ] else: parser = _ParagraphHTMLParser() parser.feed(html) title = " ".join(parser.title_parts).strip() paragraphs = parser.paragraphs cleaned = [] seen = set() for paragraph in paragraphs: paragraph = " ".join((paragraph or "").split()) if len(paragraph) < 30: continue key = paragraph[:120].lower() if key in seen: continue seen.add(key) cleaned.append(paragraph) if len(cleaned) >= 50: break return title, "\n".join(cleaned) def _fetch_web_source(source_url): if _is_blocked_source_url(source_url): raise ValueError("URL sumber tidak valid atau mengarah ke alamat lokal.") response = requests.get( source_url, headers={"User-Agent": "Mozilla/5.0 (compatible; OPTEMA-AI/1.0)"}, timeout=20, ) response.raise_for_status() title, content = _extract_html_content(response.text) if not content: raise ValueError("Konten teks tidak ditemukan pada URL tersebut. Coba sumber artikel HTML lain.") parsed = urlparse(source_url) return { "title": title or parsed.netloc, "content": content, "content_preview": content[:WEB_INTELLIGENCE_DISPLAY_CHARS], "domain": parsed.netloc, "url": source_url, "paragraph_count": content.count("\n") + 1, } def _build_web_intelligence_prompt(query, content): topic = query.strip() or "Masalah dari sumber data internet" return f""" Topik: {topic} Data internet: {content[:WEB_INTELLIGENCE_PROMPT_CHARS]} Analisis dengan format berikut: 1. Problem Detection 2. Root Cause 3. Solusi 4. Risiko 5. Action Plan 6. KPI Instruksi: - Gunakan bahasa Indonesia yang jelas dan ringkas. - Dasarkan analisis pada data yang tersedia di sumber. - Jangan mengarang angka baru; jika data kurang, tulis asumsi/kebutuhan data tambahan. - Buat solusi yang nyambung langsung dengan root cause. """.strip() def _call_openai_web_intelligence(api_key, query, content): prompt = _build_web_intelligence_prompt(query, content) payload = { "model": WEB_INTELLIGENCE_MODEL, "messages": [ {"role": "system", "content": "Anda adalah OPTEMA AI, analis problem solving berbasis data internet."}, {"role": "user", "content": prompt}, ], "temperature": 0.2, } response = requests.post( WEB_INTELLIGENCE_OPENAI_URL, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, data=json.dumps(payload), timeout=45, ) if response.status_code == 401: raise ValueError("OpenAI API Key tidak valid. Periksa kembali key yang dimasukkan.") response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"].strip() def _clamp(value, minimum=1, maximum=100): return max(minimum, min(maximum, int(value))) def _keyword_in_text(text, keyword): text = (text or "").lower() keyword = (keyword or "").lower().strip() if not keyword: return False pattern = r"(? 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 NON_MONEY_QUANTITY_WORDS = ( "jiwa", "orang", "penduduk", "populasi", "korban", "pengguna", "penyalahguna", "entitas", "unit", "pelanggan", "remaja", "siswa", "mahasiswa", "pasien", "kasus", ) def _followed_by_non_money_quantity(text, end_index): tail = text[end_index:end_index + 40].lower() return re.match(r"\s*(?:" + "|".join(NON_MONEY_QUANTITY_WORDS) + r")\b", tail) is not None def _extract_money_amounts(text): patterns = [ (r"(?:rp\.?|idr)\s*([0-9][0-9.,]*)\s*(triliun|trilyun|trillion|miliar|milyar|juta|jt|ribu|rb)?", False), (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*(triliun|trilyun|trillion|miliar|milyar|juta|jt|ribu|rb)?", False), (r"([0-9][0-9.,]*)\s*(triliun|trilyun|trillion|miliar|milyar|juta|jt|ribu|rb)\b", True), ] amounts = [] for pattern, needs_quantity_guard in patterns: for match in re.finditer(pattern, text, flags=re.IGNORECASE): if needs_quantity_guard and _followed_by_non_money_quantity(text, match.end()): continue 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)) RAW_DATA_PATTERN = re.compile( r"(?:rp\.?|idr)?\s*[0-9]+(?:[,.][0-9]+)?\s*(?:triliun|trilyun|trillion|miliar|milyar|juta|jt|ribu|rb|tahun|thn|bulan|bln|semester|%|persen|percent)?", re.IGNORECASE, ) def extract_data(text): raw = " ".join((text or "").lower().split()) values = [] for match in RAW_DATA_PATTERN.finditer(raw): token = " ".join(match.group(0).split()) if token and any(char.isdigit() for char in token): values.append(token) return values 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), "raw_data": extract_data(description), } 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 _matching_playbook(description, category): for playbook in PROBLEM_PLAYBOOKS: if playbook["kategori"] == category and _has_any(description, playbook["keywords"]): return playbook return None def _render_playbook_analysis(playbook, constraint_note): problem_label = playbook["problem"] cause_profiles = [ ( factor, score, why_chain.format(problem=problem_label, constraint=constraint_note), ) for factor, score, why_chain in playbook["causes"] ] cause_titles = [factor for factor, _, _ in playbook["causes"]] options = [] for index, option in enumerate(playbook["solutions"]): target_cause = option.get("target_cause") or cause_titles[min(index, len(cause_titles) - 1)] options.append({ "title": option["title"], "impact": option["impact"], "efficiency": option["efficiency"], "speed": option["speed"], "low_risk": option["low_risk"], "success_rate": option["success_rate"], "rationale": option["rationale"].format( problem=problem_label, constraint=constraint_note, target_cause=target_cause, ), }) steps = [ ( day, title, task.format(problem=problem_label, constraint=constraint_note), ) for day, title, task in playbook["steps"] ] return cause_profiles, options, steps 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, top_solution=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 [] raw_data = case_data.get("raw_data") or [] detected_sector = category or detect_sector(description) playbook = _matching_playbook(description, detected_sector) problem_label = playbook["problem"] if playbook else f"Masalah kategori {detected_sector}" detected = [ {"label": "Sektor/Kategori", "value": detected_sector, "note": "hasil deteksi keyword dari knowledge database OPTEMA"}, {"label": "Problem utama", "value": problem_label, "note": "anchor yang dipakai ulang oleh Root Cause dan Rekomendasi"}, {"label": "Data mentah", "value": ", ".join(raw_data[:8]) if raw_data else "Belum ada angka", "note": "hasil extract_data seperti angka, persen, budget, dan durasi" if raw_data else "tambahkan angka seperti 30%, 3 tahun, atau Rp200 juta"}, {"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"}, ] if top_solution: top_score = decision_score(top_solution.impact, top_solution.efficiency, top_solution.speed, top_solution.low_risk) detected.append({ "label": "Skor keputusan terbaik", "value": f"{float(top_score):.1f}/100", "note": f"{top_solution.title}: impact {top_solution.impact}, efficiency {top_solution.efficiency}, speed {top_solution.speed}, low risk {top_solution.low_risk}", }) 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 detected_sector == "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 detected_sector == "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 detected_sector and detected_sector != "Pendidikan": if playbook: primary_cause = playbook["causes"][0][0] primary_solution = playbook["solutions"][0]["title"] recommendation = f"Benang merah analisis: Problem Detection = {problem_label}; Root Cause utama = {primary_cause}; Rekomendasi prioritas = {primary_solution}. Semua skor solusi di bawah harus dibaca sebagai cara menutup akar masalah tersebut dengan konteks {_case_constraint_note(case_data)}." else: category_item = next((item for item in ANALYSIS_DATABASE if item["kategori"] == detected_sector), None) if category_item: primary_cause = category_item["penyebab"][0] primary_solution = category_item["solusi"][0] recommendation = f"Sektor terdeteksi: {detected_sector}. Dengan konteks {_case_constraint_note(case_data)}, validasi dulu akar masalah utama: {primary_cause}. Prioritas solusi awal: {primary_solution}." 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 detected_sector == "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.", } SECTOR_DATABASE = { "pemerintah": { "kategori": "Pemerintah", "keyword": [ "bumn", "kementerian", "negara", "pemerintah", "anggaran", "publik", "dinas", "pemda", "layanan publik", "instansi", ], "root": [ "Inefisiensi operasional", "Pengawasan lemah", "Tata kelola buruk", "Beban biaya tinggi", "Strategi tidak tepat", ], "solution": [ "Restrukturisasi organisasi", "Audit biaya", "Perbaikan tata kelola", "Digitalisasi proses", "Evaluasi kinerja", ], "impact_label": "Publik/anggaran", "priority": 25, }, "bisnis": { "kategori": "Bisnis", "keyword": [ "bisnis", "usaha", "jualan", "toko", "produk", "omzet", "penjualan", "layanan", "customer", "pelanggan", ], "root": [ "Target pasar salah", "Marketing kurang efektif", "Produk tidak sesuai kebutuhan", "Biaya operasional tinggi", ], "solution": [ "Riset pelanggan", "Perbaikan produk", "Optimasi pemasaran", "Efisiensi operasional", ], "impact_label": "Besar", "priority": 10, }, "keuangan": { "kategori": "Keuangan", "keyword": [ "uang", "hutang", "utang", "modal", "gaji", "biaya", "cashflow", "kas", "tabungan", "budget", ], "root": [ "Cashflow tidak sehat", "Pengeluaran besar", "Pemasukan rendah", ], "solution": [ "Kontrol biaya", "Buat anggaran", "Tambah pendapatan", ], "impact_label": "Cashflow/budget", "priority": 10, }, "teknologi": { "kategori": "Teknologi", "keyword": [ "aplikasi", "website", "coding", "software", "error", "program", "bug", "sistem", "otomatisasi", ], "root": [ "Bug sistem", "Arsitektur kurang baik", "Proses manual", ], "solution": [ "Audit sistem", "Perbaikan kode", "Otomatisasi", ], "impact_label": "Teknis/operasional", "priority": 10, }, "logistik": { "kategori": "Logistik", "keyword": [ "kirim", "barang", "gudang", "rute", "pengiriman", "distribusi", "tracking", "kurir", ], "root": [ "Rute buruk", "Monitoring kurang", "Distribusi lambat", ], "solution": [ "Optimasi rute", "Tracking real-time", "Perbaikan SOP", ], "impact_label": "Operasional", "priority": 10, }, } for sector_data in SECTOR_DATABASE.values(): sector_data["penyebab"] = sector_data["root"] sector_data["solusi"] = sector_data["solution"] 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": "Kesehatan Publik", "keyword": [ "narkoba", "penyalahguna", "penyalahgunaan", "zat terlarang", "adiksi", "overdosis", "rehabilitasi", "bnn", "remaja", "kesehatan mental", "populasi usia produktif", "korban narkoba", ], "penyebab": [ "Pencegahan belum tepat sasaran ke kelompok rentan", "Deteksi dini dan akses rehabilitasi belum kuat", "Koordinasi data lintas lembaga belum terpadu", ], "solusi": [ "Program pencegahan berbasis sekolah dan komunitas berisiko", "Perluas screening, rehabilitasi, dan aftercare", "Dashboard lintas lembaga untuk intervensi wilayah prioritas", ], "impact_label": "Kesehatan/sosial", "priority": 38, }, SECTOR_DATABASE["pemerintah"], SECTOR_DATABASE["bisnis"], SECTOR_DATABASE["keuangan"], { "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, }, SECTOR_DATABASE["logistik"], SECTOR_DATABASE["teknologi"], { "kategori": "Umum", "keyword": [], "penyebab": [ "Tujuan dan batasan belum cukup spesifik", "Data pendukung belum lengkap", "Opsi solusi belum diprioritaskan", ], "solusi": [ "Perjelas tujuan dan indikator sukses", "Kumpulkan data utama sebelum mengambil keputusan", "Bandingkan opsi dengan skor dampak, biaya, waktu, dan risiko", ], "impact_label": "Perlu validasi", "priority": 0, }, ] PROBLEM_PLAYBOOKS = [ { "kategori": "Pemerintah", "keywords": [ "bumn", "kerugian", "rugi", "inefisiensi", "salah kelola", "konflik kepentingan", "pengawasan", "komisaris", "penugasan pemerintah", "skala keekonomian", "subsidi", "proyek strategis", "beban proyek", ], "problem": "Kerugian dan inefisiensi tata kelola BUMN/instansi", "impact_label": "Publik/anggaran", "causes": [ ( "Inefisiensi operasional dan portofolio proyek", 96, "Problem Detection membaca {problem} dengan konteks {constraint}. Ini mengarah ke biaya, proyek, dan proses yang belum dikontrol sebagai portofolio bernilai ekonomi.", ), ( "Pengawasan dan akuntabilitas tata kelola lemah", 92, "Input menyebut sinyal salah kelola, konflik kepentingan, atau lemahnya pengawasan; akar ini menjelaskan mengapa kerugian dapat berulang walau masalah finansial sudah terlihat.", ), ( "Beban penugasan tidak selaras skala keekonomian", 86, "Jika ada mandat publik, subsidi, atau penugasan pemerintah, keputusan perlu memisahkan biaya layanan publik dari kesehatan finansial entitas.", ), ( "Early warning kinerja belum memicu intervensi cepat", 78, "Angka pada Problem Detection perlu dijadikan trigger tindakan; tanpa dashboard dan ambang batas stop-loss, masalah terlambat ditangani.", ), ], "solutions": [ { "title": "Audit biaya dan portofolio proyek bermasalah", "impact": 94, "efficiency": 84, "speed": 76, "low_risk": 72, "success_rate": 86, "target_cause": "Inefisiensi operasional dan portofolio proyek", "rationale": "Menjawab akar masalah {target_cause} dengan memetakan unit economics, membekukan proyek rugi, dan menetapkan stop-loss berdasarkan {constraint}.", }, { "title": "Perkuat governance: komisaris, audit, dan konflik kepentingan", "impact": 90, "efficiency": 78, "speed": 70, "low_risk": 84, "success_rate": 82, "target_cause": "Pengawasan dan akuntabilitas tata kelola lemah", "rationale": "Menargetkan {target_cause}; solusi ini membuat keputusan manajemen, pengawasan komisaris, dan audit memiliki owner, bukti, serta konsekuensi yang jelas.", }, { "title": "Pisahkan mandat publik dari target komersial", "impact": 86, "efficiency": 82, "speed": 68, "low_risk": 80, "success_rate": 79, "target_cause": "Beban penugasan tidak selaras skala keekonomian", "rationale": "Menjawab {target_cause} dengan membuat kontrak kinerja, kompensasi PSO/subsidi, dan batas kerugian yang transparan untuk setiap penugasan.", }, { "title": "Dashboard early warning dan review kinerja bulanan", "impact": 80, "efficiency": 88, "speed": 84, "low_risk": 86, "success_rate": 84, "target_cause": "Early warning kinerja belum memicu intervensi cepat", "rationale": "Mengubah angka pada {problem} menjadi alarm operasional: rugi, cash burn, deviasi proyek, dan efisiensi dipantau sebelum membesar.", }, ], "steps": [ (1, "Kunci problem dan angka baseline", "Tetapkan baseline {problem}: daftar entitas/proyek, nilai rugi, inefisiensi, subsidi, dan periode pengukuran dari {constraint}."), (2, "Audit portofolio rugi", "Pisahkan rugi karena operasi, proyek, tata kelola, dan mandat publik; beri status stop, turnaround, merge, atau lanjut bersyarat."), (3, "Tetapkan owner governance", "Tentukan owner keputusan, komisaris pengawas, audit internal, dan batas konflik kepentingan untuk setiap tindakan korektif."), (4, "Jalankan quick win efisiensi", "Potong biaya/proyek yang paling jelas bocor sambil menjaga layanan publik wajib tetap berjalan."), (5, "Decision gate bulanan", "Bandingkan hasil dengan baseline; scale tindakan yang menurunkan kerugian dan eskalasi entitas yang tidak membaik."), ], }, { "kategori": "Kesehatan Publik", "keywords": [ "narkoba", "penyalahguna", "penyalahgunaan", "zat terlarang", "adiksi", "overdosis", "rehabilitasi", "remaja", "kesehatan mental", "populasi usia produktif", "korban narkoba", ], "problem": "Kenaikan penyalahgunaan narkoba pada kelompok rentan", "impact_label": "Kesehatan/sosial", "causes": [ ( "Pencegahan belum tepat sasaran ke remaja dan usia produktif", 95, "Problem Detection membaca {problem} dengan konteks {constraint}. Sinyal remaja/usia produktif berarti pencegahan harus diarahkan ke segmen rentan, bukan kampanye umum saja.", ), ( "Deteksi dini dan akses rehabilitasi belum kuat", 90, "Dampak kesehatan fisik dan mental tidak selesai dengan penindakan; perlu jalur screening, konseling, rehabilitasi, dan aftercare yang mudah diakses.", ), ( "Koordinasi data lintas lembaga belum terpadu", 84, "Masalah populasi dan tren tahunan membutuhkan data gabungan sekolah, keluarga, layanan kesehatan, sosial, dan penegakan hukum agar intervensi tepat wilayah.", ), ( "Dukungan keluarga, sekolah, dan reintegrasi sosial masih lemah", 78, "Risiko kambuh naik jika lingkungan setelah intervensi tidak diperbaiki; akar ini menghubungkan aspek kesehatan, sosial, dan pendidikan.", ), ], "solutions": [ { "title": "Program pencegahan tertarget di sekolah dan komunitas rentan", "impact": 92, "efficiency": 84, "speed": 78, "low_risk": 86, "success_rate": 87, "target_cause": "Pencegahan belum tepat sasaran ke remaja dan usia produktif", "rationale": "Menjawab {target_cause}; fokus pada kelompok yang disebut di Problem Detection agar edukasi, peer mentor, dan deteksi awal tidak menyebar terlalu umum.", }, { "title": "Perluas screening, rehabilitasi, dan aftercare", "impact": 90, "efficiency": 76, "speed": 72, "low_risk": 78, "success_rate": 82, "target_cause": "Deteksi dini dan akses rehabilitasi belum kuat", "rationale": "Menargetkan {target_cause}; solusi ini mengubah kasus terdeteksi menjadi jalur bantuan yang berkelanjutan, bukan hanya kampanye atau razia.", }, { "title": "Dashboard lintas lembaga untuk wilayah prioritas", "impact": 84, "efficiency": 86, "speed": 82, "low_risk": 82, "success_rate": 80, "target_cause": "Koordinasi data lintas lembaga belum terpadu", "rationale": "Menghubungkan data {problem} dengan keputusan lapangan: wilayah, sekolah, fasilitas kesehatan, dan komunitas mana yang harus diprioritaskan.", }, ], "steps": [ (1, "Segmentasi kelompok rentan", "Pecah {problem} berdasarkan usia, wilayah, sekolah/komunitas, dan tren dari {constraint}."), (2, "Bangun jalur deteksi dan rujukan", "Tetapkan alur screening aman, konseling awal, rujukan rehabilitasi, serta perlindungan privasi."), (3, "Pilot pencegahan tertarget", "Jalankan pilot di 3-5 wilayah/sekolah berisiko dengan materi, peer mentor, dan dukungan keluarga."), (4, "Satukan dashboard", "Gabungkan indikator tren, jangkauan edukasi, rujukan rehabilitasi, relapse, dan kasus wilayah prioritas."), (5, "Evaluasi outcome", "Bandingkan perubahan awareness, jumlah rujukan, retensi rehabilitasi, dan penurunan kasus di wilayah pilot."), ], }, { "kategori": "Bisnis", "keywords": ["omzet", "penjualan", "jualan", "pelanggan", "customer", "produk", "marketing", "promosi"], "problem": "Penjualan atau pertumbuhan bisnis tidak mencapai target", "impact_label": "Besar", "causes": [ ("Segmen pelanggan belum tepat", 92, "Problem Detection membaca {problem} dengan konteks {constraint}. Jika pelanggan tidak tepat, promosi dan produk akan terlihat tidak efektif."), ("Pesan marketing dan channel akuisisi kurang tajam", 86, "Akar ini menghubungkan problem penjualan dengan cara pasar menemukan dan memahami penawaran."), ("Value produk belum sesuai kebutuhan utama pasar", 80, "Jika feedback pelanggan tidak cocok dengan value produk, solusi harus menguji ulang offer sebelum scale."), ], "solutions": [ {"title": "Riset pelanggan dan repositioning offer", "impact": 88, "efficiency": 84, "speed": 78, "low_risk": 82, "success_rate": 84, "target_cause": "Segmen pelanggan belum tepat", "rationale": "Menjawab {target_cause}; validasi ulang siapa pembeli paling siap dan ubah offer berdasarkan bukti dari {constraint}."}, {"title": "Eksperimen channel marketing terukur", "impact": 84, "efficiency": 78, "speed": 86, "low_risk": 76, "success_rate": 80, "target_cause": "Pesan marketing dan channel akuisisi kurang tajam", "rationale": "Menargetkan {target_cause} dengan eksperimen kecil pada pesan, channel, dan biaya per lead sebelum scale."}, {"title": "Perbaiki paket produk berdasarkan feedback", "impact": 80, "efficiency": 82, "speed": 72, "low_risk": 84, "success_rate": 78, "target_cause": "Value produk belum sesuai kebutuhan utama pasar", "rationale": "Menghubungkan problem penjualan ke produk; paket, harga, dan bukti manfaat diperbaiki agar sesuai kebutuhan pelanggan."}, ], "steps": [ (1, "Tentukan metrik target", "Tetapkan baseline {problem}: omzet, leads, conversion, repeat order, dan batas biaya dari {constraint}."), (2, "Interview pelanggan", "Validasi 10-20 pelanggan/prospek untuk menemukan segmen dan pain point paling kuat."), (3, "Uji pesan dan offer", "Jalankan eksperimen kecil pada 2-3 channel dengan budget dan target conversion jelas."), (4, "Perbaiki paket", "Update bundling, harga, garansi, atau proof sesuai feedback paling sering."), (5, "Scale yang terbukti", "Naikkan budget hanya pada channel/offer yang melewati target biaya dan conversion."), ], }, ] KNOWLEDGE = { item["kategori"]: { "keyword": item.get("keyword", []), "root": item.get("penyebab", []), "solution": item.get("solusi", []), } for item in ANALYSIS_DATABASE if item["kategori"] != "Umum" } def detect_sector(text): return _detect_problem_category(text or "")["kategori"] 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(text, keyword)) 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 item["kategori"] == "Pemerintah" and _has_any(text, ["bumn", "kementerian", "pemerintah", "anggaran negara", "layanan publik"]): score += 25 if score > best_score: best_item = item best_score = score return best_item or next(item for item in ANALYSIS_DATABASE if item["kategori"] == "Umum") 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 in {"Pendidikan", "Pemerintah", "Kesehatan Publik", "Umum"}: 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) playbook = _matching_playbook(problem.description, kategori) if playbook: cause_profiles, options, steps = _render_playbook_analysis(playbook, constraint_note) _write_analysis_records(problem, playbook.get("impact_label", category.get("impact_label", "Sedang")), cause_profiles, options, steps) return 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 pemerintah/publik, 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 web_intelligence(request): form = WebIntelligenceForm(request.POST or None) source_data = None ai_result = None analysis_error = None if request.method == "POST": if form.is_valid(): query = form.cleaned_data.get("query", "") source_url = form.cleaned_data["url"] api_key = (form.cleaned_data.get("api_key") or "").strip() try: source_data = _fetch_web_source(source_url) if api_key: try: ai_result = _call_openai_web_intelligence(api_key, query, source_data["content"]) except ValueError as exc: analysis_error = str(exc) except requests.Timeout: analysis_error = "Permintaan ke OpenAI timeout. Coba ulang beberapa saat lagi." except requests.RequestException as exc: logger.warning("OpenAI web intelligence request failed for %s: %s", source_data["domain"], exc) analysis_error = "Analisis OpenAI gagal diproses. Periksa API key, quota, atau koneksi." except Exception: logger.exception("Unexpected OpenAI web intelligence error") analysis_error = "Analisis OpenAI gagal diproses. Coba ulang atau gunakan sumber yang lebih ringkas." else: analysis_error = "Data internet berhasil diambil. Masukkan OpenAI API Key untuk menjalankan analisis AI." except ValueError as exc: messages.error(request, str(exc)) except requests.Timeout: messages.error(request, "URL sumber terlalu lama merespons. Coba URL lain atau ulangi nanti.") except requests.RequestException as exc: logger.warning("Web intelligence fetch failed for %s: %s", source_url, exc) messages.error(request, "URL sumber tidak bisa diakses. Pastikan halaman publik dan dapat dibuka dari internet.") except Exception: logger.exception("Unexpected web intelligence fetch error") messages.error(request, "Terjadi kesalahan saat membaca sumber data internet.") else: messages.error(request, "Mohon periksa input. URL sumber data wajib valid.") return render(request, "core/web_intelligence.html", { "form": form, "source_data": source_data, "ai_result": ai_result, "analysis_error": analysis_error, "page_title": "Web Intelligence — OPTEMA AI", "meta_description": "Analisis masalah berbasis data internet dengan OPTEMA AI: scrape sumber URL, deteksi problem, root cause, solusi, risiko, action plan, dan KPI.", }) 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"], top_solution=top_solution) return render(request, "core/case_detail.html", { "problem": problem, "top_solution": top_solution, "case_insights": case_insights, "detected_category": category["kategori"], "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}.", })