AUDIT / src /lib /agent /debateProtocol.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
9.92 kB
/**
* 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");
}