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
+ {dailyInsight.summary}
+
+ {dailyInsight.akar_masalah}
+
+ {dailyInsight.insight}
+
+ Kesimpulan
+
+
+ Akar Masalah
+
+
+ AI Insight & Rekomendasi
+
+
+ {result.akar_masalah} +
+
{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 = `
+