1333 lines
59 KiB
Python
1333 lines
59 KiB
Python
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"(?<!\w)" + r"\s+".join(re.escape(part) for part in keyword.split()) + r"(?!\w)"
|
||
return re.search(pattern, text) is not None
|
||
|
||
|
||
def _has_any(text, keywords):
|
||
return any(_keyword_in_text(text, keyword) for keyword in keywords)
|
||
|
||
|
||
def _decision_score(impact, efficiency, speed, low_risk):
|
||
return Decimal(str(round((impact * 0.40) + (efficiency * 0.30) + (speed * 0.20) + (low_risk * 0.10), 2)))
|
||
|
||
|
||
def decision_score(impact, efficiency, speed, low_risk):
|
||
return _decision_score(float(impact), float(efficiency), float(speed), float(low_risk))
|
||
|
||
|
||
def _case_title_from_description(description):
|
||
first_line = " ".join(description.strip().split())
|
||
if len(first_line) <= 78:
|
||
return first_line
|
||
return f"{first_line[:75].rstrip()}..."
|
||
|
||
|
||
MONEY_UNITS = {
|
||
"triliun": 1_000_000_000_000,
|
||
"trilyun": 1_000_000_000_000,
|
||
"trillion": 1_000_000_000_000,
|
||
"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
|
||
|
||
|
||
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}.",
|
||
})
|