/** * Narrows a per-page insight's context data down to the period the user picked. * * The per-page AI Insight used to send the whole cycle to the model no matter * which timeframe was selected, and only changed a label in the prompt — so * "Harian" and "Mingguan" produced near-identical answers. This trims the data * itself, which is the only thing the model actually reads. */ export type InsightTimeframe = 'harian' | 'mingguan'; /** * Rows of a day-indexed history carry a day number and/or a date. * * Pages are inconsistent about the field name: the dashboard builds `day`/`date` * while the FCR and EEF pages build `hari`/`tanggal`. Recognising only the * English spelling meant those two pages' histories were never sliced at all — * their timeframe selector changed nothing. */ type DayRow = Record & { day?: unknown; hari?: unknown; date?: unknown; tanggal?: unknown; }; const DAY_KEYS = ['day', 'hari'] as const; const DATE_KEYS = ['date', 'tanggal'] as const; const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const dayOf = (row: DayRow): number | null => { for (const key of DAY_KEYS) { const value = row[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; } return null; }; const hasDateField = (row: DayRow): boolean => DATE_KEYS.some((key) => typeof row[key] === 'string'); /** * A history indexed by time, as opposed to a snapshot list (cameras, devices, * coops) that has no time dimension. Only the former may be sliced by period — * trimming a device list to "today" would silently hide devices. */ const isDayIndexedArray = (value: unknown[]): value is DayRow[] => value.length > 0 && value.every( (item) => isPlainObject(item) && (dayOf(item as DayRow) !== null || hasDateField(item as DayRow)) ); const sliceHistory = ( rows: DayRow[], timeframe: InsightTimeframe, selectedDay: number | null = null ): DayRow[] => { const days = rows.map(dayOf).filter((day): day is number => day !== null); if (timeframe === 'harian') { if (days.length > 0) { // Picking a day means "the cycle so far, up to that day" — day 0 through // day N — not that day in isolation. A single row shows no trend and // cannot support a cumulative figure like mortality-to-date. if (selectedDay !== null) return rows.filter((row) => (dayOf(row) ?? Infinity) <= selectedDay); return rows.filter((row) => dayOf(row) === Math.max(...days)); } // Date-only history: the array position is the only ordering we have. if (selectedDay !== null) return rows.slice(0, selectedDay + 1); return rows.slice(-1); } if (days.length > 0) { const latestDay = Math.max(...days); // The 7-day window ending today, matched on each row's own `day` so gaps in // the data do not shift the window. return rows.filter((row) => { const day = dayOf(row); return day !== null && day > latestDay - 7 && day <= latestDay; }); } return rows.slice(-7); }; /** * Returns a copy of `contextData` where every day-indexed history is trimmed to * `timeframe`. Snapshot lists, scalars, and nested objects are preserved. */ export const scopeContextToTimeframe = ( contextData: T, timeframe: InsightTimeframe, selectedDay: number | null = null ): T => { if (Array.isArray(contextData)) { const scoped = isDayIndexedArray(contextData) ? sliceHistory(contextData as DayRow[], timeframe, selectedDay) : contextData; return scoped.map((item) => scopeContextToTimeframe(item, timeframe, selectedDay) ) as unknown as T; } if (isPlainObject(contextData)) { const scoped: Record = {}; for (const [key, value] of Object.entries(contextData)) { scoped[key] = scopeContextToTimeframe(value, timeframe, selectedDay); } return scoped as unknown as T; } return contextData; }; export const UNAVAILABLE = 'tidak tersedia'; /** * How one page's summary scalars relate to its day-indexed history. * * `derive` maps an OUTPUT key — which must carry its unit, e.g. * `bobotRataRata_gram` — to the history field it is computed from and how to * aggregate it across the window. `drop` lists the original unitless keys the * derived ones replace; leaving them in would hand the model both the stale * figure and the period one and let it pick. `unavailable` names scalars with * no day-indexed source at all: they are marked rather than left showing a * value from a different period. */ export type ScalarScopeSpec = { historyKey: string; /** * `replaces` names the original key this derived value stands in for. The * prompt drops the original, but the card's summary grid still renders it — * without the link the grid kept showing cycle-to-date figures beside a * narrative scoped to one period, which reads as the toggle doing nothing. */ derive: Record< string, { field: string; /** * `last` takes the final row verbatim. `lastValid` walks backwards past * zeros — use it for readings where 0 means "the sensor sent nothing", * not "the measurement was zero". The IoT scale stopped reporting at day * 38 and has written 0 every day since, so `last` handed the card * "Rata-rata Bobot 0 kg" while the page beside it showed the real figure. */ agg: 'mean' | 'sum' | 'last' | 'lastValid'; replaces?: string; } >; drop?: string[]; unavailable?: string[]; }; const round2 = (value: number) => Math.round(value * 100) / 100; const aggregate = (rows: DayRow[], field: string, agg: 'mean' | 'sum' | 'last' | 'lastValid') => { const values = rows .map((row) => row[field]) .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); if (values.length === 0) return null; if (agg === 'lastValid') { // Mirrors `getLatestValid` on the FCR/EEF pages, so the insight card and // the page it sits on cannot report different numbers. All-zero means the // reading is missing, which has to surface as "tidak tersedia" rather than // a confident 0. for (let i = values.length - 1; i >= 0; i--) { const value = values[i]; if (value !== undefined && value !== 0) return value; } return null; } if (agg === 'last') return values[values.length - 1] ?? null; const total = values.reduce((sum, value) => sum + value, 0); return round2(agg === 'sum' ? total : total / values.length); }; /** * Bring a per-page context's scalars into the same period as its history. * * Slicing the history alone is not enough: a "Mingguan" payload whose chart * covers days 42-48 while `averageWeight` still holds the day-48 figure lets * the model quote either, which is the exact contradiction already fixed on the * dashboard. Run this AFTER `scopeContextToTimeframe`, on the sliced context. */ export const scopeScalarsToTimeframe = ( scopedContext: Record, timeframe: InsightTimeframe, spec: ScalarScopeSpec ): Record => { const history = scopedContext[spec.historyKey]; if (!Array.isArray(history)) return scopedContext; const rows = history as DayRow[]; const out: Record = { ...scopedContext }; for (const key of spec.drop ?? []) delete out[key]; const days = rows.map(dayOf).filter((day): day is number => day !== null); // A window covering more than one day must be named as a range, or the model // reads "hari ke-30" and reports a 31-day cumulative figure as one day's. const label = days.length === 0 ? UNAVAILABLE : Math.min(...days) === Math.max(...days) ? `hari ke-${Math.max(...days)}` : `hari ke-${Math.min(...days)} s/d ke-${Math.max(...days)}`; for (const [outKey, rule] of Object.entries(spec.derive)) { const value = aggregate(rows, rule.field, rule.agg); out[outKey] = value === null ? UNAVAILABLE : value; } for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE; out.periode = label; // Only the multi-day window needs the "not today" warning; saying it in daily // mode contradicts the label directly above it. // Only a window that is not today needs the "not today" warning; saying it in // a single-day-today report contradicts the label directly above it. const satuHariTerakhir = timeframe === 'harian' && days.length > 0 && Math.min(...days) === Math.max(...days); const bukanHariIni = satuHariTerakhir ? '' : 'BUKAN kondisi hari ini. '; out.catatan_periode = `Angka ringkas di objek ini dihitung dari periode ${label} (${rows.length} hari data), ` + `${bukanHariIni}Satuan setiap angka tertulis di akhir nama field ` + `(_gram, _persen, _rasio, _karung, _ekor, _kg) — pakai satuan itu persis. ` + `Field berisi "${UNAVAILABLE}" memang tidak ada datanya: tulis "data tidak tersedia" ` + `dan JANGAN mengarang angkanya.`; return out; }; /** Human-readable description of the window, for the prompt and the UI. */ export const describeTimeframe = ( timeframe: InsightTimeframe, currentDay?: number | null, selectedDay: number | null = null ) => { if (timeframe === 'harian') { // Daily now means "the cycle so far", so the label must state the range. A // past end-day must not be described as "hari ini" either — the model // repeats that phrasing back and claims the figures are today's. if (selectedDay !== null) { const ekor = `hari ke-0 sampai hari ke-${selectedDay} (kumulatif sejak awal siklus)`; return selectedDay === currentDay ? `${ekor}, berakhir hari ini` : `${ekor}, BUKAN hari ini`; } return currentDay ? `Hari ini saja (hari ke-${currentDay})` : 'Hari ini saja (hari terakhir)'; } return currentDay ? `7 hari terakhir (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})` : '7 hari terakhir'; };