SPB Git

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%
3.9 KB · 125 lines typescript
Raw Blame History
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