/** * debateProtocol.ts — S219: Dual-Agent Debate Protocol * * Schema versionato (v:1) per massima forward-compatibility. * Gestisce: * - Tipi strutturati per le proposte (programmaticamente diff-abili) * - Divergence scoring (Jaccard pesato su plan/steps/risks) * - Tiebreak deterministico (nessuna chiamata LLM extra) * - JSON parsing robusto con 3 fallback + free-text finale * - Formato output per la UI * * Design: * - Zero LLM, zero React, zero side-effects — pure functions * - Safari-safe: nessun Worker, nessun SharedArrayBuffer * - DI-friendly: tutte le fn esportate per testing * * @module debateProtocol */ export const DEBATE_PROTOCOL_VERSION = 1 as const; // import { safeJsonParse } from '../utils/safeJsonParse'; // removed: unused export type DebateRole = "builder" | "critic"; // ── Tipi strutturati ──────────────────────────────────────────────────────── /** Proposta di un agente in un round del debate */ export interface DebateProposal { v: 1; role: DebateRole; taskId: string; round: number; /** Approccio generale alla soluzione (1–2 frasi) */ plan: string; /** Passi concreti e ordinati */ steps: string[]; /** Rischi, edge case, regressioni identificati */ risks: string[]; /** Fiducia nella proposta: 0–100 */ confidence: number; /** File/componenti da toccare */ changes: string[]; timestamp: number; } /** Risultato finale del debate (dopo N round o convergenza) */ export interface DebateConsensus { v: 1; taskId: string; rounds: number; /** true = convergenza organica, false = tiebreak applicato */ agreed: boolean; /** Nome della regola di tiebreak se agreed=false */ tiebreakRule?: string; /** Proposta finale da usare */ final: DebateProposal; builderFinal: DebateProposal; criticFinal: DebateProposal; /** 0 = proposte identiche, 1 = totalmente divergenti */ divergenceScore: number; durationMs: number; } /** Evento emesso durante il debate per aggiornamenti UI */ export type DebateEvent = | { type: "round_start"; round: number; role: DebateRole } | { type: "proposal"; proposal: DebateProposal } | { type: "divergence"; score: number; round: number } | { type: "consensus"; consensus: DebateConsensus } | { type: "error"; error: string; round?: number }; export type DebateEventHandler = (event: DebateEvent) => void; // ── Divergence scoring ────────────────────────────────────────────────────── function tokenize(text: string): Set { return new Set( text.toLowerCase().split(/\W+/).filter(t => t.length > 2) ); } function jaccardSim(a: string, b: string): number { const sa = tokenize(a); const sb = tokenize(b); if (sa.size === 0 && sb.size === 0) return 1; let inter = 0; sa.forEach(t => { if (sb.has(t)) inter++; }); const union = sa.size + sb.size - inter; return union === 0 ? 1 : inter / union; } /** * Quanto sono divergenti due proposte? * Peso: plan 30% · steps 40% · risks 30%. * Ritorna 0 (identiche) → 1 (totalmente diverse). */ export function scoreDivergence(a: DebateProposal, b: DebateProposal): number { const planSim = jaccardSim(a.plan, b.plan); const stepsSim = jaccardSim(a.steps.join(" "), b.steps.join(" ")); const risksSim = jaccardSim(a.risks.join(" "), b.risks.join(" ")); const sim = 0.30 * planSim + 0.40 * stepsSim + 0.30 * risksSim; return Math.round((1 - sim) * 100) / 100; } // ── Tiebreak deterministico ───────────────────────────────────────────────── export interface TiebreakResult { winner: "builder" | "critic" | "merge"; rule: string; proposal: DebateProposal; } const CRITICAL_RISK_RE = /security|auth|data.?loss|crash|infinite.?loop|regression|breaking.?change|sql.?inject|xss|overflow/i; /** * Tiebreak deterministico quando i due agenti non convergono. * * Regole (priorità decrescente): * 1. critic_security — critico ha ≥2 rischi di sicurezza critici → merge con warning * 2. critic_confident — confidence critico > builder + 10 → vince critico * 3. builder_dominant — confidence builder > critico + 20 → vince builder * 4. merge_default — merge steps builder + risks critico, confidence media */ export function applyTiebreak( builder: DebateProposal, critic: DebateProposal, ): TiebreakResult { const criticalRisks = critic.risks.filter(r => CRITICAL_RISK_RE.test(r)); // Regola 1: sicurezza if (criticalRisks.length >= 2) { const merged: DebateProposal = { ...builder, risks: [...new Set([...builder.risks, ...critic.risks])], steps: [ ...builder.steps, `[CRITICO] Verificare prima di procedere: ${criticalRisks.slice(0, 2).join("; ")}`, ], confidence: Math.min(builder.confidence, critic.confidence), timestamp: Date.now(), }; return { winner: "merge", rule: "critic_security", proposal: merged }; } // Regola 2: critico molto più sicuro if (critic.confidence > builder.confidence + 10) { return { winner: "critic", rule: "critic_confident", proposal: critic }; } // Regola 3: builder molto più sicuro if (builder.confidence > critic.confidence + 20) { return { winner: "builder", rule: "builder_dominant", proposal: builder }; } // Regola 4: merge default const merged: DebateProposal = { ...builder, risks: [...new Set([...builder.risks, ...critic.risks])], confidence: Math.round((builder.confidence + critic.confidence) / 2), timestamp: Date.now(), }; return { winner: "merge", rule: "merge_default", proposal: merged }; } // ── JSON parsing robusto ──────────────────────────────────────────────────── const REQUIRED_FIELDS = ["plan", "steps", "risks", "confidence"] as const; function extractJson(text: string): Record | null { // Tentativo 1: testo diretto try { return JSON.parse(text.trim()) as Record; } catch { /* */ } // Tentativo 2: blocco ```json ... ``` const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); if (fenced?.[1]) { try { return JSON.parse(fenced[1].trim()) as Record; } catch { /* */ } } // Tentativo 3: trova { ... } bilanciato const start = text.indexOf("{"); if (start !== -1) { let depth = 0; for (let i = start; i < text.length; i++) { if (text[i] === "{") depth++; else if (text[i] === "}") { depth--; if (depth === 0) { try { return JSON.parse(text.slice(start, i + 1)) as Record; } catch { break; } } } } } return null; } /** * Parsa il testo LLM in DebateProposal strutturata. * Ritorna null se il parsing fallisce completamente. */ export function parseProposal( rawText: string, role: DebateRole, taskId: string, round: number, ): DebateProposal | null { const data = extractJson(rawText); if (!data) return null; for (const f of REQUIRED_FIELDS) { if (!(f in data)) return null; } return { v: 1, role, taskId, round, plan: String(data.plan ?? "").slice(0, 500), steps: Array.isArray(data.steps) ? data.steps.map(s => String(s).slice(0, 200)) : [], risks: Array.isArray(data.risks) ? data.risks.map(r => String(r).slice(0, 200)) : [], confidence: Math.max(0, Math.min(100, Number(data.confidence ?? 50))), changes: Array.isArray(data.changes) ? data.changes.map(c => String(c).slice(0, 200)) : [], // S609: 100→200 timestamp: Date.now(), }; } /** Fallback: costruisce proposta minimale da testo libero */ export function proposalFromFreeText( text: string, role: DebateRole, taskId: string, round: number, ): DebateProposal { const lines = text.split("\n").map(l => l.trim()).filter(Boolean); return { v: 1, role, taskId, round, plan: lines[0]?.slice(0, 400) ?? text.slice(0, 400), // S610: 300→400 steps: lines.slice(1, 5).map(l => l.slice(0, 200)), // S610: 150→200 risks: [], confidence: 35, // bassa: free-text non strutturato changes: [], timestamp: Date.now(), }; } // ── Formatting ────────────────────────────────────────────────────────────── /** Formatta il consenso finale in markdown leggibile per la UI */ export function formatConsensus(c: DebateConsensus): string { const icon = c.agreed ? "✅" : "⚖️"; const how = c.agreed ? "consenso raggiunto" : `tiebreak — regola: \`${c.tiebreakRule ?? "merge"}\``; const div = Math.round(c.divergenceScore * 100); const parts = [ `${icon} **Debate concluso** — ${c.rounds} round · ${how}`, `Divergenza finale: **${div}%** · Confidence: **${c.final.confidence}/100** · ${(c.durationMs/1000).toFixed(1)}s`, "", `### Piano approvato`, c.final.plan, ]; if (c.final.steps.length) { parts.push("", "**Passi:**"); c.final.steps.forEach((s, i) => parts.push(`${i + 1}. ${s}`)); } if (c.final.risks.length) { parts.push("", "**Rischi identificati:**"); c.final.risks.forEach(r => parts.push(`- ⚠️ ${r}`)); } if (c.final.changes.length) { parts.push("", `**File da modificare:** ${c.final.changes.join(", ")}`); } if (!c.agreed) { parts.push( "", `> Builder confidence: ${c.builderFinal.confidence}/100 · Critic confidence: ${c.criticFinal.confidence}/100`, ); } return parts.join("\n"); }