/** * Search-box.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/agent/src/synthesis.ts * Description: Streaming synthesis — writes the final answer with mechanically derived citations. */ import type Anthropic from "@anthropic-ai/sdk"; import { getModels, synthesisStream } from "@search-box/anthropic"; import type { ResearchState } from "@search-box/research"; import type { CitationMapEntry } from "@search-box/events"; import { SYNTHESIS_SYSTEM, synthesisUserPrompt } from "./prompts.js"; const DELTA_FLUSH_CHARS = 160; const DELTA_FLUSH_MS = 400; /** * Builds the evidence base with mechanical citation numbers (source citation * indices assigned from state), streams the answer, and emits answer events. */ export async function runSynthesis( state: ResearchState, question: string, objectives: string[] ): Promise<{ answer: string; citations: CitationMapEntry[] }> { const models = getModels(); await state.emit({ type: "synthesis.started" }); const citations = await state.assignCitations(); const indexBySource = new Map(citations.map((c) => [c.sourceId, c.index])); const snap = await state.snapshot(); const evidenceByClaim = new Map(); for (const ev of snap.evidence) { const key = ev.claimId ?? "_unattached"; const arr = evidenceByClaim.get(key) ?? []; arr.push(ev); evidenceByClaim.set(key, arr); } const claimsBlock = snap.claims .map( (c) => `- (${c.id}) "${c.text}" — status: ${c.status}, confidence: ${Math.round(c.confidence * 100)}%${ c.publicReason ? `, note: ${c.publicReason}` : "" }` ) .join("\n"); const evidenceLines: string[] = []; for (const claim of snap.claims) { const evs = evidenceByClaim.get(claim.id) ?? []; if (evs.length === 0) continue; evidenceLines.push(`For claim "${claim.text}":`); for (const ev of evs) { const idx = indexBySource.get(ev.sourceId); if (idx === undefined) continue; evidenceLines.push(` [${idx}] (${ev.stance}) "${ev.quote}"${ev.note ? ` — ${ev.note}` : ""}`); } } const unattached = evidenceByClaim.get("_unattached") ?? []; if (unattached.length > 0) { evidenceLines.push("General context:"); for (const ev of unattached) { const idx = indexBySource.get(ev.sourceId); if (idx === undefined) continue; evidenceLines.push(` [${idx}] "${ev.quote}"`); } } const contradictionsBlock = snap.contradictions .map((c) => { const claim = snap.claims.find((cl) => cl.id === c.claimId); return `- On "${claim?.text ?? c.claimId}": ${c.description}`; }) .join("\n"); const system: Anthropic.TextBlockParam[] = [{ type: "text", text: SYNTHESIS_SYSTEM }]; const userPrompt = synthesisUserPrompt({ question, objectives, claimsBlock, evidenceBlock: evidenceLines.join("\n"), contradictionsBlock }); // Buffer token deltas so we don't write one event row per token. let buffer = ""; let lastFlush = Date.now(); let flushChain: Promise = Promise.resolve(); const flush = () => { if (!buffer) return; const chunk = buffer; buffer = ""; lastFlush = Date.now(); flushChain = flushChain.then(() => state.emit({ type: "answer.delta", delta: chunk })); }; const final = await synthesisStream({ model: models.synthesis, system, messages: [{ role: "user", content: userPrompt }], onDelta: (delta) => { buffer += delta; if (buffer.length >= DELTA_FLUSH_CHARS || Date.now() - lastFlush >= DELTA_FLUSH_MS) flush(); } }); flush(); await flushChain; if (final.stop_reason === "refusal") { throw new Error("synthesis declined (safety refusal)"); } const answer = final.content .filter((b): b is Anthropic.TextBlock => b.type === "text") .map((b) => b.text) .join(""); await state.emit({ type: "answer.completed", answer, citations }); return { answer, citations }; }