update api configuration for panel iot and adjust ai insight
This commit is contained in:
1 parent
a2b5b10977
commit
20d04acb3e
20 files changed
+814
-177
No files matched your search
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user