// ─── normalizeLlmOutput.ts ──────────────────────────────────────────────────── // Fase 5 — primo stadio della pipeline: normalize → validate → sanitize → render // // Regola: ogni output LLM passa da qui prima di qualsiasi altro trattamento. // Browser-safe, zero deps, iPhone compatible. /** Risultato della normalizzazione */ export interface NormalizeResult { text: string; changed: boolean; repairs: string[]; } /** * Normalizza l'output grezzo del modello. * Ordine operazioni (non modificare — l'ordine è semanticamente rilevante): * 0. BOM UTF-8 — DEVE essere primo: trim() nei passi successivi lo rimuoverebbe * silenziosamente senza loggarlo come repair (S226 fix) * 0a. blocks chiusi — safety net S188+S189 * 0b. blocks aperti/incompleti * 1. Sequenze escape ANSI — DEVE essere prima del passo 2: \x1b (0x1B) è incluso * nel range di controllo e verrebbe rimosso prima (S226 fix) * 2. Null-byte e caratteri di controllo non stampabili (dopo ANSI, non tocca più \x1b) * 3. Newline multipli eccessivi (>2 consecutivi → 2) * 4. Spazi bianchi trailing per riga * 5. Trim globale */ export function normalizeLlmOutput(raw: string): NormalizeResult { if (typeof raw !== "string") { return { text: "", changed: true, repairs: ["input non stringa → stringa vuota"] }; } const repairs: string[] = []; let t = raw; // 0. BOM UTF-8 — DEVE essere prima di qualsiasi trim() (S226) // trim() in step 0a/0b rimuoverebbe \uFEFF silenziosamente senza repair log. if (t.startsWith("\uFEFF")) { t = t.slice(1); repairs.push("rimosso BOM UTF-8"); } // 0a. ... blocks chiusi — S188 safety net const _c0a = t.replace(/[\s\S]*?<\/think>/gi, "").trim(); if (_c0a !== t) { repairs.push("rimossi blocchi chiusi"); t = _c0a; } // 0b. aperto non chiuso (finish_reason=length) — S189 const _c0b = t.replace(/[\s\S]*/gi, "").trim(); if (_c0b !== t) { repairs.push("rimosso blocco incompleto"); t = _c0b; } // 1. Sequenze ANSI escape (\e[...m oppure \x1b[...m) — S226: salito prima del passo 2 // \x1b (0x1B = 27) è nel range [\x0E-\x1F] del passo 2 — deve essere strippato prima. const cleaned1 = t.replace(/\x1b\[[0-9;]*[mGKHF]/g, ""); if (cleaned1 !== t) { repairs.push("rimossi escape ANSI"); t = cleaned1; } // 2. Null bytes e control chars (eccetto \t \n \r) — dopo ANSI: \x1b già rimosso const cleaned2 = t.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); if (cleaned2 !== t) { repairs.push("rimossi caratteri di controllo"); t = cleaned2; } // 2.5 Lone UTF-16 surrogate codepoints — Groq occasionally emits them in emoji/special chars. // They crash JSON.stringify / IndexedDB storage and corrupt Dexie state if unhandled. // Must run BEFORE any slice/chunk operations (slice mid-surrogate creates new lone surrogates). const cleaned2_5 = t.replace(/[\uD800-\uDFFF]/g, ""); if (cleaned2_5 !== t) { repairs.push("rimossi surrogate UTF-16 isolati"); t = cleaned2_5; } // 3. Newline eccessivi (più di 2 consecutivi → 2) const cleaned3 = t.replace(/\n{3,}/g, "\n\n"); if (cleaned3 !== t) { repairs.push("collassati newline eccessivi"); t = cleaned3; } // 4. Spazi trailing per riga const cleaned4 = t.replace(/[ \t]+$/gm, ""); if (cleaned4 !== t) { repairs.push("rimossi spazi trailing"); t = cleaned4; } // 5. Trim globale const cleaned5 = t.trim(); if (cleaned5 !== t) { t = cleaned5; } // 6. AbortController normalization in code blocks (S241-C2) // Rinomina `ctrl` → `controller` nei blocchi di codice TS/JS generati dall'AI. // Opera SOLO dentro ``` code block — non nel testo libero. const cleaned6 = t.replace( /([ `]{3}(?:typescript|javascript|ts|js)[^\n]*\n)([\s\S]*?)([ `]{3})/g, (_match: string, open: string, code: string, close: string): string => { const fixed = code .replace(/\bconst\s+ctrl\s*=\s*new\s+AbortController/g, "const controller = new AbortController") .replace(/\blet\s+ctrl\s*=\s*new\s+AbortController/g, "let controller = new AbortController") .replace(/\bvar\s+ctrl\s*=\s*new\s+AbortController/g, "var controller = new AbortController") .replace(/\bctrl\.abort\b/g, "controller.abort") .replace(/\bctrl\.signal\b/g, "controller.signal"); return open + fixed + close; } ); if (cleaned6 !== t) { repairs.push("normalizzato AbortController (ctrl→controller)"); t = cleaned6; } // 7. S371: Strip internal monologue patterns leaking from smolagents/backend // Rimosso: GOAL:/DONE_WHEN:/OUT_OF_SCOPE: blocks, Proceed.We need to output..., // Thought:/Code:/Observation: line prefixes, raw JSON tool arrays. // Safari-safe regex: no lookbehind, no named groups. const _s7a = t.replace(/(?:^|\n)(?:GOAL|DONE_WHEN|OUT_OF_SCOPE):[\s\S]*?(?=\n\n|$)/gm, ""); if (_s7a !== t) { repairs.push("rimossi blocchi GOAL/DONE_WHEN/OUT_OF_SCOPE"); t = _s7a; } const _s7b = t.replace(/Proceed\.?\s*We\s+need\s+to\s+output\s+the\s+tool\s+call\s+now\.?\s*\[[\s\S]*?\]/g, ""); if (_s7b !== t) { repairs.push("rimosso Proceed.We need to output..."); t = _s7b; } // S375: gm flag — rimuove TUTTE le occorrenze di JSON tool array (non solo la prima) // Aggiunto supporto sia per "action" con virgolette singole che doppie const _s7c = t.replace(/^\s*\[\s*\{["']action["']:/gm, ""); if (_s7c !== t) { repairs.push("rimossi JSON tool array raw (tutte le occorrenze)"); t = _s7c; } const _s7d = t.replace(/^(?:Thought|Code|Observation|Action Input):\s*/gm, ""); if (_s7d !== t) { repairs.push("rimossi prefissi smolagents Thought/Code/Observation"); t = _s7d; } const _s7e = t.replace(/\[STEP\s+\d+\/\d+\]\s*/g, ""); if (_s7e !== t) { repairs.push("rimossi [STEP N/M] markers"); t = _s7e; } // S375-F: strip Action: {"type":...} JSON leaks (smolagents structured output) // Formato alternativo usato da alcuni adapter smolagents → non mostrare all'utente const _s7f = t.replace(/^\s*Action:\s*\{["']type["'][\s\S]*?\}\s*$/gm, ""); if (_s7f !== t) { repairs.push("rimosso Action:{type:...} JSON strutturato"); t = _s7f; } // S375-G: collassa newline eccessivi creati dalle strip precedenti (step 7a-7f) const _s7g = t.replace(/\n{3,}/g, "\n\n").trim(); if (_s7g !== t) { repairs.push("collassati newline post-strip step-7"); t = _s7g; } return { text: t, changed: repairs.length > 0, repairs }; } /** Versione semplificata: ritorna solo la stringa normalizzata */ export function normalize(raw: string): string { return normalizeLlmOutput(raw).text; } /** * Alias per compatibilità con i caller esistenti che importano normalizeLlmText. * @deprecated Usa normalizeLlmOutput() o normalize() invece. */ export function normalizeLlmText(raw: string): string { return normalizeLlmOutput(raw).text; }