SPB Git

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