278 lines
11 KiB
TypeScript
278 lines
11 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,
|
|
selectedDay: number | null = null
|
|
): 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);
|
|
// Harian = cumulative from cycle start (hari ke-0), matching dashboard reports.
|
|
// Do not label from the first day that happens to have a row (e.g. "2 s/d 8").
|
|
let label: string;
|
|
if (days.length === 0) {
|
|
label = UNAVAILABLE;
|
|
} else if (timeframe === 'harian') {
|
|
const endDay = selectedDay ?? Math.max(...days);
|
|
label =
|
|
selectedDay === null && Math.min(...days) === Math.max(...days)
|
|
? `hari ke-${endDay}`
|
|
: `hari ke-0 s/d ke-${endDay}`;
|
|
} else if (Math.min(...days) === Math.max(...days)) {
|
|
label = `hari ke-${Math.max(...days)}`;
|
|
} else {
|
|
label = `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);
|
|
const finalValue = value === null ? (rule.emptyAs ?? UNAVAILABLE) : value;
|
|
out[outKey] = finalValue;
|
|
if (rule.replaces) out[rule.replaces] = finalValue;
|
|
}
|
|
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. ';
|
|
const dataSpan =
|
|
days.length > 0 && Math.min(...days) > 0
|
|
? ` Baris data tersedia mulai hari ke-${Math.min(...days)}.`
|
|
: '';
|
|
out.catatan_periode =
|
|
`Angka ringkas di objek ini dihitung dari periode ${label} (${rows.length} hari data).${dataSpan} ` +
|
|
`${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 s/d 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';
|
|
};
|