Files
dashboard-cpsp/utils/insightParse.ts
T

157 lines
4.9 KiB
TypeScript

/**
* Turns whatever JSON the local model returns into {summary, insight}.
*
* The models routinely invent their own field names — `tren_mortalitas_harian`,
* `dampak_populasi`, `rekomendasi_tindakan` instead of `kesimpulan`/`insight`.
* The old per-page parser answered that by substituting a cheerful default
* ("Analisis operasional kandang terpantau dengan baik."), which could mask a
* report of total flock loss, and the counting page answered it by discarding
* the response outright.
*
* Rule here: never invent reassuring text, and never silently drop content. Any
* field the model produced is folded into the output; only a response with no
* usable text at all returns null, so the UI can show a real error instead.
*/
export interface AiResult {
summary: string;
insight: string;
}
/** Field names seen standing in for the conclusion. */
const KESIMPULAN_KEYS = [
'kesimpulan',
'kesimpulan_analisis',
'ringkasan',
'headline',
'summary',
'conclusion',
];
/** Field names seen standing in for the recommendation. */
const INSIGHT_KEYS = [
'insight',
'insights',
'rekomendasi',
'rekomendasi_tindakan',
'tindakan',
'saran',
'actions',
'recommendations',
];
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Turns a leftover field name into a readable label: `tren_mortalitas_harian` -> `Tren mortalitas harian`. */
const humanizeKey = (key: string) => {
const spaced = key.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
};
/** Flattens any value into readable plain text. Arrays become bullet lines. */
const renderValue = (value: unknown, depth = 0): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
return value
.map((item) => renderValue(item, depth + 1))
.filter((item) => item.length > 0)
.map((item) => (depth === 0 ? `• ${item}` : item))
.join('\n');
}
if (isPlainObject(value)) {
return Object.entries(value)
.map(([key, nested]) => {
const rendered = renderValue(nested, depth + 1);
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
})
.filter((line) => line.length > 0)
.join('\n');
}
return '';
};
const takeFirst = (source: Record<string, unknown>, keys: string[]) => {
for (const key of keys) {
if (key in source) {
const rendered = renderValue(source[key]);
if (rendered) return { key, text: rendered };
}
}
return null;
};
/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */
const extractJsonObject = (text: string): string | null => {
const cleaned = text
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```\s*$/, '')
.replace(/\/\/.*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/,\s*([}\]])/g, '$1')
.trim();
const first = cleaned.indexOf('{');
const last = cleaned.lastIndexOf('}');
if (first === -1 || last === -1 || last <= first) return null;
return cleaned.slice(first, last + 1);
};
export const parseAiResult = (text: string): AiResult | null => {
if (!text || typeof text !== 'string') return null;
const candidate = extractJsonObject(text);
if (!candidate) return null;
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
return null;
}
if (!isPlainObject(parsed)) return null;
const used = new Set<string>();
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
if (kesimpulanHit) used.add(kesimpulanHit.key);
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
if (insightHit) used.add(insightHit.key);
// Anything the model invented a name for still carries its analysis, so it is
// appended under a readable label rather than thrown away.
const leftovers = Object.entries(parsed)
.filter(([key]) => !used.has(key))
.map(([key, value]) => {
const rendered = renderValue(value);
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
})
.filter((line) => line.length > 0);
let kesimpulan = kesimpulanHit?.text ?? '';
let insight = insightHit?.text ?? '';
if (!kesimpulan && leftovers.length > 0) {
// Promote the first salvaged field to the conclusion so the card is never
// headed by a generic sentence the model never wrote.
kesimpulan = leftovers.shift() as string;
}
if (leftovers.length > 0) {
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
}
if (!kesimpulan && !insight) return null;
return {
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
};
};