fix ai insight
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-28 14:22:33 +07:00
1 parent 333b1cf370
commit 01b3178bc6
7 files changed
+333 -23

No files matched your search

@@ -99,3 +99,58 @@ describe('buildRootCauseEvidence', () => {
expect(text).not.toContain('hari 12-15');
});
});
// A day-48 report was written entirely around day 42 — the worst temperature
// day in the window. The figure was right and correctly scoped, but nothing in
// the evidence stated day 48's own condition, so the reported day vanished.
describe('penambatan ke hari yang dilaporkan', () => {
const lingkungan = Array.from({ length: 49 }, (_, hari) => ({
hari,
// Day 42 is the worst; day 48 is hot too, but less so.
suhu_C: hari === 42 ? 28.3 : 27.5,
kelembapan_persen: 74,
})).filter((row) => row.hari > 0);
const evidenceFor = (hari_ke) =>
buildRootCauseEvidence({
diagnostik: {
hari_ke,
mortalitas_harian: [],
bobot_harian: [],
fcr_harian: [],
pakan_harian: [],
lingkungan_harian: lingkungan,
},
});
it('menyebut hari yang dilaporkan di kepala blok', () => {
expect(evidenceFor(48)).toContain('HARI YANG DILAPORKAN: hari ke-48');
});
it('menyatakan kondisi hari itu sendiri, bukan hanya hari terparah', () => {
const text = evidenceFor(48);
expect(text).toContain('[KONDISI HARI YANG DILAPORKAN]');
expect(text).toContain('Hari ke-48: suhu 27,5°C');
// It leads the block; the peak-day section comes after it.
expect(text.indexOf('[KONDISI HARI YANG DILAPORKAN]')).toBeLessThan(
text.indexOf('[LINGKUNGAN]')
);
// The worst-day line still exists, but it is now labelled as history.
expect(text).toContain('Terparah hari ke-42');
});
it('berkata terus terang saat hari yang dilaporkan tidak punya pembacaan', () => {
const text = buildRootCauseEvidence({
diagnostik: {
hari_ke: 60,
mortalitas_harian: [],
bobot_harian: [],
fcr_harian: [],
pakan_harian: [],
lingkungan_harian: lingkungan,
},
});
expect(text).toContain('Tidak ada pembacaan sensor untuk hari ke-60');
expect(text).toContain('Pembacaan terakhir yang ada adalah hari ke-48');
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
const {
parseDashboardInsightJson,
hasUsableContent,
} = require('../dashboardInsightService.js');
const {
ROOT_CAUSE_CONTRACT,
ROOT_CAUSE_REASONING_RULES,
} = require('../rootCauseAnalysis.js');
// Observed on the day-48 daily report: the model answered in the root-cause
// text contract's shape instead of the dashboard schema, the healing turned
// that into an all-empty shell, and the shell was served and persisted because
// its filler bullets read as content.
const dayFortyEightReply = JSON.stringify({
temuan: {
mortalitas: [{ deskripsi: 'Kematian TIDAK merata: 9 hari lonjakan.' }],
},
saran: ['Perketat biosekuriti.'],
});
describe('balasan model dengan bentuk JSON yang salah', () => {
it('tidak dianggap laporan yang bisa dipakai', () => {
const parsed = parseDashboardInsightJson(dayFortyEightReply);
// Healing still succeeds — that is its job.
expect(parsed).not.toBeNull();
expect(hasUsableContent(parsed)).toBe(false);
});
it('masih menerima laporan yang hanya satu topiknya terisi', () => {
const parsed = parseDashboardInsightJson(
JSON.stringify({
summary: {},
detailed: { berat_ayam: { status: 'warning', bullets: ['Bobot 1379 g vs target 2500 g.'] } },
})
);
expect(hasUsableContent(parsed)).toBe(true);
});
});
describe('kontrak sebab-akibat', () => {
it('tidak menyuntikkan format teks insight/kesimpulan ke prompt dashboard', () => {
expect(ROOT_CAUSE_REASONING_RULES).toContain('ATURAN ANALISIS SEBAB-AKIBAT');
expect(ROOT_CAUSE_REASONING_RULES).not.toContain('FORMAT ISI FIELD');
});
it('rute insight teks tetap mendapat kedua bagian', () => {
expect(ROOT_CAUSE_CONTRACT).toContain('ATURAN ANALISIS SEBAB-AKIBAT');
expect(ROOT_CAUSE_CONTRACT).toContain('FORMAT ISI FIELD "insight"');
expect(ROOT_CAUSE_CONTRACT).toContain('FORMAT ISI FIELD "kesimpulan"');
});
});
+18 -4
View File
@@ -6,7 +6,7 @@ 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 { buildRootCauseEvidence, ROOT_CAUSE_REASONING_RULES } = require('./rootCauseAnalysis.js');
const { buildVariationInstruction } = require('./insightVariation.js');
// v6: period-scoped scalars, computed EEF, unit-suffixed field names, and the
@@ -116,6 +116,13 @@ const isDashboardInsightJson = (value) => {
);
};
/**
* Filler `healDashboardInsightJson` writes into a section the model left empty.
* `hasUsableContent` has to recognise it, or a reply carrying nothing at all
* reads as seven populated sections.
*/
const EMPTY_SECTION_PLACEHOLDER = 'Data tidak tersedia untuk periode ini.';
const healDashboardInsightJson = (raw) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
@@ -228,7 +235,7 @@ const healDashboardInsightJson = (raw) => {
section.bullets.length === 0 && section.evidence.length === 0 && section.actions.length === 0;
if (isEmpty) {
section.status = 'unknown';
section.bullets = ['Data tidak tersedia untuk periode ini.'];
section.bullets = [EMPTY_SECTION_PLACEHOLDER];
}
}
@@ -286,13 +293,20 @@ const hasUsableContent = (parsed) => {
(key) => Array.isArray(summary[key]) && summary[key].length > 0
);
// The healing fills every empty section with EMPTY_SECTION_PLACEHOLDER, so a
// plain length check finds "content" in all seven sections of a reply that
// said nothing. Only entries the model actually wrote count.
const detailed = parsed.detailed || {};
const detailedHasContent = Object.values(detailed).some(
(section) =>
section &&
typeof section === 'object' &&
['bullets', 'evidence', 'actions'].some(
(key) => Array.isArray(section[key]) && section[key].length > 0
(key) =>
Array.isArray(section[key]) &&
section[key].some(
(item) => typeof item === 'string' && item.trim() && item.trim() !== EMPTY_SECTION_PLACEHOLDER
)
)
);
@@ -592,7 +606,7 @@ Aturan Analisis:
(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}
${ROOT_CAUSE_REASONING_RULES}
DATA OPERASIONAL:
${contextText}
+86 -7
View File
@@ -225,6 +225,41 @@ const analyseEnvironment = (bundle, mortality, findings, gaps) => {
rh: num(row.kelembapan_persen ?? row.kelembapan ?? row.humidity),
}));
/*
* State the REPORTED day's own condition before anything else.
*
* Every finding below names the worst day in the window, which for a day-48
* report was day 42 — a real, correctly scoped figure that nonetheless left
* the reader of a "Hari 48" report with no idea what day 48 looked like, and
* let the model write day 42's reading up as the current condition.
*/
const reportedDay = num(bundle.hari_ke) ?? env[env.length - 1].hari;
const onReportedDay = env.find((row) => row.hari === reportedDay) ?? null;
const reportedStd = tempStdOf(reportedDay);
if (onReportedDay && onReportedDay.suhu !== null && reportedStd !== null) {
const delta = onReportedDay.suhu - reportedStd;
findings.push({
area: 'KONDISI HARI YANG DILAPORKAN',
level: Math.abs(delta) >= 2 ? 'perhatian' : 'info',
text:
`Hari ke-${reportedDay}: suhu ${fmt(onReportedDay.suhu)}°C ` +
`vs target CP 707 ${fmt(reportedStd)}°C (${delta >= 0 ? '+' : ''}${fmt(delta)}°C)` +
`${onReportedDay.rh === null ? '' : `, kelembapan ${fmt(onReportedDay.rh)}%`}. ` +
'Inilah kondisi hari yang dilaporkan itu sendiri. WAJIB sebutkan angka ini ' +
'lebih dulu sebelum membahas hari lain mana pun.',
});
} else {
const last = env[env.length - 1];
findings.push({
area: 'KONDISI HARI YANG DILAPORKAN',
level: 'perhatian',
text:
`Tidak ada pembacaan sensor untuk hari ke-${reportedDay}. ` +
`Pembacaan terakhir yang ada adalah hari ke-${last.hari}. ` +
'Jangan menyebut angka hari lain sebagai kondisi hari ke-' + `${reportedDay}.`,
});
}
const panas = [];
const dingin = [];
for (const row of env) {
@@ -473,7 +508,17 @@ const buildRootCauseEvidence = (contextData) => {
if (findings.length === 0 && gaps.length === 0) return '';
const order = ['MORTALITAS', 'KORELASI', 'LINGKUNGAN', 'PAKAN', 'BOBOT', 'FCR'];
// The reported day's own condition leads. It is what the report is about;
// every other section is history explaining how it got there.
const order = [
'KONDISI HARI YANG DILAPORKAN',
'MORTALITAS',
'KORELASI',
'LINGKUNGAN',
'PAKAN',
'BOBOT',
'FCR',
];
const byArea = order
.map((area) => ({ area, items: findings.filter((f) => f.area === area) }))
.filter((group) => group.items.length > 0);
@@ -496,23 +541,40 @@ const buildRootCauseEvidence = (contextData) => {
? `\n\n[DATA YANG TIDAK TERSEDIA — batas dari analisis ini]\n${gaps.map((gap) => `- ${gap}`).join('\n')}`
: '';
// Without this line nothing in the block said which day was being reported
// on, so a day-48 report could be built entirely out of day-42 figures.
const reportedDay = num(bundle.hari_ke);
const scopeLine =
reportedDay === null
? ''
: `\nHARI YANG DILAPORKAN: hari ke-${reportedDay}. Semua temuan di bawah dihitung dari hari ke-0 sampai hari ke-${reportedDay}.` +
`\nHari lain yang disebut (misalnya "terparah hari ke-X") adalah RIWAYAT di dalam rentang itu, BUKAN kondisi hari ke-${reportedDay}.`;
return `
═══════════════════════════════════════════════
TEMUAN TERUKUR DARI DATA (dihitung otomatis oleh sistem dari data mentah)
Angka di blok ini SUDAH benar dan boleh Anda kutip apa adanya sebagai bukti.
Blok ini adalah satu-satunya sumber sah untuk menjelaskan SEBAB.
Blok ini adalah satu-satunya sumber sah untuk menjelaskan SEBAB.${scopeLine}
═══════════════════════════════════════════════
${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.
* The reasoning half of the output contract: WHAT the answer must establish,
* with no claim about the shape it is written in. Kept separate from the
* evidence so it can be attached even when a page sends no diagnostic bundle —
* the demand for causal reasoning holds either way; without evidence the answer
* simply has to be honest that the cause cannot be established.
*
* Callers that ask for a different response shape (the dashboard asks for a
* JSON schema with `summary`/`detailed`) must attach ONLY this half. Handing
* them the plain-text field layout below as well gave the model two conflicting
* contracts, and on the largest prompt — a day-48 daily report, cumulative from
* day 0 — it followed the text one and replied in a shape the dashboard schema
* has no place for, which then healed into an all-empty report.
*/
const ROOT_CAUSE_CONTRACT = `
const ROOT_CAUSE_REASONING_RULES = `
ATURAN ANALISIS SEBAB-AKIBAT (WAJIB — ini yang membedakan insight berguna dari sekadar laporan angka):
Menyebutkan sebuah angka menyimpang dari standar TIDAK CUKUP. Setiap penyimpangan
WAJIB Anda jelaskan SEBABNYA berdasarkan data yang ada. Contoh yang DILARANG:
@@ -535,7 +597,19 @@ Cara menyusun jawaban:
5. Sebutkan terus terang metrik yang datanya tidak tersedia dan apa akibatnya bagi
kepastian kesimpulan Anda.
6. DILARANG mengarang angka, hari, atau kejadian yang tidak ada di data.
7. Laporan ini tentang HARI YANG DILAPORKAN (lihat blok TEMUAN TERUKUR). Sebutkan
kondisi hari itu sendiri lebih dulu, baru riwayat sebelumnya sebagai sebab.
Angka dari hari lain — termasuk yang ditandai "terparah hari ke-X" — DILARANG
ditulis sebagai kondisi hari yang dilaporkan atau sebagai kondisi "saat ini".
Kalau menyebut hari lain, tulis nomor harinya dengan jelas.
`.trim();
/**
* The plain-text field layout, for callers whose response IS free text with an
* `insight` and a `kesimpulan` field. Never attach this to a JSON-schema prompt.
*/
const ROOT_CAUSE_TEXT_FORMAT = `
FORMAT ISI FIELD "insight" (teks biasa, antar baris dipisah baris baru, TANPA markdown):
PENYEBAB:
1. <sebab paling kuat> — Bukti: <angka + hari dari data>. Dampak: <kaitannya ke metrik yang menyimpang>. [Terbukti dari data]
@@ -563,9 +637,14 @@ menyimpang, DAN sebab utamanya dalam satu tarikan kalimat. Jangan hanya menilai
atau "perlu perhatian" tanpa angka dan tanpa sebab.
`.trim();
/** Both halves, for the free-text insight route. */
const ROOT_CAUSE_CONTRACT = `${ROOT_CAUSE_REASONING_RULES}\n\n${ROOT_CAUSE_TEXT_FORMAT}`;
module.exports = {
buildRootCauseEvidence,
ROOT_CAUSE_CONTRACT,
ROOT_CAUSE_REASONING_RULES,
ROOT_CAUSE_TEXT_FORMAT,
// exported for tests
describeDays,
};
@@ -569,7 +569,16 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
'hitung_ayam',
'',
{
hari_ke: props.currentDay,
// The day the report is ABOUT, not the day the cycle has reached.
//
// The backend builds its "STANDAR CP 707 UNTUK HARI INI" block from
// this field (routes/aiInsights.js → buildCp707StandardBlock), so
// sending currentDay while the picker was on an earlier day handed
// the model day-48 book figures — bobot standar 2.500 g — to judge
// day-20 data against. The evidence block beside it was already
// scoped to the picked day, so the two halves of the same prompt
// disagreed about which day was being reported on.
hari_ke: activeDay ?? props.currentDay,
...(diagnostik ? { diagnostik } : {}),
},
// Hanya saat operator menekan tombol lagi padahal sudah ada hasil di
@@ -607,6 +616,7 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Terjadi kesalahan tidak diketahui.';
console.error('❌ Generate AI Insight hitung ayam gagal:', err);
setError(msg);
} finally {
setLoading(false);
@@ -758,13 +768,26 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
</div>
)}
{/* Error */}
{error && !result && (
{/* Error.
Deliberately NOT gated on `!result`. A regeneration that fails —
an unparseable reply, or a reply identical to the one on screen —
leaves the previous answer standing, and hiding the message there
was the worst case of all: the operator waits minutes for the model,
nothing on the card changes, and nothing says why. */}
{error && (
<div className="flex items-start gap-3 bg-red-50 border border-red-200 rounded-xl p-4">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-red-700">Gagal memuat insight</p>
<p className="text-sm font-semibold text-red-700">
{result ? 'Generate ulang gagal' : 'Gagal memuat insight'}
</p>
<p className="text-sm text-red-600 mt-1">{error}</p>
{result && (
<p className="text-xs text-red-500 mt-1">
Analisis di bawah masih hasil sebelumnya, belum diperbarui.
</p>
)}
<button
onClick={() => generate(true)}
className="mt-2 text-xs text-red-700 underline hover:no-underline"
@@ -775,17 +798,30 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
</div>
)}
{/* No data / waiting */}
{/* Nothing generated for this scope yet.
This used to be a spinner reading "Memuat insight AI...", from when
the card generated on mount. Generation is click-only now, so that
spinner turned forever on a card that was not loading anything.
Same wording and same call to action as every other insight card. */}
{!loading && !error && !result && (
<div className="flex items-center justify-center py-8 text-gray-400 text-sm gap-2">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-purple-400 animate-spin" />
<span>Memuat insight AI...</span>
<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={() => generate(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-purple-200 bg-white hover:bg-purple-50 hover:border-purple-300 text-purple-700 text-xs font-semibold shadow-sm active:scale-95 transition-all"
>
<Sparkles className="w-4 h-4" />
<span>Buat AI Insight</span>
</button>
</div>
)}
{/* Result */}
{result && (
<div className="space-y-4">
<div className={`space-y-4${error ? ' mt-4' : ''}`}>
{heroSpec && (
<HeroFigure
label="Mortalitas Kumulatif"
+35
View File
@@ -53,3 +53,38 @@ TINDAKAN:
expect(parseAiResult('maaf, saya tidak bisa menjawab')).toBeNull();
});
});
/**
* The second habit, observed verbatim from qwen2.5:7b on the counting card:
* a complete answer whose last string value never gets its closing quote. The
* reply below is trimmed from a real capture — the TINDAKAN line ends at "]"
* and goes straight to the closing brace.
*/
describe('parseAiResult — balasan yang kutipnya tidak ditutup', () => {
const unterminated = `{
"kesimpulan": "Mortalitas kumulatif 9,91% (2.477 ekor) di atas standar CP 707 (5,75%).",
"insight": "PENYEBAB:
1. Suhu di atas target CP 707 (22°C) selama 36 hari, terparah pada hari ke-42. [Terbukti dari data]
TINDAKAN:
1. Lakukan pemeriksaan rutin program vaksinasi. [Dugaan — perlu dicek: Periksa riwayat penyakit]
}`;
it('memperbaikinya alih-alih membuang seluruh jawaban', () => {
const parsed = parseAiResult(unterminated);
expect(parsed).not.toBeNull();
expect(parsed?.kesimpulan).toContain('9,91%');
expect(parsed?.insight).toContain('PENYEBAB:');
// The last line must survive intact, without the stray brace.
expect(parsed?.insight).toContain('Periksa riwayat penyakit]');
expect(parsed?.insight).not.toContain('}');
});
it('tidak mengubah balasan yang kutipnya sudah benar', () => {
const wellFormed = `{"kesimpulan":"Mortalitas 2,68%.","insight":"PENYEBAB:
1. Suhu 34°C di hari ke-3."}`;
const parsed = parseAiResult(wellFormed);
expect(parsed?.kesimpulan).toBe('Mortalitas 2,68%.');
expect(parsed?.insight).toBe('PENYEBAB:\n1. Suhu 34°C di hari ke-3.');
});
});
+42 -3
View File
@@ -98,15 +98,30 @@ const takeFirst = (source: Record<string, unknown>, keys: string[]) => {
*
* Only characters inside string literals are touched; the JSON structure itself
* is left exactly as it was.
*
* It also reports back whether the text ENDED while still inside a string, and
* how many braces were still open, so the caller can repair the other habit the
* model has: stopping without the closing quote. See `extractJsonObject`.
*/
const escapeRawControlChars = (json: string): string => {
interface EscapeResult {
text: string;
/** True when the input ran out mid-string — the model never closed its quote. */
unterminated: boolean;
/** Braces opened outside a string and never closed. */
openBraces: number;
}
const escapeRawControlChars = (json: string): EscapeResult => {
let out = '';
let inString = false;
let escaped = false;
let openBraces = 0;
for (const char of json) {
if (!inString) {
if (char === '"') inString = true;
else if (char === '{') openBraces += 1;
else if (char === '}') openBraces -= 1;
out += char;
continue;
}
@@ -134,7 +149,7 @@ const escapeRawControlChars = (json: string): string => {
else out += char;
}
return out;
return { text: out, unterminated: inString, openBraces: Math.max(openBraces, 0) };
};
/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */
@@ -151,7 +166,31 @@ const extractJsonObject = (text: string): string | null => {
const first = cleaned.indexOf('{');
const last = cleaned.lastIndexOf('}');
if (first === -1 || last === -1 || last <= first) return null;
return escapeRawControlChars(cleaned.slice(first, last + 1));
const {
text: escaped,
unterminated,
openBraces,
} = escapeRawControlChars(cleaned.slice(first, last + 1));
if (!unterminated) return escaped;
/*
* The model stopped without closing its last string.
*
* Observed verbatim from qwen2.5:7b on the counting card — a complete,
* well-written PENYEBAB/TINDAKAN answer whose final line ended
* "...riwayat penyakit]" and went straight to the closing brace, no quote in
* between. `JSON.parse` rejects that, so the whole answer was discarded and
* the card showed "Format respons LM Studio tidak valid" after minutes of
* model time. No prompt wording makes a 7B model reliable about this, so it
* is repaired here, the same way the missing newline escapes already are.
*
* The braces that followed the missing quote were swallowed into the string,
* so they are trimmed off before the quote is put back and the structure is
* closed for real.
*/
const trimmed = escaped.replace(/(?:\\[nrt]|[}\s])+$/, '');
return `${trimmed}"${'}'.repeat(Math.max(openBraces, 1))}`;
};
export const parseAiResult = (text: string): AiResult | null => {