From 15ebdcce7865d56e28b82ecfacfb881491c49770 Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Tue, 6 Oct 2026 14:26:26 +0700 Subject: [PATCH] update ai insight root cause --- backend/apps/operations/tests.py | 89 +++++++++++++++++++++++ backend/apps/operations/views.py | 102 +++++++++++++++++---------- components/AiInsightCard.tsx | 66 +++++++++++++++-- components/shared/PageAiInsight.tsx | 19 ++++- types/api.ts | 1 + utils/__tests__/insightParse.test.ts | 49 +++++++++++++ utils/insightParse.ts | 19 ++++- 7 files changed, 297 insertions(+), 48 deletions(-) create mode 100644 utils/__tests__/insightParse.test.ts diff --git a/backend/apps/operations/tests.py b/backend/apps/operations/tests.py index 582f9b4..5a474de 100644 --- a/backend/apps/operations/tests.py +++ b/backend/apps/operations/tests.py @@ -478,3 +478,92 @@ class ErdPrimaryKeyTests(TestCase): live_pks = [row[1] for row in cursor.fetchall() if row[5]] self.assertEqual(live_pks, [pk_name], table) + +class AIInsightAPITests(TestCase): + def setUp(self): + import json + self.user = User.objects.create_user(user_name="insight_tester", password="secret") + self.client = APIClient() + self.client.force_authenticate(self.user) + site = _linked_site(self.user, site_name="Site A", code="site-a") + self.kandang = Kandang.objects.create(kandang_name="Kandang 01", site=site) + self.cycle = Cycle.objects.create( + kandang=self.kandang, + total_days=35, + start_date=date.today() - timedelta(days=5), + doc_in_weight=40, + doc_in_count=10000, + status=Cycle.STATUS_ACTIVE, + ) + + def test_cached_extracts_akar_masalah_from_daily_json(self): + import json + from apps.operations.models import AIInsight + + 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\n{daily_json}" + + AIInsight.objects.create( + cycle=self.cycle, + kandang=self.kandang, + topic="fcr", + report_type="page", + report_period="current", + date=date.today(), + summary="FCR tinggi pada hari ke-7.", + insight_text=stored_text, + alert="warning", + ) + + response = self.client.get( + "/api/v1/ai-insights/cached/", + { + "cycle_id": self.cycle.pk, + "kandang_id": self.kandang.pk, + "topic": "fcr", + "report_type": "page", + "report_period": "current", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["success"]) + self.assertEqual(response.data["summary"], "FCR tinggi pada hari ke-7.") + self.assertEqual(response.data["akar_masalah"], akar_masalah) + self.assertEqual(response.data["insight"], clean_insight) + self.assertIn(akar_masalah, response.data["insight_text"]) + self.assertIn(clean_insight, response.data["insight_text"]) + + def test_cached_legacy_fallback_without_akar_masalah(self): + from apps.operations.models import AIInsight + + AIInsight.objects.create( + cycle=self.cycle, + kandang=self.kandang, + topic="fcr", + report_type="page", + report_period="current", + date=date.today(), + summary="Data normal.", + insight_text="Pertahankan SOP saat ini.", + alert="healthy", + ) + + response = self.client.get( + "/api/v1/ai-insights/cached/", + { + "cycle_id": self.cycle.pk, + "kandang_id": self.kandang.pk, + "topic": "fcr", + "report_type": "page", + "report_period": "current", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["success"]) + self.assertEqual(response.data["summary"], "Data normal.") + self.assertEqual(response.data["akar_masalah"], "") + self.assertEqual(response.data["insight"], "Pertahankan SOP saat ini.") + + diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index fc3d584..0a2257b 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -254,7 +254,6 @@ class AIInsightViewSet(CycleScopedViewSet): @action(detail=False, methods=["get"], url_path="cached") def cached(self, request): - import json params = request.query_params cycle_id = params.get("cycle_id") kandang_id = params.get("kandang_id") @@ -280,27 +279,7 @@ class AIInsightViewSet(CycleScopedViewSet): if row is None: return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND) - raw_cites = row.citations - if isinstance(raw_cites, list): - cites = raw_cites - elif isinstance(raw_cites, str) and raw_cites.strip(): - try: - cites = json.loads(raw_cites) - except Exception: - cites = [] - else: - cites = [] - - return Response({ - "success": True, - "insight_text": row.insight_text, - "summary": row.summary, - "alert": row.alert, - "citations": cites, - "topic": row.topic, - "report_type": row.report_type, - "report_period": row.report_period, - }) + return Response(self._serialize_row(row)) @action(detail=False, methods=["post"], url_path="generate") def generate(self, request): @@ -327,27 +306,74 @@ class AIInsightViewSet(CycleScopedViewSet): .first() ) if row is not None: - import json - raw_cites = row.citations - try: - cites = json.loads(raw_cites) if isinstance(raw_cites, str) else (raw_cites or []) - except Exception: - cites = [] - return Response({ - "success": True, - "insight_text": row.insight_text, - "summary": row.summary, - "alert": row.alert, - "citations": cites, - "topic": row.topic, - "report_type": row.report_type, - "report_period": row.report_period, - }) + return Response(self._serialize_row(row)) return Response( {"detail": "Insight belum tersedia pada mirror HQ. Pastikan insight telah di-generate pada site dashboard dan disinkronkan."}, status=status.HTTP_404_NOT_FOUND, ) + def _serialize_row(self, row: AIInsight) -> dict: + import json + + raw_cites = row.citations + if isinstance(raw_cites, list): + cites = raw_cites + elif isinstance(raw_cites, str) and raw_cites.strip(): + try: + cites = json.loads(raw_cites) + except Exception: + cites = [] + else: + cites = [] + + insight_raw = row.insight_text or "" + akar_masalah = "" + insight_body = insight_raw + structured_end_cycle = None + + if "" in insight_raw: + raw = insight_raw.split("", 1)[1].strip() + try: + embedded = json.loads(raw) + if isinstance(embedded, dict): + akar_masalah = str(embedded.get("akar_masalah") or "").strip() + insight_body = str( + embedded.get("insight") or insight_raw.split("", 1)[0] + ).strip() + except Exception: + insight_body = insight_raw.split("", 1)[0].strip() + elif "" in insight_raw: + raw = insight_raw.split("", 1)[1].strip() + try: + structured_end_cycle = json.loads(raw) + except Exception: + pass + insight_body = insight_raw.split("", 1)[0].strip() + + parts = [row.summary] + if akar_masalah: + parts.append(akar_masalah) + if insight_body: + parts.append(insight_body) + full_text = "\n\n".join(p for p in parts if p) or insight_body or "" + + return { + "success": True, + "id": row.pk, + "cycle": row.cycle_id, + "kandang": row.kandang_id, + "topic": row.topic, + "report_type": row.report_type, + "report_period": row.report_period, + "summary": row.summary, + "akar_masalah": akar_masalah, + "insight": insight_body, + "insight_text": full_text, + "structured_end_cycle": structured_end_cycle, + "alert": row.alert, + "citations": cites, + } + class HealthView(APIView): permission_classes = [AllowAny] diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index adb3a96..402e67b 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -23,6 +23,7 @@ import { scopeDashboardInsightMetrics, } from '../utils/insightContextBuilders.ts'; import { getCobbStandardForDay } from '../utils/insightHelpers.ts'; +import { parseAiResult, type AiResult } from '../utils/insightParse.ts'; import { formatRatio } from '../utils/format.ts'; import { HeroFigure, @@ -252,6 +253,7 @@ export const AiInsightCard: React.FC = ({ 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([]); @@ -274,9 +276,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; @@ -292,7 +292,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); @@ -304,6 +306,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); + } }, [] ); @@ -426,7 +444,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); @@ -978,11 +998,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 c48f427..b1dc716 100644 --- a/components/shared/PageAiInsight.tsx +++ b/components/shared/PageAiInsight.tsx @@ -522,10 +522,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 { @@ -538,6 +539,7 @@ export const PageAiInsight: React.FC = ({ const wrapped: PageInsightResult = { summary: parsed.summary, + akar_masalah: parsed.akar_masalah, insight: parsed.insight, citations: apiResult.citations, }; @@ -939,9 +941,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 2ef7bd9..043f82a 100644 --- a/types/api.ts +++ b/types/api.ts @@ -414,6 +414,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..4094b4c --- /dev/null +++ b/utils/__tests__/insightParse.test.ts @@ -0,0 +1,49 @@ +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..c56e4c4 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 ?? ''; + let 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.', }; };