spb/search-box Public
Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.
TypeScript 76.9%
CSS 18.7%
SQL 2.1%
JavaScript 1.8%
Shell 0.5%
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: packages/agent/src/synthesis.ts6 * Description: Streaming synthesis — writes the final answer with mechanically derived citations.7 */89import type Anthropic from "@anthropic-ai/sdk";10import { getModels, synthesisStream } from "@search-box/anthropic";11import type { ResearchState } from "@search-box/research";12import type { CitationMapEntry } from "@search-box/events";13import { SYNTHESIS_SYSTEM, synthesisUserPrompt } from "./prompts.js";1415const DELTA_FLUSH_CHARS = 160;16const DELTA_FLUSH_MS = 400;1718/**19 * Builds the evidence base with mechanical citation numbers (source citation20 * indices assigned from state), streams the answer, and emits answer events.21 */22export async function runSynthesis(23 state: ResearchState,24 question: string,25 objectives: string[]26): Promise<{ answer: string; citations: CitationMapEntry[] }> {27 const models = getModels();28 await state.emit({ type: "synthesis.started" });2930 const citations = await state.assignCitations();31 const indexBySource = new Map(citations.map((c) => [c.sourceId, c.index]));32 const snap = await state.snapshot();3334 const evidenceByClaim = new Map<string, typeof snap.evidence>();35 for (const ev of snap.evidence) {36 const key = ev.claimId ?? "_unattached";37 const arr = evidenceByClaim.get(key) ?? [];38 arr.push(ev);39 evidenceByClaim.set(key, arr);40 }4142 const claimsBlock = snap.claims43 .map(44 (c) =>45 `- (${c.id}) "${c.text}" — status: ${c.status}, confidence: ${Math.round(c.confidence * 100)}%${46 c.publicReason ? `, note: ${c.publicReason}` : ""47 }`48 )49 .join("\n");5051 const evidenceLines: string[] = [];52 for (const claim of snap.claims) {53 const evs = evidenceByClaim.get(claim.id) ?? [];54 if (evs.length === 0) continue;55 evidenceLines.push(`For claim "${claim.text}":`);56 for (const ev of evs) {57 const idx = indexBySource.get(ev.sourceId);58 if (idx === undefined) continue;59 evidenceLines.push(` [${idx}] (${ev.stance}) "${ev.quote}"${ev.note ? ` — ${ev.note}` : ""}`);60 }61 }62 const unattached = evidenceByClaim.get("_unattached") ?? [];63 if (unattached.length > 0) {64 evidenceLines.push("General context:");65 for (const ev of unattached) {66 const idx = indexBySource.get(ev.sourceId);67 if (idx === undefined) continue;68 evidenceLines.push(` [${idx}] "${ev.quote}"`);69 }70 }7172 const contradictionsBlock = snap.contradictions73 .map((c) => {74 const claim = snap.claims.find((cl) => cl.id === c.claimId);75 return `- On "${claim?.text ?? c.claimId}": ${c.description}`;76 })77 .join("\n");7879 const system: Anthropic.TextBlockParam[] = [{ type: "text", text: SYNTHESIS_SYSTEM }];80 const userPrompt = synthesisUserPrompt({81 question,82 objectives,83 claimsBlock,84 evidenceBlock: evidenceLines.join("\n"),85 contradictionsBlock86 });8788 // Buffer token deltas so we don't write one event row per token.89 let buffer = "";90 let lastFlush = Date.now();91 let flushChain: Promise<void> = Promise.resolve();92 const flush = () => {93 if (!buffer) return;94 const chunk = buffer;95 buffer = "";96 lastFlush = Date.now();97 flushChain = flushChain.then(() => state.emit({ type: "answer.delta", delta: chunk }));98 };99100 const final = await synthesisStream({101 model: models.synthesis,102 system,103 messages: [{ role: "user", content: userPrompt }],104 onDelta: (delta) => {105 buffer += delta;106 if (buffer.length >= DELTA_FLUSH_CHARS || Date.now() - lastFlush >= DELTA_FLUSH_MS) flush();107 }108 });109110 flush();111 await flushChain;112113 if (final.stop_reason === "refusal") {114 throw new Error("synthesis declined (safety refusal)");115 }116117 const answer = final.content118 .filter((b): b is Anthropic.TextBlock => b.type === "text")119 .map((b) => b.text)120 .join("");121122 await state.emit({ type: "answer.completed", answer, citations });123 return { answer, citations };124}125