2340 lines
98 KiB
JavaScript
2340 lines
98 KiB
JavaScript
const AiInsight = require('../models/AiInsight.js');
|
|
const Cycle = require('../models/Cycle.js');
|
|
const Kandang = require('../models/Kandang.js');
|
|
const ChickenCount = require('../models/ChickenCount.js');
|
|
const Mortality = require('../models/Mortality.js');
|
|
const { formatDateForDb } = require('../utils/dateUtils.js');
|
|
const cp707 = require('./cp707Knowledge.js');
|
|
const { llmDispatcher, llmFetch } = require('./llmDispatcher.js');
|
|
const { buildRootCauseEvidence, ROOT_CAUSE_REASONING_RULES } = require('./rootCauseAnalysis.js');
|
|
const { buildVariationInstruction } = require('./insightVariation.js');
|
|
|
|
// v6: period-scoped scalars, computed EEF, unit-suffixed field names, and the
|
|
// data-availability guards. Insights cached under v5 were generated before those
|
|
// fixes — they still carry the fabricated figures and the false "ok" statuses,
|
|
// so the bump is what actually retires them from the dashboard.
|
|
const INSIGHT_VERSION = 'v7'; // bumped: added explicit mortality grade labels to prevent LLM from calling ≥5% "rendah"
|
|
const dashboardInsightRequests = new Map();
|
|
|
|
// num_predict is a ceiling, not a target — a well-formed JSON answer stops on
|
|
// its own, so this only ever bites when the model runs away. At 8192 a runaway
|
|
// burned the whole 20-minute timeout before demoting to the local fallback.
|
|
// The caps below sit above the largest real answers measured from stored
|
|
// insight_text (daily ~3.2k chars, weekly ~4.3k chars, i.e. ~1.0-1.3k tokens),
|
|
// with end_cycle given extra room since its prompt asks for all six areas.
|
|
// Set these too low and a valid long report gets truncated mid-JSON, which
|
|
// costs the full generation and still lands on the fallback.
|
|
// Raised together with the root-cause contract: the answer now has to carry a
|
|
// cause and its evidence per section, which measured roughly 1.4x the old
|
|
// length. Too low truncates a valid long report mid-JSON, which costs the whole
|
|
// generation and still lands on the fallback.
|
|
const NUM_PREDICT_BY_REPORT_TYPE = {
|
|
daily: 3072,
|
|
weekly: 3584,
|
|
end_cycle: 5120,
|
|
};
|
|
|
|
const isPlainObject = (value) =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
|
|
const cleanPromptValue = (value) => {
|
|
if (value === null || value === undefined) return undefined;
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return Number.isFinite(value) ? value : undefined;
|
|
}
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const cleaned = value
|
|
.map((item) => cleanPromptValue(item))
|
|
.filter((item) => item !== undefined);
|
|
return cleaned.length > 0 ? cleaned : undefined;
|
|
}
|
|
if (isPlainObject(value)) {
|
|
const entries = [];
|
|
for (const [key, item] of Object.entries(value)) {
|
|
const cleaned = cleanPromptValue(item);
|
|
if (cleaned !== undefined) {
|
|
entries.push([key, cleaned]);
|
|
}
|
|
}
|
|
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const serializePromptContext = (value) => {
|
|
const cleaned = cleanPromptValue(value);
|
|
return JSON.stringify(cleaned ?? {}, null, 2);
|
|
};
|
|
|
|
const INSIGHT_SECTION_STATUS = ['ok', 'warning', 'critical', 'unknown'];
|
|
|
|
const isInsightStatus = (value) =>
|
|
typeof value === 'string' && INSIGHT_SECTION_STATUS.includes(value);
|
|
|
|
const isStringArray = (value) =>
|
|
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
|
|
const isInsightSection = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const section = value;
|
|
return (
|
|
isInsightStatus(section.status) &&
|
|
isStringArray(section.bullets) &&
|
|
isStringArray(section.evidence) &&
|
|
isStringArray(section.actions)
|
|
);
|
|
};
|
|
|
|
const isDashboardInsightJson = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const root = value;
|
|
const summary = root.summary;
|
|
const detailed = root.detailed;
|
|
|
|
return (
|
|
!!summary &&
|
|
typeof summary === 'object' &&
|
|
!Array.isArray(summary) &&
|
|
typeof summary.headline === 'string' &&
|
|
isStringArray(summary.bullets) &&
|
|
isStringArray(summary.risks) &&
|
|
isStringArray(summary.actions) &&
|
|
!!detailed &&
|
|
typeof detailed === 'object' &&
|
|
!Array.isArray(detailed) &&
|
|
isInsightSection(detailed.hitung_ayam) &&
|
|
isInsightSection(detailed.berat_ayam) &&
|
|
isInsightSection(detailed.panel_iot) &&
|
|
isInsightSection(detailed.data_quality)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Filler `healDashboardInsightJson` writes into a section the model left empty.
|
|
* `hasUsableContent` has to recognise it, or a reply carrying nothing at all
|
|
* reads as seven populated sections.
|
|
*/
|
|
const EMPTY_SECTION_PLACEHOLDER = 'Data tidak tersedia untuk periode ini.';
|
|
|
|
const healDashboardInsightJson = (raw) => {
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
raw = {};
|
|
}
|
|
|
|
// 1. Heal summary
|
|
if (!raw.summary || typeof raw.summary !== 'object' || Array.isArray(raw.summary)) {
|
|
raw.summary = {};
|
|
}
|
|
|
|
// Map synonyms for summary
|
|
if (raw.summary.judul && typeof raw.summary.headline !== 'string') {
|
|
raw.summary.headline = raw.summary.judul;
|
|
}
|
|
if (raw.summary.ringkasan && !Array.isArray(raw.summary.bullets)) {
|
|
raw.summary.bullets = raw.summary.ringkasan;
|
|
}
|
|
if (raw.summary.risiko && !Array.isArray(raw.summary.risks)) {
|
|
raw.summary.risks = raw.summary.risiko;
|
|
}
|
|
if (raw.summary.resiko && !Array.isArray(raw.summary.risks)) {
|
|
raw.summary.risks = raw.summary.resiko;
|
|
}
|
|
if (raw.summary.tindakan && !Array.isArray(raw.summary.actions)) {
|
|
raw.summary.actions = raw.summary.tindakan;
|
|
}
|
|
if (raw.summary.saran && !Array.isArray(raw.summary.actions)) {
|
|
raw.summary.actions = raw.summary.saran;
|
|
}
|
|
|
|
// Never invent a reassuring headline: the model failing to return one says
|
|
// nothing about the flock, and "terpantau normal" would mask a critical
|
|
// finding sitting in the sections below.
|
|
if (typeof raw.summary.headline !== 'string') {
|
|
raw.summary.headline = 'Ringkasan tidak tersedia — model tidak mengembalikan headline.';
|
|
}
|
|
if (!Array.isArray(raw.summary.bullets)) raw.summary.bullets = [];
|
|
if (!Array.isArray(raw.summary.risks)) raw.summary.risks = [];
|
|
if (!Array.isArray(raw.summary.actions)) raw.summary.actions = [];
|
|
|
|
raw.summary.bullets = raw.summary.bullets.map(String);
|
|
raw.summary.risks = raw.summary.risks.map(String);
|
|
raw.summary.actions = raw.summary.actions.map(String);
|
|
|
|
// 2. Heal detailed
|
|
if (!raw.detailed || typeof raw.detailed !== 'object' || Array.isArray(raw.detailed)) {
|
|
raw.detailed = {};
|
|
}
|
|
|
|
const detailedKeys = [
|
|
'hitung_ayam',
|
|
'berat_ayam',
|
|
'fcr',
|
|
'eef',
|
|
'panel_iot',
|
|
'hitung_karung',
|
|
'data_quality',
|
|
];
|
|
|
|
for (const key of detailedKeys) {
|
|
if (
|
|
!raw.detailed[key] ||
|
|
typeof raw.detailed[key] !== 'object' ||
|
|
Array.isArray(raw.detailed[key])
|
|
) {
|
|
raw.detailed[key] = {};
|
|
}
|
|
|
|
const section = raw.detailed[key];
|
|
|
|
// Map synonyms for detailed section keys
|
|
if (section.ringkasan && !Array.isArray(section.bullets)) {
|
|
section.bullets = section.ringkasan;
|
|
}
|
|
if (section.risiko && !Array.isArray(section.evidence)) {
|
|
section.evidence = section.risiko;
|
|
}
|
|
if (section.resiko && !Array.isArray(section.evidence)) {
|
|
section.evidence = section.resiko;
|
|
}
|
|
if (section.tindakan && !Array.isArray(section.actions)) {
|
|
section.actions = section.tindakan;
|
|
}
|
|
if (section.saran && !Array.isArray(section.actions)) {
|
|
section.actions = section.saran;
|
|
}
|
|
|
|
if (typeof section.status === 'string') {
|
|
section.status = section.status.toLowerCase().trim();
|
|
}
|
|
// A missing status means the model said nothing about this topic. Defaulting
|
|
// that to "ok" paints the UI green for a topic that was never assessed — the
|
|
// observed case was `hitung_karung` coming back "ok" with no bullets at all
|
|
// while no feed data had been sent. "unknown" is the only honest default.
|
|
if (!['ok', 'warning', 'critical', 'unknown'].includes(section.status)) {
|
|
section.status = 'unknown';
|
|
}
|
|
|
|
if (!Array.isArray(section.bullets)) section.bullets = [];
|
|
if (!Array.isArray(section.evidence)) section.evidence = [];
|
|
if (!Array.isArray(section.actions)) section.actions = [];
|
|
|
|
section.bullets = section.bullets.map(String);
|
|
section.evidence = section.evidence.map(String);
|
|
section.actions = section.actions.map(String);
|
|
|
|
// A section with no findings at all cannot be a clean bill of health, no
|
|
// matter what status the model attached to it.
|
|
const isEmpty =
|
|
section.bullets.length === 0 && section.evidence.length === 0 && section.actions.length === 0;
|
|
if (isEmpty) {
|
|
section.status = 'unknown';
|
|
section.bullets = [EMPTY_SECTION_PLACEHOLDER];
|
|
}
|
|
}
|
|
|
|
return raw;
|
|
};
|
|
|
|
/**
|
|
* True when `value` holds at least one usable reading, at any depth.
|
|
*
|
|
* Descriptive keys are skipped: every compacted block ships a constant `note`
|
|
* explaining its units, and counting that as data would make the block look
|
|
* populated even when every figure inside it is missing.
|
|
*/
|
|
const DESCRIPTIVE_KEYS = new Set(['note', 'catatan', 'periode', 'satuan', 'alasanTidakTersedia']);
|
|
|
|
const hasAnyReading = (value) => {
|
|
if (value === null || value === undefined) return false;
|
|
if (typeof value === 'number') return Number.isFinite(value);
|
|
if (typeof value === 'boolean') return true;
|
|
if (typeof value === 'string') return value.trim().length > 0 && value !== 'tidak tersedia';
|
|
if (Array.isArray(value)) return value.some(hasAnyReading);
|
|
if (typeof value === 'object') {
|
|
return Object.entries(value).some(
|
|
([key, item]) => !DESCRIPTIVE_KEYS.has(key) && hasAnyReading(item)
|
|
);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Force topics with no underlying data to report as unassessed.
|
|
*
|
|
* The prompt already tells the model to mark an empty topic "unknown", and it
|
|
* mostly complies — but on an end-cycle run with no IoT keys in the pack at all
|
|
* it still returned panel_iot as "ok": "Lingkungan dalam batas standar CP 707".
|
|
* An instruction the model may ignore is not a guarantee; this check is, because
|
|
* it reads the same context the model was given and overrides the verdict.
|
|
*/
|
|
/**
|
|
* Did the model actually say anything, or is this a healed-empty shell?
|
|
*
|
|
* `parseDashboardInsightJson` fills in every missing field so the UI never
|
|
* crashes on a partial reply. The side effect is that a reply in a completely
|
|
* wrong shape still comes back as a valid-looking object — one whose headline
|
|
* is the "tidak tersedia" placeholder and whose every array is empty. Callers
|
|
* need to tell that apart from a real report, or they will serve the shell.
|
|
*/
|
|
const hasUsableContent = (parsed) => {
|
|
if (!parsed || typeof parsed !== 'object') return false;
|
|
|
|
const summary = parsed.summary || {};
|
|
const headline = typeof summary.headline === 'string' ? summary.headline.trim() : '';
|
|
const hasHeadline = headline.length > 0 && !headline.startsWith('Ringkasan tidak tersedia');
|
|
const summaryHasList = ['bullets', 'risks', 'actions'].some(
|
|
(key) => Array.isArray(summary[key]) && summary[key].length > 0
|
|
);
|
|
|
|
// The healing fills every empty section with EMPTY_SECTION_PLACEHOLDER, so a
|
|
// plain length check finds "content" in all seven sections of a reply that
|
|
// said nothing. Only entries the model actually wrote count.
|
|
const detailed = parsed.detailed || {};
|
|
const detailedHasContent = Object.values(detailed).some(
|
|
(section) =>
|
|
section &&
|
|
typeof section === 'object' &&
|
|
['bullets', 'evidence', 'actions'].some(
|
|
(key) =>
|
|
Array.isArray(section[key]) &&
|
|
section[key].some(
|
|
(item) => typeof item === 'string' && item.trim() && item.trim() !== EMPTY_SECTION_PLACEHOLDER
|
|
)
|
|
)
|
|
);
|
|
|
|
return hasHeadline || summaryHasList || detailedHasContent;
|
|
};
|
|
|
|
const enforceDataAvailability = (parsed, contextPack) => {
|
|
if (!parsed || typeof parsed !== 'object' || !parsed.detailed) return parsed;
|
|
|
|
const topicSources = {
|
|
panel_iot: [
|
|
contextPack?.iotPanel?.pembacaan,
|
|
contextPack?.iotPanel?.display,
|
|
contextPack?.iot,
|
|
contextPack?.panel_iot,
|
|
],
|
|
hitung_karung: [
|
|
contextPack?.hitung_karung,
|
|
contextPack?.feedSack,
|
|
contextPack?.pakan,
|
|
contextPack?.chickenCounting?.feedSack,
|
|
],
|
|
};
|
|
|
|
const missing = [];
|
|
for (const [topic, sources] of Object.entries(topicSources)) {
|
|
const section = parsed.detailed[topic];
|
|
if (!section || typeof section !== 'object') continue;
|
|
if (sources.some(hasAnyReading)) continue;
|
|
|
|
missing.push(topic.replace(/_/g, ' '));
|
|
section.status = 'unknown';
|
|
section.bullets = [`Data ${topic.replace(/_/g, ' ')} tidak dikirim untuk periode ini.`];
|
|
section.evidence = [];
|
|
section.actions = [`Pastikan sumber data ${topic.replace(/_/g, ' ')} aktif dan terekam.`];
|
|
}
|
|
|
|
// With whole topics missing, the data cannot also be "lengkap". An observed
|
|
// end-cycle run reported data_quality "ok" — "Data operasional lengkap dan
|
|
// tidak ada anomali" — in the same response where two topics had no data at
|
|
// all. That is the one section whose job is to notice exactly this.
|
|
const dq = parsed.detailed.data_quality;
|
|
if (missing.length > 0 && dq && typeof dq === 'object' && dq.status === 'ok') {
|
|
dq.status = 'warning';
|
|
dq.bullets = [
|
|
`Data tidak lengkap: ${missing.join(' dan ')} tidak terkirim untuk periode ini.`,
|
|
...(Array.isArray(dq.bullets) ? dq.bullets : []),
|
|
].slice(0, 3);
|
|
}
|
|
|
|
return parsed;
|
|
};
|
|
|
|
/**
|
|
* Escapes raw control characters sitting INSIDE a JSON string literal.
|
|
*
|
|
* Now that each bullet has to carry a cause and its evidence, the model
|
|
* sometimes breaks one across lines — and it writes those breaks literally
|
|
* rather than as `\n`, which `JSON.parse` rejects as "Bad control character in
|
|
* string literal". A valid report would then be discarded and the whole
|
|
* generation demoted to the local fallback. Only string contents are touched;
|
|
* the structure is left as-is.
|
|
*/
|
|
const escapeRawControlChars = (json) => {
|
|
let out = '';
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (const char of json) {
|
|
if (!inString) {
|
|
if (char === '"') inString = true;
|
|
out += char;
|
|
continue;
|
|
}
|
|
if (escaped) {
|
|
escaped = false;
|
|
out += char;
|
|
continue;
|
|
}
|
|
if (char === '\\') {
|
|
escaped = true;
|
|
out += char;
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
inString = false;
|
|
out += char;
|
|
continue;
|
|
}
|
|
if (char === '\n') out += '\\n';
|
|
else if (char === '\r') out += '\\r';
|
|
else if (char === '\t') out += '\\t';
|
|
else if (char < ' ') out += `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
else out += char;
|
|
}
|
|
|
|
return out;
|
|
};
|
|
|
|
const cleanJsonComments = (str) => {
|
|
return escapeRawControlChars(str)
|
|
.replace(/\/\/.*/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\.\.\.[\s\S]*?(?=[,}\]\n])/g, '')
|
|
.replace(/,\s*([}\]])/g, '$1');
|
|
};
|
|
|
|
const parseDashboardInsightJson = (text) => {
|
|
const trimmed = text.trim();
|
|
const first = trimmed.indexOf('{');
|
|
const last = trimmed.lastIndexOf('}');
|
|
if (first === -1 || last === -1 || last <= first) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const candidate = cleanJsonComments(trimmed.slice(first, last + 1));
|
|
const rawParsed = JSON.parse(candidate);
|
|
const parsed = healDashboardInsightJson(rawParsed);
|
|
return isDashboardInsightJson(parsed) ? parsed : parsed?.summary ? parsed : null;
|
|
} catch (error) {
|
|
console.error('[Dashboard Insight Parse Error] Failed to parse/heal JSON:', error);
|
|
try {
|
|
const fallbackClean = cleanJsonComments(trimmed.slice(first, last + 1))
|
|
.replace(/["'][^"']*?\.\.\.[^"']*?["']/g, '""')
|
|
.replace(/\.\.\.[^,}\]\n]*/g, '');
|
|
const rawParsed = JSON.parse(fallbackClean);
|
|
const parsed = healDashboardInsightJson(rawParsed);
|
|
return parsed;
|
|
} catch (fallbackError) {
|
|
console.error('[Dashboard Insight Fallback Parse Error]:', fallbackError);
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const buildDashboardInsightPrompt = (
|
|
contextText,
|
|
dayAge,
|
|
reportType = 'daily',
|
|
reportPeriod = null,
|
|
rootCauseEvidence = '',
|
|
variationInstruction = ''
|
|
) => {
|
|
const bookRef = dayAge ? cp707.buildBookReferenceContext(dayAge) : '';
|
|
|
|
const requestedDay = reportType === 'daily' ? parseDayNumber(reportPeriod) : null;
|
|
const dailyDay = requestedDay ?? dayAge ?? null;
|
|
let taskInstruction = `Hasilkan LAPORAN HARIAN dengan hari ke-${dailyDay ?? '?'} sebagai hari terakhir periode.
|
|
Field yang bernilai null atau "tidak tersedia" memang TIDAK ada untuk periode ini. Nyatakan "data tidak tersedia" untuk field itu dan JANGAN mengarang angkanya.`;
|
|
if (requestedDay !== null) {
|
|
// The window runs day 0..N, so the model must not present a 31-day
|
|
// cumulative figure as one day's achievement. And without the explicit
|
|
// headline rule it produced a correct day-28 brief that never said "28"
|
|
// anywhere, making two different days print identical reports.
|
|
taskInstruction += `
|
|
Data yang diberikan mencakup hari ke-0 SAMPAI hari ke-${requestedDay} (kumulatif sejak awal siklus), bukan hari ke-${requestedDay} saja.
|
|
Angka kumulatif seperti mortalitas dan total panen adalah akumulasi SELURUH periode itu — jangan menyebutnya sebagai capaian satu hari.
|
|
Boleh membahas tren dari hari ke-0 sampai hari ke-${requestedDay}, tetapi JANGAN membahas hari setelah hari ke-${requestedDay}.
|
|
WAJIB: "headline" HARUS diawali dengan "Laporan Harian (Hari ke-${requestedDay}):" persis seperti itu, lalu diikuti ringkasan kondisinya.
|
|
Bandingkan angka kondisi akhir dengan standar CP 707 untuk hari ke-${requestedDay}, bukan hari lain, dan jangan menyebut angkanya sebagai kondisi "saat ini" atau "terkini".`;
|
|
}
|
|
if (reportType === 'weekly') {
|
|
taskInstruction = `Hasilkan LAPORAN MINGGUAN untuk ${reportPeriod || 'minggu ini'} saja. Data yang diberikan HANYA berisi hari-hari dalam ${reportPeriod || 'minggu ini'} — analisis rata-rata dan tren di dalam minggu tersebut dibandingkan standar CP 707. Jangan membahas minggu lain.
|
|
Angka ringkas seperti averageWeight, dailyGain, dan fcr adalah RATA-RATA minggu tersebut, bukan kondisi hari ini — sebut sebagai "rata-rata ${reportPeriod || 'minggu ini'}", jangan sebut "saat ini" atau "terkini".
|
|
Field yang bernilai null TIDAK tersedia untuk minggu ini. Nyatakan "data tidak tersedia" untuk field itu dan JANGAN mengarang angkanya.`;
|
|
} else if (reportType === 'end_cycle') {
|
|
taskInstruction = `Hasilkan LAPORAN AKHIR SIKLUS (EVALUASI PANEN) yang mendalam dan komprehensif untuk SELURUH siklus dari hari ke-1 sampai panen.
|
|
Data diberikan sebagai ringkasan per minggu ditambah kondisi hari terakhir.
|
|
Laporan WAJIB mengevaluasi keenam area berikut, masing-masing minimal satu temuan, dan sebutkan angkanya:
|
|
1. Hitung ayam (populasi, mortalitas, afkir, saldo akhir)
|
|
2. Berat ayam (bobot rata-rata, ADG, uniformity vs standar CP 707)
|
|
3. Hitung karung (konsumsi pakan)
|
|
4. Panel IoT (suhu, kelembapan, kondisi kandang)
|
|
5. FCR (efisiensi konversi pakan vs standar)
|
|
6. EEF (indeks efisiensi akhir)
|
|
Bandingkan tren dari minggu ke minggu, lalu identifikasi faktor utama penentu keberhasilan atau kegagalan siklus ini.
|
|
Jika data untuk salah satu area tidak tersedia, nyatakan secara eksplisit "data tidak tersedia" untuk area itu — jangan mengarang angka.`;
|
|
}
|
|
|
|
return `
|
|
Anda adalah analis operasional peternakan ayam broiler CP 707.
|
|
|
|
PANDUAN PENTING:
|
|
- Analisis HANYA berdasarkan standar buku "Manajemen Broiler CP 707" (PT Charoen Pokphand Indonesia, edisi Juli 2023).
|
|
- Semua perbandingan harus mengacu pada standar baku CP 707 yang tercantum di bawah ini.
|
|
- Jangan gunakan standar atau referensi dari luar buku CP 707.
|
|
- Risiko yang disebutkan HARUS bersumber dari deviasi data aktual vs standar CP 707.
|
|
|
|
ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR):
|
|
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
|
|
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
|
|
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
|
|
- Mortalitas < 5% = rendah/normal.
|
|
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
|
|
- Field "statusMortalitas" di data konteks sudah berisi grade yang benar — GUNAKAN grade itu secara konsisten di seluruh teks laporan.
|
|
|
|
ATURAN ANGKA (WAJIB DIPATUHI):
|
|
- Setiap angka yang Anda tulis HARUS ada di data konteks. DILARANG menghitung sendiri,
|
|
memperkirakan, atau menyalin angka dari field lain.
|
|
- Field yang berisi "tidak tersedia" berarti datanya memang tidak ada. Untuk field itu
|
|
tulis "data tidak tersedia". DILARANG mengisinya dengan angka apa pun.
|
|
- Jika sebuah topik tidak punya data sama sekali, set "status": "unknown" dan katakan
|
|
data tidak tersedia. JANGAN set "ok" — "ok" berarti sudah dinilai dan hasilnya baik.
|
|
- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD. Contoh: "deviasiBobot_gram" wajib
|
|
ditulis dengan satuan gram, "deviasiBobot_persen" wajib ditulis dengan tanda %.
|
|
DILARANG menukar keduanya. Field berakhiran "_rasio" tidak punya satuan.
|
|
- Nilai EEF hanya boleh diambil dari field "nilaiEef_indeksTanpaSatuan". Jika field itu
|
|
berisi "tidak tersedia", tulis "data tidak tersedia" — JANGAN memakai angka bobot atau
|
|
FCR sebagai pengganti EEF, dan jangan menulis satuan apa pun di belakang nilai EEF.
|
|
- Untuk laporan harian/mingguan, EEF masih berjalan dan wajar rendah karena ayam belum
|
|
panen. Jangan menilainya terhadap standar >300 yang berlaku di umur panen.
|
|
- PANEN BUKAN KEMATIAN. Populasi turun karena DUA sebab: mortalitas/afkir DAN panen.
|
|
Kalau "totalPanen_ekor" ada isinya, WAJIB sebutkan jumlahnya di topik "hitung_ayam"
|
|
(beserta "totalBeratPanen_kg" bila tersedia), dan DILARANG menghitung mortalitas dari
|
|
selisih populasi awal dikurangi populasi saat ini — pakai "mortalityRate" dan
|
|
"totalMortality" apa adanya.
|
|
|
|
${bookRef}
|
|
|
|
Tugas:
|
|
${taskInstruction}
|
|
Kembalikan HANYA satu objek JSON valid dengan struktur persis seperti berikut (tanpa ada teks penjelasan atau komentar di luar objek JSON):
|
|
|
|
{
|
|
"summary": {
|
|
"headline": "Satu kalimat ringkasan kondisi peternakan vs standar CP 707",
|
|
"bullets": ["Ringkasan tren 1 vs standar CP 707", "Ringkasan tren 2"],
|
|
"risks": ["Risiko berdasarkan deviasi dari standar CP 707", "Risiko 2"],
|
|
"actions": ["Tindakan sesuai panduan CP 707", "Tindakan 2"]
|
|
},
|
|
"detailed": {
|
|
"hitung_ayam": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["Perbandingan populasi vs standar CP 707", "Jumlah ayam yang sudah dipanen (ekor) dan bobot panen (kg) bila tersedia"],
|
|
"evidence": ["Deviasi mortalitas dari standar kumulatif CP 707 hari ke-X"],
|
|
"actions": ["Tindakan sesuai buku CP 707"]
|
|
},
|
|
"berat_ayam": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["Bobot aktual vs target standar CP 707"],
|
|
"evidence": ["Deviasi % dari standar CP 707"],
|
|
"actions": ["Tindakan korektif sesuai CP 707"]
|
|
},
|
|
"fcr": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["FCR aktual vs standar harian CP 707"],
|
|
"evidence": ["Besaran deviasi FCR dari standar CP 707"],
|
|
"actions": ["Tindakan manajemen pakan sesuai CP 707"]
|
|
},
|
|
"eef": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["Nilai EEF aktual"],
|
|
"evidence": ["Pengaruh deplesi/FCR terhadap EEF"],
|
|
"actions": ["Tindakan optimasi berdasarkan parameter CP 707"]
|
|
},
|
|
"hitung_karung": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["Konsumsi pakan (karung dituang) vs pertumbuhan bobot"],
|
|
"evidence": ["Angka karung masuk, dituang, dan saldo dari data hitung_karung"],
|
|
"actions": ["Tindakan manajemen pakan sesuai CP 707"]
|
|
},
|
|
"panel_iot": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["Kondisi lingkungan vs standar suhu & kelembapan CP 707"],
|
|
"evidence": ["Deviasi suhu/amonia dari standar Lampiran 3 CP 707"],
|
|
"actions": ["Tindakan manajemen ventilasi sesuai CP 707"]
|
|
},
|
|
"data_quality": {
|
|
"status": "ok atau warning atau critical",
|
|
"bullets": ["Catatan kualitas data operasional"],
|
|
"evidence": ["Anomali atau ketidakkonsistenan data"],
|
|
"actions": ["Tindakan perbaikan data"]
|
|
}
|
|
}
|
|
}
|
|
|
|
Aturan Analisis:
|
|
1. Status hanya boleh bernilai "ok", "warning", atau "critical".
|
|
2. Tulis dalam Bahasa Indonesia yang profesional dan lugas.
|
|
3. Maksimal 3 poin untuk setiap array bullets, evidence, dan actions agar ringkas.
|
|
4. Bandingkan setiap parameter dengan standar CP 707 yang diberikan di atas.
|
|
5. Jika tidak ada data IoT, set panel_iot.status = "unknown" dan jelaskan di data_quality.
|
|
6. WAJIB mengisi KEENAM topik di "detailed" — termasuk "hitung_karung". Jangan mengosongkan
|
|
satu topik pun. Data pakan ada di blok "hitung_karung" pada DATA OPERASIONAL: pakai
|
|
"totalKarungDituang_karung", "totalKarungMasuk_karung", "saldoKarung_karung", dan
|
|
"riwayatHarian". Semua angka bersatuan KARUNG.
|
|
7. Kaitkan konsumsi pakan dengan pertumbuhan: karung dituang naik tetapi bobot stagnan
|
|
berarti efisiensi pakan memburuk (lihat juga FCR).
|
|
8. Setiap "bullets" pada topik yang statusnya "warning" atau "critical" WAJIB memuat
|
|
SEBAB dari penyimpangan itu, bukan hanya menyatakan bahwa angkanya menyimpang.
|
|
DILARANG menulis bullet seperti "Mortalitas 9,1% di atas standar 4,1%" tanpa
|
|
menyebutkan kenapa. Tulis seperti ini: "Mortalitas 9,1% (2.300 ekor), 61% dari
|
|
kematian itu terkumpul di hari 12-15 yang didahului suhu 5°C di atas target CP 707".
|
|
9. "evidence" berisi angka pendukung sebab tersebut: hari kejadiannya, besarnya deviasi,
|
|
dan metrik lain yang bergerak lebih dulu. Ambil dari blok TEMUAN TERUKUR di bawah
|
|
(kalau ada) atau dari DATA OPERASIONAL. Jika sebuah sebab tidak punya angka pendukung,
|
|
tulis di depannya "[Dugaan]" dan sebutkan apa yang harus diperiksa di lapangan.
|
|
|
|
${ROOT_CAUSE_REASONING_RULES}
|
|
|
|
DATA OPERASIONAL:
|
|
${contextText}
|
|
${rootCauseEvidence ? `\n${rootCauseEvidence}` : ''}
|
|
${variationInstruction}
|
|
`.trim();
|
|
};
|
|
|
|
const buildCacheKey = (cycleId, kandangId, version, reportType = 'daily', reportPeriod = null) =>
|
|
`${cycleId}:${kandangId || 'all'}:${version}:${reportType}:${reportPeriod || 'current'}`;
|
|
|
|
const toNumber = (value) => {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : null;
|
|
};
|
|
|
|
const buildChickenCountingSummary = async (cycleId, kandangId, periodLastDay = null) => {
|
|
const cycle = await Cycle.getById(cycleId);
|
|
if (!cycle || !cycle.startDate) {
|
|
return null;
|
|
}
|
|
|
|
const kandangs = await Kandang.getByCycle(cycleId);
|
|
const selectedKandangId =
|
|
kandangId !== null && kandangId !== undefined ? Number(kandangId) : null;
|
|
const selectedKandang = Number.isInteger(selectedKandangId)
|
|
? kandangs.find((item) => item.id === selectedKandangId)
|
|
: null;
|
|
|
|
const initialPopulation =
|
|
selectedKandangId !== null
|
|
? selectedKandang?.docInCount || 0
|
|
: kandangs.reduce((sum, item) => sum + (item.docInCount || 0), 0) || cycle.docInCount || 0;
|
|
|
|
const cycleStartDate = formatDateForDb(new Date(cycle.startDate));
|
|
const today = formatDateForDb(new Date());
|
|
const chickenCounts = await ChickenCount.getByDateRange(cycleStartDate, today, selectedKandangId);
|
|
|
|
const latestChickenCount =
|
|
chickenCounts.length > 0
|
|
? [...chickenCounts].sort(
|
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
|
)[0]
|
|
: null;
|
|
|
|
const allMortalityRecords = await Mortality.getByCycle(cycleId, selectedKandangId);
|
|
// Cumulative up to the period's closing day, not up to today. Records with no
|
|
// day are kept only for a whole-cycle report, where nothing is being excluded.
|
|
const mortalityRecords =
|
|
periodLastDay === null
|
|
? allMortalityRecords
|
|
: allMortalityRecords.filter(
|
|
(record) => typeof record.day === 'number' && record.day <= periodLastDay
|
|
);
|
|
const totalMortality = mortalityRecords.reduce(
|
|
(sum, record) => sum + (record.mortalityCount || 0) + (record.afkir || 0),
|
|
0
|
|
);
|
|
const totalPanen = mortalityRecords.reduce((sum, record) => sum + (record.panen || 0), 0);
|
|
const totalBeratPanen = mortalityRecords.reduce(
|
|
(sum, record) => sum + (record.beratPanen || 0),
|
|
0
|
|
);
|
|
// A rate needs a denominator. `docInCount` is currently empty for every
|
|
// kandang in this database, and reporting 0% for an unknown denominator reads
|
|
// as "no deaths at all" — the most reassuring possible reading of missing
|
|
// data, next to 5248 recorded deaths. null forces callers to say "unknown".
|
|
const mortalityRate =
|
|
initialPopulation > 0 ? Number(((totalMortality / initialPopulation) * 100).toFixed(2)) : null;
|
|
// `latestChickenCount` is always TODAY's count, so it must not stand in for a
|
|
// past period's population — it would contradict the period-scoped mortality
|
|
// right beside it. Derive it from the scoped totals instead.
|
|
const currentPopulation =
|
|
latestChickenCount && periodLastDay === null
|
|
? latestChickenCount.totalCount
|
|
: Math.max(0, initialPopulation - totalMortality - totalPanen);
|
|
|
|
return {
|
|
source: {
|
|
cycleStartDate,
|
|
selectedKandangId,
|
|
records: chickenCounts.length,
|
|
hasLatestChickenCount: Boolean(latestChickenCount),
|
|
},
|
|
summary: {
|
|
initialPopulation,
|
|
currentPopulation,
|
|
totalMortality,
|
|
// Unit-suffixed, because the prompt tells the model every number carries
|
|
// its unit in the field name. Bare `totalPanen` left it unable to tell
|
|
// ekor from kg, so it left the harvest out of the report entirely.
|
|
totalPanen_ekor: totalPanen,
|
|
totalBeratPanen_kg: totalBeratPanen > 0 ? Number(totalBeratPanen.toFixed(2)) : null,
|
|
// The single most misread figure on this page: population falls for two
|
|
// unrelated reasons and only one of them is a loss.
|
|
panenCatatan:
|
|
totalPanen > 0
|
|
? `${totalPanen} ekor sudah DIPANEN (keluar kandang dalam keadaan hidup, bukan mati). Penurunan populasi dari ${initialPopulation} ke ${currentPopulation} ekor adalah gabungan mortalitas DAN panen — jangan menyebut selisih populasi sebagai kematian.`
|
|
: undefined,
|
|
mortalityRate,
|
|
mortalityRateCatatan:
|
|
mortalityRate === null
|
|
? 'Populasi awal (docInCount) tidak terisi di database, sehingga persentase mortalitas TIDAK DAPAT dihitung. Sebut "data tidak tersedia" — jangan mengarang angka persen.'
|
|
: undefined,
|
|
latestChickenCount: latestChickenCount
|
|
? {
|
|
date: latestChickenCount.date,
|
|
totalCount: latestChickenCount.totalCount,
|
|
}
|
|
: null,
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Last cycle day the report covers, or null for "the whole cycle".
|
|
*
|
|
* The DB-derived figures below are cumulative, so they have to stop at the
|
|
* period's closing day. Without this a "Hari 28" report quoted the day-48
|
|
* mortality rate (9.91%) beside a KPI tile correctly showing 3.54% for day 28,
|
|
* and the model had no way to tell which was right.
|
|
*/
|
|
const periodLastDayOf = (reportType, reportPeriod) => {
|
|
if (reportType === 'daily') return parseDayNumber(reportPeriod);
|
|
if (reportType === 'weekly') return parseWeekNumber(reportPeriod) * 7;
|
|
return null;
|
|
};
|
|
|
|
const enrichContextPack = async (contextPack, cycleId, kandangId, periodLastDay = null) => {
|
|
const chickenCountingSummary = await buildChickenCountingSummary(
|
|
cycleId,
|
|
kandangId,
|
|
periodLastDay
|
|
);
|
|
if (!chickenCountingSummary) {
|
|
return contextPack;
|
|
}
|
|
|
|
return {
|
|
...contextPack,
|
|
chickenCounting: {
|
|
...contextPack.chickenCounting,
|
|
dashboardSummary: chickenCountingSummary.summary,
|
|
dashboardSummarySource: chickenCountingSummary.source,
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Week number requested by a 'weekly' report, e.g. "Minggu 7" -> 7. */
|
|
const parseWeekNumber = (reportPeriod) => {
|
|
const match = reportPeriod && String(reportPeriod).match(/\d+/);
|
|
const week = match ? parseInt(match[0], 10) : 1;
|
|
return Number.isFinite(week) && week > 0 ? week : 1;
|
|
};
|
|
|
|
/**
|
|
* Day number requested by a 'daily' report, e.g. "Hari 12" -> 12.
|
|
*
|
|
* Returns null when no day was requested, which keeps the old behaviour
|
|
* (report on the latest day present in the data) for callers that still send
|
|
* no period — the dashboard before this dropdown existed, and any cached row
|
|
* written back then.
|
|
*/
|
|
const parseDayNumber = (reportPeriod) => {
|
|
const match = reportPeriod && String(reportPeriod).match(/\d+/);
|
|
if (!match) return null;
|
|
const day = parseInt(match[0], 10);
|
|
return Number.isFinite(day) && day > 0 ? day : null;
|
|
};
|
|
|
|
/**
|
|
* A day-indexed history (weight per day, FCR per day, ...) as opposed to a
|
|
* snapshot list (devices, cameras, coops) that has no time dimension at all.
|
|
* Only the former may be sliced by report period — slicing a device list by
|
|
* "week 7" would silently drop devices, which is what the previous
|
|
* index-based pruning did.
|
|
*/
|
|
const isDayIndexedArray = (arr) =>
|
|
arr.length > 0 &&
|
|
arr.every(
|
|
(item) =>
|
|
item &&
|
|
typeof item === 'object' &&
|
|
!Array.isArray(item) &&
|
|
(typeof item.day === 'number' || typeof item.date === 'string')
|
|
);
|
|
|
|
const dayOfItem = (item) => (typeof item.day === 'number' ? item.day : null);
|
|
|
|
/** Mean of every numeric field across `items`, carrying non-numeric fields from the last row. */
|
|
const rollUpRows = (items) => {
|
|
const sums = {};
|
|
const counts = {};
|
|
|
|
for (const item of items) {
|
|
for (const [key, value] of Object.entries(item)) {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
sums[key] = (sums[key] || 0) + value;
|
|
counts[key] = (counts[key] || 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const averaged = {};
|
|
for (const key of Object.keys(sums)) {
|
|
averaged[key] = Math.round((sums[key] / counts[key]) * 100) / 100;
|
|
}
|
|
return averaged;
|
|
};
|
|
|
|
/**
|
|
* Collapse a full-cycle history so an end-of-cycle report can carry EVERY topic
|
|
* (population, weight, feed sacks, IoT, FCR, EEF) at once without overflowing
|
|
* the local model's context window: one averaged row per completed week, then
|
|
* the final week's days verbatim, because "how did the cycle end" is the
|
|
* question an end-cycle report exists to answer.
|
|
*
|
|
* The result stays an ARRAY of day-bearing rows. Downstream extraction reads
|
|
* these fields positionally (e.g. the last element for the closing figures), so
|
|
* returning an object here silently knocked the whole report into the local
|
|
* fallback generator.
|
|
*/
|
|
const rollUpByWeek = (items) => {
|
|
const weeks = new Map();
|
|
|
|
items.forEach((item, index) => {
|
|
const day = dayOfItem(item);
|
|
const week = day != null ? Math.ceil(day / 7) : Math.floor(index / 7) + 1;
|
|
if (!weeks.has(week)) weeks.set(week, []);
|
|
weeks.get(week).push(item);
|
|
});
|
|
|
|
const ordered = [...weeks.entries()].sort((a, b) => a[0] - b[0]);
|
|
if (ordered.length === 0) return items;
|
|
|
|
const lastWeekRows = ordered[ordered.length - 1][1];
|
|
|
|
const summarised = ordered.slice(0, -1).map(([week, rows]) => {
|
|
const lastRow = rows[rows.length - 1];
|
|
return {
|
|
...rollUpRows(rows),
|
|
// Keep a real day on the row so day-based extraction still works.
|
|
day: dayOfItem(lastRow) ?? undefined,
|
|
minggu: week,
|
|
jumlahHari: rows.length,
|
|
ringkasan: 'rata-rata minggu ini',
|
|
};
|
|
});
|
|
|
|
return [...summarised, ...lastWeekRows];
|
|
};
|
|
|
|
/**
|
|
* Narrow the context pack to exactly the period the user asked for.
|
|
*
|
|
* daily -> only the latest day present in each history
|
|
* weekly N -> only days (N-1)*7+1 .. N*7, matched on the row's own `day`
|
|
* field rather than its array position, so gaps in the data do
|
|
* not shift the window
|
|
* end_cycle -> the whole cycle, rolled up per week, with nothing dropped
|
|
*/
|
|
const pruneContextPackForLocalLLM = (obj, reportType = 'daily', reportPeriod = null) => {
|
|
if (!obj || typeof obj !== 'object') {
|
|
return obj;
|
|
}
|
|
|
|
if (Array.isArray(obj)) {
|
|
const recurse = (items) =>
|
|
items.map((item) => pruneContextPackForLocalLLM(item, reportType, reportPeriod));
|
|
|
|
// Snapshot lists carry no time dimension: keep them whole in every mode.
|
|
if (!isDayIndexedArray(obj)) {
|
|
return recurse(obj);
|
|
}
|
|
|
|
if (reportType === 'daily') {
|
|
const requestedDay = parseDayNumber(reportPeriod);
|
|
const days = obj.map(dayOfItem).filter((day) => day != null);
|
|
if (days.length > 0) {
|
|
// Picking a day means "the cycle so far, up to that day" — day 0 through
|
|
// day N — so trends and cumulative figures have the history they need.
|
|
if (requestedDay != null) {
|
|
return recurse(obj.filter((item) => (dayOfItem(item) ?? Infinity) <= requestedDay));
|
|
}
|
|
return recurse(obj.filter((item) => dayOfItem(item) === Math.max(...days)));
|
|
}
|
|
// Date-only history: the array position is the only ordering we have.
|
|
if (requestedDay != null) {
|
|
return recurse(obj.slice(0, requestedDay + 1));
|
|
}
|
|
return recurse(obj.slice(-1));
|
|
}
|
|
|
|
if (reportType === 'weekly') {
|
|
const week = parseWeekNumber(reportPeriod);
|
|
const firstDay = (week - 1) * 7 + 1;
|
|
const lastDay = week * 7;
|
|
const hasDayNumbers = obj.some((item) => dayOfItem(item) != null);
|
|
|
|
if (hasDayNumbers) {
|
|
// Return the window even when it is empty: a week with no recorded data
|
|
// must read as "no data", never as some other week's numbers.
|
|
return recurse(
|
|
obj.filter((item) => {
|
|
const day = dayOfItem(item);
|
|
return day != null && day >= firstDay && day <= lastDay;
|
|
})
|
|
);
|
|
}
|
|
|
|
// Date-only history: the array position is the only ordering we have.
|
|
return recurse(obj.slice(firstDay - 1, lastDay));
|
|
}
|
|
|
|
if (reportType === 'end_cycle') {
|
|
return recurse(rollUpByWeek(obj));
|
|
}
|
|
|
|
return recurse(obj);
|
|
}
|
|
|
|
const pruned = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
// Raw telemetry is too bulky for a daily/weekly brief, but an end-of-cycle
|
|
// evaluation is explicitly meant to cover every data source on the site,
|
|
// so it keeps these (already rolled up per week by the branch above).
|
|
const heavyKeysToDrop = ['deviceWeightData', 'ckaleData', 'liveCounts'];
|
|
if (reportType !== 'end_cycle' && heavyKeysToDrop.includes(key)) {
|
|
continue;
|
|
}
|
|
pruned[key] = pruneContextPackForLocalLLM(value, reportType, reportPeriod);
|
|
}
|
|
return pruned;
|
|
};
|
|
|
|
/** Mean of `key` across `rows`, ignoring rows where it is missing or not finite. */
|
|
const meanOfField = (rows, key) => {
|
|
if (!Array.isArray(rows)) return null;
|
|
const values = rows
|
|
.map((row) => (row && typeof row === 'object' ? row[key] : null))
|
|
.filter((value) => typeof value === 'number' && Number.isFinite(value));
|
|
if (values.length === 0) return null;
|
|
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
return Math.round(mean * 100) / 100;
|
|
};
|
|
|
|
/** First of `keys` that yields a mean over `rows`. Histories disagree on names. */
|
|
const meanOfFirstField = (rows, keys) => {
|
|
for (const key of keys) {
|
|
const mean = meanOfField(rows, key);
|
|
if (mean !== null) return mean;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** The day numbers actually present in a windowed history, for labelling. */
|
|
const dayRangeOf = (rows) => {
|
|
if (!Array.isArray(rows)) return null;
|
|
const days = rows.map((row) => (row && typeof row === 'object' ? dayOfItem(row) : null));
|
|
const present = days.filter((day) => day != null);
|
|
if (present.length === 0) return null;
|
|
return {
|
|
firstDay: Math.min(...present),
|
|
lastDay: Math.max(...present),
|
|
dayCount: present.length,
|
|
};
|
|
};
|
|
|
|
/** Copy of `obj` without `keys` — used to drop unitless duplicates. */
|
|
const omit = (obj, keys) => {
|
|
const copy = { ...obj };
|
|
for (const key of keys) delete copy[key];
|
|
return copy;
|
|
};
|
|
|
|
const UNAVAILABLE_MARK = 'tidak tersedia';
|
|
const numOrNull = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
const round2 = (value) => Math.round(value * 100) / 100;
|
|
|
|
/**
|
|
* Attach the computed EEF and the unit labels to an end-cycle pack.
|
|
*
|
|
* The period rescoping does not apply here — the closing figures are already the
|
|
* right ones — but the two fabrication guards do. Without a real `eef.value` the
|
|
* model fills the gap itself: an observed end-cycle report announced "Nilai EEF
|
|
* 1.78 (naik dari 1.62)", which is simply the FCR pair wearing an EEF label.
|
|
*/
|
|
const annotateEndCycleScalars = (pack) => {
|
|
const current = pack?.weight?.current;
|
|
if (!current || typeof current !== 'object') return pack;
|
|
|
|
const avg = numOrNull(current.averageWeight);
|
|
const target = numOrNull(current.targetWeight);
|
|
const fcr = numOrNull(current.fcr);
|
|
const day = numOrNull(pack?.metadata?.currentDay);
|
|
const mortality = numOrNull(pack?.chickenCounting?.dashboardSummary?.mortalityRate);
|
|
const survival = mortality === null ? null : 100 - mortality;
|
|
|
|
const eefValue =
|
|
survival !== null && avg !== null && fcr && day
|
|
? round2(((survival * (avg / 1000)) / (fcr * day)) * 100)
|
|
: null;
|
|
|
|
const scoped = { ...pack };
|
|
scoped.weight = {
|
|
...pack.weight,
|
|
current: {
|
|
...omit(current, ['deviation', 'deviationPersen']),
|
|
// Same unit-in-the-key rule as the weekly path.
|
|
bobotRataRata_gram: avg ?? UNAVAILABLE_MARK,
|
|
targetBobot_gram: target ?? UNAVAILABLE_MARK,
|
|
deviasiBobot_gram: avg !== null && target !== null ? round2(avg - target) : UNAVAILABLE_MARK,
|
|
deviasiBobot_persen:
|
|
avg !== null && target ? round2(((avg - target) / target) * 100) : UNAVAILABLE_MARK,
|
|
fcr_rasio: fcr ?? UNAVAILABLE_MARK,
|
|
},
|
|
};
|
|
|
|
if (pack.fcr_eef && typeof pack.fcr_eef === 'object') {
|
|
const eefBlock = pack.fcr_eef.eef;
|
|
if (eefBlock && typeof eefBlock === 'object') {
|
|
scoped.fcr_eef = {
|
|
...pack.fcr_eef,
|
|
eef: {
|
|
...omit(eefBlock, [
|
|
'value',
|
|
'avgWeight',
|
|
'fcr',
|
|
'survivalPersen',
|
|
'umurHari',
|
|
'currentDay',
|
|
]),
|
|
nilaiEef_indeksTanpaSatuan: eefValue ?? UNAVAILABLE_MARK,
|
|
persenHidup_persen: survival ?? UNAVAILABLE_MARK,
|
|
umur_hari: day ?? UNAVAILABLE_MARK,
|
|
...(eefValue === null
|
|
? {
|
|
alasanTidakTersedia:
|
|
'EEF tidak dapat dihitung karena persen hidup, bobot, FCR, atau umur tidak tersedia. ' +
|
|
'Tulis "data tidak tersedia" — JANGAN memakai angka FCR atau bobot sebagai nilai EEF.',
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return scoped;
|
|
};
|
|
|
|
/**
|
|
* Bring the pack's scalars into the same period as its histories.
|
|
*
|
|
* `averageWeight`, `fcr`, `uniformity` and friends are single "as of today"
|
|
* figures carrying no `day` of their own, so the array pruning above cannot
|
|
* touch them. Left alone they contradict the windowed histories the model reads
|
|
* beside them: a "Minggu 1" brief would show weight rows ending at 180 g next to
|
|
* `averageWeight: 2400` (the day-48 figure), and the model is free to quote
|
|
* either. That is the bug this function exists to close.
|
|
*
|
|
* Each scalar is recomputed from the ALREADY-WINDOWED history it summarises —
|
|
* the mean across the window's days. A scalar with no day-indexed source
|
|
* (uniformity, and every status/trend label, which were derived for today) is
|
|
* set to null rather than left showing another period's number, because a wrong
|
|
* number reads as fact while a null reads as "not available".
|
|
*
|
|
* End-cycle reports keep their scalars: there the window IS the whole cycle, so
|
|
* the closing figures are exactly what the scalars already hold. They still get
|
|
* the computed EEF and the unit labels, because the fabrication those prevent
|
|
* happens in every mode — an end-cycle run reported "Nilai EEF 1.78" by copying
|
|
* the FCR when the eef block carried no value of its own.
|
|
*/
|
|
const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null) => {
|
|
if (!pack || typeof pack !== 'object' || Array.isArray(pack)) return pack;
|
|
if (reportType === 'end_cycle') return annotateEndCycleScalars(pack);
|
|
|
|
const weight = pack.weight;
|
|
if (!weight || typeof weight !== 'object') return pack;
|
|
|
|
const weightRows =
|
|
Array.isArray(weight.dailyWeightData) && weight.dailyWeightData.length > 0
|
|
? weight.dailyWeightData
|
|
: weight.weightHistory;
|
|
const gainRows = weight.dailyWeightGainData;
|
|
const fcrRows = weight.fcrComparisonData;
|
|
|
|
const averageWeight = meanOfField(weightRows, 'actual');
|
|
const targetWeight = meanOfField(weightRows, 'target');
|
|
const dailyGain = meanOfField(gainRows, 'gain');
|
|
const fcr = meanOfFirstField(fcrRows, ['manualFcr', 'iotFcr']);
|
|
const fcrCobbStandard = meanOfField(fcrRows, 'cobbFcr');
|
|
const range = dayRangeOf(weightRows) || dayRangeOf(gainRows) || dayRangeOf(fcrRows);
|
|
|
|
const periodLabel =
|
|
reportType === 'weekly'
|
|
? `${reportPeriod || 'Minggu ini'} (hari ke-${range?.firstDay ?? '?'} s/d ke-${range?.lastDay ?? '?'})`
|
|
: range && range.firstDay !== range.lastDay
|
|
? `Harian (hari ke-${range.firstDay} s/d ke-${range.lastDay}, kumulatif)`
|
|
: `Harian (hari ke-${range?.lastDay ?? '?'})`;
|
|
|
|
const round2 = (value) => Math.round(value * 100) / 100;
|
|
|
|
// Uniformity is reported as a real 0 when the day has no weighing, so it
|
|
// must not go through the null-ing helpers the other scalars use.
|
|
const numberOrZero = (value) => {
|
|
const n = typeof value?.value === 'number' ? value.value : value;
|
|
return typeof n === 'number' && Number.isFinite(n) ? n : 0;
|
|
};
|
|
|
|
// `cleanPromptValue` strips nulls out of the prompt entirely, so a null scalar
|
|
// reaches the model as a MISSING key — indistinguishable from a field that was
|
|
// never part of the schema, and easy to fill in from imagination. An explicit
|
|
// marker is what actually makes the model write "data tidak tersedia".
|
|
const UNAVAILABLE = 'tidak tersedia';
|
|
const orUnavailable = (value) =>
|
|
value === null || value === undefined || Number.isNaN(value) ? UNAVAILABLE : value;
|
|
|
|
// A bare `deviation: -32.86` was read back by the model as "-32.86%" when it
|
|
// is grams. Supplying the percentage outright removes the guess.
|
|
const deviationGram =
|
|
averageWeight !== null && targetWeight !== null ? round2(averageWeight - targetWeight) : null;
|
|
const deviationPersen =
|
|
deviationGram !== null && targetWeight ? round2((deviationGram / targetWeight) * 100) : null;
|
|
const fcrDeviationPersen =
|
|
fcr !== null && fcrCobbStandard
|
|
? round2(((fcr - fcrCobbStandard) / fcrCobbStandard) * 100)
|
|
: null;
|
|
|
|
// EEF was being invented: the block carried only inputs and no value, so the
|
|
// model helpfully supplied one (it echoed averageWeight, 327.14, as the EEF).
|
|
// Compute it where every input exists, and state plainly when they do not.
|
|
const mortalityRate = pack?.chickenCounting?.dashboardSummary?.mortalityRate;
|
|
const survivalPersen =
|
|
typeof mortalityRate === 'number' && Number.isFinite(mortalityRate)
|
|
? 100 - mortalityRate
|
|
: null;
|
|
const eefDay = range?.lastDay ?? null;
|
|
const eefValue =
|
|
survivalPersen !== null && averageWeight !== null && fcr && eefDay
|
|
? round2(((survivalPersen * (averageWeight / 1000)) / (fcr * eefDay)) * 100)
|
|
: null;
|
|
const eefAlasan =
|
|
eefValue !== null
|
|
? undefined
|
|
: survivalPersen === null
|
|
? 'EEF tidak dapat dihitung: persentase hidup tidak diketahui karena populasi awal (docInCount) kosong di database. Jangan mengarang nilai EEF.'
|
|
: 'EEF tidak dapat dihitung: bobot rata-rata atau FCR periode ini tidak tersedia. Jangan mengarang nilai EEF.';
|
|
|
|
const scoped = { ...pack };
|
|
|
|
// Drop the original unitless numeric keys rather than spreading them through:
|
|
// leaving `averageWeight: 2400` beside `bobotRataRata_gram: 327.14` would hand
|
|
// the model both the stale figure and the period one and let it pick.
|
|
const carriedCurrent = {
|
|
...(weight.current && typeof weight.current === 'object' ? weight.current : {}),
|
|
};
|
|
for (const key of [
|
|
'averageWeight',
|
|
'targetWeight',
|
|
'deviation',
|
|
'deviationPersen',
|
|
'dailyGain',
|
|
'fcr',
|
|
'fcrCobbStandard',
|
|
'fcrDeviationPersen',
|
|
]) {
|
|
delete carriedCurrent[key];
|
|
}
|
|
|
|
scoped.weight = {
|
|
...weight,
|
|
current: {
|
|
...carriedCurrent,
|
|
periode: periodLabel,
|
|
// The unit lives in the KEY, not in a lookup table beside it: given a bare
|
|
// `deviation` plus a separate `satuan` map, the model quoted "-4 gram" for
|
|
// a value that was -4 PERCENT. A key it cannot read without reading the
|
|
// unit removes the choice.
|
|
// Bobot mengikuti aturan yang sama dengan uniformity: hari tanpa
|
|
// penimbangan dilaporkan 0, bukan "tidak tersedia".
|
|
bobotRataRata_gram: numberOrZero(averageWeight),
|
|
targetBobot_gram: orUnavailable(targetWeight),
|
|
deviasiBobot_gram: orUnavailable(deviationGram),
|
|
deviasiBobot_persen: orUnavailable(deviationPersen),
|
|
pertambahanHarian_gramPerHari: orUnavailable(dailyGain),
|
|
fcr_rasio: orUnavailable(fcr),
|
|
fcrStandarCobb_rasio: orUnavailable(fcrCobbStandard),
|
|
deviasiFcr_persen: orUnavailable(fcrDeviationPersen),
|
|
// No day-indexed source exists for these, so they cannot be re-scoped.
|
|
status: UNAVAILABLE,
|
|
dailyGainStatus: UNAVAILABLE,
|
|
// Uniformity is the exception: it has no per-day column either, but the
|
|
// supervisor wants the latest API figure carried through rather than
|
|
// blanked, because that is the number users see on the Berat Ayam page.
|
|
// A day with no weighing (day 48) reports 0, not "tidak tersedia".
|
|
uniformity: numberOrZero(weight.current?.uniformity),
|
|
uniformityStatus: weight.current?.uniformityStatus ?? UNAVAILABLE,
|
|
fcrStatus: UNAVAILABLE,
|
|
fcrTrend: UNAVAILABLE,
|
|
catatan:
|
|
`Semua angka di blok ini adalah rata-rata periode ${periodLabel}` +
|
|
`${range ? ` dari ${range.dayCount} hari data` : ''}, BUKAN kondisi hari ini. ` +
|
|
`Field yang berisi "${UNAVAILABLE}" memang tidak ada datanya untuk periode ini — ` +
|
|
'WAJIB tulis "data tidak tersedia" untuk field itu dan JANGAN mengarang angkanya. ' +
|
|
'Satuan setiap angka sudah tertulis di akhir nama field ' +
|
|
'(_gram, _persen, _rasio, _gramPerHari) — pakai satuan itu persis, jangan menggantinya.',
|
|
},
|
|
};
|
|
|
|
// The same figures are mirrored under fcr_eef; leaving those stale would just
|
|
// reintroduce the contradiction one key over.
|
|
if (pack.fcr_eef && typeof pack.fcr_eef === 'object') {
|
|
const { fcr: fcrBlock, eef: eefBlock, ...restFcrEef } = pack.fcr_eef;
|
|
scoped.fcr_eef = {
|
|
...restFcrEef,
|
|
...(fcrBlock && typeof fcrBlock === 'object'
|
|
? {
|
|
fcr: {
|
|
...omit(fcrBlock, ['actual', 'cobbStandard', 'deviationPersen']),
|
|
periode: periodLabel,
|
|
aktual_rasio: orUnavailable(fcr),
|
|
standarCobb_rasio: orUnavailable(fcrCobbStandard),
|
|
deviasi_persen: orUnavailable(fcrDeviationPersen),
|
|
status: UNAVAILABLE,
|
|
trend: UNAVAILABLE,
|
|
},
|
|
}
|
|
: {}),
|
|
...(eefBlock && typeof eefBlock === 'object'
|
|
? {
|
|
eef: {
|
|
...omit(eefBlock, [
|
|
'value',
|
|
'avgWeight',
|
|
'fcr',
|
|
'survivalPersen',
|
|
'umurHari',
|
|
'currentDay',
|
|
]),
|
|
periode: periodLabel,
|
|
// `value` is the only field the model may quote as "the EEF".
|
|
nilaiEef_indeksTanpaSatuan: orUnavailable(eefValue),
|
|
// Bobot mengikuti aturan yang sama dengan uniformity: hari tanpa
|
|
// penimbangan dilaporkan 0, bukan "tidak tersedia".
|
|
bobotRataRata_gram: numberOrZero(averageWeight),
|
|
fcr_rasio: orUnavailable(fcr),
|
|
persenHidup_persen: orUnavailable(survivalPersen),
|
|
umur_hari: orUnavailable(eefDay),
|
|
catatan:
|
|
'EEF adalah indeks performa AKHIR SIKLUS. Nilai pada periode berjalan ' +
|
|
`(hari ke-${eefDay ?? '?'}) wajar rendah karena ayam belum mencapai bobot panen — ` +
|
|
'JANGAN bandingkan dengan standar >300 yang berlaku untuk umur panen, ' +
|
|
'dan jangan menyimpulkan performa buruk hanya dari angka ini.',
|
|
...(eefAlasan ? { alasanTidakTersedia: eefAlasan } : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
if (range && pack.metadata && typeof pack.metadata === 'object') {
|
|
scoped.metadata = {
|
|
...pack.metadata,
|
|
periode: periodLabel,
|
|
periodeHariPertama: range.firstDay,
|
|
periodeHariTerakhir: range.lastDay,
|
|
};
|
|
}
|
|
|
|
return scoped;
|
|
};
|
|
|
|
/**
|
|
* Guarantee a daily report names the day it describes.
|
|
*
|
|
* The prompt asks for it, but a model that ignores the instruction produced a
|
|
* correctly scoped day-28 brief whose text said "28" nowhere — so day 28 and
|
|
* day 30 printed indistinguishable headlines and the picker looked broken.
|
|
* Prefixing here makes the label independent of model compliance.
|
|
*
|
|
* Left alone when the headline already names that day, so an obedient model is
|
|
* not given a doubled prefix.
|
|
*/
|
|
const stampDailyPeriodOnHeadline = (parsed, reportType, reportPeriod) => {
|
|
if (reportType !== 'daily') return parsed;
|
|
const day = parseDayNumber(reportPeriod);
|
|
if (day === null) return parsed;
|
|
if (!parsed || typeof parsed !== 'object' || !parsed.summary) return parsed;
|
|
|
|
const headline = typeof parsed.summary.headline === 'string' ? parsed.summary.headline.trim() : '';
|
|
if (!headline) return parsed;
|
|
// `\D` on both sides so "Hari ke-3" is not treated as naming day 30.
|
|
if (new RegExp(`hari\\s*ke-?\\s*${day}(\\D|$)`, 'i').test(headline)) return parsed;
|
|
|
|
return {
|
|
...parsed,
|
|
summary: {
|
|
...parsed.summary,
|
|
headline: `Laporan Harian (Hari ke-${day}): ${headline}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeriod = null) => {
|
|
// Ambil umur ayam untuk referensi standar CP 707
|
|
const cycleDay =
|
|
context?.metadata?.currentDay ||
|
|
context?.cycle?.currentAgeDay ||
|
|
context?.ageDay ||
|
|
context?.umurHari ||
|
|
null;
|
|
|
|
/**
|
|
* A weekly report describes a PAST window, so it must be headlined and graded
|
|
* at that window's age — not at today's.
|
|
*
|
|
* Both were taken from `currentDay`, so a "Minggu 1" report announced
|
|
* "Hari ke-48" and compared week-1 figures against the day-48 CP 707
|
|
* standard. The bounds come from the week number rather than from the data,
|
|
* so a week with no rows still reports its own days instead of falling back
|
|
* to today. `Math.min` keeps a week that runs past the cycle from claiming a
|
|
* day that has not happened yet.
|
|
*/
|
|
const weekNumber = reportType === 'weekly' ? parseWeekNumber(reportPeriod) : null;
|
|
const weekFirstDay = weekNumber === null ? null : (weekNumber - 1) * 7 + 1;
|
|
const weekLastDay =
|
|
weekNumber === null ? null : cycleDay ? Math.min(weekNumber * 7, cycleDay) : weekNumber * 7;
|
|
// A daily report for a past day must be graded at THAT day's CP 707 standard
|
|
// for the same reason a weekly one is: comparing day-5 figures against the
|
|
// day-30 standard invents a deficit that is not there.
|
|
const requestedDailyDay = reportType === 'daily' ? parseDayNumber(reportPeriod) : null;
|
|
const dayAge = weekLastDay ?? requestedDailyDay ?? cycleDay;
|
|
/** How the period should be named in headlines. */
|
|
const periodeText =
|
|
reportType === 'weekly'
|
|
? `${reportPeriod || 'Minggu ini'}, hari ke-${weekFirstDay} s/d ke-${weekLastDay}`
|
|
: requestedDailyDay !== null
|
|
? `hari ke-${requestedDailyDay}`
|
|
: null;
|
|
|
|
// ─── 1. Hitung Ayam (Mortalitas) ─────────────────────────────────────────
|
|
const isNum = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
const cc =
|
|
context?.chickenCounting?.dashboardSummary || context?.chickenCounting?.cameraSummary || {};
|
|
// These used to fall back to a hardcoded 10000 / 9800 flock. With no data at
|
|
// all the report then read "Populasi awal: 10000 ekor, saat ini: 9800 ekor" —
|
|
// a fully invented flock, stated as fact, and reassuring on top of it. Unknown
|
|
// has to stay unknown.
|
|
const firstNumber = (...values) => values.find((value) => isNum(value) && value > 0) ?? null;
|
|
const ccInitial = firstNumber(cc.initialPopulation, context?.metadata?.initialPopulation);
|
|
const ccCurrent = firstNumber(cc.currentPopulation, cc.estimatedPopulation);
|
|
|
|
/**
|
|
* Cumulative deaths+culls at the END of the reported window.
|
|
*
|
|
* `chickenCounting.mortality` holds cycle totals only, so a weekly report
|
|
* graded the day-48 cumulative rate against its own week's CP 707 standard.
|
|
* The history is day-indexed and therefore already sliced to the window, and
|
|
* each row carries the running cumulative — so its last row is the figure the
|
|
* cumulative standard expects. End-of-cycle rows are rolled up per week and
|
|
* lose that shape, so it keeps using the cycle totals.
|
|
*/
|
|
const mortalityRows = context?.chickenCounting?.mortalityHistory;
|
|
const periodMortality =
|
|
reportType !== 'end_cycle' && Array.isArray(mortalityRows) && mortalityRows.length > 0
|
|
? mortalityRows[mortalityRows.length - 1]
|
|
: null;
|
|
|
|
const ccMortality =
|
|
(periodMortality && isNum(periodMortality.kumulatifMatiAfkir)
|
|
? periodMortality.kumulatifMatiAfkir
|
|
: null) ??
|
|
firstNumber(cc.totalMortality, context?.chickenCounting?.mortality?.mati) ??
|
|
(ccInitial !== null && ccCurrent !== null ? ccInitial - ccCurrent : null);
|
|
const ekorText = (value) => (value === null ? 'data tidak tersedia' : `${value} ekor`);
|
|
|
|
// ─── Evaluasi CP 707 ───────────────────────────────────────────────────
|
|
let headline = `Laporan Harian (Hari ke-${dayAge || '?'}): Evaluasi Performa Kandang CP 707.`;
|
|
if (reportType === 'weekly') {
|
|
headline = `Laporan Mingguan (${periodeText}): Evaluasi Performa Kandang CP 707.`;
|
|
} else if (reportType === 'end_cycle') {
|
|
headline = `Laporan Akhir Siklus (Hari ke-${dayAge || '?'}): Evaluasi Keseluruhan Performa Kandang CP 707.`;
|
|
}
|
|
|
|
// `mortalityRate` is null when the population denominator is missing, so the
|
|
// rate is genuinely unknown here — every reader below must handle that rather
|
|
// than print a confident 0.00%.
|
|
// The period's own cumulative rate outranks the cycle-wide one, for the same
|
|
// reason as `ccMortality` above. Computed here rather than sent ready-made,
|
|
// because `ccInitial` is resolved from the enriched pack — the frontend's own
|
|
// flock size falls back to a hardcoded number when `docInCount` is null.
|
|
const periodRate =
|
|
periodMortality &&
|
|
isNum(periodMortality.kumulatifMatiAfkir) &&
|
|
ccInitial !== null &&
|
|
ccInitial > 0
|
|
? (periodMortality.kumulatifMatiAfkir / ccInitial) * 100
|
|
: null;
|
|
|
|
const ccRate =
|
|
periodRate !== null
|
|
? periodRate
|
|
: isNum(cc.mortalityRate)
|
|
? cc.mortalityRate
|
|
: isNum(context?.chickenCounting?.mortality?.mortalityCount)
|
|
? context.chickenCounting.mortality.mortalityCount
|
|
: ccInitial !== null && ccInitial > 0 && ccMortality !== null
|
|
? (ccMortality / ccInitial) * 100
|
|
: null;
|
|
const ccRateText = ccRate === null ? 'data tidak tersedia' : `${ccRate.toFixed(2)}%`;
|
|
|
|
// Analisis mortalitas berdasarkan standar CP 707
|
|
const mortalityAnalysis =
|
|
dayAge && ccRate !== null ? cp707.analyzeMortality(ccRate, dayAge) : null;
|
|
const stdMortalityCum = dayAge ? cp707.getMortalityCumStdByDay(dayAge) : null;
|
|
|
|
// An unknown rate cannot be graded, so it must not read as a pass.
|
|
let statusAyam = mortalityAnalysis?.status || (ccRate === null ? 'unknown' : 'ok');
|
|
if (!mortalityAnalysis && ccRate !== null) {
|
|
if (ccRate > 7) statusAyam = 'critical';
|
|
else if (ccRate >= 5) statusAyam = 'warning';
|
|
}
|
|
|
|
// Compute a human-readable mortality grade that is injected into the context
|
|
// so the LLM can read it directly instead of inferring it from prose.
|
|
const mortalityGradeLabel =
|
|
ccRate === null
|
|
? 'tidak diketahui'
|
|
: ccRate > 7
|
|
? 'SANGAT TINGGI (critical, melampaui batas maksimum CP 707 >7%)'
|
|
: ccRate >= 5
|
|
? 'TINGGI (warning, melampaui standar normal CP 707 <5%)'
|
|
: 'NORMAL (di bawah standar CP 707 <5%)';
|
|
|
|
const hitung_ayam = {
|
|
status: statusAyam,
|
|
// Explicit grade label — prevents the model from labelling 9.91% as "rendah".
|
|
statusMortalitas: mortalityGradeLabel,
|
|
bullets: [
|
|
`Mortalitas kumulatif: ${ccRateText} — STATUS: ${mortalityGradeLabel}. Standar CP 707 pada hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}.`,
|
|
`Populasi awal: ${ekorText(ccInitial)}, saat ini: ${ekorText(ccCurrent)}. Total deplesi: ${ekorText(ccMortality)}.`,
|
|
// Without this the fallback reported a flock shrinking from 25000 to 2
|
|
// and attributed all of it to deplesi, because harvest was never named.
|
|
...(isNum(cc.totalPanen_ekor) && cc.totalPanen_ekor > 0
|
|
? [
|
|
`Sudah dipanen: ${cc.totalPanen_ekor} ekor${
|
|
isNum(cc.totalBeratPanen_kg) ? ` (${cc.totalBeratPanen_kg} kg)` : ''
|
|
} — keluar kandang hidup, bukan bagian dari deplesi.`,
|
|
]
|
|
: []),
|
|
],
|
|
evidence:
|
|
statusAyam !== 'ok'
|
|
? [
|
|
mortalityAnalysis?.message ||
|
|
(ccRate === null
|
|
? 'Persentase deplesi tidak dapat dihitung: populasi awal tidak tersedia.'
|
|
: `Deplesi ${ccRate.toFixed(2)}% melampaui standar CP 707 (<5%). Status mortalitas: ${mortalityGradeLabel}.`),
|
|
]
|
|
: [],
|
|
actions:
|
|
statusAyam === 'critical'
|
|
? [
|
|
'Lakukan bedah bangkai (nekropsi) segera untuk identifikasi penyakit (sesuai buku CP 707 - Biosekuriti).',
|
|
'Keluarkan ayam mati setiap hari dan desinfektan kandang (sesuai prosedur biosekuriti CP 707).',
|
|
'Perketat akses masuk kandang dan semprot disinfektan semua kendaraan.',
|
|
]
|
|
: statusAyam === 'warning'
|
|
? [
|
|
'Periksa kualitas litter: sekam basah diambil dan diganti sekam baru untuk cegah amoniak tinggi (buku CP 707 - Manajemen Litter).',
|
|
'Cek kualitas air minum: pastikan kadar klorin 3-5 ppm dan bebas bakteri patogen (standar CP 707).',
|
|
]
|
|
: ['Pertahankan pengawasan harian dan sanitasi rutin sesuai standar CP 707.'],
|
|
};
|
|
|
|
// ─── 2. Berat Ayam ───────────────────────────────────────────────────────
|
|
const wm = context?.weight?.current || context?.weightStats || context?.deviceWeightData || {};
|
|
|
|
/**
|
|
* Latest recorded weight from the day-indexed histories.
|
|
*
|
|
* `weight.current.averageWeight` arrives as 0 for this farm — the dashboard
|
|
* shows "BW: N/A" — but the growth chart beside it is fully populated, so the
|
|
* weight is on hand, just not in the scalar. Reading only the scalar made the
|
|
* report say "Data bobot badan tidak tersedia" about data the user can see
|
|
* plotted on the same screen.
|
|
*/
|
|
const latestFromHistory = (rows, field) => {
|
|
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
const withDay = rows
|
|
.filter((row) => row && isNum(row[field]) && row[field] > 0)
|
|
.sort((a, b) => (dayOfItem(a) ?? 0) - (dayOfItem(b) ?? 0));
|
|
return withDay.length > 0 ? withDay[withDay.length - 1][field] : null;
|
|
};
|
|
|
|
const positiveOrNull = (value) => {
|
|
const n = isNum(value?.value) ? value.value : isNum(value) ? value : null;
|
|
return n !== null && n > 0 ? n : null;
|
|
};
|
|
|
|
const wmAvg =
|
|
positiveOrNull(wm.averageWeight) ??
|
|
latestFromHistory(context?.weight?.dailyWeightData, 'actual') ??
|
|
latestFromHistory(context?.weight?.weightHistory, 'actual');
|
|
|
|
const wmTarget = dayAge
|
|
? cp707.getBwStandardByDay(dayAge)
|
|
: (positiveOrNull(wm.targetWeight) ??
|
|
latestFromHistory(context?.weight?.dailyWeightData, 'target'));
|
|
// 0 is a real reading here (a day with no weighing), not a missing value, so
|
|
// this deliberately does NOT use `positiveOrNull` like the figures above.
|
|
const wmUniformity = (() => {
|
|
const raw = wm.uniformity;
|
|
const n = isNum(raw?.value) ? raw.value : isNum(raw) ? raw : null;
|
|
return n === null ? 0 : n;
|
|
})();
|
|
|
|
// An unmeasured bird cannot be graded "ok".
|
|
let statusBerat = wmAvg === null ? 'unknown' : 'ok';
|
|
let bwAnalysis = null;
|
|
|
|
if (wmAvg !== null && dayAge !== null) {
|
|
bwAnalysis = cp707.analyzeBw(wmAvg, dayAge);
|
|
statusBerat = bwAnalysis.status;
|
|
} else if (wmAvg !== null && wmTarget !== null) {
|
|
const diffPct = ((wmTarget - wmAvg) / wmTarget) * 100;
|
|
if (diffPct > 15) statusBerat = 'critical';
|
|
else if (diffPct > 5) statusBerat = 'warning';
|
|
}
|
|
|
|
const bwDisplayAvg = wmAvg || 'N/A';
|
|
const bwDisplayTarget = wmTarget || 'N/A';
|
|
|
|
const berat_ayam = {
|
|
status: statusBerat,
|
|
bullets: [
|
|
`Bobot aktual: ${bwDisplayAvg}g. Target standar CP 707 hari ke-${dayAge || '?'}: ${bwDisplayTarget}g.`,
|
|
`Keseragaman (uniformity): ${wmUniformity}% ayam berada dalam ±5% dari bobot rata-rata (band yang dipakai di lapangan, bukan ±10% rumus buku).`,
|
|
],
|
|
evidence:
|
|
statusBerat !== 'ok'
|
|
? [bwAnalysis?.message || `Bobot badan di bawah target standar CP 707 hari ke-${dayAge}.`]
|
|
: [],
|
|
actions:
|
|
statusBerat === 'critical'
|
|
? [
|
|
'Lakukan grading: pisahkan ayam kecil dengan sekat pembatas (sesuai buku CP 707 - Manajemen Litter).',
|
|
'Evaluasi kecukupan ruang pakan: bibir feeder harus sejajar bagian atas dada ayam (standar CP 707).',
|
|
'Tingkatkan frekuensi pemberian pakan: umur >15 hari minimal 2x sehari (CP 707 - Sistem Pemberian Pakan).',
|
|
]
|
|
: statusBerat === 'warning'
|
|
? [
|
|
'Tingkatkan stimulasi pakan di siang hari: isi tempat pakan sedikit demi sedikit tapi sering (buku CP 707).',
|
|
'Sesuaikan ketinggian tempat pakan: bibir feeder pan sejajar bagian atas dada ayam (standar CP 707).',
|
|
]
|
|
: [
|
|
'Pertahankan manajemen pencahayaan 23 jam nyala untuk periode grower (buku CP 707 - Program Pencahayaan).',
|
|
],
|
|
};
|
|
|
|
// ─── 3. Panel IoT ────────────────────────────────────────────────────────
|
|
const iot =
|
|
context?.iotPanel?.pembacaan ||
|
|
context?.iotPanel?.display ||
|
|
context?.panel_iot ||
|
|
context?.telemetry ||
|
|
context?.iotData ||
|
|
{};
|
|
// Readings now arrive already formatted and localised ("32,1 °C", "62,8 %"),
|
|
// because the raw sensor encoding is ten times the real value and reporting it
|
|
// verbatim produced "suhu 316°C". Strip the unit and accept the Indonesian
|
|
// decimal comma; a bare number still parses exactly as before.
|
|
const parseVal = (v) => {
|
|
if (v === undefined || v === null || v === '') return null;
|
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
const cleaned = String(v)
|
|
.replace(/[^\d,.-]/g, '')
|
|
.replace(/\.(?=\d{3}\b)/g, '')
|
|
.replace(',', '.');
|
|
const n = Number(cleaned);
|
|
return Number.isFinite(n) ? n : null;
|
|
};
|
|
const temp = parseVal(iot.suhuRataRata ?? iot.avgTemp ?? iot.suhu_rata_rata_C);
|
|
const hum = parseVal(iot.kelembapan ?? iot.humidity ?? iot.kelembapan_persen);
|
|
const amonia = parseVal(iot.amonia ?? iot.ammonia ?? iot.amonia_ppm);
|
|
const co2 = parseVal(iot.co2 ?? iot.co2_ppm);
|
|
|
|
let statusIot = 'ok';
|
|
const panel_iot = { status: 'ok', bullets: [], evidence: [], actions: [] };
|
|
|
|
if (temp === null && hum === null && amonia === null) {
|
|
// Tidak ada data IoT
|
|
statusIot = 'unknown';
|
|
panel_iot.status = 'unknown';
|
|
panel_iot.bullets = ['Data sensor IoT tidak tersedia untuk siklus ini.'];
|
|
panel_iot.evidence = ['Panel IoT tidak terkoneksi atau belum ada data telemetri.'];
|
|
panel_iot.actions = ['Pastikan sensor IoT aktif dan terkoneksi ke sistem dashboard.'];
|
|
} else {
|
|
// Analisis suhu berdasarkan standar CP 707
|
|
if (temp !== null && dayAge !== null) {
|
|
const envAnalysis = cp707.analyzeEnvironment(temp, hum, amonia, dayAge);
|
|
statusIot = envAnalysis.status;
|
|
const tempStd = envAnalysis.tempStd;
|
|
|
|
panel_iot.bullets.push(
|
|
`Suhu: ${temp.toFixed(1)}°C (standar CP 707 hari ke-${dayAge}: ${tempStd.temp_C}°C).`
|
|
);
|
|
if (hum !== null)
|
|
panel_iot.bullets.push(
|
|
`Kelembapan: ${hum.toFixed(1)}% (standar CP 707: ${tempStd.humidity_pct_min}%-${tempStd.humidity_pct_max}%).`
|
|
);
|
|
if (amonia !== null)
|
|
panel_iot.bullets.push(
|
|
`Amonia: ${amonia.toFixed(1)} ppm (standar CP 707 ideal: <10 ppm, batas kritis: >25 ppm).`
|
|
);
|
|
|
|
if (envAnalysis.issues.length > 0) {
|
|
panel_iot.evidence = envAnalysis.issues.slice(0, 3);
|
|
}
|
|
|
|
// Tindakan berdasarkan standar CP 707
|
|
if (temp > tempStd.temp_C + 2) {
|
|
panel_iot.actions.push(
|
|
temp > 34
|
|
? 'Nyalakan semua cooling pad dan exhaust fan. Target Efektif Temperatur (TET) CP 707 harus terpenuhi.'
|
|
: 'Tingkatkan kecepatan angin (wind chill effect) sesuai tabel TET buku CP 707.'
|
|
);
|
|
} else if (temp < tempStd.temp_C - 3) {
|
|
panel_iot.actions.push(
|
|
'Aktifkan heater: nyalakan 2-3 jam sebelum DOC/ayam membutuhkan (standar CP 707 - Periode Brooding).'
|
|
);
|
|
}
|
|
if (amonia !== null && amonia > 10) {
|
|
panel_iot.actions.push(
|
|
'Gemburkan sekam basah dan ganti dengan sekam baru untuk turunkan amonia (Manajemen Litter CP 707).'
|
|
);
|
|
panel_iot.actions.push(
|
|
'Tingkatkan ventilasi minimum: kebutuhan 4 CFM per 1 kg ayam (standar CP 707 - Manajemen Ventilasi).'
|
|
);
|
|
}
|
|
if (co2 !== null && co2 > 3000) {
|
|
panel_iot.actions.push(
|
|
`CO2 ${co2} ppm mendekati/melampaui batas kritis 3000 ppm (Lampiran 3 CP 707). Tingkatkan pertukaran udara segera.`
|
|
);
|
|
if (statusIot !== 'critical') statusIot = 'warning';
|
|
}
|
|
} else if (temp !== null) {
|
|
// Fallback tanpa umur
|
|
panel_iot.bullets.push(`Suhu kandang: ${temp.toFixed(1)}°C.`);
|
|
if (hum !== null) panel_iot.bullets.push(`Kelembapan: ${hum.toFixed(1)}%.`);
|
|
const isAmoniaCritical = amonia !== null && amonia > 25;
|
|
if (isAmoniaCritical) {
|
|
statusIot = 'critical';
|
|
panel_iot.evidence.push(
|
|
`Amonia ${amonia.toFixed(1)} ppm melampaui batas kritis CP 707 (>25 ppm).`
|
|
);
|
|
} else if (amonia !== null && amonia > 10) {
|
|
statusIot = 'warning';
|
|
panel_iot.evidence.push(`Amonia ${amonia.toFixed(1)} ppm di atas ideal CP 707 (<10 ppm).`);
|
|
}
|
|
}
|
|
panel_iot.status = statusIot;
|
|
if (panel_iot.actions.length === 0) {
|
|
panel_iot.actions.push(
|
|
'Pertahankan pengawasan ventilasi otomatis. Kalibrasi sensor temptron setiap periode (buku CP 707).'
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── 4. FCR ──────────────────────────────────────────────────────────────
|
|
const fcrObj =
|
|
context?.fcr_eef?.fcr || context?.fcr || context?.fcr_terakhir || context?.fcrSummary || {};
|
|
const fcrVal = fcrObj.actual || fcrObj.current || fcrObj.value || null;
|
|
const stdFcr = dayAge ? cp707.getFcrStandardByDay(dayAge) : null;
|
|
let statusFcr = 'ok';
|
|
let fcrAnalysis = null;
|
|
|
|
if (fcrVal !== null && dayAge !== null) {
|
|
fcrAnalysis = cp707.analyzeFcr(fcrVal, dayAge);
|
|
statusFcr = fcrAnalysis.status;
|
|
} else if (fcrVal !== null) {
|
|
if (fcrVal > 1.7) statusFcr = 'critical';
|
|
else if (fcrVal > 1.65) statusFcr = 'warning';
|
|
}
|
|
|
|
const fcr = {
|
|
status: statusFcr,
|
|
bullets: [
|
|
fcrVal !== null
|
|
? `FCR aktual: ${fcrVal.toFixed(2)}. Standar CP 707 hari ke-${dayAge || '?'}: ${stdFcr ? stdFcr.toFixed(3) : 'N/A'}.`
|
|
: 'Data FCR tidak tersedia.',
|
|
],
|
|
evidence: statusFcr !== 'ok' && fcrAnalysis ? [fcrAnalysis.message] : [],
|
|
actions:
|
|
statusFcr === 'critical'
|
|
? [
|
|
'Periksa potensi pakan tercecer: ketinggian alas feeder harus sejajar bagian atas dada ayam (buku CP 707).',
|
|
'Evaluasi kadar fines dalam pakan. Distribusi pakan harus merata di seluruh sistem (standar CP 707).',
|
|
]
|
|
: statusFcr === 'warning'
|
|
? [
|
|
'Atur ketinggian piringan pakan (pan feeder) agar tidak terlalu tinggi/rendah (buku CP 707 - Sistem Pemberian Pakan).',
|
|
'Pastikan frekuensi pemberian pakan sesuai umur: >15 hari minimal 2x sehari (standar CP 707).',
|
|
]
|
|
: ['Pertahankan ketepatan pemberian pakan harian sesuai standar konsumsi CP 707.'],
|
|
};
|
|
|
|
// ─── 5. EEF ──────────────────────────────────────────────────────────────
|
|
const eefObj = context?.fcr_eef?.eef || context?.eef || context?.eef_terakhir || {};
|
|
let eefVal = eefObj.value || eefObj.actual || null;
|
|
|
|
// EEF needs a survival rate. Treating an unknown mortality rate as 0 would
|
|
// assume a perfect flock and inflate the score, so the index stays unreported
|
|
// instead — an absent EEF is recoverable, a flattering wrong one is not.
|
|
if (eefVal === null && fcrVal && wmAvg && dayAge && ccRate !== null) {
|
|
const survivalRate = 100 - ccRate;
|
|
const weightKg = wmAvg / 1000;
|
|
eefVal = ((survivalRate * weightKg) / (fcrVal * dayAge)) * 100;
|
|
}
|
|
|
|
let statusEef = 'ok';
|
|
if (eefVal !== null) {
|
|
if (eefVal < 200) statusEef = 'critical';
|
|
else if (eefVal < 250) statusEef = 'warning';
|
|
}
|
|
|
|
const eef = {
|
|
status: eefVal !== null ? statusEef : 'unknown',
|
|
bullets: [
|
|
eefVal !== null
|
|
? `EEF (European Efficiency Factor): ${eefVal.toFixed(0)}. Target standar peternakan modern: >300.`
|
|
: 'Data EEF tidak tersedia.',
|
|
],
|
|
evidence:
|
|
statusEef !== 'ok' && eefVal !== null
|
|
? [
|
|
`Skor EEF ${eefVal.toFixed(0)} di bawah standar. EEF dipengaruhi oleh mortalitas (${ccRateText}) dan FCR (${fcrVal ? fcrVal.toFixed(2) : 'N/A'}).`,
|
|
]
|
|
: [],
|
|
actions:
|
|
statusEef !== 'ok'
|
|
? [
|
|
'Identifikasi faktor utama: apakah deplesi tinggi atau FCR tinggi yang paling mempengaruhi EEF.',
|
|
'Optimalkan iklim mikro kandang sesuai Target Efektif Temperatur (TET) buku CP 707.',
|
|
]
|
|
: [
|
|
'Pertahankan performa optimal: kendalikan mortalitas <5% dan FCR sesuai standar CP 707 hingga panen.',
|
|
],
|
|
};
|
|
|
|
// ─── 6. Hitung Karung ────────────────────────────────────────────────────
|
|
const hk = context?.hitung_karung || {};
|
|
const sisa = hk.sisa_pakan || hk.remainingBags || null;
|
|
let statusKarung = 'ok';
|
|
if (sisa !== null) {
|
|
if (sisa < 10) statusKarung = 'critical';
|
|
else if (sisa < 30) statusKarung = 'warning';
|
|
}
|
|
|
|
const hitung_karung = {
|
|
status: sisa !== null ? statusKarung : 'unknown',
|
|
bullets: [
|
|
sisa !== null ? `Stok pakan tersisa: ${sisa} karung.` : 'Data stok pakan tidak tersedia.',
|
|
],
|
|
evidence:
|
|
statusKarung !== 'ok' && sisa !== null
|
|
? [`Persediaan pakan ${sisa} karung menipis. Segera lakukan pengadaan.`]
|
|
: [],
|
|
actions:
|
|
statusKarung === 'critical'
|
|
? ['Segera hubungi logistik/supplier pakan CP untuk pengiriman darurat.']
|
|
: statusKarung === 'warning'
|
|
? [
|
|
'Ajukan permintaan pakan baru hari ini. Pantau stok harian (buku CP 707 - Persiapan Kandang).',
|
|
]
|
|
: ['Lakukan stock opname pakan harian sesuai prosedur CP 707.'],
|
|
};
|
|
|
|
// ─── 7. Data Quality ──────────────────────────────────────────────────────
|
|
const missingData = [];
|
|
if (temp === null) missingData.push('data suhu IoT');
|
|
if (wmAvg === null) missingData.push('data bobot badan');
|
|
if (fcrVal === null) missingData.push('data FCR');
|
|
if (dayAge === null) missingData.push('umur ayam (hari ke-)');
|
|
|
|
const data_quality = {
|
|
status: missingData.length > 2 ? 'warning' : 'ok',
|
|
bullets: [
|
|
missingData.length > 0
|
|
? `Data tidak lengkap: ${missingData.join(', ')}. Analisis menggunakan data yang tersedia.`
|
|
: 'Semua data operasional tersedia dan sinkron.',
|
|
],
|
|
evidence:
|
|
missingData.length > 0
|
|
? [`${missingData.length} parameter data kosong: ${missingData.join(', ')}.`]
|
|
: [],
|
|
actions:
|
|
missingData.length > 0
|
|
? [
|
|
'Lengkapi data operasional harian: timbang ayam sampel, catat pakan, dan pastikan sensor IoT aktif.',
|
|
]
|
|
: ['Pertahankan pemeliharaan berkala sensor IoT dan pencatatan data harian.'],
|
|
};
|
|
|
|
// ─── Summary ──────────────────────────────────────────────────────────────
|
|
const allStatuses = [
|
|
statusAyam,
|
|
statusBerat,
|
|
statusIot === 'unknown' ? 'ok' : statusIot,
|
|
statusFcr,
|
|
statusEef === 'unknown' ? 'ok' : statusEef,
|
|
statusKarung === 'unknown' ? 'ok' : statusKarung,
|
|
];
|
|
let overallStatus = 'ok';
|
|
if (allStatuses.includes('critical')) overallStatus = 'critical';
|
|
else if (allStatuses.includes('warning')) overallStatus = 'warning';
|
|
|
|
let reportPrefix = 'Analisis Kritis';
|
|
if (reportType === 'weekly') reportPrefix = `Evaluasi Mingguan Kritis (${periodeText})`;
|
|
if (reportType === 'end_cycle') reportPrefix = 'Evaluasi Akhir Siklus Kritis';
|
|
|
|
headline =
|
|
overallStatus === 'critical'
|
|
? `${reportPrefix} (Hari ke-${dayAge || '?'}): Ditemukan parameter operasional di luar batas toleransi standar CP 707.`
|
|
: overallStatus === 'warning'
|
|
? `Perhatian (Hari ke-${dayAge || '?'}): Terdapat deviasi pada parameter vs standar buku CP 707.`
|
|
: `Kondisi kandang hari ke-${dayAge || '?'} terpantau optimal dan sesuai standar buku CP 707.`;
|
|
|
|
if (overallStatus !== 'critical') {
|
|
if (reportType === 'weekly') headline = `Laporan Mingguan (${periodeText}): ` + headline;
|
|
if (reportType === 'end_cycle') headline = `Laporan Akhir Siklus: ` + headline;
|
|
}
|
|
|
|
const risks = [
|
|
...(statusAyam !== 'ok' ? hitung_ayam.evidence : []),
|
|
...(statusBerat !== 'ok' ? berat_ayam.evidence : []),
|
|
...(statusIot !== 'ok' && statusIot !== 'unknown' ? panel_iot.evidence.slice(0, 1) : []),
|
|
...(statusFcr !== 'ok' ? fcr.evidence.slice(0, 1) : []),
|
|
].slice(0, 3);
|
|
|
|
return {
|
|
summary: {
|
|
headline,
|
|
bullets: [
|
|
`Mortalitas: ${ccRateText} (Standar CP 707 hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}) — Status: ${statusAyam.toUpperCase()}.`,
|
|
wmAvg !== null
|
|
? `Bobot badan: ${wmAvg}g (Target CP 707: ${wmTarget || 'N/A'}g) — Status: ${statusBerat.toUpperCase()}.`
|
|
: 'Data bobot badan tidak tersedia.',
|
|
temp !== null
|
|
? `Lingkungan: Suhu ${temp.toFixed(1)}°C, Amonia ${amonia !== null ? amonia.toFixed(1) + ' ppm' : 'N/A'} — Status: ${statusIot.toUpperCase()}.`
|
|
: 'Data lingkungan IoT tidak tersedia.',
|
|
],
|
|
risks:
|
|
risks.length > 0
|
|
? risks
|
|
: ['Tidak ada risiko signifikan yang terdeteksi berdasarkan standar CP 707.'],
|
|
actions: [
|
|
...hitung_ayam.actions.slice(0, 1),
|
|
...berat_ayam.actions.slice(0, 1),
|
|
...panel_iot.actions.slice(0, 1),
|
|
].slice(0, 3),
|
|
},
|
|
detailed: {
|
|
hitung_ayam,
|
|
berat_ayam,
|
|
panel_iot,
|
|
fcr,
|
|
eef,
|
|
hitung_karung,
|
|
data_quality,
|
|
},
|
|
};
|
|
};
|
|
|
|
const generateDashboardInsight = async ({
|
|
cycleId,
|
|
kandangId = null,
|
|
contextPack,
|
|
version = INSIGHT_VERSION,
|
|
forceRefresh = false,
|
|
reportType = 'daily',
|
|
reportPeriod: rawReportPeriod = null,
|
|
}) => {
|
|
// Daily and weekly reports are each scoped by their own label ("Hari 12",
|
|
// "Minggu 3") and must be cached per label, or every day would overwrite the
|
|
// previous one's row. An end-cycle report covers the whole cycle and has no
|
|
// label of its own, so it is pinned to null — otherwise it would be cached
|
|
// under whichever day or week the dropdown happened to be showing.
|
|
const reportPeriod = reportType === 'end_cycle' ? null : rawReportPeriod;
|
|
const cacheKey = buildCacheKey(cycleId, kandangId, version, reportType, reportPeriod);
|
|
|
|
if (!forceRefresh) {
|
|
const existingRequest = dashboardInsightRequests.get(cacheKey);
|
|
if (existingRequest) {
|
|
return existingRequest;
|
|
}
|
|
}
|
|
|
|
const requestPromise = (async () => {
|
|
try {
|
|
if (!forceRefresh) {
|
|
const cached = await AiInsight.getByCycleAndKandang(
|
|
cycleId,
|
|
kandangId,
|
|
reportType,
|
|
reportPeriod
|
|
);
|
|
if (cached && cached.version === version) {
|
|
return {
|
|
source: 'cache',
|
|
insight: cached,
|
|
};
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Apakah ini generate ULANG untuk periode data yang sama?
|
|
*
|
|
* `forceRefresh` saja tidak bisa membedakan: tombol "Buat AI Insight"
|
|
* yang pertama pun mengirimnya. Yang membedakan adalah sudah ada atau
|
|
* belum baris tersimpan untuk kunci yang sama. Kalau sudah ada, operator
|
|
* menekan tombol lagi karena jawaban pertama kurang memuaskan — jadi
|
|
* yang diminta sudut pandang dan tindakan yang berbeda, dengan angka
|
|
* yang tetap sama karena datanya memang tidak berubah.
|
|
*/
|
|
let isRegeneration = false;
|
|
let variationInstruction = '';
|
|
if (forceRefresh) {
|
|
const previous = await AiInsight.getByCycleAndKandang(
|
|
cycleId,
|
|
kandangId,
|
|
reportType,
|
|
reportPeriod
|
|
);
|
|
if (previous && previous.insightText) {
|
|
isRegeneration = true;
|
|
variationInstruction = buildVariationInstruction(previous.insightText);
|
|
console.log(
|
|
`[Dashboard Insight] Generate ulang terdeteksi untuk ${cacheKey} — meminta sudut pandang berbeda dengan angka yang sama.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// The dashboard card attaches the same cross-domain bundle the page cards
|
|
// send. It is consumed here and then dropped: the pruning and re-scoping
|
|
// below would mangle its day-indexed rows, and the evidence block already
|
|
// states everything those rows were carried for.
|
|
const rootCauseEvidence = buildRootCauseEvidence(contextPack);
|
|
if (contextPack && typeof contextPack === 'object' && 'diagnostik' in contextPack) {
|
|
const { diagnostik: _diagnostik, ...rest } = contextPack;
|
|
contextPack = rest;
|
|
}
|
|
if (rootCauseEvidence) {
|
|
console.log(
|
|
`[RootCause] Dashboard evidence block attached (${rootCauseEvidence.length} chars)`
|
|
);
|
|
}
|
|
|
|
const enrichedContextPack = await enrichContextPack(
|
|
contextPack,
|
|
cycleId,
|
|
kandangId,
|
|
periodLastDayOf(reportType, reportPeriod)
|
|
);
|
|
|
|
/**
|
|
* The age the report is ABOUT, which is not today's age once a past
|
|
* period is picked. Every CP 707 lookup keys off this, so a "Hari 5"
|
|
* report must not be graded against the day-30 standard.
|
|
*/
|
|
const packAgeDay =
|
|
enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null;
|
|
const scopedDayAge =
|
|
reportType === 'daily' ? (parseDayNumber(reportPeriod) ?? packAgeDay) : packAgeDay;
|
|
const rawLength = JSON.stringify(enrichedContextPack).length;
|
|
const prunedContextPack = rescopeScalarsToPeriod(
|
|
pruneContextPackForLocalLLM(enrichedContextPack, reportType, reportPeriod),
|
|
reportType,
|
|
reportPeriod
|
|
);
|
|
const prunedLength = JSON.stringify(prunedContextPack).length;
|
|
console.log(
|
|
`[LM Studio Context Optimizer] Pruned context size from ${rawLength} to ${prunedLength} chars (saved ${Math.round((1 - prunedLength / rawLength) * 100)}%)`
|
|
);
|
|
const contextText = serializePromptContext(prunedContextPack);
|
|
|
|
let responseText = '';
|
|
let success = false;
|
|
|
|
// ─── Pastikan konteks cukup panjang untuk LM Studio ─────────────────────
|
|
// Jika konteks terlalu pendek, tambahkan standar CP 707 agar model bisa generate output
|
|
let finalContextText = contextText;
|
|
if (contextText.length < 400) {
|
|
const dayAge = scopedDayAge;
|
|
const padding = dayAge
|
|
? cp707.buildBookReferenceContext(dayAge)
|
|
: '\n[Data operasional kandang tidak tersedia lengkap. Gunakan standar umum CP 707.]';
|
|
finalContextText = contextText + '\n' + padding;
|
|
console.log(
|
|
`[LM Studio Dashboard] Context terlalu pendek (${contextText.length} chars), padding ke ${finalContextText.length} chars`
|
|
);
|
|
}
|
|
|
|
// 1. Try LM Studio (dengan retry jika model output error)
|
|
const MAX_RETRIES = 1;
|
|
for (let attempt = 1; attempt <= MAX_RETRIES && !success; attempt++) {
|
|
try {
|
|
if (attempt > 1) {
|
|
console.log(`[LM Studio Dashboard] Retry attempt ${attempt}/${MAX_RETRIES}...`);
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
}
|
|
|
|
const lmStudioBaseUrl = process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:11434';
|
|
let modelName = process.env.LLM_MODEL_NAME || 'qwen2.5:7b';
|
|
try {
|
|
const modelsResponse = await fetch(`${lmStudioBaseUrl}/v1/models`, {
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (modelsResponse.ok) {
|
|
const modelsJson = await modelsResponse.json();
|
|
if (modelsJson && modelsJson.data && modelsJson.data.length > 0) {
|
|
modelName = modelsJson.data[0].id;
|
|
} else if (
|
|
modelsJson &&
|
|
Array.isArray(modelsJson.models) &&
|
|
modelsJson.models.length > 0
|
|
) {
|
|
modelName = modelsJson.models[0].key;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
'[LLM Service] Failed to fetch loaded models list, using default:',
|
|
err.message
|
|
);
|
|
}
|
|
|
|
const dayAge = scopedDayAge;
|
|
|
|
const isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm');
|
|
const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions';
|
|
|
|
const payload = isOllama
|
|
? {
|
|
model: modelName,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
'Anda adalah analis operasional peternakan ayam broiler CP 707. Kembalikan HANYA format JSON valid, tanpa teks tambahan di luar JSON. Langsung ke poin utama.',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: buildDashboardInsightPrompt(
|
|
finalContextText,
|
|
dayAge,
|
|
reportType,
|
|
reportPeriod,
|
|
rootCauseEvidence,
|
|
variationInstruction
|
|
),
|
|
},
|
|
],
|
|
stream: false,
|
|
format: 'json',
|
|
options: {
|
|
// Ollama truncates an over-long prompt to HALF of num_ctx, so
|
|
// 8192 only ever admitted 4096 prompt tokens. An end_cycle
|
|
// prompt carries all six topics plus the per-week rollups and
|
|
// measured 4098 — i.e. exactly at the cap, silently cut from
|
|
// the START, which is where the JSON schema and the output
|
|
// contract live. The model then answered in a shape nothing
|
|
// downstream could read. 16384 leaves 8192 for the prompt.
|
|
num_ctx: 16384,
|
|
num_predict: NUM_PREDICT_BY_REPORT_TYPE[reportType] ?? 2048,
|
|
// Tanpa `seed` Ollama memakai seed tetap, dan pada temperature
|
|
// 0.1 samplingnya praktis greedy -- prompt yang sama selalu
|
|
// menghasilkan teks yang sama persis. Seed acak per permintaan
|
|
// adalah syarat pertama supaya generate ulang bisa berbeda.
|
|
seed: Math.floor(Math.random() * 1_000_000_000),
|
|
// Seed saja tidak cukup di temperature 0.1: distribusinya
|
|
// terlalu tajam sehingga token yang terpilih hampir selalu
|
|
// sama. Diukur pada qwen2.5:7b, 0.45 pun masih menghasilkan
|
|
// jawaban 90,7% mirip dengan daftar tindakan yang identik.
|
|
// 0.7 dipakai HANYA saat generate ulang; pengaman angkanya
|
|
// adalah blok "ANGKA TIDAK BOLEH BERUBAH" di prompt variasi,
|
|
// bukan suhu yang rendah.
|
|
temperature: isRegeneration ? 0.7 : 0.1,
|
|
},
|
|
}
|
|
: {
|
|
model: modelName,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
'Anda adalah analis operasional peternakan ayam broiler CP 707 (PT Charoen Pokphand Indonesia). Analisis HANYA berdasarkan standar buku CP 707 edisi Juli 2023. Kembalikan HANYA format JSON valid. Dilarang memberikan penjelasan di luar JSON.',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: buildDashboardInsightPrompt(
|
|
finalContextText,
|
|
dayAge,
|
|
reportType,
|
|
reportPeriod,
|
|
rootCauseEvidence,
|
|
variationInstruction
|
|
),
|
|
},
|
|
],
|
|
temperature: isRegeneration ? 0.7 : 0.1,
|
|
seed: Math.floor(Math.random() * 1_000_000_000),
|
|
max_tokens: 3072,
|
|
lm_studio: { reasoning: { budget_tokens: 1000 } },
|
|
};
|
|
|
|
const response = await llmFetch(`${lmStudioBaseUrl}${endpoint}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
// Without this dispatcher undici cuts the connection at its default
|
|
// 300 s headersTimeout, long before the signal below ever fires.
|
|
dispatcher: llmDispatcher,
|
|
// 20 minutes. At ~6 tok/s a full answer can take far longer than
|
|
// the previous 9-minute cap, and every overrun silently demoted the
|
|
// report to the local fallback generator.
|
|
signal: AbortSignal.timeout(1200000),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const json = await response.json();
|
|
const msg = isOllama ? json.message : json.choices?.[0]?.message;
|
|
const finishReason = isOllama ? json.done_reason : json.choices?.[0]?.finish_reason;
|
|
|
|
// Hitting the cap means the JSON was cut mid-object and will fail
|
|
// to parse a few lines below. Say so here, otherwise the only clue
|
|
// is a "JSON parse failed" on a response that looks well-formed in
|
|
// the 200-char sample.
|
|
// Ollama truncates an over-long prompt to HALF of num_ctx and keeps
|
|
// the tail, so the system prompt and CP 707 standards are the first
|
|
// thing dropped. prompt_tokens near num_ctx/2 means that happened.
|
|
console.log(
|
|
`[LM Studio Dashboard] reportType=${reportType}, finish_reason=${finishReason}, prompt_tokens=${json.prompt_eval_count ?? '?'}, completion_tokens=${json.eval_count ?? '?'}`
|
|
);
|
|
|
|
if (finishReason === 'length') {
|
|
console.warn(
|
|
`[LM Studio Dashboard] Response hit the num_predict cap (reportType=${reportType}) and was truncated — raise NUM_PREDICT_BY_REPORT_TYPE for this report type.`
|
|
);
|
|
}
|
|
|
|
if (msg?.content && typeof msg.content === 'string' && msg.content.trim()) {
|
|
responseText = msg.content.trim();
|
|
} else if (
|
|
msg?.reasoning_content &&
|
|
typeof msg.reasoning_content === 'string' &&
|
|
msg.reasoning_content.trim()
|
|
) {
|
|
responseText = msg.reasoning_content.trim();
|
|
} else if (!isOllama && json.content && typeof json.content === 'string') {
|
|
responseText = json.content;
|
|
} else if (!isOllama && json.response) {
|
|
responseText = json.response;
|
|
} else if (!isOllama && Array.isArray(json.output)) {
|
|
const parts = json.output
|
|
.map((item) => (typeof item.content === 'string' ? item.content : ''))
|
|
.filter(Boolean);
|
|
responseText = parts.join('\n').trim();
|
|
}
|
|
success = !!responseText;
|
|
} else {
|
|
const errBody = await response.text().catch(() => '');
|
|
console.warn(
|
|
`[LM Studio Dashboard] attempt=${attempt} status=${response.status}: ${errBody.slice(0, 300)}`
|
|
);
|
|
// model output error → retry dengan padding lebih besar
|
|
if ((response.status === 400 || response.status === 422) && attempt < MAX_RETRIES) {
|
|
finalContextText =
|
|
finalContextText +
|
|
'\n\nCatatan: Hasilkan JSON analisis berdasarkan data di atas dan standar CP 707.';
|
|
console.log(
|
|
'[LM Studio Dashboard] Model output error, will retry with padded context'
|
|
);
|
|
}
|
|
}
|
|
} catch (lmStudioError) {
|
|
console.error(
|
|
`[LM Studio Dashboard] attempt=${attempt} Error type=${lmStudioError.name} msg=${lmStudioError.message}`
|
|
);
|
|
// Jika bukan timeout asli (120s), retry
|
|
if (attempt < MAX_RETRIES && lmStudioError.name !== 'TimeoutError') {
|
|
console.log('[LM Studio Dashboard] Connection error, will retry...');
|
|
}
|
|
}
|
|
} // end retry loop
|
|
|
|
let parsed = null;
|
|
if (success) {
|
|
// Bersihkan markdown code block (```json ... ```) dan think tags dari LLM (Qwen)
|
|
const cleanedResponseText = responseText
|
|
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
.replace(/```json\s*/gi, '')
|
|
.replace(/```\s*/g, '')
|
|
.trim();
|
|
parsed = parseDashboardInsightJson(cleanedResponseText);
|
|
if (!parsed) {
|
|
console.warn(
|
|
'[LM Studio Dashboard] JSON parse failed. Raw sample:',
|
|
cleanedResponseText.slice(0, 200)
|
|
);
|
|
} else if (!hasUsableContent(parsed)) {
|
|
// The healing in parseDashboardInsightJson is deliberately forgiving,
|
|
// so a reply in the wrong shape still "parses" — into a shell whose
|
|
// every list is empty. That shell was being served: the card rendered
|
|
// "Ringkasan tidak tersedia" over "data belum tersedia", while the
|
|
// local fallback that could have filled it never ran, because a
|
|
// non-null parse counts as success. Treat empty as failure.
|
|
console.warn(
|
|
'[LM Studio Dashboard] Parsed JSON carried no usable content (no headline, no bullets). Raw sample:',
|
|
cleanedResponseText.slice(0, 200)
|
|
);
|
|
parsed = null;
|
|
}
|
|
}
|
|
|
|
if (!parsed) {
|
|
console.warn(
|
|
'[Dashboard Insight Service] LLM failed or parsed null. Using local fallback generator.'
|
|
);
|
|
// The PERIOD-SCOPED pack, same as the LLM receives. Handing it the raw
|
|
// enriched pack meant a fallback "Minggu 1" report was written from
|
|
// whole-cycle, day-48 figures under a week-1 heading.
|
|
parsed = generateLocalDashboardInsight(prunedContextPack, reportType, reportPeriod);
|
|
}
|
|
|
|
// Applies to both paths: the fallback generator can reach the same verdict.
|
|
// Judged on what was actually sent — this marks a topic "tidak dikirim
|
|
// untuk periode ini", which is only true of the scoped pack.
|
|
parsed = enforceDataAvailability(parsed, prunedContextPack);
|
|
parsed = stampDailyPeriodOnHeadline(parsed, reportType, reportPeriod);
|
|
|
|
// Check if newly generated content is identical to existing content in database
|
|
const existing = await AiInsight.getByCycleAndKandang(
|
|
cycleId,
|
|
kandangId,
|
|
reportType,
|
|
reportPeriod
|
|
);
|
|
if (existing && existing.insightText) {
|
|
try {
|
|
const oldObj = JSON.parse(existing.insightText);
|
|
if (JSON.stringify(oldObj) === JSON.stringify(parsed)) {
|
|
// Return existing instead of throwing
|
|
return {
|
|
source: 'generated_identical',
|
|
insight: existing,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
// Ignore parse errors on old text
|
|
}
|
|
}
|
|
|
|
const insight = await AiInsight.upsert({
|
|
cycleId,
|
|
kandangId,
|
|
insightText: JSON.stringify(parsed, null, 2),
|
|
version,
|
|
reportType,
|
|
reportPeriod,
|
|
});
|
|
|
|
return {
|
|
source: 'generated',
|
|
insight,
|
|
};
|
|
} catch (error) {
|
|
console.error(
|
|
'[Dashboard Insight Service] Unexpected error during AI Insight generation:',
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
})().finally(() => {
|
|
dashboardInsightRequests.delete(cacheKey);
|
|
});
|
|
|
|
if (!forceRefresh) {
|
|
dashboardInsightRequests.set(cacheKey, requestPromise);
|
|
}
|
|
|
|
return requestPromise;
|
|
};
|
|
|
|
const extractQuotaErrorInfoFromText = (text) => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return extractQuotaErrorInfo(JSON.parse(trimmed));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
INSIGHT_VERSION,
|
|
generateDashboardInsight,
|
|
// Exported for tests: the period slicing is what makes Harian/Mingguan/Akhir
|
|
// Siklus actually differ, so it needs to be verifiable on its own.
|
|
pruneContextPackForLocalLLM,
|
|
rescopeScalarsToPeriod,
|
|
// Exported for tests: this is what guarantees a daily report names its day
|
|
// even when the model ignores the instruction to do so.
|
|
stampDailyPeriodOnHeadline,
|
|
// Exported for tests: the healing defaults decide whether an unassessed topic
|
|
// shows up green, so they need to be verifiable directly.
|
|
parseDashboardInsightJson,
|
|
// Exported for tests: this is what stops a healed-empty shell being served in
|
|
// place of a report.
|
|
hasUsableContent,
|
|
// Exported for tests: this runs only when the LLM is unreachable, so it is the
|
|
// path least likely to be exercised by hand and most likely to rot.
|
|
generateLocalDashboardInsight,
|
|
// Exported for tests: this is the last line of defence against a topic being
|
|
// reported green when no data for it was ever sent.
|
|
enforceDataAvailability,
|
|
};
|