SPB Git
10.3 KB · 93 lines typescript
1import "server-only";2import { sql } from "../db";3import { newId } from "../ids";4import { adapterFor, estimateCost, type AIModel } from "../ai";5import { requireChatModel } from "../ai/registry";6import { recordUsage, assertWithinBudget } from "../usage";7import { webSearch, fetchPage } from "../tools/web";8import { utilityModel } from "../chat/post";9import { createConversation, insertMessage, updateMessage } from "../chat/store";1011export type Emit = (event: string, data: unknown) => void;12interface Source { n: number; url: string; title: string; snippet: string; publishedAt?: string; text?: string; fetched: boolean; evidence?: { claim: string; quote: string; confidence: "high" | "medium" | "low" }[] }1314async function complete(model: AIModel, prompt: string, kind: string, runId: string, maxTokens = 6000, effort?: "low" | "medium" | "high" | "xhigh" | "max", signal?: AbortSignal): Promise<{ text: string; cost: number }> {15  let text = ""; let usage = { input: 0, output: 0, cached: 0, reasoning: 0 }; const t0 = Date.now();16  for await (const ev of adapterFor(model.provider).stream({ model, messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], settings: { maxOutputTokens: maxTokens, effort, reasoningSummary: false }, signal, loadAttachment: async () => null })) {17    if (ev.type === "text") text += ev.delta;18    else if (ev.type === "usage") usage = { input: usage.input + ev.usage.input, output: usage.output + ev.usage.output, cached: usage.cached + (ev.usage.cached ?? 0), reasoning: usage.reasoning + (ev.usage.reasoning ?? 0) };19    else if (ev.type === "error") throw new Error(ev.message);20  }21  const cost = estimateCost(model, usage) ?? 0;22  await recordUsage({ provider: model.provider, modelId: model.id, kind, usage, costEstimated: cost, latencyMs: Date.now() - t0, meta: { researchId: runId } });23  return { text, cost };24}2526const parseJson = <T,>(s: string, fallback: T): T => { try { return JSON.parse(s.replace(/```(?:json)?/g, "").trim()) as T; } catch { const m = s.match(/[\[{][\s\S]*[\]}]/); if (m) { try { return JSON.parse(m[0]) as T; } catch { /* */ } } return fallback; } };2728export async function runResearch(o: { question: string; modelId: string; projectId?: string | null; depth?: "quick" | "standard" | "deep"; emit: Emit; signal?: AbortSignal }): Promise<{ id: string; conversationId: string }> {29  await assertWithinBudget();30  const model = await requireChatModel(o.modelId);31  const extractor = (await utilityModel()) ?? model;32  const depth = o.depth ?? "standard";33  const nQueries = depth === "quick" ? 3 : depth === "deep" ? 8 : 5;34  const nFetch = depth === "quick" ? 4 : depth === "deep" ? 12 : 7;35  const id = newId("rs");36  const conv = await createConversation({ projectId: o.projectId ?? null, modelId: model.id, title: o.question.slice(0, 80), mode: "research" });37  const userMsg = await insertMessage({ conversationId: conv.id, role: "user", parts: [{ type: "text", text: o.question }] });38  const assistant = await insertMessage({ conversationId: conv.id, parentId: userMsg.id, role: "assistant", parts: [], modelId: model.id, provider: model.provider, status: "streaming" });39  await sql`INSERT INTO research_runs (id, conversation_id, project_id, question, model_id, status) VALUES (${id}, ${conv.id}, ${o.projectId ?? null}, ${o.question}, ${model.id}, 'planning')`;40  let cost = 0; const steps: { step: string; at: string; detail?: unknown }[] = [];41  const step = async (name: string, status: string, detail?: unknown) => { steps.push({ step: name, at: new Date().toISOString(), detail }); o.emit("step", { step: name, status, detail }); await sql`UPDATE research_runs SET status = ${status}, steps = ${sql.json(steps as never)}, cost_usd = ${cost} WHERE id = ${id}`; };42  o.emit("start", { id, conversationId: conv.id, model: model.id, extractor: extractor.id, depth });43  try {44    // 1. plan45    const planRaw = await complete(model, `You are planning a research investigation. Question: """${o.question}"""\nToday: ${new Date().toISOString().slice(0, 10)}.\nReturn ONLY JSON: {"subquestions": [3-6 precise sub-questions], "queries": [${nQueries} diverse web search queries in the most useful language(s), mixing broad and specific], "evidence_needed": [what kinds of sources would settle it]}.`, "research", id, 2000, "low", o.signal);46    cost += planRaw.cost;47    const plan = parseJson<{ subquestions: string[]; queries: string[]; evidence_needed: string[] }>(planRaw.text, { subquestions: [o.question], queries: [o.question], evidence_needed: [] });48    plan.queries = (plan.queries ?? [o.question]).slice(0, nQueries);49    await sql`UPDATE research_runs SET plan = ${sql.json(plan as never)} WHERE id = ${id}`;50    await step("plan", "searching", plan);51    // 2. discovery52    const seen = new Map<string, Source>();53    const searches = await Promise.all(plan.queries.map(q => webSearch(q, { maxResults: 8 })));54    for (const s of searches) if ("results" in s) for (const r of s.results) if (!seen.has(r.url) && !/\.(pdf|zip)$/i.test(r.url)) seen.set(r.url, { n: seen.size + 1, url: r.url, title: r.title, snippet: r.snippet, publishedAt: r.publishedAt, fetched: false });55    const sources = [...seen.values()];56    if (!sources.length) throw new Error(searches.map(s => "error" in s ? s.error : "").filter(Boolean)[0] ?? "Aucune source trouvée");57    await step("discovery", "reading", { queries: plan.queries, found: sources.length });58    // 3. retrieval (top N by snippet relevance order as returned)59    const toFetch = sources.slice(0, nFetch);60    await Promise.all(toFetch.map(async s => { const r = await fetchPage(s.url); if ("text" in r && r.text.length > 300) { s.text = r.text.slice(0, 12_000); s.fetched = true; if (r.title && !s.title) s.title = r.title; } o.emit("source", { n: s.n, url: s.url, title: s.title, fetched: s.fetched }); }));61    await step("retrieval", "reading", { fetched: toFetch.filter(s => s.fetched).length, of: toFetch.length });62    // 4. evidence extraction (cheap model, parallel)63    await Promise.all(toFetch.filter(s => s.fetched).map(async s => {64      const r = await complete(extractor, `Extract evidence relevant to the research question from this source. Question: """${o.question}"""\nSub-questions: ${plan.subquestions.join(" | ")}\nSource [${s.n}] ${s.title} — ${s.url}\n"""\n${s.text}\n"""\nReturn ONLY JSON: [{"claim": "one factual statement supported by the source", "quote": "short verbatim excerpt (<= 30 words)", "confidence": "high|medium|low"}] (max 6 items, empty array if nothing relevant).`, "research", id, 1500, "low", o.signal);65      cost += r.cost; s.evidence = parseJson(r.text, []);66      o.emit("evidence", { n: s.n, count: s.evidence?.length ?? 0 });67    }));68    await step("extraction", "synthesizing", { claims: toFetch.reduce((a, s) => a + (s.evidence?.length ?? 0), 0) });69    // 5+6. cross-validation & synthesis (selected model, streamed)70    const dossier = toFetch.filter(s => s.fetched).map(s => `[${s.n}] ${s.title} — ${s.url}${s.publishedAt ? ` (${s.publishedAt})` : ""}\n${(s.evidence ?? []).map(e => `  • (${e.confidence}) ${e.claim} — « ${e.quote} »`).join("\n") || "  • (no structured evidence extracted; snippet: " + s.snippet.slice(0, 300) + ")"}`).join("\n\n");71    const unread = sources.slice(nFetch).map(s => `[${s.n}] ${s.title} — ${s.url} (not read; snippet: ${s.snippet.slice(0, 200)})`).join("\n");72    const synthPrompt = `You are writing a rigorous research report in the language of the question.\nQuestion: """${o.question}"""\nSub-questions: ${plan.subquestions.map((q, i) => `${i + 1}. ${q}`).join(" ")}\n\nEVIDENCE DOSSIER (numbered sources; cite them as [n]):\n${dossier}\n\nADDITIONAL SOURCES FOUND BUT NOT READ:\n${unread || "(none)"}\n\nWrite the report in Markdown with: a 3-5 sentence executive summary; one section per sub-question; a "Cross-validation" section listing where sources agree, where they conflict, and what remains unverified; a "Limitations" section; and a final "Sources" list ([n] title — URL). Rules: every factual sentence that relies on a source carries a [n] citation; clearly label interpretation ("Interprétation :" / "Interpretation:") and speculation; never cite a source for something it does not say; do not invent sources or numbers; if evidence is thin say so.`;73    let report = ""; let usage = { input: 0, output: 0, cached: 0, reasoning: 0 }; const t0 = Date.now();74    for await (const ev of adapterFor(model.provider).stream({ model, messages: [{ role: "user", parts: [{ type: "text", text: synthPrompt }] }], settings: { maxOutputTokens: 16_000, effort: depth === "deep" ? "high" : "medium", reasoningSummary: false }, signal: o.signal, loadAttachment: async () => null })) {75      if (ev.type === "text") { report += ev.delta; o.emit("text", { delta: ev.delta }); }76      else if (ev.type === "usage") usage = { input: usage.input + ev.usage.input, output: usage.output + ev.usage.output, cached: usage.cached + (ev.usage.cached ?? 0), reasoning: usage.reasoning + (ev.usage.reasoning ?? 0) };77      else if (ev.type === "error") throw new Error(ev.message);78    }79    const synthCost = estimateCost(model, usage) ?? 0; cost += synthCost;80    await recordUsage({ provider: model.provider, modelId: model.id, kind: "research", conversationId: conv.id, projectId: o.projectId ?? null, messageId: assistant.id, usage, costEstimated: synthCost, latencyMs: Date.now() - t0, meta: { researchId: id } });81    const srcOut = sources.map(({ text: _t, ...s }) => s);82    await sql`UPDATE research_runs SET status = 'complete', report_md = ${report}, sources = ${sql.json(srcOut as never)}, cost_usd = ${cost}, completed_at = now(), steps = ${sql.json(steps as never)} WHERE id = ${id}`;83    await updateMessage(assistant.id, { parts: [{ type: "text", text: report }, ...srcOut.filter(s => s.fetched).map(s => ({ type: "citation" as const, url: s.url, title: s.title, snippet: s.snippet.slice(0, 200) }))], status: "complete", usage: usage as unknown as Record<string, number>, costUsd: cost, latencyMs: Date.now() - t0 });84    o.emit("done", { id, conversationId: conv.id, costUsd: cost, sources: srcOut.length });85  } catch (e) {86    const msg = (e as Error).message;87    await sql`UPDATE research_runs SET status = ${o.signal?.aborted ? "cancelled" : "error"}, error = ${msg}, cost_usd = ${cost} WHERE id = ${id}`;88    await updateMessage(assistant.id, { status: "error", error: msg });89    o.emit("error", { message: msg });90  }91  return { id, conversationId: conv.id };92}93
Files · main
Menu

Theme