From d5b0c12c40071060e7706cfa598f320eb515ad99 Mon Sep 17 00:00:00 2001
From: Alberto-Audrix
Date: Tue, 6 Oct 2026 14:14:11 +0700
Subject: [PATCH] ai insight rework #3, add root cause to daily ai insight
---
.../operations/services/cp707_knowledge.py | 2 +-
.../operations/services/insight_service.py | 188 ++++++++++++++----
.../apps/operations/tests_insight_quality.py | 82 ++++++++
components/AiInsightCard.tsx | 68 ++++++-
components/shared/PageAiInsight.tsx | 19 +-
types/api.ts | 1 +
utils/__tests__/insightParse.test.ts | 53 +++++
utils/insightParse.ts | 19 +-
8 files changed, 385 insertions(+), 47 deletions(-)
create mode 100644 utils/__tests__/insightParse.test.ts
diff --git a/backend/apps/operations/services/cp707_knowledge.py b/backend/apps/operations/services/cp707_knowledge.py
index ea8284e..c21b58d 100644
--- a/backend/apps/operations/services/cp707_knowledge.py
+++ b/backend/apps/operations/services/cp707_knowledge.py
@@ -492,7 +492,7 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
message = (
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"]:
status = "warning"
diff --git a/backend/apps/operations/services/insight_service.py b/backend/apps/operations/services/insight_service.py
index 7dd2e8a..57d6c4a 100644
--- a/backend/apps/operations/services/insight_service.py
+++ b/backend/apps/operations/services/insight_service.py
@@ -32,6 +32,21 @@ _INFERENCE_LOCK = threading.Lock()
_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$")
+DAILY_JSON_MARKER = ""
+
+
+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":"",'
- '"insight":""}'
- ' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
- )
+ if actual_alert in (ALERT_WARNING, ALERT_CRITICAL):
+ lines.append(
+ '\nBALAS HANYA JSON persis: '
+ '{"kesimpulan":"",'
+ '"akar_masalah":"",'
+ '"insight":""}'
+ ' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
+ )
+ elif actual_alert == ALERT_HEALTHY:
+ lines.append(
+ '\nBALAS HANYA JSON persis: '
+ '{"kesimpulan":"",'
+ '"akar_masalah":"",'
+ '"insight":""}'
+ ' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
+ )
+ else:
+ lines.append(
+ '\nBALAS HANYA JSON persis: '
+ '{"kesimpulan":"",'
+ '"akar_masalah":"",'
+ '"insight":""}'
+ ' - 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."
diff --git a/backend/apps/operations/tests_insight_quality.py b/backend/apps/operations/tests_insight_quality.py
index 0725986..ab5131e 100644
--- a/backend/apps/operations/tests_insight_quality.py
+++ b/backend/apps/operations/tests_insight_quality.py
@@ -672,6 +672,88 @@ class RootCauseTests(TestCase):
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":" = ({
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
const [insightText, setInsightText] = useState('');
+ const [dailyInsight, setDailyInsight] = useState(null);
const [structuredInsight, setStructuredInsight] = useState(null);
const [endCycleInsight, setEndCycleInsight] = useState(null);
const [citations, setCitations] = useState([]);
@@ -275,9 +277,7 @@ export const AiInsightCard: React.FC = ({
reportType === 'daily'
? (dayPeriod ??
(typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null))
- : reportType === 'weekly'
- ? dayPeriod
- : null;
+ : null;
const scopedMetrics = useMemo(() => {
if (!dashboardMetrics) return null;
@@ -293,7 +293,9 @@ export const AiInsightCard: React.FC = ({
text: string,
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
structuredEndCycle?: EndCycleStructuredInsight | null,
- summary?: string | null
+ summary?: string | null,
+ akarMasalah?: string | null,
+ insightBody?: string | null
) => {
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
setInsightText(text);
@@ -305,6 +307,22 @@ export const AiInsightCard: React.FC = ({
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
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 = ({
apiResult.insight_text,
apiResult.citations,
apiResult.structured_end_cycle ?? null,
- apiResult.summary
+ apiResult.summary,
+ apiResult.akar_masalah,
+ apiResult.insight
);
setLastUpdated(new Date());
setDetailsOpen(false);
@@ -1013,11 +1033,45 @@ export const AiInsightCard: React.FC = ({
>
)}
+ ) : dailyInsight ? (
+
+
+
+ Kesimpulan
+
+
+ {dailyInsight.summary}
+
+
+
+ {dailyInsight.akar_masalah && (
+
+
+ Akar Masalah
+
+
+ {dailyInsight.akar_masalah}
+
+
+ )}
+
+
+
+ AI Insight & Rekomendasi
+
+
+ {dailyInsight.insight}
+
+
+
+
) : insightText ? (
{insightText.includes(END_CYCLE_JSON_MARKER)
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
- : insightText}
+ : insightText.includes('')
+ ? insightText.split('')[0]?.trim()
+ : insightText}
) : (
diff --git a/components/shared/PageAiInsight.tsx b/components/shared/PageAiInsight.tsx
index 9e77b94..f9e5bc3 100644
--- a/components/shared/PageAiInsight.tsx
+++ b/components/shared/PageAiInsight.tsx
@@ -603,10 +603,11 @@ export const PageAiInsight: React.FC = ({
}
const text = apiResult.insight_text ?? '';
- let parsed: { summary: string; insight: string } | null = null;
- if (apiResult.summary || apiResult.insight) {
+ let parsed: AiResult | null = null;
+ if (apiResult.summary || apiResult.insight || apiResult.akar_masalah) {
parsed = {
summary: apiResult.summary || '',
+ akar_masalah: apiResult.akar_masalah || undefined,
insight: apiResult.insight || apiResult.insight_text || '',
};
} else {
@@ -619,6 +620,7 @@ export const PageAiInsight: React.FC = ({
const wrapped: PageInsightResult = {
summary: parsed.summary,
+ akar_masalah: parsed.akar_masalah,
insight: parsed.insight,
citations: apiResult.citations,
};
@@ -1041,9 +1043,20 @@ export const PageAiInsight: React.FC = ({
+ {result.akar_masalah && (
+
+
+ Akar Masalah
+
+
+ {result.akar_masalah}
+
+
+ )}
+
- AI Insight
+ AI Insight & Rekomendasi
{result.insight}
diff --git a/types/api.ts b/types/api.ts
index cf8165b..1aa5a79 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -337,6 +337,7 @@ export type InsightApiResponse = {
success?: boolean;
insight_text: string;
summary?: string;
+ akar_masalah?: string;
insight?: string;
structured?: InsightStructuredReport | null;
structured_end_cycle?: EndCycleStructuredInsight | null;
diff --git a/utils/__tests__/insightParse.test.ts b/utils/__tests__/insightParse.test.ts
new file mode 100644
index 0000000..152e34f
--- /dev/null
+++ b/utils/__tests__/insightParse.test.ts
@@ -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 = `
+
+Evaluating data for day 28. FCR is high.
+
+\`\`\`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.');
+ });
+});
diff --git a/utils/insightParse.ts b/utils/insightParse.ts
index 346c9bb..d05ff77 100644
--- a/utils/insightParse.ts
+++ b/utils/insightParse.ts
@@ -15,6 +15,7 @@
export interface AiResult {
summary: string;
+ akar_masalah?: string;
insight: string;
}
@@ -28,6 +29,17 @@ const KESIMPULAN_KEYS = [
'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. */
const INSIGHT_KEYS = [
'insight',
@@ -122,6 +134,9 @@ export const parseAiResult = (text: string): AiResult | null => {
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
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);
if (insightHit) used.add(insightHit.key);
@@ -136,6 +151,7 @@ export const parseAiResult = (text: string): AiResult | null => {
.filter((line) => line.length > 0);
let kesimpulan = kesimpulanHit?.text ?? '';
+ const akarMasalah = akarMasalahHit?.text ?? '';
let insight = insightHit?.text ?? '';
if (!kesimpulan && leftovers.length > 0) {
@@ -147,10 +163,11 @@ export const parseAiResult = (text: string): AiResult | null => {
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
}
- if (!kesimpulan && !insight) return null;
+ if (!kesimpulan && !insight && !akarMasalah) return null;
return {
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
+ akar_masalah: akarMasalah || undefined,
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
};
};