@tryinget/pi-evalset-lab 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/LICENSE +78 -0
- package/README.md +141 -0
- package/examples/.gitkeep +0 -0
- package/examples/evalset-compare-sample-embedded.html +142 -0
- package/examples/evalset-compare-sample.png +0 -0
- package/examples/fixed-task-set-v2.json +127 -0
- package/examples/fixed-task-set-v3.json +126 -0
- package/examples/fixed-task-set.json +22 -0
- package/examples/system-baseline.txt +1 -0
- package/examples/system-candidate.txt +6 -0
- package/extensions/evalset.ts +1148 -0
- package/package.json +85 -0
- package/policy/security-policy.json +10 -0
- package/policy/stack-lane.json +10 -0
- package/prompts/implementation-planning.md +21 -0
- package/prompts/security-review.md +21 -0
- package/scripts/export-evalset-report-html.mjs +364 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type AssistantMessage,
|
|
6
|
+
type Context,
|
|
7
|
+
complete,
|
|
8
|
+
type Model,
|
|
9
|
+
type Usage,
|
|
10
|
+
} from "@mariozechner/pi-ai";
|
|
11
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
const COMMAND_NAME = "evalset";
|
|
14
|
+
const CUSTOM_MESSAGE_TYPE = "evalset";
|
|
15
|
+
|
|
16
|
+
const HELP_TEXT = [
|
|
17
|
+
"evalset command",
|
|
18
|
+
"",
|
|
19
|
+
"Usage:",
|
|
20
|
+
` /${COMMAND_NAME} help`,
|
|
21
|
+
` /${COMMAND_NAME} run <dataset.json> [--system-file <path>] [--system-text <text>] [--variant <name>] [--max-cases <n>] [--temperature <n>] [--out <report.json>]`,
|
|
22
|
+
` /${COMMAND_NAME} compare <dataset.json> <baseline-system.txt> <candidate-system.txt> [--baseline-name <name>] [--candidate-name <name>] [--max-cases <n>] [--temperature <n>] [--out <report.json>]`,
|
|
23
|
+
` /${COMMAND_NAME} init [dataset-path] [--force]`,
|
|
24
|
+
"",
|
|
25
|
+
"Notes:",
|
|
26
|
+
` /${COMMAND_NAME} is a pi slash command (not a shell executable).`,
|
|
27
|
+
" Non-interactive shell usage:",
|
|
28
|
+
` pi -e ./extensions/${COMMAND_NAME}.ts -p "/${COMMAND_NAME} compare <dataset.json> <baseline-system.txt> <candidate-system.txt>"`,
|
|
29
|
+
"",
|
|
30
|
+
"Dataset shape:",
|
|
31
|
+
" {",
|
|
32
|
+
' "name": "optional-name",',
|
|
33
|
+
' "systemPrompt": "optional base system prompt",',
|
|
34
|
+
' "cases": [',
|
|
35
|
+
" {",
|
|
36
|
+
' "id": "case-id",',
|
|
37
|
+
' "input": "user prompt",',
|
|
38
|
+
' "expectContains": ["term-a", "term-b"],',
|
|
39
|
+
' "expectNotContains": ["forbidden-term"],',
|
|
40
|
+
' "expectRegex": "^optional-regex$"',
|
|
41
|
+
" }",
|
|
42
|
+
" ]",
|
|
43
|
+
" }",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
interface EvalCaseDefinition {
|
|
47
|
+
id?: string;
|
|
48
|
+
input: string;
|
|
49
|
+
expectContains?: string[];
|
|
50
|
+
expectNotContains?: string[];
|
|
51
|
+
expectRegex?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface EvalDataset {
|
|
55
|
+
name?: string;
|
|
56
|
+
systemPrompt?: string;
|
|
57
|
+
cases: EvalCaseDefinition[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RunCommandConfig {
|
|
61
|
+
datasetPath: string;
|
|
62
|
+
systemFilePath?: string;
|
|
63
|
+
systemText?: string;
|
|
64
|
+
outPath?: string;
|
|
65
|
+
variantName: string;
|
|
66
|
+
maxCases?: number;
|
|
67
|
+
temperature?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CompareCommandConfig {
|
|
71
|
+
datasetPath: string;
|
|
72
|
+
baselineSystemPath: string;
|
|
73
|
+
candidateSystemPath: string;
|
|
74
|
+
outPath?: string;
|
|
75
|
+
baselineName: string;
|
|
76
|
+
candidateName: string;
|
|
77
|
+
maxCases?: number;
|
|
78
|
+
temperature?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface VariantDefinition {
|
|
82
|
+
name: string;
|
|
83
|
+
systemPrompt: string;
|
|
84
|
+
source: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface CaseCheckResult {
|
|
88
|
+
check: string;
|
|
89
|
+
pass: boolean;
|
|
90
|
+
details: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface EvalCaseResult {
|
|
94
|
+
id: string;
|
|
95
|
+
input: string;
|
|
96
|
+
scored: boolean;
|
|
97
|
+
pass: boolean;
|
|
98
|
+
checks: CaseCheckResult[];
|
|
99
|
+
failedChecks: string[];
|
|
100
|
+
outputPreview: string;
|
|
101
|
+
latencyMs: number;
|
|
102
|
+
stopReason: string;
|
|
103
|
+
usage: Usage;
|
|
104
|
+
error?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface EvalRunTotals {
|
|
108
|
+
cases: number;
|
|
109
|
+
scoredCases: number;
|
|
110
|
+
passedCases: number;
|
|
111
|
+
failedCases: number;
|
|
112
|
+
passRate: number | null;
|
|
113
|
+
totalLatencyMs: number;
|
|
114
|
+
avgLatencyMs: number;
|
|
115
|
+
usage: Usage;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface EvalRunIdentity {
|
|
119
|
+
runId: string;
|
|
120
|
+
startedAt: string;
|
|
121
|
+
finishedAt: string;
|
|
122
|
+
modelKey: string;
|
|
123
|
+
temperature: number | null;
|
|
124
|
+
datasetHash: string;
|
|
125
|
+
casesHash: string;
|
|
126
|
+
variantHash: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface EvalRunReport {
|
|
130
|
+
kind: "evalset-run";
|
|
131
|
+
createdAt: string;
|
|
132
|
+
run: EvalRunIdentity;
|
|
133
|
+
dataset: {
|
|
134
|
+
name: string;
|
|
135
|
+
path: string;
|
|
136
|
+
};
|
|
137
|
+
model: {
|
|
138
|
+
provider: string;
|
|
139
|
+
id: string;
|
|
140
|
+
api: string;
|
|
141
|
+
};
|
|
142
|
+
variant: VariantDefinition;
|
|
143
|
+
totals: EvalRunTotals;
|
|
144
|
+
cases: EvalCaseResult[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface EvalCompareIdentity {
|
|
148
|
+
runId: string;
|
|
149
|
+
startedAt: string;
|
|
150
|
+
finishedAt: string;
|
|
151
|
+
modelKey: string;
|
|
152
|
+
temperature: number | null;
|
|
153
|
+
datasetHash: string;
|
|
154
|
+
casesHash: string;
|
|
155
|
+
baselineRunId: string;
|
|
156
|
+
candidateRunId: string;
|
|
157
|
+
baselineVariantHash: string;
|
|
158
|
+
candidateVariantHash: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface EvalCompareReport {
|
|
162
|
+
kind: "evalset-compare";
|
|
163
|
+
createdAt: string;
|
|
164
|
+
run: EvalCompareIdentity;
|
|
165
|
+
dataset: {
|
|
166
|
+
name: string;
|
|
167
|
+
path: string;
|
|
168
|
+
};
|
|
169
|
+
model: {
|
|
170
|
+
provider: string;
|
|
171
|
+
id: string;
|
|
172
|
+
api: string;
|
|
173
|
+
};
|
|
174
|
+
baseline: EvalRunReport;
|
|
175
|
+
candidate: EvalRunReport;
|
|
176
|
+
delta: {
|
|
177
|
+
passRate: number | null;
|
|
178
|
+
avgLatencyMs: number;
|
|
179
|
+
totalCost: number;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseArgs(input: string): string[] {
|
|
184
|
+
const tokens: string[] = [];
|
|
185
|
+
const regex = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\S+)/g;
|
|
186
|
+
|
|
187
|
+
for (const match of input.matchAll(regex)) {
|
|
188
|
+
const value = match[1] ?? match[2] ?? match[3] ?? "";
|
|
189
|
+
tokens.push(value.replace(/\\(["'\\])/g, "$1").replace(/\\n/g, "\n"));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return tokens;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
196
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function canonicalize(value: unknown): unknown {
|
|
200
|
+
if (Array.isArray(value)) {
|
|
201
|
+
return value.map((entry) => canonicalize(entry));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (isRecord(value)) {
|
|
205
|
+
const sortedEntries = Object.entries(value)
|
|
206
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
207
|
+
.map(([key, entry]) => [key, canonicalize(entry)] as const);
|
|
208
|
+
return Object.fromEntries(sortedEntries);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hashString(value: string): string {
|
|
215
|
+
return createHash("sha256").update(value).digest("hex");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hashObject(value: unknown): string {
|
|
219
|
+
return hashString(JSON.stringify(canonicalize(value)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function shortHash(value: string, length = 12): string {
|
|
223
|
+
return value.slice(0, length);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseStringArray(value: unknown, fieldName: string): string[] | undefined {
|
|
227
|
+
if (value === undefined) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
|
|
231
|
+
throw new Error(`Field '${fieldName}' must be an array of strings when provided.`);
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseCase(value: unknown, index: number): EvalCaseDefinition {
|
|
237
|
+
if (!isRecord(value)) {
|
|
238
|
+
throw new Error(`Case at index ${index} must be an object.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const input = value.input;
|
|
242
|
+
if (typeof input !== "string" || input.trim().length === 0) {
|
|
243
|
+
throw new Error(`Case at index ${index} must include a non-empty 'input' string.`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const id = value.id;
|
|
247
|
+
if (id !== undefined && typeof id !== "string") {
|
|
248
|
+
throw new Error(`Case at index ${index}: 'id' must be a string when provided.`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const expectContains = parseStringArray(value.expectContains, `cases[${index}].expectContains`);
|
|
252
|
+
const expectNotContains = parseStringArray(
|
|
253
|
+
value.expectNotContains,
|
|
254
|
+
`cases[${index}].expectNotContains`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const expectRegex = value.expectRegex;
|
|
258
|
+
if (expectRegex !== undefined && typeof expectRegex !== "string") {
|
|
259
|
+
throw new Error(`Case at index ${index}: 'expectRegex' must be a string when provided.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
id,
|
|
264
|
+
input,
|
|
265
|
+
expectContains,
|
|
266
|
+
expectNotContains,
|
|
267
|
+
expectRegex,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseDataset(raw: string): EvalDataset {
|
|
272
|
+
let parsed: unknown;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(raw);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
throw new Error(`Invalid JSON dataset: ${message}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!isRecord(parsed)) {
|
|
281
|
+
throw new Error("Dataset must be an object.");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const cases = parsed.cases;
|
|
285
|
+
if (!Array.isArray(cases) || cases.length === 0) {
|
|
286
|
+
throw new Error("Dataset must include a non-empty 'cases' array.");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const name = parsed.name;
|
|
290
|
+
if (name !== undefined && typeof name !== "string") {
|
|
291
|
+
throw new Error("Dataset field 'name' must be a string when provided.");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const systemPrompt = parsed.systemPrompt;
|
|
295
|
+
if (systemPrompt !== undefined && typeof systemPrompt !== "string") {
|
|
296
|
+
throw new Error("Dataset field 'systemPrompt' must be a string when provided.");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
name,
|
|
301
|
+
systemPrompt,
|
|
302
|
+
cases: cases.map((entry, index) => parseCase(entry, index)),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function toAbsolutePath(cwd: string, inputPath: string): string {
|
|
307
|
+
return isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface LoadedDataset {
|
|
311
|
+
absolutePath: string;
|
|
312
|
+
raw: string;
|
|
313
|
+
hash: string;
|
|
314
|
+
dataset: EvalDataset;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function loadDataset(cwd: string, datasetPath: string): Promise<LoadedDataset> {
|
|
318
|
+
const absolutePath = toAbsolutePath(cwd, datasetPath);
|
|
319
|
+
const raw = await readFile(absolutePath, "utf8");
|
|
320
|
+
return {
|
|
321
|
+
absolutePath,
|
|
322
|
+
raw,
|
|
323
|
+
hash: hashString(raw),
|
|
324
|
+
dataset: parseDataset(raw),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function loadTextFile(
|
|
329
|
+
cwd: string,
|
|
330
|
+
filePath: string,
|
|
331
|
+
): Promise<{ absolutePath: string; text: string }> {
|
|
332
|
+
const absolutePath = toAbsolutePath(cwd, filePath);
|
|
333
|
+
return {
|
|
334
|
+
absolutePath,
|
|
335
|
+
text: await readFile(absolutePath, "utf8"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function requireValue(tokens: string[], index: number, flag: string): string {
|
|
340
|
+
const value = tokens[index + 1];
|
|
341
|
+
if (!value || value.startsWith("--")) {
|
|
342
|
+
throw new Error(`Missing value for ${flag}.`);
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parsePositiveInteger(raw: string, field: string): number {
|
|
348
|
+
const parsed = Number.parseInt(raw, 10);
|
|
349
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
350
|
+
throw new Error(`${field} must be a positive integer.`);
|
|
351
|
+
}
|
|
352
|
+
return parsed;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseTemperature(raw: string): number {
|
|
356
|
+
const parsed = Number.parseFloat(raw);
|
|
357
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2) {
|
|
358
|
+
throw new Error("--temperature must be a number between 0 and 2.");
|
|
359
|
+
}
|
|
360
|
+
return parsed;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseRunCommand(tokens: string[]): RunCommandConfig {
|
|
364
|
+
if (tokens.length < 2) {
|
|
365
|
+
throw new Error(`Usage: /${COMMAND_NAME} run <dataset.json> [options]`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const config: RunCommandConfig = {
|
|
369
|
+
datasetPath: tokens[1],
|
|
370
|
+
variantName: "candidate",
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
for (let i = 2; i < tokens.length; i++) {
|
|
374
|
+
const token = tokens[i];
|
|
375
|
+
switch (token) {
|
|
376
|
+
case "--system-file": {
|
|
377
|
+
config.systemFilePath = requireValue(tokens, i, token);
|
|
378
|
+
i += 1;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "--system-text": {
|
|
382
|
+
config.systemText = requireValue(tokens, i, token);
|
|
383
|
+
i += 1;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case "--variant": {
|
|
387
|
+
config.variantName = requireValue(tokens, i, token);
|
|
388
|
+
i += 1;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case "--out": {
|
|
392
|
+
config.outPath = requireValue(tokens, i, token);
|
|
393
|
+
i += 1;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case "--max-cases": {
|
|
397
|
+
config.maxCases = parsePositiveInteger(requireValue(tokens, i, token), token);
|
|
398
|
+
i += 1;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
case "--temperature": {
|
|
402
|
+
config.temperature = parseTemperature(requireValue(tokens, i, token));
|
|
403
|
+
i += 1;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
default:
|
|
407
|
+
throw new Error(`Unknown option for run: ${token}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (config.systemFilePath && config.systemText) {
|
|
412
|
+
throw new Error("Use either --system-file or --system-text, not both.");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return config;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function parseCompareCommand(tokens: string[]): CompareCommandConfig {
|
|
419
|
+
if (tokens.length < 4) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Usage: /${COMMAND_NAME} compare <dataset.json> <baseline-system.txt> <candidate-system.txt> [options]`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const config: CompareCommandConfig = {
|
|
426
|
+
datasetPath: tokens[1],
|
|
427
|
+
baselineSystemPath: tokens[2],
|
|
428
|
+
candidateSystemPath: tokens[3],
|
|
429
|
+
baselineName: "baseline",
|
|
430
|
+
candidateName: "candidate",
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
for (let i = 4; i < tokens.length; i++) {
|
|
434
|
+
const token = tokens[i];
|
|
435
|
+
switch (token) {
|
|
436
|
+
case "--baseline-name": {
|
|
437
|
+
config.baselineName = requireValue(tokens, i, token);
|
|
438
|
+
i += 1;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case "--candidate-name": {
|
|
442
|
+
config.candidateName = requireValue(tokens, i, token);
|
|
443
|
+
i += 1;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
case "--out": {
|
|
447
|
+
config.outPath = requireValue(tokens, i, token);
|
|
448
|
+
i += 1;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case "--max-cases": {
|
|
452
|
+
config.maxCases = parsePositiveInteger(requireValue(tokens, i, token), token);
|
|
453
|
+
i += 1;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case "--temperature": {
|
|
457
|
+
config.temperature = parseTemperature(requireValue(tokens, i, token));
|
|
458
|
+
i += 1;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
default:
|
|
462
|
+
throw new Error(`Unknown option for compare: ${token}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return config;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function extractAssistantText(message: AssistantMessage): string {
|
|
470
|
+
return message.content
|
|
471
|
+
.filter(
|
|
472
|
+
(block): block is Extract<AssistantMessage["content"][number], { type: "text" }> =>
|
|
473
|
+
block.type === "text",
|
|
474
|
+
)
|
|
475
|
+
.map((block) => block.text)
|
|
476
|
+
.join("\n")
|
|
477
|
+
.trim();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function clip(text: string, maxChars = 280): string {
|
|
481
|
+
if (text.length <= maxChars) {
|
|
482
|
+
return text;
|
|
483
|
+
}
|
|
484
|
+
return `${text.slice(0, maxChars)}...`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createEmptyUsage(): Usage {
|
|
488
|
+
return {
|
|
489
|
+
input: 0,
|
|
490
|
+
output: 0,
|
|
491
|
+
cacheRead: 0,
|
|
492
|
+
cacheWrite: 0,
|
|
493
|
+
totalTokens: 0,
|
|
494
|
+
cost: {
|
|
495
|
+
input: 0,
|
|
496
|
+
output: 0,
|
|
497
|
+
cacheRead: 0,
|
|
498
|
+
cacheWrite: 0,
|
|
499
|
+
total: 0,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function evaluateCase(
|
|
505
|
+
expected: EvalCaseDefinition,
|
|
506
|
+
output: string,
|
|
507
|
+
): {
|
|
508
|
+
scored: boolean;
|
|
509
|
+
pass: boolean;
|
|
510
|
+
checks: CaseCheckResult[];
|
|
511
|
+
} {
|
|
512
|
+
const checks: CaseCheckResult[] = [];
|
|
513
|
+
const outputLower = output.toLowerCase();
|
|
514
|
+
|
|
515
|
+
for (const term of expected.expectContains ?? []) {
|
|
516
|
+
const pass = outputLower.includes(term.toLowerCase());
|
|
517
|
+
checks.push({
|
|
518
|
+
check: "expectContains",
|
|
519
|
+
pass,
|
|
520
|
+
details: `contains ${JSON.stringify(term)}`,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const term of expected.expectNotContains ?? []) {
|
|
525
|
+
const pass = !outputLower.includes(term.toLowerCase());
|
|
526
|
+
checks.push({
|
|
527
|
+
check: "expectNotContains",
|
|
528
|
+
pass,
|
|
529
|
+
details: `does not contain ${JSON.stringify(term)}`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (expected.expectRegex) {
|
|
534
|
+
try {
|
|
535
|
+
const regex = new RegExp(expected.expectRegex, "m");
|
|
536
|
+
const pass = regex.test(output);
|
|
537
|
+
checks.push({
|
|
538
|
+
check: "expectRegex",
|
|
539
|
+
pass,
|
|
540
|
+
details: `matches /${expected.expectRegex}/m`,
|
|
541
|
+
});
|
|
542
|
+
} catch (error) {
|
|
543
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
544
|
+
checks.push({
|
|
545
|
+
check: "expectRegex",
|
|
546
|
+
pass: false,
|
|
547
|
+
details: `invalid regex ${JSON.stringify(expected.expectRegex)}: ${message}`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const scored = checks.length > 0;
|
|
553
|
+
const pass = scored ? checks.every((check) => check.pass) : true;
|
|
554
|
+
|
|
555
|
+
return { scored, pass, checks };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function mergeSystemPrompt(base: string | undefined, variant: string | undefined): string {
|
|
559
|
+
const parts = [base?.trim(), variant?.trim()].filter((part): part is string =>
|
|
560
|
+
Boolean(part && part.length > 0),
|
|
561
|
+
);
|
|
562
|
+
return parts.join("\n\n");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function formatPercent(value: number | null): string {
|
|
566
|
+
return value === null ? "n/a" : `${(value * 100).toFixed(1)}%`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function formatCurrency(value: number): string {
|
|
570
|
+
return `$${value.toFixed(4)}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function summarizeRun(report: EvalRunReport, reportPath: string): string {
|
|
574
|
+
const failed = report.cases
|
|
575
|
+
.filter((entry) => entry.scored && !entry.pass)
|
|
576
|
+
.map((entry) => entry.id);
|
|
577
|
+
|
|
578
|
+
const lines = [
|
|
579
|
+
"evalset run completed",
|
|
580
|
+
"",
|
|
581
|
+
`dataset: ${report.dataset.name}`,
|
|
582
|
+
`dataset path: ${report.dataset.path}`,
|
|
583
|
+
`model: ${report.model.provider}/${report.model.id}`,
|
|
584
|
+
`variant: ${report.variant.name}`,
|
|
585
|
+
`run: ${report.run.runId} (dataset ${shortHash(report.run.datasetHash)}, variant ${shortHash(report.run.variantHash)})`,
|
|
586
|
+
`cases: ${report.totals.cases} total, ${report.totals.scoredCases} scored`,
|
|
587
|
+
`pass: ${report.totals.passedCases}/${report.totals.scoredCases} (${formatPercent(report.totals.passRate)})`,
|
|
588
|
+
`latency: ${report.totals.avgLatencyMs.toFixed(0)}ms avg, ${(report.totals.totalLatencyMs / 1000).toFixed(2)}s total`,
|
|
589
|
+
`tokens: in=${report.totals.usage.input}, out=${report.totals.usage.output}, total=${report.totals.usage.totalTokens}`,
|
|
590
|
+
`cost: ${formatCurrency(report.totals.usage.cost.total)}`,
|
|
591
|
+
`report: ${reportPath}`,
|
|
592
|
+
];
|
|
593
|
+
|
|
594
|
+
if (failed.length > 0) {
|
|
595
|
+
lines.push(`failed cases: ${failed.join(", ")}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return lines.join("\n");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function summarizeCompare(report: EvalCompareReport, reportPath: string): string {
|
|
602
|
+
return [
|
|
603
|
+
"evalset compare completed",
|
|
604
|
+
"",
|
|
605
|
+
`dataset: ${report.dataset.name}`,
|
|
606
|
+
`model: ${report.model.provider}/${report.model.id}`,
|
|
607
|
+
`run: ${report.run.runId} (dataset ${shortHash(report.run.datasetHash)})`,
|
|
608
|
+
`baseline: ${report.baseline.variant.name} -> ${formatPercent(report.baseline.totals.passRate)} (run ${report.run.baselineRunId})`,
|
|
609
|
+
`candidate: ${report.candidate.variant.name} -> ${formatPercent(report.candidate.totals.passRate)} (run ${report.run.candidateRunId})`,
|
|
610
|
+
`delta pass rate: ${formatPercent(report.delta.passRate)}`,
|
|
611
|
+
`delta avg latency: ${report.delta.avgLatencyMs.toFixed(0)}ms`,
|
|
612
|
+
`delta total cost: ${formatCurrency(report.delta.totalCost)}`,
|
|
613
|
+
`report: ${reportPath}`,
|
|
614
|
+
].join("\n");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function sanitizeSlug(value: string): string {
|
|
618
|
+
const slug = value
|
|
619
|
+
.toLowerCase()
|
|
620
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
621
|
+
.replace(/^-+|-+$/g, "");
|
|
622
|
+
return slug.length > 0 ? slug : "evalset";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function timestampSlug(): string {
|
|
626
|
+
const now = new Date();
|
|
627
|
+
const pad = (v: number): string => v.toString().padStart(2, "0");
|
|
628
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}T${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function defaultRunReportPath(cwd: string, datasetName: string, variantName: string): string {
|
|
632
|
+
return resolve(
|
|
633
|
+
cwd,
|
|
634
|
+
".evalset",
|
|
635
|
+
"reports",
|
|
636
|
+
`run-${sanitizeSlug(datasetName)}-${sanitizeSlug(variantName)}-${timestampSlug()}.json`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function defaultCompareReportPath(cwd: string, datasetName: string): string {
|
|
641
|
+
return resolve(
|
|
642
|
+
cwd,
|
|
643
|
+
".evalset",
|
|
644
|
+
"reports",
|
|
645
|
+
`compare-${sanitizeSlug(datasetName)}-${timestampSlug()}.json`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function writeReportFile(path: string, data: unknown): Promise<string> {
|
|
650
|
+
await mkdir(dirname(path), { recursive: true });
|
|
651
|
+
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
652
|
+
return path;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function evaluateVariant(args: {
|
|
656
|
+
ctx: ExtensionCommandContext;
|
|
657
|
+
model: Model;
|
|
658
|
+
datasetPath: string;
|
|
659
|
+
datasetName: string;
|
|
660
|
+
datasetHash: string;
|
|
661
|
+
casesHash: string;
|
|
662
|
+
cases: EvalCaseDefinition[];
|
|
663
|
+
variant: VariantDefinition;
|
|
664
|
+
apiKey?: string;
|
|
665
|
+
temperature?: number;
|
|
666
|
+
}): Promise<EvalRunReport> {
|
|
667
|
+
const {
|
|
668
|
+
ctx,
|
|
669
|
+
model,
|
|
670
|
+
datasetPath,
|
|
671
|
+
datasetName,
|
|
672
|
+
datasetHash,
|
|
673
|
+
casesHash,
|
|
674
|
+
cases,
|
|
675
|
+
variant,
|
|
676
|
+
apiKey,
|
|
677
|
+
temperature,
|
|
678
|
+
} = args;
|
|
679
|
+
const runId = randomUUID();
|
|
680
|
+
const startedAt = new Date().toISOString();
|
|
681
|
+
const variantHash = hashObject(variant);
|
|
682
|
+
const modelKey = `${model.provider}/${model.id}`;
|
|
683
|
+
const results: EvalCaseResult[] = [];
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
for (let index = 0; index < cases.length; index += 1) {
|
|
687
|
+
const entry = cases[index];
|
|
688
|
+
const id = entry.id?.trim() || `case-${index + 1}`;
|
|
689
|
+
|
|
690
|
+
if (ctx.hasUI) {
|
|
691
|
+
ctx.ui.setStatus(
|
|
692
|
+
COMMAND_NAME,
|
|
693
|
+
`${COMMAND_NAME}: ${variant.name} ${index + 1}/${cases.length}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const context: Context = {
|
|
698
|
+
systemPrompt: variant.systemPrompt,
|
|
699
|
+
messages: [
|
|
700
|
+
{
|
|
701
|
+
role: "user",
|
|
702
|
+
content: entry.input,
|
|
703
|
+
timestamp: Date.now(),
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const startedAt = Date.now();
|
|
709
|
+
try {
|
|
710
|
+
const response = await complete(model, context, {
|
|
711
|
+
apiKey,
|
|
712
|
+
temperature,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const outputText = extractAssistantText(response);
|
|
716
|
+
const evaluation = evaluateCase(entry, outputText);
|
|
717
|
+
|
|
718
|
+
results.push({
|
|
719
|
+
id,
|
|
720
|
+
input: entry.input,
|
|
721
|
+
scored: evaluation.scored,
|
|
722
|
+
pass: evaluation.pass,
|
|
723
|
+
checks: evaluation.checks,
|
|
724
|
+
failedChecks: evaluation.checks
|
|
725
|
+
.filter((check) => !check.pass)
|
|
726
|
+
.map((check) => check.details),
|
|
727
|
+
outputPreview: clip(outputText),
|
|
728
|
+
latencyMs: Date.now() - startedAt,
|
|
729
|
+
stopReason: response.stopReason,
|
|
730
|
+
usage: response.usage,
|
|
731
|
+
});
|
|
732
|
+
} catch (error) {
|
|
733
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
734
|
+
results.push({
|
|
735
|
+
id,
|
|
736
|
+
input: entry.input,
|
|
737
|
+
scored: true,
|
|
738
|
+
pass: false,
|
|
739
|
+
checks: [
|
|
740
|
+
{
|
|
741
|
+
check: "request",
|
|
742
|
+
pass: false,
|
|
743
|
+
details: message,
|
|
744
|
+
},
|
|
745
|
+
],
|
|
746
|
+
failedChecks: [message],
|
|
747
|
+
outputPreview: "",
|
|
748
|
+
latencyMs: Date.now() - startedAt,
|
|
749
|
+
stopReason: "error",
|
|
750
|
+
usage: createEmptyUsage(),
|
|
751
|
+
error: message,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
} finally {
|
|
756
|
+
if (ctx.hasUI) {
|
|
757
|
+
ctx.ui.setStatus(COMMAND_NAME, undefined);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const scoredCases = results.filter((result) => result.scored);
|
|
762
|
+
const passedCases = scoredCases.filter((result) => result.pass);
|
|
763
|
+
|
|
764
|
+
const totalLatencyMs = results.reduce((sum, result) => sum + result.latencyMs, 0);
|
|
765
|
+
const usage = results.reduce<Usage>((sum, result) => {
|
|
766
|
+
sum.input += result.usage.input;
|
|
767
|
+
sum.output += result.usage.output;
|
|
768
|
+
sum.cacheRead += result.usage.cacheRead;
|
|
769
|
+
sum.cacheWrite += result.usage.cacheWrite;
|
|
770
|
+
sum.totalTokens += result.usage.totalTokens;
|
|
771
|
+
sum.cost.input += result.usage.cost.input;
|
|
772
|
+
sum.cost.output += result.usage.cost.output;
|
|
773
|
+
sum.cost.cacheRead += result.usage.cost.cacheRead;
|
|
774
|
+
sum.cost.cacheWrite += result.usage.cost.cacheWrite;
|
|
775
|
+
sum.cost.total += result.usage.cost.total;
|
|
776
|
+
return sum;
|
|
777
|
+
}, createEmptyUsage());
|
|
778
|
+
|
|
779
|
+
const passRate = scoredCases.length > 0 ? passedCases.length / scoredCases.length : null;
|
|
780
|
+
|
|
781
|
+
const finishedAt = new Date().toISOString();
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
kind: "evalset-run",
|
|
785
|
+
createdAt: finishedAt,
|
|
786
|
+
run: {
|
|
787
|
+
runId,
|
|
788
|
+
startedAt,
|
|
789
|
+
finishedAt,
|
|
790
|
+
modelKey,
|
|
791
|
+
temperature: temperature ?? null,
|
|
792
|
+
datasetHash,
|
|
793
|
+
casesHash,
|
|
794
|
+
variantHash,
|
|
795
|
+
},
|
|
796
|
+
dataset: {
|
|
797
|
+
name: datasetName,
|
|
798
|
+
path: datasetPath,
|
|
799
|
+
},
|
|
800
|
+
model: {
|
|
801
|
+
provider: model.provider,
|
|
802
|
+
id: model.id,
|
|
803
|
+
api: model.api,
|
|
804
|
+
},
|
|
805
|
+
variant,
|
|
806
|
+
totals: {
|
|
807
|
+
cases: results.length,
|
|
808
|
+
scoredCases: scoredCases.length,
|
|
809
|
+
passedCases: passedCases.length,
|
|
810
|
+
failedCases: scoredCases.length - passedCases.length,
|
|
811
|
+
passRate,
|
|
812
|
+
totalLatencyMs,
|
|
813
|
+
avgLatencyMs: results.length > 0 ? totalLatencyMs / results.length : 0,
|
|
814
|
+
usage,
|
|
815
|
+
},
|
|
816
|
+
cases: results,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function postMessage(pi: ExtensionAPI, message: string, details?: unknown): void {
|
|
821
|
+
pi.sendMessage({
|
|
822
|
+
customType: CUSTOM_MESSAGE_TYPE,
|
|
823
|
+
content: message,
|
|
824
|
+
display: true,
|
|
825
|
+
details,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function ensureActiveModel(ctx: ExtensionCommandContext): Model {
|
|
830
|
+
if (!ctx.model) {
|
|
831
|
+
throw new Error("No active model. Select one first via /model.");
|
|
832
|
+
}
|
|
833
|
+
return ctx.model;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function handleInit(
|
|
837
|
+
pi: ExtensionAPI,
|
|
838
|
+
ctx: ExtensionCommandContext,
|
|
839
|
+
tokens: string[],
|
|
840
|
+
): Promise<void> {
|
|
841
|
+
let targetPath = "examples/fixed-task-set.json";
|
|
842
|
+
let force = false;
|
|
843
|
+
|
|
844
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
845
|
+
const token = tokens[i];
|
|
846
|
+
if (token === "--force") {
|
|
847
|
+
force = true;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (token.startsWith("--")) {
|
|
852
|
+
throw new Error(`Unknown option for init: ${token}`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (targetPath !== "examples/fixed-task-set.json") {
|
|
856
|
+
throw new Error("init accepts at most one dataset path argument.");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
targetPath = token;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const absolutePath = toAbsolutePath(ctx.cwd, targetPath);
|
|
863
|
+
const sample = {
|
|
864
|
+
name: "maintainer-clarity-smoke",
|
|
865
|
+
systemPrompt: "Answer concisely and explicitly.",
|
|
866
|
+
cases: [
|
|
867
|
+
{
|
|
868
|
+
id: "fixed-task-set-definition",
|
|
869
|
+
input: "In one sentence: what does fixed task set mean for evals?",
|
|
870
|
+
expectContains: ["same tasks"],
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
id: "extension-gaps",
|
|
874
|
+
input: "List two things an extension may still need for reproducible eval workflows.",
|
|
875
|
+
expectContains: ["trace", "reproducibility"],
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
881
|
+
await writeFile(absolutePath, `${JSON.stringify(sample, null, 2)}\n`, {
|
|
882
|
+
encoding: "utf8",
|
|
883
|
+
flag: force ? "w" : "wx",
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
const message = `Created evalset dataset template: ${absolutePath}`;
|
|
887
|
+
if (ctx.hasUI) {
|
|
888
|
+
ctx.ui.notify(message, "info");
|
|
889
|
+
}
|
|
890
|
+
postMessage(pi, message, { path: absolutePath });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function handleRun(
|
|
894
|
+
pi: ExtensionAPI,
|
|
895
|
+
ctx: ExtensionCommandContext,
|
|
896
|
+
tokens: string[],
|
|
897
|
+
): Promise<void> {
|
|
898
|
+
const config = parseRunCommand(tokens);
|
|
899
|
+
const model = ensureActiveModel(ctx);
|
|
900
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
901
|
+
|
|
902
|
+
const loaded = await loadDataset(ctx.cwd, config.datasetPath);
|
|
903
|
+
const datasetName = loaded.dataset.name?.trim() || sanitizeSlug(config.datasetPath);
|
|
904
|
+
|
|
905
|
+
const datasetCases =
|
|
906
|
+
config.maxCases && config.maxCases < loaded.dataset.cases.length
|
|
907
|
+
? loaded.dataset.cases.slice(0, config.maxCases)
|
|
908
|
+
: loaded.dataset.cases;
|
|
909
|
+
const casesHash = hashObject(datasetCases);
|
|
910
|
+
|
|
911
|
+
let variantPrompt = loaded.dataset.systemPrompt;
|
|
912
|
+
let variantSource = "dataset.systemPrompt";
|
|
913
|
+
|
|
914
|
+
if (config.systemFilePath) {
|
|
915
|
+
const system = await loadTextFile(ctx.cwd, config.systemFilePath);
|
|
916
|
+
variantPrompt = mergeSystemPrompt(loaded.dataset.systemPrompt, system.text);
|
|
917
|
+
variantSource = `dataset.systemPrompt + file:${system.absolutePath}`;
|
|
918
|
+
} else if (config.systemText) {
|
|
919
|
+
variantPrompt = mergeSystemPrompt(loaded.dataset.systemPrompt, config.systemText);
|
|
920
|
+
variantSource = "dataset.systemPrompt + --system-text";
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const variant: VariantDefinition = {
|
|
924
|
+
name: config.variantName,
|
|
925
|
+
systemPrompt: variantPrompt ?? "",
|
|
926
|
+
source: variantSource,
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const runReport = await evaluateVariant({
|
|
930
|
+
ctx,
|
|
931
|
+
model,
|
|
932
|
+
datasetPath: loaded.absolutePath,
|
|
933
|
+
datasetName,
|
|
934
|
+
datasetHash: loaded.hash,
|
|
935
|
+
casesHash,
|
|
936
|
+
cases: datasetCases,
|
|
937
|
+
variant,
|
|
938
|
+
apiKey,
|
|
939
|
+
temperature: config.temperature,
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const outputPath = config.outPath
|
|
943
|
+
? toAbsolutePath(ctx.cwd, config.outPath)
|
|
944
|
+
: defaultRunReportPath(ctx.cwd, datasetName, config.variantName);
|
|
945
|
+
const reportPath = await writeReportFile(outputPath, runReport);
|
|
946
|
+
|
|
947
|
+
const summary = summarizeRun(runReport, reportPath);
|
|
948
|
+
if (ctx.hasUI) {
|
|
949
|
+
ctx.ui.notify(`evalset run finished: ${formatPercent(runReport.totals.passRate)}`, "info");
|
|
950
|
+
}
|
|
951
|
+
postMessage(pi, summary, {
|
|
952
|
+
reportPath,
|
|
953
|
+
run: {
|
|
954
|
+
runId: runReport.run.runId,
|
|
955
|
+
datasetHash: runReport.run.datasetHash,
|
|
956
|
+
casesHash: runReport.run.casesHash,
|
|
957
|
+
variantHash: runReport.run.variantHash,
|
|
958
|
+
modelKey: runReport.run.modelKey,
|
|
959
|
+
},
|
|
960
|
+
totals: {
|
|
961
|
+
passRate: runReport.totals.passRate,
|
|
962
|
+
avgLatencyMs: runReport.totals.avgLatencyMs,
|
|
963
|
+
totalCost: runReport.totals.usage.cost.total,
|
|
964
|
+
scoredCases: runReport.totals.scoredCases,
|
|
965
|
+
passedCases: runReport.totals.passedCases,
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function handleCompare(
|
|
971
|
+
pi: ExtensionAPI,
|
|
972
|
+
ctx: ExtensionCommandContext,
|
|
973
|
+
tokens: string[],
|
|
974
|
+
): Promise<void> {
|
|
975
|
+
const config = parseCompareCommand(tokens);
|
|
976
|
+
const model = ensureActiveModel(ctx);
|
|
977
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
978
|
+
|
|
979
|
+
const loaded = await loadDataset(ctx.cwd, config.datasetPath);
|
|
980
|
+
const datasetName = loaded.dataset.name?.trim() || sanitizeSlug(config.datasetPath);
|
|
981
|
+
|
|
982
|
+
const datasetCases =
|
|
983
|
+
config.maxCases && config.maxCases < loaded.dataset.cases.length
|
|
984
|
+
? loaded.dataset.cases.slice(0, config.maxCases)
|
|
985
|
+
: loaded.dataset.cases;
|
|
986
|
+
const casesHash = hashObject(datasetCases);
|
|
987
|
+
|
|
988
|
+
const baselineSystem = await loadTextFile(ctx.cwd, config.baselineSystemPath);
|
|
989
|
+
const candidateSystem = await loadTextFile(ctx.cwd, config.candidateSystemPath);
|
|
990
|
+
|
|
991
|
+
const baselineVariant: VariantDefinition = {
|
|
992
|
+
name: config.baselineName,
|
|
993
|
+
systemPrompt: mergeSystemPrompt(loaded.dataset.systemPrompt, baselineSystem.text),
|
|
994
|
+
source: `dataset.systemPrompt + file:${baselineSystem.absolutePath}`,
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const candidateVariant: VariantDefinition = {
|
|
998
|
+
name: config.candidateName,
|
|
999
|
+
systemPrompt: mergeSystemPrompt(loaded.dataset.systemPrompt, candidateSystem.text),
|
|
1000
|
+
source: `dataset.systemPrompt + file:${candidateSystem.absolutePath}`,
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const compareRunId = randomUUID();
|
|
1004
|
+
const compareStartedAt = new Date().toISOString();
|
|
1005
|
+
|
|
1006
|
+
const baseline = await evaluateVariant({
|
|
1007
|
+
ctx,
|
|
1008
|
+
model,
|
|
1009
|
+
datasetPath: loaded.absolutePath,
|
|
1010
|
+
datasetName,
|
|
1011
|
+
datasetHash: loaded.hash,
|
|
1012
|
+
casesHash,
|
|
1013
|
+
cases: datasetCases,
|
|
1014
|
+
variant: baselineVariant,
|
|
1015
|
+
apiKey,
|
|
1016
|
+
temperature: config.temperature,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
const candidate = await evaluateVariant({
|
|
1020
|
+
ctx,
|
|
1021
|
+
model,
|
|
1022
|
+
datasetPath: loaded.absolutePath,
|
|
1023
|
+
datasetName,
|
|
1024
|
+
datasetHash: loaded.hash,
|
|
1025
|
+
casesHash,
|
|
1026
|
+
cases: datasetCases,
|
|
1027
|
+
variant: candidateVariant,
|
|
1028
|
+
apiKey,
|
|
1029
|
+
temperature: config.temperature,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const deltaPassRate =
|
|
1033
|
+
baseline.totals.passRate !== null && candidate.totals.passRate !== null
|
|
1034
|
+
? candidate.totals.passRate - baseline.totals.passRate
|
|
1035
|
+
: null;
|
|
1036
|
+
|
|
1037
|
+
const compareFinishedAt = new Date().toISOString();
|
|
1038
|
+
|
|
1039
|
+
const compareReport: EvalCompareReport = {
|
|
1040
|
+
kind: "evalset-compare",
|
|
1041
|
+
createdAt: compareFinishedAt,
|
|
1042
|
+
run: {
|
|
1043
|
+
runId: compareRunId,
|
|
1044
|
+
startedAt: compareStartedAt,
|
|
1045
|
+
finishedAt: compareFinishedAt,
|
|
1046
|
+
modelKey: `${model.provider}/${model.id}`,
|
|
1047
|
+
temperature: config.temperature ?? null,
|
|
1048
|
+
datasetHash: loaded.hash,
|
|
1049
|
+
casesHash,
|
|
1050
|
+
baselineRunId: baseline.run.runId,
|
|
1051
|
+
candidateRunId: candidate.run.runId,
|
|
1052
|
+
baselineVariantHash: baseline.run.variantHash,
|
|
1053
|
+
candidateVariantHash: candidate.run.variantHash,
|
|
1054
|
+
},
|
|
1055
|
+
dataset: {
|
|
1056
|
+
name: datasetName,
|
|
1057
|
+
path: loaded.absolutePath,
|
|
1058
|
+
},
|
|
1059
|
+
model: {
|
|
1060
|
+
provider: model.provider,
|
|
1061
|
+
id: model.id,
|
|
1062
|
+
api: model.api,
|
|
1063
|
+
},
|
|
1064
|
+
baseline,
|
|
1065
|
+
candidate,
|
|
1066
|
+
delta: {
|
|
1067
|
+
passRate: deltaPassRate,
|
|
1068
|
+
avgLatencyMs: candidate.totals.avgLatencyMs - baseline.totals.avgLatencyMs,
|
|
1069
|
+
totalCost: candidate.totals.usage.cost.total - baseline.totals.usage.cost.total,
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
const outputPath = config.outPath
|
|
1074
|
+
? toAbsolutePath(ctx.cwd, config.outPath)
|
|
1075
|
+
: defaultCompareReportPath(ctx.cwd, datasetName);
|
|
1076
|
+
const reportPath = await writeReportFile(outputPath, compareReport);
|
|
1077
|
+
|
|
1078
|
+
const summary = summarizeCompare(compareReport, reportPath);
|
|
1079
|
+
if (ctx.hasUI) {
|
|
1080
|
+
ctx.ui.notify("evalset compare finished", "info");
|
|
1081
|
+
}
|
|
1082
|
+
postMessage(pi, summary, {
|
|
1083
|
+
reportPath,
|
|
1084
|
+
run: {
|
|
1085
|
+
runId: compareReport.run.runId,
|
|
1086
|
+
datasetHash: compareReport.run.datasetHash,
|
|
1087
|
+
casesHash: compareReport.run.casesHash,
|
|
1088
|
+
baselineRunId: compareReport.run.baselineRunId,
|
|
1089
|
+
candidateRunId: compareReport.run.candidateRunId,
|
|
1090
|
+
},
|
|
1091
|
+
delta: compareReport.delta,
|
|
1092
|
+
baseline: {
|
|
1093
|
+
passRate: compareReport.baseline.totals.passRate,
|
|
1094
|
+
totalCost: compareReport.baseline.totals.usage.cost.total,
|
|
1095
|
+
},
|
|
1096
|
+
candidate: {
|
|
1097
|
+
passRate: compareReport.candidate.totals.passRate,
|
|
1098
|
+
totalCost: compareReport.candidate.totals.usage.cost.total,
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function formatError(error: unknown): string {
|
|
1104
|
+
if (error instanceof Error) {
|
|
1105
|
+
return error.message;
|
|
1106
|
+
}
|
|
1107
|
+
return String(error);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export default function (pi: ExtensionAPI): void {
|
|
1111
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
1112
|
+
description: "Run fixed-task-set evals and compare prompt/system variants",
|
|
1113
|
+
handler: async (args, ctx) => {
|
|
1114
|
+
const tokens = parseArgs(args);
|
|
1115
|
+
const subcommand = tokens[0] ?? "help";
|
|
1116
|
+
|
|
1117
|
+
try {
|
|
1118
|
+
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
1119
|
+
postMessage(pi, HELP_TEXT);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (subcommand === "init") {
|
|
1124
|
+
await handleInit(pi, ctx, [subcommand, ...tokens.slice(1)]);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (subcommand === "run") {
|
|
1129
|
+
await handleRun(pi, ctx, [subcommand, ...tokens.slice(1)]);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (subcommand === "compare") {
|
|
1134
|
+
await handleCompare(pi, ctx, [subcommand, ...tokens.slice(1)]);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
throw new Error(`Unknown subcommand: ${subcommand}`);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
const message = `${COMMAND_NAME} error: ${formatError(error)}`;
|
|
1141
|
+
if (ctx.hasUI) {
|
|
1142
|
+
ctx.ui.notify(message, "error");
|
|
1143
|
+
}
|
|
1144
|
+
postMessage(pi, `${message}\n\n${HELP_TEXT}`);
|
|
1145
|
+
}
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
}
|