@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.
- 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 +48 -0
- package/dist/attention.js.map +1 -0
- package/dist/board.d.ts +55 -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 +50 -0
- package/dist/models.js +39 -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 +193 -0
- package/dist/runner.js +706 -0
- package/dist/runner.js.map +1 -0
- package/dist/settings.d.ts +157 -0
- package/dist/settings.js +183 -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 +146 -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 +150 -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 +341 -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 +219 -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 +111 -0
- package/dist/weekly.js.map +1 -0
- package/package.json +81 -4
- package/src/attention.ts +88 -0
- package/src/board.ts +56 -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 +147 -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 +82 -0
- package/src/prune.ts +95 -0
- package/src/quickSet.ts +55 -0
- package/src/runner.ts +971 -0
- package/src/settings.ts +331 -0
- package/src/stableJson.ts +24 -0
- package/src/stats.ts +215 -0
- package/src/store.ts +167 -0
- package/src/suggest.ts +245 -0
- package/src/summary.ts +109 -0
- package/src/testing.ts +392 -0
- package/src/timeout.ts +14 -0
- package/src/types.ts +252 -0
- package/src/upkeepRules.ts +46 -0
- package/src/util.ts +17 -0
- package/src/weekly.ts +167 -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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allPassed,
|
|
3
|
+
type CaseRecord,
|
|
4
|
+
type GlossaryTerm,
|
|
5
|
+
type OutputRecord,
|
|
6
|
+
type AnySuite,
|
|
7
|
+
type BoardRow,
|
|
8
|
+
type CheckResult,
|
|
9
|
+
} from "@crowandraven/evalkeeper";
|
|
10
|
+
import {
|
|
11
|
+
addCase,
|
|
12
|
+
confirmAnswer,
|
|
13
|
+
editAnswer,
|
|
14
|
+
retireCase,
|
|
15
|
+
} from "@/lib/evalkeeper/actions";
|
|
16
|
+
import { Check, ChevronRight, CircleAlert, X } from "lucide-react";
|
|
17
|
+
import { store } from "@/lib/evalkeeper";
|
|
18
|
+
import { FieldValues, Fields } from "./Fields";
|
|
19
|
+
import { AddCaseHeader } from "./AddCase";
|
|
20
|
+
import { CaseMenu } from "./CaseMenu";
|
|
21
|
+
import { Submit } from "./Submit";
|
|
22
|
+
import { Chip, type ChipTone } from "./ui";
|
|
23
|
+
import { InfoTip, Tip } from "./Tip";
|
|
24
|
+
|
|
25
|
+
/** A suite's cases in use, with each case's answers newest first. */
|
|
26
|
+
async function loadCases(suite: string): Promise<CaseRecord[]> {
|
|
27
|
+
return (await store.listCases(suite)).map((c) => ({
|
|
28
|
+
...c,
|
|
29
|
+
labels: [...c.labels].sort((a, b) => +b.updatedAt - +a.updatedAt),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STATUS: Record<
|
|
34
|
+
string,
|
|
35
|
+
{ text: string; tone: ChipTone; term: GlossaryTerm }
|
|
36
|
+
> = {
|
|
37
|
+
CONFIRMED: { text: "Confirmed", tone: "live", term: "confirmed" },
|
|
38
|
+
PROVISIONAL: { text: "Provisional", tone: "warn", term: "provisional" },
|
|
39
|
+
PENDING: { text: "Pending", tone: "warn", term: "pending" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The answer key: every case in use, each opening to its input, its
|
|
44
|
+
* answer, and how each model's latest test did on it, with forms to add a
|
|
45
|
+
* case, edit an answer, or retire a case.
|
|
46
|
+
*/
|
|
47
|
+
export async function AnswerKey({
|
|
48
|
+
suite,
|
|
49
|
+
rows,
|
|
50
|
+
heading,
|
|
51
|
+
tools,
|
|
52
|
+
}: {
|
|
53
|
+
suite: AnySuite;
|
|
54
|
+
rows: BoardRow[];
|
|
55
|
+
/** The section's heading, with "Add a case" across from it. */
|
|
56
|
+
heading: React.ReactNode;
|
|
57
|
+
/** More buttons beside "Add a case" (rescore). */
|
|
58
|
+
tools?: React.ReactNode;
|
|
59
|
+
}) {
|
|
60
|
+
const cases = await loadCases(suite.key);
|
|
61
|
+
// Each model's latest test (at the operation's setting), by case.
|
|
62
|
+
const modelByRun = new Map(
|
|
63
|
+
rows.flatMap((r) =>
|
|
64
|
+
r.test && !r.variant ? [[r.test.runId, r.label] as const] : [],
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
const outputs = (
|
|
68
|
+
await Promise.all([...modelByRun.keys()].map((id) => store.listOutputs(id)))
|
|
69
|
+
).flat();
|
|
70
|
+
const byCase = new Map<string, { model: string; output: OutputRecord }[]>();
|
|
71
|
+
for (const o of outputs) {
|
|
72
|
+
byCase.set(o.caseId, [
|
|
73
|
+
...(byCase.get(o.caseId) ?? []),
|
|
74
|
+
{ model: modelByRun.get(o.runId) ?? o.runId, output: o },
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
const fields = suite.fields;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-4">
|
|
81
|
+
<AddCaseHeader
|
|
82
|
+
heading={heading}
|
|
83
|
+
tools={tools}
|
|
84
|
+
form={
|
|
85
|
+
fields &&
|
|
86
|
+
suite.labelFromForm && (
|
|
87
|
+
<form action={addCase} className="space-y-4">
|
|
88
|
+
<input type="hidden" name="suite" value={suite.key} />
|
|
89
|
+
<Fields fields={fields.input} prefix="new-" />
|
|
90
|
+
<p className="readable text-sm text-muted">
|
|
91
|
+
What a good answer is. You're writing it, so it's confirmed.
|
|
92
|
+
</p>
|
|
93
|
+
<Fields fields={fields.label} prefix="new-" />
|
|
94
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
95
|
+
<div className="space-y-1">
|
|
96
|
+
<label
|
|
97
|
+
htmlFor="new-tags"
|
|
98
|
+
className="block text-sm font-medium"
|
|
99
|
+
>
|
|
100
|
+
Tags
|
|
101
|
+
<InfoTip term="tags" id="new-tags" />
|
|
102
|
+
</label>
|
|
103
|
+
<input
|
|
104
|
+
id="new-tags"
|
|
105
|
+
name="tags"
|
|
106
|
+
placeholder="e.g. billing, money now"
|
|
107
|
+
className="w-full rounded-md border border-line bg-card px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
|
108
|
+
/>
|
|
109
|
+
<p className="text-xs text-muted">
|
|
110
|
+
What this case tests, comma-separated. The quick set picks
|
|
111
|
+
cases to cover the most tags.
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="space-y-1">
|
|
115
|
+
<label
|
|
116
|
+
htmlFor="new-split"
|
|
117
|
+
className="block text-sm font-medium"
|
|
118
|
+
>
|
|
119
|
+
Set
|
|
120
|
+
<InfoTip
|
|
121
|
+
text="DEV: cases you tune against. TEST: cases you only check, never tune against, so they show how a change does on cases it wasn't fitted to."
|
|
122
|
+
id="new-split"
|
|
123
|
+
/>
|
|
124
|
+
</label>
|
|
125
|
+
<select
|
|
126
|
+
id="new-split"
|
|
127
|
+
name="split"
|
|
128
|
+
defaultValue="DEV"
|
|
129
|
+
className="w-full rounded-md border border-line bg-card px-3 py-2 text-sm"
|
|
130
|
+
>
|
|
131
|
+
<option value="DEV">DEV: tune against it</option>
|
|
132
|
+
<option value="TEST">
|
|
133
|
+
TEST: check only, never tune against
|
|
134
|
+
</option>
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="flex gap-2">
|
|
139
|
+
<Submit>Add case</Submit>
|
|
140
|
+
</div>
|
|
141
|
+
</form>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
<div className="space-y-2">
|
|
147
|
+
{cases.map((c) => (
|
|
148
|
+
<CaseItem
|
|
149
|
+
key={c.id}
|
|
150
|
+
suite={suite}
|
|
151
|
+
c={c}
|
|
152
|
+
results={byCase.get(c.id) ?? []}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
{!cases.length && <p className="text-sm text-muted">No cases yet.</p>}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function CaseItem({
|
|
162
|
+
suite,
|
|
163
|
+
c,
|
|
164
|
+
results,
|
|
165
|
+
}: {
|
|
166
|
+
suite: AnySuite;
|
|
167
|
+
c: CaseRecord;
|
|
168
|
+
results: {
|
|
169
|
+
model: string;
|
|
170
|
+
output: { scores: unknown; error: string | null };
|
|
171
|
+
}[];
|
|
172
|
+
}) {
|
|
173
|
+
const answer =
|
|
174
|
+
c.labels.find((l) => l.status === "CONFIRMED") ??
|
|
175
|
+
c.labels.find((l) => l.status === "PROVISIONAL") ??
|
|
176
|
+
c.labels.find((l) => l.status === "PENDING");
|
|
177
|
+
const status = (answer && STATUS[answer.status]) ?? {
|
|
178
|
+
text: "No answer",
|
|
179
|
+
tone: "warn" as const,
|
|
180
|
+
term: "needsAnswer" as const,
|
|
181
|
+
};
|
|
182
|
+
const input = suite.input.parse(c.input);
|
|
183
|
+
const right = results.filter(
|
|
184
|
+
(r) =>
|
|
185
|
+
!r.output.error && allPassed(r.output.scores as CheckResult[] | null),
|
|
186
|
+
).length;
|
|
187
|
+
const label = (key: string) =>
|
|
188
|
+
suite.checks.find((x) => x.key === key)?.label ?? key;
|
|
189
|
+
const tags = Array.isArray(c.tags) ? c.tags : [];
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<details className="group rounded-md border border-line bg-card">
|
|
193
|
+
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 p-3 text-sm">
|
|
194
|
+
<span className="font-medium">
|
|
195
|
+
<ChevronRight
|
|
196
|
+
aria-hidden
|
|
197
|
+
className="mr-1.5 inline-block h-4 w-4 align-[-3px] text-muted transition-transform group-open:rotate-90"
|
|
198
|
+
/>
|
|
199
|
+
{suite.title(input)}
|
|
200
|
+
</span>
|
|
201
|
+
<span className="flex items-center gap-1.5">
|
|
202
|
+
<Tip term={status.term} id={`status-${c.id}`}>
|
|
203
|
+
<Chip tone={status.tone}>{status.text}</Chip>
|
|
204
|
+
</Tip>
|
|
205
|
+
<Tip term={c.split === "TEST" ? "test" : "dev"} id={`split-${c.id}`}>
|
|
206
|
+
<Chip tone="neutral">{c.split === "TEST" ? "Test" : "Dev"}</Chip>
|
|
207
|
+
</Tip>
|
|
208
|
+
{results.length > 0 && (
|
|
209
|
+
<Tip
|
|
210
|
+
text={`Right on ${right} of ${results.length} models' latest tests.`}
|
|
211
|
+
id={`right-${c.id}`}
|
|
212
|
+
align="end"
|
|
213
|
+
>
|
|
214
|
+
<span
|
|
215
|
+
className={`ml-1 inline-flex items-center gap-1 text-xs tabular-nums ${right === results.length ? "text-good" : "text-muted"}`}
|
|
216
|
+
>
|
|
217
|
+
<Check aria-hidden className="h-3.5 w-3.5" />
|
|
218
|
+
{right}/{results.length}
|
|
219
|
+
</span>
|
|
220
|
+
</Tip>
|
|
221
|
+
)}
|
|
222
|
+
</span>
|
|
223
|
+
</summary>
|
|
224
|
+
|
|
225
|
+
<div className="relative space-y-5 px-3 pb-4 text-sm">
|
|
226
|
+
<div className="grid gap-5 md:grid-cols-2">
|
|
227
|
+
<section className="space-y-2">
|
|
228
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
229
|
+
Input
|
|
230
|
+
</h4>
|
|
231
|
+
{suite.fields ? (
|
|
232
|
+
<FieldValues
|
|
233
|
+
fields={suite.fields.input}
|
|
234
|
+
values={c.input}
|
|
235
|
+
prefix={`${c.id}-in-`}
|
|
236
|
+
/>
|
|
237
|
+
) : (
|
|
238
|
+
<pre className="whitespace-pre-wrap text-xs">
|
|
239
|
+
{suite.describe?.(input) ?? JSON.stringify(c.input, null, 2)}
|
|
240
|
+
</pre>
|
|
241
|
+
)}
|
|
242
|
+
</section>
|
|
243
|
+
<section className="space-y-2">
|
|
244
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
245
|
+
Answer
|
|
246
|
+
</h4>
|
|
247
|
+
{answer && suite.fields ? (
|
|
248
|
+
<FieldValues
|
|
249
|
+
fields={suite.fields.label}
|
|
250
|
+
values={answer.value}
|
|
251
|
+
prefix={`${c.id}-ans-`}
|
|
252
|
+
/>
|
|
253
|
+
) : (
|
|
254
|
+
<pre className="whitespace-pre-wrap text-xs">
|
|
255
|
+
{JSON.stringify(answer?.value ?? null, null, 2)}
|
|
256
|
+
</pre>
|
|
257
|
+
)}
|
|
258
|
+
{(tags.length > 0 || c.note) && (
|
|
259
|
+
<p className="readable text-xs text-muted">
|
|
260
|
+
{tags.length > 0 && `Tags: ${tags.join(", ")}. `}
|
|
261
|
+
{c.note}
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
264
|
+
</section>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{results.length > 0 && (
|
|
268
|
+
<section className="space-y-2">
|
|
269
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
270
|
+
Each model's latest test
|
|
271
|
+
</h4>
|
|
272
|
+
<ul className="space-y-1">
|
|
273
|
+
{results.map(({ model, output }) => {
|
|
274
|
+
const checks = (output.scores as CheckResult[] | null) ?? null;
|
|
275
|
+
const failed = checks?.filter((x) => x.pass === false) ?? [];
|
|
276
|
+
return (
|
|
277
|
+
<li key={model} className="flex items-start gap-1.5">
|
|
278
|
+
{output.error ? (
|
|
279
|
+
<CircleAlert
|
|
280
|
+
aria-label="No answer"
|
|
281
|
+
className="mt-0.5 h-4 w-4 shrink-0 text-bad"
|
|
282
|
+
/>
|
|
283
|
+
) : failed.length ? (
|
|
284
|
+
<X
|
|
285
|
+
aria-label="Wrong"
|
|
286
|
+
className="mt-0.5 h-4 w-4 shrink-0 text-warn"
|
|
287
|
+
/>
|
|
288
|
+
) : (
|
|
289
|
+
<Check
|
|
290
|
+
aria-label="Right"
|
|
291
|
+
className="mt-0.5 h-4 w-4 shrink-0 text-good"
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
<span>
|
|
295
|
+
<span className="font-medium">{model}</span>
|
|
296
|
+
{output.error && (
|
|
297
|
+
<span className="text-muted">: {output.error}</span>
|
|
298
|
+
)}
|
|
299
|
+
{failed.map((x) => (
|
|
300
|
+
<span key={x.key} className="text-muted">
|
|
301
|
+
{" "}
|
|
302
|
+
· {label(x.key)}
|
|
303
|
+
{x.note ? `: ${x.note}` : ""}
|
|
304
|
+
</span>
|
|
305
|
+
))}
|
|
306
|
+
</span>
|
|
307
|
+
</li>
|
|
308
|
+
);
|
|
309
|
+
})}
|
|
310
|
+
</ul>
|
|
311
|
+
</section>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{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>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
<CaseMenu
|
|
323
|
+
suite={suite.key}
|
|
324
|
+
caseId={c.id}
|
|
325
|
+
retire={retireCase}
|
|
326
|
+
startEditing={!answer}
|
|
327
|
+
editLabel={
|
|
328
|
+
suite.fields && suite.labelFromForm
|
|
329
|
+
? answer
|
|
330
|
+
? "Edit answer"
|
|
331
|
+
: "Write the answer"
|
|
332
|
+
: null
|
|
333
|
+
}
|
|
334
|
+
edit={
|
|
335
|
+
suite.fields && (
|
|
336
|
+
<form
|
|
337
|
+
key={answer?.updatedAt.toISOString() ?? "none"}
|
|
338
|
+
action={editAnswer}
|
|
339
|
+
className="space-y-4"
|
|
340
|
+
>
|
|
341
|
+
<input type="hidden" name="suite" value={suite.key} />
|
|
342
|
+
<input type="hidden" name="case" value={c.id} />
|
|
343
|
+
<Fields
|
|
344
|
+
fields={suite.fields.label}
|
|
345
|
+
values={answer?.value}
|
|
346
|
+
prefix={`${c.id}-`}
|
|
347
|
+
/>
|
|
348
|
+
<p className="readable text-xs text-muted">
|
|
349
|
+
Saves a new confirmed answer and keeps the old one in the
|
|
350
|
+
history. Tests are rescored for free.
|
|
351
|
+
</p>
|
|
352
|
+
<Submit>Save answer</Submit>
|
|
353
|
+
</form>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
</details>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { EllipsisVertical } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A case's menu, in the top right of the open case: edit its answer (the
|
|
8
|
+
* form opens below the menu's row) and retire it. Closes on a click
|
|
9
|
+
* outside or Escape.
|
|
10
|
+
*/
|
|
11
|
+
export function CaseMenu({
|
|
12
|
+
suite,
|
|
13
|
+
caseId,
|
|
14
|
+
editLabel,
|
|
15
|
+
edit,
|
|
16
|
+
retire,
|
|
17
|
+
startEditing = false,
|
|
18
|
+
}: {
|
|
19
|
+
suite: string;
|
|
20
|
+
caseId: string;
|
|
21
|
+
/** "Edit answer" or "Write the answer"; null when there's no form. */
|
|
22
|
+
editLabel: string | null;
|
|
23
|
+
/** The answer form, shown when someone picks editLabel. */
|
|
24
|
+
edit: React.ReactNode;
|
|
25
|
+
/** Your server action that retires a case (gets suite and case). */
|
|
26
|
+
retire: (form: FormData) => Promise<void>;
|
|
27
|
+
/** Open the form from the start, for a case with no answer yet. */
|
|
28
|
+
startEditing?: boolean;
|
|
29
|
+
}) {
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const [editing, setEditing] = useState(startEditing);
|
|
32
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!open) return;
|
|
36
|
+
const close = (e: MouseEvent | KeyboardEvent) => {
|
|
37
|
+
if (
|
|
38
|
+
e instanceof KeyboardEvent
|
|
39
|
+
? e.key === "Escape"
|
|
40
|
+
: !ref.current?.contains(e.target as Node)
|
|
41
|
+
)
|
|
42
|
+
setOpen(false);
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener("mousedown", close);
|
|
45
|
+
document.addEventListener("keydown", close);
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener("mousedown", close);
|
|
48
|
+
document.removeEventListener("keydown", close);
|
|
49
|
+
};
|
|
50
|
+
}, [open]);
|
|
51
|
+
|
|
52
|
+
const item =
|
|
53
|
+
"block w-full rounded px-3 py-1.5 text-left text-sm hover:bg-tint";
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<div ref={ref} className="absolute right-3 top-0 z-10">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
aria-label="Case actions"
|
|
61
|
+
title="Case actions"
|
|
62
|
+
aria-haspopup="menu"
|
|
63
|
+
aria-expanded={open}
|
|
64
|
+
onClick={() => {
|
|
65
|
+
setOpen(!open);
|
|
66
|
+
}}
|
|
67
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-tint hover:text-ink"
|
|
68
|
+
>
|
|
69
|
+
<EllipsisVertical aria-hidden className="h-4 w-4" />
|
|
70
|
+
</button>
|
|
71
|
+
{open && (
|
|
72
|
+
<div
|
|
73
|
+
role="menu"
|
|
74
|
+
className="absolute right-0 mt-1 w-44 rounded-md border border-line bg-card p-1 shadow-lg"
|
|
75
|
+
>
|
|
76
|
+
{editLabel && (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
role="menuitem"
|
|
80
|
+
className={item}
|
|
81
|
+
onClick={() => {
|
|
82
|
+
setEditing(true);
|
|
83
|
+
setOpen(false);
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{editLabel}
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
89
|
+
<form action={retire}>
|
|
90
|
+
<input type="hidden" name="suite" value={suite} />
|
|
91
|
+
<input type="hidden" name="case" value={caseId} />
|
|
92
|
+
<button
|
|
93
|
+
type="submit"
|
|
94
|
+
role="menuitem"
|
|
95
|
+
className={`${item} text-bad`}
|
|
96
|
+
>
|
|
97
|
+
Retire case
|
|
98
|
+
</button>
|
|
99
|
+
</form>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
{editing && (
|
|
104
|
+
<div className="space-y-3 rounded-md bg-tint p-3">
|
|
105
|
+
<div className="flex items-center justify-between gap-2">
|
|
106
|
+
<h4 className="text-sm font-medium">{editLabel}</h4>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => {
|
|
110
|
+
setEditing(false);
|
|
111
|
+
}}
|
|
112
|
+
className="text-sm text-muted hover:text-ink"
|
|
113
|
+
>
|
|
114
|
+
Cancel
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
{edit}
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { asText, fieldValue, type FormField } from "@crowandraven/evalkeeper";
|
|
2
|
+
import { InfoTip } from "./Tip";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A suite's form fields, rendered from its field specs: label, input, and
|
|
6
|
+
* help in one wrapper. `values` fills them from a stored input or answer.
|
|
7
|
+
*/
|
|
8
|
+
export function Fields({
|
|
9
|
+
fields,
|
|
10
|
+
values,
|
|
11
|
+
prefix = "",
|
|
12
|
+
}: {
|
|
13
|
+
fields: FormField[];
|
|
14
|
+
values?: unknown;
|
|
15
|
+
/** Keeps ids unique when a page has several forms. */
|
|
16
|
+
prefix?: string;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
{fields.map((f) => {
|
|
21
|
+
const id = `${prefix}${f.name}`;
|
|
22
|
+
const value = values === undefined ? "" : fieldValue(f, values);
|
|
23
|
+
const box =
|
|
24
|
+
"w-full rounded-md border border-line bg-card px-3 py-2 text-sm focus:border-accent focus:outline-none";
|
|
25
|
+
return (
|
|
26
|
+
<div key={f.name} className="space-y-1">
|
|
27
|
+
<label htmlFor={id} className="block text-sm font-medium">
|
|
28
|
+
{f.label}
|
|
29
|
+
</label>
|
|
30
|
+
{f.kind === "select" ? (
|
|
31
|
+
<select
|
|
32
|
+
id={id}
|
|
33
|
+
name={f.name}
|
|
34
|
+
defaultValue={value || f.options?.[0]}
|
|
35
|
+
className={box}
|
|
36
|
+
>
|
|
37
|
+
{f.options?.map((o) => (
|
|
38
|
+
<option key={o} value={o}>
|
|
39
|
+
{o.replace(/_/g, " ")}
|
|
40
|
+
</option>
|
|
41
|
+
))}
|
|
42
|
+
</select>
|
|
43
|
+
) : f.kind === "text" ? (
|
|
44
|
+
<input
|
|
45
|
+
id={id}
|
|
46
|
+
name={f.name}
|
|
47
|
+
defaultValue={value}
|
|
48
|
+
required={f.required}
|
|
49
|
+
className={box}
|
|
50
|
+
/>
|
|
51
|
+
) : (
|
|
52
|
+
<textarea
|
|
53
|
+
id={id}
|
|
54
|
+
name={f.name}
|
|
55
|
+
defaultValue={value}
|
|
56
|
+
required={f.required}
|
|
57
|
+
rows={f.kind === "lines" ? 4 : 5}
|
|
58
|
+
className={box}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
<p className="readable text-xs text-muted">{f.help}</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A stored input or answer, read with the suite's field labels. */
|
|
70
|
+
export function FieldValues({
|
|
71
|
+
fields,
|
|
72
|
+
values,
|
|
73
|
+
prefix = "",
|
|
74
|
+
}: {
|
|
75
|
+
fields: FormField[];
|
|
76
|
+
values: unknown;
|
|
77
|
+
/** Keeps tooltip ids unique when a page shows several. */
|
|
78
|
+
prefix?: string;
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<dl className="space-y-2">
|
|
82
|
+
{fields.map((f) => {
|
|
83
|
+
const v = (values as Record<string, unknown> | null)?.[f.name];
|
|
84
|
+
return (
|
|
85
|
+
<div key={f.name}>
|
|
86
|
+
<dt className="text-xs text-muted">
|
|
87
|
+
{f.label}
|
|
88
|
+
<InfoTip text={f.help} id={`${prefix}${f.name}-read`} />
|
|
89
|
+
</dt>
|
|
90
|
+
<dd className="readable whitespace-pre-wrap">
|
|
91
|
+
{Array.isArray(v) ? (
|
|
92
|
+
v.length ? (
|
|
93
|
+
<ul className="list-disc pl-5">
|
|
94
|
+
{v.map((x, i) => (
|
|
95
|
+
<li key={i}>{asText(x)}</li>
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
) : (
|
|
99
|
+
<span className="text-muted">None</span>
|
|
100
|
+
)
|
|
101
|
+
) : (
|
|
102
|
+
(asText(v) || "—").replace(/_/g, " ")
|
|
103
|
+
)}
|
|
104
|
+
</dd>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</dl>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
/** Refreshes the page every few seconds while something runs. */
|
|
7
|
+
export function RefreshWhile({
|
|
8
|
+
active,
|
|
9
|
+
ms = 2000,
|
|
10
|
+
}: {
|
|
11
|
+
active: boolean;
|
|
12
|
+
ms?: number;
|
|
13
|
+
}) {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!active) return;
|
|
17
|
+
const t = setInterval(() => {
|
|
18
|
+
router.refresh();
|
|
19
|
+
}, ms);
|
|
20
|
+
return () => {
|
|
21
|
+
clearInterval(t);
|
|
22
|
+
};
|
|
23
|
+
}, [active, ms, router]);
|
|
24
|
+
return null;
|
|
25
|
+
}
|