/** * 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 => 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, 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(/[\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(); 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.', }; };