ai insight rework #3, add root cause to daily ai insight

This commit is contained in:
Alberto-Audrix committed 2026-10-06 14:14:11 +07:00
1 parent c963bb778c
commit d5b0c12c40
8 files changed
+385 -47

No files matched your search

@@ -32,6 +32,21 @@ _INFERENCE_LOCK = threading.Lock()
_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.
ALERT_HEALTHY = "healthy"
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".
FORMAT OUTPUT (JSON SAJA):
{"kesimpulan":"...","insight":"..."}
{"kesimpulan":"...","akar_masalah":"...","insight":"..."}
"""
END_CYCLE_OUTPUT_RULES = """
@@ -241,6 +256,7 @@ def build_insight_user_prompt(
lines.append(f" - {b}")
# 5. Length contract + field role contract
actual_alert = alert_from_graded(graded) if graded else ALERT_UNKNOWN
if is_end_cycle:
lines.append(
"\nPANJANG WAJIB: kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
@@ -252,13 +268,32 @@ def build_insight_user_prompt(
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
)
else:
lines.append(
"\nPANJANG WAJIB: insight = narasi 3-6 kalimat berurutan: "
"(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, "
"(3) tindakan konkret. kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
)
if actual_alert in (ALERT_WARNING, ALERT_CRITICAL):
lines.append(
f"\nPANJANG WAJIB (STATUS AKTUAL ADALAH {actual_alert.upper()} — ADA ANOMALI/MASALAH): "
"kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi, sebutkan angka aktual vs standar CP 707). "
"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, "
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
)
# 6. Output schema
if is_end_cycle:
@@ -272,12 +307,30 @@ def build_insight_user_prompt(
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
)
else:
lines.append(
'\nBALAS HANYA JSON persis: '
'{"kesimpulan":"<ringkasan penilaian 1-3 kalimat>",'
'"insight":"<narasi panjang 3-6 kalimat: aktual vs standar, penyebab, tindakan>"}'
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
)
if actual_alert in (ALERT_WARNING, ALERT_CRITICAL):
lines.append(
'\nBALAS HANYA JSON persis: '
'{"kesimpulan":"<ringkasan penilaian data aktual vs standar 1-3 kalimat>",'
'"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.'
)
return "\n".join(lines)
@@ -671,16 +724,27 @@ def parse_llm_json(text: str) -> dict[str, str] | None:
or parsed.get("summary")
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 = (
parsed.get("insight")
or parsed.get("rekomendasi")
or parsed.get("insights")
or parsed.get("saran")
or ""
)
if not kesimpulan and not insight:
if not kesimpulan and not insight and not akar_masalah:
return None
return {
"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.",
}
@@ -737,11 +801,17 @@ def _text_soft_labels_mortality(text: str) -> bool:
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(
parsed: dict[str, Any],
graded: dict[str, Any] | None,
) -> 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 {}
status = str(mort.get("status") or "").lower()
if status not in ("warning", "critical"):
@@ -754,23 +824,33 @@ def enforce_mortality_wording(
out = dict(parsed)
kesimpulan = str(out.get("kesimpulan") or "")
akar_masalah = str(out.get("akar_masalah") 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
# Prefer the deterministic graded message; keep non-mortality insight if clean.
if message:
out["kesimpulan"] = message
else:
pct_part = f" ({pct})" if pct else ""
out["kesimpulan"] = (
f"Mortalitas kumulatif{pct_part} adalah {label} menurut ambang CP 707."
pct_part = f" ({pct})" if pct else ""
if has_soft_kesimpulan:
if message:
out["kesimpulan"] = message
else:
out["kesimpulan"] = (
f"Mortalitas kumulatif{pct_part} adalah {label} menurut ambang CP 707."
)
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 _text_soft_labels_mortality(insight):
if has_soft_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."
)
return out
@@ -792,6 +872,7 @@ def enforce_bw_wording(
out = dict(parsed)
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 status == "ok" and any(
@@ -803,6 +884,14 @@ def enforce_bw_wording(
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)."
# 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
@@ -824,7 +913,7 @@ def _placeholder_text(value: str) -> bool:
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
"""Deterministic cleanup of narrative fields after the LLM."""
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:
out[key] = collapse_duplicate_kandang(str(out[key]))
return out
@@ -912,15 +1001,36 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
def serialize_insight(row: AIInsight) -> dict[str, Any]:
norm_citations = decode_citations(row.citations)
summary = collapse_duplicate_kandang(row.summary or "")
insight_body = collapse_duplicate_kandang(row.insight_text or "")
insight_text = "\n\n".join(
part for part in [summary, insight_body] if part
) or insight_body or ""
structured = root_cause.parse_embedded_end_cycle(insight_body)
insight_raw = collapse_duplicate_kandang(row.insight_text or "")
structured = root_cause.parse_embedded_end_cycle(insight_raw)
if structured and summary and not structured.get("kesimpulan"):
structured = {**structured, "kesimpulan": summary}
elif 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 {
"success": True,
"id": row.pk,
@@ -930,6 +1040,7 @@ def serialize_insight(row: AIInsight) -> dict[str, Any]:
"report_type": row.report_type,
"report_period": row.report_period,
"summary": summary,
"akar_masalah": akar_masalah,
"insight": insight_body,
"insight_text": insight_text,
"structured_end_cycle": structured,
@@ -1176,10 +1287,17 @@ def generate_insight(
parsed = enforce_mortality_wording(parsed, graded)
parsed = enforce_bw_wording(parsed, graded)
parsed = sanitize_insight_narrative(parsed)
insight_text = parsed["insight"]
summary = parsed["kesimpulan"]
if _placeholder_text(summary) or _placeholder_text(insight_text):
degenerate_reason = f"kesimpulan={summary!r} insight={insight_text!r}"
akar_masalah = parsed.get("akar_masalah", "").strip()
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 += (
"\nCATATAN: keluaran sebelumnya hanya placeholder. "
"Tulis kalimat lengkap sendiri untuk kesimpulan dan insight."