AUDIT / src /lib /agent /validationGate.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
15.3 kB
/**
* 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<GateDeps>): 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<GateVerdict, number> = {
accept: 0,
repaired: 1,
retry: 2,
reject: 3,
};
const worstVerdict = results.reduce<GateVerdict>(
(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 };
}