// @vitest-environment node /** * Offline answer-quality eval: builds the exact prompt the app sends (via the * real getFormattedSearchResults + getDefaultChatMessages, with the pubSub * state mocked to the golden query), asks a chosen LLM backend for an answer, * and scores it with a separate LLM judge against the golden set's reference * answer and rubric. * * This is the regression signal for changes to the system prompt (imported * from shared/defaultSystemPrompt.ts, the same source the client uses), the * search-results formatting, or the model. The prompt-construction checks * (no model, no key) live in promptConstruction.test.ts and run in the * default suite; only the LLM-judged tests are here. buildMessagesForGolden * is shared with that file (eval/goldenPrompt.ts) so both build the prompt * identically. * * It makes real network calls, so the judged tests are gated on an API key and * skip cleanly without one. * * EVAL_LLM_API_KEY=... \ * EVAL_LLM_BASE_URL=https://api.openai.com/v1 \ * EVAL_LLM_MODEL=gpt-4o-mini \ * EVAL_JUDGE_MODEL=gpt-4o \ * npx vitest run --config vitest.eval.config.ts answer */ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { buildMessagesForGolden, type ChatMessage } from "./goldenPrompt.ts"; import { goldenQueries } from "./goldenSet.ts"; const state = vi.hoisted(() => ({ query: "", searchResults: [] as [string, string, string][], pageContents: {} as Record, settings: { systemPrompt: "", inferenceType: "openai", openAiContextLength: 4096, } as { systemPrompt: string; inferenceType?: string; openAiContextLength?: number; }, })); vi.mock("../client/modules/pubSub", () => ({ getSettings: () => state.settings, getLlmTextSearchResults: () => state.searchResults, getPageContents: () => state.pageContents, getQuery: () => state.query, getSearchPromise: vi.fn(), updateTextGenerationState: vi.fn(), })); const LLM_API_KEY = process.env.EVAL_LLM_API_KEY ?? ""; // Strip any trailing slash so a configured base URL can't produce /v1//chat/.... const LLM_BASE_URL = ( process.env.EVAL_LLM_BASE_URL ?? "https://api.openai.com/v1" ).replace(/\/$/, ""); const LLM_MODEL = process.env.EVAL_LLM_MODEL ?? "gpt-4o-mini"; const JUDGE_MODEL = process.env.EVAL_JUDGE_MODEL ?? LLM_MODEL; // Match the app's own answer budget (openAiContextLength default) so an answer // is not truncated for budget reasons; the judge needs far less. const ANSWER_MAX_TOKENS = 4096; const JUDGE_MAX_TOKENS = 512; // A regression guard, not a quality target: the current model clears it // comfortably, and a prompt/model change that degrades answers drops it. const MIN_MEAN_RUBRIC_PASS = 0.7; /** * The fraction of answers that cite at least one source as a Markdown link. * The system prompt instructs the model to cite each fact; this deterministic * check is what makes deleting that instruction visible. It is a rate rather * than a per-query assert so a single model non-citation does not fail the * run, while a wholesale loss of citations (the regression we care about) * drops the rate to near zero. */ const MIN_CITATION_RATE = 0.8; // The mean rubric pass fraction over the snippet-only entries (facts that // exist only in the search results). Graded separately so a handful of them // can't be drowned out by the rest of the set: this is what catches the // results path breaking while the citation rate stays high. const MIN_SNIPPET_ONLY_PASS = 0.8; /** True if the text contains at least one Markdown link, [any](url). */ function hasMarkdownLink(text: string): boolean { return /\[[^\]]+\]\([^)]+\)/.test(text); } const hasLlmKey = LLM_API_KEY.length > 0; interface ChatCompletionResponse { error?: { message?: string }; choices?: { message?: { content?: string | null }; finish_reason?: string; }[]; } async function chatCompletion( model: string, messages: ChatMessage[], maxTokens: number, label: string, ): Promise { const res = await fetch(`${LLM_BASE_URL}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${LLM_API_KEY}`, }, body: JSON.stringify({ model, messages, temperature: 0, max_tokens: maxTokens, }), // Keep each call well under the 180s per-test budget, which covers the two // sequential calls (answer + judge); a hung call is closed here instead of // outliving the test's own timeout. signal: AbortSignal.timeout(60_000), }); if (!res.ok) { throw new Error(`LLM call failed (${res.status}): ${await res.text()}`); } const data = (await res.json()) as ChatCompletionResponse; // Some OpenAI-compatible proxies return 200 with an { error } body. if (data.error) { throw new Error(`LLM error: ${JSON.stringify(data.error)}`); } const choice = data.choices?.[0]; if (choice?.finish_reason === "length") { throw new Error( `${label} was truncated at max_tokens=${maxTokens}; raise the budget or shorten the prompt.`, ); } const content = choice?.message?.content; if (typeof content !== "string") { // Empty choices (content filter) or null content (refusal) are not a // usable answer; fail loudly with the raw body for diagnosis. throw new Error( `${label} returned no usable content: ${JSON.stringify(data).slice(0, 500)}`, ); } return content; } const JudgeResponseSchema = z.object({ scores: z.array(z.object({ criterion: z.string(), pass: z.boolean() })), }); interface JudgeScore { criterion: string; pass: boolean; } /** * Asks the judge to check each rubric point against the answer and return * strict JSON. Throws if the response is not valid JSON matching the schema, * or if the number of returned rubric points does not match the golden * rubric (a judge returning fewer points would silently inflate the score). */ async function judgeAnswer( goldenId: string, answer: string, ): Promise<{ scores: JudgeScore[]; passFraction: number }> { const golden = goldenQueries.find((g) => g.id === goldenId); if (!golden) throw new Error(`Unknown golden id: ${goldenId}`); const rubric = golden.rubric.map((r, i) => `${i + 1}. ${r}`).join("\n"); const judgePrompt = [ "You are grading an AI search answer. For each numbered rubric point", "below, decide whether the answer satisfies it. Be strict: a point passes", "only if the answer clearly and correctly satisfies it.", "", `Question: ${golden.query}`, "", `Reference answer: ${golden.referenceAnswer}`, "", `Answer under evaluation:\n${answer}`, "", "Rubric points:", rubric, "", "Respond with ONLY a JSON object of the exact form:", '{"scores":[{"criterion":"","pass":true}]}', "with one entry per rubric point, in the same order.", ].join("\n"); const raw = await chatCompletion( JUDGE_MODEL, [{ role: "user", content: judgePrompt }], JUDGE_MAX_TOKENS, "Judge", ); const jsonText = raw .replace(/```json\s*/g, "") .replace(/```/g, "") .trim(); const start = jsonText.indexOf("{"); const end = jsonText.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) { throw new Error(`Judge returned no JSON object: ${raw.slice(0, 500)}`); } let parsed: unknown; try { parsed = JSON.parse(jsonText.slice(start, end + 1)); } catch { throw new Error(`Judge returned invalid JSON: ${raw.slice(0, 500)}`); } const result = JudgeResponseSchema.safeParse(parsed); if (!result.success) { throw new Error( `Judge response failed schema (${result.error.message}): ${raw.slice(0, 500)}`, ); } const scores = result.data.scores; if (scores.length !== golden.rubric.length) { throw new Error( `Judge returned ${scores.length} rubric points, expected ${golden.rubric.length}: ${raw.slice(0, 500)}`, ); } const passFraction = scores.filter((s) => s.pass).length / scores.length; return { scores, passFraction }; } describe("answer eval: LLM-judged quality", () => { // Module-level results collected by the per-query tests below and asserted // on by the aggregate test. Within a file, vitest runs tests in order, so // the per-query tests always populate this before the aggregate runs. const results: { id: string; passFraction: number; cited: boolean; failed: string[]; }[] = []; for (const golden of goldenQueries) { it.skipIf(!hasLlmKey)( `grades ${golden.id}`, async () => { const messages = buildMessagesForGolden(state, golden.id); const answer = await chatCompletion( LLM_MODEL, messages, ANSWER_MAX_TOKENS, "Answer", ); expect(answer.trim().length).toBeGreaterThan(0); const { scores, passFraction } = await judgeAnswer(golden.id, answer); const failed = scores.filter((s) => !s.pass).map((s) => s.criterion); results.push({ id: golden.id, passFraction, cited: hasMarkdownLink(answer), failed, }); }, 180_000, ); } it.skipIf(!hasLlmKey)( "keeps the mean rubric pass fraction above the threshold", async () => { // Print the breakdown before asserting, so a per-query failure still // shows which queries scored what (the diagnostic you need when a // regression fires). The length assert below keeps the headline from // being a partial-run average on a complete run. const mean = results.length > 0 ? results.reduce((sum, r) => sum + r.passFraction, 0) / results.length : 0; const citationRate = results.length > 0 ? results.filter((r) => r.cited).length / results.length : 0; console.table( results.map((r) => ({ id: r.id, "rubric pass": r.passFraction.toFixed(2), cited: r.cited ? "yes" : "no", failed: r.failed.join("; ") || "-", })), ); console.log( `mean rubric pass fraction: ${mean.toFixed(3)} citation rate: ${citationRate.toFixed(3)}`, ); expect(results).toHaveLength(goldenQueries.length); expect(mean).toBeGreaterThanOrEqual(MIN_MEAN_RUBRIC_PASS); expect( citationRate, "too few answers cite their sources", ).toBeGreaterThanOrEqual(MIN_CITATION_RATE); const snippetOnlyIds = new Set( goldenQueries.filter((g) => g.snippetOnly).map((g) => g.id), ); const snippetOnly = results.filter((r) => snippetOnlyIds.has(r.id)); expect( snippetOnly.length, "no snippet-only entries in the golden set", ).toBeGreaterThan(0); const snippetOnlyMean = snippetOnly.reduce((sum, r) => sum + r.passFraction, 0) / snippetOnly.length; console.log( `mean snippet-only rubric pass fraction: ${snippetOnlyMean.toFixed(3)}`, ); expect( snippetOnlyMean, "snippet-only facts are not being answered from the search results", ).toBeGreaterThanOrEqual(MIN_SNIPPET_ONLY_PASS); }, 60_000, ); });