@crowandraven/evalkeeper 0.0.0-stage → 0.1.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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/bin/evalkeeper.mjs +9 -0
  4. package/dist/attention.d.ts +49 -0
  5. package/dist/attention.js +48 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +55 -0
  8. package/dist/board.js +2 -0
  9. package/dist/board.js.map +1 -0
  10. package/dist/cli/addPages.d.ts +79 -0
  11. package/dist/cli/addPages.js +181 -0
  12. package/dist/cli/addPages.js.map +1 -0
  13. package/dist/cli/main.d.ts +18 -0
  14. package/dist/cli/main.js +115 -0
  15. package/dist/cli/main.js.map +1 -0
  16. package/dist/common.d.ts +72 -0
  17. package/dist/common.js +276 -0
  18. package/dist/common.js.map +1 -0
  19. package/dist/compare.d.ts +85 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/decisions.d.ts +84 -0
  23. package/dist/decisions.js +146 -0
  24. package/dist/decisions.js.map +1 -0
  25. package/dist/feedback.d.ts +114 -0
  26. package/dist/feedback.js +116 -0
  27. package/dist/feedback.js.map +1 -0
  28. package/dist/format.d.ts +6 -0
  29. package/dist/format.js +22 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/glossary.d.ts +46 -0
  32. package/dist/glossary.js +45 -0
  33. package/dist/glossary.js.map +1 -0
  34. package/dist/health.d.ts +40 -0
  35. package/dist/health.js +101 -0
  36. package/dist/health.js.map +1 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +34 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/labels.d.ts +8 -0
  41. package/dist/labels.js +15 -0
  42. package/dist/labels.js.map +1 -0
  43. package/dist/legs.d.ts +23 -0
  44. package/dist/legs.js +22 -0
  45. package/dist/legs.js.map +1 -0
  46. package/dist/memoryStore.d.ts +21 -0
  47. package/dist/memoryStore.js +228 -0
  48. package/dist/memoryStore.js.map +1 -0
  49. package/dist/models.d.ts +50 -0
  50. package/dist/models.js +39 -0
  51. package/dist/models.js.map +1 -0
  52. package/dist/prune.d.ts +48 -0
  53. package/dist/prune.js +77 -0
  54. package/dist/prune.js.map +1 -0
  55. package/dist/quickSet.d.ts +26 -0
  56. package/dist/quickSet.js +44 -0
  57. package/dist/quickSet.js.map +1 -0
  58. package/dist/runner.d.ts +193 -0
  59. package/dist/runner.js +706 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +157 -0
  62. package/dist/settings.js +183 -0
  63. package/dist/settings.js.map +1 -0
  64. package/dist/stableJson.d.ts +11 -0
  65. package/dist/stableJson.js +15 -0
  66. package/dist/stableJson.js.map +1 -0
  67. package/dist/stats.d.ts +98 -0
  68. package/dist/stats.js +119 -0
  69. package/dist/stats.js.map +1 -0
  70. package/dist/store.d.ts +146 -0
  71. package/dist/store.js +3 -0
  72. package/dist/store.js.map +1 -0
  73. package/dist/suggest.d.ts +75 -0
  74. package/dist/suggest.js +150 -0
  75. package/dist/suggest.js.map +1 -0
  76. package/dist/summary.d.ts +51 -0
  77. package/dist/summary.js +61 -0
  78. package/dist/summary.js.map +1 -0
  79. package/dist/testing.d.ts +26 -0
  80. package/dist/testing.js +341 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/timeout.d.ts +2 -0
  83. package/dist/timeout.js +15 -0
  84. package/dist/timeout.js.map +1 -0
  85. package/dist/types.d.ts +219 -0
  86. package/dist/types.js +46 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/upkeepRules.d.ts +28 -0
  89. package/dist/upkeepRules.js +40 -0
  90. package/dist/upkeepRules.js.map +1 -0
  91. package/dist/util.d.ts +8 -0
  92. package/dist/util.js +18 -0
  93. package/dist/util.js.map +1 -0
  94. package/dist/weekly.d.ts +80 -0
  95. package/dist/weekly.js +111 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +88 -0
  99. package/src/board.ts +56 -0
  100. package/src/cli/addPages.ts +256 -0
  101. package/src/cli/main.ts +125 -0
  102. package/src/common.ts +338 -0
  103. package/src/compare.ts +261 -0
  104. package/src/decisions.ts +230 -0
  105. package/src/feedback.ts +225 -0
  106. package/src/format.ts +19 -0
  107. package/src/glossary.ts +75 -0
  108. package/src/health.ts +147 -0
  109. package/src/index.ts +33 -0
  110. package/src/labels.ts +18 -0
  111. package/src/legs.ts +38 -0
  112. package/src/memoryStore.ts +268 -0
  113. package/src/models.ts +82 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +971 -0
  117. package/src/settings.ts +331 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +167 -0
  121. package/src/suggest.ts +245 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +392 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +252 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +167 -0
  129. package/starter/index.ts +86 -0
  130. package/templates/app/api/cron/evals-weekly/route.ts +24 -0
  131. package/templates/app/evals/[suite]/page.tsx +449 -0
  132. package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
  133. package/templates/app/evals/feedback/page.tsx +219 -0
  134. package/templates/app/evals/page.tsx +313 -0
  135. package/templates/components/evalkeeper/AddCase.tsx +62 -0
  136. package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
  137. package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
  138. package/templates/components/evalkeeper/Fields.tsx +110 -0
  139. package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
  140. package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
  141. package/templates/components/evalkeeper/Submit.tsx +58 -0
  142. package/templates/components/evalkeeper/Thumbs.tsx +117 -0
  143. package/templates/components/evalkeeper/Tip.tsx +98 -0
  144. package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
  145. package/templates/components/evalkeeper/evalkeeper.css +71 -0
  146. package/templates/components/evalkeeper/ui.tsx +103 -0
  147. package/templates/lib/evalkeeper/actions.ts +263 -0
  148. package/templates/lib/evalkeeper/cronAuth.ts +18 -0
  149. package/templates/lib/evalkeeper/paths.ts +11 -0
  150. package/templates/lib/evalkeeper/time.ts +4 -0
  151. package/templates/lib/evalkeeper/weekly.ts +51 -0
@@ -0,0 +1,219 @@
1
+ import Link from "next/link";
2
+ import {
3
+ asText,
4
+ thumbsSummary,
5
+ whyNotACase,
6
+ type AnySuite,
7
+ type FeedbackRecord,
8
+ } from "@crowandraven/evalkeeper";
9
+ import { FieldValues } from "@/components/evalkeeper/Fields";
10
+ import { Submit } from "@/components/evalkeeper/Submit";
11
+ import { InfoTip, Tip } from "@/components/evalkeeper/Tip";
12
+ import { BackLink, Card, Chip, pct } from "@/components/evalkeeper/ui";
13
+ import { ThumbsDown, ThumbsUp } from "lucide-react";
14
+ import { feedbackStore, requireAdmin, SUITES } from "@/lib/evalkeeper";
15
+ import { daysAgo } from "@/lib/evalkeeper/time";
16
+ import { dismissFeedback, makeCase } from "@/lib/evalkeeper/actions";
17
+ import { EVALS_BASE } from "@/lib/evalkeeper/paths";
18
+
19
+ export const dynamic = "force-dynamic";
20
+
21
+ /**
22
+ * What people thought of the AI's answers: thumbs per feature this week,
23
+ * and each rating waiting for a look, to make into a case or dismiss.
24
+ */
25
+ export default async function FeedbackPage() {
26
+ await requireAdmin();
27
+ const all = await feedbackStore.listFeedback();
28
+ const summary = thumbsSummary(all, daysAgo(7));
29
+ const open = all
30
+ .filter((f) => f.status === "OPEN")
31
+ .sort((a, b) => Number(b.rating === "DOWN") - Number(a.rating === "DOWN"));
32
+ const suiteFor = (op: string) => SUITES.find((s) => s.operation === op);
33
+ const label = (op: string) => suiteFor(op)?.label ?? op;
34
+
35
+ return (
36
+ <div className="space-y-8">
37
+ <div className="space-y-2">
38
+ <BackLink href={EVALS_BASE}>Evals</BackLink>
39
+ <h1 className="text-2xl font-semibold">Feedback</h1>
40
+ <p className="readable text-muted">
41
+ Thumbs up and down from people using the product. A rating on a kept
42
+ input can become a draft case, so the next model is tested on the
43
+ answer people didn&apos;t like.
44
+ </p>
45
+ </div>
46
+
47
+ <section className="space-y-3">
48
+ <h2 className="text-lg font-semibold">
49
+ This week
50
+ <InfoTip term="thumbs" />
51
+ </h2>
52
+ {summary.length === 0 ? (
53
+ <p className="text-sm text-muted">No ratings this week.</p>
54
+ ) : (
55
+ <Card className="overflow-x-auto p-0 lg:overflow-visible">
56
+ <table className="w-full text-sm">
57
+ <thead className="text-left text-muted">
58
+ <tr className="border-b border-line">
59
+ <th className="p-3 font-normal">Feature</th>
60
+ <th className="p-3 font-normal">Thumbs up</th>
61
+ <th className="p-3 font-normal">Thumbs down</th>
62
+ <th className="p-3 font-normal">Share down</th>
63
+ <th className="p-3 font-normal">Waiting</th>
64
+ </tr>
65
+ </thead>
66
+ <tbody>
67
+ {summary.map((s) => (
68
+ <tr
69
+ key={s.operation}
70
+ className="border-b border-line last:border-0"
71
+ >
72
+ <td className="p-3">
73
+ <span className="mr-2 font-medium">
74
+ {label(s.operation)}
75
+ </span>
76
+ {s.alert && (
77
+ <Tip
78
+ text="At least 5 ratings, and at least 20% of them thumbs down: worth a look."
79
+ id={`alert-${s.operation}`}
80
+ side="bottom"
81
+ >
82
+ <Chip tone="warn">Needs a look</Chip>
83
+ </Tip>
84
+ )}
85
+ </td>
86
+ <td className="p-3 tabular-nums">{s.up}</td>
87
+ <td className="p-3 tabular-nums">{s.down}</td>
88
+ <td className="p-3 tabular-nums">
89
+ {s.downShare === null ? "—" : pct(s.downShare)}
90
+ </td>
91
+ <td className="p-3 tabular-nums">{s.open}</td>
92
+ </tr>
93
+ ))}
94
+ </tbody>
95
+ </table>
96
+ </Card>
97
+ )}
98
+ </section>
99
+
100
+ <section className="space-y-3">
101
+ <h2 className="text-lg font-semibold">
102
+ Waiting for a look ({open.length})
103
+ </h2>
104
+ {open.length === 0 && (
105
+ <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.
111
+ </p>
112
+ )}
113
+ <div className="space-y-3">
114
+ {open.map((f) => (
115
+ <Rating
116
+ key={f.id}
117
+ fields={suiteFor(f.operation)?.fields}
118
+ f={f}
119
+ feature={label(f.operation)}
120
+ why={whyNotACase(f, suiteFor(f.operation))}
121
+ />
122
+ ))}
123
+ </div>
124
+ </section>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function Rating({
130
+ f,
131
+ fields,
132
+ feature,
133
+ why,
134
+ }: {
135
+ f: FeedbackRecord;
136
+ fields: AnySuite["fields"];
137
+ feature: string;
138
+ why: string | null;
139
+ }) {
140
+ return (
141
+ <article
142
+ aria-label={`Rating: ${feature}${f.reason ? `, ${f.reason}` : ""}`}
143
+ >
144
+ <Card className="space-y-3 text-sm">
145
+ <div className="flex flex-wrap items-center justify-between gap-2">
146
+ <span className="flex items-center gap-2">
147
+ <Chip tone={f.rating === "DOWN" ? "serious" : "live"}>
148
+ {f.rating === "DOWN" ? (
149
+ <>
150
+ <ThumbsDown aria-hidden className="h-3.5 w-3.5" /> Thumbs down
151
+ </>
152
+ ) : (
153
+ <>
154
+ <ThumbsUp aria-hidden className="h-3.5 w-3.5" /> Thumbs up
155
+ </>
156
+ )}
157
+ </Chip>
158
+ <span className="font-medium">{feature}</span>
159
+ </span>
160
+ <span className="text-xs text-muted">
161
+ {f.updatedAt.toLocaleString()}
162
+ </span>
163
+ </div>
164
+ {f.reason && <p className="readable">&ldquo;{f.reason}&rdquo;</p>}
165
+ <div className="grid gap-4 md:grid-cols-2">
166
+ <div className="space-y-1">
167
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted">
168
+ Input
169
+ </p>
170
+ {f.input === null ? (
171
+ <p className="text-muted">Not kept</p>
172
+ ) : fields ? (
173
+ <FieldValues
174
+ fields={fields.input}
175
+ values={f.input}
176
+ prefix={`${f.id}-in-`}
177
+ />
178
+ ) : (
179
+ <p className="whitespace-pre-wrap font-mono text-xs">
180
+ {asText(f.input)}
181
+ </p>
182
+ )}
183
+ </div>
184
+ <div className="space-y-1">
185
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted">
186
+ Answer
187
+ </p>
188
+ {fields && f.output !== null ? (
189
+ <FieldValues
190
+ fields={fields.label}
191
+ values={f.output}
192
+ prefix={`${f.id}-out-`}
193
+ />
194
+ ) : (
195
+ <p className="whitespace-pre-wrap font-mono text-xs">
196
+ {asText(f.output) || f.excerpt || "—"}
197
+ </p>
198
+ )}
199
+ </div>
200
+ </div>
201
+ <div className="flex flex-wrap items-center gap-2">
202
+ {why === null ? (
203
+ <form action={makeCase} className="flex items-center gap-1">
204
+ <input type="hidden" name="feedback" value={f.id} />
205
+ <Submit>Make a case</Submit>
206
+ <InfoTip term="makeCase" id={`make-${f.id}`} />
207
+ </form>
208
+ ) : (
209
+ <p className="readable text-xs text-muted">{why}</p>
210
+ )}
211
+ <form action={dismissFeedback}>
212
+ <input type="hidden" name="feedback" value={f.id} />
213
+ <Submit variant="quiet">Dismiss</Submit>
214
+ </form>
215
+ </div>
216
+ </Card>
217
+ </article>
218
+ );
219
+ }
@@ -0,0 +1,313 @@
1
+ import Link from "next/link";
2
+ import {
3
+ attention,
4
+ perAnswer,
5
+ seconds,
6
+ suiteHealth,
7
+ thumbsSummary,
8
+ type GlossaryTerm,
9
+ type HistoryEntry,
10
+ type Settings,
11
+ type SettingsVersion,
12
+ } from "@crowandraven/evalkeeper";
13
+ import { Card, StatusBadge, pct } from "@/components/evalkeeper/ui";
14
+ import {
15
+ feedbackStore,
16
+ MODELS,
17
+ requireAdmin,
18
+ runner,
19
+ settings,
20
+ SUITES,
21
+ } from "@/lib/evalkeeper";
22
+ import { daysAgo } from "@/lib/evalkeeper/time";
23
+ import { loadWeekly } from "@/lib/evalkeeper/weekly";
24
+ import { WeeklyCheck } from "@/components/evalkeeper/WeeklyCheck";
25
+ import { InfoTip } from "@/components/evalkeeper/Tip";
26
+ import { evalsPath } from "@/lib/evalkeeper/paths";
27
+ import { HistoryEntries } from "@/components/evalkeeper/SettingsHistory";
28
+ import { ChevronDown } from "lucide-react";
29
+
30
+ export const dynamic = "force-dynamic";
31
+
32
+ /** The evals hub: where each suite stands, and what needs attention. */
33
+ export default async function EvalsHub() {
34
+ await requireAdmin();
35
+ const [boards, weekly] = await Promise.all([
36
+ Promise.all(SUITES.map((s) => runner.board(s.key))),
37
+ loadWeekly(),
38
+ ]);
39
+ const histories = await Promise.all(
40
+ SUITES.map((s) =>
41
+ Promise.all([
42
+ settings.history(s.operation),
43
+ settings.current(s.operation),
44
+ ]),
45
+ ),
46
+ );
47
+ const thumbs = thumbsSummary(await feedbackStore.listFeedback(), daysAgo(7));
48
+ const down = thumbs.reduce((t, s) => t + s.down, 0);
49
+ const waiting = thumbs.reduce((t, s) => t + s.open, 0);
50
+ const flagged = thumbs.filter((s) => s.alert).length;
51
+ const needs = attention(
52
+ boards.map((b) => ({
53
+ key: b.suite.key,
54
+ label: b.suite.label,
55
+ rows: b.rows,
56
+ toReview: b.provisional,
57
+ })),
58
+ );
59
+ return (
60
+ <div className="space-y-8">
61
+ <div className="readable space-y-2">
62
+ <h1 className="text-2xl font-semibold">Evals</h1>
63
+ <p className="text-muted">
64
+ Each suite tests one AI feature against answers people confirmed. Test
65
+ models on it, then pick the live one on evidence.
66
+ </p>
67
+ </div>
68
+
69
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
70
+ <Stat
71
+ title="Most room to improve"
72
+ term="improve"
73
+ value={needs.improve ? pct(needs.improve.best) : "—"}
74
+ detail={
75
+ !needs.improve
76
+ ? "Test a suite to see"
77
+ : needs.improve.best >= 1
78
+ ? "Every suite has a model scoring 100%"
79
+ : `${needs.improve.label}, best on ${needs.improve.model}`
80
+ }
81
+ />
82
+ <Stat
83
+ title="Slowest live model"
84
+ term="slowest"
85
+ value={needs.slowest ? seconds(needs.slowest.ms) : "—"}
86
+ detail={
87
+ needs.slowest
88
+ ? `${needs.slowest.label} on ${needs.slowest.model}`
89
+ : "—"
90
+ }
91
+ />
92
+ <Stat
93
+ title="Costliest live model"
94
+ term="costliest"
95
+ value={needs.costliest ? perAnswer(needs.costliest.usd) : "—"}
96
+ detail={
97
+ needs.costliest
98
+ ? `${needs.costliest.label} on ${needs.costliest.model}`
99
+ : "—"
100
+ }
101
+ />
102
+ <Link href={evalsPath("feedback")}>
103
+ <Stat
104
+ title="Thumbs down this week"
105
+ term="thumbs"
106
+ value={String(down)}
107
+ detail={
108
+ flagged
109
+ ? `${flagged} feature${flagged === 1 ? "" : "s"} need a look · ${waiting} waiting`
110
+ : `${waiting} waiting for a look`
111
+ }
112
+ />
113
+ </Link>
114
+ </div>
115
+
116
+ <WeeklyCheck weekly={weekly} />
117
+
118
+ <section aria-labelledby="suites" className="space-y-3">
119
+ <h2 id="suites" className="text-lg font-semibold">
120
+ Suites
121
+ <InfoTip term="suites" />
122
+ </h2>
123
+ <div className="space-y-4">
124
+ {boards.map((b, i) => {
125
+ const h = suiteHealth(
126
+ b.rows,
127
+ b.suite.checks,
128
+ b.suite.priority,
129
+ b.cases,
130
+ b.provisional,
131
+ MODELS,
132
+ );
133
+ const [history, current] = histories[i] ?? [[], null];
134
+ return (
135
+ <SuiteCard
136
+ key={b.suite.key}
137
+ board={b}
138
+ health={h}
139
+ history={history}
140
+ current={current}
141
+ />
142
+ );
143
+ })}
144
+ </div>
145
+ </section>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ function Stat({
151
+ title,
152
+ term,
153
+ value,
154
+ detail,
155
+ }: {
156
+ title: string;
157
+ term: GlossaryTerm;
158
+ value: string;
159
+ detail: string;
160
+ }) {
161
+ return (
162
+ <Card className="space-y-1">
163
+ <p className="text-sm text-muted">
164
+ {title}
165
+ <InfoTip term={term} />
166
+ </p>
167
+ <p className="text-2xl font-semibold tabular-nums">{value}</p>
168
+ <p className="text-sm text-muted">{detail}</p>
169
+ </Card>
170
+ );
171
+ }
172
+
173
+ type Board = Awaited<ReturnType<typeof runner.board>>;
174
+
175
+ const label = (model: string) => MODELS[model]?.label ?? model;
176
+ const day = (d: Date) => d.toLocaleDateString();
177
+ /** Green at 95% and up, amber from 80%, red below. */
178
+ const scoreTone = (rate: number) =>
179
+ rate >= 0.95 ? "text-good" : rate >= 0.8 ? "text-warn" : "text-bad";
180
+
181
+ /**
182
+ * One feature on the hub: its live version and models, what the live
183
+ * model costs and how fast it is, its latest test, what needs a look, and
184
+ * its settings history.
185
+ */
186
+ function SuiteCard({
187
+ board: b,
188
+ health: h,
189
+ history,
190
+ current,
191
+ }: {
192
+ board: Board;
193
+ health: ReturnType<typeof suiteHealth>;
194
+ history: HistoryEntry[];
195
+ current: { settings: Settings; version: SettingsVersion | null } | null;
196
+ }) {
197
+ const live = b.rows.find((r) => r.live && !r.variant);
198
+ const t = live?.test;
199
+ const version = current?.version ?? null;
200
+ const since = version
201
+ ? (history.find((e) => e.to.id === version.id)?.createdAt ??
202
+ version.createdAt)
203
+ : null;
204
+ const s = current?.settings;
205
+ const price = s ? MODELS[s.model] : undefined;
206
+ const versions = Math.max(1, ...history.map((e) => e.to.number));
207
+ return (
208
+ <Card className="relative space-y-4 transition-colors hover:border-muted">
209
+ <div className="flex flex-wrap items-start justify-between gap-4">
210
+ <div className="min-w-0 space-y-2">
211
+ <div className="flex flex-wrap items-center gap-2">
212
+ <h2 className="font-semibold">
213
+ {/* Covers the whole card; the versions toggle sits above it. */}
214
+ <Link
215
+ href={evalsPath(b.suite.key)}
216
+ className="after:absolute after:inset-0 after:rounded-lg"
217
+ >
218
+ {b.suite.label}
219
+ </Link>
220
+ </h2>
221
+ <StatusBadge status={h.status} />
222
+ </div>
223
+ <p className="readable text-sm text-muted">{b.suite.description}</p>
224
+ </div>
225
+ {t && (
226
+ <div className="shrink-0 space-y-0.5 text-right text-sm">
227
+ <p className="font-semibold tabular-nums">
228
+ {perAnswer(t.costPerAnswer)} / answer
229
+ </p>
230
+ <p className="text-xs text-muted">{seconds(t.avgMs)} avg</p>
231
+ {price?.inputPer1M !== undefined &&
232
+ price.outputPer1M !== undefined && (
233
+ <p className="text-xs text-muted">
234
+ ${price.inputPer1M} / ${price.outputPer1M} per 1M
235
+ </p>
236
+ )}
237
+ </div>
238
+ )}
239
+ </div>
240
+
241
+ {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>
257
+ )}
258
+
259
+ <p className="text-sm">
260
+ {t ? (
261
+ <>
262
+ Score{" "}
263
+ <span className={`font-semibold ${scoreTone(t.right / t.total)}`}>
264
+ {pct(t.right / t.total)}
265
+ </span>
266
+ </>
267
+ ) : (
268
+ "Live model not tested yet"
269
+ )}{" "}
270
+ · {b.cases} cases · {h.testedModels} models tested ·{" "}
271
+ <Link
272
+ href={evalsPath(b.suite.key)}
273
+ className="relative text-accent hover:underline"
274
+ >
275
+ Compare models
276
+ </Link>
277
+ </p>
278
+
279
+ {h.details.length > 0 && (
280
+ <ul className="readable list-disc space-y-1 pl-5 text-sm text-muted">
281
+ {h.details.slice(0, 3).map((d) => (
282
+ <li key={d}>{d}</li>
283
+ ))}
284
+ </ul>
285
+ )}
286
+
287
+ {s && (
288
+ <details className="group/history relative rounded-md bg-tint">
289
+ <summary className="flex list-none items-center justify-between gap-2 p-3 text-sm font-medium">
290
+ Versions
291
+ <span className="flex items-center gap-1 text-xs font-normal text-muted">
292
+ {versions} version{versions === 1 ? "" : "s"}
293
+ <ChevronDown
294
+ aria-hidden
295
+ className="h-4 w-4 transition-transform group-open/history:rotate-180"
296
+ />
297
+ </span>
298
+ </summary>
299
+ <div className="px-3 pb-3">
300
+ <HistoryEntries
301
+ suite={b.suite.key}
302
+ entries={history}
303
+ liveId={version?.id ?? null}
304
+ label={label}
305
+ defaults={s}
306
+ plain
307
+ />
308
+ </div>
309
+ </details>
310
+ )}
311
+ </Card>
312
+ );
313
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Plus } from "lucide-react";
5
+ import { Card } from "./ui";
6
+
7
+ /**
8
+ * A section's header row with an "Add a case" button at the top right,
9
+ * beside `tools`; the button opens `form` below the header.
10
+ */
11
+ export function AddCaseHeader({
12
+ heading,
13
+ tools,
14
+ form,
15
+ }: {
16
+ heading: React.ReactNode;
17
+ tools?: React.ReactNode;
18
+ /** The add-a-case form; null hides the button. */
19
+ form: React.ReactNode;
20
+ }) {
21
+ const [open, setOpen] = useState(false);
22
+ return (
23
+ <>
24
+ <div className="flex flex-wrap items-center justify-between gap-4">
25
+ {heading}
26
+ <div className="flex items-center gap-2">
27
+ {form && (
28
+ <button
29
+ type="button"
30
+ aria-expanded={open}
31
+ onClick={() => {
32
+ setOpen(!open);
33
+ }}
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
+ >
36
+ <Plus aria-hidden className="h-4 w-4" />
37
+ Add a case
38
+ </button>
39
+ )}
40
+ {tools}
41
+ </div>
42
+ </div>
43
+ {open && form && (
44
+ <Card className="space-y-4">
45
+ <div className="flex items-center justify-between gap-2">
46
+ <h3 className="font-medium">New case</h3>
47
+ <button
48
+ type="button"
49
+ onClick={() => {
50
+ setOpen(false);
51
+ }}
52
+ className="text-sm text-muted hover:text-ink"
53
+ >
54
+ Cancel
55
+ </button>
56
+ </div>
57
+ {form}
58
+ </Card>
59
+ )}
60
+ </>
61
+ );
62
+ }