import { search, type SearchIndex } from "./bm25"; import { normalize, tokenize } from "./text"; import type { AnswerIntent, AskAnswer, Citation, PortfolioDocument, SearchHit } from "./types"; export interface AnswerOptions { intents?: AnswerIntent[]; /** Subject name used in fallback copy, e.g. "Gavin". */ subject?: string; maxCitations?: number; /** Minimum top BM25 score to answer without an intent (default 1.2). */ minScore?: number; /** Suggestions shown when nothing matches. */ suggestions?: string[]; } export interface RetrievedContext { intent?: AnswerIntent; hits: SearchHit[]; } const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); export function detectIntent(question: string, intents: AnswerIntent[] = []): AnswerIntent | undefined { const q = normalize(question); return intents.find((i) => i.match.test(q)); } /** Retrieve the grounded context (intent + ranked hits) for a question. */ export function retrieve(index: SearchIndex, question: string, opts: AnswerOptions = {}): RetrievedContext { const intent = detectIntent(question, opts.intents); const query = [question, ...(intent?.boostTerms || [])].join(" "); let hits = search(index, query, { limit: 12 }); if (intent?.pin?.length) { const pinned = intent.pin .map((id) => hits.find((h) => h.doc.id === id) || docHit(index, id)) .filter((h): h is SearchHit => Boolean(h)); hits = [...pinned, ...hits.filter((h) => !intent.pin!.includes(h.doc.id))]; } return { intent, hits }; } function docHit(index: SearchIndex, id: string): SearchHit | undefined { const doc = index.docs.find((d) => d.id === id); return doc ? { doc, score: 0, matched: [] } : undefined; } function clip(text: string, max: number): string { if (text.length <= max) return text; const cut = text.slice(0, max); return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 20)).replace(/[\s,;:—–-]+$/, "")}…`; } function bestSentence(doc: PortfolioDocument, terms: Set): string { const candidates = [...(doc.facts || []), doc.summary].filter(Boolean); let best = doc.summary; let bestScore = -1; candidates.forEach((c, i) => { const toks = tokenize(c); const overlap = toks.filter((t) => terms.has(t)).length; const score = overlap * 2 - i * 0.01 + (c === doc.summary ? 0.5 : 0); if (score > bestScore) { bestScore = score; best = c; } }); return clip(best.replace(/\s+/g, " ").trim().replace(/[.;,]?$/, ""), 180); } export function toCitation(doc: PortfolioDocument): Citation { return { id: doc.id, title: doc.title, url: doc.url, category: doc.category, previewKey: doc.previewKey }; } /** * Deterministic, grounded answer synthesizer. Every sentence is either a * host-authored verified intent lead or copied from a cited document's facts. * It never invents content and never throws. */ export function synthesizeAnswer(index: SearchIndex, question: string, opts: AnswerOptions = {}): AskAnswer { const t0 = now(); const subject = opts.subject || "this portfolio"; const q = question.trim().slice(0, 400); const { intent, hits } = retrieve(index, q, opts); const max = Math.min(opts.maxCitations ?? 5, intent?.maxPoints ?? 5); const pinned = new Set(intent?.pin || []); const firstFree = hits.find((h) => !pinned.has(h.doc.id))?.score || 0; // With pinned sources, allow at most one extra strong match; otherwise keep matches within 30% of the best. const floor = firstFree * (pinned.size ? 0.6 : 0.3); let extras = 0; const top = hits .filter((h) => { if (pinned.has(h.doc.id)) return true; if (h.score < floor) return false; if (pinned.size && extras >= 1) return false; extras += 1; return true; }) .slice(0, max); const minScore = opts.minScore ?? 1.2; if (!intent && (!top.length || top[0].score < minScore)) { const tips = (opts.suggestions || []).slice(0, 3); return { question: q, answer: `I only answer from ${subject}'s verified portfolio, and nothing in it clearly matches “${q}”.${ tips.length ? ` Try: ${tips.map((t) => `“${t}”`).join(", ")}.` : "" }`, points: [], citations: [], mode: "deterministic", tookMs: Math.round((now() - t0) * 10) / 10, }; } const terms = new Set(tokenize([q, ...(intent?.boostTerms || [])].join(" "))); const citations = top.map((h) => toCitation(h.doc)); const points = top.map((h, i) => `${h.doc.title} — ${bestSentence(h.doc, terms)} [${i + 1}]`); const answer = intent ? `${intent.lead.trim()}${citations.length ? ` [1]` : ""}` : `Best match: ${top[0].doc.title} — ${bestSentence(top[0].doc, terms)}. [1]`; return { question: q, answer, points: intent ? points : points.slice(1), citations, mode: "deterministic", intent: intent?.id, tookMs: Math.round((now() - t0) * 10) / 10, }; }