Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string, number> { | |
| 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<void> { | |
| 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<void> { | |
| 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. | |