adjust ai insight and seed data

This commit is contained in:
Alberto-Audrix committed 2026-09-18 11:34:07 +07:00
1 parent 68e0d1a0bb
commit a2b5b10977
18 files changed
+1835 -140

No files matched your search

+218 -5
View File
@@ -8,6 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useFarm } from '../context/FarmContext.tsx';
import { api, errorMessage } from '../services/apiClient.ts';
import type {
EndCycleStructuredInsight,
InsightCitation,
InsightContext,
InsightReportType,
@@ -16,7 +17,9 @@ import type {
InsightStatusLabel,
} from '../types/api.ts';
import {
buildEndCycleRollups,
type DashboardInsightMetrics,
type EndCycleIotDayAggregate,
scopeDashboardInsightMetrics,
} from '../utils/insightContextBuilders.ts';
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
@@ -25,6 +28,7 @@ import { HeroFigure, MutedStatRow, StatTile, type InsightStatus } from './shared
import InsightTrendChart from './shared/InsightTrendChart.tsx';
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes
const END_CYCLE_JSON_MARKER = '<!--end_cycle_json-->';
const sectionBadgeClasses: Record<InsightStatusLabel, string> = {
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
@@ -33,6 +37,12 @@ const sectionBadgeClasses: Record<InsightStatusLabel, string> = {
unknown: 'border-slate-200 bg-slate-50 text-slate-600',
};
const confidenceBadge: Record<string, string> = {
high: 'border-red-200 bg-red-50 text-red-700',
med: 'border-amber-200 bg-amber-50 text-amber-700',
low: 'border-slate-200 bg-slate-50 text-slate-600',
};
export interface AiInsightCardProps {
/** Required — insight is always per kandang. */
kandangId: number;
@@ -40,6 +50,8 @@ export interface AiInsightCardProps {
context?: InsightContext;
/** Display-only KPIs + weight series (not sent to the LLM). */
dashboardMetrics?: DashboardInsightMetrics | null;
/** Optional IoT day aggregates for end-cycle root-cause enrichment. */
iotDayAggregates?: EndCycleIotDayAggregate[];
page?: 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality' | 'dashboard';
}
@@ -133,6 +145,73 @@ const normalizeStructuredInsight = (rawText: string): InsightStructuredReport |
}
};
const normalizeEndCycleInsight = (
raw: unknown,
fallbackSummary?: string | null
): EndCycleStructuredInsight | null => {
if (!raw || typeof raw !== 'object') return null;
const parsed = raw as Record<string, unknown>;
const hasShape =
Array.isArray(parsed.masalah) ||
Array.isArray(parsed.akar_penyebab) ||
Array.isArray(parsed.perbaikan_siklus_berikutnya);
if (!hasShape) return null;
const akarRaw = Array.isArray(parsed.akar_penyebab) ? parsed.akar_penyebab : [];
const fixRaw = Array.isArray(parsed.perbaikan_siklus_berikutnya)
? parsed.perbaikan_siklus_berikutnya
: [];
return {
kesimpulan:
asNonEmptyString(parsed.kesimpulan) ||
asNonEmptyString(fallbackSummary) ||
'Ringkasan akhir siklus',
masalah: asStringArray(parsed.masalah, 8),
akar_penyebab: akarRaw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
.map((item) => ({
id: asNonEmptyString(item.id) ?? undefined,
hipotesis: asNonEmptyString(item.hipotesis) || 'Hipotesis',
bukti: asStringArray(item.bukti, 6),
confidence: asNonEmptyString(item.confidence) ?? undefined,
fase: asNonEmptyString(item.fase) ?? undefined,
})),
perbaikan_siklus_berikutnya: fixRaw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
.map((item) => ({
id: asNonEmptyString(item.id) ?? undefined,
fase: asNonEmptyString(item.fase) || 'growth',
aksi: asNonEmptyString(item.aksi) || '',
metrik_pantau: asNonEmptyString(item.metrik_pantau) ?? undefined,
}))
.filter((item) => item.aksi.length > 0),
insight: asNonEmptyString(parsed.insight) ?? undefined,
};
};
const parseEndCycleFromText = (
text: string,
fallbackSummary?: string | null
): EndCycleStructuredInsight | null => {
if (!text) return null;
if (text.includes(END_CYCLE_JSON_MARKER)) {
const raw = text.split(END_CYCLE_JSON_MARKER, 2)[1]?.trim() ?? '';
try {
return normalizeEndCycleInsight(JSON.parse(raw), fallbackSummary);
} catch {
/* fall through */
}
}
const jsonText = extractJsonObject(text);
if (!jsonText) return null;
try {
return normalizeEndCycleInsight(JSON.parse(jsonText), fallbackSummary);
} catch {
return null;
}
};
const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => {
if (!citations || citations.length === 0) return null;
return (
@@ -163,12 +242,14 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
kandangId,
context: contextProp,
dashboardMetrics = null,
iotDayAggregates = [],
page = 'dashboard',
}) => {
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
const [insightText, setInsightText] = useState('');
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | null>(null);
const [citations, setCitations] = useState<InsightCitation[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
@@ -203,10 +284,22 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page;
const applyInsight = useCallback(
(text: string, nextCitations?: InsightCitation[]) => {
(
text: string,
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
structuredEndCycle?: EndCycleStructuredInsight | null,
summary?: string | null
) => {
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
setInsightText(text);
setStructuredInsight(normalizeStructuredInsight(text));
setCitations(nextCitations ?? []);
const endCycle =
structuredEndCycle ??
parseEndCycleFromText(text, summary) ??
(typeof nextCitations === 'string' ? null : null);
setEndCycleInsight(endCycle);
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
setCitations(citationsArg);
},
[]
);
@@ -263,6 +356,22 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung;
}
if (reportType === 'end_cycle' && scopedMetrics) {
const rollups = buildEndCycleRollups(scopedMetrics, iotDayAggregates);
contextPayload.weekly_summaries = rollups.weekly_summaries;
contextPayload.phase_summaries = rollups.phase_summaries;
contextPayload.tren_kpi_harian = rollups.tren_kpi_harian;
contextPayload.mati_ekor = rollups.mati_ekor;
contextPayload.panen_ekor = rollups.panen_ekor;
contextPayload.saldo_ekor = rollups.saldo_ekor;
if (iotDayAggregates.length > 0) {
const latestIot = iotDayAggregates[iotDayAggregates.length - 1]!;
contextPayload.suhu_rata_rata_C = latestIot.avg_temp_C;
contextPayload.kelembapan_persen = latestIot.avg_humidity_pct;
contextPayload.iot_day_aggregates = iotDayAggregates;
}
}
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
if (!forceRefresh) {
@@ -313,7 +422,12 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server');
}
applyInsight(apiResult.insight_text, apiResult.citations);
applyInsight(
apiResult.insight_text,
apiResult.citations,
apiResult.structured_end_cycle ?? null,
apiResult.summary
);
setLastUpdated(new Date());
setDetailsOpen(false);
} catch (err) {
@@ -343,6 +457,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
reportType,
reportPeriod,
scopedMetrics,
iotDayAggregates,
applyInsight,
]
);
@@ -701,6 +816,102 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
Coba lagi
</button>
</div>
) : endCycleInsight ? (
<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">
{endCycleInsight.kesimpulan}
</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-3 flex items-center gap-2">
<i className="fa-solid fa-triangle-exclamation text-amber-600" /> Masalah
</h4>
{renderBulletList(endCycleInsight.masalah, 'Tidak ada masalah kritis dari graded facts')}
</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-3 flex items-center gap-2">
<i className="fa-solid fa-magnifying-glass-chart text-red-600" /> Akar penyebab
</h4>
{endCycleInsight.akar_penyebab.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada kandidat akar penyebab berbukti.</p>
) : (
<ul className="space-y-3">
{endCycleInsight.akar_penyebab.map((item, index) => (
<li
key={`${item.id ?? item.hipotesis}-${index}`}
className="rounded-lg border border-gray-100 bg-gray-50 p-3"
>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-gray-800">{item.hipotesis}</p>
{item.confidence ? (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
confidenceBadge[item.confidence] ?? confidenceBadge.low
}`}
>
{item.confidence}
</span>
) : null}
{item.fase ? (
<span className="text-[10px] uppercase tracking-wide text-gray-500">
{item.fase}
</span>
) : null}
</div>
{item.bukti.length > 0 ? (
<ul className="mt-2 space-y-1">
{item.bukti.map((bukti, buktiIdx) => (
<li key={`${bukti}-${buktiIdx}`} className="text-xs text-gray-600">
— {bukti}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
)}
</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-3 flex items-center gap-2">
<i className="fa-solid fa-clipboard-check text-blue-600" /> Perbaikan siklus
berikutnya
</h4>
{endCycleInsight.perbaikan_siklus_berikutnya.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada checklist perbaikan.</p>
) : (
<ul className="space-y-3">
{endCycleInsight.perbaikan_siklus_berikutnya.map((item, index) => (
<li
key={`${item.id ?? item.aksi}-${index}`}
className="rounded-lg border border-blue-100 bg-blue-50/40 p-3"
>
<p className="text-[10px] font-semibold uppercase tracking-wide text-blue-700">
{item.fase}
</p>
<p className="mt-1 text-sm text-gray-800">{item.aksi}</p>
{item.metrik_pantau ? (
<p className="mt-1 text-xs text-gray-500">
Pantau: {item.metrik_pantau}
</p>
) : null}
</li>
))}
</ul>
)}
{endCycleInsight.insight ? (
<p className="mt-3 text-sm text-gray-600">{endCycleInsight.insight}</p>
) : null}
<CitationsBlock citations={citations} />
</div>
</div>
) : structuredInsight && summary ? (
<div className="space-y-4">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
@@ -778,7 +989,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
</div>
) : insightText ? (
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
{insightText}
{insightText.includes(END_CYCLE_JSON_MARKER)
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
: insightText}
<CitationsBlock citations={citations} />
</div>
) : (