spb/worthdoing Public
Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL
TypeScript 91.5%
SQL 5.8%
CSS 2.2%
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/lib/agent/synthesis.ts6 * Description: Streamed opportunity report synthesis — Anthropic streaming forwarded as report.delta events, citations resolved mechanically.7 */8import { and, asc, eq, inArray } from "drizzle-orm";9import { db } from "@/lib/db/client";10import {11 opportunities,12 opportunityEvidence,13 opportunityScores,14 opportunityCompetitors,15 competitors,16 evidence,17 sources,18 investigations,19} from "@/lib/db/schema";20import { anthropic, synthesisModel } from "@/lib/anthropic/client";21import { emitEvent } from "./events";2223const SYNTHESIS_SYSTEM = `You write evidence-grounded opportunity reports for WorthDoing.ai. Voice: curious, skeptical, analytical — never hype. If evidence is weak, say so plainly.2425Rules:26- Cite evidence with bracketed numbers like [1], [3] — ONLY numbers from the provided evidence list. Never invent citations, URLs, facts, or numbers not present in the evidence.27- Every substantive claim carries a citation. Uncited reasoning must be clearly framed as interpretation.28- Include the skeptic's case honestly and give it real weight.29- Markdown, no top-level H1 (the page provides the title). Use ## sections: Problem, Why Now, The Opportunity, Evidence Assessment, Competition, Skeptic's Case, Risks, Verdict.30- Do NOT append a sources list — it is added mechanically.31- 700–1200 words. Dense, specific, decision-useful.`;3233export type CitationEntry = { index: number; evidenceId: string; url: string; title: string | null };3435/** Load everything needed to write the report, in a deterministic evidence order. */36async function loadReportContext(investigationId: string, opportunityId: string) {37 const [opp] = await db.select().from(opportunities).where(eq(opportunities.id, opportunityId));38 const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId));39 const links = await db40 .select()41 .from(opportunityEvidence)42 .where(eq(opportunityEvidence.opportunityId, opportunityId));43 const evidenceIds = links.map((l) => l.evidenceId);44 const evRows = evidenceIds.length45 ? await db46 .select({47 id: evidence.id,48 kind: evidence.kind,49 quote: evidence.quote,50 summary: evidence.summary,51 strength: evidence.strength,52 sourceId: evidence.sourceId,53 url: sources.canonicalUrl,54 title: sources.title,55 })56 .from(evidence)57 .innerJoin(sources, eq(sources.id, evidence.sourceId))58 .where(and(eq(evidence.investigationId, investigationId), inArray(evidence.id, evidenceIds)))59 .orderBy(asc(evidence.createdAt))60 : [];61 const scores = await db62 .select()63 .from(opportunityScores)64 .where(eq(opportunityScores.opportunityId, opportunityId));65 const comps = await db66 .select({ name: competitors.name, url: competitors.url, description: competitors.description, note: opportunityCompetitors.note })67 .from(opportunityCompetitors)68 .innerJoin(competitors, eq(competitors.id, opportunityCompetitors.competitorId))69 .where(eq(opportunityCompetitors.opportunityId, opportunityId));70 return { opp, inv, evRows, scores, comps };71}7273/**74 * Stream the report for an opportunity: consume the Anthropic stream server-side,75 * forward deltas as report.delta SSE events, validate citations mechanically,76 * persist the final markdown.77 */78export async function synthesizeOpportunityReport(79 investigationId: string,80 opportunityId: string,81): Promise<string> {82 const { opp, inv, evRows, scores, comps } = await loadReportContext(investigationId, opportunityId);8384 const citationMap: CitationEntry[] = evRows.map((e, i) => ({85 index: i + 1,86 evidenceId: e.id,87 url: e.url,88 title: e.title,89 }));9091 const evidenceList = evRows92 .map(93 (e, i) =>94 `[${i + 1}] (${e.kind}, strength ${Math.round(e.strength * 100)}%) "${e.quote.slice(0, 600)}" — ${e.summary} (source: ${e.title ?? e.url})`,95 )96 .join("\n");9798 const scoreList = scores99 .map((s) => `- ${s.dimension}: ${s.score}/100 at ${Math.round(s.confidence * 100)}% confidence — ${s.reasoning}`)100 .join("\n");101102 const compList = comps.length103 ? comps.map((c) => `- ${c.name}${c.url ? ` (${c.url})` : ""}: ${c.description ?? ""} ${c.note ?? ""}`).join("\n")104 : "(none identified)";105106 const userPrompt = [107 `Investigation objective: ${inv.objective}`,108 ``,109 `Opportunity: ${opp.title}`,110 `Summary: ${opp.summary}`,111 `Problem: ${opp.problem}`,112 `Why now: ${opp.whyNow}`,113 `Risks: ${opp.risks}`,114 `Skeptic's case: ${opp.skepticCase}`,115 `Worth Score: ${opp.worthScore} (evidence confidence ${Math.round((opp.evidenceConfidence ?? 0) * 100)}%)`,116 ``,117 `Dimension scores:`,118 scoreList,119 ``,120 `Competitors:`,121 compList,122 ``,123 `EVIDENCE (cite by [number] only):`,124 evidenceList,125 ``,126 `Write the report now.`,127 ].join("\n");128129 await emitEvent(investigationId, "report.started", { opportunityId, title: opp.title });130131 const stream = anthropic().messages.stream({132 model: synthesisModel(),133 max_tokens: 8000,134 system: SYNTHESIS_SYSTEM,135 messages: [{ role: "user", content: userPrompt }],136 });137138 let buffer = "";139 stream.on("text", (delta) => {140 buffer += delta;141 // Transient: forwarded live over SSE, not persisted per-delta.142 void emitEvent(investigationId, "report.delta", { opportunityId, delta }, { transient: true });143 });144 const final = await stream.finalMessage();145 if (final.stop_reason === "refusal") {146 throw new Error("Synthesis model declined the report request.");147 }148 let report = buffer.trim();149150 // Mechanical citation validation: strip citation numbers that don't exist.151 const validIndices = new Set(citationMap.map((c) => c.index));152 report = report.replace(/\[(\d{1,3})\]/g, (match, numRaw) => {153 const num = parseInt(numRaw, 10);154 return validIndices.has(num) ? match : "";155 });156157 // Mechanical sources section — never model-generated.158 if (citationMap.length) {159 report += `\n\n## Sources\n${citationMap160 .map((c) => `${c.index}. [${c.title ?? c.url}](${c.url})`)161 .join("\n")}`;162 }163164 await db165 .update(opportunities)166 .set({ reportMd: report, status: "validated", updatedAt: new Date() })167 .where(eq(opportunities.id, opportunityId));168169 await emitEvent(investigationId, "report.completed", {170 opportunityId,171 title: opp.title,172 length: report.length,173 citations: citationMap.length,174 });175176 return report;177}178