update ai insight to give reasoning for the insight
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-27 14:26:19 +07:00
1 parent 34336b9bd1
commit 333b1cf370
16 files changed
+2079 -130

No files matched your search

+117 -3
View File
@@ -5,6 +5,7 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
## 📋 Daftar Isi
- [Fitur Utama](#fitur-utama)
- [Cara Kerja AI Insight](#-cara-kerja-ai-insight)
- [Teknologi](#teknologi)
- [Deployment](#deployment)
- [Persyaratan Sistem](#persyaratan-sistem)
@@ -28,9 +29,11 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
- **Standar Performa**: Perbandingan dengan standar Cobb
- **Penjadwalan Pakan**: Rekomendasi jadwal pemberian pakan
- **Feed Wastage**: Monitoring pemborosan pakan
- **AI Insight**: Analisis otomatis per topik (hitung ayam, berat, FCR, EEF, panel IoT,
karung pakan) memakai LLM lokal + RAG buku standar CP 707. Tiap kartu punya toggle
Harian/Mingguan, grafik pendukung, dan ekspor PDF berlabel lokasi & periode
- **AI Insight**: Analisis per topik (dashboard, hitung ayam, berat, FCR, EEF, panel IoT,
karung pakan) memakai LLM lokal + RAG buku standar CP 707. Setiap kartu menjelaskan
**penyebab** dari angka yang menyimpang, bukan sekadar melaporkannya — lihat
[Cara kerja AI Insight](#-cara-kerja-ai-insight). Dilengkapi pemilih hari, grafik
pendukung, dan ekspor PDF berlabel lokasi & periode
- **Behavior Analysis**: Analisis perilaku ayam dengan AI
- **Network Monitoring**: Status koneksi IoT devices
- **ERP Integration**: Sinkronisasi dengan sistem ERP
@@ -44,6 +47,112 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
(lihat [SETUP.md](SETUP.md)) — tanpa itu dashboard terbuka tanpa data sama sekali
- **Persistent Storage**: Semua perubahan data tersimpan permanen
## 🧠 Cara Kerja AI Insight
AI Insight tidak hanya melaporkan angka — ia menjelaskan **kenapa** sebuah angka
menyimpang dari standar CP 707. Contoh keluarannya:
> **Kesimpulan:** Mortalitas kumulatif 11,68% (2.920 ekor) jauh lebih tinggi dari standar
> CP 707 3,52%, atau 3,3x standar. Penyebab utama adalah lonjakan kematian di hari ke-12
> hingga 15, didahului suhu kandang 34,2°C dan penurunan konsumsi pakan.
>
> **PENYEBAB:**
>
> 1. Suhu kandang di atas target CP 707 pada hari ke-11 s/d 15 mencapai 34,2°C
> (6,2°C di atas target 28°C) — [Terbukti dari data]
> 2. Penurunan konsumsi pakan >25% pada hari ke-12 — [Terbukti dari data]
>
> **TINDAKAN:** 1. Kembalikan suhu ke 28°C … 2. Periksa sistem pemberian pakan …
### Korelasi dihitung di kode, bukan oleh model
Model 7B tidak dapat diandalkan mengkorelasikan deret angka mentah — ia cenderung
melewatkan polanya atau mengarang. Karena itu korelasinya dihitung lebih dulu secara
deterministik, lalu model hanya diminta mengurutkan dan menuliskannya:
| Berkas | Peran |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `utils/insightEvidence.ts` | Mengumpulkan deret lintas-domain (kematian, suhu, bobot, pakan, FCR) jadi satu objek `diagnostik`, di-cache sekali per siklus/hari |
| `backend/services/rootCauseAnalysis.js` | Menghitung temuan: lonjakan kematian, deviasi terhadap standar CP 707, dan jeda 0–3 hari antara penyimpangan suhu/pakan dengan lonjakan kematian |
| `backend/services/cp707Knowledge.js` | Angka standar resmi dari Lampiran 2 & 3 buku CP 707 |
| `AI Insight/rag_service.py` | Kutipan teks buku CP 707 (ChromaDB) |
Setiap sebab wajib berlabel `[Terbukti dari data]` atau
`[Dugaan — perlu dicek: ...]`, dan metrik yang datanya kosong disebut terus terang di
bagian **BELUM DAPAT DIPASTIKAN** — bukan disimpulkan aman diam-diam.
### Generate bersifat manual
Kartu insight **tidak pernah** memanggil model dengan sendirinya. Membuka halaman hanya
menampilkan hasil yang sudah pernah dibuat untuk periode itu (dari cache/database). Untuk
membuat yang baru, tekan tombol **✨ Buat AI Insight** atau ikon refresh di kartu.
Alasannya: satu generate memakan waktu menit-an, jadi menjalankannya otomatis di setiap
halaman yang dibuka hanya membuang waktu model.
### Data diperbarui sekali sehari, pukul 17.00 WIB
Data operasional tidak mengalir terus menerus — sumbernya memperbarui sekali sehari pada
pukul 17.00 WIB. Konsekuensinya: insight yang dibuat pagi hari memakai data pembaruan
**hari sebelumnya**, dan angkanya baru berganti setelah pembaruan berikutnya. Kalau hari
ini belum sempat generate, masih ada waktu sampai sebelum pukul 17.00 besok tanpa datanya
berubah.
Karena itu setiap kartu AI Insight mencantumkan keterangan
_"Data diperbarui setiap hari pukul 17.00 WIB"_ di bawah judulnya, dengan penjelasan
lengkap pada tooltip-nya. Teksnya ada di `utils/insightDataSchedule.ts`.
Batas jam yang sama sudah dipakai sebagai aturan D-1 di `utils/dateFilter.ts` dan
`backend/utils/dateUtils.js` (`CUTOFF_HOUR = 17`): sebelum pukul 17.00, tanggal efektif
untuk query data masih D-1. **Kalau jam pembaruan berubah, ketiga berkas itu harus diubah
bersamaan** — `insightDataSchedule.ts` hanya keterangan untuk operator, bukan sumber
aturannya.
### Generate ulang memberi jawaban yang berbeda
Menekan generate untuk kedua kalinya pada periode data yang sama menghasilkan analisis
yang **berbeda** — sudut pandang lain dan tindakan lain — dengan **angka yang tetap sama**,
karena datanya memang tidak berubah.
Sebelumnya generate kedua mengembalikan teks yang sama persis. Penyebabnya bukan data:
`temperature` 0.1 **tanpa `seed`** membuat Ollama memakai seed tetap, dan pada suhu
serendah itu samplingnya praktis greedy. Gejalanya menyesatkan — kartu Hitung Ayam
melempar pesan _"Data belum diperbarui"_, seolah datanya yang salah.
Perbaikannya tiga lapis, dan lapisan ketiga yang menentukan:
1. **Seed acak** di setiap permintaan.
2. **Temperature 0.7 hanya saat generate ulang** — generate pertama tetap 0.1.
3. **Sudut pandang yang dirotasi** (`backend/services/insightVariation.js`): enam lensa
berbeda — manajemen pakan, lingkungan kandang, kesehatan & biosekuriti, air &
pencahayaan, operasional harian, kehilangan pakan — dan tindakan dari jawaban
sebelumnya dikirim sebagai daftar yang **dilarang diulang**.
Lapisan 1 dan 2 saja tidak cukup untuk model 7B: terukur hasilnya masih 90,7% mirip
dengan daftar tindakan yang identik. Menyuruh model "jangan sama" tidak berhasil; memberi
lensa konkret berhasil. Setelah lapisan 3 ditambahkan, kemiripan turun ke **26,4%** dengan
tindakan yang berbeda seluruhnya dan **nol angka karangan** — angka baru yang muncul hanya
turunan aritmetika yang benar dari data yang sama.
Pengaman angkanya adalah blok "ANGKA TIDAK BOLEH BERUBAH" di prompt variasi, bukan suhu
yang rendah.
Backend tahu sebuah permintaan adalah generate ulang lewat dua jalur berbeda: jalur
dashboard memeriksa apakah sudah ada baris tersimpan untuk kunci cache yang sama, sedangkan
jalur proxy per-halaman tidak punya cache sehingga frontend yang mengirim
`previous_insight` — teks yang sedang tampil di layar.
### Kecepatan
Waktu generate ditentukan perangkat keras yang menjalankan Ollama:
- **Ollama native di macOS** memakai GPU (Metal) — paling cepat, disarankan untuk
pengembangan
- **Ollama di dalam Docker** pada macOS berjalan **CPU-only** (~6 token/detik), sehingga
satu insight bisa memakan beberapa menit
Periksa dengan `curl -s http://localhost:11434/api/ps` — `"size_vram": 0` berarti model
sedang berjalan di CPU.
## 🛠 Teknologi
### Frontend
@@ -268,6 +377,9 @@ dashboard-solusi-ai-peternakan-ayam/
├── mockData/ # Mock data for demo mode
├── types/ # TypeScript type definitions
├── public/ # Static assets
├── utils/
│ ├── insightEvidence.ts # Pengumpul data lintas-domain untuk analisis sebab
│ └── insightParse.ts # Parser JSON toleran untuk jawaban model
├── AI Insight/ # Layanan RAG (Python) + ChromaDB + dokumen CP 707
├── SETUP.md # Panduan setup dari nol
├── start_all.sh # Menyalakan RAG + backend + frontend
@@ -669,6 +781,8 @@ curl http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10
- ✅ Frontend-Backend Integration
- ✅ Data Persistence
- ✅ AI Insight per topik dengan LLM lokal + RAG CP 707
- ✅ Analisis sebab-akibat berbasis data lintas-domain (mortalitas, suhu, pakan, bobot)
- ✅ Generate insight manual (tidak lagi otomatis saat halaman dibuka)
- ✅ Ekspor PDF berlabel lokasi, kandang, dan periode
- ✅ Docker Deployment (6 service)
- ✅ Database Backup & Restore Scripts
+61 -7
View File
@@ -7,6 +7,8 @@ const {
} = require('../services/dashboardInsightService.js');
const cp707 = require('../services/cp707Knowledge.js');
const { llmDispatcher, llmFetch } = require('../services/llmDispatcher.js');
const { buildVariationInstruction } = require('../services/insightVariation.js');
const { buildRootCauseEvidence, ROOT_CAUSE_CONTRACT } = require('../services/rootCauseAnalysis.js');
// GET /api/ai-insights/:cycleId
// Optional query params: kandangId, reportType, reportPeriod
@@ -272,19 +274,55 @@ router.post('/proxy', async (req, res) => {
topic = '',
context_query = '',
contextData = null,
previous_insight: previousInsight = null,
} = req.body;
/*
* Jalur ini tidak punya cache, jadi backend tidak bisa tahu sendiri apakah
* sebuah permintaan adalah generate ULANG. Frontend yang memberi tahu:
* kartu mengirim teks insight yang sedang tampil ketika operator menekan
* tombol lagi. Datanya sama, yang diminta sudut pandang dan tindakan lain.
*/
const isRegeneration = typeof previousInsight === 'string' && previousInsight.trim().length > 0;
let responseText = '';
let success = false;
// Correlations computed in code, not left to the model. A 7B model asked to
// spot "kematian melonjak di hari 12-15, tiga hari setelah suhu 34°C" from
// raw series either misses it or invents one; here the spikes, the
// deviations from the book and the lag between them are already resolved,
// so the model only has to rank and word them.
const rootCauseEvidence = buildRootCauseEvidence(contextData);
// Format contextData cleanly as JSON if present
if (contextData && typeof contextData === 'object') {
const dataStr = JSON.stringify(contextData, null, 2);
// The raw `diagnostik` series are dropped from the JSON dump: they are
// tens of rows of numbers whose only purpose was to feed the analysis
// above, and leaving them in doubles the prompt while inviting the model
// to re-derive — badly — what the evidence block already states.
const { diagnostik: _diagnostik, ...promptContext } = contextData;
const dataStr = JSON.stringify(promptContext, null, 2);
// Append formatted context data to user input if not already present
if (!input.includes(dataStr)) {
input = `${input}\n\n[Data Halaman (JSON)]:\n${dataStr}`;
}
}
if (rootCauseEvidence) {
input = `${input}\n\n${rootCauseEvidence}`;
console.log(`[RootCause] Evidence block attached (${rootCauseEvidence.length} chars)`);
}
if (isRegeneration) {
// Datanya identik, jadi angkanya wajib identik juga. Yang diminta berbeda
// adalah sudut pandang dan tindakannya — lihat services/insightVariation.js
// untuk alasan kenapa "jangan sama" saja tidak cukup untuk model 7B.
input = `${input}${buildVariationInstruction(previousInsight)}`;
console.log(
`[Insight] Generate ulang untuk topik "${topic}" — meminta sudut pandang berbeda.`
);
}
// 1. Ambil chunk CP707 yang relevan dari RAG service
const ragQuery = context_query || input.slice(0, 300); // gunakan konteks query atau 300 char pertama dari input
const rawChunks = await fetchCp707Chunks(ragQuery, topic, 4);
@@ -335,6 +373,10 @@ ATURAN ANGKA (WAJIB DIPATUHI):
menyimpulkan kondisi baik hanya karena tidak ada angka yang buruk.
- Perhatikan arah tren: FCR yang MENURUN berarti efisiensi MEMBAIK; bobot yang
menurun berarti performa memburuk. Periksa ulang arahnya sebelum menyimpulkan.`;
enrichedSystemPrompt = `${enrichedSystemPrompt}
${ROOT_CAUSE_CONTRACT}`;
if (cp707Chunks.length > 0) {
const chunksText = cp707Chunks
.map((chunk, i) => `[Referensi CP707 #${i + 1}]\n${chunk}`)
@@ -402,11 +444,22 @@ ${chunksText}
// the model whenever num_ctx changes, so two different values on
// the same model would pay a reload on every alternating request.
num_ctx: 16384,
// Measured completions on this route run 106-771 tokens. 8000 was
// unreachable anyway and only served to let a runaway model burn
// the full timeout.
num_predict: 1536,
temperature,
// Measured completions on this route ran 106-771 tokens BEFORE the
// root-cause contract; the structured answer it now asks for
// (penyebab + bukti + tindakan) is roughly twice that. 3072 keeps
// headroom above the longest observed answer without letting a
// runaway model burn the full timeout the way 8000 did.
num_predict: 3072,
// Tanpa `seed` Ollama memakai seed tetap, sehingga prompt yang
// sama selalu menghasilkan teks yang sama persis. Seed acak per
// permintaan adalah syarat pertama supaya generate ulang berbeda.
seed: Math.floor(Math.random() * 1_000_000_000),
// Seed saja tidak cukup pada temperature serendah ini: token yang
// terpilih hampir selalu sama. Dinaikkan hanya saat generate
// ulang, dan hanya ke 0.45 — cukup mengubah susunan kalimat dan
// urutan tindakan, masih jauh dari suhu yang membuat model 7B
// mulai mengarang angka.
temperature: isRegeneration ? 0.7 : temperature,
},
}
: {
@@ -415,7 +468,8 @@ ${chunksText}
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input },
],
temperature,
temperature: isRegeneration ? 0.7 : temperature,
seed: Math.floor(Math.random() * 1_000_000_000),
max_tokens: 8000,
};
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import variation from '../insightVariation.js';
const { buildVariationInstruction, extractPreviousActions, ANALYSIS_ANGLES } = variation;
const JAWABAN_SEBELUMNYA = `
PENYEBAB:
1. Konsumsi pakan lebih tinggi dari bobot badan — [Terbukti dari data]
2. Kondisi lingkungan kurang optimal — [Dugaan]
TINDAKAN:
1. Periksa suhu kandang dan kelembaban secara rutin setiap hari.
2. Evaluasi program pencahayaan untuk memastikan tidak ada gangguan.
`;
describe('extractPreviousActions', () => {
it('mengambil hanya bagian TINDAKAN, bukan daftar PENYEBAB', () => {
const actions = extractPreviousActions(JAWABAN_SEBELUMNYA);
expect(actions).toHaveLength(2);
expect(actions[0]).toContain('Periksa suhu kandang');
expect(actions[1]).toContain('Evaluasi program pencahayaan');
// Kalau bagian PENYEBAB ikut terbawa, larangannya jadi salah sasaran.
expect(actions.join(' ')).not.toContain('Konsumsi pakan lebih tinggi');
});
it('mengembalikan array kosong untuk masukan yang bukan teks', () => {
expect(extractPreviousActions(null)).toEqual([]);
expect(extractPreviousActions(undefined)).toEqual([]);
expect(extractPreviousActions(42)).toEqual([]);
});
it('tidak jatuh saat tidak ada bagian TINDAKAN sama sekali', () => {
expect(() => extractPreviousActions('teks bebas tanpa daftar')).not.toThrow();
});
});
describe('buildVariationInstruction', () => {
it('kosong kalau tidak ada jawaban sebelumnya — berarti bukan permintaan ulang', () => {
expect(buildVariationInstruction(null)).toBe('');
expect(buildVariationInstruction('')).toBe('');
expect(buildVariationInstruction(' ')).toBe('');
});
it('mewajibkan angka tidak berubah', () => {
const block = buildVariationInstruction(JAWABAN_SEBELUMNYA, 0);
expect(block).toContain('ANGKA TIDAK BOLEH BERUBAH');
});
it('menyebut satu sudut pandang konkret, bukan sekadar "jangan sama"', () => {
const block = buildVariationInstruction(JAWABAN_SEBELUMNYA, 1);
expect(block).toContain(ANALYSIS_ANGLES[1]);
});
it('mencantumkan tindakan lama sebagai daftar yang dilarang diulang', () => {
const block = buildVariationInstruction(JAWABAN_SEBELUMNYA, 0);
expect(block).toContain('DILARANG DIULANG');
expect(block).toContain('Periksa suhu kandang');
});
it('sudut pandang berputar sesuai indeks dan tidak keluar dari daftar', () => {
const dipakai = ANALYSIS_ANGLES.map((_, i) => {
const block = buildVariationInstruction(JAWABAN_SEBELUMNYA, i);
return ANALYSIS_ANGLES.find((angle) => block.includes(angle));
});
expect(new Set(dipakai).size).toBe(ANALYSIS_ANGLES.length);
expect(dipakai.every(Boolean)).toBe(true);
});
it('indeks di luar rentang tetap memilih sudut yang sah', () => {
const block = buildVariationInstruction(JAWABAN_SEBELUMNYA, ANALYSIS_ANGLES.length + 2);
expect(ANALYSIS_ANGLES.some((angle) => block.includes(angle))).toBe(true);
});
});
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
const { buildRootCauseEvidence, describeDays } = require('../rootCauseAnalysis.js');
/** A cycle where days 12-15 go badly: hot coop, feed refused, birds die. */
const buildCycle = ({ hotDays = [12, 13, 14, 15], spikeDays = [12, 13, 14, 15] } = {}) => {
const mortalitas_harian = [];
const lingkungan_harian = [];
const bobot_harian = [];
const pakan_harian = [];
for (let hari = 1; hari <= 30; hari += 1) {
mortalitas_harian.push({ hari, mati: spikeDays.includes(hari) ? 450 : 40, afkir: 0 });
lingkungan_harian.push({
hari,
suhu_C: hotDays.includes(hari) ? 34 : 29,
kelembapan_persen: 60,
});
bobot_harian.push({ hari, aktual_gram: 50 + hari * 60 });
pakan_harian.push({ hari, karung: hari === 12 ? 4 : 10 + hari * 0.5 });
}
return {
hari_ke: 30,
populasi_awal_ekor: 25000,
mortalitas_harian,
lingkungan_harian,
bobot_harian,
pakan_harian,
};
};
describe('describeDays', () => {
it('collapses consecutive days into ranges', () => {
expect(describeDays([1, 2, 3, 7, 9, 10])).toBe('1-3, 7, 9-10');
});
it('ignores non-numeric entries and duplicates', () => {
expect(describeDays([5, 5, null, undefined, 6])).toBe('5-6');
});
});
describe('buildRootCauseEvidence', () => {
it('returns nothing when the page sent no diagnostic bundle', () => {
expect(buildRootCauseEvidence({ hari_ke: 30 })).toBe('');
expect(buildRootCauseEvidence(null)).toBe('');
});
it('grades cumulative mortality against the CP 707 standard for that day', () => {
const text = buildRootCauseEvidence({ diagnostik: buildCycle() });
expect(text).toContain('Mortalitas kumulatif s/d hari ke-30');
// 2 990 deaths out of 25 000, against the book's 3,52 % for day 30.
expect(text).toContain('11,36%');
expect(text).toContain('Standar CP 707 hari ke-30: 3,52%');
});
it('locates the days the deaths actually happened instead of spreading them', () => {
const text = buildRootCauseEvidence({ diagnostik: buildCycle() });
expect(text).toContain('4 hari lonjakan (12-15)');
expect(text).toMatch(/63,4% dari seluruh kematian/);
});
it('reads the temperature standard as a number, not as the table row', () => {
// getTempStandardByDay returns the whole row; treating it as a number made
// every comparison NaN and silently dropped all temperature findings.
const text = buildRootCauseEvidence({ diagnostik: buildCycle() });
expect(text).toContain('Suhu DI ATAS target CP 707');
expect(text).not.toContain('NaN');
});
it('links a mortality spike to an environment excursion that preceded it', () => {
const text = buildRootCauseEvidence({ diagnostik: buildCycle() });
expect(text).toContain('didahului penyimpangan suhu dalam 0-2 hari');
});
it('says so explicitly when temperature does NOT explain the spikes', () => {
const text = buildRootCauseEvidence({
diagnostik: buildCycle({ hotDays: [], spikeDays: [12, 13, 14, 15] }),
});
expect(text).toContain('Suhu kemungkinan BUKAN pemicu utama');
});
it('reports missing series as a limit on the conclusion, not as a clean bill', () => {
const bundle = buildCycle();
delete bundle.lingkungan_harian;
const text = buildRootCauseEvidence({ diagnostik: bundle });
expect(text).toContain('DATA YANG TIDAK TERSEDIA');
expect(text).toContain('TIDAK dapat dibuktikan');
});
it('omits the mortality rate when the initial population is unknown', () => {
const bundle = buildCycle();
bundle.populasi_awal_ekor = null;
const text = buildRootCauseEvidence({ diagnostik: bundle });
expect(text).not.toContain('Mortalitas kumulatif s/d hari ke-30:');
});
it('ignores days after the day being reported on', () => {
const bundle = { ...buildCycle(), hari_ke: 10 };
const text = buildRootCauseEvidence({ diagnostik: bundle });
expect(text).toContain('hari ke-10');
expect(text).not.toContain('hari 12-15');
});
});
+137 -10
View File
@@ -6,6 +6,8 @@ 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_CONTRACT } = 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
@@ -22,10 +24,14 @@ const dashboardInsightRequests = new Map();
// 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: 2048,
weekly: 2560,
end_cycle: 4096,
daily: 3072,
weekly: 3584,
end_cycle: 5120,
};
const isPlainObject = (value) =>
@@ -340,8 +346,54 @@ const enforceDataAvailability = (parsed, contextPack) => {
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 str
return escapeRawControlChars(str)
.replace(/\/\/.*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\.\.\.[\s\S]*?(?=[,}\]\n])/g, '')
@@ -381,7 +433,9 @@ const buildDashboardInsightPrompt = (
contextText,
dayAge,
reportType = 'daily',
reportPeriod = null
reportPeriod = null,
rootCauseEvidence = '',
variationInstruction = ''
) => {
const bookRef = dayAge ? cp707.buildBookReferenceContext(dayAge) : '';
@@ -528,9 +582,22 @@ Aturan Analisis:
"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_CONTRACT}
DATA OPERASIONAL:
${contextText}
${rootCauseEvidence ? `\n${rootCauseEvidence}` : ''}
${variationInstruction}
`.trim();
};
@@ -1841,6 +1908,49 @@ const generateDashboardInsight = async ({
}
}
/*
* 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,
@@ -1940,7 +2050,9 @@ const generateDashboardInsight = async ({
finalContextText,
dayAge,
reportType,
reportPeriod
reportPeriod,
rootCauseEvidence,
variationInstruction
),
},
],
@@ -1956,7 +2068,19 @@ const generateDashboardInsight = async ({
// downstream could read. 16384 leaves 8192 for the prompt.
num_ctx: 16384,
num_predict: NUM_PREDICT_BY_REPORT_TYPE[reportType] ?? 2048,
temperature: 0.1,
// 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,
},
}
: {
@@ -1973,12 +2097,15 @@ const generateDashboardInsight = async ({
finalContextText,
dayAge,
reportType,
reportPeriod
reportPeriod,
rootCauseEvidence,
variationInstruction
),
},
],
temperature: 0.1,
max_tokens: 2048,
temperature: isRegeneration ? 0.7 : 0.1,
seed: Math.floor(Math.random() * 1_000_000_000),
max_tokens: 3072,
lm_studio: { reasoning: { budget_tokens: 1000 } },
};
+101
View File
@@ -0,0 +1,101 @@
/**
* Blok prompt untuk permintaan generate ULANG.
*
* Operator menekan tombol lagi karena jawaban pertama kurang memuaskan. Datanya
* tidak berubah sama sekali, jadi yang diminta adalah sudut pandang dan tindakan
* yang berbeda — bukan angka yang berbeda.
*
* Percobaan pertama hanya menyuruh model "jangan sama". Itu tidak cukup untuk
* qwen2.5:7b: dengan jawaban lama ikut dikirim, model justru terpaku padanya dan
* mengembalikan kalimat yang sama dengan beberapa anak kalimat tambahan —
* tindakannya identik (terukur 90,7% mirip, daftar tindakan persis sama).
*
* Yang bekerja adalah memberi model LENSA yang konkret dan berbeda, bukan
* sekadar larangan. Tiap permintaan ulang mengambil satu sudut dari daftar di
* bawah, dan tindakan lama dikirim sebagai daftar yang HARUS dihindari, bukan
* sebagai contoh yang bisa ditiru.
*/
/**
* Sudut analisis yang berbeda-beda. Semuanya sah untuk kasus broiler dan tidak
* saling meniadakan — satu masalah yang sama memang bisa ditangani lewat jalur
* berbeda, dan itulah yang diminta operator saat menekan generate ulang.
*/
const ANALYSIS_ANGLES = [
'manajemen pakan — mutu, jadwal, bentuk ransum, dan cara pemberiannya',
'lingkungan kandang — suhu, kelembapan, ventilasi, dan kepadatan',
'kesehatan dan biosekuriti — penyakit subklinis, stres, dan program vaksinasi',
'manajemen air minum dan pencahayaan',
'penanganan operasional harian — keseragaman bobot, sortir, dan waktu panen',
'efisiensi biaya dan kehilangan pakan — tumpah, terbuang, atau tidak tercatat',
];
/** Ambil daftar tindakan dari jawaban sebelumnya supaya bisa dilarang diulang. */
const extractPreviousActions = (previousText) => {
if (typeof previousText !== 'string') return [];
// Bagian TINDAKAN ditulis sebagai daftar bernomor di akhir jawaban.
const section = previousText.split(/TINDAKAN\s*:?/i)[1];
const source = section || previousText;
return source
.split('\n')
.map((line) => line.trim())
.filter((line) => /^\d+[.)]\s+/.test(line))
.map((line) => line.replace(/^\d+[.)]\s+/, '').slice(0, 200))
.slice(0, 6);
};
/**
* @param {string} previousText Jawaban yang sedang tampil di layar.
* @param {number} [angleIndex] Sudut yang dipakai; acak kalau tidak diberikan.
* @returns {string} Blok untuk ditempel di akhir prompt. Kosong kalau tidak ada
* jawaban sebelumnya — artinya ini bukan permintaan ulang.
*/
const buildVariationInstruction = (previousText, angleIndex) => {
if (typeof previousText !== 'string' || previousText.trim().length === 0) {
return '';
}
const index =
Number.isInteger(angleIndex) && angleIndex >= 0
? angleIndex % ANALYSIS_ANGLES.length
: Math.floor(Math.random() * ANALYSIS_ANGLES.length);
const angle = ANALYSIS_ANGLES[index];
const previousActions = extractPreviousActions(previousText);
const banned =
previousActions.length > 0
? `\n\nTINDAKAN BERIKUT SUDAH PERNAH DISARANKAN DAN DILARANG DIULANG:\n${previousActions
.map((action, i) => `${i + 1}. ${action}`)
.join('\n')}`
: '';
return `
PERMINTAAN ULANG — WAJIB BEDA DARI JAWABAN SEBELUMNYA
Operator sudah membaca analisis sebelumnya dan meminta analisis LAIN untuk data
yang sama persis. Aturannya:
1. ANGKA TIDAK BOLEH BERUBAH. Datanya identik, jadi setiap angka wajib sama
persis dengan DATA OPERASIONAL di atas. Angka baru yang tidak ada di data
berarti mengarang, dan itu lebih buruk daripada jawaban yang mirip.
2. SUDUT PANDANG WAJIB: ${angle}.
Bahas penyimpangan yang sama DARI SUDUT INI. Kalau data untuk sudut ini tidak
tersedia, katakan terus terang di bagian BELUM DAPAT DIPASTIKAN — jangan
mengarang datanya, dan jangan diam-diam kembali ke sudut sebelumnya.
3. TINDAKAN WAJIB BARU. Berikan tindakan yang berbeda, yang masuk akal dari
sudut pandang di atas. Menyalin atau memparafrasekan tindakan sebelumnya
dianggap gagal memenuhi permintaan.
4. JANGAN menyalin kalimat mana pun dari jawaban sebelumnya. Tulis ulang dari
awal.${banned}`;
};
module.exports = {
buildVariationInstruction,
extractPreviousActions,
ANALYSIS_ANGLES,
};
+571
View File
@@ -0,0 +1,571 @@
/**
* Deterministic root-cause evidence for AI Insight.
*
* The supervisor's complaint: an insight that says "mortalitas 9%, di atas
* standar" without saying WHY. The model cannot answer "why" on its own —
* a single page's payload only carries its own metric, and asking a 7B model to
* correlate raw series in its head is how fabricated causes get written.
*
* So the correlation is done here, in code, before the prompt is built: spikes,
* deviations from the CP 707 book, and the lag between an environment excursion
* and the deaths that follow it are all computed from the data and handed to the
* model as quotable findings. The model's job is reduced to ranking and wording
* them, and anything NOT in this block it must label as a hypothesis.
*
* Every function here is null-safe: pages send different payloads, and a missing
* series must produce a "data tidak tersedia" note, never a silent zero.
*/
const cp707 = require('./cp707Knowledge.js');
/**
* Target suhu (°C) untuk umur tertentu.
*
* `cp707.getTempStandardByDay` mengembalikan BARIS tabelnya
* (`{ ageDay_from, ageDay_to, temp_C, ... }`), bukan angkanya — memakai nilai
* kembalian itu langsung sebagai angka membuat setiap perbandingan suhu diam-diam
* menghasilkan NaN dan seluruh temuan suhu hilang dari blok bukti.
*/
const tempStdOf = (day) => {
const row = cp707.getTempStandardByDay(day);
return row && typeof row.temp_C === 'number' ? row.temp_C : null;
};
const num = (value) => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
// Readings arrive display-formatted ("32,1 °C"), with the Indonesian comma.
const cleaned = value.replace(/[^\d,.\-]/g, '').replace(',', '.');
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const round = (value, digits = 2) => {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
};
const median = (values) => {
const sorted = values
.filter((v) => typeof v === 'number' && Number.isFinite(v))
.sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
};
const fmt = (value, digits = 1) =>
value === null || value === undefined
? 'tidak tersedia'
: String(round(value, digits)).replace('.', ',');
/** "hari 12, 13, 14" collapsed to "hari 12-14"; keeps a reader from re-deriving the span. */
const describeDays = (days) => {
const sorted = [...new Set(days.filter((d) => Number.isFinite(d)))].sort((a, b) => a - b);
if (sorted.length === 0) return '';
const groups = [];
let start = sorted[0];
let prev = sorted[0];
for (const day of sorted.slice(1)) {
if (day === prev + 1) {
prev = day;
continue;
}
groups.push(start === prev ? `${start}` : `${start}-${prev}`);
start = day;
prev = day;
}
groups.push(start === prev ? `${start}` : `${start}-${prev}`);
return groups.join(', ');
};
const asRows = (value) =>
Array.isArray(value) ? value.filter((row) => row && typeof row === 'object') : [];
const dayOf = (row) => num(row.hari ?? row.day ?? row.hari_ke);
/** Rows up to and including `lastDay`, sorted by day. `null` keeps everything. */
const upTo = (rows, lastDay) =>
rows
.map((row) => ({ ...row, _hari: dayOf(row) }))
.filter((row) => row._hari !== null && (lastDay === null || row._hari <= lastDay))
.sort((a, b) => a._hari - b._hari);
// ─── Findings ────────────────────────────────────────────────────────────────
const analyseMortality = (bundle, findings, gaps) => {
const rows = upTo(asRows(bundle.mortalitas_harian), bundle.hari_ke);
if (rows.length === 0) {
gaps.push(
'Riwayat kematian harian (mortalitas_harian) tidak tersedia — sebaran kematian per hari dan lonjakannya tidak dapat ditelusuri.'
);
return null;
}
const daily = rows.map((row) => ({
hari: row._hari,
mati: num(row.mati ?? row.mortalityCount) ?? 0,
afkir: num(row.afkir) ?? 0,
}));
const totalMati = daily.reduce((sum, row) => sum + row.mati + row.afkir, 0);
const populasiAwal = num(bundle.populasi_awal_ekor);
const lastDay = daily[daily.length - 1].hari;
const rate = populasiAwal && populasiAwal > 0 ? (totalMati / populasiAwal) * 100 : null;
const std = cp707.getMortalityCumStdByDay(lastDay);
if (rate !== null) {
if (std !== null) {
const ratio = std > 0 ? rate / std : null;
findings.push({
area: 'MORTALITAS',
level: rate >= std * 1.5 ? 'kritis' : rate > std ? 'perhatian' : 'info',
text:
`Mortalitas kumulatif s/d hari ke-${lastDay}: ${fmt(rate, 2)}% (${totalMati.toLocaleString('id-ID')} ekor dari ` +
`${populasiAwal.toLocaleString('id-ID')} ekor). Standar CP 707 hari ke-${lastDay}: ${fmt(std, 2)}%` +
(ratio
? ` — aktual ${fmt(ratio, 1)}x standar, selisih ${fmt(rate - std, 2)} poin persen`
: '') +
(ratio && populasiAwal
? `, setara ${Math.round(((rate - std) / 100) * populasiAwal).toLocaleString('id-ID')} ekor di atas batas wajar`
: ''),
});
} else {
findings.push({
area: 'MORTALITAS',
level: 'info',
text: `Mortalitas kumulatif s/d hari ke-${lastDay}: ${fmt(rate, 2)}% (${totalMati.toLocaleString('id-ID')} ekor). Standar kumulatif hari ke-${lastDay} tidak tercantum di buku CP 707.`,
});
}
}
// Where the deaths actually happened. A flat rate hides the fact that most of
// the loss can sit in three days, which is the difference between "manajemen
// buruk sepanjang siklus" and "ada kejadian di hari 12-15".
const perDay = daily.map((row) => row.mati + row.afkir);
const med = median(perDay.filter((v) => v > 0));
const spikes =
med && med > 0 ? daily.filter((row) => row.mati + row.afkir >= Math.max(med * 2, med + 5)) : [];
if (spikes.length > 0 && totalMati > 0) {
const spikeTotal = spikes.reduce((sum, row) => sum + row.mati + row.afkir, 0);
const top = [...spikes].sort((a, b) => b.mati + b.afkir - (a.mati + a.afkir)).slice(0, 5);
findings.push({
area: 'MORTALITAS',
level: 'perhatian',
text:
`Kematian TIDAK merata: ${spikes.length} hari lonjakan (${describeDays(spikes.map((r) => r.hari))}) menyumbang ` +
`${spikeTotal.toLocaleString('id-ID')} ekor = ${fmt((spikeTotal / totalMati) * 100, 1)}% dari seluruh kematian, ` +
`padahal kematian hari normal hanya ${fmt(med, 0)} ekor/hari. Rincian hari terparah: ` +
top
.map(
(row) => `hari ke-${row.hari} ${(row.mati + row.afkir).toLocaleString('id-ID')} ekor`
)
.join('; ') +
'.',
});
} else if (med !== null) {
findings.push({
area: 'MORTALITAS',
level: 'info',
text: `Kematian tersebar merata (median ${fmt(med, 0)} ekor/hari, tidak ada hari yang melonjak ≥2x median). Pola ini mengarah ke sebab kronis/berjalan terus, bukan satu kejadian tunggal.`,
});
}
// Which rearing phase carries the loss — the book ties each phase to a
// different set of causes (brooding vs heat stress vs ascites).
const phase = { brooding: 0, grower: 0, finisher: 0 };
for (const row of daily) {
const total = row.mati + row.afkir;
if (row.hari <= 14) phase.brooding += total;
else if (row.hari <= 28) phase.grower += total;
else phase.finisher += total;
}
if (totalMati > 0) {
findings.push({
area: 'MORTALITAS',
level: 'info',
text:
`Sebaran kematian per fase: brooding (hari 1-14) ${phase.brooding.toLocaleString('id-ID')} ekor = ${fmt((phase.brooding / totalMati) * 100, 1)}%, ` +
`grower (hari 15-28) ${phase.grower.toLocaleString('id-ID')} ekor = ${fmt((phase.grower / totalMati) * 100, 1)}%, ` +
`finisher (hari 29+) ${phase.finisher.toLocaleString('id-ID')} ekor = ${fmt((phase.finisher / totalMati) * 100, 1)}%.`,
});
}
return { daily, spikes, totalMati, lastDay, rate, std };
};
const analyseEnvironment = (bundle, mortality, findings, gaps) => {
const rows = upTo(asRows(bundle.lingkungan_harian), bundle.hari_ke);
if (rows.length === 0) {
const now =
bundle.lingkungan_terkini && typeof bundle.lingkungan_terkini === 'object'
? bundle.lingkungan_terkini
: null;
const suhu = now ? num(now.suhu_C ?? now.suhu) : null;
if (suhu !== null && bundle.hari_ke) {
const std = tempStdOf(bundle.hari_ke);
if (std !== null) {
findings.push({
area: 'LINGKUNGAN',
level: Math.abs(suhu - std) >= 2 ? 'perhatian' : 'info',
text: `Suhu kandang terkini ${fmt(suhu)}°C vs target CP 707 hari ke-${bundle.hari_ke} ${fmt(std)}°C (selisih ${fmt(suhu - std)}°C). Hanya pembacaan sesaat — riwayat harian tidak tersedia.`,
});
}
}
gaps.push(
'Riwayat suhu/kelembapan harian (lingkungan_harian) tidak tersedia — kaitan antara kondisi kandang dan hari-hari kematian tinggi TIDAK dapat dibuktikan, hanya bisa diduga.'
);
return;
}
const env = rows.map((row) => ({
hari: row._hari,
suhu: num(row.suhu_C ?? row.suhu ?? row.temp),
rh: num(row.kelembapan_persen ?? row.kelembapan ?? row.humidity),
}));
const panas = [];
const dingin = [];
for (const row of env) {
const std = tempStdOf(row.hari);
if (row.suhu === null || std === null) continue;
if (row.suhu - std >= 2) panas.push({ ...row, std, delta: row.suhu - std });
else if (std - row.suhu >= 2) dingin.push({ ...row, std, delta: row.suhu - std });
}
if (panas.length > 0) {
const worst = [...panas].sort((a, b) => b.delta - a.delta)[0];
findings.push({
area: 'LINGKUNGAN',
level: panas.length >= 3 ? 'kritis' : 'perhatian',
text:
`Suhu DI ATAS target CP 707 pada ${panas.length} hari (hari ${describeDays(panas.map((r) => r.hari))}). ` +
`Terparah hari ke-${worst.hari}: ${fmt(worst.suhu)}°C vs target ${fmt(worst.std)}°C (+${fmt(worst.delta)}°C). ` +
`Buku CP 707: suhu di atas target memicu heat stress — konsumsi pakan turun, ayam megap-megap, kematian naik terutama pada ayam berbobot besar.`,
});
}
if (dingin.length > 0) {
const worst = [...dingin].sort((a, b) => a.delta - b.delta)[0];
findings.push({
area: 'LINGKUNGAN',
level: dingin.length >= 3 ? 'perhatian' : 'info',
text:
`Suhu DI BAWAH target CP 707 pada ${dingin.length} hari (hari ${describeDays(dingin.map((r) => r.hari))}). ` +
`Terparah hari ke-${worst.hari}: ${fmt(worst.suhu)}°C vs target ${fmt(worst.std)}°C (${fmt(worst.delta)}°C). ` +
`Pada fase brooding suhu kurang menyebabkan ayam menumpuk, konsumsi turun, dan kematian dini naik.`,
});
}
const rhLuar = env.filter((row) => row.rh !== null && (row.rh < 50 || row.rh > 70));
if (rhLuar.length > 0) {
findings.push({
area: 'LINGKUNGAN',
level: 'perhatian',
text:
`Kelembapan di luar rentang ideal 50-70% pada ${rhLuar.length} hari (hari ${describeDays(rhLuar.map((r) => r.hari))}), ` +
`terendah ${fmt(Math.min(...rhLuar.map((r) => r.rh)))}% dan tertinggi ${fmt(Math.max(...rhLuar.map((r) => r.rh)))}%. ` +
`RH >70% membuat litter basah (amonia naik, penyakit pernapasan); RH <50% saat brooding membuat ayam dehidrasi.`,
});
}
// The correlation the model is being asked for, computed rather than guessed:
// an excursion must PRECEDE the deaths (0-2 days) to be a candidate cause.
if (mortality && mortality.spikes.length > 0 && (panas.length > 0 || dingin.length > 0)) {
const excursions = [...panas, ...dingin];
const matched = mortality.spikes.filter((spike) =>
excursions.some((e) => e.hari <= spike.hari && spike.hari - e.hari <= 2)
);
if (matched.length > 0) {
findings.push({
area: 'KORELASI',
level: matched.length >= mortality.spikes.length / 2 ? 'kritis' : 'perhatian',
text:
`${matched.length} dari ${mortality.spikes.length} hari lonjakan kematian (hari ${describeDays(matched.map((r) => r.hari))}) ` +
`didahului penyimpangan suhu dalam 0-2 hari sebelumnya. Ini bukti terkuat yang tersedia untuk mengaitkan kematian dengan kondisi kandang.`,
});
} else {
findings.push({
area: 'KORELASI',
level: 'info',
text: `Tidak satu pun hari lonjakan kematian didahului penyimpangan suhu dalam 0-2 hari. Suhu kemungkinan BUKAN pemicu utama lonjakan tersebut — cari sebab lain (penyakit, kualitas DOC, pakan, air minum).`,
});
}
}
};
const analyseWeight = (bundle, mortality, findings, gaps) => {
const rows = upTo(asRows(bundle.bobot_harian), bundle.hari_ke);
if (rows.length === 0) {
gaps.push(
'Riwayat bobot harian (bobot_harian) tidak tersedia — dampak pertumbuhan terhadap/dari kematian tidak dapat diuji.'
);
return;
}
const weights = rows
.map((row) => ({
hari: row._hari,
aktual: num(row.aktual_gram ?? row.aktual ?? row.bobot_gram),
target: num(row.target_gram ?? row.target) ?? cp707.getBwStandardByDay(row._hari),
}))
.filter((row) => row.aktual !== null && row.aktual > 0);
if (weights.length === 0) {
gaps.push(
'Bobot harian ada barisnya tetapi seluruh nilainya kosong — pertumbuhan tidak dapat dinilai.'
);
return;
}
const last = weights[weights.length - 1];
if (last.target) {
const gap = ((last.aktual - last.target) / last.target) * 100;
findings.push({
area: 'BOBOT',
level: gap <= -10 ? 'kritis' : gap <= -5 ? 'perhatian' : 'info',
text:
`Bobot hari ke-${last.hari}: ${fmt(last.aktual, 0)} g vs standar CP 707 ${fmt(last.target, 0)} g ` +
`(${gap >= 0 ? '+' : ''}${fmt(gap, 1)}%). ` +
(gap <= -5
? 'Bobot tertinggal biasanya menyertai gangguan konsumsi pakan, penyakit, atau stres lingkungan pada periode sebelumnya.'
: 'Pertumbuhan mendekati atau melampaui standar.'),
});
}
// ADG drops flag the day the flock stopped eating — usually 1-3 days before
// the deaths show up in the ledger.
const adg = [];
for (let i = 1; i < weights.length; i += 1) {
const span = weights[i].hari - weights[i - 1].hari;
if (span <= 0) continue;
adg.push({ hari: weights[i].hari, nilai: (weights[i].aktual - weights[i - 1].aktual) / span });
}
const medAdg = median(adg.map((row) => row.nilai));
const drops = medAdg && medAdg > 0 ? adg.filter((row) => row.nilai < medAdg * 0.5) : [];
if (drops.length > 0) {
findings.push({
area: 'BOBOT',
level: 'perhatian',
text:
`Pertambahan bobot harian (ADG) anjlok di bawah setengah nilai biasanya pada hari ${describeDays(drops.map((r) => r.hari))} ` +
`(ADG normal ${fmt(medAdg, 0)} g/hari, terendah ${fmt(Math.min(...drops.map((r) => r.nilai)), 0)} g/hari). ` +
`ADG anjlok umumnya mendahului kematian: ayam berhenti makan lebih dulu, baru mati beberapa hari kemudian.`,
});
if (mortality && mortality.spikes.length > 0) {
const matched = mortality.spikes.filter((spike) =>
drops.some((d) => d.hari <= spike.hari && spike.hari - d.hari <= 3)
);
if (matched.length > 0) {
findings.push({
area: 'KORELASI',
level: 'perhatian',
text: `${matched.length} dari ${mortality.spikes.length} hari lonjakan kematian (hari ${describeDays(matched.map((r) => r.hari))}) didahului ADG anjlok dalam 0-3 hari sebelumnya.`,
});
}
}
}
const uniformity = num(bundle.keseragaman_persen);
if (uniformity !== null && uniformity > 0 && uniformity < 80) {
findings.push({
area: 'BOBOT',
level: uniformity < 70 ? 'perhatian' : 'info',
text:
`Keseragaman ${fmt(uniformity, 1)}% (standar CP 707 >80%). Populasi tidak seragam berarti ada kelompok ayam kerdil yang ` +
`kalah berebut pakan dan minum — kelompok inilah yang paling banyak mati dan diafkir.`,
});
}
};
const analyseFeed = (bundle, mortality, findings, gaps) => {
const rows = upTo(asRows(bundle.pakan_harian), bundle.hari_ke);
if (rows.length === 0) {
gaps.push(
'Riwayat pakan harian (pakan_harian) tidak tersedia — penurunan konsumsi sebagai gejala awal penyakit tidak dapat diperiksa.'
);
return;
}
const feed = rows
.map((row) => ({ hari: row._hari, karung: num(row.karung ?? row.jumlah_karung) }))
.filter((row) => row.karung !== null);
if (feed.length < 3) return;
const drops = [];
for (let i = 2; i < feed.length; i += 1) {
const baseline = (feed[i - 1].karung + feed[i - 2].karung) / 2;
if (baseline > 0 && feed[i].karung < baseline * 0.75) {
drops.push({ hari: feed[i].hari, karung: feed[i].karung, baseline });
}
}
if (drops.length > 0) {
findings.push({
area: 'PAKAN',
level: 'perhatian',
text:
`Konsumsi pakan turun >25% dibanding dua hari sebelumnya pada hari ${describeDays(drops.map((r) => r.hari))} ` +
`(contoh hari ke-${drops[0].hari}: ${fmt(drops[0].karung, 1)} karung vs rata-rata ${fmt(drops[0].baseline, 1)} karung). ` +
`Konsumsi pakan yang turun mendadak adalah gejala paling awal dari penyakit atau heat stress — pakan turun DULU, kematian menyusul.`,
});
if (mortality && mortality.spikes.length > 0) {
const matched = mortality.spikes.filter((spike) =>
drops.some((d) => d.hari <= spike.hari && spike.hari - d.hari <= 3)
);
if (matched.length > 0) {
findings.push({
area: 'KORELASI',
level: 'kritis',
text: `${matched.length} dari ${mortality.spikes.length} hari lonjakan kematian (hari ${describeDays(matched.map((r) => r.hari))}) didahului penurunan konsumsi pakan dalam 0-3 hari sebelumnya.`,
});
}
}
}
};
const analyseFcr = (bundle, findings) => {
const rows = upTo(asRows(bundle.fcr_harian), bundle.hari_ke);
const fcrRows = rows
.map((row) => ({
hari: row._hari,
aktual: num(row.aktual ?? row.fcr ?? row.manualFcr ?? row.iotFcr),
standar: num(row.standar ?? row.cobbFcr) ?? cp707.getFcrStandardByDay(row._hari),
}))
.filter((row) => row.aktual !== null && row.aktual > 0);
if (fcrRows.length === 0) return;
const last = fcrRows[fcrRows.length - 1];
if (last.standar) {
const gap = ((last.aktual - last.standar) / last.standar) * 100;
findings.push({
area: 'FCR',
level: gap >= 10 ? 'kritis' : gap >= 5 ? 'perhatian' : 'info',
text:
`FCR hari ke-${last.hari}: ${fmt(last.aktual, 3)} vs standar CP 707 ${fmt(last.standar, 3)} (${gap >= 0 ? '+' : ''}${fmt(gap, 1)}%). ` +
(gap >= 5
? 'FCR di atas standar berarti pakan terbuang: bisa karena pakan tumpah/tercecer, ayam mati setelah makan (pakan sudah dikonsumsi tapi bobotnya hilang dari panen), atau pertumbuhan terhambat.'
: 'Efisiensi pakan sesuai atau lebih baik dari standar.'),
});
}
};
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Reads the `diagnostik` bundle a page attaches to its contextData and returns
* the evidence block, or '' when the page sent nothing to work with.
*/
const buildRootCauseEvidence = (contextData) => {
const bundle =
contextData &&
typeof contextData === 'object' &&
contextData.diagnostik &&
typeof contextData.diagnostik === 'object'
? contextData.diagnostik
: null;
if (!bundle) return '';
const findings = [];
const gaps = [];
const mortality = analyseMortality(bundle, findings, gaps);
analyseEnvironment(bundle, mortality, findings, gaps);
analyseWeight(bundle, mortality, findings, gaps);
analyseFeed(bundle, mortality, findings, gaps);
analyseFcr(bundle, findings);
if (findings.length === 0 && gaps.length === 0) return '';
const order = ['MORTALITAS', 'KORELASI', 'LINGKUNGAN', 'PAKAN', 'BOBOT', 'FCR'];
const byArea = order
.map((area) => ({ area, items: findings.filter((f) => f.area === area) }))
.filter((group) => group.items.length > 0);
const body = byArea
.map(
(group) =>
`[${group.area}]\n` +
group.items
.map(
(item) =>
`- ${item.level === 'kritis' ? '(KRITIS) ' : item.level === 'perhatian' ? '(PERLU PERHATIAN) ' : ''}${item.text}`
)
.join('\n')
)
.join('\n\n');
const gapBlock =
gaps.length > 0
? `\n\n[DATA YANG TIDAK TERSEDIA — batas dari analisis ini]\n${gaps.map((gap) => `- ${gap}`).join('\n')}`
: '';
return `
═══════════════════════════════════════════════
TEMUAN TERUKUR DARI DATA (dihitung otomatis oleh sistem dari data mentah)
Angka di blok ini SUDAH benar dan boleh Anda kutip apa adanya sebagai bukti.
Blok ini adalah satu-satunya sumber sah untuk menjelaskan SEBAB.
═══════════════════════════════════════════════
${body}${gapBlock}
═══════════════════════════════════════════════`.trim();
};
/**
* The output contract. Kept separate from the evidence so it can be attached
* even when a page sends no diagnostic bundle — the demand for causal reasoning
* holds either way; without evidence the answer simply has to be honest that
* the cause cannot be established.
*/
const ROOT_CAUSE_CONTRACT = `
ATURAN ANALISIS SEBAB-AKIBAT (WAJIB — ini yang membedakan insight berguna dari sekadar laporan angka):
Menyebutkan sebuah angka menyimpang dari standar TIDAK CUKUP. Setiap penyimpangan
WAJIB Anda jelaskan SEBABNYA berdasarkan data yang ada. Contoh yang DILARANG:
"Mortalitas 9,2% berada di atas standar 4,1%, perlu perhatian." — ini tidak menjawab KENAPA.
Contoh yang BENAR: "Mortalitas 9,2% (2.300 ekor). 61% dari kematian itu terjadi pada
hari 12-15 saja (1.400 ekor), dan ketiga hari itu didahului suhu 33-34°C — 5°C di atas
target CP 707 (29°C). Konsumsi pakan juga turun 30% di hari ke-12."
Cara menyusun jawaban:
1. Mulai dari penyimpangan terbesar terhadap standar CP 707.
2. Untuk setiap penyimpangan, telusuri sebabnya HANYA dari blok TEMUAN TERUKUR
dan [Data Halaman (JSON)]: hari berapa kejadiannya, angka apa yang menyertai,
metrik lain apa yang bergerak lebih dulu.
3. Urutkan sebab dari yang paling kuat buktinya.
4. Tandai setiap sebab dengan salah satu label:
[Terbukti dari data] — angkanya ada di blok TEMUAN TERUKUR atau di Data Halaman.
[Dugaan — perlu dicek: ...] — masuk akal secara teknis tetapi TIDAK ada angkanya
di data. WAJIB sebutkan apa yang harus diperiksa peternak di lapangan untuk
memastikannya. DILARANG menuliskan angka apa pun pada sebab berlabel dugaan.
5. Sebutkan terus terang metrik yang datanya tidak tersedia dan apa akibatnya bagi
kepastian kesimpulan Anda.
6. DILARANG mengarang angka, hari, atau kejadian yang tidak ada di data.
FORMAT ISI FIELD "insight" (teks biasa, antar baris dipisah baris baru, TANPA markdown):
PENYEBAB:
1. <sebab paling kuat> — Bukti: <angka + hari dari data>. Dampak: <kaitannya ke metrik yang menyimpang>. [Terbukti dari data]
2. <sebab berikutnya> — Bukti: <...>. Dampak: <...>. [Terbukti dari data]
3. <sebab yang belum terbukti> — Alasan menduga: <...>. [Dugaan — perlu dicek: <langkah pemeriksaan konkret>]
BELUM DAPAT DIPASTIKAN:
- <metrik yang datanya kosong dan bagaimana itu membatasi kesimpulan>
TINDAKAN:
1. <tindakan konkret, sebutkan angka target dan tenggat waktunya>
2. <tindakan konkret berikutnya>
Tulis 2-5 poin PENYEBAB dan 2-4 poin TINDAKAN. Bahasa Indonesia lugas untuk peternak.
Aturan tambahan untuk format di atas:
- Label [Terbukti dari data] dan [Dugaan — perlu dicek: ...] HANYA dipakai pada poin
PENYEBAB, tepat SATU label di akhir setiap poin. DILARANG menempelkan label pada poin
TINDAKAN atau pada bagian BELUM DAPAT DIPASTIKAN.
- Bagian BELUM DAPAT DIPASTIKAN hanya untuk metrik yang datanya TIDAK ADA sama sekali.
Jangan memindahkan sebab berlabel [Dugaan] ke sana — tempatnya tetap di daftar PENYEBAB.
- Satu poin PENYEBAB = satu sebab. Jangan mengulang sebab yang sama dengan kalimat berbeda.
FORMAT ISI FIELD "kesimpulan" (2-4 kalimat):
Sebutkan metrik utama beserta angkanya, standar CP 707 pembandingnya, seberapa jauh
menyimpang, DAN sebab utamanya dalam satu tarikan kalimat. Jangan hanya menilai "baik"
atau "perlu perhatian" tanpa angka dan tanpa sebab.
`.trim();
module.exports = {
buildRootCauseEvidence,
ROOT_CAUSE_CONTRACT,
// exported for tests
describeDays,
};
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
/**
* Batas 17:00 WIB adalah aturan yang menentukan "hari data" seluruh aplikasi:
* data operasional hanya diperbarui sekali sehari pada pukul 17:00, jadi
* sebelum jam itu hari yang datanya sudah lengkap masih D-1.
*
* `CUTOFF_HOUR` dibaca sekali saat modul dimuat, jadi tiap kasus di bawah
* me-reset module registry dan mengimpor ulang supaya override-nya terpakai.
* Dengan begitu tesnya deterministik — tidak ikut berubah tergantung jam
* berapa suite ini dijalankan.
*/
const loadWithCutoff = async (hour) => {
vi.resetModules();
process.env.CUTOFF_HOUR_OVERRIDE = String(hour);
const mod = await import('../dateUtils.js');
return mod.default ?? mod;
};
afterEach(() => {
delete process.env.CUTOFF_HOUR_OVERRIDE;
vi.resetModules();
});
describe('batas 17:00 (D-1 logic)', () => {
it('cutoff 24 berarti jam berapa pun masih sebelum batas — hari efektif selalu D-1', async () => {
const { isBeforeCutoffTime, getEffectiveQueryDate, getCurrentWibDate } =
await loadWithCutoff(24);
expect(isBeforeCutoffTime()).toBe(true);
const today = getCurrentWibDate();
const effective = getEffectiveQueryDate();
today.setHours(0, 0, 0, 0);
effective.setHours(0, 0, 0, 0);
const selisihHari = Math.round((today - effective) / (1000 * 60 * 60 * 24));
expect(selisihHari).toBe(1);
});
it('cutoff 0 berarti batas sudah lewat — hari efektif adalah hari ini', async () => {
const { isBeforeCutoffTime, getEffectiveQueryDate, getCurrentWibDate } = await loadWithCutoff(0);
expect(isBeforeCutoffTime()).toBe(false);
const today = getCurrentWibDate();
const effective = getEffectiveQueryDate();
today.setHours(0, 0, 0, 0);
effective.setHours(0, 0, 0, 0);
expect(effective.getTime()).toBe(today.getTime());
});
it('nilai bawaannya 17, bukan tengah malam', async () => {
vi.resetModules();
delete process.env.CUTOFF_HOUR_OVERRIDE;
const mod = await import('../dateUtils.js');
const { CUTOFF_HOUR } = mod.default ?? mod;
expect(CUTOFF_HOUR).toBe(17);
});
it('hari efektif tidak pernah melompat lebih dari satu hari dari hari ini', async () => {
for (const hour of [0, 8, 17, 23, 24]) {
const { getEffectiveQueryDate, getCurrentWibDate } = await loadWithCutoff(hour);
const today = getCurrentWibDate();
const effective = getEffectiveQueryDate();
today.setHours(0, 0, 0, 0);
effective.setHours(0, 0, 0, 0);
const selisihHari = Math.round((today - effective) / (1000 * 60 * 60 * 24));
expect(selisihHari === 0 || selisihHari === 1).toBe(true);
}
});
});
+101 -25
View File
@@ -16,6 +16,8 @@ import { useAppContext } from '../context/AppContext.tsx';
import type { DashboardInsightData, DashboardInsightJson } from '../services/aiInsightService.ts';
import { apiClient } from '../services/apiService.ts';
import { getDiagnostikBundle } from '../utils/insightEvidence.ts';
import { DATA_UPDATE_NOTICE, DATA_UPDATE_NOTICE_DETAIL } from '../utils/insightDataSchedule.ts';
import { latestResultRow, loadDashboardData } from './iotPanel/iotApi.ts';
import { computeDashboard } from './iotPanel/computeDashboard.ts';
import { preprocessFlockPayloadForPanel } from './iotPanel/preprocessFlockPayload.ts';
@@ -97,7 +99,6 @@ interface AiInsightCardProps {
// Keep in step with INSIGHT_VERSION in backend/services/dashboardInsightService.js:
// a stale browser cache would show pre-fix insights even after the server retires them.
const CACHE_VERSION = 'v6';
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
const QUOTA_ERROR_PREFIX = 'QUOTA_LIMIT_EXCEEDED';
type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
@@ -986,7 +987,13 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
ckaleData,
feedSackHistory,
} = context;
const kandangs = ((context as any).kandangs || []) as Array<{ id: number; name?: string }>;
// `cageUuid` is what the scales/sensor APIs are keyed by; the evidence
// bundle needs it to fetch the right coop's history.
const kandangs = ((context as any).kandangs || []) as Array<{
id: number;
name?: string;
cageUuid?: string | null;
}>;
const sensorWeightSummary = ((context as any).sensorWeightSummary ||
[]) as SensorWeightSummary[];
@@ -1129,6 +1136,32 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
ckaleData: ckaleData || [],
};
// Same cross-domain bundle the page cards send, so the dashboard's causal
// reasoning is computed by the same backend code and cannot contradict
// them. Failure is tolerated: the pack is still valid without it.
const selectedCageUuid =
selectedKandangId === null
? null
: ((kandangs ?? []).find((item) => item?.id === selectedKandangId)?.cageUuid ?? null);
const diagnostik = await getDiagnostikBundle({
cycleId: cycleData.id,
cageUuid: selectedCageUuid,
kandangId: selectedKandangId,
startDate: cycleData.startDate,
currentDay,
totalDays,
// Deliberately NOT `initialPopulation`: that falls back to a hardcoded
// 20000 when `docInCount` is null, and a fabricated denominator turns
// every mortality rate in the causal analysis into fiction.
populasiAwal: cycleData.docInCount ?? null,
keseragaman: uniformity || null,
apiMode: 'LIVE',
mortalityRows:
mortalityRecords.status === 'fulfilled' && Array.isArray(mortalityRecords.value)
? mortalityRecords.value
: null,
});
const contextPack = buildContextPack({
kandangId,
kandangName,
@@ -1165,7 +1198,10 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
phase,
kandangName,
initialPopulation,
contextPack,
// Attached beside the pack, not inside `buildContextPack`: the backend
// reads it, computes the evidence block, then strips it before the pack
// is pruned and serialised.
contextPack: diagnostik ? { ...contextPack, diagnostik } : contextPack,
};
} catch (err) {
console.error('Error preparing insight data:', err);
@@ -1251,31 +1287,52 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
// Monitor when KPIs become available
const kpisReady = context.kpis && Object.keys(context.kpis).length > 0;
// Initial load - wait for KPIs to be ready
useEffect(() => {
if (!kpisReady) {
return;
/**
* Shows a report that was generated earlier, WITHOUT generating one.
*
* Generation is manual now, but a report already produced for this scope must
* still appear when the user comes back to the page — otherwise every
* navigation would look like the card had never been run.
*/
const loadStoredInsight = useCallback(async () => {
const cycleIdForLookup = context.cycleData?.id;
if (!cycleIdForLookup) return;
try {
const stored = await apiClient.getAiInsight(
cycleIdForLookup,
kandangId,
reportType,
reportPeriod
);
// A row written by an older prompt version would show retired figures
// under the current card, so it counts as "nothing stored".
if (stored?.success && stored?.data?.insightText && stored.data.version === CACHE_VERSION) {
applyInsightText(stored.data.insightText);
setLastUpdated(stored.data.generatedAt ? new Date(stored.data.generatedAt) : new Date());
setError(null);
return;
}
} catch (err) {
console.error('❌ Error reading stored insight:', err);
}
// Add a small delay after KPIs are ready to ensure all data is stable
const timer = setTimeout(() => {
generateInsight(false);
}, 1000); // Wait 1 second after KPIs are loaded
// Nothing stored for this scope: clear whatever the previous scope showed,
// so a "Hari 12" report is never left on screen under a "Hari 13" heading.
applyInsightText('');
setLastUpdated(null);
setError(null);
}, [applyInsightText, context.cycleData?.id, kandangId, reportType, reportPeriod]);
// The scope key already captures cycle/kandang changes. Avoid retriggering on unrelated context updates.
return () => clearTimeout(timer);
}, [kpisReady, kandangId, context.cycleData?.id, generateInsight, reportType, reportPeriod]); // Regenerate when KPIs become ready or the insight scope changes
// Auto-refresh every 6 hours
// Scope changed (cycle, coop, report type/period): show what is stored for it.
// Deliberately does NOT generate — the user asked for generation to happen
// only on an explicit click, and a generate here costs minutes of LLM time
// for a card nobody may be looking at.
useEffect(() => {
const interval = setInterval(() => {
generateInsight(false);
}, CACHE_TTL);
void loadStoredInsight();
}, [loadStoredInsight]);
return () => clearInterval(interval);
}, [generateInsight]);
// Manual refresh handler
// Manual generate/refresh handler — the only path that calls the model.
const handleRefresh = () => {
generateInsight(true);
};
@@ -2078,7 +2135,12 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
<div className="flex items-center gap-2">
<Sparkles className="w-6 h-6 text-purple-600" />
<h2 className="text-xl font-bold text-gray-900">AI Insight</h2>
<div>
<h2 className="text-xl font-bold text-gray-900">AI Insight</h2>
<p className="text-[11px] text-gray-400" title={DATA_UPDATE_NOTICE_DETAIL}>
{DATA_UPDATE_NOTICE}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2" data-html2canvas-ignore="true">
{/* Filter controls only visible on dashboard overview */}
@@ -2178,7 +2240,21 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
/>
</div>
) : (
<div className="text-gray-500 text-sm italic">Insight tidak tersedia saat ini</div>
/* Idle: nothing stored for this scope and nothing running. The card
waits for an explicit click instead of generating on its own. */
<div className="flex flex-col items-center justify-center gap-3 py-8 text-center">
<p className="text-sm text-gray-500">
Belum ada AI Insight untuk periode ini. Klik tombol di bawah untuk membuatnya.
</p>
<button
onClick={handleRefresh}
disabled={loading || !kpisReady}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-semibold shadow-sm hover:bg-purple-700 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<Sparkles className="w-4 h-4" />
<span>Buat AI Insight</span>
</button>
</div>
)}
</div>
+109 -48
View File
@@ -2,6 +2,8 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Sparkles, RefreshCw, AlertTriangle, CheckCircle, Download } from 'lucide-react';
import { apiClient } from '../../services/apiService.ts';
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
import { getDiagnostikBundle, scopeDiagnostikToDay } from '../../utils/insightEvidence.ts';
import { DATA_UPDATE_NOTICE, DATA_UPDATE_NOTICE_DETAIL } from '../../utils/insightDataSchedule.ts';
import { useAppContext } from '../../context/AppContext.tsx';
import InsightTrendChart from '../shared/InsightTrendChart.tsx';
import { exportCardToPdf } from '../../utils/pdfExport.ts';
@@ -286,12 +288,21 @@ RENTANG WAKTU ANALISIS: ${periodLabel}.
Blok A sudah dibatasi pada rentang tersebut; blok B dan DATA PER KANDANG sengaja kumulatif sebagai konteks. Jadikan blok A sebagai pokok analisis, jangan menyimpulkan tren di luar rentang tersebut, dan jangan mengarang angka dari hari lain.
Jadikan ini sebagai sudut pandang utama analisis. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. Dilarang keras berhalusinasi atau menambahkan angka fiktif.
TUGAS UTAMA — JELASKAN SEBABNYA:
Menyebut "mortalitas 9,1% di atas standar" saja TIDAK diterima. Anda WAJIB menjelaskan
KENAPA angkanya bisa setinggi itu, dengan menunjuk hari, angka, dan metrik lain yang
bergerak lebih dulu. Gunakan blok TEMUAN TERUKUR DARI DATA (jika ada di bawah) sebagai
sumber bukti — angka di blok itu sudah dihitung sistem dan boleh dikutip apa adanya.
Ikuti aturan analisis sebab-akibat pada system prompt: setiap sebab diberi label
[Terbukti dari data] atau [Dugaan — perlu dicek: ...], dan sebutkan terus terang metrik
yang datanya tidak tersedia.
INSTRUKSI OUTPUT:
Kembalikan HANYA satu objek JSON valid tanpa markdown, tanpa komentar, tanpa teks tambahan di luar JSON.
Format JSON yang harus dikembalikan:
{
"kesimpulan": "Ringkasan kesimpulan dari performa peternakan pada siklus ini berdasarkan data.",
"insight": "Insight mendalam terkait tren populasi, mortalitas, dan rekomendasi tindakan."
"kesimpulan": "2-4 kalimat: metrik utama + angkanya + standar CP 707 pembandingnya + seberapa jauh menyimpang + sebab utamanya.",
"insight": "Ikuti format PENYEBAB / BELUM DAPAT DIPASTIKAN / TINDAKAN, pisahkan baris dengan baris baru."
}`;
};
@@ -356,8 +367,18 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
const inFlightRef = useRef(false);
const hasGeneratedRef = useRef(false);
/**
* Hasil yang sedang tampil, di ref supaya `generate` bisa membacanya tanpa
* ikut jadi dependensi useCallback. Dikirim ke backend saat operator menekan
* generate lagi, sebagai penanda bahwa ini permintaan ULANG: datanya sama,
* yang diminta sudut pandang dan tindakan yang berbeda.
*/
const lastResultRef = useRef<AiResult | null>(null);
const lastCacheKeyRef = useRef<string | null>(null);
const pdfRef = useRef<HTMLDivElement>(null);
useEffect(() => {
lastResultRef.current = result;
}, [result]);
// The day belongs in the key: without it day 5 and day 12 share a cache entry
// and switching days shows the previous day's report.
@@ -367,8 +388,6 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
// The scope the user is currently asking for, so a request dropped because
// another was in flight can be re-run once that one finishes.
const desiredKeyRef = useRef<string>('');
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
// Flow figures for the selected period. `departures` stays cycle-wide because
// the saldo is a stock: it must count every departure up to now, not just the
@@ -508,15 +527,58 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
try {
const prompt = buildPrompt(props, timeframe, departures, periodDepartures, period.label);
const system_prompt =
'Anda adalah manajer peternakan ayam broiler CP 707. Analisis HANYA berdasarkan standar buku CP 707. Kembalikan HANYA objek JSON valid. Dilarang memberikan teks di luar JSON.';
'Anda adalah manajer peternakan ayam broiler CP 707. Analisis HANYA berdasarkan standar buku CP 707. Tugas utama Anda adalah MENJELASKAN PENYEBAB dari setiap angka yang menyimpang dari standar, bukan sekadar melaporkan angkanya. Kembalikan HANYA objek JSON valid. Dilarang memberikan teks di luar JSON.';
// Race antara LM Studio dan timeout 20 menit (sama dengan backend)
// contextData carries the cycle day so the backend can inject the CP 707
// standard for THIS day; without it the prompt has no book figure to
// compare against and the model falls back to guessing one.
const fetchCall = apiClient.proxyLmStudio(prompt, system_prompt, 0.2, 'hitung_ayam', '', {
hari_ke: props.currentDay,
});
// Why the mortality is what it is cannot be answered from the counting
// ledger alone. The bundle adds the other domains' daily series — suhu,
// pakan, bobot — so the backend can point at the days that explain the
// deaths instead of only restating the rate. Cached per cycle-day and
// shared with every other insight card in the session.
const cageUuid =
props.kandangId === null || props.kandangId === undefined
? null
: ((appContext.kandangs ?? []).find((item: any) => item?.id === props.kandangId)
?.cageUuid ?? null);
const diagnostik = scopeDiagnostikToDay(
await getDiagnostikBundle({
cycleId: props.cycleId,
cageUuid,
kandangId: props.kandangId ?? null,
startDate: cycleData?.startDate,
currentDay: props.currentDay,
totalDays: props.totalDays,
populasiAwal: props.totalInitialPopulation || null,
keseragaman: appContext.weightStats?.uniformity?.value ?? null,
apiMode: appContext.apiMode,
// This page holds the authoritative ledger — deaths, culls and
// harvest in separate columns — so it passes it rather than letting
// the bundle fall back to the KPI feed's deaths-only figure.
mortalityRows,
}),
activeDay ?? props.currentDay
);
const fetchCall = apiClient.proxyLmStudio(
prompt,
system_prompt,
0.2,
'hitung_ayam',
'',
{
hari_ke: props.currentDay,
...(diagnostik ? { diagnostik } : {}),
},
// Hanya saat operator menekan tombol lagi padahal sudah ada hasil di
// layar — itulah penanda permintaan ULANG. Pemuatan biasa (`force`
// false) tidak boleh ikut meminta variasi.
force && lastResultRef.current
? `${lastResultRef.current.kesimpulan}\n\n${lastResultRef.current.insight}`
: null
);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LM Studio timeout setelah 20 menit.')), 1200000)
);
@@ -526,8 +588,13 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
if (!parsed) throw new Error('Format respons LM Studio tidak valid. Coba lagi.');
if (result && JSON.stringify(parsed) === JSON.stringify(result)) {
// Dulu pesan ini berbunyi "Data belum diperbarui", padahal penyebab
// sebenarnya adalah sampling: tanpa seed acak dan pada temperature
// 0.1, prompt yang sama selalu menghasilkan teks yang sama. Keduanya
// sudah diperbaiki, jadi kalau ini masih terjadi penyebabnya memang
// di model — bukan di datanya.
throw new Error(
'Data belum diperbarui. Hasil analisis AI masih sama dengan versi sebelumnya.'
'Model mengembalikan analisis yang sama persis dengan sebelumnya. Coba tekan sekali lagi.'
);
}
@@ -544,11 +611,10 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
} finally {
setLoading(false);
inFlightRef.current = false;
// A timeframe switch while this was in flight was dropped, not queued —
// run the scope the user actually wants now.
if (desiredKeyRef.current !== lastCacheKeyRef.current) {
setTimeout(() => generateRef.current(false), 0);
}
// A day switch made while this was in flight is NOT re-run for the new
// scope: generation costs minutes of model time and now only ever
// starts from a click. The effect above shows the stored answer for the
// scope the user landed on, or the button if there is none.
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -564,49 +630,41 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
]
);
// Refreshed after commit, not during render: writing a ref while rendering is
// unsafe under concurrent rendering, and both are only read from async
// callbacks that run well after this effect.
useEffect(() => {
desiredKeyRef.current = cacheKey;
generateRef.current = generate;
});
// Auto-generate on mount, cycle change, or timeframe change.
// Scope changed: show the answer stored for it, and generate NOTHING.
//
// Generation runs only when the user presses the button. The stored result
// still has to reappear here, or leaving the page and coming back would look
// like the card had never been run.
//
// Deliberately NOT dependent on `generate`: it is rebuilt whenever `result` or
// any watched prop changes, and the cleanup would then keep clearing the 600ms
// timer before it could fire.
// any watched prop changes, which would make this effect re-run constantly.
useEffect(() => {
if (!props.cycleId) return;
if (lastCacheKeyRef.current === cacheKey) return;
const timer = setTimeout(() => {
generateRef.current(false);
}, 600);
try {
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const parsed = parseAiResult(cached);
if (parsed) {
setResult(parsed);
setError(null);
hasGeneratedRef.current = true;
lastCacheKeyRef.current = cacheKey;
return;
}
sessionStorage.removeItem(cacheKey);
}
} catch {
// sessionStorage might be unavailable
}
return () => clearTimeout(timer);
// Nothing stored for this scope — clear the previous one's answer so it is
// never left on screen under a different heading, and wait for the click.
setResult(null);
setError(null);
}, [props.cycleId, cacheKey]);
// Re-generate when data becomes available (population data loaded from API).
//
// `mortalityRows` belongs in here too: it is fetched separately and often
// lands after the 600ms auto-generate above has already fired. Every
// deaths/culls/harvest figure comes from that ledger, so an insight built
// before it arrived reported "data tidak tersedia" for all of them — the
// harvest count included — and nothing ever asked for a redo.
useEffect(() => {
if (!hasGeneratedRef.current) return;
if (props.populationTrend.length === 0 && mortalityRows.length === 0) return;
const timer = setTimeout(() => {
generateRef.current(true);
}, 1000);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.populationTrend.length, props.kandangInsights.length, mortalityRows.length]);
return (
<div
ref={pdfRef}
@@ -637,6 +695,9 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
<p className="text-xs text-gray-500">
Analisis populasi &amp; mortalitas berbasis kecerdasan buatan
</p>
<p className="text-[11px] text-gray-400" title={DATA_UPDATE_NOTICE_DETAIL}>
{DATA_UPDATE_NOTICE}
</p>
</div>
</div>
<div className="flex items-center gap-2" data-html2canvas-ignore="true">
+138 -33
View File
@@ -24,6 +24,8 @@ import {
type ScalarScopeSpec,
} from '../../utils/insightScope.ts';
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
import { DATA_UPDATE_NOTICE, DATA_UPDATE_NOTICE_DETAIL } from '../../utils/insightDataSchedule.ts';
import { getDiagnostikBundle, scopeDiagnostikToDay } from '../../utils/insightEvidence.ts';
// ─── Global in-memory cache (persists across page navigation in same session) ──
// Key: storageKey string, Value: parsed AiResult
@@ -175,6 +177,16 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}) => {
const appCtx = useAppContext();
const { cycleData } = appCtx;
// The scales/sensor APIs are addressed by the coop's external UUID, not by the
// internal kandang id, and the evidence bundle has to be fetched for the same
// coop the card is reporting on.
const selectedCageUuid = React.useMemo(() => {
if (appCtx.selectedKandangId === null) return null;
const kandang = (appCtx.kandangs ?? []).find(
(item: any) => item?.id === appCtx.selectedKandangId
);
return kandang?.cageUuid ?? null;
}, [appCtx.kandangs, appCtx.selectedKandangId]);
// Removed unused variable declarations
// Removed unused variable declarations
@@ -266,15 +278,24 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const inFlightRef = useRef(false);
const lastCacheKeyRef = useRef<string | null>(null);
/**
* Hasil yang sedang tampil, disimpan di ref supaya `generate` bisa
* membacanya tanpa ikut jadi dependensi useCallback — kalau lewat state,
* setiap hasil baru akan membuat ulang `generate` dan memicu efek di
* sekitarnya. Dipakai untuk memberi tahu backend bahwa sebuah permintaan
* adalah generate ULANG, sehingga jawabannya diminta berbeda.
*/
const lastResultRef = useRef<AiResult | null>(null);
useEffect(() => {
lastResultRef.current = result;
}, [result]);
const pdfRef = useRef<HTMLDivElement>(null);
// The scope the user is currently asking for. A request already in flight is
// dropped rather than queued, so without recording the want, switching the
// timeframe mid-request left the previous period's text on screen forever.
const desiredKeyRef = useRef<string>('');
// Lets the scheduling effect call the latest `generate` without listing it as
// a dependency — see the effect below for why that matters.
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
// Which days the insight text covers, computed the same way the payload is
// sliced so the shaded band can never disagree with the words.
@@ -603,6 +624,30 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
? `\n- CATATAN: data terbaru yang tersedia adalah ${dataPeriode}, sedangkan siklus sudah di hari ke-${currentDayVal}. Sebut angka sesuai harinya sendiri, jangan mengaitkannya ke hari ke-${currentDayVal}.`
: '';
// Cross-domain evidence: the card's own payload can say the metric missed
// its standard, but not why. The bundle carries the other domains' daily
// series (kematian, suhu, pakan, bobot) so the backend can correlate them
// — a spike three days after a heat excursion is a finding no single
// page's data can produce on its own. Cached per cycle-day, so only the
// first card of the session pays for it, and a failure here must not stop
// the insight from being generated.
const diagnostik = scopeDiagnostikToDay(
await getDiagnostikBundle({
cycleId: cycleData?.id,
cageUuid: selectedCageUuid,
kandangId: appCtx.selectedKandangId,
startDate: cycleData?.startDate,
currentDay: currentDayVal,
totalDays: maxDays,
// No invented denominator: `docInCount` is NULL on some cycles, and a
// made-up initial population turns every mortality rate into fiction.
populasiAwal: cycleData?.docInCount ?? null,
keseragaman: appCtx.weightStats?.uniformity?.value ?? null,
apiMode: appCtx.apiMode,
}),
activeDay ?? (typeof currentDayVal === 'number' ? currentDayVal : null)
);
const prompt = `Sebagai konsultan manajemen peternakan ayam broiler profesional (CP 707 Standard).
Anda ditugaskan untuk menganalisis data operasional kandang:
- Topik Analisis: ${topic}
@@ -614,8 +659,8 @@ ${JSON.stringify(scopedContextData, null, 2)}
Tugas Anda:
1. Analisis HANYA rentang waktu di atas (${timeframeLabel}). Data yang diberikan sudah dipotong sesuai rentang itu, jadi jangan menyimpulkan tren di luar rentang tersebut dan jangan mengarang angka dari hari lain.
2. Buat "kesimpulan" singkat (1-2 kalimat) terkait status performa saat ini berdasarkan standar CP 707.
3. Buat "insight" (rekomendasi/tindakan) operasional yang spesifik, praktis, dan dapat langsung diterapkan di lapangan.
2. Buat "kesimpulan" (2-4 kalimat): sebutkan metrik utama beserta angkanya, standar CP 707 pembandingnya, seberapa jauh menyimpang, dan sebab utamanya.
3. Buat "insight" yang MENJELASKAN SEBAB, bukan sekadar mengulang angka. Ikuti format PENYEBAB / BELUM DAPAT DIPASTIKAN / TINDAKAN pada aturan analisis sebab-akibat di system prompt, dan gunakan blok TEMUAN TERUKUR sebagai sumber bukti.
Keluarkan hasil analisis HANYA dalam format JSON dengan skema berikut:
{
@@ -635,7 +680,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
// look broken. The memory + sessionStorage caches above are keyed by
// scopeKey, which is the scope this component needs.
const system_prompt =
'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"kesimpulan": "kesimpulan analisis", "insight": "rekomendasi/tindakan praktis"}. Dilarang memberikan teks penjelasan di luar objek JSON.';
'Anda adalah analis peternakan ayam broiler berpengalaman. Tugas utama Anda adalah MENJELASKAN PENYEBAB dari setiap angka yang menyimpang dari standar, bukan sekadar melaporkan angkanya. Kembalikan HANYA objek JSON valid dengan format: {"kesimpulan": "kesimpulan analisis", "insight": "penyebab, batas kepastian, dan tindakan"}. Dilarang memberikan teks penjelasan di luar objek JSON.';
// Build a short semantic query for RAG (describe what we're analyzing)
const context_query = `Analisis ${timeframe} ${topic.replace(/_/g, ' ')} hari ke-${currentDayVal || 0} broiler CP707`;
@@ -646,7 +691,18 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
0.2,
topic,
context_query,
scopedContextData
// `diagnostik` rides along here rather than in the prompt text: the
// backend consumes it to compute the evidence block and then strips
// it, so the raw series never reach the model twice.
diagnostik
? { ...(scopedContextData as Record<string, unknown>), diagnostik }
: scopedContextData,
// Hanya saat operator menekan tombol lagi padahal sudah ada hasil
// di layar. Datanya sama; yang diminta sudut pandang dan tindakan
// yang berbeda.
force && lastResultRef.current
? `${lastResultRef.current.kesimpulan}\n\n${lastResultRef.current.insight}`
: null
);
const timeoutPromise = new Promise<never>((_, reject) =>
// 20 minutes, matching the backend. A shorter client cap just makes
@@ -681,11 +737,10 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
setLoading(false);
inFlightRef.current = false;
_insightInFlight.delete(storageKey);
// The user may have switched timeframe while this was in flight; that
// request was dropped, so run the scope they actually want now.
if (desiredKeyRef.current !== lastCacheKeyRef.current) {
setTimeout(() => generateRef.current(false), 0);
}
// A day switch made while this was in flight is NOT re-run for the new
// scope: generation costs minutes of model time and now only ever
// starts from a click. The effect above shows the stored answer for
// the scope the user landed on, or the button if there is none.
}
})();
@@ -694,28 +749,62 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
// eslint-disable-next-line react-hooks/preserve-manual-memoization
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[topic, cacheKey, contextData, cycleData?.id, result, timeframe, activeDay, scalarScope]
[
topic,
cacheKey,
contextData,
cycleData?.id,
result,
timeframe,
activeDay,
scalarScope,
selectedCageUuid,
]
);
// Refreshed after commit, not during render: writing a ref while rendering is
// unsafe under concurrent rendering, and both are only ever read from async
// callbacks that run well after this effect.
useEffect(() => {
desiredKeyRef.current = scopeKey;
generateRef.current = generate;
});
// Depends on the scope only, NOT on `generate`.
// Scope changed: show what was generated for it earlier, and NOTHING else.
//
// `generate` is rebuilt whenever `contextData` changes, and every page passes
// it as an inline object literal — so it is a new reference on each parent
// render. With `generate` in the dependency list the cleanup kept clearing the
// 800ms timer and rescheduling it, and on a page that re-renders faster than
// that the insight was never requested at all.
// Generation is manual — the card must never call the model on its own. But a
// result already produced for this exact scope still has to reappear, or
// switching pages would look like the card had never been run.
//
// Depends on the scope only, NOT on `generate`: `generate` is rebuilt whenever
// `contextData` changes, and every page passes that as an inline object
// literal, so it is a new reference on each parent render.
useEffect(() => {
if (lastCacheKeyRef.current === scopeKey) return;
const timer = setTimeout(() => generateRef.current(false), 800);
return () => clearTimeout(timer);
}, [cacheKey, timeframe, activeDay]);
const storageKey = `page-insight-v5-${scopeKey}`;
const cached = _insightMemoryCache.get(storageKey);
if (cached) {
setResult(cached);
setError(null);
lastCacheKeyRef.current = scopeKey;
return;
}
try {
const raw = sessionStorage.getItem(storageKey);
if (raw) {
const parsed = parseAiResult(raw);
if (parsed) {
_insightMemoryCache.set(storageKey, parsed);
setResult(parsed);
setError(null);
lastCacheKeyRef.current = scopeKey;
return;
}
sessionStorage.removeItem(storageKey);
}
} catch {
/* ignore */
}
// Nothing stored for this scope. Clear the previous scope's answer so a
// day-12 report is never left on screen under a day-13 heading, and wait
// for the user to press the button.
setResult(null);
setError(null);
}, [scopeKey, cacheKey, timeframe, activeDay]);
const downloadPdf = async () => {
if (!result || !pdfRef.current) return;
@@ -796,6 +885,9 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
{meta.emoji} {meta.title}
</span>
<p className="text-xs text-gray-400">Analisis berbasis kecerdasan buatan Qwen 2.5</p>
<p className="text-[11px] text-gray-400" title={DATA_UPDATE_NOTICE_DETAIL}>
{DATA_UPDATE_NOTICE}
</p>
</div>
</div>
<div className="flex items-center gap-2" data-html2canvas-ignore="true">
@@ -869,9 +961,17 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
)}
{!loading && !error && !result && (
<div className="flex items-center justify-center gap-2 py-6 text-gray-300 text-xs">
<div className="w-4 h-4 rounded-full border-2 border-gray-200 border-t-gray-400 animate-spin" />
<span>Memuat AI insight...</span>
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<p className="text-xs text-gray-500">
Belum ada AI Insight untuk periode ini. Klik tombol di bawah untuk membuatnya.
</p>
<button
onClick={() => generate(true)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${colors.btnBorder} bg-white ${colors.btnHover} ${colors.sparkText} text-xs font-semibold shadow-sm active:scale-95 transition-all`}
>
<Sparkles className="w-4 h-4" />
<span>Buat AI Insight</span>
</button>
</div>
)}
@@ -1072,7 +1172,12 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
<h4 className="text-sm font-bold text-purple-700 mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" /> AI Insight
</h4>
<p className="text-sm text-gray-700 bg-purple-50/50 p-3 rounded-lg border border-purple-100">
{/* `whitespace-pre-line`: the insight is now written as
PENYEBAB / BELUM DAPAT DIPASTIKAN / TINDAKAN across several
lines, and without this the whole thing collapses into one
paragraph. Same box, same position — only the line breaks
survive. Mirrors the counting card, which already had it. */}
<p className="text-sm text-gray-700 bg-purple-50/50 p-3 rounded-lg border border-purple-100 whitespace-pre-line">
{result.insight}
</p>
</div>
+120 -3
View File
@@ -280,6 +280,84 @@ async function fetchLiveIotSensorRange(
throw new Error('Invalid data structure from API');
}
/** One cycle day of environment readings, averaged from every sensor sample. */
export interface DailyEnvironmentPoint {
hari: number;
tanggal: string;
suhu_C: number;
suhuMin_C: number;
suhuMax_C: number;
kelembapan_persen: number;
co2_ppm: number | null;
jumlahSampel: number;
}
/**
* Environment history aggregated per CYCLE DAY, for root-cause analysis.
*
* `fetchLiveIotSensorRange` cannot serve this: it throws away everything older
* than 12 hours and reduces the timestamp to "HH:MM", which is right for the
* live chart and useless for asking why the birds died on day 13. This one
* keeps the API's own `day` field — verified to be the cycle day, and already
* cut at the WIB midnight boundary rather than UTC's.
*/
async function fetchIotDailyEnvironment(
startDate: string,
endDate: string
): Promise<DailyEnvironmentPoint[]> {
const response = await fetch(
`${IOT_API_BASE}/flocks/sensors/range/?start_date=${startDate}&end_date=${endDate}`
);
if (response.status === 404) return [];
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const json = await response.json();
if (!json?.success || !Array.isArray(json.data)) return [];
const buckets = new Map<
number,
{ tanggal: string; suhu: number[]; rh: number[]; co2: number[] }
>();
for (const item of json.data) {
const hari = Number(item?.day);
const suhu = Number(item?.suhu);
const rh = Number(item?.kelembaban);
// A dead sensor reports 0 for both; averaging those in drags the day's mean
// below the standard and manufactures a "kandang kedinginan" finding.
if (!Number.isFinite(hari) || hari <= 0 || !(suhu > 0) || !(rh > 0)) continue;
const bucket =
buckets.get(hari) ??
(buckets.set(hari, {
tanggal: String(item?.update_at ?? '').slice(0, 10),
suhu: [],
rh: [],
co2: [],
}),
buckets.get(hari)!);
bucket.suhu.push(suhu);
bucket.rh.push(rh);
const co2 = Number(item?.co2);
if (Number.isFinite(co2) && co2 > 0) bucket.co2.push(co2);
}
const mean = (values: number[]) =>
values.length === 0 ? 0 : values.reduce((sum, v) => sum + v, 0) / values.length;
const round1 = (value: number) => Math.round(value * 10) / 10;
return [...buckets.entries()]
.map(([hari, bucket]) => ({
hari,
tanggal: bucket.tanggal,
suhu_C: round1(mean(bucket.suhu)),
suhuMin_C: round1(Math.min(...bucket.suhu)),
suhuMax_C: round1(Math.max(...bucket.suhu)),
kelembapan_persen: round1(mean(bucket.rh)),
co2_ppm: bucket.co2.length > 0 ? round1(mean(bucket.co2)) : null,
jumlahSampel: bucket.suhu.length,
}))
.sort((a, b) => a.hari - b.hari);
}
/**
* Returns mock IoT sensor range data
*/
@@ -675,6 +753,20 @@ export const apiClient = {
}
},
/**
* Environment readings averaged per cycle day, for the AI Insight
* root-cause analysis. DEMO mode has no dated history to aggregate, so it
* returns nothing rather than mock days that would invent correlations.
*/
getIotDailyEnvironment: async (
startDate: string,
endDate: string,
mode: 'LIVE' | 'DEMO'
): Promise<DailyEnvironmentPoint[]> => {
if (mode !== 'LIVE') return [];
return fetchIotDailyEnvironment(startDate, endDate);
},
/**
* Gets feed sack counting data.
* In LIVE mode, throws errors (no fallback to mock).
@@ -1530,8 +1622,24 @@ export const apiClient = {
* @param kandangId - Kandang ID (optional, null means all kandangs)
* @returns Promise<{success: boolean, data?: any, message?: string}>
*/
getAiInsight: async (cycleId: string, kandangId: string | null = null) => {
const queryParams = kandangId ? `?kandangId=${kandangId}` : '';
/**
* Reads a STORED insight. Never generates one.
*
* `reportType`/`reportPeriod` scope the lookup the same way the generate
* endpoint stores it — without them a card asking for "Hari 12" gets whatever
* report happens to be newest for the cycle.
*/
getAiInsight: async (
cycleId: string,
kandangId: string | null = null,
reportType: 'daily' | 'weekly' | 'end_cycle' | null = null,
reportPeriod: string | null = null
) => {
const params = new URLSearchParams();
if (kandangId) params.set('kandangId', kandangId);
if (reportType) params.set('reportType', reportType);
if (reportPeriod) params.set('reportPeriod', reportPeriod);
const queryParams = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/${cycleId}${queryParams}`);
if (response.status === 404) {
@@ -1634,7 +1742,15 @@ export const apiClient = {
temperature = 0.2,
topic = '',
context_query = '',
contextData: Record<string, any> | null = null
contextData: Record<string, any> | null = null,
/**
* Teks insight yang sedang tampil, dikirim HANYA ketika operator menekan
* generate lagi untuk periode data yang sama. Jalur proxy tidak punya
* cache, jadi backend tidak bisa tahu sendiri ini permintaan ulang —
* kehadiran field inilah penandanya. Backend memakainya untuk meminta
* sudut pandang dan tindakan yang berbeda, dengan angka yang tetap sama.
*/
previousInsight: string | null = null
) => {
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/proxy`, {
method: 'POST',
@@ -1646,6 +1762,7 @@ export const apiClient = {
topic,
context_query,
contextData,
previous_insight: previousInsight,
}),
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { parseAiResult } from '../insightParse.ts';
/**
* The multi-line insight format (PENYEBAB / TINDAKAN) is what broke this:
* qwen2.5 writes those line breaks as literal newlines inside the JSON string,
* which `JSON.parse` rejects outright. A valid answer was being discarded and
* the card showed "Respons AI tidak dapat diproses".
*/
describe('parseAiResult — model JSON that is not quite JSON', () => {
it('accepts literal newlines inside a string value', () => {
const raw = `{"kesimpulan":"Mortalitas 9,1% di atas standar.","insight":"PENYEBAB:
1. Suhu 34°C di hari 12-15. [Terbukti dari data]
TINDAKAN:
1. Turunkan suhu ke 28°C."}`;
const parsed = parseAiResult(raw);
expect(parsed).not.toBeNull();
expect(parsed?.kesimpulan).toBe('Mortalitas 9,1% di atas standar.');
// The line breaks must SURVIVE — the card renders them with
// `whitespace-pre-line`, so collapsing them would flatten the format.
expect(parsed?.insight.split('\n')).toHaveLength(4);
expect(parsed?.insight).toContain('PENYEBAB:');
expect(parsed?.insight).toContain('TINDAKAN:');
});
it('accepts tabs and carriage returns inside a string value', () => {
const parsed = parseAiResult('{"kesimpulan":"a\tb","insight":"c\r\nd"}');
expect(parsed?.kesimpulan).toBe('a\tb');
expect(parsed?.insight).toContain('c');
expect(parsed?.insight).toContain('d');
});
it('leaves already-escaped newlines alone', () => {
const parsed = parseAiResult('{"kesimpulan":"satu\\ndua","insight":"tiga"}');
expect(parsed?.kesimpulan).toBe('satu\ndua');
});
it('does not confuse an escaped quote for the end of a string', () => {
const raw = '{"kesimpulan":"dia bilang \\"panas\\" sekali","insight":"x\ny"}';
const parsed = parseAiResult(raw);
expect(parsed?.kesimpulan).toBe('dia bilang "panas" sekali');
expect(parsed?.insight).toBe('x\ny');
});
it('still handles a code fence around the object', () => {
const parsed = parseAiResult('```json\n{"kesimpulan":"a","insight":"b\nc"}\n```');
expect(parsed?.kesimpulan).toBe('a');
expect(parsed?.insight).toBe('b\nc');
});
it('returns null when there is no object at all', () => {
expect(parseAiResult('maaf, saya tidak bisa menjawab')).toBeNull();
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Jadwal pembaruan data operasional, untuk ditampilkan di kartu AI Insight.
*
* Data peternakan diperbarui sekali sehari pada pukul 17.00 WIB — bukan terus
* menerus. Tanpa keterangan ini operator mudah menyangka insight yang dibuat
* pagi hari memakai data pagi itu, padahal yang dipakai adalah data pembaruan
* pukul 17.00 sebelumnya. Angkanya baru berganti setelah pembaruan berikutnya.
*
* Batas jam yang sama sudah dipakai di `utils/dateFilter.ts` dan
* `backend/utils/dateUtils.js` (CUTOFF_HOUR). Kalau jamnya berubah, ubah
* ketiganya bersamaan — teks ini hanya keterangan, bukan sumber aturannya.
*/
/** Keterangan singkat untuk header kartu insight. */
export const DATA_UPDATE_NOTICE = 'Data diperbarui setiap hari pukul 17.00 WIB';
/** Penjelasan lengkap untuk tooltip, saat operator ingin tahu alasannya. */
export const DATA_UPDATE_NOTICE_DETAIL =
'Data operasional diperbarui sekali sehari pada pukul 17.00 WIB. ' +
'Insight yang dibuat sebelum jam tersebut memakai data pembaruan hari sebelumnya.';
+244
View File
@@ -0,0 +1,244 @@
/**
* Cross-domain evidence for the AI Insight cards.
*
* A page's own payload can only ever say WHAT happened on that page — "mortalitas
* 9,1%, di atas standar". Answering WHY needs the other domains: the temperature
* on the days the deaths spiked, the feed intake that fell first, the growth that
* stalled. This module gathers those series once per cycle and hands them to the
* card as a `diagnostik` bundle.
*
* The correlation itself is NOT done here — `backend/services/rootCauseAnalysis.js`
* does it, so the dashboard path and every page path reach the same conclusions
* from the same code. This file only collects and normalises.
*/
import { apiClient } from '../services/apiService.ts';
export interface DiagnostikBundle {
hari_ke: number | null;
total_hari?: number | null;
populasi_awal_ekor: number | null;
populasi_kini_ekor?: number | null;
keseragaman_persen?: number | null;
mortalitas_harian: Array<{ hari: number; mati: number; afkir: number; panen?: number }>;
bobot_harian: Array<{ hari: number; aktual_gram: number; target_gram: number | null }>;
fcr_harian: Array<{ hari: number; aktual: number; standar: number | null }>;
pakan_harian: Array<{ hari: number; karung: number }>;
lingkungan_harian: Array<{ hari: number; suhu_C: number; kelembapan_persen: number }>;
}
export interface DiagnostikInput {
cycleId: string | null | undefined;
/**
* External coop UUID for the scales/sensor APIs.
*
* `getMultiCoopWeightData` returns null without it, so with "semua kandang"
* selected the weight/feed/FCR series are simply absent — which the analysis
* reports as missing data rather than as zeros.
*/
cageUuid: string | null | undefined;
/** Internal kandang id, for scoping the mortality ledger on our own backend. */
kandangId?: number | null;
startDate: Date | string | null | undefined;
currentDay: number | null | undefined;
totalDays?: number | null;
populasiAwal?: number | null;
populasiKini?: number | null;
keseragaman?: number | null;
apiMode?: 'LIVE' | 'DEMO';
/**
* The mortality ledger, when the caller already has it.
*
* It is the authoritative record of deaths, culls and harvest — the KPI feed
* carries deaths only, and reading a population drop as deaths is what once
* made the counting page announce "mortalitas 100%".
*/
mortalityRows?: Array<{
day?: number | null;
mortalityCount?: number | null;
afkir?: number | null;
panen?: number | null;
}> | null;
}
const toNum = (value: unknown): number | null => {
const parsed = typeof value === 'string' ? Number.parseFloat(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null;
};
const isoDate = (date: Date): string => {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
};
/**
* One fetch per cycle-day, shared by every card on every page.
*
* Six insight cards mounting in one session would otherwise pull the whole
* cycle's sensor history six times. The day is part of the key so the data still
* refreshes when the cycle moves on.
*/
const _bundleCache = new Map<string, Promise<DiagnostikBundle | null>>();
const buildBundle = async (input: DiagnostikInput): Promise<DiagnostikBundle | null> => {
const currentDay = toNum(input.currentDay);
const start = input.startDate ? new Date(input.startDate) : null;
const mode = input.apiMode ?? 'LIVE';
// Both sources are optional: a missing one must degrade to "data tidak
// tersedia" in the insight, never block the card from generating.
// The ledger is fetched here when the caller has none of its own. It is the
// only source that separates deaths from culls and from harvest, and it works
// for "semua kandang" too — unlike the KPI feed, which needs a cage UUID.
const needsLedger = !Array.isArray(input.mortalityRows) || input.mortalityRows.length === 0;
const [kpiResult, envResult, ledgerResult] = await Promise.allSettled([
apiClient.getMultiCoopWeightData(mode, input.cageUuid ?? null),
start && Number.isFinite(start.getTime())
? apiClient.getIotDailyEnvironment(isoDate(start), isoDate(new Date()), mode)
: Promise.resolve([]),
needsLedger && input.cycleId
? apiClient.getMortalityRecords(input.cycleId, input.kandangId ?? undefined)
: Promise.resolve([]),
]);
const kpiRows: Record<string, Record<string, unknown>> = kpiResult.status === 'fulfilled' &&
kpiResult.value &&
typeof kpiResult.value === 'object'
? (kpiResult.value as Record<string, Record<string, unknown>>)
: {};
const envRows =
envResult.status === 'fulfilled' && Array.isArray(envResult.value) ? envResult.value : [];
if (kpiResult.status === 'rejected') {
console.warn('[insightEvidence] KPI harian tidak terambil:', kpiResult.reason);
}
if (envResult.status === 'rejected') {
console.warn('[insightEvidence] Riwayat lingkungan tidak terambil:', envResult.reason);
}
if (ledgerResult.status === 'rejected') {
console.warn('[insightEvidence] Catatan mortalitas tidak terambil:', ledgerResult.reason);
}
const mortalitas_harian: DiagnostikBundle['mortalitas_harian'] = [];
const bobot_harian: DiagnostikBundle['bobot_harian'] = [];
const fcr_harian: DiagnostikBundle['fcr_harian'] = [];
const pakan_harian: DiagnostikBundle['pakan_harian'] = [];
for (const [key, row] of Object.entries(kpiRows)) {
const index = Number.parseInt(key, 10);
if (!Number.isFinite(index) || !row || typeof row !== 'object') continue;
// The KPI feed is keyed 0-based; every other series in the app counts days
// from 1, and mixing the two shifts every correlation by a day.
const hari = index + 1;
const mati = toNum(row.mortality);
if (mati !== null && mati >= 0) mortalitas_harian.push({ hari, mati, afkir: 0 });
const bobot = toNum(row.iot_weight) ?? toNum(row.manual_weight);
if (bobot !== null && bobot > 0) {
bobot_harian.push({ hari, aktual_gram: bobot, target_gram: toNum(row.ref_weight) });
}
const fcr = toNum(row.iot_fcr) ?? toNum(row.manual_fcr);
if (fcr !== null && fcr > 0) fcr_harian.push({ hari, aktual: fcr, standar: null });
const karung = toNum(row.food);
if (karung !== null && karung >= 0) pakan_harian.push({ hari, karung });
}
// The ledger wins over the KPI feed either way: it separates deaths from culls
// and from harvest, which the KPI feed does not.
const fetchedLedger =
ledgerResult.status === 'fulfilled' && Array.isArray(ledgerResult.value)
? (ledgerResult.value as DiagnostikInput['mortalityRows'])
: [];
const ledger =
!needsLedger && Array.isArray(input.mortalityRows)
? input.mortalityRows
: (fetchedLedger ?? []);
const ledgerRows = ledger
.map((row) => ({
hari: toNum(row.day),
mati: toNum(row.mortalityCount) ?? 0,
afkir: toNum(row.afkir) ?? 0,
panen: toNum(row.panen) ?? 0,
}))
.filter(
(row): row is { hari: number; mati: number; afkir: number; panen: number } =>
row.hari !== null
);
const sortByDay = <T extends { hari: number }>(rows: T[]) => rows.sort((a, b) => a.hari - b.hari);
const bundle: DiagnostikBundle = {
hari_ke: currentDay,
total_hari: toNum(input.totalDays),
populasi_awal_ekor: toNum(input.populasiAwal),
populasi_kini_ekor: toNum(input.populasiKini),
keseragaman_persen: toNum(input.keseragaman),
mortalitas_harian: sortByDay(ledgerRows.length > 0 ? ledgerRows : mortalitas_harian),
bobot_harian: sortByDay(bobot_harian),
fcr_harian: sortByDay(fcr_harian),
pakan_harian: sortByDay(pakan_harian),
lingkungan_harian: sortByDay(
envRows.map((row) => ({
hari: row.hari,
suhu_C: row.suhu_C,
kelembapan_persen: row.kelembapan_persen,
}))
),
};
const hasAnySeries =
bundle.mortalitas_harian.length > 0 ||
bundle.bobot_harian.length > 0 ||
bundle.pakan_harian.length > 0 ||
bundle.lingkungan_harian.length > 0;
return hasAnySeries ? bundle : null;
};
/**
* The `diagnostik` bundle for a cycle, or null when nothing could be gathered.
*
* Never throws: a card that cannot get its evidence still has to render its
* insight, only without the causal section.
*/
export const getDiagnostikBundle = async (
input: DiagnostikInput
): Promise<DiagnostikBundle | null> => {
if (!input.cycleId) return null;
const key = `${input.cycleId}|${input.cageUuid ?? 'all'}|${input.kandangId ?? 'all'}|${input.currentDay ?? '?'}`;
const cached = _bundleCache.get(key);
if (cached) return cached;
const promise = buildBundle(input).catch((error) => {
console.warn('[insightEvidence] Gagal menyusun bukti diagnostik:', error);
_bundleCache.delete(key);
return null;
});
_bundleCache.set(key, promise);
return promise;
};
/** Same bundle, re-scoped to a chosen cycle day so a report for day 20 never cites day 30. */
export const scopeDiagnostikToDay = (
bundle: DiagnostikBundle | null,
day: number | null
): DiagnostikBundle | null => {
if (!bundle) return null;
if (day === null || !Number.isFinite(day)) return bundle;
const cut = <T extends { hari: number }>(rows: T[]) => rows.filter((row) => row.hari <= day);
return {
...bundle,
hari_ke: day,
mortalitas_harian: cut(bundle.mortalitas_harian),
bobot_harian: cut(bundle.bobot_harian),
fcr_harian: cut(bundle.fcr_harian),
pakan_harian: cut(bundle.pakan_harian),
lingkungan_harian: cut(bundle.lingkungan_harian),
};
};
+52 -1
View File
@@ -86,6 +86,57 @@ const takeFirst = (source: Record<string, unknown>, keys: string[]) => {
return null;
};
/**
* Escapes raw control characters that sit INSIDE a JSON string literal.
*
* The insight is now asked for as several lines (PENYEBAB / TINDAKAN), and the
* model writes those line breaks literally instead of as `\n`. That is invalid
* JSON — `JSON.parse` rejects it with "Bad control character in string literal"
* — so a perfectly good answer was being thrown away and the card showed
* "Respons AI tidak dapat diproses". Repairing it here is the durable fix: no
* prompt wording makes a 7B model escape newlines reliably.
*
* Only characters inside string literals are touched; the JSON structure itself
* is left exactly as it was.
*/
const escapeRawControlChars = (json: string): string => {
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;
};
/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */
const extractJsonObject = (text: string): string | null => {
const cleaned = text
@@ -100,7 +151,7 @@ const extractJsonObject = (text: string): string | null => {
const first = cleaned.indexOf('{');
const last = cleaned.lastIndexOf('}');
if (first === -1 || last === -1 || last <= first) return null;
return cleaned.slice(first, last + 1);
return escapeRawControlChars(cleaned.slice(first, last + 1));
};
export const parseAiResult = (text: string): AiResult | null => {