Files
Alberto-Audrix 01b3178bc6
CI / lint-and-test (push) Canceled after 0s
fix ai insight
2026-08-28 14:22:33 +07:00

651 lines
28 KiB
JavaScript

/**
* Deterministic root-cause evidence for AI Insight.
*
* The supervisor's complaint: an insight that says "mortalitas 9%, di atas
* standar" without saying WHY. The model cannot answer "why" on its own —
* a single page's payload only carries its own metric, and asking a 7B model to
* correlate raw series in its head is how fabricated causes get written.
*
* So the correlation is done here, in code, before the prompt is built: spikes,
* deviations from the CP 707 book, and the lag between an environment excursion
* and the deaths that follow it are all computed from the data and handed to the
* model as quotable findings. The model's job is reduced to ranking and wording
* them, and anything NOT in this block it must label as a hypothesis.
*
* Every function here is null-safe: pages send different payloads, and a missing
* series must produce a "data tidak tersedia" note, never a silent zero.
*/
const cp707 = require('./cp707Knowledge.js');
/**
* Target suhu (°C) untuk umur tertentu.
*
* `cp707.getTempStandardByDay` mengembalikan BARIS tabelnya
* (`{ ageDay_from, ageDay_to, temp_C, ... }`), bukan angkanya — memakai nilai
* kembalian itu langsung sebagai angka membuat setiap perbandingan suhu diam-diam
* menghasilkan NaN dan seluruh temuan suhu hilang dari blok bukti.
*/
const tempStdOf = (day) => {
const row = cp707.getTempStandardByDay(day);
return row && typeof row.temp_C === 'number' ? row.temp_C : null;
};
const num = (value) => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
// Readings arrive display-formatted ("32,1 °C"), with the Indonesian comma.
const cleaned = value.replace(/[^\d,.\-]/g, '').replace(',', '.');
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const round = (value, digits = 2) => {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
};
const median = (values) => {
const sorted = values
.filter((v) => typeof v === 'number' && Number.isFinite(v))
.sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
};
const fmt = (value, digits = 1) =>
value === null || value === undefined
? 'tidak tersedia'
: String(round(value, digits)).replace('.', ',');
/** "hari 12, 13, 14" collapsed to "hari 12-14"; keeps a reader from re-deriving the span. */
const describeDays = (days) => {
const sorted = [...new Set(days.filter((d) => Number.isFinite(d)))].sort((a, b) => a - b);
if (sorted.length === 0) return '';
const groups = [];
let start = sorted[0];
let prev = sorted[0];
for (const day of sorted.slice(1)) {
if (day === prev + 1) {
prev = day;
continue;
}
groups.push(start === prev ? `${start}` : `${start}-${prev}`);
start = day;
prev = day;
}
groups.push(start === prev ? `${start}` : `${start}-${prev}`);
return groups.join(', ');
};
const asRows = (value) =>
Array.isArray(value) ? value.filter((row) => row && typeof row === 'object') : [];
const dayOf = (row) => num(row.hari ?? row.day ?? row.hari_ke);
/** Rows up to and including `lastDay`, sorted by day. `null` keeps everything. */
const upTo = (rows, lastDay) =>
rows
.map((row) => ({ ...row, _hari: dayOf(row) }))
.filter((row) => row._hari !== null && (lastDay === null || row._hari <= lastDay))
.sort((a, b) => a._hari - b._hari);
// ─── Findings ────────────────────────────────────────────────────────────────
const analyseMortality = (bundle, findings, gaps) => {
const rows = upTo(asRows(bundle.mortalitas_harian), bundle.hari_ke);
if (rows.length === 0) {
gaps.push(
'Riwayat kematian harian (mortalitas_harian) tidak tersedia — sebaran kematian per hari dan lonjakannya tidak dapat ditelusuri.'
);
return null;
}
const daily = rows.map((row) => ({
hari: row._hari,
mati: num(row.mati ?? row.mortalityCount) ?? 0,
afkir: num(row.afkir) ?? 0,
}));
const totalMati = daily.reduce((sum, row) => sum + row.mati + row.afkir, 0);
const populasiAwal = num(bundle.populasi_awal_ekor);
const lastDay = daily[daily.length - 1].hari;
const rate = populasiAwal && populasiAwal > 0 ? (totalMati / populasiAwal) * 100 : null;
const std = cp707.getMortalityCumStdByDay(lastDay);
if (rate !== null) {
if (std !== null) {
const ratio = std > 0 ? rate / std : null;
findings.push({
area: 'MORTALITAS',
level: rate >= std * 1.5 ? 'kritis' : rate > std ? 'perhatian' : 'info',
text:
`Mortalitas kumulatif s/d hari ke-${lastDay}: ${fmt(rate, 2)}% (${totalMati.toLocaleString('id-ID')} ekor dari ` +
`${populasiAwal.toLocaleString('id-ID')} ekor). Standar CP 707 hari ke-${lastDay}: ${fmt(std, 2)}%` +
(ratio
? ` — aktual ${fmt(ratio, 1)}x standar, selisih ${fmt(rate - std, 2)} poin persen`
: '') +
(ratio && populasiAwal
? `, setara ${Math.round(((rate - std) / 100) * populasiAwal).toLocaleString('id-ID')} ekor di atas batas wajar`
: ''),
});
} else {
findings.push({
area: 'MORTALITAS',
level: 'info',
text: `Mortalitas kumulatif s/d hari ke-${lastDay}: ${fmt(rate, 2)}% (${totalMati.toLocaleString('id-ID')} ekor). Standar kumulatif hari ke-${lastDay} tidak tercantum di buku CP 707.`,
});
}
}
// Where the deaths actually happened. A flat rate hides the fact that most of
// the loss can sit in three days, which is the difference between "manajemen
// buruk sepanjang siklus" and "ada kejadian di hari 12-15".
const perDay = daily.map((row) => row.mati + row.afkir);
const med = median(perDay.filter((v) => v > 0));
const spikes =
med && med > 0 ? daily.filter((row) => row.mati + row.afkir >= Math.max(med * 2, med + 5)) : [];
if (spikes.length > 0 && totalMati > 0) {
const spikeTotal = spikes.reduce((sum, row) => sum + row.mati + row.afkir, 0);
const top = [...spikes].sort((a, b) => b.mati + b.afkir - (a.mati + a.afkir)).slice(0, 5);
findings.push({
area: 'MORTALITAS',
level: 'perhatian',
text:
`Kematian TIDAK merata: ${spikes.length} hari lonjakan (${describeDays(spikes.map((r) => r.hari))}) menyumbang ` +
`${spikeTotal.toLocaleString('id-ID')} ekor = ${fmt((spikeTotal / totalMati) * 100, 1)}% dari seluruh kematian, ` +
`padahal kematian hari normal hanya ${fmt(med, 0)} ekor/hari. Rincian hari terparah: ` +
top
.map(
(row) => `hari ke-${row.hari} ${(row.mati + row.afkir).toLocaleString('id-ID')} ekor`
)
.join('; ') +
'.',
});
} else if (med !== null) {
findings.push({
area: 'MORTALITAS',
level: 'info',
text: `Kematian tersebar merata (median ${fmt(med, 0)} ekor/hari, tidak ada hari yang melonjak ≥2x median). Pola ini mengarah ke sebab kronis/berjalan terus, bukan satu kejadian tunggal.`,
});
}
// Which rearing phase carries the loss — the book ties each phase to a
// different set of causes (brooding vs heat stress vs ascites).
const phase = { brooding: 0, grower: 0, finisher: 0 };
for (const row of daily) {
const total = row.mati + row.afkir;
if (row.hari <= 14) phase.brooding += total;
else if (row.hari <= 28) phase.grower += total;
else phase.finisher += total;
}
if (totalMati > 0) {
findings.push({
area: 'MORTALITAS',
level: 'info',
text:
`Sebaran kematian per fase: brooding (hari 1-14) ${phase.brooding.toLocaleString('id-ID')} ekor = ${fmt((phase.brooding / totalMati) * 100, 1)}%, ` +
`grower (hari 15-28) ${phase.grower.toLocaleString('id-ID')} ekor = ${fmt((phase.grower / totalMati) * 100, 1)}%, ` +
`finisher (hari 29+) ${phase.finisher.toLocaleString('id-ID')} ekor = ${fmt((phase.finisher / totalMati) * 100, 1)}%.`,
});
}
return { daily, spikes, totalMati, lastDay, rate, std };
};
const analyseEnvironment = (bundle, mortality, findings, gaps) => {
const rows = upTo(asRows(bundle.lingkungan_harian), bundle.hari_ke);
if (rows.length === 0) {
const now =
bundle.lingkungan_terkini && typeof bundle.lingkungan_terkini === 'object'
? bundle.lingkungan_terkini
: null;
const suhu = now ? num(now.suhu_C ?? now.suhu) : null;
if (suhu !== null && bundle.hari_ke) {
const std = tempStdOf(bundle.hari_ke);
if (std !== null) {
findings.push({
area: 'LINGKUNGAN',
level: Math.abs(suhu - std) >= 2 ? 'perhatian' : 'info',
text: `Suhu kandang terkini ${fmt(suhu)}°C vs target CP 707 hari ke-${bundle.hari_ke} ${fmt(std)}°C (selisih ${fmt(suhu - std)}°C). Hanya pembacaan sesaat — riwayat harian tidak tersedia.`,
});
}
}
gaps.push(
'Riwayat suhu/kelembapan harian (lingkungan_harian) tidak tersedia — kaitan antara kondisi kandang dan hari-hari kematian tinggi TIDAK dapat dibuktikan, hanya bisa diduga.'
);
return;
}
const env = rows.map((row) => ({
hari: row._hari,
suhu: num(row.suhu_C ?? row.suhu ?? row.temp),
rh: num(row.kelembapan_persen ?? row.kelembapan ?? row.humidity),
}));
/*
* State the REPORTED day's own condition before anything else.
*
* Every finding below names the worst day in the window, which for a day-48
* report was day 42 — a real, correctly scoped figure that nonetheless left
* the reader of a "Hari 48" report with no idea what day 48 looked like, and
* let the model write day 42's reading up as the current condition.
*/
const reportedDay = num(bundle.hari_ke) ?? env[env.length - 1].hari;
const onReportedDay = env.find((row) => row.hari === reportedDay) ?? null;
const reportedStd = tempStdOf(reportedDay);
if (onReportedDay && onReportedDay.suhu !== null && reportedStd !== null) {
const delta = onReportedDay.suhu - reportedStd;
findings.push({
area: 'KONDISI HARI YANG DILAPORKAN',
level: Math.abs(delta) >= 2 ? 'perhatian' : 'info',
text:
`Hari ke-${reportedDay}: suhu ${fmt(onReportedDay.suhu)}°C ` +
`vs target CP 707 ${fmt(reportedStd)}°C (${delta >= 0 ? '+' : ''}${fmt(delta)}°C)` +
`${onReportedDay.rh === null ? '' : `, kelembapan ${fmt(onReportedDay.rh)}%`}. ` +
'Inilah kondisi hari yang dilaporkan itu sendiri. WAJIB sebutkan angka ini ' +
'lebih dulu sebelum membahas hari lain mana pun.',
});
} else {
const last = env[env.length - 1];
findings.push({
area: 'KONDISI HARI YANG DILAPORKAN',
level: 'perhatian',
text:
`Tidak ada pembacaan sensor untuk hari ke-${reportedDay}. ` +
`Pembacaan terakhir yang ada adalah hari ke-${last.hari}. ` +
'Jangan menyebut angka hari lain sebagai kondisi hari ke-' + `${reportedDay}.`,
});
}
const panas = [];
const dingin = [];
for (const row of env) {
const std = tempStdOf(row.hari);
if (row.suhu === null || std === null) continue;
if (row.suhu - std >= 2) panas.push({ ...row, std, delta: row.suhu - std });
else if (std - row.suhu >= 2) dingin.push({ ...row, std, delta: row.suhu - std });
}
if (panas.length > 0) {
const worst = [...panas].sort((a, b) => b.delta - a.delta)[0];
findings.push({
area: 'LINGKUNGAN',
level: panas.length >= 3 ? 'kritis' : 'perhatian',
text:
`Suhu DI ATAS target CP 707 pada ${panas.length} hari (hari ${describeDays(panas.map((r) => r.hari))}). ` +
`Terparah hari ke-${worst.hari}: ${fmt(worst.suhu)}°C vs target ${fmt(worst.std)}°C (+${fmt(worst.delta)}°C). ` +
`Buku CP 707: suhu di atas target memicu heat stress — konsumsi pakan turun, ayam megap-megap, kematian naik terutama pada ayam berbobot besar.`,
});
}
if (dingin.length > 0) {
const worst = [...dingin].sort((a, b) => a.delta - b.delta)[0];
findings.push({
area: 'LINGKUNGAN',
level: dingin.length >= 3 ? 'perhatian' : 'info',
text:
`Suhu DI BAWAH target CP 707 pada ${dingin.length} hari (hari ${describeDays(dingin.map((r) => r.hari))}). ` +
`Terparah hari ke-${worst.hari}: ${fmt(worst.suhu)}°C vs target ${fmt(worst.std)}°C (${fmt(worst.delta)}°C). ` +
`Pada fase brooding suhu kurang menyebabkan ayam menumpuk, konsumsi turun, dan kematian dini naik.`,
});
}
const rhLuar = env.filter((row) => row.rh !== null && (row.rh < 50 || row.rh > 70));
if (rhLuar.length > 0) {
findings.push({
area: 'LINGKUNGAN',
level: 'perhatian',
text:
`Kelembapan di luar rentang ideal 50-70% pada ${rhLuar.length} hari (hari ${describeDays(rhLuar.map((r) => r.hari))}), ` +
`terendah ${fmt(Math.min(...rhLuar.map((r) => r.rh)))}% dan tertinggi ${fmt(Math.max(...rhLuar.map((r) => r.rh)))}%. ` +
`RH >70% membuat litter basah (amonia naik, penyakit pernapasan); RH <50% saat brooding membuat ayam dehidrasi.`,
});
}
// The correlation the model is being asked for, computed rather than guessed:
// an excursion must PRECEDE the deaths (0-2 days) to be a candidate cause.
if (mortality && mortality.spikes.length > 0 && (panas.length > 0 || dingin.length > 0)) {
const excursions = [...panas, ...dingin];
const matched = mortality.spikes.filter((spike) =>
excursions.some((e) => e.hari <= spike.hari && spike.hari - e.hari <= 2)
);
if (matched.length > 0) {
findings.push({
area: 'KORELASI',
level: matched.length >= mortality.spikes.length / 2 ? 'kritis' : 'perhatian',
text:
`${matched.length} dari ${mortality.spikes.length} hari lonjakan kematian (hari ${describeDays(matched.map((r) => r.hari))}) ` +
`didahului penyimpangan suhu dalam 0-2 hari sebelumnya. Ini bukti terkuat yang tersedia untuk mengaitkan kematian dengan kondisi kandang.`,
});
} else {
findings.push({
area: 'KORELASI',
level: 'info',
text: `Tidak satu pun hari lonjakan kematian didahului penyimpangan suhu dalam 0-2 hari. Suhu kemungkinan BUKAN pemicu utama lonjakan tersebut — cari sebab lain (penyakit, kualitas DOC, pakan, air minum).`,
});
}
}
};
const analyseWeight = (bundle, mortality, findings, gaps) => {
const rows = upTo(asRows(bundle.bobot_harian), bundle.hari_ke);
if (rows.length === 0) {
gaps.push(
'Riwayat bobot harian (bobot_harian) tidak tersedia — dampak pertumbuhan terhadap/dari kematian tidak dapat diuji.'
);
return;
}
const weights = rows
.map((row) => ({
hari: row._hari,
aktual: num(row.aktual_gram ?? row.aktual ?? row.bobot_gram),
target: num(row.target_gram ?? row.target) ?? cp707.getBwStandardByDay(row._hari),
}))
.filter((row) => row.aktual !== null && row.aktual > 0);
if (weights.length === 0) {
gaps.push(
'Bobot harian ada barisnya tetapi seluruh nilainya kosong — pertumbuhan tidak dapat dinilai.'
);
return;
}
const last = weights[weights.length - 1];
if (last.target) {
const gap = ((last.aktual - last.target) / last.target) * 100;
findings.push({
area: 'BOBOT',
level: gap <= -10 ? 'kritis' : gap <= -5 ? 'perhatian' : 'info',
text:
`Bobot hari ke-${last.hari}: ${fmt(last.aktual, 0)} g vs standar CP 707 ${fmt(last.target, 0)} g ` +
`(${gap >= 0 ? '+' : ''}${fmt(gap, 1)}%). ` +
(gap <= -5
? 'Bobot tertinggal biasanya menyertai gangguan konsumsi pakan, penyakit, atau stres lingkungan pada periode sebelumnya.'
: 'Pertumbuhan mendekati atau melampaui standar.'),
});
}
// ADG drops flag the day the flock stopped eating — usually 1-3 days before
// the deaths show up in the ledger.
const adg = [];
for (let i = 1; i < weights.length; i += 1) {
const span = weights[i].hari - weights[i - 1].hari;
if (span <= 0) continue;
adg.push({ hari: weights[i].hari, nilai: (weights[i].aktual - weights[i - 1].aktual) / span });
}
const medAdg = median(adg.map((row) => row.nilai));
const drops = medAdg && medAdg > 0 ? adg.filter((row) => row.nilai < medAdg * 0.5) : [];
if (drops.length > 0) {
findings.push({
area: 'BOBOT',
level: 'perhatian',
text:
`Pertambahan bobot harian (ADG) anjlok di bawah setengah nilai biasanya pada hari ${describeDays(drops.map((r) => r.hari))} ` +
`(ADG normal ${fmt(medAdg, 0)} g/hari, terendah ${fmt(Math.min(...drops.map((r) => r.nilai)), 0)} g/hari). ` +
`ADG anjlok umumnya mendahului kematian: ayam berhenti makan lebih dulu, baru mati beberapa hari kemudian.`,
});
if (mortality && mortality.spikes.length > 0) {
const matched = mortality.spikes.filter((spike) =>
drops.some((d) => d.hari <= spike.hari && spike.hari - d.hari <= 3)
);
if (matched.length > 0) {
findings.push({
area: 'KORELASI',
level: 'perhatian',
text: `${matched.length} dari ${mortality.spikes.length} hari lonjakan kematian (hari ${describeDays(matched.map((r) => r.hari))}) didahului ADG anjlok dalam 0-3 hari sebelumnya.`,
});
}
}
}
const uniformity = num(bundle.keseragaman_persen);
if (uniformity !== null && uniformity > 0 && uniformity < 80) {
findings.push({
area: 'BOBOT',
level: uniformity < 70 ? 'perhatian' : 'info',
text:
`Keseragaman ${fmt(uniformity, 1)}% (standar CP 707 >80%). Populasi tidak seragam berarti ada kelompok ayam kerdil yang ` +
`kalah berebut pakan dan minum — kelompok inilah yang paling banyak mati dan diafkir.`,
});
}
};
const analyseFeed = (bundle, mortality, findings, gaps) => {
const rows = upTo(asRows(bundle.pakan_harian), bundle.hari_ke);
if (rows.length === 0) {
gaps.push(
'Riwayat pakan harian (pakan_harian) tidak tersedia — penurunan konsumsi sebagai gejala awal penyakit tidak dapat diperiksa.'
);
return;
}
const feed = rows
.map((row) => ({ hari: row._hari, karung: num(row.karung ?? row.jumlah_karung) }))
.filter((row) => row.karung !== null);
if (feed.length < 3) return;
const drops = [];
for (let i = 2; i < feed.length; i += 1) {
const baseline = (feed[i - 1].karung + feed[i - 2].karung) / 2;
if (baseline > 0 && feed[i].karung < baseline * 0.75) {
drops.push({ hari: feed[i].hari, karung: feed[i].karung, baseline });
}
}
if (drops.length > 0) {
findings.push({
area: 'PAKAN',
level: 'perhatian',
text:
`Konsumsi pakan turun >25% dibanding dua hari sebelumnya pada hari ${describeDays(drops.map((r) => r.hari))} ` +
`(contoh hari ke-${drops[0].hari}: ${fmt(drops[0].karung, 1)} karung vs rata-rata ${fmt(drops[0].baseline, 1)} karung). ` +
`Konsumsi pakan yang turun mendadak adalah gejala paling awal dari penyakit atau heat stress — pakan turun DULU, kematian menyusul.`,
});
if (mortality && mortality.spikes.length > 0) {
const matched = mortality.spikes.filter((spike) =>
drops.some((d) => d.hari <= spike.hari && spike.hari - d.hari <= 3)
);
if (matched.length > 0) {
findings.push({
area: 'KORELASI',
level: 'kritis',
text: `${matched.length} dari ${mortality.spikes.length} hari lonjakan kematian (hari ${describeDays(matched.map((r) => r.hari))}) didahului penurunan konsumsi pakan dalam 0-3 hari sebelumnya.`,
});
}
}
}
};
const analyseFcr = (bundle, findings) => {
const rows = upTo(asRows(bundle.fcr_harian), bundle.hari_ke);
const fcrRows = rows
.map((row) => ({
hari: row._hari,
aktual: num(row.aktual ?? row.fcr ?? row.manualFcr ?? row.iotFcr),
standar: num(row.standar ?? row.cobbFcr) ?? cp707.getFcrStandardByDay(row._hari),
}))
.filter((row) => row.aktual !== null && row.aktual > 0);
if (fcrRows.length === 0) return;
const last = fcrRows[fcrRows.length - 1];
if (last.standar) {
const gap = ((last.aktual - last.standar) / last.standar) * 100;
findings.push({
area: 'FCR',
level: gap >= 10 ? 'kritis' : gap >= 5 ? 'perhatian' : 'info',
text:
`FCR hari ke-${last.hari}: ${fmt(last.aktual, 3)} vs standar CP 707 ${fmt(last.standar, 3)} (${gap >= 0 ? '+' : ''}${fmt(gap, 1)}%). ` +
(gap >= 5
? 'FCR di atas standar berarti pakan terbuang: bisa karena pakan tumpah/tercecer, ayam mati setelah makan (pakan sudah dikonsumsi tapi bobotnya hilang dari panen), atau pertumbuhan terhambat.'
: 'Efisiensi pakan sesuai atau lebih baik dari standar.'),
});
}
};
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Reads the `diagnostik` bundle a page attaches to its contextData and returns
* the evidence block, or '' when the page sent nothing to work with.
*/
const buildRootCauseEvidence = (contextData) => {
const bundle =
contextData &&
typeof contextData === 'object' &&
contextData.diagnostik &&
typeof contextData.diagnostik === 'object'
? contextData.diagnostik
: null;
if (!bundle) return '';
const findings = [];
const gaps = [];
const mortality = analyseMortality(bundle, findings, gaps);
analyseEnvironment(bundle, mortality, findings, gaps);
analyseWeight(bundle, mortality, findings, gaps);
analyseFeed(bundle, mortality, findings, gaps);
analyseFcr(bundle, findings);
if (findings.length === 0 && gaps.length === 0) return '';
// The reported day's own condition leads. It is what the report is about;
// every other section is history explaining how it got there.
const order = [
'KONDISI HARI YANG DILAPORKAN',
'MORTALITAS',
'KORELASI',
'LINGKUNGAN',
'PAKAN',
'BOBOT',
'FCR',
];
const byArea = order
.map((area) => ({ area, items: findings.filter((f) => f.area === area) }))
.filter((group) => group.items.length > 0);
const body = byArea
.map(
(group) =>
`[${group.area}]\n` +
group.items
.map(
(item) =>
`- ${item.level === 'kritis' ? '(KRITIS) ' : item.level === 'perhatian' ? '(PERLU PERHATIAN) ' : ''}${item.text}`
)
.join('\n')
)
.join('\n\n');
const gapBlock =
gaps.length > 0
? `\n\n[DATA YANG TIDAK TERSEDIA — batas dari analisis ini]\n${gaps.map((gap) => `- ${gap}`).join('\n')}`
: '';
// Without this line nothing in the block said which day was being reported
// on, so a day-48 report could be built entirely out of day-42 figures.
const reportedDay = num(bundle.hari_ke);
const scopeLine =
reportedDay === null
? ''
: `\nHARI YANG DILAPORKAN: hari ke-${reportedDay}. Semua temuan di bawah dihitung dari hari ke-0 sampai hari ke-${reportedDay}.` +
`\nHari lain yang disebut (misalnya "terparah hari ke-X") adalah RIWAYAT di dalam rentang itu, BUKAN kondisi hari ke-${reportedDay}.`;
return `
═══════════════════════════════════════════════
TEMUAN TERUKUR DARI DATA (dihitung otomatis oleh sistem dari data mentah)
Angka di blok ini SUDAH benar dan boleh Anda kutip apa adanya sebagai bukti.
Blok ini adalah satu-satunya sumber sah untuk menjelaskan SEBAB.${scopeLine}
═══════════════════════════════════════════════
${body}${gapBlock}
═══════════════════════════════════════════════`.trim();
};
/**
* The reasoning half of the output contract: WHAT the answer must establish,
* with no claim about the shape it is written in. Kept separate from the
* evidence so it can be attached even when a page sends no diagnostic bundle —
* the demand for causal reasoning holds either way; without evidence the answer
* simply has to be honest that the cause cannot be established.
*
* Callers that ask for a different response shape (the dashboard asks for a
* JSON schema with `summary`/`detailed`) must attach ONLY this half. Handing
* them the plain-text field layout below as well gave the model two conflicting
* contracts, and on the largest prompt — a day-48 daily report, cumulative from
* day 0 — it followed the text one and replied in a shape the dashboard schema
* has no place for, which then healed into an all-empty report.
*/
const ROOT_CAUSE_REASONING_RULES = `
ATURAN ANALISIS SEBAB-AKIBAT (WAJIB — ini yang membedakan insight berguna dari sekadar laporan angka):
Menyebutkan sebuah angka menyimpang dari standar TIDAK CUKUP. Setiap penyimpangan
WAJIB Anda jelaskan SEBABNYA berdasarkan data yang ada. Contoh yang DILARANG:
"Mortalitas 9,2% berada di atas standar 4,1%, perlu perhatian." — ini tidak menjawab KENAPA.
Contoh yang BENAR: "Mortalitas 9,2% (2.300 ekor). 61% dari kematian itu terjadi pada
hari 12-15 saja (1.400 ekor), dan ketiga hari itu didahului suhu 33-34°C — 5°C di atas
target CP 707 (29°C). Konsumsi pakan juga turun 30% di hari ke-12."
Cara menyusun jawaban:
1. Mulai dari penyimpangan terbesar terhadap standar CP 707.
2. Untuk setiap penyimpangan, telusuri sebabnya HANYA dari blok TEMUAN TERUKUR
dan [Data Halaman (JSON)]: hari berapa kejadiannya, angka apa yang menyertai,
metrik lain apa yang bergerak lebih dulu.
3. Urutkan sebab dari yang paling kuat buktinya.
4. Tandai setiap sebab dengan salah satu label:
[Terbukti dari data] — angkanya ada di blok TEMUAN TERUKUR atau di Data Halaman.
[Dugaan — perlu dicek: ...] — masuk akal secara teknis tetapi TIDAK ada angkanya
di data. WAJIB sebutkan apa yang harus diperiksa peternak di lapangan untuk
memastikannya. DILARANG menuliskan angka apa pun pada sebab berlabel dugaan.
5. Sebutkan terus terang metrik yang datanya tidak tersedia dan apa akibatnya bagi
kepastian kesimpulan Anda.
6. DILARANG mengarang angka, hari, atau kejadian yang tidak ada di data.
7. Laporan ini tentang HARI YANG DILAPORKAN (lihat blok TEMUAN TERUKUR). Sebutkan
kondisi hari itu sendiri lebih dulu, baru riwayat sebelumnya sebagai sebab.
Angka dari hari lain — termasuk yang ditandai "terparah hari ke-X" — DILARANG
ditulis sebagai kondisi hari yang dilaporkan atau sebagai kondisi "saat ini".
Kalau menyebut hari lain, tulis nomor harinya dengan jelas.
`.trim();
/**
* The plain-text field layout, for callers whose response IS free text with an
* `insight` and a `kesimpulan` field. Never attach this to a JSON-schema prompt.
*/
const ROOT_CAUSE_TEXT_FORMAT = `
FORMAT ISI FIELD "insight" (teks biasa, antar baris dipisah baris baru, TANPA markdown):
PENYEBAB:
1. <sebab paling kuat> — Bukti: <angka + hari dari data>. Dampak: <kaitannya ke metrik yang menyimpang>. [Terbukti dari data]
2. <sebab berikutnya> — Bukti: <...>. Dampak: <...>. [Terbukti dari data]
3. <sebab yang belum terbukti> — Alasan menduga: <...>. [Dugaan — perlu dicek: <langkah pemeriksaan konkret>]
BELUM DAPAT DIPASTIKAN:
- <metrik yang datanya kosong dan bagaimana itu membatasi kesimpulan>
TINDAKAN:
1. <tindakan konkret, sebutkan angka target dan tenggat waktunya>
2. <tindakan konkret berikutnya>
Tulis 2-5 poin PENYEBAB dan 2-4 poin TINDAKAN. Bahasa Indonesia lugas untuk peternak.
Aturan tambahan untuk format di atas:
- Label [Terbukti dari data] dan [Dugaan — perlu dicek: ...] HANYA dipakai pada poin
PENYEBAB, tepat SATU label di akhir setiap poin. DILARANG menempelkan label pada poin
TINDAKAN atau pada bagian BELUM DAPAT DIPASTIKAN.
- Bagian BELUM DAPAT DIPASTIKAN hanya untuk metrik yang datanya TIDAK ADA sama sekali.
Jangan memindahkan sebab berlabel [Dugaan] ke sana — tempatnya tetap di daftar PENYEBAB.
- Satu poin PENYEBAB = satu sebab. Jangan mengulang sebab yang sama dengan kalimat berbeda.
FORMAT ISI FIELD "kesimpulan" (2-4 kalimat):
Sebutkan metrik utama beserta angkanya, standar CP 707 pembandingnya, seberapa jauh
menyimpang, DAN sebab utamanya dalam satu tarikan kalimat. Jangan hanya menilai "baik"
atau "perlu perhatian" tanpa angka dan tanpa sebab.
`.trim();
/** Both halves, for the free-text insight route. */
const ROOT_CAUSE_CONTRACT = `${ROOT_CAUSE_REASONING_RULES}\n\n${ROOT_CAUSE_TEXT_FORMAT}`;
module.exports = {
buildRootCauseEvidence,
ROOT_CAUSE_CONTRACT,
ROOT_CAUSE_REASONING_RULES,
ROOT_CAUSE_TEXT_FORMAT,
// exported for tests
describeDays,
};