@crowandraven/evalkeeper 0.3.0 → 0.4.1

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.
@@ -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 />}
@@ -49,7 +49,9 @@ export function Score({ right, total }: { right: number; total: number }) {
49
49
  />
50
50
  </span>
51
51
  <span className="text-xs text-muted tabular-nums">
52
- {right}/{total}
52
+ {/* Whole numbers: a failed answer counts a graded answer's average
53
+ parts, which can be a fraction. */}
54
+ {Math.round(right)}/{Math.round(total)}
53
55
  </span>
54
56
  </div>
55
57
  );
@@ -66,6 +66,24 @@ async function evidenceFor(suiteKey: string, model: string) {
66
66
  };
67
67
  }
68
68
 
69
+ /** What a fallback choice records: its current test, if it has one. */
70
+ async function fallbackEvidence(suiteKey: string, model: string) {
71
+ const board = await runner.board(suiteKey);
72
+ const row = board.rows.find((r) => r.model === model && !r.variant);
73
+ if (!row?.canMakeFallback)
74
+ throw new Error("This model can't be the fallback");
75
+ const test = row.test && !row.test.stale ? row.test : null;
76
+ const failed = test?.failedAnswers ? `, ${test.failedAnswers} failed` : "";
77
+ return {
78
+ reason: `Made ${row.label} the fallback: ${test ? `${Math.round(test.right)}/${Math.round(test.total)}${failed}` : "not tested"}`,
79
+ evidence: {
80
+ runId: test?.runId ?? null,
81
+ liveRunId: board.liveRunId,
82
+ score: test ? { right: test.right, total: test.total } : null,
83
+ },
84
+ };
85
+ }
86
+
69
87
  const VS: Record<string, string> = {
70
88
  BETTER: "more accurate than live",
71
89
  SAME: "about as accurate as live",
@@ -94,15 +112,11 @@ export async function makeFallback(form: FormData) {
94
112
  const admin = await requireAdmin();
95
113
  const suite = suiteFrom(form);
96
114
  const model = formText(form, "model");
97
- const { row, evidence } = await evidenceFor(suite.key, model);
115
+ const { reason, evidence } = await fallbackEvidence(suite.key, model);
98
116
  await settings.update(
99
117
  suite.operation,
100
118
  { fallbackModel: model },
101
- {
102
- by: admin.name,
103
- reason: `Made ${row.label} the fallback: ${evidence.score.right}/${evidence.score.total}, from another provider`,
104
- evidence,
105
- },
119
+ { by: admin.name, reason, evidence },
106
120
  );
107
121
  revalidatePath(EVALS_BASE, "layout");
108
122
  }