File size: 11,275 Bytes
a9fbc84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// @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,
  );
});