commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -0,0 +1,939 @@
|
||||
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 INSIGHT_VERSION = 'v5';
|
||||
const dashboardInsightRequests = new Map();
|
||||
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (typeof raw.summary.headline !== 'string') {
|
||||
raw.summary.headline = 'Kondisi operasional peternakan terpantau normal.';
|
||||
}
|
||||
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();
|
||||
}
|
||||
if (!['ok', 'warning', 'critical', 'unknown'].includes(section.status)) {
|
||||
section.status = 'ok';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return raw;
|
||||
};
|
||||
|
||||
const cleanJsonComments = (str) => {
|
||||
return 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) => {
|
||||
const bookRef = dayAge ? cp707.buildBookReferenceContext(dayAge) : '';
|
||||
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.
|
||||
|
||||
${bookRef}
|
||||
|
||||
Tugas:
|
||||
Analisis data operasional berikut dan hasilkan insight terstruktur.
|
||||
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"],
|
||||
"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"]
|
||||
},
|
||||
"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.
|
||||
|
||||
DATA OPERASIONAL:
|
||||
${contextText}
|
||||
`.trim();
|
||||
};
|
||||
|
||||
const buildCacheKey = (cycleId, kandangId, version) =>
|
||||
`${cycleId}:${kandangId || 'all'}:${version}`;
|
||||
|
||||
const toNumber = (value) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const buildChickenCountingSummary = async (cycleId, kandangId) => {
|
||||
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 mortalityRecords = await Mortality.getByCycle(cycleId, selectedKandangId);
|
||||
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 mortalityRate =
|
||||
initialPopulation > 0 ? Number(((totalMortality / initialPopulation) * 100).toFixed(2)) : 0;
|
||||
const currentPopulation = latestChickenCount
|
||||
? latestChickenCount.totalCount
|
||||
: Math.max(0, initialPopulation - totalMortality - totalPanen);
|
||||
|
||||
return {
|
||||
source: {
|
||||
cycleStartDate,
|
||||
selectedKandangId,
|
||||
records: chickenCounts.length,
|
||||
hasLatestChickenCount: Boolean(latestChickenCount),
|
||||
},
|
||||
summary: {
|
||||
initialPopulation,
|
||||
currentPopulation,
|
||||
totalMortality,
|
||||
totalPanen,
|
||||
mortalityRate,
|
||||
latestChickenCount: latestChickenCount
|
||||
? {
|
||||
date: latestChickenCount.date,
|
||||
totalCount: latestChickenCount.totalCount,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const enrichContextPack = async (contextPack, cycleId, kandangId) => {
|
||||
const chickenCountingSummary = await buildChickenCountingSummary(cycleId, kandangId);
|
||||
if (!chickenCountingSummary) {
|
||||
return contextPack;
|
||||
}
|
||||
|
||||
return {
|
||||
...contextPack,
|
||||
chickenCounting: {
|
||||
...contextPack.chickenCounting,
|
||||
dashboardSummary: chickenCountingSummary.summary,
|
||||
dashboardSummarySource: chickenCountingSummary.source,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pruneContextPackForLocalLLM = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// If it's an array, let's see if we should truncate it
|
||||
if (obj.length > 7) {
|
||||
// Keep only the last 7 items (latest data)
|
||||
return obj.slice(-7).map(item => pruneContextPackForLocalLLM(item));
|
||||
}
|
||||
return obj.map(item => pruneContextPackForLocalLLM(item));
|
||||
}
|
||||
|
||||
// If it's an object, map its keys
|
||||
const pruned = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Drop heavy raw telemetry data keys that are not critical for high-level manager insight
|
||||
const heavyKeysToDrop = ['deviceWeightData', 'ckaleData', 'liveCounts'];
|
||||
if (heavyKeysToDrop.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
pruned[key] = pruneContextPackForLocalLLM(value);
|
||||
}
|
||||
return pruned;
|
||||
};
|
||||
|
||||
const generateLocalDashboardInsight = (context) => {
|
||||
// Ambil umur ayam untuk referensi standar CP 707
|
||||
const dayAge = context?.cycle?.currentAgeDay ||
|
||||
context?.ageDay ||
|
||||
context?.umurHari ||
|
||||
null;
|
||||
|
||||
// ─── 1. Hitung Ayam (Mortalitas) ─────────────────────────────────────────
|
||||
const cc = context?.chickenCounting?.dashboardSummary || {};
|
||||
const ccInitial = cc.initialPopulation || 10000;
|
||||
const ccCurrent = cc.currentPopulation || 9800;
|
||||
const ccMortality = cc.totalMortality || (ccInitial - ccCurrent);
|
||||
const ccRate = cc.mortalityRate !== undefined ? cc.mortalityRate : (ccInitial > 0 ? (ccMortality / ccInitial * 100) : 0);
|
||||
|
||||
// Analisis mortalitas berdasarkan standar CP 707
|
||||
const mortalityAnalysis = dayAge ? cp707.analyzeMortality(ccRate, dayAge) : null;
|
||||
const stdMortalityCum = dayAge ? cp707.getMortalityCumStdByDay(dayAge) : null;
|
||||
|
||||
let statusAyam = mortalityAnalysis?.status || 'ok';
|
||||
if (!mortalityAnalysis) {
|
||||
if (ccRate > 7) statusAyam = 'critical';
|
||||
else if (ccRate >= 5) statusAyam = 'warning';
|
||||
}
|
||||
|
||||
const hitung_ayam = {
|
||||
status: statusAyam,
|
||||
bullets: [
|
||||
`Mortalitas kumulatif: ${ccRate.toFixed(2)}%. Standar CP 707 pada hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}.`,
|
||||
`Populasi awal: ${ccInitial} ekor, saat ini: ${ccCurrent} ekor. Total deplesi: ${ccMortality} ekor.`
|
||||
],
|
||||
evidence: statusAyam !== 'ok' ? [
|
||||
mortalityAnalysis?.message || `Deplesi ${ccRate.toFixed(2)}% melampaui standar CP 707 (<5%).`
|
||||
] : [],
|
||||
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?.weightStats || context?.deviceWeightData || {};
|
||||
const wmAvg = wm.averageWeight?.value || null;
|
||||
const wmTarget = dayAge ? cp707.getBwStandardByDay(dayAge) : (wm.targetWeight?.value || null);
|
||||
const wmUniformity = wm.uniformity?.value || null;
|
||||
|
||||
let statusBerat = '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.`,
|
||||
wmUniformity !== null ? `Keseragaman (uniformity): ${wmUniformity}%. Standar CP 707: >80%.` : 'Data uniformity tidak tersedia.'
|
||||
],
|
||||
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?.panel_iot || context?.telemetry || context?.iotData || {};
|
||||
const temp = iot.avgTemp || iot.suhu_rata_rata_C || null;
|
||||
const hum = iot.humidity || iot.kelembapan_persen || null;
|
||||
const amonia = iot.ammonia || iot.amonia_ppm || null;
|
||||
const co2 = iot.co2 || iot.co2_ppm || null;
|
||||
|
||||
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 || 65, amonia || 0, 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 fcrVal = context?.fcr || context?.fcr_terakhir || context?.fcrSummary?.current || 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.70) 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 eefVal = context?.eef || context?.eef_terakhir || null;
|
||||
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 (${ccRate.toFixed(2)}%) 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';
|
||||
|
||||
const headline = overallStatus === 'critical'
|
||||
? `Analisis Kritis (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.`;
|
||||
|
||||
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: ${ccRate.toFixed(2)}% (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,
|
||||
}) => {
|
||||
const cacheKey = buildCacheKey(cycleId, kandangId, version);
|
||||
|
||||
if (!forceRefresh) {
|
||||
const existingRequest = dashboardInsightRequests.get(cacheKey);
|
||||
if (existingRequest) {
|
||||
return existingRequest;
|
||||
}
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
if (!forceRefresh) {
|
||||
const cached = await AiInsight.getByCycleAndKandang(cycleId, kandangId);
|
||||
if (cached && cached.version === version) {
|
||||
return {
|
||||
source: 'cache',
|
||||
insight: cached,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedContextPack = await enrichContextPack(contextPack, cycleId, kandangId);
|
||||
const rawLength = JSON.stringify(enrichedContextPack).length;
|
||||
const prunedContextPack = pruneContextPackForLocalLLM(enrichedContextPack);
|
||||
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 = enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null;
|
||||
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 || 'deepseek-r1:8b';
|
||||
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 = enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null;
|
||||
|
||||
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. DILARANG BERPIKIR TERLALU PANJANG. RINGKAS ANALISIS ANDA, LANGSUNG KE INTI MASALAH!' },
|
||||
{ role: 'user', content: buildDashboardInsightPrompt(finalContextText, dayAge) }
|
||||
],
|
||||
stream: false,
|
||||
options: {
|
||||
num_ctx: 8192,
|
||||
num_predict: 2048,
|
||||
temperature: 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) }
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 2048,
|
||||
lm_studio: { reasoning: { budget_tokens: 1000 } }
|
||||
};
|
||||
|
||||
const response = await fetch(`${lmStudioBaseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(540000)
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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 DeepSeek R1
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error('Gagal menghasilkan AI Insight karena masalah koneksi/timeout dengan LLM atau format respons tidak valid.');
|
||||
}
|
||||
|
||||
// Check if newly generated content is identical to existing content in database
|
||||
const existing = await AiInsight.getByCycleAndKandang(cycleId, kandangId);
|
||||
if (existing && existing.insightText) {
|
||||
try {
|
||||
const oldObj = JSON.parse(existing.insightText);
|
||||
if (JSON.stringify(oldObj) === JSON.stringify(parsed)) {
|
||||
throw new Error('Data belum diperbarui. Hasil analisis AI masih sama dengan versi sebelumnya.');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message.includes('Data belum diperbarui')) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const insight = await AiInsight.upsert({
|
||||
cycleId,
|
||||
kandangId,
|
||||
insightText: JSON.stringify(parsed, null, 2),
|
||||
version,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in new issue
Block a user