157 lines
4.9 KiB
TypeScript
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.',
|
|
};
|
|
};
|