Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string> { | |
| 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<string, unknown> | null { | |
| // Tentativo 1: testo diretto | |
| try { return JSON.parse(text.trim()) as Record<string, unknown>; } 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<string, unknown>; } 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<string, unknown>; } 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"); | |
| } | |