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"');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user