AUDIT / src /lib /utils /normalizeLlmOutput.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 4)
641b62c verified
Raw
History Blame Contribute Delete
7.04 kB
// ─── 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. <think> blocks chiusi — safety net S188+S189
* 0b. <think> 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. <think>...</think> blocks chiusi — S188 safety net
const _c0a = t.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
if (_c0a !== t) { repairs.push("rimossi blocchi <think> chiusi"); t = _c0a; }
// 0b. <think> aperto non chiuso (finish_reason=length) — S189
const _c0b = t.replace(/<think>[\s\S]*/gi, "").trim();
if (_c0b !== t) { repairs.push("rimosso blocco <think> 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;
}