diff --git a/docs/architecture.md b/docs/architecture.md index 30b508c00..7360b7fba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,6 +90,8 @@ A configured mock provider bypasses Supabase for local and test behavior. It is Shared research, source ranking, URL validation, and prompt construction live under [`supabase/functions/_shared/`](../supabase/functions/_shared/). +Research makes two small model calls of its own: Ask decides whether a question needs web search, and deep research writes follow-up search queries. Both run on `BACKGROUND_MODEL` (Claude Haiku 5.5 at low effort) in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts), not on the model the user picked; only the answer uses that model. Their usage is still added to the parent request's row. + The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction. Every AI request in a Current Debate carries a `debateContext` of IDs and preferences, never text: the debate, the side the request prepares, the Library source IDs, and the saved results to read. [`src/lib/debate/context.ts`](../src/lib/debate/context.ts) chooses the default with the rules in [`supabase/functions/_shared/debate-context.ts`](../supabase/functions/_shared/debate-context.ts): the debate's active sources, pinned results first, then up to three of the newest results of the types that feature uses, for the requested side. A compact line above each tool's primary button shows that selection, and Review changes it for one request, or for a whole Crossfire or Sparring round, where Review appears only before the round starts; pins persist in `debate_context_pins`. [`supabase/functions/_shared/debate-context-loader.ts`](../supabase/functions/_shared/debate-context-loader.ts) loads the motion, settings, instructions, sources, and result digests with every query filtered by the caller and the one debate, before quota or provider work. The prompt renders that material in delimited blocks followed by rules that it cannot override. Live sparring stays transcript-only: the scorecard sees only the motion and settings. Crossfire omits free-form instructions. Whole results are dropped when their budget is spent, and a Crossfire round that would pass its 100,000-character prompt budget drops its saved results. In both cases `meta.debateContext` reports what reached the model and what was set aside. A debate deleted on another device returns 404, and the browser reloads its debate list. One-off requests send no debate context. The retired per-user custom system prompt is no longer sent or honored; operator prompts in `global_prompts` remain. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration. diff --git a/supabase/functions/_shared/model-catalog.ts b/supabase/functions/_shared/model-catalog.ts index 9ac386965..57cb9b61d 100644 --- a/supabase/functions/_shared/model-catalog.ts +++ b/supabase/functions/_shared/model-catalog.ts @@ -40,6 +40,12 @@ export type ModelSelection = ModelId | AutoModelSelection; export const DEFAULT_MODEL = "anthropic/claude-haiku-5.5" as const; export const DEFAULT_PRO_MODEL = "anthropic/claude-opus-5.5" as const; +// Small background steps inside research (the Ask search decision and the +// follow-up search queries) run on this fixed cheap model at low effort, +// whatever model the user picked. Only the answer itself uses their model. +export const BACKGROUND_MODEL: ModelId = "anthropic/claude-haiku-5.5"; +export const BACKGROUND_REASONING_EFFORT: ReasoningEffort = "low"; + // Retired catalog IDs that older clients (an installed PWA, a saved setting) // may still send. Both the server and the settings store resolve them here. // The old defaults (GPT-5.6 Luna, Claude Sonnet 5) move to the new defaults; diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index d9ab9383c..721b58b72 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -18,7 +18,7 @@ for (const [key, value] of Object.entries(environment)) Deno.env.set(key, value) const { createRequestBudgetSignal, handleRequest } = await import("./index.ts"); const { handleRequest: handleStreamRequest } = await import("../ai-stream/index.ts"); -const { MAX_REQUEST_OUTPUT_TOKENS, MODEL_CATALOG } = await import("../_shared/model-catalog.ts"); +const { BACKGROUND_MODEL, MAX_REQUEST_OUTPUT_TOKENS, MODEL_CATALOG } = await import("../_shared/model-catalog.ts"); Deno.test("AI request work shares one deadline signal", () => { const caller = new AbortController(); @@ -400,6 +400,18 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn try { const emptySearch = await runAsk({ message: "What is current evidence?", mode: "general", citationMode: true }); + // The search decision and follow-up queries are background steps on the + // fixed cheap model; only the answer uses the model the user picked. + const helperModels = providerBodies.flatMap((body) => { + const content = (body.messages as Array<{ content?: unknown }>).at(-1)?.content; + return typeof content === "string" && content.startsWith("Message:") ? [] : [body.model]; + }); + if (helperModels.length !== 2 || helperModels.some((model) => model !== BACKGROUND_MODEL)) { + throw new Error(`research helpers did not use the background model: ${JSON.stringify(helperModels)}`); + } + if (providerBodies.filter((body) => body.model === "openai/gpt-6-luna").length !== 1) { + throw new Error("Ask answer did not use the requested model"); + } if (!emptySearch.user.includes("Research Context: NONE")) throw new Error("empty search was marked available"); if (emptySearch.user.includes("WEB RESEARCH RESULTS") || emptySearch.system.includes("RESEARCH CITATION RULES:")) { throw new Error("empty search context leaked into the Ask provider prompt"); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 786ccb8f2..44cea4f3d 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -27,6 +27,8 @@ import { type NormalizedUsage, } from "../_shared/cost.ts"; import { + BACKGROUND_MODEL, + BACKGROUND_REASONING_EFFORT, DEFAULT_MODEL, effectiveReasoningEffort, isModelSelection, @@ -628,10 +630,12 @@ export async function handleRequest(req: Request): Promise { // collected here so the parent request's row accounts for every token. const researchUsage: NormalizedUsage[] = []; let researchAccountingIncomplete = false; + // Research sub-calls are small background steps, so they use the fixed + // cheap model instead of the user's choice. const researchDeps: ResearchDeps = { provider, - model, - reasoningEffort, + model: BACKGROUND_MODEL, + reasoningEffort: BACKGROUND_REASONING_EFFORT, signal: workSignal, onTimeout: () => { researchAccountingIncomplete = true;