/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/agent/synthesis.ts * Description: Streamed opportunity report synthesis — Anthropic streaming forwarded as report.delta events, citations resolved mechanically. */ import { and, asc, eq, inArray } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { opportunities, opportunityEvidence, opportunityScores, opportunityCompetitors, competitors, evidence, sources, investigations, } from "@/lib/db/schema"; import { anthropic, synthesisModel } from "@/lib/anthropic/client"; import { emitEvent } from "./events"; const SYNTHESIS_SYSTEM = `You write evidence-grounded opportunity reports for WorthDoing.ai. Voice: curious, skeptical, analytical — never hype. If evidence is weak, say so plainly. Rules: - 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. - Every substantive claim carries a citation. Uncited reasoning must be clearly framed as interpretation. - Include the skeptic's case honestly and give it real weight. - 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. - Do NOT append a sources list — it is added mechanically. - 700–1200 words. Dense, specific, decision-useful.`; export type CitationEntry = { index: number; evidenceId: string; url: string; title: string | null }; /** Load everything needed to write the report, in a deterministic evidence order. */ async function loadReportContext(investigationId: string, opportunityId: string) { const [opp] = await db.select().from(opportunities).where(eq(opportunities.id, opportunityId)); const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId)); const links = await db .select() .from(opportunityEvidence) .where(eq(opportunityEvidence.opportunityId, opportunityId)); const evidenceIds = links.map((l) => l.evidenceId); const evRows = evidenceIds.length ? await db .select({ id: evidence.id, kind: evidence.kind, quote: evidence.quote, summary: evidence.summary, strength: evidence.strength, sourceId: evidence.sourceId, url: sources.canonicalUrl, title: sources.title, }) .from(evidence) .innerJoin(sources, eq(sources.id, evidence.sourceId)) .where(and(eq(evidence.investigationId, investigationId), inArray(evidence.id, evidenceIds))) .orderBy(asc(evidence.createdAt)) : []; const scores = await db .select() .from(opportunityScores) .where(eq(opportunityScores.opportunityId, opportunityId)); const comps = await db .select({ name: competitors.name, url: competitors.url, description: competitors.description, note: opportunityCompetitors.note }) .from(opportunityCompetitors) .innerJoin(competitors, eq(competitors.id, opportunityCompetitors.competitorId)) .where(eq(opportunityCompetitors.opportunityId, opportunityId)); return { opp, inv, evRows, scores, comps }; } /** * Stream the report for an opportunity: consume the Anthropic stream server-side, * forward deltas as report.delta SSE events, validate citations mechanically, * persist the final markdown. */ export async function synthesizeOpportunityReport( investigationId: string, opportunityId: string, ): Promise { const { opp, inv, evRows, scores, comps } = await loadReportContext(investigationId, opportunityId); const citationMap: CitationEntry[] = evRows.map((e, i) => ({ index: i + 1, evidenceId: e.id, url: e.url, title: e.title, })); const evidenceList = evRows .map( (e, i) => `[${i + 1}] (${e.kind}, strength ${Math.round(e.strength * 100)}%) "${e.quote.slice(0, 600)}" — ${e.summary} (source: ${e.title ?? e.url})`, ) .join("\n"); const scoreList = scores .map((s) => `- ${s.dimension}: ${s.score}/100 at ${Math.round(s.confidence * 100)}% confidence — ${s.reasoning}`) .join("\n"); const compList = comps.length ? comps.map((c) => `- ${c.name}${c.url ? ` (${c.url})` : ""}: ${c.description ?? ""} ${c.note ?? ""}`).join("\n") : "(none identified)"; const userPrompt = [ `Investigation objective: ${inv.objective}`, ``, `Opportunity: ${opp.title}`, `Summary: ${opp.summary}`, `Problem: ${opp.problem}`, `Why now: ${opp.whyNow}`, `Risks: ${opp.risks}`, `Skeptic's case: ${opp.skepticCase}`, `Worth Score: ${opp.worthScore} (evidence confidence ${Math.round((opp.evidenceConfidence ?? 0) * 100)}%)`, ``, `Dimension scores:`, scoreList, ``, `Competitors:`, compList, ``, `EVIDENCE (cite by [number] only):`, evidenceList, ``, `Write the report now.`, ].join("\n"); await emitEvent(investigationId, "report.started", { opportunityId, title: opp.title }); const stream = anthropic().messages.stream({ model: synthesisModel(), max_tokens: 8000, system: SYNTHESIS_SYSTEM, messages: [{ role: "user", content: userPrompt }], }); let buffer = ""; stream.on("text", (delta) => { buffer += delta; // Transient: forwarded live over SSE, not persisted per-delta. void emitEvent(investigationId, "report.delta", { opportunityId, delta }, { transient: true }); }); const final = await stream.finalMessage(); if (final.stop_reason === "refusal") { throw new Error("Synthesis model declined the report request."); } let report = buffer.trim(); // Mechanical citation validation: strip citation numbers that don't exist. const validIndices = new Set(citationMap.map((c) => c.index)); report = report.replace(/\[(\d{1,3})\]/g, (match, numRaw) => { const num = parseInt(numRaw, 10); return validIndices.has(num) ? match : ""; }); // Mechanical sources section — never model-generated. if (citationMap.length) { report += `\n\n## Sources\n${citationMap .map((c) => `${c.index}. [${c.title ?? c.url}](${c.url})`) .join("\n")}`; } await db .update(opportunities) .set({ reportMd: report, status: "validated", updatedAt: new Date() }) .where(eq(opportunities.id, opportunityId)); await emitEvent(investigationId, "report.completed", { opportunityId, title: opp.title, length: report.length, citations: citationMap.length, }); return report; }