651 lines
28 KiB
JavaScript
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,
|
|
};
|