@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.
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 +49 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +57 -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 +76 -0
  50. package/dist/models.js +56 -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 +202 -0
  59. package/dist/runner.js +734 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +196 -0
  62. package/dist/settings.js +272 -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 +151 -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 +152 -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 +362 -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 +225 -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 +113 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +89 -0
  99. package/src/board.ts +58 -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 +149 -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 +127 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +1010 -0
  117. package/src/settings.ts +441 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +172 -0
  121. package/src/suggest.ts +247 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +415 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +258 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +169 -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,103 @@
1
+ import Link from "next/link";
2
+ import { ArrowLeft } from "lucide-react";
3
+ import type { HealthStatus } from "@crowandraven/evalkeeper";
4
+
5
+ export const pct = (x: number) => `${Math.round(x * 100)}%`;
6
+
7
+ const STATUS: Record<HealthStatus, { label: string; tone: keyof typeof CHIP }> =
8
+ {
9
+ good: { label: "Good", tone: "live" },
10
+ fair: { label: "Fair", tone: "warn" },
11
+ improve: { label: "Needs work", tone: "serious" },
12
+ few: { label: "Few cases", tone: "warn" },
13
+ stale: { label: "Retest", tone: "warn" },
14
+ untested: { label: "Untested", tone: "neutral" },
15
+ };
16
+
17
+ /** A suite's health as a chip; the card's details say why. */
18
+ export function StatusBadge({ status }: { status: HealthStatus }) {
19
+ const s = STATUS[status];
20
+ return <Chip tone={s.tone}>{s.label}</Chip>;
21
+ }
22
+
23
+ export function Card({
24
+ children,
25
+ className = "",
26
+ }: {
27
+ children: React.ReactNode;
28
+ className?: string;
29
+ }) {
30
+ return (
31
+ <section
32
+ className={`rounded-lg border border-line bg-card p-5 ${className}`}
33
+ >
34
+ {children}
35
+ </section>
36
+ );
37
+ }
38
+
39
+ /** A score with a thin bar, for tables. */
40
+ export function Score({ right, total }: { right: number; total: number }) {
41
+ const rate = total ? right / total : 0;
42
+ return (
43
+ <div className="flex items-center gap-2">
44
+ <span className="w-10 tabular-nums">{pct(rate)}</span>
45
+ <span className="h-1.5 w-20 rounded-full bg-tint">
46
+ <span
47
+ className="block h-1.5 rounded-full bg-accent"
48
+ style={{ width: `${rate * 100}%` }}
49
+ />
50
+ </span>
51
+ <span className="text-xs text-muted tabular-nums">
52
+ {right}/{total}
53
+ </span>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ const CHIP = {
59
+ live: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300",
60
+ fallback: "bg-sky-100 text-sky-800 dark:bg-sky-500/15 dark:text-sky-200",
61
+ warn: "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
62
+ serious: "bg-red-50 text-red-800 dark:bg-red-500/10 dark:text-red-200",
63
+ neutral: "bg-tint text-muted",
64
+ } as const;
65
+
66
+ /** A chip's color. */
67
+ export type ChipTone = keyof typeof CHIP;
68
+
69
+ /** A small rounded label: Live, Fallback, Retires …, serious, minor. */
70
+ export function Chip({
71
+ tone,
72
+ children,
73
+ }: {
74
+ tone: keyof typeof CHIP;
75
+ children: React.ReactNode;
76
+ }) {
77
+ return (
78
+ <span
79
+ className={`inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-full px-2.5 text-xs font-medium leading-none ${CHIP[tone]}`}
80
+ >
81
+ {children}
82
+ </span>
83
+ );
84
+ }
85
+
86
+ /** A link back to the page above, with an arrow. */
87
+ export function BackLink({
88
+ href,
89
+ children,
90
+ }: {
91
+ href: string;
92
+ children: React.ReactNode;
93
+ }) {
94
+ return (
95
+ <Link
96
+ href={href}
97
+ className="inline-flex items-center gap-1 text-sm text-muted hover:text-ink"
98
+ >
99
+ <ArrowLeft aria-hidden className="h-4 w-4" />
100
+ {children}
101
+ </Link>
102
+ );
103
+ }
@@ -0,0 +1,263 @@
1
+ "use server";
2
+
3
+ import { after } from "next/server";
4
+ import { revalidatePath } from "next/cache";
5
+ import { caseFromFeedback, formText, suggest } from "@crowandraven/evalkeeper";
6
+ import {
7
+ MODELS,
8
+ SUITES,
9
+ modelsFor,
10
+ feedbackStore,
11
+ requireAdmin,
12
+ runner,
13
+ settings,
14
+ store,
15
+ } from ".";
16
+ import { EVALS_BASE, evalsPath } from "./paths";
17
+ import { runWeeklyCheck, startWeeklyCheck } from "./weekly";
18
+
19
+ /**
20
+ * Every action on the eval pages. Each one checks requireAdmin() first,
21
+ * and records the admin as who made a change.
22
+ */
23
+
24
+ const suiteFrom = (form: FormData) =>
25
+ runner.suiteOrThrow(formText(form, "suite"));
26
+
27
+ export async function testModel(form: FormData) {
28
+ await requireAdmin();
29
+ const suite = suiteFrom(form);
30
+ const model = formText(form, "model");
31
+ if (!modelsFor(suite.operation).includes(model))
32
+ throw new Error("Unknown model");
33
+ const { run } = await runner.createRun({ suite: suite.key, model });
34
+ // Answers come back after the response; the page refreshes until done.
35
+ after(() => runner.runEval(run.id));
36
+ revalidatePath(evalsPath(suite.key));
37
+ }
38
+
39
+ export async function testAll(form: FormData) {
40
+ await requireAdmin();
41
+ const suite = suiteFrom(form);
42
+ for (const model of modelsFor(suite.operation)) {
43
+ const { run } = await runner.createRun({ suite: suite.key, model });
44
+ after(() => runner.runEval(run.id));
45
+ }
46
+ revalidatePath(evalsPath(suite.key));
47
+ }
48
+
49
+ /**
50
+ * The evidence for a promotion: the model's current test, the live
51
+ * model's, and how they compared, so the history can show why.
52
+ */
53
+ async function evidenceFor(suiteKey: string, model: string) {
54
+ const board = await runner.board(suiteKey);
55
+ const row = board.rows.find((r) => r.model === model && !r.variant);
56
+ if (!row?.test || row.test.stale)
57
+ throw new Error("Test this model on the current cases first");
58
+ return {
59
+ row,
60
+ evidence: {
61
+ runId: row.test.runId,
62
+ liveRunId: board.liveRunId,
63
+ vsLive: row.vsLive,
64
+ score: { right: row.test.right, total: row.test.total },
65
+ },
66
+ };
67
+ }
68
+
69
+ const VS: Record<string, string> = {
70
+ BETTER: "more accurate than live",
71
+ SAME: "about as accurate as live",
72
+ WORSE: "less accurate than live",
73
+ TOO_FEW: "too few cases to compare",
74
+ };
75
+
76
+ export async function makeLive(form: FormData) {
77
+ const admin = await requireAdmin();
78
+ const suite = suiteFrom(form);
79
+ const model = formText(form, "model");
80
+ const { row, evidence } = await evidenceFor(suite.key, model);
81
+ await settings.update(
82
+ suite.operation,
83
+ { model },
84
+ {
85
+ by: admin.name,
86
+ reason: `Made ${row.label} live: ${evidence.score.right}/${evidence.score.total}, ${VS[row.vsLive ?? ""] ?? "no comparison"}`,
87
+ evidence,
88
+ },
89
+ );
90
+ revalidatePath(EVALS_BASE, "layout");
91
+ }
92
+
93
+ export async function makeFallback(form: FormData) {
94
+ const admin = await requireAdmin();
95
+ const suite = suiteFrom(form);
96
+ const model = formText(form, "model");
97
+ const { row, evidence } = await evidenceFor(suite.key, model);
98
+ await settings.update(
99
+ suite.operation,
100
+ { 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
+ },
106
+ );
107
+ revalidatePath(EVALS_BASE, "layout");
108
+ }
109
+
110
+ /** Applies the suggestion: its live and fallback models, in one change. */
111
+ export async function useSuggestion(form: FormData) {
112
+ const admin = await requireAdmin();
113
+ const suite = suiteFrom(form);
114
+ const board = await runner.board(suite.key);
115
+ const s = suggest(board.rows, suite.checks, suite.priority, MODELS);
116
+ if (!s || s.same) return;
117
+ await settings.update(
118
+ suite.operation,
119
+ {
120
+ model: s.live.model,
121
+ fallbackModel: s.fallback?.model ?? null,
122
+ },
123
+ {
124
+ by: admin.name,
125
+ reason: s.reasons.join(" "),
126
+ evidence: {
127
+ suggestion: s.reasons,
128
+ live: s.live.test?.runId ?? null,
129
+ fallback: s.fallback?.test?.runId ?? null,
130
+ },
131
+ },
132
+ );
133
+ revalidatePath(EVALS_BASE, "layout");
134
+ }
135
+
136
+ /** Makes an earlier version live again; a reason is required. */
137
+ export async function rollBackSettings(form: FormData) {
138
+ const admin = await requireAdmin();
139
+ const suite = suiteFrom(form);
140
+ const reason = formText(form, "reason");
141
+ if (!reason) throw new Error("Say why you're rolling back");
142
+ await settings.rollBack(suite.operation, formText(form, "version"), {
143
+ by: admin.name,
144
+ reason,
145
+ });
146
+ revalidatePath(EVALS_BASE, "layout");
147
+ }
148
+
149
+ /** A person confirms an answer: the only way a draft gets used. */
150
+ export async function confirmAnswer(form: FormData) {
151
+ await requireAdmin();
152
+ const suite = suiteFrom(form);
153
+ await store.setLabelStatus(formText(form, "label"), "CONFIRMED");
154
+ after(() => runner.rescoreRecent(suite.key));
155
+ revalidatePath(evalsPath(suite.key));
156
+ }
157
+
158
+ export async function rescore(form: FormData) {
159
+ await requireAdmin();
160
+ const suite = suiteFrom(form);
161
+ await runner.rescoreRecent(suite.key);
162
+ revalidatePath(evalsPath(suite.key));
163
+ }
164
+
165
+ /** A person adds a case. They wrote its answer, so it's confirmed. */
166
+ export async function addCase(form: FormData) {
167
+ await requireAdmin();
168
+ const suite = suiteFrom(form);
169
+ if (!suite.labelFromForm) throw new Error("This suite has no answer form");
170
+ await store.createCase({
171
+ suite: suite.key,
172
+ input: suite.inputFromForm(form),
173
+ tags: formText(form, "tags")
174
+ .split(",")
175
+ .map((t) => t.trim())
176
+ .filter(Boolean),
177
+ split: formText(form, "split") === "TEST" ? "TEST" : "DEV",
178
+ status: "ACTIVE",
179
+ source: "OWNER",
180
+ sourceRef: null,
181
+ note: null,
182
+ labels: [
183
+ {
184
+ kind: "EXPECTED",
185
+ status: "CONFIRMED",
186
+ value: suite.labelFromForm(form),
187
+ raterRole: "OWNER",
188
+ raterId: null,
189
+ note: null,
190
+ },
191
+ ],
192
+ });
193
+ revalidatePath(evalsPath(suite.key));
194
+ }
195
+
196
+ /**
197
+ * A person changes a case's answer: a new confirmed answer, with the old
198
+ * ones kept as history. Stored answers are rescored for free.
199
+ */
200
+ export async function editAnswer(form: FormData) {
201
+ await requireAdmin();
202
+ const suite = suiteFrom(form);
203
+ if (!suite.labelFromForm) throw new Error("This suite has no answer form");
204
+ const caseId = formText(form, "case");
205
+ const known = (await store.listCases(suite.key)).some((c) => c.id === caseId);
206
+ if (!known) throw new Error("Unknown case");
207
+ await store.addLabel(caseId, {
208
+ kind: "EXPECTED",
209
+ status: "CONFIRMED",
210
+ value: suite.labelFromForm(form),
211
+ raterRole: "OWNER",
212
+ raterId: null,
213
+ note: null,
214
+ });
215
+ // A draft (from feedback) is in use once it has an answer.
216
+ await store.setCaseStatus(caseId, "ACTIVE");
217
+ after(() => runner.rescoreRecent(suite.key));
218
+ revalidatePath(evalsPath(suite.key));
219
+ }
220
+
221
+ /** Takes a case out of use; its stored answers stay. */
222
+ export async function retireCase(form: FormData) {
223
+ await requireAdmin();
224
+ const suite = suiteFrom(form);
225
+ const caseId = formText(form, "case");
226
+ const known = (await store.listCases(suite.key)).some((c) => c.id === caseId);
227
+ if (!known) throw new Error("Unknown case");
228
+ await store.setCaseStatus(caseId, "RETIRED");
229
+ revalidatePath(evalsPath(suite.key));
230
+ }
231
+
232
+ async function find(id: string) {
233
+ const f = (await feedbackStore.listFeedback()).find((x) => x.id === id);
234
+ if (!f) throw new Error("Unknown rating");
235
+ return f;
236
+ }
237
+
238
+ /** Makes a draft case from a rating; a person finishes its answer. */
239
+ export async function makeCase(form: FormData) {
240
+ await requireAdmin();
241
+ const f = await find(formText(form, "feedback"));
242
+ const suite = SUITES.find((s) => s.operation === f.operation);
243
+ const draft = suite ? caseFromFeedback(f, suite) : null;
244
+ if (!draft || !suite) throw new Error("This rating can't become a case");
245
+ const c = await store.createCase(draft);
246
+ await feedbackStore.setFeedbackStatus(f.id, "CASED", c.id);
247
+ revalidatePath(EVALS_BASE, "layout");
248
+ }
249
+
250
+ export async function dismissFeedback(form: FormData) {
251
+ await requireAdmin();
252
+ const f = await find(formText(form, "feedback"));
253
+ await feedbackStore.setFeedbackStatus(f.id, "DISMISSED");
254
+ revalidatePath(evalsPath("feedback"));
255
+ }
256
+
257
+ export async function runWeeklyNow(): Promise<void> {
258
+ await requireAdmin();
259
+ // Recorded before the page re-renders, so it shows them running.
260
+ const ids = await startWeeklyCheck();
261
+ after(() => runWeeklyCheck(ids));
262
+ revalidatePath(EVALS_BASE);
263
+ }
@@ -0,0 +1,18 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Whether a request carries the cron secret ("Authorization: Bearer …").
5
+ * Fails closed when no secret is set, and compares in constant time so the
6
+ * secret can't be guessed a character at a time.
7
+ */
8
+ export function isAuthorizedCron(
9
+ header: string | null,
10
+ secret: string | undefined,
11
+ ): boolean {
12
+ if (!secret || !header) return false;
13
+ const presented = Buffer.from(header);
14
+ const expected = Buffer.from(`Bearer ${secret}`);
15
+ return (
16
+ presented.length === expected.length && timingSafeEqual(presented, expected)
17
+ );
18
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where the eval pages live in your app. The CLI sets this to the base you
3
+ * choose (`npx @crowandraven/evalkeeper add pages --base admin/evals`); every link and
4
+ * refresh on the pages is built from it.
5
+ */
6
+ export const EVALS_BASE = "/evals";
7
+
8
+ /** A path under the eval pages: evalsPath("ticket_triage", "runs", id). */
9
+ export function evalsPath(...parts: string[]): string {
10
+ return [EVALS_BASE, ...parts.map(encodeURIComponent)].join("/");
11
+ }
@@ -0,0 +1,4 @@
1
+ /** The moment `days` days ago, for "this week" summaries. */
2
+ export function daysAgo(days: number): Date {
3
+ return new Date(Date.now() - days * 86_400_000);
4
+ }
@@ -0,0 +1,51 @@
1
+ import "server-only";
2
+ import {
3
+ thumbsSummary,
4
+ weeklySummary,
5
+ type WeeklySummary,
6
+ } from "@crowandraven/evalkeeper";
7
+ import { MODELS, SUITES, feedbackStore, routeFor, runner, store } from ".";
8
+ import { daysAgo } from "./time";
9
+
10
+ /**
11
+ * The weekly check: each suite's quick set on its live model. `start`
12
+ * records every suite's test first (so the hub shows them running), then
13
+ * `run` answers them one suite after another. On a platform with a request
14
+ * limit, give the runner `requestLimitMs` and `continueRun` so a slow suite
15
+ * carries on in a fresh request.
16
+ */
17
+ export async function startWeeklyCheck(): Promise<string[]> {
18
+ const ids: string[] = [];
19
+ for (const suite of SUITES) {
20
+ const id = await runner.startDriftCheck(suite.key);
21
+ if (id) ids.push(id);
22
+ }
23
+ return ids;
24
+ }
25
+
26
+ export async function runWeeklyCheck(ids: string[]): Promise<void> {
27
+ // runEval never throws: a suite that fails says why on its own test.
28
+ for (const id of ids) await runner.runEval(id, { fresh: true });
29
+ }
30
+
31
+ /** Where the weekly check stands, for the evals hub. */
32
+ export async function loadWeekly(): Promise<WeeklySummary> {
33
+ const [runs, routes, feedback] = await Promise.all([
34
+ Promise.all(SUITES.map((s) => store.listRuns(s.key, { limit: 50 }))).then(
35
+ (r) => r.flat(),
36
+ ),
37
+ Promise.all(
38
+ SUITES.map(
39
+ async (s) => [s.operation, await routeFor(s.operation)] as const,
40
+ ),
41
+ ).then((r) => Object.fromEntries(r)),
42
+ feedbackStore.listFeedback({ since: daysAgo(7) }),
43
+ ]);
44
+ return weeklySummary({
45
+ runs,
46
+ suites: SUITES,
47
+ routes,
48
+ models: MODELS,
49
+ thumbs: thumbsSummary(feedback, daysAgo(7)),
50
+ });
51
+ }