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.', }; };