Files

263 lines
10 KiB
TypeScript

/**
* 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<string, unknown> & {
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<string, unknown> =>
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 = <T>(
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<string, unknown> = {};
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;
/**
* What to write when the window holds no usable reading. The default is
* `UNAVAILABLE`, which is right for figures a missing day says nothing
* about. Set `0` for the ones the supervisor wants reported as a plain
* zero instead — a day with no weighing (day 48) has to render "0 g",
* not a blank, because a blank read as a broken card.
*/
emptyAs?: 0;
}
>;
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<string, unknown>,
timeframe: InsightTimeframe,
spec: ScalarScopeSpec
): Record<string, unknown> => {
const history = scopedContext[spec.historyKey];
if (!Array.isArray(history)) return scopedContext;
const rows = history as DayRow[];
const out: Record<string, unknown> = { ...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 ? (rule.emptyAs ?? 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';
};