@crowandraven/evalkeeper 0.2.0 → 0.4.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.
Files changed (55) hide show
  1. package/dist/common.js +4 -2
  2. package/dist/common.js.map +1 -1
  3. package/dist/importCases.d.ts +94 -0
  4. package/dist/importCases.js +185 -0
  5. package/dist/importCases.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/labels.d.ts +9 -3
  10. package/dist/labels.js +2 -0
  11. package/dist/labels.js.map +1 -1
  12. package/dist/memoryStore.js +16 -2
  13. package/dist/memoryStore.js.map +1 -1
  14. package/dist/models.d.ts +17 -6
  15. package/dist/models.js +29 -5
  16. package/dist/models.js.map +1 -1
  17. package/dist/runner.d.ts +16 -1
  18. package/dist/runner.js +68 -18
  19. package/dist/runner.js.map +1 -1
  20. package/dist/settings.js +1 -0
  21. package/dist/settings.js.map +1 -1
  22. package/dist/stats.d.ts +5 -1
  23. package/dist/stats.js +21 -6
  24. package/dist/stats.js.map +1 -1
  25. package/dist/store.d.ts +24 -2
  26. package/dist/testing.js +77 -0
  27. package/dist/testing.js.map +1 -1
  28. package/dist/types.d.ts +5 -0
  29. package/dist/types.js.map +1 -1
  30. package/dist/weekly.js +3 -2
  31. package/dist/weekly.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/common.ts +5 -2
  34. package/src/importCases.ts +252 -0
  35. package/src/index.ts +1 -0
  36. package/src/labels.ts +9 -4
  37. package/src/memoryStore.ts +24 -2
  38. package/src/models.ts +35 -7
  39. package/src/runner.ts +126 -49
  40. package/src/settings.ts +1 -0
  41. package/src/stats.ts +19 -6
  42. package/src/store.ts +28 -2
  43. package/src/testing.ts +91 -0
  44. package/src/types.ts +5 -0
  45. package/src/weekly.ts +3 -2
  46. package/templates/app/evals/[suite]/page.tsx +15 -10
  47. package/templates/app/evals/feedback/page.tsx +11 -6
  48. package/templates/app/evals/page.tsx +59 -15
  49. package/templates/components/evalkeeper/AddCase.tsx +2 -1
  50. package/templates/components/evalkeeper/AnswerKey.tsx +14 -15
  51. package/templates/components/evalkeeper/ConfirmAnswer.tsx +46 -0
  52. package/templates/components/evalkeeper/RefreshWhile.tsx +16 -7
  53. package/templates/components/evalkeeper/RollBack.tsx +62 -0
  54. package/templates/components/evalkeeper/SettingsHistory.tsx +6 -20
  55. package/templates/components/evalkeeper/Submit.tsx +13 -4
@@ -7,7 +7,6 @@ import {
7
7
  money,
8
8
  probabilityQuality,
9
9
  perAnswer,
10
- type ReasoningEffort,
11
10
  retiresLabel,
12
11
  seconds,
13
12
  seriousMistakes,
@@ -56,17 +55,23 @@ export default async function SuitePage({
56
55
  }) {
57
56
  await requireAdmin();
58
57
  const { suite: key } = await params;
59
- if (!SUITE_BY_KEY[key]) notFound();
60
- const b = await runner.board(key);
61
- // Records a deploy if the instructions changed under the live settings.
62
- await settings.syncPrompt(b.suite.operation);
58
+ const operation = SUITE_BY_KEY[key]?.operation;
59
+ if (!operation) notFound();
60
+ // The board and the settings load together: on a remote database each
61
+ // waits on round trips, so one after the other doubles the wait.
62
+ const [b, [history, liveSettings]] = await Promise.all([
63
+ runner.board(key),
64
+ // Records a deploy if the instructions changed under the live
65
+ // settings, before reading them.
66
+ settings
67
+ .syncPrompt(operation)
68
+ .then(() =>
69
+ Promise.all([settings.history(operation), settings.current(operation)]),
70
+ ),
71
+ ]);
63
72
  const { suite, rows } = b;
64
73
  const s = suggest(rows, suite.checks, suite.priority, MODELS);
65
74
  const testing = rows.some((r) => r.testing);
66
- const [history, liveSettings] = await Promise.all([
67
- settings.history(suite.operation),
68
- settings.current(suite.operation),
69
- ]);
70
75
  // What testing each model would cost now, counting answers it would reuse.
71
76
  const estimates = new Map(
72
77
  await Promise.all(
@@ -75,7 +80,7 @@ export default async function SuitePage({
75
80
  [
76
81
  r.key,
77
82
  await runner.estimateRun(key, r.model, "ALL", 1, {
78
- reasoningEffort: r.effort as ReasoningEffort | null,
83
+ reasoningEffort: r.effort,
79
84
  }),
80
85
  ] as const,
81
86
  ),
@@ -1,4 +1,3 @@
1
- import Link from "next/link";
2
1
  import {
3
2
  asText,
4
3
  thumbsSummary,
@@ -103,11 +102,17 @@ export default async function FeedbackPage() {
103
102
  </h2>
104
103
  {open.length === 0 && (
105
104
  <p className="text-sm text-muted">
106
- Nothing waiting. Rate an answer on the{" "}
107
- <Link href="/try" className="underline">
108
- try it
109
- </Link>{" "}
110
- page to see one here.
105
+ Nothing waiting. Ratings show up here once an AI feature has a
106
+ thumbs up and down (step 8 of{" "}
107
+ <a
108
+ href="https://github.com/seanhudson/evalkeeper/blob/main/docs/adding-to-your-app.md#8-add-thumbs-to-your-ai-answers-optional"
109
+ target="_blank"
110
+ rel="noreferrer"
111
+ className="underline"
112
+ >
113
+ the guide
114
+ </a>
115
+ ).
111
116
  </p>
112
117
  )}
113
118
  <div className="space-y-3">
@@ -120,6 +120,7 @@ export default async function EvalsHub() {
120
120
  Suites
121
121
  <InfoTip term="suites" />
122
122
  </h2>
123
+ {boards.length === 0 && <NoSuites />}
123
124
  <div className="space-y-4">
124
125
  {boards.map((b, i) => {
125
126
  const h = suiteHealth(
@@ -147,6 +148,45 @@ export default async function EvalsHub() {
147
148
  );
148
149
  }
149
150
 
151
+ /** Before the first suite: what a suite is, and how to add one. */
152
+ function NoSuites() {
153
+ return (
154
+ <Card className="readable space-y-3 text-sm">
155
+ <p className="font-semibold">No suites yet</p>
156
+ <p className="text-muted">
157
+ A suite tests one AI feature: its cases, the answers a person confirms,
158
+ and the checks that grade any model against them. To add one:
159
+ </p>
160
+ <ol className="list-decimal space-y-2 pl-4">
161
+ <li>
162
+ Write it: the input and answer shapes with zod, two or three checks,{" "}
163
+ <code>run</code>, and <code>grade</code>.
164
+ </li>
165
+ <li>
166
+ Add it to <code>SUITES</code> in <code>lib/evalkeeper/index.ts</code>,
167
+ with its instructions hash.
168
+ </li>
169
+ <li>
170
+ Add cases (here, or with <code>importCases</code> from files you
171
+ have), confirm their answers, and test models.
172
+ </li>
173
+ </ol>
174
+ <p className="text-muted">
175
+ Step by step:{" "}
176
+ <a
177
+ href="https://github.com/seanhudson/evalkeeper/blob/main/docs/adding-to-your-app.md#5-write-your-first-suite"
178
+ target="_blank"
179
+ rel="noreferrer"
180
+ className="underline"
181
+ >
182
+ Write your first suite
183
+ </a>
184
+ .
185
+ </p>
186
+ </Card>
187
+ );
188
+ }
189
+
150
190
  function Stat({
151
191
  title,
152
192
  term,
@@ -239,21 +279,21 @@ function SuiteCard({
239
279
  </div>
240
280
 
241
281
  {s && (
242
- <div className="space-y-0.5 text-sm">
243
- <p>
244
- <span className="font-medium">v{version?.number ?? 1}</span>{" "}
245
- <span className="text-muted">
246
- · {since ? `live since ${day(since)}` : "defaults from code"}
247
- </span>
248
- </p>
249
- <p className="text-xs text-muted">
250
- {label(s.model)}
251
- {s.fallbackModel
252
- ? ` · fallback ${label(s.fallbackModel)}`
253
- : " · no fallback"}
254
- {s.reasoningEffort ? ` · reasoning ${s.reasoningEffort}` : ""}
255
- </p>
256
- </div>
282
+ <p className="text-sm text-muted">
283
+ <span className="font-semibold text-ink">{label(s.model)}</span>
284
+ {s.fallbackModel ? (
285
+ <>
286
+ {" "}
287
+ · fallback{" "}
288
+ <span className="font-semibold text-ink">
289
+ {label(s.fallbackModel)}
290
+ </span>
291
+ </>
292
+ ) : (
293
+ " · no fallback"
294
+ )}
295
+ {s.reasoningEffort ? ` · reasoning ${s.reasoningEffort}` : ""}
296
+ </p>
257
297
  )}
258
298
 
259
299
  <p className="text-sm">
@@ -289,6 +329,10 @@ function SuiteCard({
289
329
  <summary className="flex list-none items-center justify-between gap-2 p-3 text-sm font-medium">
290
330
  Versions
291
331
  <span className="flex items-center gap-1 text-xs font-normal text-muted">
332
+ <span className="font-medium text-ink">
333
+ v{version?.number ?? 1}
334
+ </span>{" "}
335
+ {since ? `live since ${day(since)}` : "defaults from code"} ·{" "}
292
336
  {versions} version{versions === 1 ? "" : "s"}
293
337
  <ChevronDown
294
338
  aria-hidden
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { Plus } from "lucide-react";
5
+ import { QUIET_BUTTON } from "./Submit";
5
6
  import { Card } from "./ui";
6
7
 
7
8
  /**
@@ -31,7 +32,7 @@ export function AddCaseHeader({
31
32
  onClick={() => {
32
33
  setOpen(!open);
33
34
  }}
34
- className="inline-flex items-center gap-1.5 rounded-md border border-line bg-card px-3 py-1.5 text-sm hover:bg-tint"
35
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${QUIET_BUTTON}`}
35
36
  >
36
37
  <Plus aria-hidden className="h-4 w-4" />
37
38
  Add a case
@@ -7,17 +7,13 @@ import {
7
7
  type BoardRow,
8
8
  type CheckResult,
9
9
  } from "@crowandraven/evalkeeper";
10
- import {
11
- addCase,
12
- confirmAnswer,
13
- editAnswer,
14
- retireCase,
15
- } from "@/lib/evalkeeper/actions";
10
+ import { addCase, editAnswer, retireCase } from "@/lib/evalkeeper/actions";
16
11
  import { Check, ChevronRight, CircleAlert, X } from "lucide-react";
17
12
  import { store } from "@/lib/evalkeeper";
18
13
  import { FieldValues, Fields } from "./Fields";
19
14
  import { AddCaseHeader } from "./AddCase";
20
15
  import { CaseMenu } from "./CaseMenu";
16
+ import { ConfirmAnswer } from "./ConfirmAnswer";
21
17
  import { Submit } from "./Submit";
22
18
  import { Chip, type ChipTone } from "./ui";
23
19
  import { InfoTip, Tip } from "./Tip";
@@ -64,9 +60,12 @@ export async function AnswerKey({
64
60
  r.test && !r.variant ? [[r.test.runId, r.label] as const] : [],
65
61
  ),
66
62
  );
67
- const outputs = (
68
- await Promise.all([...modelByRun.keys()].map((id) => store.listOutputs(id)))
69
- ).flat();
63
+ // One query where the store can: one per test is slow on a remote
64
+ // database.
65
+ const runIds = [...modelByRun.keys()];
66
+ const outputs = store.listOutputsFor
67
+ ? await store.listOutputsFor(runIds)
68
+ : (await Promise.all(runIds.map((id) => store.listOutputs(id)))).flat();
70
69
  const byCase = new Map<string, { model: string; output: OutputRecord }[]>();
71
70
  for (const o of outputs) {
72
71
  byCase.set(o.caseId, [
@@ -189,7 +188,11 @@ function CaseItem({
189
188
  const tags = Array.isArray(c.tags) ? c.tags : [];
190
189
 
191
190
  return (
192
- <details className="group rounded-md border border-line bg-card">
191
+ <details
192
+ className="group rounded-md border border-line bg-card"
193
+ // Where ConfirmAnswer moves on to after confirming the case above.
194
+ data-pending={answer?.status === "PENDING" ? "true" : undefined}
195
+ >
193
196
  <summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 p-3 text-sm">
194
197
  <span className="font-medium">
195
198
  <ChevronRight
@@ -312,11 +315,7 @@ function CaseItem({
312
315
  )}
313
316
 
314
317
  {answer?.status === "PENDING" && (
315
- <form action={confirmAnswer}>
316
- <input type="hidden" name="suite" value={suite.key} />
317
- <input type="hidden" name="label" value={answer.id} />
318
- <Submit>Confirm this answer</Submit>
319
- </form>
318
+ <ConfirmAnswer suite={suite.key} labelId={answer.id} />
320
319
  )}
321
320
 
322
321
  <CaseMenu
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { confirmAnswer } from "@/lib/evalkeeper/actions";
4
+ import { Submit } from "./Submit";
5
+
6
+ /**
7
+ * Confirms a case's pending answer and moves straight on: this case closes
8
+ * and the next one waiting for a confirmation opens, while the save and
9
+ * the page's refresh finish behind it. Reviewing a run of drafts shouldn't
10
+ * wait on the whole page each time.
11
+ */
12
+ export function ConfirmAnswer({
13
+ suite,
14
+ labelId,
15
+ }: {
16
+ suite: string;
17
+ labelId: string;
18
+ }) {
19
+ return (
20
+ <form
21
+ action={confirmAnswer}
22
+ onSubmit={(e) => {
23
+ const here = e.currentTarget.closest("details");
24
+ if (!here) return;
25
+ here.open = false;
26
+ let next = here.nextElementSibling;
27
+ while (
28
+ next &&
29
+ !(
30
+ next instanceof HTMLDetailsElement &&
31
+ next.dataset.pending === "true"
32
+ )
33
+ )
34
+ next = next.nextElementSibling;
35
+ if (next instanceof HTMLDetailsElement) {
36
+ next.open = true;
37
+ next.scrollIntoView({ block: "nearest" });
38
+ }
39
+ }}
40
+ >
41
+ <input type="hidden" name="suite" value={suite} />
42
+ <input type="hidden" name="label" value={labelId} />
43
+ <Submit>Confirm this answer</Submit>
44
+ </form>
45
+ );
46
+ }
@@ -1,9 +1,15 @@
1
1
  "use client";
2
2
 
3
3
  import { useRouter } from "next/navigation";
4
- import { useEffect } from "react";
4
+ import { useEffect, useTransition } from "react";
5
5
 
6
- /** Refreshes the page every few seconds while something runs. */
6
+ /**
7
+ * Refreshes the page every few seconds while something runs, waiting for
8
+ * each refresh to land before scheduling the next. A refresh on a timer
9
+ * cancels one still in flight, so when the page takes longer to render
10
+ * than the interval (the board against a remote database), no refresh ever
11
+ * lands and the page never updates (#25).
12
+ */
7
13
  export function RefreshWhile({
8
14
  active,
9
15
  ms = 2000,
@@ -12,14 +18,17 @@ export function RefreshWhile({
12
18
  ms?: number;
13
19
  }) {
14
20
  const router = useRouter();
21
+ const [refreshing, startTransition] = useTransition();
15
22
  useEffect(() => {
16
- if (!active) return;
17
- const t = setInterval(() => {
18
- router.refresh();
23
+ if (!active || refreshing) return;
24
+ const t = setTimeout(() => {
25
+ startTransition(() => {
26
+ router.refresh();
27
+ });
19
28
  }, ms);
20
29
  return () => {
21
- clearInterval(t);
30
+ clearTimeout(t);
22
31
  };
23
- }, [active, ms, router]);
32
+ }, [active, refreshing, ms, router]);
24
33
  return null;
25
34
  }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { rollBackSettings } from "@/lib/evalkeeper/actions";
5
+ import { Submit } from "./Submit";
6
+ import { Tip } from "./Tip";
7
+
8
+ /**
9
+ * Rolls a feature back to an earlier version. The button stays off until
10
+ * there's a note saying why: the note is kept with the version, for
11
+ * whoever reads the history next.
12
+ */
13
+ export function RollBack({
14
+ suite,
15
+ versionId,
16
+ number,
17
+ }: {
18
+ suite: string;
19
+ versionId: string;
20
+ number: number;
21
+ }) {
22
+ const [reason, setReason] = useState("");
23
+ const missing = reason.trim() === "";
24
+ const button = (
25
+ <Submit variant="quiet" disabled={missing}>
26
+ Roll back to v{number}
27
+ </Submit>
28
+ );
29
+ return (
30
+ <form
31
+ action={rollBackSettings}
32
+ className="flex flex-wrap items-center gap-2"
33
+ >
34
+ <input type="hidden" name="suite" value={suite} />
35
+ <input type="hidden" name="version" value={versionId} />
36
+ <label htmlFor={`why-${versionId}`} className="sr-only">
37
+ Why roll back to v{number}?
38
+ </label>
39
+ <input
40
+ id={`why-${versionId}`}
41
+ name="reason"
42
+ required
43
+ value={reason}
44
+ onChange={(e) => {
45
+ setReason(e.target.value);
46
+ }}
47
+ placeholder="Why roll back?"
48
+ className="rounded-md border border-line bg-card px-2 py-1 text-xs focus:border-accent focus:outline-none"
49
+ />
50
+ {missing ? (
51
+ <Tip
52
+ text="Say why you're rolling back first. The note is kept with the version, for whoever reads the history next."
53
+ id={`rollback-${versionId}`}
54
+ >
55
+ {button}
56
+ </Tip>
57
+ ) : (
58
+ button
59
+ )}
60
+ </form>
61
+ );
62
+ }
@@ -5,8 +5,7 @@ import {
5
5
  type HistoryEntry,
6
6
  type Settings,
7
7
  } from "@crowandraven/evalkeeper";
8
- import { rollBackSettings } from "@/lib/evalkeeper/actions";
9
- import { Submit } from "./Submit";
8
+ import { RollBack } from "./RollBack";
10
9
  import { InfoTip, Tip } from "./Tip";
11
10
  import { Chip } from "./ui";
12
11
  import { evalsPath } from "@/lib/evalkeeper/paths";
@@ -163,24 +162,11 @@ export function HistoryEntries({
163
162
  </Link>
164
163
  )}
165
164
  {!isLive && (
166
- <form
167
- action={rollBackSettings}
168
- className="flex flex-wrap items-center gap-2"
169
- >
170
- <input type="hidden" name="suite" value={suite} />
171
- <input type="hidden" name="version" value={e.to.id} />
172
- <label htmlFor={`why-${e.id}`} className="sr-only">
173
- Why roll back to v{e.to.number}?
174
- </label>
175
- <input
176
- id={`why-${e.id}`}
177
- name="reason"
178
- required
179
- placeholder="Why roll back?"
180
- className="rounded-md border border-line bg-card px-2 py-1 text-xs focus:border-accent focus:outline-none"
181
- />
182
- <Submit variant="quiet">Roll back to v{e.to.number}</Submit>
183
- </form>
165
+ <RollBack
166
+ suite={suite}
167
+ versionId={e.to.id}
168
+ number={e.to.number}
169
+ />
184
170
  )}
185
171
  </div>
186
172
  </article>
@@ -3,23 +3,32 @@
3
3
  import { LoaderCircle } from "lucide-react";
4
4
  import { useFormStatus } from "react-dom";
5
5
 
6
+ /**
7
+ * A button that isn't the page's main action: the app's accent as an
8
+ * outline, so it still reads as a button in the app's colors (set
9
+ * `--accent` in evalkeeper.css).
10
+ */
11
+ export const QUIET_BUTTON =
12
+ "border border-accent/40 bg-card text-accent hover:bg-accent/10";
13
+
6
14
  /** A submit button that spins while its form's action runs. */
7
15
  export function Submit({
8
16
  children,
9
17
  variant = "primary",
18
+ disabled = false,
10
19
  }: {
11
20
  children: React.ReactNode;
12
21
  variant?: "primary" | "quiet";
22
+ /** Off until the form is ready; wrap it in a `<Tip>` saying why. */
23
+ disabled?: boolean;
13
24
  }) {
14
25
  const { pending } = useFormStatus();
15
26
  return (
16
27
  <button
17
28
  type="submit"
18
- disabled={pending}
29
+ disabled={pending || disabled}
19
30
  className={`inline-flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5 text-sm disabled:opacity-60 ${
20
- variant === "primary"
21
- ? "bg-accent text-white"
22
- : "border border-line bg-card text-ink hover:bg-tint"
31
+ variant === "primary" ? "bg-accent text-white" : QUIET_BUTTON
23
32
  }`}
24
33
  >
25
34
  {pending && <Spinner />}