/** * validationGate.ts — S220: Unified Validation Gate * * REGOLA: tutto l'output LLM grezzo deve passare attraverso questo gate * prima di essere renderizzato nella UI o usato da un tool. * * Unifica in un unico punto: * - outputValidator.ts (quality score, rifiuti, truncation, garbage) * - normalizeLlmOutput.ts (whitespace, encoding, BOM) * - sanitizeMarkdown.ts (markdown safety) * - repairCodeBlocks.ts (code block chiusi male) * - ai/output-validator.ts (detectHallucination, validateJson) * * Percorso: raw → normalize → sanitize → repairCode → validate → gate * * Il gate è ADDITIVO: non rompe nessun caller esistente. * I moduli originali rimangono — questo li aggrega. * * Design: * - Zero LLM, zero React, zero side-effects globali * - DI: _setTestDeps() / _resetDeps() * - Mai lancia — fallback a raw text in caso di errore interno * - Safari-safe, synchronous (eccetto validateGateAsync per diff) * * @module validationGate */ import { normalizeLlmOutput } from "@/lib/utils/normalizeLlmOutput"; import { sanitizeMarkdown } from "@/lib/utils/sanitizeMarkdown"; import { repairCodeBlocks } from "@/lib/utils/repairCodeBlocks"; import { validateAgentOutput as validateOutput } from "@/lib/ai/output-validator"; import { detectHallucination } from "@/lib/ai/output-validator"; import { auditOutput } from "@/lib/antiRewriteGuard"; import type { AntiRewriteAudit } from "@/lib/antiRewriteGuard"; // ── Tipi ──────────────────────────────────────────────────────────────────── export type GateVerdict = "accept" | "repaired" | "retry" | "reject"; export interface ValidationGateResult { /** Testo finale da usare (potrebbe essere riparato) */ text: string; /** Decisione del gate */ verdict: GateVerdict; /** Qualità normalizzata 0–10 */ quality: number; /** Elenco di problemi trovati */ issues: string[]; /** Elenco di riparazioni applicate */ repairs: string[]; /** true = il modello ha inventato fatti (score allucinazione elevato) */ hallucination: boolean; /** Suggerimento per il retry prompt (presente se verdict === "retry") */ retryHint?: string; /** S-ARCH-3: true se l'output rimuove pattern critici presenti nel codice di riferimento */ regressionRisk?: boolean; /** Pattern critici mancanti rispetto al codice di riferimento */ regressionPatterns?: string[]; } export interface GateOptions { /** Qualità minima per "accept" (default 4) */ minQuality?: number; /** Qualità minima dopo repair per "repaired" invece di "retry" (default 2) */ repairFloor?: number; /** Hint sul tipo di task: usato per validateOutput */ taskHint?: string; /** Se true, esegui hallucination check (default true) */ checkHallucination?: boolean; /** * S-ARCH-3: Snapshot del codice "noto buono" per rilevare regressioni. * Se fornito, il gate confronta l'output con il codice di riferimento * e segnala i pattern critici rimossi (export, function, class signatures). */ referenceCode?: string; } // ── Soglie ─────────────────────────────────────────────────────────────────── const DEFAULT_MIN_QUALITY = 4; const DEFAULT_REPAIR_FLOOR = 2; // ── DI per testing ────────────────────────────────────────────────────────── interface GateDeps { normalize: typeof normalizeLlmOutput; sanitize: typeof sanitizeMarkdown; repair: typeof repairCodeBlocks; validate: typeof validateOutput; detectHall: typeof detectHallucination; } const defaultDeps: GateDeps = { normalize: normalizeLlmOutput, sanitize: sanitizeMarkdown, repair: repairCodeBlocks, validate: validateOutput, detectHall: detectHallucination, }; let _deps: GateDeps = { ...defaultDeps }; export function _setTestDeps(d: Partial): void { _deps = { ...defaultDeps, ...d }; } export function _resetDeps(): void { _deps = { ...defaultDeps }; } // ── Gate principale ─────────────────────────���──────────────────────────────── /** * Unico punto di verità per la validazione dell'output LLM. * * Uso: * const gate = validateGate(rawLLMOutput, { taskHint: "codice TypeScript" }); * if (gate.verdict === "reject") return; // scarta * if (gate.verdict === "retry") retryWithHint(gate.retryHint!); * renderUI(gate.text); // "accept" o "repaired" */ // S765 Gap 6: pattern per comandi distruttivi operativi. // Nessun output LLM legittimo dovrebbe contenere questi pattern come testo da rendere all'utente. // Hard-coded (non configurabile) — reject immediato, quality=0. const _OPERATIONAL_HALLUCINATION_RE = /\b(sudo\s+rm\s+-rf|DROP\s+TABLE|DELETE\s+FROM\s+.{0,60}WHERE\s+1\s*=\s*1|format\s+c:|mkfs\.|dd\s+if=\/dev\/zero)/i; // ── S-ARCH-3: Regression Risk Detector ──────────────────────────────────────── // Estrae le "firma critiche" dal codice (export, function, class, interface). // Confronta vecchio codice con nuovo: se firme critiche spariscono → regression risk. const _CRITICAL_SIGNATURE_RE = /(?:export\s+(?:default\s+)?(?:function|class|interface|type|const|enum)\s+\w+|^(?:function|class)\s+\w+)/gm; // S-ARCH-3-FIX: backslash mancanti function _extractSignatures(code: string): string[] { const sigs: string[] = []; let m: RegExpExecArray | null; const re = new RegExp(_CRITICAL_SIGNATURE_RE.source, 'gm'); while ((m = re.exec(code)) !== null) { sigs.push(m[0].trim().slice(0, 80)); } return sigs; } /** * Rileva rischio di regressione confrontando output con codice di riferimento. * Ritorna le firme critiche presenti nel riferimento ma assenti nell'output. * Zero LLM, Zero rete — puramente sintattico. */ function _detectRegressionRisk( output: string, reference: string, ): { risk: boolean; missing: string[] } { if (!reference.trim() || !output.trim()) return { risk: false, missing: [] }; const refSigs = _extractSignatures(reference); if (!refSigs.length) return { risk: false, missing: [] }; const outputLower = output.toLowerCase(); const missing = refSigs.filter(sig => { // Estrai il nome identificatore dalla firma per una ricerca flessibile const name = sig.replace(/^.*?(\w+)\s*$/, '$1') // S-ARCH-3-FIX.toLowerCase(); return name.length > 2 && !outputLower.includes(name); }); return { risk: missing.length > 0, missing }; } export function validateGate( raw: string, opts: GateOptions = {}, ): ValidationGateResult { const minQuality = opts.minQuality ?? DEFAULT_MIN_QUALITY; const repairFloor = opts.repairFloor ?? DEFAULT_REPAIR_FLOOR; const checkHall = opts.checkHallucination !== false; const repairs: string[] = []; const issues: string[] = []; let text = raw; try { // ── 1. Normalize ──────────────────────────────────────────────────── const norm = _deps.normalize(text); if (norm.repairs.length) repairs.push(...norm.repairs); text = norm.text; // ── 2. Sanitize markdown ───────────────────────────────────────────── const san = _deps.sanitize(text); if (san.repairs.length) repairs.push(...san.repairs); text = san.text; // ── 3. Repair code blocks ───────────────────────────────────────────── const rep = _deps.repair(text); if (rep.repairs.length) repairs.push(...rep.repairs); text = rep.text; // ── 4. Quality validation ────────────────────────────────────────────── const val = _deps.validate(text, opts.taskHint as "code" | "markdown" | "tool_result" | "chat" | undefined); issues.push(...val.issues); // quality: 10=perfect, ≥4=accept, ≥2=repair, <2=reject const quality = !val.valid ? 1 : val.issues.length === 0 ? 10 : Math.max(3, 8 - val.issues.length * 2); // ── 5. Hallucination check ────────────────────────────────────────────��� let hallucination = false; // S765 Gap 6: comandi distruttivi — reject immediato prima del check semantico if (_OPERATIONAL_HALLUCINATION_RE.test(text)) { issues.push("hallucination operativa: comando distruttivo rilevato nel testo"); return { text, verdict: "reject", quality: 0, issues, repairs, hallucination: true }; } if (checkHall) { const hall = _deps.detectHall(text); if (hall.suspect) { hallucination = true; issues.push(`hallucination_suspect: ${hall.reasons.slice(0, 2).join("; ")}`); } } // ── 6. S-ARCH-3: Regression risk check ──────────────────────────────────── let regressionRisk = false; let regressionPatterns: string[] = []; if (opts.referenceCode) { const reg = _detectRegressionRisk(text, opts.referenceCode); if (reg.risk) { regressionRisk = true; regressionPatterns = reg.missing; issues.push(`regression_risk: ${reg.missing.slice(0, 3).join(", ")}`); } } // ── 7. S-ANTIREGRESS: Anti-rewrite audit ──────────────────────────────── let antiRewriteAuditResult: AntiRewriteAudit | undefined; if (opts.referenceCode) { try { antiRewriteAuditResult = auditOutput(opts.referenceCode, text); if (antiRewriteAuditResult.hasIssues) { if (antiRewriteAuditResult.rewrite.isRewrite) { issues.push(`anti_rewrite: similarity=${antiRewriteAuditResult.rewrite.similarity.toFixed(2)} — probabile riscrittura completa`); } if (antiRewriteAuditResult.incomplete.incomplete) { issues.push(`incompleteness: ${antiRewriteAuditResult.incomplete.missingCount} strutture mancanti (${antiRewriteAuditResult.incomplete.missingNames.slice(0, 3).join(', ')})`); } if (antiRewriteAuditResult.importInject.injected) { issues.push(`import_injection: nuovi pacchetti (${antiRewriteAuditResult.importInject.newPackages.slice(0, 3).join(', ')})`); } } } catch { /* non-fatal — guard non deve bloccare il gate */ } } // ── 8. Verdict ───────────────────────────────────────────────────────── let verdict: GateVerdict; let retryHint: string | undefined; if (text.length < 3) { verdict = "reject"; retryHint = "Output vuoto o troppo breve. Fornisci una risposta completa."; } else if (quality >= minQuality && !hallucination) { verdict = repairs.length > 0 ? "repaired" : "accept"; } else if (quality >= repairFloor) { verdict = "retry"; retryHint = buildRetryHint(issues, quality); } else { verdict = "reject"; retryHint = buildRetryHint(issues, quality); } // Arricchisci retryHint con i suggerimenti anti-rewrite se presenti if (antiRewriteAuditResult?.hasIssues && antiRewriteAuditResult.combinedHint) { retryHint = retryHint ? `${antiRewriteAuditResult.combinedHint} ${retryHint}` : antiRewriteAuditResult.combinedHint; } return { text, verdict, quality, issues, repairs, hallucination, retryHint, regressionRisk, regressionPatterns }; } catch (e) { // Gate non deve mai lanciare — fallback a raw text return { text: raw, verdict: "accept", // fallback conservativo quality: 3, issues: [`gate_error: ${String(e).slice(0, 80)}`], repairs: [], hallucination: false, regressionRisk: false, }; } } // ── Retry hint builder ─────────────────────────────────────────────────────── function buildRetryHint(issues: string[], quality: number): string { const parts: string[] = []; if (issues.some(i => /refus|cannot|don't|non posso/i.test(i))) { parts.push("Non rifiutare il task. Rispondi direttamente alla richiesta."); } if (issues.some(i => /truncat|cut.*off|incomplet/i.test(i))) { // S266-fix: non dire "risposta precedente" — il LLM lo echeggia all'utente parts.push("Completa la risposta senza troncarla."); } if (issues.some(i => /repetiti|repeat/i.test(i))) { parts.push("Evita le ripetizioni. Sii conciso e diretto."); } if (issues.some(i => /hallucination/i.test(i))) { parts.push("Usa solo fatti verificabili. Se non sei sicuro, dì esplicitamente che non lo sai."); } if (quality < 2) { // S266-fix: non usare "la qualità della risposta è troppo bassa" — il LLM lo cita parts.push("Rispondi in modo preciso e strutturato."); } return parts.length > 0 ? `Non commentare problemi precedenti. ${parts.join(" ")}` : `Rispondi in modo più completo e accurato.`; } // ── Batch validation (per tool calls multipli) ──────────────────────────────── export interface BatchGateResult { results: ValidationGateResult[]; allAccepted: boolean; worstVerdict: GateVerdict; summary: string; } /** * Valida un array di output (es. risposte parallele da consensusMode). * Ritorna il risultato migliore tra quelli accettati. */ export function validateGateBatch( raws: string[], opts: GateOptions = {}, ): BatchGateResult { const results = raws.map(r => validateGate(r, opts)); const verdictPriority: Record = { accept: 0, repaired: 1, retry: 2, reject: 3, }; const worstVerdict = results.reduce( (worst, r) => verdictPriority[r.verdict] > verdictPriority[worst] ? r.verdict : worst, "accept" ); const allAccepted = results.every(r => r.verdict === "accept" || r.verdict === "repaired"); const accepted = results.filter(r => r.verdict === "accept" || r.verdict === "repaired"); const best = accepted.sort((a, b) => b.quality - a.quality)[0]; const summary = best ? `${accepted.length}/${raws.length} accettati — miglior quality: ${best.quality}/10` : `Nessun output accettato su ${raws.length}`; return { results, allAccepted, worstVerdict, summary }; }