import { createIndex, search, type SearchIndex } from "./bm25"; import { synthesizeAnswer, type AnswerOptions } from "./answer"; import { refineWithLlm, type LlmConfig } from "./llm"; import type { PortfolioDocument } from "./types"; export { resolveProvider, refineWithLlm, type LlmConfig } from "./llm"; type DocsSource = () => PortfolioDocument[] | Promise; export interface HandlerOptions extends AnswerOptions { /** Return the corpus. Cached for `cacheMs` (default 60s). */ documents: DocsSource; cacheMs?: number; /** Enable optional LLM refinement when a provider key is present (default true). */ llm?: boolean | LlmConfig; /** Extra response headers (e.g. CORS). */ headers?: Record; } function cachedIndex(src: DocsSource, cacheMs = 60_000) { let cache: { at: number; index: SearchIndex } | null = null; let inflight: Promise | null = null; return async (): Promise => { if (cache && Date.now() - cache.at < cacheMs) return cache.index; if (!inflight) { inflight = Promise.resolve(src()) .then((docs) => { cache = { at: Date.now(), index: createIndex(docs) }; return cache.index; }) .catch((err) => { if (cache) return cache.index; // serve stale on failure throw err; }) .finally(() => { inflight = null; }); } return inflight; }; } const json = (body: unknown, status = 200, headers: Record = {}) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }, }); async function readQuery(req: Request, key: string): Promise { if (req.method === "GET") return new URL(req.url).searchParams.get(key) || new URL(req.url).searchParams.get("q") || ""; const body = (await req.json().catch(() => ({}))) as Record; return String(body[key] ?? body.q ?? body.query ?? ""); } /** Next.js App Router handler for `POST|GET /api/search` → ranked hits. */ export function createSearchHandler(opts: HandlerOptions) { const getIndex = cachedIndex(opts.documents, opts.cacheMs); return async function handler(req: Request): Promise { const t0 = Date.now(); try { const query = (await readQuery(req, "query")).trim().slice(0, 200); const index = await getIndex(); const hits = query ? search(index, query, { limit: 12 }) : []; return json({ query, hits: hits.map((h) => ({ ...h, score: Math.round(h.score * 100) / 100 })), tookMs: Date.now() - t0 }, 200, opts.headers); } catch { return json({ query: "", hits: [], tookMs: Date.now() - t0, error: "search_unavailable" }, 200, opts.headers); } }; } /** Next.js App Router handler for `POST|GET /api/ask` → grounded, cited answer. */ export function createAskHandler(opts: HandlerOptions) { const getIndex = cachedIndex(opts.documents, opts.cacheMs); return async function handler(req: Request): Promise { const question = (await readQuery(req, "question").catch(() => "")).trim(); if (!question) return json({ error: "missing_question", hint: 'POST {"question": "..."}' }, 400, opts.headers); try { const index = await getIndex(); const base = synthesizeAnswer(index, question, opts); if (opts.llm !== false && base.citations.length) { const docs = base.citations.map((c) => index.docs.find((d) => d.id === c.id)!).filter(Boolean); const refined = await refineWithLlm(base, docs, typeof opts.llm === "object" ? opts.llm : { subject: opts.subject }); if (refined) return json(refined, 200, opts.headers); } return json(base, 200, opts.headers); } catch { return json( { question, answer: "Search is warming up — please try again in a moment.", points: [], citations: [], mode: "deterministic", tookMs: 0 }, 200, opts.headers, ); } }; }