MiniSearch / eval /answer.integration.test.ts
github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
a9fbc84
Raw
History Blame Contribute Delete
11.3 kB
// @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<string, string>,
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<string> {
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":"<rubric point>","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,
);
});