adjust ai insight and seed data
This commit is contained in:
1 parent
68e0d1a0bb
commit
a2b5b10977
18 files changed
+1835
-140
No files matched your search
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user