ai insight rework #3, add root cause to daily ai insight
This commit is contained in:
1 parent
c963bb778c
commit
d5b0c12c40
8 files changed
+370
-32
No files matched your search
@@ -492,7 +492,7 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
|
|||||||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||||||
message = (
|
message = (
|
||||||
f"Mortalitas kumulatif {rate:.2f}% adalah SANGAT TINGGI — melebihi batas kritis "
|
f"Mortalitas kumulatif {rate:.2f}% adalah SANGAT TINGGI — melebihi batas kritis "
|
||||||
f"CP 707 (>7%). Lakukan nekropsi darurat dan perketat biosekuriti."
|
f"CP 707 (>7%)."
|
||||||
)
|
)
|
||||||
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ _INFERENCE_LOCK = threading.Lock()
|
|||||||
|
|
||||||
_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$")
|
_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$")
|
||||||
|
|
||||||
|
DAILY_JSON_MARKER = "<!--daily_json-->"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_embedded_daily(text: str) -> dict[str, Any] | None:
|
||||||
|
"""Extract structured daily payload (akar_masalah, insight) from stored insight_text."""
|
||||||
|
if not text or DAILY_JSON_MARKER not in text:
|
||||||
|
return None
|
||||||
|
raw = text.split(DAILY_JSON_MARKER, 1)[1].strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
# Stored in AIInsight.alert — condition of graded data, not narrative text.
|
# Stored in AIInsight.alert — condition of graded data, not narrative text.
|
||||||
ALERT_HEALTHY = "healthy"
|
ALERT_HEALTHY = "healthy"
|
||||||
ALERT_WARNING = "warning"
|
ALERT_WARNING = "warning"
|
||||||
@@ -122,7 +137,7 @@ ATURAN STANDAR CP 707 — DILARANG MENGARANG (WAJIB DIPATUHI):
|
|||||||
- Bobot standar hanya hari 1–37. Di luar itu "tidak tercantum di buku".
|
- Bobot standar hanya hari 1–37. Di luar itu "tidak tercantum di buku".
|
||||||
|
|
||||||
FORMAT OUTPUT (JSON SAJA):
|
FORMAT OUTPUT (JSON SAJA):
|
||||||
{"kesimpulan":"...","insight":"..."}
|
{"kesimpulan":"...","akar_masalah":"...","insight":"..."}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
END_CYCLE_OUTPUT_RULES = """
|
END_CYCLE_OUTPUT_RULES = """
|
||||||
@@ -241,6 +256,7 @@ def build_insight_user_prompt(
|
|||||||
lines.append(f" - {b}")
|
lines.append(f" - {b}")
|
||||||
|
|
||||||
# 5. Length contract + field role contract
|
# 5. Length contract + field role contract
|
||||||
|
actual_alert = alert_from_graded(graded) if graded else ALERT_UNKNOWN
|
||||||
if is_end_cycle:
|
if is_end_cycle:
|
||||||
lines.append(
|
lines.append(
|
||||||
"\nPANJANG WAJIB: kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
|
"\nPANJANG WAJIB: kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
|
||||||
@@ -252,10 +268,29 @@ def build_insight_user_prompt(
|
|||||||
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
|
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if actual_alert in (ALERT_WARNING, ALERT_CRITICAL):
|
||||||
lines.append(
|
lines.append(
|
||||||
"\nPANJANG WAJIB: insight = narasi 3-6 kalimat berurutan: "
|
f"\nPANJANG WAJIB (STATUS AKTUAL ADALAH {actual_alert.upper()} — ADA ANOMALI/MASALAH): "
|
||||||
"(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, "
|
"kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi, sebutkan angka aktual vs standar CP 707). "
|
||||||
"(3) tindakan konkret. kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
|
"akar_masalah = 1-3 kalimat ANALISIS AKAR PENYEBAB & FAKTOR PEMICU DEVIASI (soroti anomali mortalitas, fluktuasi konsumsi pakan, atau deviasi mikroklimat suhu/kelembapan). DILARANG KERAS menulis 'kondisi optimal' atau 'tidak terdeteksi anomali' karena status data sedang bermasalah. "
|
||||||
|
"insight = narasi 3-5 kalimat REKOMENDASI TINDAKAN KONKRET & PANDUAN SOP CP 707. "
|
||||||
|
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
|
||||||
|
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
|
||||||
|
)
|
||||||
|
elif actual_alert == ALERT_HEALTHY:
|
||||||
|
lines.append(
|
||||||
|
f"\nPANJANG WAJIB (STATUS AKTUAL ADALAH {actual_alert.upper()} — KONDISI OPTIMAL/NORMAL): "
|
||||||
|
"kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (konfirmasi performa sesuai standar CP 707, tanpa sebab/aksi). "
|
||||||
|
"akar_masalah = 1-2 kalimat konfirmasi bahwa parameter lingkungan dan pakan terkendali dengan baik tanpa indikasi deviasi. "
|
||||||
|
"insight = narasi 3-5 kalimat panduan mempertahankan SOP CP 707 dan monitoring rutin. "
|
||||||
|
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
|
||||||
|
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
"\nPANJANG WAJIB: kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
|
||||||
|
"akar_masalah = 1-3 kalimat ANALISIS AKAR PENYEBAB / FAKTOR PEMICU berdasarkan data yang tersedia. "
|
||||||
|
"insight = narasi 3-5 kalimat REKOMENDASI TINDAKAN KONKRET & PANDUAN SOP CP 707. "
|
||||||
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
|
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
|
||||||
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
|
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
|
||||||
)
|
)
|
||||||
@@ -272,10 +307,28 @@ def build_insight_user_prompt(
|
|||||||
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if actual_alert in (ALERT_WARNING, ALERT_CRITICAL):
|
||||||
lines.append(
|
lines.append(
|
||||||
'\nBALAS HANYA JSON persis: '
|
'\nBALAS HANYA JSON persis: '
|
||||||
'{"kesimpulan":"<ringkasan penilaian 1-3 kalimat>",'
|
'{"kesimpulan":"<ringkasan penilaian data aktual vs standar 1-3 kalimat>",'
|
||||||
'"insight":"<narasi panjang 3-6 kalimat: aktual vs standar, penyebab, tindakan>"}'
|
'"akar_masalah":"<analisis faktor pemicu / akar masalah deviasi dari data faktual>",'
|
||||||
|
'"insight":"<narasi rekomendasi 3-5 kalimat: tindakan konkret & panduan SOP CP 707>"}'
|
||||||
|
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
||||||
|
)
|
||||||
|
elif actual_alert == ALERT_HEALTHY:
|
||||||
|
lines.append(
|
||||||
|
'\nBALAS HANYA JSON persis: '
|
||||||
|
'{"kesimpulan":"<ringkasan penilaian data sesuai standar CP 707 1-3 kalimat>",'
|
||||||
|
'"akar_masalah":"<konfirmasi parameter operasional terkendali baik tanpa anomali>",'
|
||||||
|
'"insight":"<narasi rekomendasi 3-5 kalimat: panduan SOP CP 707 & monitoring berkala>"}'
|
||||||
|
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
'\nBALAS HANYA JSON persis: '
|
||||||
|
'{"kesimpulan":"<ringkasan penilaian data 1-3 kalimat>",'
|
||||||
|
'"akar_masalah":"<analisis faktor pemicu berdasarkan data yang ada>",'
|
||||||
|
'"insight":"<narasi rekomendasi 3-5 kalimat: tindakan konkret & panduan SOP CP 707>"}'
|
||||||
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -671,16 +724,27 @@ def parse_llm_json(text: str) -> dict[str, str] | None:
|
|||||||
or parsed.get("summary")
|
or parsed.get("summary")
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
|
akar_masalah = (
|
||||||
|
parsed.get("akar_masalah")
|
||||||
|
or parsed.get("root_cause")
|
||||||
|
or parsed.get("akar_penyebab")
|
||||||
|
or parsed.get("penyebab")
|
||||||
|
or parsed.get("faktor_penyebab")
|
||||||
|
or parsed.get("faktor_pemicu")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
insight = (
|
insight = (
|
||||||
parsed.get("insight")
|
parsed.get("insight")
|
||||||
or parsed.get("rekomendasi")
|
or parsed.get("rekomendasi")
|
||||||
or parsed.get("insights")
|
or parsed.get("insights")
|
||||||
|
or parsed.get("saran")
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
if not kesimpulan and not insight:
|
if not kesimpulan and not insight and not akar_masalah:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"kesimpulan": str(kesimpulan) or "Model tidak mengembalikan kesimpulan eksplisit.",
|
"kesimpulan": str(kesimpulan) or "Model tidak mengembalikan kesimpulan eksplisit.",
|
||||||
|
"akar_masalah": str(akar_masalah).strip() if akar_masalah else "",
|
||||||
"insight": str(insight) or "Model tidak mengembalikan rekomendasi eksplisit.",
|
"insight": str(insight) or "Model tidak mengembalikan rekomendasi eksplisit.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,11 +801,17 @@ def _text_soft_labels_mortality(text: str) -> bool:
|
|||||||
return bool(_SOFT_MORTALITY_WORD.search(text) and _MENTIONS_MORTALITY.search(text))
|
return bool(_SOFT_MORTALITY_WORD.search(text) and _MENTIONS_MORTALITY.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
_OPTIMAL_CONTRADICTION = re.compile(
|
||||||
|
r"\b(kondisi\s+operasional\s+optimal|tidak\s+terdeteksi\s+(?:indikasi\s+)?anomali|semua\s+parameter\s+normal|kondisi\s+optimal|operasional\s+optimal|tidak\s+ada\s+anomali)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def enforce_mortality_wording(
|
def enforce_mortality_wording(
|
||||||
parsed: dict[str, Any],
|
parsed: dict[str, Any],
|
||||||
graded: dict[str, Any] | None,
|
graded: dict[str, Any] | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rewrite LLM text that calls high mortality 'rendah/normal' (small-model slip)."""
|
"""Rewrite LLM text that calls high mortality 'rendah/normal' or claims optimal when critical."""
|
||||||
mort = ((graded or {}).get("analyses") or {}).get("mortality") or {}
|
mort = ((graded or {}).get("analyses") or {}).get("mortality") or {}
|
||||||
status = str(mort.get("status") or "").lower()
|
status = str(mort.get("status") or "").lower()
|
||||||
if status not in ("warning", "critical"):
|
if status not in ("warning", "critical"):
|
||||||
@@ -754,23 +824,33 @@ def enforce_mortality_wording(
|
|||||||
|
|
||||||
out = dict(parsed)
|
out = dict(parsed)
|
||||||
kesimpulan = str(out.get("kesimpulan") or "")
|
kesimpulan = str(out.get("kesimpulan") or "")
|
||||||
|
akar_masalah = str(out.get("akar_masalah") or "")
|
||||||
insight = str(out.get("insight") or "")
|
insight = str(out.get("insight") or "")
|
||||||
|
|
||||||
if not (_text_soft_labels_mortality(kesimpulan) or _text_soft_labels_mortality(insight)):
|
has_soft_kesimpulan = _text_soft_labels_mortality(kesimpulan)
|
||||||
|
has_soft_akar = _text_soft_labels_mortality(akar_masalah) or bool(_OPTIMAL_CONTRADICTION.search(akar_masalah))
|
||||||
|
has_soft_insight = _text_soft_labels_mortality(insight)
|
||||||
|
|
||||||
|
if not (has_soft_kesimpulan or has_soft_akar or has_soft_insight):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Prefer the deterministic graded message; keep non-mortality insight if clean.
|
pct_part = f" ({pct})" if pct else ""
|
||||||
|
if has_soft_kesimpulan:
|
||||||
if message:
|
if message:
|
||||||
out["kesimpulan"] = message
|
out["kesimpulan"] = message
|
||||||
else:
|
else:
|
||||||
pct_part = f" ({pct})" if pct else ""
|
|
||||||
out["kesimpulan"] = (
|
out["kesimpulan"] = (
|
||||||
f"Mortalitas kumulatif{pct_part} adalah {label} menurut ambang CP 707."
|
f"Mortalitas kumulatif{pct_part} adalah {label} menurut ambang CP 707."
|
||||||
)
|
)
|
||||||
|
|
||||||
if _text_soft_labels_mortality(insight):
|
if has_soft_akar:
|
||||||
|
out["akar_masalah"] = (
|
||||||
|
f"Lonjakan mortalitas kumulatif{pct_part} yang tergolong {label} menjadi pemicu deviasi performa, mengindikasikan adanya stres lingkungan, tantangan biosekuriti, atau paparan patogen."
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_soft_insight:
|
||||||
out["insight"] = (
|
out["insight"] = (
|
||||||
"Segera tinjau penyebab kematian (nekropsi bila kritis), perketat biosekuriti, "
|
"Segera lakukan nekropsi pada ayam mati untuk identifikasi patogen, perketat biosekuriti kandang, "
|
||||||
"dan pantau mortalitas harian hingga tren menurun."
|
"dan pantau mortalitas harian hingga tren menurun."
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -792,6 +872,7 @@ def enforce_bw_wording(
|
|||||||
|
|
||||||
out = dict(parsed)
|
out = dict(parsed)
|
||||||
kesimpulan = str(out.get("kesimpulan") or "")
|
kesimpulan = str(out.get("kesimpulan") or "")
|
||||||
|
akar_masalah = str(out.get("akar_masalah") or "")
|
||||||
|
|
||||||
# If graded status is OK, but summary claims it is below standard / critical:
|
# If graded status is OK, but summary claims it is below standard / critical:
|
||||||
if status == "ok" and any(
|
if status == "ok" and any(
|
||||||
@@ -803,6 +884,14 @@ def enforce_bw_wording(
|
|||||||
elif actual is not None and std is not None:
|
elif actual is not None and std is not None:
|
||||||
out["kesimpulan"] = f"Bobot badan ayam ({actual:g}g) sesuai dengan target standar ({std:g}g)."
|
out["kesimpulan"] = f"Bobot badan ayam ({actual:g}g) sesuai dengan target standar ({std:g}g)."
|
||||||
|
|
||||||
|
# If graded status is warning/critical for BW, but akar_masalah claims optimal:
|
||||||
|
if status in ("warning", "critical") and _OPTIMAL_CONTRADICTION.search(akar_masalah):
|
||||||
|
diff_str = f" ({actual:g}g vs {std:g}g)" if actual is not None and std is not None else ""
|
||||||
|
out["akar_masalah"] = (
|
||||||
|
f"Pertumbuhan bobot badan tertinggal dari target standar CP 707{diff_str}, "
|
||||||
|
"dipicu oleh fluktuasi asupan pakan harian atau ketidaksesuaian mikroklimat kandang."
|
||||||
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -824,7 +913,7 @@ def _placeholder_text(value: str) -> bool:
|
|||||||
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
|
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Deterministic cleanup of narrative fields after the LLM."""
|
"""Deterministic cleanup of narrative fields after the LLM."""
|
||||||
out = dict(parsed)
|
out = dict(parsed)
|
||||||
for key in ("kesimpulan", "insight", "summary", "insight_text"):
|
for key in ("kesimpulan", "akar_masalah", "insight", "summary", "insight_text"):
|
||||||
if key in out and out[key] is not None:
|
if key in out and out[key] is not None:
|
||||||
out[key] = collapse_duplicate_kandang(str(out[key]))
|
out[key] = collapse_duplicate_kandang(str(out[key]))
|
||||||
return out
|
return out
|
||||||
@@ -912,15 +1001,36 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
|
|||||||
def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||||
norm_citations = decode_citations(row.citations)
|
norm_citations = decode_citations(row.citations)
|
||||||
summary = collapse_duplicate_kandang(row.summary or "")
|
summary = collapse_duplicate_kandang(row.summary or "")
|
||||||
insight_body = collapse_duplicate_kandang(row.insight_text or "")
|
insight_raw = collapse_duplicate_kandang(row.insight_text or "")
|
||||||
insight_text = "\n\n".join(
|
|
||||||
part for part in [summary, insight_body] if part
|
structured = root_cause.parse_embedded_end_cycle(insight_raw)
|
||||||
) or insight_body or ""
|
|
||||||
structured = root_cause.parse_embedded_end_cycle(insight_body)
|
|
||||||
if structured and summary and not structured.get("kesimpulan"):
|
if structured and summary and not structured.get("kesimpulan"):
|
||||||
structured = {**structured, "kesimpulan": summary}
|
structured = {**structured, "kesimpulan": summary}
|
||||||
elif structured:
|
elif structured:
|
||||||
structured = sanitize_insight_narrative(structured)
|
structured = sanitize_insight_narrative(structured)
|
||||||
|
|
||||||
|
embedded_daily = parse_embedded_daily(insight_raw)
|
||||||
|
akar_masalah = ""
|
||||||
|
if embedded_daily:
|
||||||
|
akar_masalah = str(embedded_daily.get("akar_masalah") or "").strip()
|
||||||
|
insight_body = str(
|
||||||
|
embedded_daily.get("insight") or insight_raw.split(DAILY_JSON_MARKER, 1)[0]
|
||||||
|
).strip()
|
||||||
|
elif structured:
|
||||||
|
insight_body = insight_raw.split(root_cause.END_CYCLE_JSON_MARKER, 1)[0].strip()
|
||||||
|
else:
|
||||||
|
insight_body = insight_raw
|
||||||
|
|
||||||
|
insight_body = collapse_duplicate_kandang(insight_body)
|
||||||
|
akar_masalah = collapse_duplicate_kandang(akar_masalah)
|
||||||
|
|
||||||
|
parts = [summary]
|
||||||
|
if akar_masalah:
|
||||||
|
parts.append(akar_masalah)
|
||||||
|
if insight_body:
|
||||||
|
parts.append(insight_body)
|
||||||
|
insight_text = "\n\n".join(part for part in parts if part) or insight_body or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"id": row.pk,
|
"id": row.pk,
|
||||||
@@ -930,6 +1040,7 @@ def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
|||||||
"report_type": row.report_type,
|
"report_type": row.report_type,
|
||||||
"report_period": row.report_period,
|
"report_period": row.report_period,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
"akar_masalah": akar_masalah,
|
||||||
"insight": insight_body,
|
"insight": insight_body,
|
||||||
"insight_text": insight_text,
|
"insight_text": insight_text,
|
||||||
"structured_end_cycle": structured,
|
"structured_end_cycle": structured,
|
||||||
@@ -1176,10 +1287,17 @@ def generate_insight(
|
|||||||
parsed = enforce_mortality_wording(parsed, graded)
|
parsed = enforce_mortality_wording(parsed, graded)
|
||||||
parsed = enforce_bw_wording(parsed, graded)
|
parsed = enforce_bw_wording(parsed, graded)
|
||||||
parsed = sanitize_insight_narrative(parsed)
|
parsed = sanitize_insight_narrative(parsed)
|
||||||
insight_text = parsed["insight"]
|
|
||||||
summary = parsed["kesimpulan"]
|
summary = parsed["kesimpulan"]
|
||||||
if _placeholder_text(summary) or _placeholder_text(insight_text):
|
akar_masalah = parsed.get("akar_masalah", "").strip()
|
||||||
degenerate_reason = f"kesimpulan={summary!r} insight={insight_text!r}"
|
clean_insight = parsed["insight"]
|
||||||
|
if akar_masalah:
|
||||||
|
daily_payload = {"akar_masalah": akar_masalah, "insight": clean_insight}
|
||||||
|
insight_text = f"{clean_insight}\n\n{DAILY_JSON_MARKER}\n{json.dumps(daily_payload, ensure_ascii=False)}"
|
||||||
|
else:
|
||||||
|
insight_text = clean_insight
|
||||||
|
check_insight = clean_insight if not is_end_cycle else insight_text
|
||||||
|
if _placeholder_text(summary) or _placeholder_text(check_insight):
|
||||||
|
degenerate_reason = f"kesimpulan={summary!r} insight={check_insight!r}"
|
||||||
user_prompt += (
|
user_prompt += (
|
||||||
"\nCATATAN: keluaran sebelumnya hanya placeholder. "
|
"\nCATATAN: keluaran sebelumnya hanya placeholder. "
|
||||||
"Tulis kalimat lengkap sendiri untuk kesimpulan dan insight."
|
"Tulis kalimat lengkap sendiri untuk kesimpulan dan insight."
|
||||||
|
|||||||
@@ -672,6 +672,88 @@ class RootCauseTests(TestCase):
|
|||||||
self.assertEqual(sanitized["perbaikan_siklus_berikutnya"][0]["id"], "pakan_akses")
|
self.assertEqual(sanitized["perbaikan_siklus_berikutnya"][0]["id"], "pakan_akses")
|
||||||
|
|
||||||
|
|
||||||
|
class DailyAkarMasalahTests(TestCase):
|
||||||
|
"""Test daily AI insight 3-section format (kesimpulan, akar_masalah, insight)."""
|
||||||
|
|
||||||
|
def test_parse_llm_json_with_akar_masalah(self):
|
||||||
|
payload = json.dumps({
|
||||||
|
"kesimpulan": "FCR 1.40 pada hari ke-28 berada di atas standar CP 707 (1.315).",
|
||||||
|
"akar_masalah": "Fluktuasi suhu kandang malam hari dan akses pakan tidak merata.",
|
||||||
|
"insight": "Lakukan kalibrasi pemanas dan ratakan distribusi pakan di sepanjang brooding area."
|
||||||
|
})
|
||||||
|
parsed = insight_service.parse_llm_json(payload)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["kesimpulan"], "FCR 1.40 pada hari ke-28 berada di atas standar CP 707 (1.315).")
|
||||||
|
self.assertEqual(parsed["akar_masalah"], "Fluktuasi suhu kandang malam hari dan akses pakan tidak merata.")
|
||||||
|
self.assertIn("kalibrasi pemanas", parsed["insight"])
|
||||||
|
|
||||||
|
def test_parse_llm_json_variants(self):
|
||||||
|
payload = json.dumps({
|
||||||
|
"summary": "Kondisi pakan normal.",
|
||||||
|
"root_cause": "Kondisi operasional optimal, tidak terdeteksi indikasi deviasi.",
|
||||||
|
"rekomendasi": "Pertahankan SOP saat ini."
|
||||||
|
})
|
||||||
|
parsed = insight_service.parse_llm_json(payload)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["kesimpulan"], "Kondisi pakan normal.")
|
||||||
|
self.assertEqual(parsed["akar_masalah"], "Kondisi operasional optimal, tidak terdeteksi indikasi deviasi.")
|
||||||
|
self.assertEqual(parsed["insight"], "Pertahankan SOP saat ini.")
|
||||||
|
|
||||||
|
def test_parse_llm_json_legacy_fallback(self):
|
||||||
|
payload = json.dumps({
|
||||||
|
"kesimpulan": "Data normal.",
|
||||||
|
"insight": "Lanjutkan pemantauan harian."
|
||||||
|
})
|
||||||
|
parsed = insight_service.parse_llm_json(payload)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["kesimpulan"], "Data normal.")
|
||||||
|
self.assertEqual(parsed["akar_masalah"], "")
|
||||||
|
self.assertEqual(parsed["insight"], "Lanjutkan pemantauan harian.")
|
||||||
|
|
||||||
|
def test_embedded_daily_serialization(self):
|
||||||
|
clean_insight = "Lakukan penyesuaian tirai kandang dan ventilasi minimal."
|
||||||
|
akar_masalah = "Suhu brooding terlalu dingin di malam hari (26C vs 32C)."
|
||||||
|
daily_json = json.dumps({"akar_masalah": akar_masalah, "insight": clean_insight}, ensure_ascii=False)
|
||||||
|
stored_text = f"{clean_insight}\n\n{insight_service.DAILY_JSON_MARKER}\n{daily_json}"
|
||||||
|
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.pk = 1
|
||||||
|
mock_row.cycle_id = 10
|
||||||
|
mock_row.kandang_id = 2
|
||||||
|
mock_row.topic = "fcr"
|
||||||
|
mock_row.report_type = "page"
|
||||||
|
mock_row.report_period = "current"
|
||||||
|
mock_row.summary = "FCR tinggi pada hari ke-7."
|
||||||
|
mock_row.insight_text = stored_text
|
||||||
|
mock_row.alert = "warning"
|
||||||
|
mock_row.citations = "[]"
|
||||||
|
mock_row.date = None
|
||||||
|
mock_row.created_at = None
|
||||||
|
mock_row.updated_at = None
|
||||||
|
|
||||||
|
serialized = insight_service.serialize_insight(mock_row)
|
||||||
|
self.assertTrue(serialized["success"])
|
||||||
|
self.assertEqual(serialized["summary"], "FCR tinggi pada hari ke-7.")
|
||||||
|
self.assertEqual(serialized["akar_masalah"], akar_masalah)
|
||||||
|
self.assertEqual(serialized["insight"], clean_insight)
|
||||||
|
self.assertIn(akar_masalah, serialized["insight_text"])
|
||||||
|
self.assertIn(clean_insight, serialized["insight_text"])
|
||||||
|
|
||||||
|
def test_daily_prompt_structure(self):
|
||||||
|
prompt = insight_service.build_insight_user_prompt(
|
||||||
|
topic="fcr",
|
||||||
|
kandang_name="Kandang 01",
|
||||||
|
report_type="page",
|
||||||
|
report_period="current",
|
||||||
|
context={"fcr_terakhir": 1.4},
|
||||||
|
graded={"analyses": {"fcr": {"status": "warning", "message": "FCR tinggi"}}},
|
||||||
|
is_end_cycle=False,
|
||||||
|
)
|
||||||
|
self.assertIn("akar_masalah", prompt)
|
||||||
|
self.assertIn("ANALISIS AKAR PENYEBAB", prompt)
|
||||||
|
self.assertIn('"akar_masalah":"<analisis faktor pemicu', prompt)
|
||||||
|
|
||||||
|
|
||||||
class TraceLintTests(TestCase):
|
class TraceLintTests(TestCase):
|
||||||
"""Test rag_trace.jsonl linting logic."""
|
"""Test rag_trace.jsonl linting logic."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard AI Insight card (page / daily / weekly / end_cycle report modes).
|
* Dashboard AI Insight card (daily / end_cycle report modes).
|
||||||
*
|
*
|
||||||
* Parallel client to `PageAiInsight` (topic pages) — same API, different UX.
|
* Parallel client to `PageAiInsight` (topic pages) — same API, different UX.
|
||||||
* Architecture: docs/ai-insight/README.md
|
* Architecture: docs/ai-insight/README.md
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
scopeDashboardInsightMetrics,
|
scopeDashboardInsightMetrics,
|
||||||
} from '../utils/insightContextBuilders.ts';
|
} from '../utils/insightContextBuilders.ts';
|
||||||
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
|
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
|
||||||
|
import { parseAiResult, type AiResult } from '../utils/insightParse.ts';
|
||||||
import { formatRatio } from '../utils/format.ts';
|
import { formatRatio } from '../utils/format.ts';
|
||||||
import {
|
import {
|
||||||
HeroFigure,
|
HeroFigure,
|
||||||
@@ -253,6 +254,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||||
|
|
||||||
const [insightText, setInsightText] = useState('');
|
const [insightText, setInsightText] = useState('');
|
||||||
|
const [dailyInsight, setDailyInsight] = useState<AiResult | null>(null);
|
||||||
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
||||||
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | null>(null);
|
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | null>(null);
|
||||||
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
||||||
@@ -275,8 +277,6 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
reportType === 'daily'
|
reportType === 'daily'
|
||||||
? (dayPeriod ??
|
? (dayPeriod ??
|
||||||
(typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null))
|
(typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null))
|
||||||
: reportType === 'weekly'
|
|
||||||
? dayPeriod
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const scopedMetrics = useMemo(() => {
|
const scopedMetrics = useMemo(() => {
|
||||||
@@ -293,7 +293,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
text: string,
|
text: string,
|
||||||
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
|
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
|
||||||
structuredEndCycle?: EndCycleStructuredInsight | null,
|
structuredEndCycle?: EndCycleStructuredInsight | null,
|
||||||
summary?: string | null
|
summary?: string | null,
|
||||||
|
akarMasalah?: string | null,
|
||||||
|
insightBody?: string | null
|
||||||
) => {
|
) => {
|
||||||
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
|
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
|
||||||
setInsightText(text);
|
setInsightText(text);
|
||||||
@@ -305,6 +307,22 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
|
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
|
||||||
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
|
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
|
||||||
setCitations(citationsArg);
|
setCitations(citationsArg);
|
||||||
|
|
||||||
|
if (!endCycle) {
|
||||||
|
if (summary || akarMasalah || insightBody) {
|
||||||
|
setDailyInsight({
|
||||||
|
summary: summary || '',
|
||||||
|
akar_masalah: akarMasalah || undefined,
|
||||||
|
insight: insightBody || text || '',
|
||||||
|
});
|
||||||
|
} else if (text) {
|
||||||
|
setDailyInsight(parseAiResult(text));
|
||||||
|
} else {
|
||||||
|
setDailyInsight(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDailyInsight(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -451,7 +469,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
apiResult.insight_text,
|
apiResult.insight_text,
|
||||||
apiResult.citations,
|
apiResult.citations,
|
||||||
apiResult.structured_end_cycle ?? null,
|
apiResult.structured_end_cycle ?? null,
|
||||||
apiResult.summary
|
apiResult.summary,
|
||||||
|
apiResult.akar_masalah,
|
||||||
|
apiResult.insight
|
||||||
);
|
);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
setDetailsOpen(false);
|
setDetailsOpen(false);
|
||||||
@@ -1013,10 +1033,44 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : dailyInsight ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||||
|
{dailyInsight.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dailyInsight.akar_masalah && (
|
||||||
|
<div className="rounded-xl border border-amber-100/80 bg-amber-50/40 p-5 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-magnifying-glass-chart text-amber-600" /> Akar Masalah
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-white/80 p-3 rounded-lg border border-amber-100/80">
|
||||||
|
{dailyInsight.akar_masalah}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-brain text-red-600" /> AI Insight & Rekomendasi
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||||
|
{dailyInsight.insight}
|
||||||
|
</p>
|
||||||
|
<CitationsBlock citations={citations} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : insightText ? (
|
) : insightText ? (
|
||||||
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
||||||
{insightText.includes(END_CYCLE_JSON_MARKER)
|
{insightText.includes(END_CYCLE_JSON_MARKER)
|
||||||
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
|
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
|
||||||
|
: insightText.includes('<!--daily_json-->')
|
||||||
|
? insightText.split('<!--daily_json-->')[0]?.trim()
|
||||||
: insightText}
|
: insightText}
|
||||||
<CitationsBlock citations={citations} />
|
<CitationsBlock citations={citations} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -603,10 +603,11 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = apiResult.insight_text ?? '';
|
const text = apiResult.insight_text ?? '';
|
||||||
let parsed: { summary: string; insight: string } | null = null;
|
let parsed: AiResult | null = null;
|
||||||
if (apiResult.summary || apiResult.insight) {
|
if (apiResult.summary || apiResult.insight || apiResult.akar_masalah) {
|
||||||
parsed = {
|
parsed = {
|
||||||
summary: apiResult.summary || '',
|
summary: apiResult.summary || '',
|
||||||
|
akar_masalah: apiResult.akar_masalah || undefined,
|
||||||
insight: apiResult.insight || apiResult.insight_text || '',
|
insight: apiResult.insight || apiResult.insight_text || '',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -619,6 +620,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
|
|
||||||
const wrapped: PageInsightResult = {
|
const wrapped: PageInsightResult = {
|
||||||
summary: parsed.summary,
|
summary: parsed.summary,
|
||||||
|
akar_masalah: parsed.akar_masalah,
|
||||||
insight: parsed.insight,
|
insight: parsed.insight,
|
||||||
citations: apiResult.citations,
|
citations: apiResult.citations,
|
||||||
};
|
};
|
||||||
@@ -1041,9 +1043,20 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{result.akar_masalah && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
<i className="fa-solid fa-magnifying-glass-chart text-amber-600" /> Akar Masalah
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-amber-50/50 p-3 rounded-lg border border-amber-100/80">
|
||||||
|
{result.akar_masalah}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-brain text-red-600" /> AI Insight & Rekomendasi
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||||
{result.insight}
|
{result.insight}
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export type InsightApiResponse = {
|
|||||||
success?: boolean;
|
success?: boolean;
|
||||||
insight_text: string;
|
insight_text: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
akar_masalah?: string;
|
||||||
insight?: string;
|
insight?: string;
|
||||||
structured?: InsightStructuredReport | null;
|
structured?: InsightStructuredReport | null;
|
||||||
structured_end_cycle?: EndCycleStructuredInsight | null;
|
structured_end_cycle?: EndCycleStructuredInsight | null;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseAiResult } from '../insightParse.ts';
|
||||||
|
|
||||||
|
describe('parseAiResult with akar_masalah', () => {
|
||||||
|
it('parses standard 3-section json output', () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
kesimpulan: 'FCR 1.40 pada hari ke-28 berada di atas standar CP 707 (1.315).',
|
||||||
|
akar_masalah: 'Suhu lingkungan fluktuatif dan distribusi pakan kurang merata.',
|
||||||
|
insight: 'Lakukan kalibrasi suhu pemanas dan ratakan tempat pakan.',
|
||||||
|
});
|
||||||
|
const res = parseAiResult(raw);
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res?.summary).toBe('FCR 1.40 pada hari ke-28 berada di atas standar CP 707 (1.315).');
|
||||||
|
expect(res?.akar_masalah).toBe(
|
||||||
|
'Suhu lingkungan fluktuatif dan distribusi pakan kurang merata.'
|
||||||
|
);
|
||||||
|
expect(res?.insight).toBe('Lakukan kalibrasi suhu pemanas dan ratakan tempat pakan.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles markdown fences and think tags', () => {
|
||||||
|
const raw = `
|
||||||
|
<think>
|
||||||
|
Evaluating data for day 28. FCR is high.
|
||||||
|
</think>
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"summary": "Kondisi pakan stabil.",
|
||||||
|
"root_cause": "Kondisi operasional optimal, tidak terdeteksi indikasi anomali.",
|
||||||
|
"rekomendasi": "Pertahankan ventilasi dan jadwal pemberian pakan."
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
const res = parseAiResult(raw);
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res?.summary).toBe('Kondisi pakan stabil.');
|
||||||
|
expect(res?.akar_masalah).toBe(
|
||||||
|
'Kondisi operasional optimal, tidak terdeteksi indikasi anomali.'
|
||||||
|
);
|
||||||
|
expect(res?.insight).toBe('Pertahankan ventilasi dan jadwal pemberian pakan.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates legacy 2-field format without akar_masalah', () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
kesimpulan: 'Bobot badan sesuai target.',
|
||||||
|
insight: 'Lanjutkan monitoring bobot mingguan.',
|
||||||
|
});
|
||||||
|
const res = parseAiResult(raw);
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res?.summary).toBe('Bobot badan sesuai target.');
|
||||||
|
expect(res?.akar_masalah).toBeUndefined();
|
||||||
|
expect(res?.insight).toBe('Lanjutkan monitoring bobot mingguan.');
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
-1
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
export interface AiResult {
|
export interface AiResult {
|
||||||
summary: string;
|
summary: string;
|
||||||
|
akar_masalah?: string;
|
||||||
insight: string;
|
insight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,17 @@ const KESIMPULAN_KEYS = [
|
|||||||
'conclusion',
|
'conclusion',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Field names seen standing in for the root cause / factors. */
|
||||||
|
const AKAR_MASALAH_KEYS = [
|
||||||
|
'akar_masalah',
|
||||||
|
'akar_penyebab',
|
||||||
|
'root_cause',
|
||||||
|
'penyebab',
|
||||||
|
'faktor_penyebab',
|
||||||
|
'faktor_pemicu',
|
||||||
|
'indikasi_masalah',
|
||||||
|
];
|
||||||
|
|
||||||
/** Field names seen standing in for the recommendation. */
|
/** Field names seen standing in for the recommendation. */
|
||||||
const INSIGHT_KEYS = [
|
const INSIGHT_KEYS = [
|
||||||
'insight',
|
'insight',
|
||||||
@@ -122,6 +134,9 @@ export const parseAiResult = (text: string): AiResult | null => {
|
|||||||
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
|
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
|
||||||
if (kesimpulanHit) used.add(kesimpulanHit.key);
|
if (kesimpulanHit) used.add(kesimpulanHit.key);
|
||||||
|
|
||||||
|
const akarMasalahHit = takeFirst(parsed, AKAR_MASALAH_KEYS);
|
||||||
|
if (akarMasalahHit) used.add(akarMasalahHit.key);
|
||||||
|
|
||||||
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
|
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
|
||||||
if (insightHit) used.add(insightHit.key);
|
if (insightHit) used.add(insightHit.key);
|
||||||
|
|
||||||
@@ -136,6 +151,7 @@ export const parseAiResult = (text: string): AiResult | null => {
|
|||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
let kesimpulan = kesimpulanHit?.text ?? '';
|
let kesimpulan = kesimpulanHit?.text ?? '';
|
||||||
|
const akarMasalah = akarMasalahHit?.text ?? '';
|
||||||
let insight = insightHit?.text ?? '';
|
let insight = insightHit?.text ?? '';
|
||||||
|
|
||||||
if (!kesimpulan && leftovers.length > 0) {
|
if (!kesimpulan && leftovers.length > 0) {
|
||||||
@@ -147,10 +163,11 @@ export const parseAiResult = (text: string): AiResult | null => {
|
|||||||
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
|
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!kesimpulan && !insight) return null;
|
if (!kesimpulan && !insight && !akarMasalah) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
|
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
|
||||||
|
akar_masalah: akarMasalah || undefined,
|
||||||
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
|
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user