ai insight rework #1

This commit is contained in:
Alberto-Audrix committed 2026-09-29 15:19:10 +07:00
1 parent 0d960c8153
commit 9d76f54d33
13 files changed
+730 -299

No files matched your search

@@ -0,0 +1,49 @@
import type { InsightJobStage } from '../../types/api.ts';
/** Phase 4 pipeline step indicator for async AI Insight generation. */
const STEPS: { stage: InsightJobStage; label: string; icon: string }[] = [
{ stage: 'grading', label: 'Evaluating CP 707 rules...', icon: 'fa-solid fa-scale-balanced' },
{ stage: 'retrieving', label: 'Fetching SOP guidelines...', icon: 'fa-solid fa-book-open' },
{
stage: 'synthesizing',
label: 'Synthesizing recommendations...',
icon: 'fa-solid fa-wand-magic-sparkles',
},
{ stage: 'saving', label: 'Saving insight...', icon: 'fa-solid fa-floppy-disk' },
];
const ORDER: InsightJobStage[] = ['queued', 'starting', ...STEPS.map((s) => s.stage), 'done'];
function stageIndex(stage: InsightJobStage | null): number {
if (!stage) return 0;
const i = ORDER.indexOf(stage);
return i < 0 ? 0 : i;
}
export default function InsightProgressSteps({ stage }: { stage: InsightJobStage | null }) {
const current = stageIndex(stage);
return (
<ol className="space-y-1.5 text-xs text-gray-600" aria-live="polite">
{STEPS.map((step, i) => {
const state = current > i ? 'done' : current === i ? 'active' : 'pending';
return (
<li
key={step.stage}
className={`flex items-center gap-2 ${state === 'pending' ? 'opacity-40' : ''}`}
>
<i
className={`${step.icon} ${
state === 'done' ? 'text-green-600' : 'text-blue-600'
} ${state === 'active' ? 'fa-spin' : ''}`}
aria-hidden="true"
/>
<span>{step.label}</span>
{state === 'done' && (
<i className="fa-solid fa-check text-green-600" aria-hidden="true" />
)}
</li>
);
})}
</ol>
);
}
+215 -221
View File
@@ -7,12 +7,14 @@
* the DB row is the source of truth.
* Distinct from `AiInsightCard` (Dashboard report modes).
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useFarm } from '../../context/FarmContext.tsx';
import { api, errorMessage } from '../../services/apiClient.ts';
import type {
InsightApiResponse,
InsightCitation,
InsightContext,
InsightJobStage,
InsightTopic,
} from '../../types/api.ts';
import { formatRatio } from '../../utils/format.ts';
@@ -28,9 +30,31 @@ import {
import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx';
import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx';
import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
import InsightProgressSteps from './InsightProgressSteps.tsx';
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
/** Phase 4: poll async generate job until done / error / timeout / cancel. */
async function pollInsightJob(
jobId: string,
isCurrent: () => boolean,
setStage: (s: InsightJobStage | null) => void
): Promise<InsightApiResponse | null> {
const deadline = Date.now() + CLIENT_TIMEOUT_MS;
for (;;) {
if (!isCurrent()) return null;
const job = await api.insights.jobStatus(jobId);
if (!isCurrent()) return null;
setStage(job.stage);
if (job.status === 'done') return job.result;
if (job.status === 'error') throw new Error(job.error || 'Gagal menghasilkan AI Insight.');
if (Date.now() > deadline) {
throw new Error('TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.');
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
type PageInsightResult = AiResult & {
citations?: InsightCitation[];
};
@@ -144,6 +168,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const [result, setResult] = useState<PageInsightResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stage, setStage] = useState<InsightJobStage | null>(null);
const supportsTimeframe = Boolean(scalarScope);
const timeframe: 'harian' | 'mingguan' = 'harian';
@@ -157,18 +182,15 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
return null;
}
});
const setSelectedDay = useCallback(
(next: number | null) => {
setSelectedDayState(next);
try {
if (next === null) sessionStorage.removeItem(selectedDayKey);
else sessionStorage.setItem(selectedDayKey, String(next));
} catch {
/* ignore */
}
},
[selectedDayKey]
);
const setSelectedDay = (next: number | null) => {
setSelectedDayState(next);
try {
if (next === null) sessionStorage.removeItem(selectedDayKey);
else sessionStorage.setItem(selectedDayKey, String(next));
} catch {
/* ignore */
}
};
const availableDays = React.useMemo(() => {
const key =
@@ -308,7 +330,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
label: 'FCR Aktual',
value: formatRatio(actual, 3),
unit: undefined,
context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null,
context: std
? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}`
: null,
status: (std === null
? 'unknown'
: actual <= std
@@ -353,9 +377,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const hi = maxTemp ?? lastTemp!;
// Grade against the farther extreme from setpoint so a bad stretch that day surfaces.
const worstDiff =
setpoint === null
? null
: Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
setpoint === null ? null : Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
const signedWorst =
setpoint === null
? null
@@ -412,9 +434,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
value: Math.round(actual).toLocaleString('id-ID'),
unit: 'ekor',
context:
awal === null
? null
: `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
awal === null ? null : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
status: (mortPct === null
? 'unknown'
: mortPct < 5
@@ -434,221 +454,192 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
const generate = useCallback(
async (force = false) => {
const storageKey = `page-insight-v1-${scopeKey}`;
const requestId = ++requestIdRef.current;
const isCurrent = () => requestIdRef.current === requestId;
const generate = async (force = false) => {
const storageKey = `page-insight-v1-${scopeKey}`;
const requestId = ++requestIdRef.current;
const isCurrent = () => requestIdRef.current === requestId;
// Soft load: share one in-flight DB GET for the same scope (no client content cache).
if (!force && _insightInFlight.has(storageKey)) {
const shared = await _insightInFlight.get(storageKey);
if (!isCurrent()) return;
setResult(shared ?? null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
}
// Soft load: share one in-flight DB GET for the same scope (no client content cache).
if (!force && _insightInFlight.has(storageKey)) {
const shared = await _insightInFlight.get(storageKey);
if (!isCurrent()) return;
setResult(shared ?? null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
}
if (inFlightRef.current && !force) return;
inFlightRef.current = true;
// Only show the LLM loading UI when the user clicks Generate.
if (force) {
setLoading(true);
setResult(null);
}
setError(null);
if (inFlightRef.current && !force) return;
inFlightRef.current = true;
// Only show the LLM loading UI when the user clicks Generate.
if (force) {
setLoading(true);
setResult(null);
}
setError(null);
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
try {
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
throw new Error(
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
);
}
if (!selectedCycle?.id) {
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
}
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
try {
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
throw new Error(
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
);
}
if (!selectedCycle?.id) {
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
}
const rawCtx = contextData as Record<string, unknown>;
const kandangId = requireKandangId({
...rawCtx,
kandangId: rawCtx.kandangId ?? selectedKandangId,
});
const rawCtx = contextData as Record<string, unknown>;
const kandangId = requireKandangId({
...rawCtx,
kandangId: rawCtx.kandangId ?? selectedKandangId,
});
const rawCtxDay =
effectiveDay ??
rawCtx.hari_ke ??
rawCtx.hari_terakhir ??
rawCtx.currentDay ??
currentDay;
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
const currentDayVal =
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
const rawCtxDay =
effectiveDay ?? rawCtx.hari_ke ?? rawCtx.hari_terakhir ?? rawCtx.currentDay ?? currentDay;
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
const currentDayVal =
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
const scopedContextData = scalarScope
? scopeScalarsToTimeframe(
slicedContextData as Record<string, unknown>,
timeframe,
scalarScope,
effectiveDay
)
: slicedContextData;
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
const scopedContextData = scalarScope
? scopeScalarsToTimeframe(
slicedContextData as Record<string, unknown>,
timeframe,
scalarScope,
effectiveDay
)
: slicedContextData;
const dataPeriode = (scopedContextData as Record<string, unknown>)?.periode;
// Prefer explicit day-0..N wording when a day is selected (matches dashboard reports).
const timeframeLabel =
effectiveDay !== null
? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay)
: !supportsTimeframe
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
? dataPeriode
: describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay);
const dataPeriode = (scopedContextData as Record<string, unknown>)?.periode;
// Prefer explicit day-0..N wording when a day is selected (matches dashboard reports).
const timeframeLabel =
effectiveDay !== null
? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay)
: !supportsTimeframe
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
? dataPeriode
: describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay);
const reportPeriod =
timeframe === 'harian'
? effectiveDay !== null
? `Hari ${effectiveDay}`
: currentDayVal
? `Hari ${currentDayVal}`
: null
: null;
const reportPeriod =
timeframe === 'harian'
? effectiveDay !== null
? `Hari ${effectiveDay}`
: currentDayVal
? `Hari ${currentDayVal}`
: null
: null;
const contextPayload: InsightContext = {
...(scopedContextData as InsightContext),
kandangId,
kandangName:
(rawCtx.kandangName as string | undefined) ??
selectedKandang?.kandang_name ??
undefined,
cycleId: selectedCycle.id,
hari_ke:
typeof effectiveDay === 'number'
? effectiveDay
: typeof currentDayVal === 'number'
? currentDayVal
: null,
totalDays: maxDays ?? null,
periode: timeframeLabel,
};
const contextPayload: InsightContext = {
...(scopedContextData as InsightContext),
kandangId,
kandangName:
(rawCtx.kandangName as string | undefined) ??
selectedKandang?.kandang_name ??
undefined,
cycleId: selectedCycle.id,
hari_ke:
typeof effectiveDay === 'number'
? effectiveDay
: typeof currentDayVal === 'number'
? currentDayVal
: null,
totalDays: maxDays ?? null,
periode: timeframeLabel,
};
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
if (!force) {
try {
apiResult = await api.insights.cached({
cycle_id: selectedCycle.id,
kandang_id: kandangId,
topic,
report_type: 'page',
report_period: reportPeriod,
});
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
} catch {
apiResult = null;
}
// Manual-generate only: never POST generate on mount / soft refresh.
if (!apiResult) {
if (!isCurrent()) return null;
setResult(null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return null;
}
} else {
const generateCall = api.insights.generate({
if (!force) {
try {
apiResult = await api.insights.cached({
cycle_id: selectedCycle.id,
kandang_id: kandangId,
topic,
report_type: 'page',
report_period: reportPeriod,
context: contextPayload,
force_refresh: true,
});
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
'TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.'
)
),
CLIENT_TIMEOUT_MS
)
);
apiResult = await Promise.race([generateCall, timeoutPromise]);
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
} catch {
apiResult = null;
}
if (!isCurrent()) return null;
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
}
const text = apiResult.insight_text ?? '';
const parsed =
apiResult.summary || apiResult.insight
? {
summary: apiResult.summary || '',
insight: apiResult.insight || '',
}
: parseAiResult(text);
if (!parsed || (!parsed.summary && !parsed.insight)) {
throw new Error('Respons AI tidak dapat diproses.');
}
const wrapped: PageInsightResult = {
summary: parsed.summary,
insight: parsed.insight,
citations: apiResult.citations,
};
if (!isCurrent()) return wrapped;
setResult(wrapped);
lastCacheKeyRef.current = scopeKey;
return wrapped;
} catch (err) {
if (!isCurrent()) return null;
console.error(`[PageAiInsight:${topic}] request failed:`, err);
// Soft DB misses/errors should not look like a failed generate.
if (force) {
setError(errorMessage(err));
// Manual-generate only: never POST generate on mount / soft refresh.
if (!apiResult) {
if (!isCurrent()) return null;
setResult(null);
} else {
setResult(null);
}
return null;
} finally {
if (isCurrent()) {
setLoading(false);
inFlightRef.current = false;
lastCacheKeyRef.current = scopeKey;
return null;
}
_insightInFlight.delete(storageKey);
} else {
setStage(null);
const job = await api.insights.generateAsync({
cycle_id: selectedCycle.id,
kandang_id: kandangId,
topic,
report_type: 'page',
report_period: reportPeriod,
context: contextPayload,
force_refresh: true,
});
apiResult = await pollInsightJob(job.job_id, isCurrent, setStage);
}
})();
if (!force) {
_insightInFlight.set(storageKey, fetchPromise);
if (!isCurrent()) return null;
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
}
const text = apiResult.insight_text ?? '';
const parsed =
apiResult.summary || apiResult.insight
? {
summary: apiResult.summary || '',
insight: apiResult.insight || '',
}
: parseAiResult(text);
if (!parsed || (!parsed.summary && !parsed.insight)) {
throw new Error('Respons AI tidak dapat diproses.');
}
const wrapped: PageInsightResult = {
summary: parsed.summary,
insight: parsed.insight,
citations: apiResult.citations,
};
if (!isCurrent()) return wrapped;
setResult(wrapped);
lastCacheKeyRef.current = scopeKey;
return wrapped;
} catch (err) {
if (!isCurrent()) return null;
console.error(`[PageAiInsight:${topic}] request failed:`, err);
// Soft DB misses/errors should not look like a failed generate.
if (force) {
setError(errorMessage(err));
setResult(null);
} else {
setResult(null);
}
return null;
} finally {
if (isCurrent()) {
setLoading(false);
inFlightRef.current = false;
}
_insightInFlight.delete(storageKey);
}
await fetchPromise;
},
[
topic,
contextData,
selectedCycle,
selectedKandang,
selectedKandangId,
currentDay,
timeframe,
effectiveDay,
scalarScope,
supportsTimeframe,
scopeKey,
]
);
})();
if (!force) {
_insightInFlight.set(storageKey, fetchPromise);
}
await fetchPromise;
};
useEffect(() => {
desiredKeyRef.current = scopeKey;
@@ -679,7 +670,11 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
<p className="text-xs text-gray-400 truncate">
{selectedKandang?.kandang_name ?? 'Kandang'}
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
{effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}
{effectiveDay != null
? ` · Hari ke-${effectiveDay}`
: currentDay != null
? ` · Hari ke-${currentDay}`
: ''}
</p>
</div>
</div>
@@ -725,6 +720,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
{loading && !result && (
<div className="flex flex-col items-center gap-3 py-8 justify-center">
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
<InsightProgressSteps stage={stage} />
<p className="text-sm text-gray-500 text-center max-w-sm">
Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.
</p>
@@ -870,7 +866,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
</span>
</div>
<div className="flex justify-between items-center py-1 border-b border-gray-50">
<span className="text-sm text-gray-600 font-medium">Umur Rata-rata Panen</span>
<span className="text-sm text-gray-600 font-medium">
Umur Rata-rata Panen
</span>
<span className="text-sm font-bold text-gray-900">
{typeof displayContext.umur_panen_rata_hari === 'number'
? `${displayContext.umur_panen_rata_hari.toLocaleString('id-ID', {
@@ -888,10 +886,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
<span className="text-sm text-gray-600 font-medium">Experience Suhu</span>
<span className="text-sm font-bold text-gray-900">
{renderDayRange(
displayContext.experience_suhu_min_C ??
displayContext.experience_suhu_C,
displayContext.experience_suhu_max_C ??
displayContext.experience_suhu_C,
displayContext.experience_suhu_min_C ?? displayContext.experience_suhu_C,
displayContext.experience_suhu_max_C ?? displayContext.experience_suhu_C,
'°C'
)}
</span>
@@ -900,10 +896,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
<span className="text-sm font-bold text-gray-900">
{renderDayRange(
displayContext.kelembapan_min_persen ??
displayContext.kelembapan_persen,
displayContext.kelembapan_max_persen ??
displayContext.kelembapan_persen,
displayContext.kelembapan_min_persen ?? displayContext.kelembapan_persen,
displayContext.kelembapan_max_persen ?? displayContext.kelembapan_persen,
'%'
)}
</span>