update api configuration for panel iot and adjust ai insight

This commit is contained in:
Alberto-Audrix committed 2026-09-18 16:06:41 +07:00
1 parent a2b5b10977
commit 20d04acb3e
20 files changed
+814 -177

No files matched your search

+128 -90
View File
@@ -1,10 +1,11 @@
/**
* Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …).
*
* Builds/scopes context, then loads cached insight on mount.
* LLM generate runs only when the user clicks Generate (force=true).
* Distinct from
* `AiInsightCard` (Dashboard report modes). See docs/ai-insight/README.md.
* Builds/scopes context, then loads the insight from the DB on mount
* (`GET /ai-insights/cached/`). LLM generate runs only when the user clicks
* Generate (force=true). No client memory/sessionStorage for insight text —
* the DB row is the source of truth.
* Distinct from `AiInsightCard` (Dashboard report modes).
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useFarm } from '../../context/FarmContext.tsx';
@@ -30,13 +31,13 @@ import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
const _insightMemoryCache = new Map<string, CachedPageInsight>();
const _insightInFlight = new Map<string, Promise<void>>();
type CachedPageInsight = AiResult & {
type PageInsightResult = AiResult & {
citations?: InsightCitation[];
};
/** Dedupes concurrent soft DB loads for the same scope (not a content cache). */
const _insightInFlight = new Map<string, Promise<PageInsightResult | null>>();
export type { InsightTopic };
export interface PageAiInsightProps {
@@ -73,6 +74,20 @@ const renderValue = (val: unknown, suffix: string = ''): string => {
return `${val} ${suffix}`.trim();
};
/** Match FeedSaldoCard: masuk IOT + saldo awal. */
const feedMasukTotal = (ctx: Record<string, unknown>): number | null => {
const masuk =
typeof ctx.total_karung_masuk_iot === 'number' && Number.isFinite(ctx.total_karung_masuk_iot)
? ctx.total_karung_masuk_iot
: null;
const saldo =
typeof ctx.saldo_awal_karung === 'number' && Number.isFinite(ctx.saldo_awal_karung)
? ctx.saldo_awal_karung
: null;
if (masuk === null && saldo === null) return null;
return (masuk ?? 0) + (saldo ?? 0);
};
const requireKandangId = (ctx: Record<string, unknown>): number => {
const raw = ctx.kandangId ?? ctx.kandang_id;
const id = typeof raw === 'number' ? raw : Number(raw);
@@ -82,15 +97,6 @@ const requireKandangId = (ctx: Record<string, unknown>): number => {
return id;
};
const _saveToCache = (storageKey: string, result: CachedPageInsight): void => {
_insightMemoryCache.set(storageKey, result);
try {
sessionStorage.setItem(storageKey, JSON.stringify(result));
} catch {
/* ignore */
}
};
const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => {
if (!citations || citations.length === 0) return null;
return (
@@ -121,7 +127,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}) => {
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
const [result, setResult] = useState<CachedPageInsight | null>(null);
const [result, setResult] = useState<PageInsightResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const supportsTimeframe = Boolean(scalarScope);
@@ -350,8 +356,10 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
if (actual === null) return null;
const manual = n(displayContext.total_karung_dituang_manual);
const perDay = n(displayContext.rata_karung_per_hari);
const masukTotal = feedMasukTotal(displayContext);
const parts = [
manual === null ? null : `Manual: ${manual.toLocaleString('id-ID')} karung`,
masukTotal === null ? null : `Masuk: ${masukTotal.toLocaleString('id-ID')} karung`,
manual === null ? null : `Manual dituang: ${manual.toLocaleString('id-ID')} karung`,
perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`,
].filter(Boolean);
return {
@@ -364,6 +372,33 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
};
}
if (topic === 'hitung_ayam') {
const actual = n(displayContext.populasi_kini_ekor);
if (actual === null) return null;
const awal = n(displayContext.populasi_awal_ekor);
const mortPct = n(displayContext.mortalitas_persen);
return {
label: 'Populasi Kini',
value: Math.round(actual).toLocaleString('id-ID'),
unit: 'ekor',
context:
awal === null
? null
: `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
status: (mortPct === null
? 'unknown'
: mortPct < 5
? 'ok'
: mortPct <= 7
? 'warning'
: 'critical') as InsightStatus,
deltaLabel:
mortPct === null
? null
: `Mortalitas ${mortPct.toLocaleString('id-ID', { maximumFractionDigits: 2 })}%`,
};
}
return null;
}, [topic, displayContext, currentDay, effectiveDay]);
@@ -375,68 +410,16 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const requestId = ++requestIdRef.current;
const isCurrent = () => requestIdRef.current === requestId;
if (force) {
_insightMemoryCache.delete(storageKey);
try {
sessionStorage.removeItem(storageKey);
} catch {
/* ignore */
}
}
if (!force) {
const memCached = _insightMemoryCache.get(storageKey);
if (memCached) {
if (!isCurrent()) return;
setResult(memCached);
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)) {
await _insightInFlight.get(storageKey);
const shared = await _insightInFlight.get(storageKey);
if (!isCurrent()) return;
const memCached = _insightMemoryCache.get(storageKey);
if (memCached) {
setResult(memCached);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
}
setResult(shared ?? null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
}
if (!force) {
try {
const raw = sessionStorage.getItem(storageKey);
if (raw) {
const parsedJson = JSON.parse(raw) as CachedPageInsight;
if (parsedJson?.summary || parsedJson?.insight) {
_insightMemoryCache.set(storageKey, parsedJson);
if (!isCurrent()) return;
setResult(parsedJson);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
}
const parsed = parseAiResult(raw);
if (parsed) {
const wrapped: CachedPageInsight = { ...parsed };
_insightMemoryCache.set(storageKey, wrapped);
if (!isCurrent()) return;
setResult(wrapped);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
}
sessionStorage.removeItem(storageKey);
}
} catch {
/* ignore */
}
}
if (inFlightRef.current && !force) return;
inFlightRef.current = true;
// Only show the LLM loading UI when the user clicks Generate.
@@ -446,7 +429,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}
setError(null);
const fetchPromise = (async () => {
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
try {
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
throw new Error(
@@ -511,7 +494,12 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
selectedKandang?.kandang_name ??
undefined,
cycleId: selectedCycle.id,
hari_ke: typeof effectiveDay === 'number' ? effectiveDay : typeof currentDayVal === 'number' ? currentDayVal : null,
hari_ke:
typeof effectiveDay === 'number'
? effectiveDay
: typeof currentDayVal === 'number'
? currentDayVal
: null,
totalDays: maxDays ?? null,
periode: timeframeLabel,
};
@@ -533,11 +521,11 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}
// Manual-generate only: never POST generate on mount / soft refresh.
if (!apiResult) {
if (!isCurrent()) return;
if (!isCurrent()) return null;
setResult(null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
return null;
}
} else {
const generateCall = api.insights.generate({
@@ -563,7 +551,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
apiResult = await Promise.race([generateCall, timeoutPromise]);
}
if (!isCurrent()) return;
if (!isCurrent()) return null;
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
@@ -582,26 +570,27 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
throw new Error('Respons AI tidak dapat diproses.');
}
const wrapped: CachedPageInsight = {
const wrapped: PageInsightResult = {
summary: parsed.summary,
insight: parsed.insight,
citations: apiResult.citations,
};
_saveToCache(storageKey, wrapped);
if (!isCurrent()) return;
if (!isCurrent()) return wrapped;
setResult(wrapped);
lastCacheKeyRef.current = scopeKey;
return wrapped;
} catch (err) {
if (!isCurrent()) return;
if (!isCurrent()) return null;
console.error(`[PageAiInsight:${topic}] request failed:`, err);
// Soft cache misses/errors should not look like a failed generate.
// 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);
@@ -611,7 +600,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}
})();
_insightInFlight.set(storageKey, fetchPromise);
if (!force) {
_insightInFlight.set(storageKey, fetchPromise);
}
await fetchPromise;
},
[
@@ -634,7 +625,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
generateRef.current = generate;
});
// Day / scope change: drop previous insight immediately and only soft-load cache (no generate UI).
// Day / scope change: clear UI and soft-load from DB (no generate).
useEffect(() => {
if (lastCacheKeyRef.current === scopeKey) return;
requestIdRef.current += 1;
@@ -865,9 +856,56 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
{topic === 'hitung_karung' && (
<>
<div className="flex justify-between items-center py-1 border-b border-gray-50">
<span className="text-sm text-gray-600 font-medium">Saldo Awal Pakan</span>
<span className="text-sm text-gray-600 font-medium">Total Karung Masuk</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.saldo_awal_karung, 'Karung')}
{renderValue(feedMasukTotal(displayContext), 'Karung')}
</span>
</div>
</>
)}
{topic === 'hitung_ayam' && (
<>
<div className="flex justify-between items-center py-1 border-b border-gray-50">
<span className="text-sm text-gray-600 font-medium">Populasi Awal</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.populasi_awal_ekor, 'Ekor')}
</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">Populasi Kini</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.populasi_kini_ekor, 'Ekor')}
</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">
Mortalitas Kumulatif
</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.mortalitas_kumulatif_ekor, 'Ekor')}
</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">Mortalitas</span>
<span className="text-sm font-bold text-gray-900">
{typeof displayContext.mortalitas_persen === 'number'
? `${displayContext.mortalitas_persen.toLocaleString('id-ID', {
maximumFractionDigits: 2,
})} %`
: renderValue(displayContext.mortalitas_persen, '%')}
</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">Panen Kumulatif</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.panen_kumulatif_ekor, 'Ekor')}
</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">Berat Panen</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.berat_panen_kumulatif_kg, 'kg')}
</span>
</div>
</>