update ai insight to give reasoning for the insight
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-27 14:26:19 +07:00
1 parent 34336b9bd1
commit 333b1cf370
16 files changed
+2079 -130

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
/**
* Cross-domain evidence for the AI Insight cards.
*
* A page's own payload can only ever say WHAT happened on that page — "mortalitas
* 9,1%, di atas standar". Answering WHY needs the other domains: the temperature
* on the days the deaths spiked, the feed intake that fell first, the growth that
* stalled. This module gathers those series once per cycle and hands them to the
* card as a `diagnostik` bundle.
*
* The correlation itself is NOT done here — `backend/services/rootCauseAnalysis.js`
* does it, so the dashboard path and every page path reach the same conclusions
* from the same code. This file only collects and normalises.
*/
import { apiClient } from '../services/apiService.ts';
export interface DiagnostikBundle {
hari_ke: number | null;
total_hari?: number | null;
populasi_awal_ekor: number | null;
populasi_kini_ekor?: number | null;
keseragaman_persen?: number | null;
mortalitas_harian: Array<{ hari: number; mati: number; afkir: number; panen?: number }>;
bobot_harian: Array<{ hari: number; aktual_gram: number; target_gram: number | null }>;
fcr_harian: Array<{ hari: number; aktual: number; standar: number | null }>;
pakan_harian: Array<{ hari: number; karung: number }>;
lingkungan_harian: Array<{ hari: number; suhu_C: number; kelembapan_persen: number }>;
}
export interface DiagnostikInput {
cycleId: string | null | undefined;
/**
* External coop UUID for the scales/sensor APIs.
*
* `getMultiCoopWeightData` returns null without it, so with "semua kandang"
* selected the weight/feed/FCR series are simply absent — which the analysis
* reports as missing data rather than as zeros.
*/
cageUuid: string | null | undefined;
/** Internal kandang id, for scoping the mortality ledger on our own backend. */
kandangId?: number | null;
startDate: Date | string | null | undefined;
currentDay: number | null | undefined;
totalDays?: number | null;
populasiAwal?: number | null;
populasiKini?: number | null;
keseragaman?: number | null;
apiMode?: 'LIVE' | 'DEMO';
/**
* The mortality ledger, when the caller already has it.
*
* It is the authoritative record of deaths, culls and harvest — the KPI feed
* carries deaths only, and reading a population drop as deaths is what once
* made the counting page announce "mortalitas 100%".
*/
mortalityRows?: Array<{
day?: number | null;
mortalityCount?: number | null;
afkir?: number | null;
panen?: number | null;
}> | null;
}
const toNum = (value: unknown): number | null => {
const parsed = typeof value === 'string' ? Number.parseFloat(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null;
};
const isoDate = (date: Date): string => {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
};
/**
* One fetch per cycle-day, shared by every card on every page.
*
* Six insight cards mounting in one session would otherwise pull the whole
* cycle's sensor history six times. The day is part of the key so the data still
* refreshes when the cycle moves on.
*/
const _bundleCache = new Map<string, Promise<DiagnostikBundle | null>>();
const buildBundle = async (input: DiagnostikInput): Promise<DiagnostikBundle | null> => {
const currentDay = toNum(input.currentDay);
const start = input.startDate ? new Date(input.startDate) : null;
const mode = input.apiMode ?? 'LIVE';
// Both sources are optional: a missing one must degrade to "data tidak
// tersedia" in the insight, never block the card from generating.
// The ledger is fetched here when the caller has none of its own. It is the
// only source that separates deaths from culls and from harvest, and it works
// for "semua kandang" too — unlike the KPI feed, which needs a cage UUID.
const needsLedger = !Array.isArray(input.mortalityRows) || input.mortalityRows.length === 0;
const [kpiResult, envResult, ledgerResult] = await Promise.allSettled([
apiClient.getMultiCoopWeightData(mode, input.cageUuid ?? null),
start && Number.isFinite(start.getTime())
? apiClient.getIotDailyEnvironment(isoDate(start), isoDate(new Date()), mode)
: Promise.resolve([]),
needsLedger && input.cycleId
? apiClient.getMortalityRecords(input.cycleId, input.kandangId ?? undefined)
: Promise.resolve([]),
]);
const kpiRows: Record<string, Record<string, unknown>> = kpiResult.status === 'fulfilled' &&
kpiResult.value &&
typeof kpiResult.value === 'object'
? (kpiResult.value as Record<string, Record<string, unknown>>)
: {};
const envRows =
envResult.status === 'fulfilled' && Array.isArray(envResult.value) ? envResult.value : [];
if (kpiResult.status === 'rejected') {
console.warn('[insightEvidence] KPI harian tidak terambil:', kpiResult.reason);
}
if (envResult.status === 'rejected') {
console.warn('[insightEvidence] Riwayat lingkungan tidak terambil:', envResult.reason);
}
if (ledgerResult.status === 'rejected') {
console.warn('[insightEvidence] Catatan mortalitas tidak terambil:', ledgerResult.reason);
}
const mortalitas_harian: DiagnostikBundle['mortalitas_harian'] = [];
const bobot_harian: DiagnostikBundle['bobot_harian'] = [];
const fcr_harian: DiagnostikBundle['fcr_harian'] = [];
const pakan_harian: DiagnostikBundle['pakan_harian'] = [];
for (const [key, row] of Object.entries(kpiRows)) {
const index = Number.parseInt(key, 10);
if (!Number.isFinite(index) || !row || typeof row !== 'object') continue;
// The KPI feed is keyed 0-based; every other series in the app counts days
// from 1, and mixing the two shifts every correlation by a day.
const hari = index + 1;
const mati = toNum(row.mortality);
if (mati !== null && mati >= 0) mortalitas_harian.push({ hari, mati, afkir: 0 });
const bobot = toNum(row.iot_weight) ?? toNum(row.manual_weight);
if (bobot !== null && bobot > 0) {
bobot_harian.push({ hari, aktual_gram: bobot, target_gram: toNum(row.ref_weight) });
}
const fcr = toNum(row.iot_fcr) ?? toNum(row.manual_fcr);
if (fcr !== null && fcr > 0) fcr_harian.push({ hari, aktual: fcr, standar: null });
const karung = toNum(row.food);
if (karung !== null && karung >= 0) pakan_harian.push({ hari, karung });
}
// The ledger wins over the KPI feed either way: it separates deaths from culls
// and from harvest, which the KPI feed does not.
const fetchedLedger =
ledgerResult.status === 'fulfilled' && Array.isArray(ledgerResult.value)
? (ledgerResult.value as DiagnostikInput['mortalityRows'])
: [];
const ledger =
!needsLedger && Array.isArray(input.mortalityRows)
? input.mortalityRows
: (fetchedLedger ?? []);
const ledgerRows = ledger
.map((row) => ({
hari: toNum(row.day),
mati: toNum(row.mortalityCount) ?? 0,
afkir: toNum(row.afkir) ?? 0,
panen: toNum(row.panen) ?? 0,
}))
.filter(
(row): row is { hari: number; mati: number; afkir: number; panen: number } =>
row.hari !== null
);
const sortByDay = <T extends { hari: number }>(rows: T[]) => rows.sort((a, b) => a.hari - b.hari);
const bundle: DiagnostikBundle = {
hari_ke: currentDay,
total_hari: toNum(input.totalDays),
populasi_awal_ekor: toNum(input.populasiAwal),
populasi_kini_ekor: toNum(input.populasiKini),
keseragaman_persen: toNum(input.keseragaman),
mortalitas_harian: sortByDay(ledgerRows.length > 0 ? ledgerRows : mortalitas_harian),
bobot_harian: sortByDay(bobot_harian),
fcr_harian: sortByDay(fcr_harian),
pakan_harian: sortByDay(pakan_harian),
lingkungan_harian: sortByDay(
envRows.map((row) => ({
hari: row.hari,
suhu_C: row.suhu_C,
kelembapan_persen: row.kelembapan_persen,
}))
),
};
const hasAnySeries =
bundle.mortalitas_harian.length > 0 ||
bundle.bobot_harian.length > 0 ||
bundle.pakan_harian.length > 0 ||
bundle.lingkungan_harian.length > 0;
return hasAnySeries ? bundle : null;
};
/**
* The `diagnostik` bundle for a cycle, or null when nothing could be gathered.
*
* Never throws: a card that cannot get its evidence still has to render its
* insight, only without the causal section.
*/
export const getDiagnostikBundle = async (
input: DiagnostikInput
): Promise<DiagnostikBundle | null> => {
if (!input.cycleId) return null;
const key = `${input.cycleId}|${input.cageUuid ?? 'all'}|${input.kandangId ?? 'all'}|${input.currentDay ?? '?'}`;
const cached = _bundleCache.get(key);
if (cached) return cached;
const promise = buildBundle(input).catch((error) => {
console.warn('[insightEvidence] Gagal menyusun bukti diagnostik:', error);
_bundleCache.delete(key);
return null;
});
_bundleCache.set(key, promise);
return promise;
};
/** Same bundle, re-scoped to a chosen cycle day so a report for day 20 never cites day 30. */
export const scopeDiagnostikToDay = (
bundle: DiagnostikBundle | null,
day: number | null
): DiagnostikBundle | null => {
if (!bundle) return null;
if (day === null || !Number.isFinite(day)) return bundle;
const cut = <T extends { hari: number }>(rows: T[]) => rows.filter((row) => row.hari <= day);
return {
...bundle,
hari_ke: day,
mortalitas_harian: cut(bundle.mortalitas_harian),
bobot_harian: cut(bundle.bobot_harian),
fcr_harian: cut(bundle.fcr_harian),
pakan_harian: cut(bundle.pakan_harian),
lingkungan_harian: cut(bundle.lingkungan_harian),
};
};