update ai insight root cause
This commit is contained in:
1 parent
7e8c06d36f
commit
15ebdcce78
7 files changed
+295
-46
No files matched your search
@@ -478,3 +478,92 @@ class ErdPrimaryKeyTests(TestCase):
|
|||||||
live_pks = [row[1] for row in cursor.fetchall() if row[5]]
|
live_pks = [row[1] for row in cursor.fetchall() if row[5]]
|
||||||
self.assertEqual(live_pks, [pk_name], table)
|
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<!--daily_json-->\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.")
|
||||||
|
|
||||||
|
|
||||||
@@ -254,7 +254,6 @@ class AIInsightViewSet(CycleScopedViewSet):
|
|||||||
|
|
||||||
@action(detail=False, methods=["get"], url_path="cached")
|
@action(detail=False, methods=["get"], url_path="cached")
|
||||||
def cached(self, request):
|
def cached(self, request):
|
||||||
import json
|
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
cycle_id = params.get("cycle_id")
|
cycle_id = params.get("cycle_id")
|
||||||
kandang_id = params.get("kandang_id")
|
kandang_id = params.get("kandang_id")
|
||||||
@@ -280,27 +279,7 @@ class AIInsightViewSet(CycleScopedViewSet):
|
|||||||
if row is None:
|
if row is None:
|
||||||
return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
raw_cites = row.citations
|
return Response(self._serialize_row(row))
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="generate")
|
@action(detail=False, methods=["post"], url_path="generate")
|
||||||
def generate(self, request):
|
def generate(self, request):
|
||||||
@@ -327,27 +306,74 @@ class AIInsightViewSet(CycleScopedViewSet):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
import json
|
return Response(self._serialize_row(row))
|
||||||
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(
|
return Response(
|
||||||
{"detail": "Insight belum tersedia pada mirror HQ. Pastikan insight telah di-generate pada site dashboard dan disinkronkan."},
|
{"detail": "Insight belum tersedia pada mirror HQ. Pastikan insight telah di-generate pada site dashboard dan disinkronkan."},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
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 "<!--daily_json-->" in insight_raw:
|
||||||
|
raw = insight_raw.split("<!--daily_json-->", 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("<!--daily_json-->", 1)[0]
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
insight_body = insight_raw.split("<!--daily_json-->", 1)[0].strip()
|
||||||
|
elif "<!--end_cycle_json-->" in insight_raw:
|
||||||
|
raw = insight_raw.split("<!--end_cycle_json-->", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
structured_end_cycle = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
insight_body = insight_raw.split("<!--end_cycle_json-->", 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):
|
class HealthView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
scopeDashboardInsightMetrics,
|
scopeDashboardInsightMetrics,
|
||||||
} from '../utils/insightContextBuilders.ts';
|
} from '../utils/insightContextBuilders.ts';
|
||||||
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
|
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
|
||||||
|
import { parseAiResult, type AiResult } from '../utils/insightParse.ts';
|
||||||
import { formatRatio } from '../utils/format.ts';
|
import { formatRatio } from '../utils/format.ts';
|
||||||
import {
|
import {
|
||||||
HeroFigure,
|
HeroFigure,
|
||||||
@@ -252,6 +253,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||||
|
|
||||||
const [insightText, setInsightText] = useState('');
|
const [insightText, setInsightText] = useState('');
|
||||||
|
const [dailyInsight, setDailyInsight] = useState<AiResult | null>(null);
|
||||||
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
||||||
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | null>(null);
|
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | null>(null);
|
||||||
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
||||||
@@ -274,8 +276,6 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
reportType === 'daily'
|
reportType === 'daily'
|
||||||
? (dayPeriod ??
|
? (dayPeriod ??
|
||||||
(typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null))
|
(typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null))
|
||||||
: reportType === 'weekly'
|
|
||||||
? dayPeriod
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const scopedMetrics = useMemo(() => {
|
const scopedMetrics = useMemo(() => {
|
||||||
@@ -292,7 +292,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
text: string,
|
text: string,
|
||||||
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
|
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
|
||||||
structuredEndCycle?: EndCycleStructuredInsight | null,
|
structuredEndCycle?: EndCycleStructuredInsight | null,
|
||||||
summary?: string | null
|
summary?: string | null,
|
||||||
|
akarMasalah?: string | null,
|
||||||
|
insightBody?: string | null
|
||||||
) => {
|
) => {
|
||||||
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
|
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
|
||||||
setInsightText(text);
|
setInsightText(text);
|
||||||
@@ -304,6 +306,22 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
|
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
|
||||||
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
|
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
|
||||||
setCitations(citationsArg);
|
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<AiInsightCardProps> = ({
|
|||||||
apiResult.insight_text,
|
apiResult.insight_text,
|
||||||
apiResult.citations,
|
apiResult.citations,
|
||||||
apiResult.structured_end_cycle ?? null,
|
apiResult.structured_end_cycle ?? null,
|
||||||
apiResult.summary
|
apiResult.summary,
|
||||||
|
apiResult.akar_masalah,
|
||||||
|
apiResult.insight
|
||||||
);
|
);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
setDetailsOpen(false);
|
setDetailsOpen(false);
|
||||||
@@ -978,10 +998,44 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : dailyInsight ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||||
|
{dailyInsight.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dailyInsight.akar_masalah && (
|
||||||
|
<div className="rounded-xl border border-amber-100/80 bg-amber-50/40 p-5 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-magnifying-glass-chart text-amber-600" /> Akar Masalah
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-white/80 p-3 rounded-lg border border-amber-100/80">
|
||||||
|
{dailyInsight.akar_masalah}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-brain text-red-600" /> AI Insight & Rekomendasi
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||||
|
{dailyInsight.insight}
|
||||||
|
</p>
|
||||||
|
<CitationsBlock citations={citations} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : insightText ? (
|
) : insightText ? (
|
||||||
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
||||||
{insightText.includes(END_CYCLE_JSON_MARKER)
|
{insightText.includes(END_CYCLE_JSON_MARKER)
|
||||||
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
|
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
|
||||||
|
: insightText.includes('<!--daily_json-->')
|
||||||
|
? insightText.split('<!--daily_json-->')[0]?.trim()
|
||||||
: insightText}
|
: insightText}
|
||||||
<CitationsBlock citations={citations} />
|
<CitationsBlock citations={citations} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -522,10 +522,11 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = apiResult.insight_text ?? '';
|
const text = apiResult.insight_text ?? '';
|
||||||
let parsed: { summary: string; insight: string } | null = null;
|
let parsed: AiResult | null = null;
|
||||||
if (apiResult.summary || apiResult.insight) {
|
if (apiResult.summary || apiResult.insight || apiResult.akar_masalah) {
|
||||||
parsed = {
|
parsed = {
|
||||||
summary: apiResult.summary || '',
|
summary: apiResult.summary || '',
|
||||||
|
akar_masalah: apiResult.akar_masalah || undefined,
|
||||||
insight: apiResult.insight || apiResult.insight_text || '',
|
insight: apiResult.insight || apiResult.insight_text || '',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -538,6 +539,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
|
|
||||||
const wrapped: PageInsightResult = {
|
const wrapped: PageInsightResult = {
|
||||||
summary: parsed.summary,
|
summary: parsed.summary,
|
||||||
|
akar_masalah: parsed.akar_masalah,
|
||||||
insight: parsed.insight,
|
insight: parsed.insight,
|
||||||
citations: apiResult.citations,
|
citations: apiResult.citations,
|
||||||
};
|
};
|
||||||
@@ -939,9 +941,20 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{result.akar_masalah && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
<i className="fa-solid fa-magnifying-glass-chart text-amber-600" /> Akar Masalah
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-amber-50/50 p-3 rounded-lg border border-amber-100/80">
|
||||||
|
{result.akar_masalah}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-brain text-red-600" /> AI Insight & Rekomendasi
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||||
{result.insight}
|
{result.insight}
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ export type InsightApiResponse = {
|
|||||||
success?: boolean;
|
success?: boolean;
|
||||||
insight_text: string;
|
insight_text: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
akar_masalah?: string;
|
||||||
insight?: string;
|
insight?: string;
|
||||||
structured?: InsightStructuredReport | null;
|
structured?: InsightStructuredReport | null;
|
||||||
structured_end_cycle?: EndCycleStructuredInsight | null;
|
structured_end_cycle?: EndCycleStructuredInsight | null;
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<think>
|
||||||
|
Evaluating data for day 28. FCR is high.
|
||||||
|
</think>
|
||||||
|
\`\`\`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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
-1
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
export interface AiResult {
|
export interface AiResult {
|
||||||
summary: string;
|
summary: string;
|
||||||
|
akar_masalah?: string;
|
||||||
insight: string;
|
insight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,17 @@ const KESIMPULAN_KEYS = [
|
|||||||
'conclusion',
|
'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. */
|
/** Field names seen standing in for the recommendation. */
|
||||||
const INSIGHT_KEYS = [
|
const INSIGHT_KEYS = [
|
||||||
'insight',
|
'insight',
|
||||||
@@ -122,6 +134,9 @@ export const parseAiResult = (text: string): AiResult | null => {
|
|||||||
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
|
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
|
||||||
if (kesimpulanHit) used.add(kesimpulanHit.key);
|
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);
|
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
|
||||||
if (insightHit) used.add(insightHit.key);
|
if (insightHit) used.add(insightHit.key);
|
||||||
|
|
||||||
@@ -136,6 +151,7 @@ export const parseAiResult = (text: string): AiResult | null => {
|
|||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
let kesimpulan = kesimpulanHit?.text ?? '';
|
let kesimpulan = kesimpulanHit?.text ?? '';
|
||||||
|
let akarMasalah = akarMasalahHit?.text ?? '';
|
||||||
let insight = insightHit?.text ?? '';
|
let insight = insightHit?.text ?? '';
|
||||||
|
|
||||||
if (!kesimpulan && leftovers.length > 0) {
|
if (!kesimpulan && leftovers.length > 0) {
|
||||||
@@ -147,10 +163,11 @@ export const parseAiResult = (text: string): AiResult | null => {
|
|||||||
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
|
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!kesimpulan && !insight) return null;
|
if (!kesimpulan && !insight && !akarMasalah) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
|
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
|
||||||
|
akar_masalah: akarMasalah || undefined,
|
||||||
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
|
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user