@crowandraven/evalkeeper 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +6 -2
- package/bin/evalkeeper.mjs +9 -0
- package/dist/attention.d.ts +49 -0
- package/dist/attention.js +49 -0
- package/dist/attention.js.map +1 -0
- package/dist/board.d.ts +57 -0
- package/dist/board.js +2 -0
- package/dist/board.js.map +1 -0
- package/dist/cli/addPages.d.ts +79 -0
- package/dist/cli/addPages.js +181 -0
- package/dist/cli/addPages.js.map +1 -0
- package/dist/cli/main.d.ts +18 -0
- package/dist/cli/main.js +115 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/common.d.ts +72 -0
- package/dist/common.js +276 -0
- package/dist/common.js.map +1 -0
- package/dist/compare.d.ts +85 -0
- package/dist/compare.js +185 -0
- package/dist/compare.js.map +1 -0
- package/dist/decisions.d.ts +84 -0
- package/dist/decisions.js +146 -0
- package/dist/decisions.js.map +1 -0
- package/dist/feedback.d.ts +114 -0
- package/dist/feedback.js +116 -0
- package/dist/feedback.js.map +1 -0
- package/dist/format.d.ts +6 -0
- package/dist/format.js +22 -0
- package/dist/format.js.map +1 -0
- package/dist/glossary.d.ts +46 -0
- package/dist/glossary.js +45 -0
- package/dist/glossary.js.map +1 -0
- package/dist/health.d.ts +40 -0
- package/dist/health.js +101 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +15 -0
- package/dist/labels.js.map +1 -0
- package/dist/legs.d.ts +23 -0
- package/dist/legs.js +22 -0
- package/dist/legs.js.map +1 -0
- package/dist/memoryStore.d.ts +21 -0
- package/dist/memoryStore.js +228 -0
- package/dist/memoryStore.js.map +1 -0
- package/dist/models.d.ts +76 -0
- package/dist/models.js +56 -0
- package/dist/models.js.map +1 -0
- package/dist/prune.d.ts +48 -0
- package/dist/prune.js +77 -0
- package/dist/prune.js.map +1 -0
- package/dist/quickSet.d.ts +26 -0
- package/dist/quickSet.js +44 -0
- package/dist/quickSet.js.map +1 -0
- package/dist/runner.d.ts +202 -0
- package/dist/runner.js +734 -0
- package/dist/runner.js.map +1 -0
- package/dist/settings.d.ts +196 -0
- package/dist/settings.js +272 -0
- package/dist/settings.js.map +1 -0
- package/dist/stableJson.d.ts +11 -0
- package/dist/stableJson.js +15 -0
- package/dist/stableJson.js.map +1 -0
- package/dist/stats.d.ts +98 -0
- package/dist/stats.js +119 -0
- package/dist/stats.js.map +1 -0
- package/dist/store.d.ts +151 -0
- package/dist/store.js +3 -0
- package/dist/store.js.map +1 -0
- package/dist/suggest.d.ts +75 -0
- package/dist/suggest.js +152 -0
- package/dist/suggest.js.map +1 -0
- package/dist/summary.d.ts +51 -0
- package/dist/summary.js +61 -0
- package/dist/summary.js.map +1 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +362 -0
- package/dist/testing.js.map +1 -0
- package/dist/timeout.d.ts +2 -0
- package/dist/timeout.js +15 -0
- package/dist/timeout.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/upkeepRules.d.ts +28 -0
- package/dist/upkeepRules.js +40 -0
- package/dist/upkeepRules.js.map +1 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.js +18 -0
- package/dist/util.js.map +1 -0
- package/dist/weekly.d.ts +80 -0
- package/dist/weekly.js +113 -0
- package/dist/weekly.js.map +1 -0
- package/package.json +81 -4
- package/src/attention.ts +89 -0
- package/src/board.ts +58 -0
- package/src/cli/addPages.ts +256 -0
- package/src/cli/main.ts +125 -0
- package/src/common.ts +338 -0
- package/src/compare.ts +261 -0
- package/src/decisions.ts +230 -0
- package/src/feedback.ts +225 -0
- package/src/format.ts +19 -0
- package/src/glossary.ts +75 -0
- package/src/health.ts +149 -0
- package/src/index.ts +33 -0
- package/src/labels.ts +18 -0
- package/src/legs.ts +38 -0
- package/src/memoryStore.ts +268 -0
- package/src/models.ts +127 -0
- package/src/prune.ts +95 -0
- package/src/quickSet.ts +55 -0
- package/src/runner.ts +1010 -0
- package/src/settings.ts +441 -0
- package/src/stableJson.ts +24 -0
- package/src/stats.ts +215 -0
- package/src/store.ts +172 -0
- package/src/suggest.ts +247 -0
- package/src/summary.ts +109 -0
- package/src/testing.ts +415 -0
- package/src/timeout.ts +14 -0
- package/src/types.ts +258 -0
- package/src/upkeepRules.ts +46 -0
- package/src/util.ts +17 -0
- package/src/weekly.ts +169 -0
- package/starter/index.ts +86 -0
- package/templates/app/api/cron/evals-weekly/route.ts +24 -0
- package/templates/app/evals/[suite]/page.tsx +449 -0
- package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
- package/templates/app/evals/feedback/page.tsx +219 -0
- package/templates/app/evals/page.tsx +313 -0
- package/templates/components/evalkeeper/AddCase.tsx +62 -0
- package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
- package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
- package/templates/components/evalkeeper/Fields.tsx +110 -0
- package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
- package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
- package/templates/components/evalkeeper/Submit.tsx +58 -0
- package/templates/components/evalkeeper/Thumbs.tsx +117 -0
- package/templates/components/evalkeeper/Tip.tsx +98 -0
- package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
- package/templates/components/evalkeeper/evalkeeper.css +71 -0
- package/templates/components/evalkeeper/ui.tsx +103 -0
- package/templates/lib/evalkeeper/actions.ts +263 -0
- package/templates/lib/evalkeeper/cronAuth.ts +18 -0
- package/templates/lib/evalkeeper/paths.ts +11 -0
- package/templates/lib/evalkeeper/time.ts +4 -0
- package/templates/lib/evalkeeper/weekly.ts +51 -0
package/src/testing.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contracts every store must meet, as Vitest tests.
|
|
3
|
+
*
|
|
4
|
+
* @module evalkeeper/testing
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { FeedbackStore, NewRating } from "./feedback.js";
|
|
9
|
+
import { createRunner } from "./runner.js";
|
|
10
|
+
import {
|
|
11
|
+
SettingsConflict,
|
|
12
|
+
type Settings,
|
|
13
|
+
type SettingsStore,
|
|
14
|
+
} from "./settings.js";
|
|
15
|
+
import type { EvalStore, NewCase } from "./store.js";
|
|
16
|
+
import type { Suite } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The contract every EvalStore must meet, as Vitest tests. Run it against
|
|
20
|
+
* your store to know the runner will work on it. `make` must return an
|
|
21
|
+
* empty store each time it's called.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { describeStoreContract } from "@crowandraven/evalkeeper/testing";
|
|
26
|
+
*
|
|
27
|
+
* describeStoreContract("my store", () => myStore(freshDatabase()));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function describeStoreContract(
|
|
31
|
+
name: string,
|
|
32
|
+
make: () => EvalStore | Promise<EvalStore>,
|
|
33
|
+
) {
|
|
34
|
+
const newCase = (suite: string, over: Partial<NewCase> = {}): NewCase => ({
|
|
35
|
+
suite,
|
|
36
|
+
input: { text: "hello", nested: { n: 1 } },
|
|
37
|
+
tags: ["a", "b"],
|
|
38
|
+
split: "DEV",
|
|
39
|
+
status: "ACTIVE",
|
|
40
|
+
source: "OWNER",
|
|
41
|
+
sourceRef: null,
|
|
42
|
+
note: null,
|
|
43
|
+
labels: [
|
|
44
|
+
{
|
|
45
|
+
kind: "EXPECTED",
|
|
46
|
+
status: "CONFIRMED",
|
|
47
|
+
value: { want: "HELLO" },
|
|
48
|
+
raterRole: "OWNER",
|
|
49
|
+
raterId: null,
|
|
50
|
+
note: null,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
...over,
|
|
54
|
+
});
|
|
55
|
+
const newRun = (suite: string) => ({
|
|
56
|
+
suite,
|
|
57
|
+
model: "model-a",
|
|
58
|
+
promptVersion: "v1",
|
|
59
|
+
reasoningEffort: null,
|
|
60
|
+
set: "ALL" as const,
|
|
61
|
+
repeats: 1,
|
|
62
|
+
datasetVersion: null,
|
|
63
|
+
graderVersion: "1",
|
|
64
|
+
estimatedCostUsd: null,
|
|
65
|
+
startedBy: null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe(`EvalStore contract: ${name}`, () => {
|
|
69
|
+
it("keeps a case's input, tags, and answers as given", async () => {
|
|
70
|
+
const store = await make();
|
|
71
|
+
const c = await store.createCase(newCase("s"));
|
|
72
|
+
const [listed] = await store.listCases("s");
|
|
73
|
+
expect(listed?.id).toBe(c.id);
|
|
74
|
+
expect(listed?.input).toEqual({ text: "hello", nested: { n: 1 } });
|
|
75
|
+
expect(listed?.tags).toEqual(["a", "b"]);
|
|
76
|
+
expect(listed?.labels).toHaveLength(1);
|
|
77
|
+
expect(listed?.labels[0]).toMatchObject({
|
|
78
|
+
kind: "EXPECTED",
|
|
79
|
+
status: "CONFIRMED",
|
|
80
|
+
value: { want: "HELLO" },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lists a suite's cases oldest first, leaving out retired ones", async () => {
|
|
85
|
+
const store = await make();
|
|
86
|
+
const first = await store.createCase(newCase("s"));
|
|
87
|
+
const second = await store.createCase(newCase("s"));
|
|
88
|
+
await store.createCase(newCase("other"));
|
|
89
|
+
await store.setCaseStatus(first.id, "RETIRED");
|
|
90
|
+
expect((await store.listCases("s")).map((c) => c.id)).toEqual([
|
|
91
|
+
second.id,
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("adds answers without overwriting, and changes their status", async () => {
|
|
96
|
+
const store = await make();
|
|
97
|
+
const c = await store.createCase(newCase("s", { labels: [] }));
|
|
98
|
+
const l = await store.addLabel(c.id, {
|
|
99
|
+
kind: "EXPECTED",
|
|
100
|
+
status: "PENDING",
|
|
101
|
+
value: { want: "X" },
|
|
102
|
+
raterRole: "MODEL",
|
|
103
|
+
raterId: null,
|
|
104
|
+
note: null,
|
|
105
|
+
});
|
|
106
|
+
await store.setLabelStatus(l.id, "CONFIRMED");
|
|
107
|
+
const [listed] = await store.listCases("s");
|
|
108
|
+
expect(listed?.labels).toHaveLength(1);
|
|
109
|
+
expect(listed?.labels[0]?.status).toBe("CONFIRMED");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("starts runs as running, lists them newest first, and filters", async () => {
|
|
113
|
+
const store = await make();
|
|
114
|
+
const a = await store.createRun(newRun("s"));
|
|
115
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
116
|
+
const b = await store.createRun(newRun("s"));
|
|
117
|
+
expect(a.status).toBe("RUNNING");
|
|
118
|
+
expect((await store.listRuns("s")).map((r) => r.id)).toEqual([
|
|
119
|
+
b.id,
|
|
120
|
+
a.id,
|
|
121
|
+
]);
|
|
122
|
+
expect(await store.listRuns("s", { limit: 1 })).toHaveLength(1);
|
|
123
|
+
await store.updateRun(a.id, { status: "SUCCEEDED" });
|
|
124
|
+
expect(
|
|
125
|
+
(await store.listRuns("s", { status: "SUCCEEDED" })).map((r) => r.id),
|
|
126
|
+
).toEqual([a.id]);
|
|
127
|
+
expect(await store.getRun("missing")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("keeps the temperature a run was tested at", async () => {
|
|
131
|
+
const store = await make();
|
|
132
|
+
const warm = await store.createRun({ ...newRun("s"), temperature: 0.7 });
|
|
133
|
+
const none = await store.createRun({ ...newRun("s"), temperature: null });
|
|
134
|
+
expect((await store.getRun(warm.id))?.temperature).toBe(0.7);
|
|
135
|
+
expect((await store.getRun(none.id))?.temperature ?? null).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("updates a run only while it has the status it's guarded by", async () => {
|
|
139
|
+
const store = await make();
|
|
140
|
+
const r = await store.createRun(newRun("s"));
|
|
141
|
+
await store.updateRun(r.id, { status: "FAILED" }, { status: "RUNNING" });
|
|
142
|
+
await store.updateRun(
|
|
143
|
+
r.id,
|
|
144
|
+
{ status: "SUCCEEDED" },
|
|
145
|
+
{ status: "RUNNING" },
|
|
146
|
+
);
|
|
147
|
+
expect((await store.getRun(r.id))?.status).toBe("FAILED");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("stores outputs and scores, and finds only originals to reuse", async () => {
|
|
151
|
+
const store = await make();
|
|
152
|
+
const c = await store.createCase(newCase("s"));
|
|
153
|
+
const r = await store.createRun(newRun("s"));
|
|
154
|
+
const base = {
|
|
155
|
+
runId: r.id,
|
|
156
|
+
caseId: c.id,
|
|
157
|
+
repeat: 1,
|
|
158
|
+
requestHash: "h",
|
|
159
|
+
output: { text: "HELLO" },
|
|
160
|
+
error: null,
|
|
161
|
+
scores: null,
|
|
162
|
+
costUsd: 0.01,
|
|
163
|
+
ms: 12,
|
|
164
|
+
reused: false,
|
|
165
|
+
};
|
|
166
|
+
const o = await store.createOutput(base);
|
|
167
|
+
await store.createOutput({ ...base, reused: true });
|
|
168
|
+
await store.createOutput({ ...base, error: "boom", output: null });
|
|
169
|
+
await store.setOutputScores(o.id, [{ key: "match", pass: true }]);
|
|
170
|
+
const outs = await store.listOutputs(r.id);
|
|
171
|
+
expect(outs).toHaveLength(3);
|
|
172
|
+
expect(outs.find((x) => x.id === o.id)?.scores).toEqual([
|
|
173
|
+
{ key: "match", pass: true },
|
|
174
|
+
]);
|
|
175
|
+
const found = await store.findReusableOutput("h", new Date(0));
|
|
176
|
+
expect(found?.id).toBe(o.id);
|
|
177
|
+
expect(found?.output).toEqual({ text: "HELLO" });
|
|
178
|
+
expect(
|
|
179
|
+
await store.findReusableOutput("h", new Date(Date.now() + 60_000)),
|
|
180
|
+
).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("runs a whole test through the runner", async () => {
|
|
184
|
+
const store = await make();
|
|
185
|
+
const echo: Suite<{ text: string }, { text: string }, { want: string }> =
|
|
186
|
+
{
|
|
187
|
+
key: "contract",
|
|
188
|
+
operation: "contract",
|
|
189
|
+
priority: { favor: "quality" },
|
|
190
|
+
label: "Contract",
|
|
191
|
+
description: "Uppercases text.",
|
|
192
|
+
graderVersion: "1",
|
|
193
|
+
input: z.object({ text: z.string() }),
|
|
194
|
+
labelValue: z.object({ want: z.string() }),
|
|
195
|
+
checks: [
|
|
196
|
+
{
|
|
197
|
+
key: "match",
|
|
198
|
+
label: "Matches",
|
|
199
|
+
description: "",
|
|
200
|
+
kind: "label",
|
|
201
|
+
severity: "serious",
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
run: async (i) => ({ text: i.text.toUpperCase() }),
|
|
205
|
+
grade: async (_i, out, want) => [
|
|
206
|
+
{ key: "match", pass: want ? out.text === want.want : null },
|
|
207
|
+
],
|
|
208
|
+
inputFromForm: () => ({ text: "" }),
|
|
209
|
+
title: (i) => i.text,
|
|
210
|
+
};
|
|
211
|
+
await store.createCase(newCase("contract"));
|
|
212
|
+
const runner = createRunner({
|
|
213
|
+
store,
|
|
214
|
+
suites: [echo],
|
|
215
|
+
models: { "model-a": { label: "Model A", provider: "acme" } },
|
|
216
|
+
promptVersion: () => "v1",
|
|
217
|
+
});
|
|
218
|
+
const { run } = await runner.createRun({
|
|
219
|
+
suite: "contract",
|
|
220
|
+
model: "model-a",
|
|
221
|
+
});
|
|
222
|
+
await runner.runEval(run.id);
|
|
223
|
+
const done = await store.getRun(run.id);
|
|
224
|
+
expect(done?.status).toBe("SUCCEEDED");
|
|
225
|
+
expect(done?.metrics?.allPass.passed).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The contract every FeedbackStore must meet. `make` must return an empty
|
|
232
|
+
* store each time it's called.
|
|
233
|
+
*/
|
|
234
|
+
export function describeFeedbackStoreContract(
|
|
235
|
+
name: string,
|
|
236
|
+
make: () => FeedbackStore | Promise<FeedbackStore>,
|
|
237
|
+
) {
|
|
238
|
+
const rating = (over: Partial<NewRating> = {}): NewRating => ({
|
|
239
|
+
operation: "triage",
|
|
240
|
+
targetId: "answer-1",
|
|
241
|
+
raterId: "person-1",
|
|
242
|
+
rating: "DOWN",
|
|
243
|
+
reason: "Wrong queue",
|
|
244
|
+
excerpt: "billing",
|
|
245
|
+
input: { subject: "Refund", body: "Please refund me." },
|
|
246
|
+
output: { category: "bug" },
|
|
247
|
+
...over,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe(`FeedbackStore contract: ${name}`, () => {
|
|
251
|
+
it("keeps a rating with its reason, input, and output, open", async () => {
|
|
252
|
+
const store = await make();
|
|
253
|
+
const f = await store.rate(rating());
|
|
254
|
+
expect(f).toMatchObject({
|
|
255
|
+
operation: "triage",
|
|
256
|
+
rating: "DOWN",
|
|
257
|
+
reason: "Wrong queue",
|
|
258
|
+
excerpt: "billing",
|
|
259
|
+
input: { subject: "Refund", body: "Please refund me." },
|
|
260
|
+
output: { category: "bug" },
|
|
261
|
+
status: "OPEN",
|
|
262
|
+
caseId: null,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("keeps one rating per person per answer, and reopens a changed one", async () => {
|
|
267
|
+
const store = await make();
|
|
268
|
+
const first = await store.rate(rating());
|
|
269
|
+
await store.setFeedbackStatus(first.id, "DISMISSED");
|
|
270
|
+
const changed = await store.rate(rating({ rating: "UP" }));
|
|
271
|
+
expect(changed.id).toBe(first.id);
|
|
272
|
+
expect(changed.status).toBe("OPEN");
|
|
273
|
+
expect(changed.reason).toBeNull();
|
|
274
|
+
await store.rate(rating({ raterId: "person-2" }));
|
|
275
|
+
expect(await store.listFeedback()).toHaveLength(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("takes a rating back", async () => {
|
|
279
|
+
const store = await make();
|
|
280
|
+
await store.rate(rating());
|
|
281
|
+
await store.clearRating("person-1", "triage", "answer-1");
|
|
282
|
+
expect(await store.listFeedback()).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("filters by feature, rating, status, and date, newest first", async () => {
|
|
286
|
+
const store = await make();
|
|
287
|
+
const a = await store.rate(rating({ targetId: "a" }));
|
|
288
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
289
|
+
const b = await store.rate(rating({ targetId: "b", rating: "UP" }));
|
|
290
|
+
await store.rate(rating({ targetId: "c", operation: "summary" }));
|
|
291
|
+
expect(
|
|
292
|
+
(await store.listFeedback({ operation: "triage" })).map((f) => f.id),
|
|
293
|
+
).toEqual([b.id, a.id]);
|
|
294
|
+
expect(await store.listFeedback({ rating: "UP" })).toHaveLength(1);
|
|
295
|
+
await store.setFeedbackStatus(a.id, "CASED", "case-1");
|
|
296
|
+
const cased = await store.listFeedback({ status: "CASED" });
|
|
297
|
+
expect(cased.map((f) => [f.id, f.caseId])).toEqual([[a.id, "case-1"]]);
|
|
298
|
+
expect(
|
|
299
|
+
await store.listFeedback({ since: new Date(Date.now() + 60_000) }),
|
|
300
|
+
).toHaveLength(0);
|
|
301
|
+
expect(await store.listFeedback({ limit: 1 })).toHaveLength(1);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The contract every SettingsStore must meet. `make` must return an empty
|
|
308
|
+
* store each time it's called.
|
|
309
|
+
*/
|
|
310
|
+
export function describeSettingsStoreContract(
|
|
311
|
+
name: string,
|
|
312
|
+
make: () => SettingsStore | Promise<SettingsStore>,
|
|
313
|
+
) {
|
|
314
|
+
const settings = (model: string): Settings => ({
|
|
315
|
+
model,
|
|
316
|
+
fallbackModel: null,
|
|
317
|
+
reasoningEffort: null,
|
|
318
|
+
maxOutputTokens: null,
|
|
319
|
+
});
|
|
320
|
+
const create = (model: string, expectLive: string | null) => ({
|
|
321
|
+
create: { settings: settings(model), promptVersion: "p1" },
|
|
322
|
+
kind: "CHANGE" as const,
|
|
323
|
+
reason: `to ${model}`,
|
|
324
|
+
by: "person-1",
|
|
325
|
+
expectLive,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe(`SettingsStore contract: ${name}`, () => {
|
|
329
|
+
it("has nothing live until the first switch, then numbers versions", async () => {
|
|
330
|
+
const store = await make();
|
|
331
|
+
expect(await store.liveVersion("triage")).toBeNull();
|
|
332
|
+
const v1 = await store.switchTo("triage", create("a", null));
|
|
333
|
+
const v2 = await store.switchTo("triage", create("b", v1.id));
|
|
334
|
+
expect([v1.number, v2.number]).toEqual([1, 2]);
|
|
335
|
+
expect(v2).toMatchObject({
|
|
336
|
+
operation: "triage",
|
|
337
|
+
model: "b",
|
|
338
|
+
promptVersion: "p1",
|
|
339
|
+
});
|
|
340
|
+
expect((await store.liveVersion("triage"))?.id).toBe(v2.id);
|
|
341
|
+
expect((await store.listVersions("triage")).map((v) => v.number)).toEqual(
|
|
342
|
+
[2, 1],
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("logs every switch, newest first, with who, why, and evidence", async () => {
|
|
347
|
+
const store = await make();
|
|
348
|
+
const v1 = await store.switchTo("triage", create("a", null));
|
|
349
|
+
await store.switchTo("triage", {
|
|
350
|
+
...create("b", v1.id),
|
|
351
|
+
kind: "PROMOTE",
|
|
352
|
+
evidence: { runId: "r1", verdict: "BETTER" },
|
|
353
|
+
});
|
|
354
|
+
const [latest, first] = await store.listChanges("triage");
|
|
355
|
+
expect(latest).toMatchObject({
|
|
356
|
+
kind: "PROMOTE",
|
|
357
|
+
fromVersionId: v1.id,
|
|
358
|
+
reason: "to b",
|
|
359
|
+
by: "person-1",
|
|
360
|
+
evidence: { runId: "r1", verdict: "BETTER" },
|
|
361
|
+
});
|
|
362
|
+
expect(first).toMatchObject({ kind: "CHANGE", fromVersionId: null });
|
|
363
|
+
expect(await store.listChanges("triage", { limit: 1 })).toHaveLength(1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("makes an earlier version live again without a new one", async () => {
|
|
367
|
+
const store = await make();
|
|
368
|
+
const v1 = await store.switchTo("triage", create("a", null));
|
|
369
|
+
const v2 = await store.switchTo("triage", create("b", v1.id));
|
|
370
|
+
await store.switchTo("triage", {
|
|
371
|
+
versionId: v1.id,
|
|
372
|
+
kind: "ROLLBACK",
|
|
373
|
+
reason: "b was worse",
|
|
374
|
+
by: "person-1",
|
|
375
|
+
expectLive: v2.id,
|
|
376
|
+
});
|
|
377
|
+
expect((await store.liveVersion("triage"))?.id).toBe(v1.id);
|
|
378
|
+
expect(await store.listVersions("triage")).toHaveLength(2);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("refuses a switch decided against a live version that changed", async () => {
|
|
382
|
+
const store = await make();
|
|
383
|
+
const v1 = await store.switchTo("triage", create("a", null));
|
|
384
|
+
await store.switchTo("triage", create("b", v1.id));
|
|
385
|
+
await expect(
|
|
386
|
+
store.switchTo("triage", create("c", v1.id)),
|
|
387
|
+
).rejects.toBeInstanceOf(SettingsConflict);
|
|
388
|
+
expect((await store.liveVersion("triage"))?.model).toBe("b");
|
|
389
|
+
expect(await store.listVersions("triage")).toHaveLength(2);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("keeps a version's temperature", async () => {
|
|
393
|
+
const store = await make();
|
|
394
|
+
const v1 = await store.switchTo("triage", {
|
|
395
|
+
...create("a", null),
|
|
396
|
+
create: {
|
|
397
|
+
settings: { ...settings("a"), temperature: 0.4 },
|
|
398
|
+
promptVersion: "p1",
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
expect(v1.temperature).toBe(0.4);
|
|
402
|
+
expect((await store.liveVersion("triage"))?.temperature).toBe(0.4);
|
|
403
|
+
const v2 = await store.switchTo("triage", create("b", v1.id));
|
|
404
|
+
expect(v2.temperature ?? null).toBeNull();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("keeps each feature's versions and live one apart", async () => {
|
|
408
|
+
const store = await make();
|
|
409
|
+
await store.switchTo("triage", create("a", null));
|
|
410
|
+
const other = await store.switchTo("summary", create("x", null));
|
|
411
|
+
expect(other.number).toBe(1);
|
|
412
|
+
expect((await store.liveVersion("triage"))?.model).toBe("a");
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Rejects if the call takes longer than `ms`. Pure. */
|
|
2
|
+
export function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
3
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
4
|
+
return Promise.race([
|
|
5
|
+
p,
|
|
6
|
+
new Promise<never>((_, reject) => {
|
|
7
|
+
timer = setTimeout(() => {
|
|
8
|
+
reject(new Error(`No answer within ${ms / 1000}s`));
|
|
9
|
+
}, ms);
|
|
10
|
+
}),
|
|
11
|
+
]).finally(() => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
});
|
|
14
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { ReasoningEffort } from "./models.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An eval suite: how to run one AI operation on a frozen case and how to
|
|
6
|
+
* grade what comes back. Code says how to evaluate; the database holds the
|
|
7
|
+
* cases and labels (EvalCase, EvalCaseLabel). See docs/evals.md.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One check on one output. null: doesn't apply to this case. */
|
|
11
|
+
export type CheckResult = {
|
|
12
|
+
key: string;
|
|
13
|
+
pass: boolean | null;
|
|
14
|
+
/** Why it failed (or a detail worth showing), in plain words. */
|
|
15
|
+
note?: string | undefined;
|
|
16
|
+
/** For matching checks: which expected item matched which output, and why. */
|
|
17
|
+
matches?: { expected: string; actual: string; why: string }[] | undefined;
|
|
18
|
+
/** "meaning": a judge model matched them; "words": shared words did. */
|
|
19
|
+
matchedBy?: "meaning" | "words" | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Partial credit: of the parts this answer should have (e.g. expected
|
|
22
|
+
* items), how many it got right. The headline score sums these.
|
|
23
|
+
*/
|
|
24
|
+
units?: { right: number; total: number } | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Numbers measured on this answer, averaged across a test: a decision's
|
|
27
|
+
* `brier`, `logLoss`, `confidence`, and `correct` (see decisionCheck).
|
|
28
|
+
*/
|
|
29
|
+
measures?: Record<string, number> | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One check a suite runs: what it checks, whether it needs an answer, and how serious a
|
|
34
|
+
* miss is.
|
|
35
|
+
*/
|
|
36
|
+
export type CheckDef = {
|
|
37
|
+
key: string;
|
|
38
|
+
label: string;
|
|
39
|
+
/** What it checks, for the page that explains the suite. */
|
|
40
|
+
description: string;
|
|
41
|
+
/** "code": needs no label. "label": compares with a confirmed label. */
|
|
42
|
+
kind: "code" | "label";
|
|
43
|
+
/**
|
|
44
|
+
* Serious: changes what the product does (which queue a ticket lands
|
|
45
|
+
* in, what a summary claims). Minor: a fair difference of judgment (a category, how finely
|
|
46
|
+
* to split). Models are ranked on serious mistakes.
|
|
47
|
+
*/
|
|
48
|
+
severity: "serious" | "minor";
|
|
49
|
+
/** Pass rate it should reach; default by severity (checkBar). */
|
|
50
|
+
bar?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The pass rate a check should reach: its own, else 95% serious, 75% minor. */
|
|
54
|
+
export function checkBar(c: CheckDef): number {
|
|
55
|
+
return c.bar ?? (c.severity === "serious" ? 0.95 : 0.75);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Meets the bar, is within 10 points of it, or is further below. */
|
|
59
|
+
export function barStatus(
|
|
60
|
+
rate: number,
|
|
61
|
+
bar: number,
|
|
62
|
+
): "meets" | "close" | "below" {
|
|
63
|
+
return rate >= bar ? "meets" : rate >= bar - 0.1 ? "close" : "below";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a suite cares about once accuracy holds. Accuracy is always the
|
|
68
|
+
* floor; this is the one thing worth switching models for beyond it.
|
|
69
|
+
*/
|
|
70
|
+
export type SuitePriority = {
|
|
71
|
+
/** speed: someone waits on it. cost: runs in the background, often.
|
|
72
|
+
* quality: people read and act on it, so only accuracy counts. */
|
|
73
|
+
favor: "speed" | "cost" | "quality";
|
|
74
|
+
/** Models slower than this on average are never suggested. */
|
|
75
|
+
maxSeconds?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Each priority, explained for the suite page. */
|
|
79
|
+
export const FAVOR_TEXT: Record<SuitePriority["favor"], string> = {
|
|
80
|
+
speed:
|
|
81
|
+
"speed: someone waits on this, so a model that scores as well and answers clearly faster is worth switching to, and so is one about as accurate (within 10 points) in half the time",
|
|
82
|
+
cost: "cost: this runs in the background, often, so a model that scores as well and is clearly cheaper is worth switching to, and so is one about as accurate (within 10 points) at half the cost",
|
|
83
|
+
quality:
|
|
84
|
+
"quality: people read and act on this, so only clearly better accuracy or fewer serious mistakes is worth switching for",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An eval suite: how to call one AI feature on a model and how to grade what comes back.
|
|
89
|
+
* Code says how to evaluate; the store holds the cases and answers.
|
|
90
|
+
*/
|
|
91
|
+
export type Suite<Input, Output, Label> = {
|
|
92
|
+
key: string;
|
|
93
|
+
/** The AI feature it tests, as your app names it ("summarize_ticket"). */
|
|
94
|
+
operation: string;
|
|
95
|
+
priority: SuitePriority;
|
|
96
|
+
label: string;
|
|
97
|
+
description: string;
|
|
98
|
+
/** Bump when checks change, so runs say which grading they used. */
|
|
99
|
+
graderVersion: string;
|
|
100
|
+
input: z.ZodType<Input>;
|
|
101
|
+
/** The EXPECTED label's value. */
|
|
102
|
+
labelValue: z.ZodType<Label>;
|
|
103
|
+
checks: CheckDef[];
|
|
104
|
+
/** Calls the operation on exactly this model, recorded as an eval. */
|
|
105
|
+
run(
|
|
106
|
+
input: Input,
|
|
107
|
+
ctx: {
|
|
108
|
+
model: string;
|
|
109
|
+
/** A reasoning setting to test; undefined: the operation's own. */
|
|
110
|
+
reasoningEffort?: ReasoningEffort | null;
|
|
111
|
+
/**
|
|
112
|
+
* The temperature the test runs at: the operation's live one when the
|
|
113
|
+
* test was started. Null: send none. Undefined from runners before
|
|
114
|
+
* temperature was versioned.
|
|
115
|
+
*/
|
|
116
|
+
temperature?: number | null;
|
|
117
|
+
onCost: (usd: number) => void;
|
|
118
|
+
},
|
|
119
|
+
): Promise<Output>;
|
|
120
|
+
/**
|
|
121
|
+
* Grades one output. `label` is the confirmed one, or null. A check that
|
|
122
|
+
* asks a judge model reports its cost through `ctx.onCost`.
|
|
123
|
+
*/
|
|
124
|
+
grade(
|
|
125
|
+
input: Input,
|
|
126
|
+
output: Output,
|
|
127
|
+
label: Label | null,
|
|
128
|
+
ctx?: { onCost: (usd: number) => void },
|
|
129
|
+
): Promise<CheckResult[]>;
|
|
130
|
+
/**
|
|
131
|
+
* Checks that compare a test's answers with each other, such as ranking.
|
|
132
|
+
* Gets every graded answer of one repeat; returns extra checks for each,
|
|
133
|
+
* in the same order. Pure; runs after a test and on every regrade.
|
|
134
|
+
*/
|
|
135
|
+
gradeTogether?(
|
|
136
|
+
answers: { input: Input; output: Output; label: Label | null }[],
|
|
137
|
+
): CheckResult[][];
|
|
138
|
+
/** Reads a case's input from the add/edit form; throws on bad input. */
|
|
139
|
+
inputFromForm(form: FormData): Input;
|
|
140
|
+
/** Reads an answer from the add/edit form; throws on bad input. */
|
|
141
|
+
labelFromForm?(form: FormData): Label;
|
|
142
|
+
/**
|
|
143
|
+
* The operation's output as an answer, so a thumbs-up can propose it
|
|
144
|
+
* (pending until a person confirms it). Omit when outputs and answers
|
|
145
|
+
* differ too much to propose one.
|
|
146
|
+
*/
|
|
147
|
+
labelFromOutput?(output: Output): Label;
|
|
148
|
+
/**
|
|
149
|
+
* The form's fields, so pages can render one for any suite. Every field
|
|
150
|
+
* has a plain label and help: people confirming answers weeks later
|
|
151
|
+
* won't remember what a field means.
|
|
152
|
+
*/
|
|
153
|
+
fields?: { input: FormField[]; label: FormField[] };
|
|
154
|
+
/** Drafted cases to start from, added as drafts with PENDING labels. */
|
|
155
|
+
starter?: {
|
|
156
|
+
input: Input;
|
|
157
|
+
label: Label;
|
|
158
|
+
tags: string[];
|
|
159
|
+
split: "DEV" | "TEST";
|
|
160
|
+
smoke?: boolean;
|
|
161
|
+
note: string;
|
|
162
|
+
}[];
|
|
163
|
+
/**
|
|
164
|
+
* Drafts new cases aimed at what the answer key is missing, for a person
|
|
165
|
+
* to review (they arrive with PENDING answers).
|
|
166
|
+
*/
|
|
167
|
+
draftMore?(args: {
|
|
168
|
+
existing: { input: Input; tags: string[] }[];
|
|
169
|
+
count: number;
|
|
170
|
+
focus: string | null;
|
|
171
|
+
}): Promise<{ input: Input; label: Label; tags: string[]; note: string }[]>;
|
|
172
|
+
/**
|
|
173
|
+
* How long one answer may take before it counts as failed. Default 60s;
|
|
174
|
+
* multi-step agent turns need more. Keep it under the run budget.
|
|
175
|
+
*/
|
|
176
|
+
answerTimeoutMs?: number;
|
|
177
|
+
/**
|
|
178
|
+
* How many answers a test asks for at once. Default 6; slow operations
|
|
179
|
+
* need more to finish before the platform's time limit.
|
|
180
|
+
*/
|
|
181
|
+
concurrency?: number;
|
|
182
|
+
/**
|
|
183
|
+
* Graded as decisions (typed answers with probabilities, as from the AI
|
|
184
|
+
* SDK's `experimental_decide`), so pages show probability quality and
|
|
185
|
+
* calibration next to accuracy, and which models' probabilities are
|
|
186
|
+
* native or prompted.
|
|
187
|
+
*/
|
|
188
|
+
decisions?: boolean;
|
|
189
|
+
/** What the headline counts: "items", "tickets", … Default "answers". */
|
|
190
|
+
unitNoun?: string;
|
|
191
|
+
/**
|
|
192
|
+
* "typed": cases are text someone types (added by hand, or drafted).
|
|
193
|
+
* "imported": cases are frozen from the app's own data, then rated by a
|
|
194
|
+
* person.
|
|
195
|
+
* "fixture": cases are structured inputs, shown with describe() and
|
|
196
|
+
* edited as JSON.
|
|
197
|
+
*/
|
|
198
|
+
caseKind?: "typed" | "imported" | "fixture";
|
|
199
|
+
/** A short line naming the case in lists. */
|
|
200
|
+
title(input: Input): string;
|
|
201
|
+
/** Fixture suites: the whole input, readable, for reviewing a case. */
|
|
202
|
+
describe?(input: Input): string;
|
|
203
|
+
/** Fixture suites: what the expected answer's fields mean. */
|
|
204
|
+
labelHelp?: string;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One field of a suite's add/edit form. `name` is the form key (and the
|
|
209
|
+
* input or answer property it fills). "lines": one item per line, read as
|
|
210
|
+
* a list.
|
|
211
|
+
*/
|
|
212
|
+
export type FormField = {
|
|
213
|
+
name: string;
|
|
214
|
+
label: string;
|
|
215
|
+
help: string;
|
|
216
|
+
kind: "text" | "textarea" | "select" | "lines";
|
|
217
|
+
/** For "select". */
|
|
218
|
+
options?: readonly string[];
|
|
219
|
+
required?: boolean;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** A value as plain text: strings as they are, other values as JSON. */
|
|
223
|
+
export function asText(v: unknown): string {
|
|
224
|
+
if (v === undefined || v === null) return "";
|
|
225
|
+
if (typeof v === "string") return v;
|
|
226
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
227
|
+
return JSON.stringify(v);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A form value as text: "" when missing or a file. */
|
|
231
|
+
export function formText(form: FormData, name: string): string {
|
|
232
|
+
const v = form.get(name);
|
|
233
|
+
return typeof v === "string" ? v.trim() : "";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A field's value from a stored input or answer, as the form shows it. */
|
|
237
|
+
export function fieldValue(field: FormField, value: unknown): string {
|
|
238
|
+
const v =
|
|
239
|
+
value && typeof value === "object"
|
|
240
|
+
? (value as Record<string, unknown>)[field.name]
|
|
241
|
+
: undefined;
|
|
242
|
+
if (Array.isArray(v)) return v.map(asText).join("\n");
|
|
243
|
+
return asText(v);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A "lines" field's value as a list: one item per non-empty line. */
|
|
247
|
+
export function linesOf(form: FormData, name: string): string[] {
|
|
248
|
+
return formText(form, name)
|
|
249
|
+
.split("\n")
|
|
250
|
+
.map((l) => l.trim())
|
|
251
|
+
.filter(Boolean);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A suite of any shape, for registries. Its methods take and return
|
|
256
|
+
* `unknown`; a suite's own code keeps its precise types.
|
|
257
|
+
*/
|
|
258
|
+
export type AnySuite = Suite<unknown, unknown, unknown>;
|