/** * agentTelemetry.ts — Sistema di telemetria unificato per tutti i blocchi verdict-based * * Raccoglie contatori per ogni sistema di verifica/controllo qualità del loop agente. * Permette di calibrare le soglie nel tempo (es: complexity >= 7, routeQualityScore < 0.85). * * Sistemi tracciati: * - crossCritic → PASS / FAIL / UNCERTAIN * - postResponseVerify → ok / unsafe / claim_vs_reality / [issue type] * - goalVerify → pass / fail * - planCritic → validated / rejected / skipped * - validationGate → accept / repaired / retry / reject * - finalOutputGate → pass / garbage / empty / no-proof * - inlineVerify → safe / [issue: hallucination | contradiction | ...] * - injectionGuard → detected / clean * * Design: * - localStorage + in-memory cache (identico a scenarioFingerprintBlock — BUG-1-FIX applicato) * - Fail-open totale: qualsiasi errore → noop silenzioso * - Safari-safe: no Worker, no SharedArrayBuffer * - Zero dipendenze esterne * - Additive-only: non modifica mai il comportamento dei blocchi chiamanti * * @module agentTelemetry */ const LS_KEY = "agent_telemetry_v1"; const MAX_STORE = 200; // max entries totali prima di pruning (evita crescita unbounded) // ─── Tipi ───────────────────────────────────────────────────────────────────── export type TelemetrySystem = | "crossCritic" | "postResponseVerify" | "goalVerify" | "planCritic" | "validationGate" | "finalOutputGate" | "inlineVerify" | "injectionGuard"; interface VerdictStats { count: number; lastSeenMs: number; } interface SystemStats { [verdict: string]: VerdictStats; } interface TelemetryStore { [system: string]: SystemStats; } // ─── Cache in-memory (lazy-load, flush on write — BUG-1-FIX pattern) ────────── let _cache: TelemetryStore | null = null; function _load(): TelemetryStore { if (_cache !== null) return _cache; try { const raw = typeof localStorage !== "undefined" ? localStorage.getItem(LS_KEY) : null; _cache = raw ? (JSON.parse(raw) as TelemetryStore) : {}; return _cache; } catch { _cache = {}; return _cache; } } function _save(store: TelemetryStore): void { _cache = store; try { if (typeof localStorage !== "undefined") localStorage.setItem(LS_KEY, JSON.stringify(store)); } catch { /* Safari private mode — non-fatal */ } } function _prune(store: TelemetryStore): TelemetryStore { // Conta entries totali — se sopra MAX_STORE, rimuovi le più vecchie let total = 0; for (const sys of Object.values(store)) total += Object.keys(sys).length; if (total <= MAX_STORE) return store; // Semplice: rimuovi i sistemi con meno dati (sacrifica i meno usati) const sorted = Object.entries(store).sort(([, a], [, b]) => { const sumA = Object.values(a).reduce((s, v) => s + v.count, 0); const sumB = Object.values(b).reduce((s, v) => s + v.count, 0); return sumB - sumA; }); const pruned: TelemetryStore = {}; let kept = 0; for (const [sys, stats] of sorted) { if (kept + Object.keys(stats).length > MAX_STORE) break; pruned[sys] = stats; kept += Object.keys(stats).length; } return pruned; } // ─── API pubblica ────────────────────────────────────────────────────────────── /** * Registra un evento verdict per un sistema. * Fire-and-forget, mai lancia eccezioni. * * @param system - Nome del sistema (es: "crossCritic") * @param verdict - Risultato (es: "PASS", "fail", "retry") */ export function record(system: TelemetrySystem | string, verdict: string): void { try { const store = _load(); if (!store[system]) store[system] = {}; const sys = store[system]!; const key = verdict.toLowerCase(); const prev = sys[key] ?? { count: 0, lastSeenMs: 0 }; sys[key] = { count: prev.count + 1, lastSeenMs: Date.now() }; _save(_prune(store)); // Gap N4: auto-sync al backend ogni _SYNC_EVERY chiamate (fire-and-forget). // _syncCounter e _SYNC_EVERY sono definiti più in basso ma inizializzati prima // di qualsiasi call (modulo caricato prima dell'uso). syncToBackend è hoisted. _syncCounter++; if (_syncCounter >= _SYNC_EVERY) { _syncCounter = 0; syncToBackend().catch(() => {}); // eslint-disable-line @typescript-eslint/no-floating-promises } } catch { /* fail-open */ } } export interface TelemetryEntry { system: string; verdict: string; count: number; lastSeenMs: number; } /** * Restituisce tutti gli eventi registrati ordinati per sistema e count. * Per diagnostica e calibrazione soglie. */ export function getReport(): TelemetryEntry[] { try { const store = _load(); const entries: TelemetryEntry[] = []; for (const [system, stats] of Object.entries(store)) { for (const [verdict, vs] of Object.entries(stats)) { entries.push({ system, verdict, count: vs.count, lastSeenMs: vs.lastSeenMs }); } } return entries.sort((a, b) => a.system.localeCompare(b.system) || b.count - a.count ); } catch { return []; } } /** * Restituisce il report per un singolo sistema (es: calibrazione soglie). * Esempio: getSystemReport("crossCritic") → { pass: 12, fail: 3, uncertain: 1 } */ export function getSystemReport(system: string): Record { try { const store = _load(); const stats = store[system] ?? {}; return Object.fromEntries(Object.entries(stats).map(([v, s]) => [v, s.count])); } catch { return {}; } } /** * Calcola il fail rate di un sistema (fail / (pass + fail)). * Utile per calibrare se la soglia di trigger è troppo alta o troppo bassa. * * @param system - Sistema da analizzare * @param failVerdict - Chiave che conta come "fail" (default: "fail") * @param passVerdict - Chiave che conta come "pass" (default: "pass") * @returns null se non abbastanza dati (< 5 eventi totali) */ export function getFailRate( system: string, failVerdict = "fail", passVerdict = "pass", ): number | null { try { const rep = getSystemReport(system); const fail = rep[failVerdict] ?? 0; const pass = rep[passVerdict] ?? 0; const total = fail + pass; if (total < 5) return null; return Math.round((fail / total) * 1000) / 1000; } catch { return null; } } /** Solo per unit test — non usare in produzione. */ export function _resetTelemetry(): void { _cache = null; try { if (typeof localStorage !== "undefined") localStorage.removeItem(LS_KEY); } catch { /* noop */ } } // ─── Gap N4: Backend sync — verdetti persistenti cross-session/device ───────── // Obiettivo: agentTelemetry usava solo localStorage → dati di calibrazione persi // su altri device o dopo clear cache. Questi helper sincronizzano con il backend // HF Space (/data/agent_telemetry.json) per persistenza vera. // // Flusso: // BOOT → loadFromBackend() → merge server store in localStorage (silent, 2s delay) // RECORD → ogni 10 record → syncToBackend() fire-and-forget (non blocca mai) // // Merge: additive — max(count), max(lastSeenMs) — i contatori non calano mai. // Fail-open totale: qualsiasi errore di rete → silent noop, localStorage non toccato. let _syncCounter = 0; // conta i record dall'ultimo sync const _SYNC_EVERY = 10; // sync al backend ogni N record /** Merge additive locale: max(count), max(lastSeenMs) per ogni (system, verdict). */ function _mergeLocal(a: TelemetryStore, b: TelemetryStore): TelemetryStore { const out: TelemetryStore = {}; // Copia a for (const [sys, verdicts] of Object.entries(a)) { out[sys] = { ...verdicts }; } // Merge b sopra a for (const [sys, verdicts] of Object.entries(b)) { if (!out[sys]) out[sys] = {}; for (const [verdict, vs] of Object.entries(verdicts)) { const prev = out[sys]![verdict] ?? { count: 0, lastSeenMs: 0 }; out[sys]![verdict] = { count: Math.max(prev.count, vs.count), lastSeenMs: Math.max(prev.lastSeenMs, vs.lastSeenMs), }; } } return out; } /** * Carica dati dal backend e fa merge con il localStorage locale. * Chiamata una volta al boot (delay 2s, non-blocking). * GET /api/agent-telemetry/sync → merge → _save(). */ export async function loadFromBackend(): Promise { try { const { ENV } = await import("@/config/env"); const base = ENV.BACKEND_URL; if (!base) return; const res = await fetch(base.replace(/\/$/, "") + "/api/agent-telemetry/sync", { method: "GET", signal: AbortSignal.timeout(6000), }); if (!res.ok) return; const body = await res.json() as { ok?: boolean; data?: unknown }; if (!body.ok || !body.data || typeof body.data !== "object") return; const local = _load(); const merged = _mergeLocal(local, body.data as TelemetryStore); _save(merged); } catch { /* fail-open — nessun impatto sull'agente */ } } /** * Sincronizza il localStorage corrente con il backend (merge bidirezionale). * Il server ritorna il merged: aggiorniamo localStorage con dati da altri device. * POST /api/agent-telemetry/sync → merge → _save(). * Fire-and-forget — non restituisce mai un errore. */ export async function syncToBackend(): Promise { try { const { ENV } = await import("@/config/env"); const base = ENV.BACKEND_URL; if (!base) return; const store = _load(); const res = await fetch(base.replace(/\/$/, "") + "/api/agent-telemetry/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: store }), signal: AbortSignal.timeout(8000), }); if (!res.ok) return; const body = await res.json() as { ok?: boolean; merged?: unknown }; if (!body.ok || !body.merged || typeof body.merged !== "object") return; // Aggiorna localStorage con il merged del server (include dati da altri device) _save(body.merged as TelemetryStore); } catch { /* fail-open */ } } // ── Auto-boot: carica dal backend 2s dopo il primo import ────────────────── if (typeof window !== "undefined" && typeof fetch !== "undefined") { setTimeout(() => { loadFromBackend().catch(() => {}); }, 2000); } // Gap N4: auto-sync integrato direttamente in record() — counter sopra.