update applying current ai insight from dashboard-cpsp
This commit is contained in:
1 parent
31272035e9
commit
b87f20e3b3
27 files changed
+5300
-343
No files matched your search
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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.',
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user