evalcore 0.1.0__py3-none-any.whl

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.
evalkit/rating.py ADDED
@@ -0,0 +1,1323 @@
1
+ """Human blind-rating loop and judge<->human agreement.
2
+
3
+ Two halves that share one open JSONL format (``models.Rating``):
4
+
5
+ * :func:`serve` - a dependency-free localhost web app that presents a
6
+ *blind* queue over one or more persisted runs (no model/variant reaches
7
+ the browser), renders each output's screenshot + HTML, collects 1..scale
8
+ scores per rubric dimension, and appends ratings to a JSONL file. The
9
+ same file can be produced by any external tool instead.
10
+ * :func:`compute_agreement` - reads a run + its ratings and reports how
11
+ well the LLM judge tracks the humans (per-dimension MAE and correlation).
12
+ A judge that doesn't agree with human taste isn't trustworthy as a win
13
+ metric yet - this is the calibration gate before a sweep.
14
+
15
+ Blinding is enforced server-side: the queue payload carries an opaque item
16
+ index and the case input (shared across variants, so safe to show), never
17
+ the run/variant/model. Ratings map back to (run_id, case_id, sample_idx)
18
+ server-side.
19
+ """
20
+
21
+ import http.server
22
+ import json
23
+ import pathlib
24
+ import random
25
+ import statistics
26
+ import threading
27
+ import webbrowser
28
+
29
+ from evalkit import models, refs, store
30
+
31
+ # Artifact rendering is type-driven, not consumer-specific: a panel's ``kind``
32
+ # decides how the browser shows it. File-backed kinds (image/pdf) stream
33
+ # through /api/artifact; inline kinds (html/json/text) ride in the queue.
34
+ _CONTENT_TYPES = {
35
+ '.png': 'image/png',
36
+ '.jpg': 'image/jpeg',
37
+ '.jpeg': 'image/jpeg',
38
+ '.gif': 'image/gif',
39
+ '.webp': 'image/webp',
40
+ '.svg': 'image/svg+xml',
41
+ '.pdf': 'application/pdf',
42
+ '.html': 'text/html',
43
+ '.htm': 'text/html',
44
+ '.json': 'application/json',
45
+ '.txt': 'text/plain',
46
+ '.md': 'text/markdown',
47
+ '.mjs': 'text/javascript',
48
+ '.js': 'text/javascript',
49
+ }
50
+ _IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
51
+
52
+
53
+ def _kind_for_path(path: str) -> str:
54
+ """Map an artifact file path to a render kind by extension."""
55
+ ext = pathlib.Path(path).suffix.lower()
56
+ if ext in _IMAGE_EXTS:
57
+ return 'image'
58
+ if ext == '.pdf':
59
+ return 'pdf'
60
+ if ext in ('.html', '.htm'):
61
+ return 'html'
62
+ if ext == '.json':
63
+ return 'json'
64
+ return 'text'
65
+
66
+
67
+ def _infer_kind(ref: str, value) -> str:
68
+ """Best-effort kind for a view whose ``kind`` was not declared."""
69
+ if isinstance(value, str) and ref.startswith('artifacts.'):
70
+ return _kind_for_path(value)
71
+ if isinstance(value, (dict, list)):
72
+ return 'json'
73
+ if isinstance(value, str) and value.lstrip().startswith('<'):
74
+ return 'html'
75
+ return 'text'
76
+
77
+
78
+ def _default_view_specs(
79
+ content_ref: str | None, screenshot_ref: str | None
80
+ ) -> list[dict]:
81
+ """Back-compat views from the old content_ref/screenshot_ref params."""
82
+ specs: list[dict] = []
83
+ if content_ref:
84
+ specs.append({'label': 'Rendered', 'kind': 'html', 'ref': content_ref})
85
+ if screenshot_ref:
86
+ specs.append(
87
+ {'label': 'Screenshot', 'kind': 'image', 'ref': screenshot_ref}
88
+ )
89
+ return specs
90
+
91
+
92
+ def _prettify(name: str) -> str:
93
+ return name.replace('_', ' ').replace('-', ' ').strip().title()
94
+
95
+
96
+ def _judge_scores(run: models.RunResult, judge_name: str) -> dict:
97
+ """{(case_id, sample_idx): {dimension: normalized_value}} for the judge."""
98
+ prefix = f'{judge_name}.'
99
+ out: dict[tuple[str, int], dict[str, float]] = {}
100
+ for result in run.results:
101
+ dims: dict[str, float] = {}
102
+ for score in result.scores:
103
+ if score.metric.startswith(prefix) and score.value is not None:
104
+ dims[score.metric[len(prefix) :]] = score.value
105
+ out[result.case.id, result.sample_idx] = dims
106
+ return out
107
+
108
+
109
+ def _human_scores(
110
+ ratings: list[models.Rating], run_id: str, scale: int
111
+ ) -> dict:
112
+ """{(case_id, sample_idx): {dimension: [normalized human scores]}}."""
113
+ out: dict[tuple[str, int], dict[str, list[float]]] = {}
114
+ for rating in ratings:
115
+ if rating.run_id != run_id:
116
+ continue
117
+ key = (rating.case_id, rating.sample_idx)
118
+ dims = out.setdefault(key, {})
119
+ for dimension, raw in rating.scores.items():
120
+ dims.setdefault(dimension, []).append(raw / scale)
121
+ return out
122
+
123
+
124
+ def _correlation(xs: list[float], ys: list[float]) -> float | None:
125
+ if len(xs) < 2:
126
+ return None
127
+ try:
128
+ return statistics.correlation(xs, ys)
129
+ except statistics.StatisticsError: # zero variance on a side
130
+ return None
131
+
132
+
133
+ def compute_agreement(
134
+ run: models.RunResult,
135
+ ratings: list[models.Rating],
136
+ dimensions: list[str],
137
+ *,
138
+ judge_name: str = 'quality',
139
+ scale: int = 5,
140
+ ) -> models.AgreementResult:
141
+ """Judge-vs-human agreement over one run's rated (case, sample) pairs."""
142
+ judge = _judge_scores(run, judge_name)
143
+ human = _human_scores(ratings, run.run_id, scale)
144
+ raters = {r.rater for r in ratings if r.run_id == run.run_id}
145
+ n_ratings = sum(1 for r in ratings if r.run_id == run.run_id)
146
+
147
+ dim_results: list[models.DimensionAgreement] = []
148
+ all_h: list[float] = []
149
+ all_j: list[float] = []
150
+ for dimension in dimensions:
151
+ hs: list[float] = []
152
+ js: list[float] = []
153
+ for key, human_dims in human.items():
154
+ values = human_dims.get(dimension)
155
+ judged = judge.get(key, {}).get(dimension)
156
+ if values and judged is not None:
157
+ hs.append(statistics.fmean(values))
158
+ js.append(judged)
159
+ n = len(hs)
160
+ mae = (
161
+ statistics.fmean([abs(h - j) for h, j in zip(hs, js, strict=True)])
162
+ if n
163
+ else None
164
+ )
165
+ dim_results.append(
166
+ models.DimensionAgreement(
167
+ dimension=dimension,
168
+ n=n,
169
+ human_mean=statistics.fmean(hs) if hs else None,
170
+ judge_mean=statistics.fmean(js) if js else None,
171
+ mae=mae,
172
+ correlation=_correlation(hs, js),
173
+ )
174
+ )
175
+ all_h.extend(hs)
176
+ all_j.extend(js)
177
+
178
+ overall_mae = (
179
+ statistics.fmean(
180
+ [abs(h - j) for h, j in zip(all_h, all_j, strict=True)]
181
+ )
182
+ if all_h
183
+ else None
184
+ )
185
+ return models.AgreementResult(
186
+ judge_name=judge_name,
187
+ scale=scale,
188
+ n_ratings=n_ratings,
189
+ n_raters=len(raters),
190
+ dimensions=dim_results,
191
+ overall_mae=overall_mae,
192
+ overall_correlation=_correlation(all_h, all_j),
193
+ )
194
+
195
+
196
+ def _tally(picks: list[str]) -> tuple[int, int, int, float | None]:
197
+ """(a_wins, b_wins, ties, win_rate_a) over 'a'/'b'/'tie' picks; ties=half."""
198
+ a = picks.count('a')
199
+ b = picks.count('b')
200
+ t = picks.count('tie')
201
+ n = a + b + t
202
+ return a, b, t, ((a + 0.5 * t) / n if n else None)
203
+
204
+
205
+ def aggregate_preferences(
206
+ run_a: models.RunResult,
207
+ run_b: models.RunResult,
208
+ preferences: list[models.Preference],
209
+ dimensions: list[str] | None = None,
210
+ ) -> models.PreferenceResult:
211
+ """Human side-by-side win-rate of A vs B, overall and per dimension.
212
+
213
+ Only preferences whose ``(variant_a, variant_b)`` matches this pair's
214
+ orientation are counted, so a preferences file mixing several comparisons
215
+ is filtered to the one asked for.
216
+ """
217
+ va = run_a.scorecard.variant.name
218
+ vb = run_b.scorecard.variant.name
219
+ prefs = [p for p in preferences if p.variant_a == va and p.variant_b == vb]
220
+ a_wins, b_wins, ties, win_rate = _tally([p.winner for p in prefs])
221
+
222
+ keys = dimensions or sorted({d for p in prefs for d in p.dims})
223
+ dim_results: list[models.DimensionPreference] = []
224
+ for dimension in keys:
225
+ picks = [p.dims[dimension] for p in prefs if dimension in p.dims]
226
+ da, db, dt, dwr = _tally(picks)
227
+ dim_results.append(
228
+ models.DimensionPreference(
229
+ dimension=dimension,
230
+ n=da + db + dt,
231
+ a_wins=da,
232
+ b_wins=db,
233
+ ties=dt,
234
+ win_rate_a=dwr,
235
+ )
236
+ )
237
+ return models.PreferenceResult(
238
+ project=run_a.scorecard.project,
239
+ suite=run_a.scorecard.suite,
240
+ variant_a=va,
241
+ variant_b=vb,
242
+ n_raters=len({p.rater for p in prefs}),
243
+ n=len(prefs),
244
+ a_wins=a_wins,
245
+ b_wins=b_wins,
246
+ ties=ties,
247
+ win_rate_a=win_rate,
248
+ dimensions=dim_results,
249
+ )
250
+
251
+
252
+ def _majority_winner(picks: list[str]) -> str:
253
+ """The most-picked winner; a tie in the *counts* resolves to 'tie'."""
254
+ counts = {w: picks.count(w) for w in ('a', 'b', 'tie')}
255
+ ordered = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
256
+ if ordered[0][1] == ordered[1][1]: # no clear plurality
257
+ return 'tie'
258
+ return ordered[0][0]
259
+
260
+
261
+ def compute_pairwise_agreement(
262
+ preferences: list[models.Preference], pairwise: models.PairwiseResult
263
+ ) -> models.PairwiseAgreement:
264
+ """How often the human panel and the LLM pairwise judge agree on the
265
+ per-case winner. Human winner per case = majority of raters' overall picks.
266
+ """
267
+ va, vb = pairwise.variant_a, pairwise.variant_b
268
+ by_case: dict[tuple[str, int], list[str]] = {}
269
+ for p in preferences:
270
+ if p.variant_a == va and p.variant_b == vb:
271
+ by_case.setdefault((p.case_id, p.sample_idx), []).append(p.winner)
272
+ human = {k: _majority_winner(v) for k, v in by_case.items()}
273
+ judge = {(o.case_id, o.sample_idx): o.winner for o in pairwise.outcomes}
274
+
275
+ keys = sorted(human.keys() & judge.keys())
276
+ outcomes: list[models.PairwiseAgreementCase] = []
277
+ agree = 0
278
+ for key in keys:
279
+ ok = human[key] == judge[key]
280
+ agree += ok
281
+ outcomes.append(
282
+ models.PairwiseAgreementCase(
283
+ case_id=key[0],
284
+ sample_idx=key[1],
285
+ human=human[key],
286
+ judge=judge[key],
287
+ agree=ok,
288
+ )
289
+ )
290
+ n = len(keys)
291
+ _, _, _, human_wr = _tally([human[k] for k in keys])
292
+ _, _, _, judge_wr = _tally([judge[k] for k in keys])
293
+ return models.PairwiseAgreement(
294
+ variant_a=va,
295
+ variant_b=vb,
296
+ judge_name=pairwise.judge_name,
297
+ n=n,
298
+ agree=agree,
299
+ human_win_rate_a=human_wr,
300
+ judge_win_rate_a=judge_wr,
301
+ agreement_rate=(agree / n if n else None),
302
+ outcomes=outcomes,
303
+ )
304
+
305
+
306
+ def _derive_panels(
307
+ output: models.Output, specs: list[dict]
308
+ ) -> tuple[list[dict], list[str]]:
309
+ """Derive an output's render panels (shared by the rate + rank apps).
310
+
311
+ Configured views first, then any *visual* artifacts (image/pdf) not
312
+ already referenced, then a fields fallback so a plain text or JSON result
313
+ still shows something with zero configuration. Returns ``(panels, files)``
314
+ where ``panels`` is client-safe (no paths) and ``files`` is the server-only
315
+ ``index -> path`` map that file-backed panels' ``v`` index into.
316
+ """
317
+ ctx = {
318
+ 'output': output.fields,
319
+ 'artifacts': output.artifacts,
320
+ 'case': None,
321
+ 'input': None,
322
+ }
323
+ panels: list[dict] = []
324
+ files: list[str] = []
325
+ covered: set[str] = set()
326
+
327
+ def add_inline(label, kind, value):
328
+ # The value stays exact for the rendered iframe, Source view, and copy.
329
+ panels.append({'label': label, 'kind': kind, 'value': value})
330
+
331
+ def add_file(label, kind, path):
332
+ panels.append({'label': label, 'kind': kind, 'v': len(files)})
333
+ files.append(path)
334
+
335
+ for spec in specs:
336
+ ref = spec['ref']
337
+ value = refs.resolve_ref(ctx, ref)
338
+ if value is None or value == '':
339
+ continue
340
+ kind = spec.get('kind') or _infer_kind(ref, value)
341
+ if ref.startswith('artifacts.'):
342
+ covered.add(ref.split('.', 1)[1])
343
+ if kind in ('image', 'pdf'):
344
+ if isinstance(value, str):
345
+ add_file(spec['label'], kind, value)
346
+ elif kind == 'json':
347
+ add_inline(spec['label'], 'json', value)
348
+ elif kind == 'html':
349
+ add_inline(spec['label'], 'html', str(value))
350
+ else:
351
+ add_inline(spec['label'], 'text', str(value))
352
+
353
+ # Any visual artifact not already shown -> its own panel (quantity).
354
+ for name, path in (output.artifacts or {}).items():
355
+ if name in covered or not isinstance(path, str):
356
+ continue
357
+ kind = _kind_for_path(path)
358
+ if kind in ('image', 'pdf'):
359
+ add_file(_prettify(name), kind, path)
360
+
361
+ # Nothing configured/derived: fall back to the output fields, so a
362
+ # text-only or JSON-only result is reviewable out of the box.
363
+ if not panels:
364
+ fields = output.fields or {}
365
+ str_fields = {k: v for k, v in fields.items() if isinstance(v, str)}
366
+ if len(fields) == 1 and str_fields:
367
+ key, value = next(iter(str_fields.items()))
368
+ add_inline(
369
+ _prettify(key),
370
+ 'html' if value.lstrip().startswith('<') else 'text',
371
+ value,
372
+ )
373
+ elif fields:
374
+ add_inline('Output', 'json', fields)
375
+ return panels, files
376
+
377
+
378
+ # -- the blind rating web app ------------------------------------------------
379
+
380
+ # Self-contained single page (no external assets): a production-minded blind
381
+ # rating console. ``__CONFIG__`` is replaced server-side with a JSON blob
382
+ # ``{scale, dims}`` - one robust injection point rather than string-replacing
383
+ # bare ``SCALE``/``DIMS`` tokens that could collide with page text.
384
+ _PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
385
+ <meta name="viewport" content="width=device-width, initial-scale=1">
386
+ <title>evalkit &middot; blind rating</title><style>
387
+ :root{
388
+ --bg:#f5f6f8;--panel:#fff;--panel-2:#fafbfc;--ink:#1a1d21;--muted:#68707a;
389
+ --line:#e3e6ea;--brand:#2f6fed;--brand-ink:#fff;--on:#1f8f5f;--on-ink:#fff;
390
+ --shadow:0 1px 3px rgba(20,25,35,.08),0 8px 24px rgba(20,25,35,.06);--frame:#fff;
391
+ }
392
+ @media (prefers-color-scheme:dark){:root{
393
+ --bg:#0d0f12;--panel:#16191e;--panel-2:#1b1f25;--ink:#eceef1;--muted:#9aa3ad;
394
+ --line:#2a2f37;--brand:#4f8bff;--brand-ink:#06101f;--on:#3ecf8e;--on-ink:#052012;
395
+ --shadow:0 1px 2px rgba(0,0,0,.4),0 12px 30px rgba(0,0,0,.35);--frame:#f4f5f7;
396
+ }}
397
+ *{box-sizing:border-box}
398
+ body{margin:0;background:var(--bg);color:var(--ink);
399
+ font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
400
+ header{position:sticky;top:0;z-index:5;background:var(--panel);
401
+ border-bottom:1px solid var(--line);backdrop-filter:blur(6px)}
402
+ .hrow{display:flex;align-items:center;gap:14px;max-width:1180px;margin:0 auto;
403
+ padding:12px 20px}
404
+ .brand{font-weight:700;letter-spacing:.2px}
405
+ .brand small{font-weight:500;color:var(--muted);margin-left:8px}
406
+ .who{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--muted);
407
+ font-size:13px}
408
+ .chip{background:var(--panel-2);border:1px solid var(--line);border-radius:999px;
409
+ padding:3px 10px;color:var(--ink);font-weight:600}
410
+ .progress{height:4px;background:var(--line)}
411
+ .progress i{display:block;height:100%;width:0;background:var(--brand);
412
+ transition:width .25s ease}
413
+ main{max-width:1180px;margin:0 auto;padding:22px 20px 80px}
414
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;
415
+ box-shadow:var(--shadow);overflow:hidden}
416
+ .panes{display:flex;flex-direction:column;gap:16px;margin:0 0 16px}
417
+ .pane{background:var(--panel);border:1px solid var(--line);border-radius:14px;
418
+ box-shadow:var(--shadow);overflow:hidden}
419
+ .phd{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--panel);
420
+ border-bottom:1px solid var(--line)}
421
+ .plabel{font-weight:700;font-size:13px}
422
+ .pkind{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);
423
+ border:1px solid var(--line);border-radius:999px;padding:1px 8px}
424
+ .pacts{margin-left:auto;display:flex;gap:6px}
425
+ .pacts button{padding:4px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);
426
+ border-radius:6px;cursor:pointer;background:var(--panel-2);color:var(--ink)}
427
+ .pacts button.active{background:var(--brand);color:var(--brand-ink);border-color:var(--brand)}
428
+ .pbody{background:var(--frame)}
429
+ .pbody .rendered iframe{display:block;width:100%;height:720px;border:0;background:var(--frame)}
430
+ .pbody .embed{display:block;width:100%;height:640px;border:0;background:var(--frame)}
431
+ .pbody img{display:block;width:100%;height:auto}
432
+ .pbody pre.src{margin:0;padding:14px 16px;max-height:620px;overflow:auto;
433
+ white-space:pre-wrap;word-break:break-word;tab-size:2;
434
+ font:12.5px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
435
+ background:var(--panel)}
436
+ .hidden{display:none}
437
+ .body{padding:18px 20px}
438
+ details{margin:0 0 16px;border:1px solid var(--line);border-radius:10px;
439
+ background:var(--panel-2)}
440
+ details summary{cursor:pointer;padding:10px 14px;color:var(--muted);
441
+ font-size:13px;font-weight:600;user-select:none}
442
+ details pre{margin:0;padding:0 14px 12px;white-space:pre-wrap;word-break:break-word;
443
+ font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink)}
444
+ .dim{padding:14px 0;border-top:1px solid var(--line)}
445
+ .dim:first-of-type{border-top:0}
446
+ .dim .lab{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
447
+ .dim .lab b{font-size:15px}
448
+ .dim .lab .hint{color:var(--muted);font-size:12px}
449
+ .scale{display:flex;gap:8px;flex-wrap:wrap}
450
+ .scale button{min-width:46px;height:42px;padding:0 12px;border:1px solid var(--line);
451
+ background:var(--panel-2);color:var(--ink);border-radius:10px;cursor:pointer;
452
+ font-size:15px;font-weight:600;transition:transform .05s,background .12s,border-color .12s}
453
+ .scale button:hover{border-color:var(--brand)}
454
+ .scale button:active{transform:translateY(1px)}
455
+ .scale button.on{background:var(--on);color:var(--on-ink);border-color:var(--on)}
456
+ .actions{position:sticky;bottom:0;display:flex;align-items:center;gap:12px;
457
+ padding:14px 20px;background:var(--panel);border-top:1px solid var(--line)}
458
+ .actions .rem{color:var(--muted);font-size:13px;margin-right:auto}
459
+ button.primary,button.ghost{border-radius:10px;font-size:15px;font-weight:600;
460
+ cursor:pointer;padding:11px 20px;border:1px solid var(--line)}
461
+ button.primary{background:var(--brand);color:var(--brand-ink);border-color:var(--brand)}
462
+ button.primary:disabled{opacity:.45;cursor:not-allowed}
463
+ button.ghost{background:transparent;color:var(--muted)}
464
+ .kbd{font:12px ui-monospace,monospace;background:var(--panel-2);border:1px solid var(--line);
465
+ border-radius:6px;padding:1px 6px;color:var(--muted)}
466
+ .empty{text-align:center;padding:70px 20px;color:var(--muted)}
467
+ .empty h2{color:var(--ink);margin:0 0 8px}
468
+ #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%) translateY(20px);
469
+ background:#c0392b;color:#fff;padding:10px 16px;border-radius:8px;opacity:0;
470
+ transition:.2s;pointer-events:none}
471
+ #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
472
+ .foot{max-width:1180px;margin:14px auto 0;padding:0 20px;color:var(--muted);
473
+ font-size:12px;display:flex;gap:16px;flex-wrap:wrap}
474
+ </style></head><body>
475
+ <header>
476
+ <div class="hrow">
477
+ <span class="brand">evalkit<small>blind rating</small></span>
478
+ <span class="who">rater <span class="chip" id="rater"></span>
479
+ <span id="pos"></span></span>
480
+ </div>
481
+ <div class="progress"><i id="pbar"></i></div>
482
+ </header>
483
+ <main><div id="card"><div class="empty">Loading queue&hellip;</div></div>
484
+ <div class="foot" id="foot"></div>
485
+ </main>
486
+ <div id="toast"></div>
487
+ <script>
488
+ const CONFIG=__CONFIG__, scale=CONFIG.scale, dims=CONFIG.dims;
489
+ const q=new URLSearchParams(location.search), rater=q.get('rater')||'anon';
490
+ let queue=[], i=0, cur={}, total=0, focus=0;
491
+ document.getElementById('rater').textContent=rater;
492
+ const pretty=d=>d.replace(/[_-]+/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase());
493
+ // A complete document (has <html>/<head>/<body>/<style>/doctype) already
494
+ // carries its own styling - render it AS-IS. Only bare fragments (e.g.
495
+ // replay fixtures: a lone <form> with no CSS) get wrapped in this neutral
496
+ // legibility frame, so we never override a real page's own layout.
497
+ const isDoc=h=>/<(?:!doctype|html|head|body|style)[\\s>]/i.test(h);
498
+ const FRAME='<!doctype html><html><head><meta charset="utf-8">'+
499
+ '<meta name="viewport" content="width=device-width,initial-scale=1"><style>'+
500
+ 'html{color-scheme:light}body{margin:0;padding:28px;background:#f6f7f9;'+
501
+ 'font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#1a1d21}'+
502
+ 'h1,h2,h3{margin:0 0 14px;line-height:1.25}'+
503
+ 'form{max-width:480px;margin:0 auto;background:#fff;border:1px solid #e3e6ea;'+
504
+ 'border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:12px;'+
505
+ 'box-shadow:0 1px 3px rgba(20,25,35,.06)}'+
506
+ 'label{font-size:13px;font-weight:600;color:#57606a}'+
507
+ 'input,select,textarea{width:100%;padding:10px 12px;font-size:15px;'+
508
+ 'border:1px solid #cbd2da;border-radius:8px;background:#fff;color:#1a1d21}'+
509
+ 'button{padding:11px 16px;font-size:15px;font-weight:600;border:0;'+
510
+ 'border-radius:8px;background:#2f6fed;color:#fff;cursor:pointer}'+
511
+ 'p{color:#57606a;margin:2px 0;font-size:13px}</style></head><body>';
512
+ const frameDoc=html=>
513
+ (isDoc(html)?html:FRAME+html+'</body></html>').replace(/"/g,'&quot;');
514
+ const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
515
+ // Render one item's typed panels (image/pdf/html/json/text), stacked and
516
+ // full-width so nothing is compressed. html panels carry a rendered/source
517
+ // toggle; text/json/html carry a copy button.
518
+ function panelHTML(it){
519
+ if(!it.views||!it.views.length)
520
+ return '<div class="pbody"><pre class="src">(no renderable output)</pre></div>';
521
+ const secs=it.views.map((vw,vi)=>{
522
+ let body='',acts='';
523
+ if(vw.kind==='image'){
524
+ body='<img alt="'+esc(vw.label)+'" src="/api/artifact?id='+it.id+'&v='+vw.v+'">';
525
+ }else if(vw.kind==='pdf'){
526
+ body='<iframe class="embed" title="'+esc(vw.label)+
527
+ '" src="/api/artifact?id='+it.id+'&v='+vw.v+'"></iframe>';
528
+ }else if(vw.kind==='html'){
529
+ body='<div class="rendered"><iframe sandbox title="'+esc(vw.label)+
530
+ '" srcdoc="'+frameDoc(vw.value)+'"></iframe></div>'+
531
+ '<div class="source hidden"><pre class="src">'+esc(vw.value)+'</pre></div>';
532
+ acts='<button data-t="rendered" class="active">Rendered</button>'+
533
+ '<button data-t="source">Source</button>'+
534
+ '<button data-copy="'+vi+'">Copy</button>';
535
+ }else if(vw.kind==='json'){
536
+ body='<pre class="src">'+esc(JSON.stringify(vw.value,null,2))+'</pre>';
537
+ acts='<button data-copy="'+vi+'">Copy</button>';
538
+ }else{
539
+ body='<pre class="src">'+esc(vw.value)+'</pre>';
540
+ acts='<button data-copy="'+vi+'">Copy</button>';
541
+ }
542
+ return '<section class="pane" data-vi="'+vi+'"><div class="phd">'+
543
+ '<span class="plabel">'+esc(vw.label)+'</span>'+
544
+ '<span class="pkind">'+vw.kind+'</span><div class="pacts">'+acts+
545
+ '</div></div><div class="pbody">'+body+'</div></section>';
546
+ }).join('');
547
+ return '<div class="panes">'+secs+'</div>';
548
+ }
549
+ function toast(msg){const t=document.getElementById('toast');t.textContent=msg;
550
+ t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2600);}
551
+ function setProgress(){const done=total-queue.length+i;
552
+ document.getElementById('pbar').style.width=total?(100*done/total)+'%':'0';
553
+ document.getElementById('pos').textContent=total?((done+1>total?total:done+1)+' / '+total):'';}
554
+ async function load(){
555
+ try{queue=await (await fetch('/api/queue?rater='+encodeURIComponent(rater))).json();}
556
+ catch(e){toast('Could not load the queue');return;}
557
+ total=queue.length;i=0;render();
558
+ }
559
+ function done(){
560
+ document.getElementById('card').innerHTML=
561
+ '<div class="empty"><h2>All done &mdash; thank you.</h2>'+
562
+ '<p>'+total+' item'+(total===1?'':'s')+' rated as <b>'+rater+'</b>.</p>'+
563
+ '<p>Reopen this page anytime to resume where you left off.</p></div>';
564
+ document.getElementById('pbar').style.width='100%';
565
+ document.getElementById('pos').textContent=total+' / '+total;
566
+ document.getElementById('foot').innerHTML='';
567
+ }
568
+ function render(){
569
+ const card=document.getElementById('card');
570
+ if(i>=queue.length){done();return;}
571
+ const it=queue[i];cur={};focus=0;setProgress();
572
+ const media=panelHTML(it);
573
+ const req=it.input?'<details><summary>Prompt / input</summary><pre>'+
574
+ JSON.stringify(it.input,null,2).replace(/</g,'&lt;')+'</pre></details>':'';
575
+ const rows=dims.map((d,di)=>'<div class="dim" data-di="'+di+'"><div class="lab">'+
576
+ '<b>'+pretty(d)+'</b><span class="hint">1 = worst &middot; '+scale+' = best</span></div>'+
577
+ '<div class="scale">'+Array.from({length:scale},(_,k)=>
578
+ '<button type="button" data-d="'+d+'" data-v="'+(k+1)+'">'+(k+1)+
579
+ '</button>').join('')+'</div></div>').join('');
580
+ card.innerHTML=media+
581
+ '<div class="card"><div class="body">'+req+rows+'</div>'+
582
+ '<div class="actions"><span class="rem" id="rem"></span>'+
583
+ '<button class="ghost" id="skip">Skip <span class="kbd">S</span></button>'+
584
+ '<button class="primary" id="next" disabled>Submit <span class="kbd">Enter</span></button>'+
585
+ '</div></div>';
586
+ card.querySelectorAll('.scale button').forEach(b=>b.onclick=()=>pick(b.dataset.d,+b.dataset.v));
587
+ document.getElementById('next').onclick=submit;
588
+ document.getElementById('skip').onclick=()=>{i++;render();};
589
+ card.querySelectorAll('.pane').forEach(pane=>{
590
+ pane.querySelectorAll('.pacts [data-t]').forEach(b=>b.onclick=()=>{
591
+ const t=b.dataset.t;
592
+ pane.querySelector('.rendered').classList.toggle('hidden',t!=='rendered');
593
+ pane.querySelector('.source').classList.toggle('hidden',t!=='source');
594
+ pane.querySelectorAll('[data-t]').forEach(x=>x.classList.toggle('active',x===b));
595
+ });
596
+ });
597
+ card.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{
598
+ const vw=it.views[+b.dataset.copy];
599
+ const text=typeof vw.value==='string'?vw.value:JSON.stringify(vw.value,null,2);
600
+ navigator.clipboard.writeText(text).then(()=>{
601
+ b.textContent='Copied';setTimeout(()=>{b.textContent='Copy';},1200);});
602
+ });
603
+ updateRem();
604
+ document.getElementById('foot').innerHTML=
605
+ 'Keys: <span class="kbd">1</span>&ndash;<span class="kbd">'+scale+
606
+ '</span> rate &middot; <span class="kbd">Tab</span> next field &middot; '+
607
+ '<span class="kbd">Enter</span> submit &middot; <span class="kbd">S</span> skip';
608
+ }
609
+ function pick(d,v){
610
+ cur[d]=v;
611
+ document.querySelectorAll('[data-d="'+d+'"]').forEach(x=>x.classList.remove('on'));
612
+ document.querySelector('[data-d="'+d+'"][data-v="'+v+'"]').classList.add('on');
613
+ const di=dims.indexOf(d);if(di>-1&&di+1<dims.length&&di===focus)focus=di+1;
614
+ updateRem();
615
+ }
616
+ function updateRem(){
617
+ const left=dims.filter(d=>!(d in cur)).length;
618
+ document.getElementById('next').disabled=left>0;
619
+ document.getElementById('rem').textContent=left
620
+ ?left+' dimension'+(left===1?'':'s')+' left':'ready to submit';
621
+ }
622
+ async function submit(){
623
+ if(dims.some(d=>!(d in cur)))return;
624
+ try{
625
+ const r=await fetch('/api/rate',{method:'POST',
626
+ headers:{'content-type':'application/json'},
627
+ body:JSON.stringify({id:queue[i].id,rater:rater,scores:cur})});
628
+ if(!r.ok)throw 0;
629
+ }catch(e){toast('Save failed - not recorded');return;}
630
+ i++;render();
631
+ }
632
+ document.addEventListener('keydown',e=>{
633
+ if(i>=queue.length)return;
634
+ if(e.key==='Enter'){if(!document.getElementById('next').disabled)submit();return;}
635
+ if(e.key==='s'||e.key==='S'){i++;render();return;}
636
+ const n=parseInt(e.key,10);
637
+ if(!isNaN(n)&&n>=1&&n<=scale&&dims[focus])pick(dims[focus],n);
638
+ });
639
+ load();
640
+ </script></body></html>"""
641
+
642
+
643
+ class _RatingApp:
644
+ """Server-side state: the blind queue and rating persistence."""
645
+
646
+ def __init__(
647
+ self,
648
+ runs,
649
+ ratings_path,
650
+ dimensions,
651
+ scale,
652
+ content_ref=None,
653
+ screenshot_ref=None,
654
+ views=None,
655
+ ):
656
+ self.ratings_path = ratings_path
657
+ self.dimensions = dimensions
658
+ self.scale = scale
659
+ specs = views or _default_view_specs(content_ref, screenshot_ref)
660
+ # Full items keep the identity + file paths the client never sees.
661
+ self.items = []
662
+ for run in runs:
663
+ for result in run.results:
664
+ if result.output.error:
665
+ continue
666
+ panels, files = self._panels(result.output, specs)
667
+ self.items.append(
668
+ {
669
+ 'run_id': run.run_id,
670
+ 'case_id': result.case.id,
671
+ 'sample_idx': result.sample_idx,
672
+ 'input': result.case.input,
673
+ 'panels': panels, # client-safe (no paths)
674
+ 'files': files, # server-only: index -> path
675
+ }
676
+ )
677
+
678
+ def _panels(
679
+ self, output: models.Output, specs: list[dict]
680
+ ) -> tuple[list[dict], list[str]]:
681
+ """Derive an item's render panels from its output (see
682
+ :func:`_derive_panels`)."""
683
+ return _derive_panels(output, specs)
684
+
685
+ def _rated_keys(self, rater: str) -> set:
686
+ """(run_id, case_id, sample_idx) this rater has already scored.
687
+
688
+ Read from the ratings file so a reopened session resumes where the
689
+ rater left off instead of re-presenting completed items.
690
+ """
691
+ return {
692
+ (r.run_id, r.case_id, r.sample_idx)
693
+ for r in store.read_ratings(self.ratings_path)
694
+ if r.rater == rater
695
+ }
696
+
697
+ def queue_for(self, rater: str) -> list[dict]:
698
+ """Seeded-shuffled blind payload: opaque id + safe fields only.
699
+
700
+ Items this rater already rated are dropped (resumable sessions);
701
+ the payload never carries run/variant/model identity.
702
+ """
703
+ order = list(range(len(self.items)))
704
+ # Non-crypto: a stable per-rater presentation order, not a secret.
705
+ random.Random(rater).shuffle(order) # noqa: S311
706
+ rated = self._rated_keys(rater)
707
+ blind = []
708
+ for idx in order:
709
+ item = self.items[idx]
710
+ key = (item['run_id'], item['case_id'], item['sample_idx'])
711
+ if key in rated:
712
+ continue
713
+ blind.append(
714
+ {'id': idx, 'input': item['input'], 'views': item['panels']}
715
+ )
716
+ return blind
717
+
718
+ def record(self, idx: int, rater: str, scores: dict) -> None:
719
+ item = self.items[idx]
720
+ store.append_rating(
721
+ self.ratings_path,
722
+ models.Rating(
723
+ run_id=item['run_id'],
724
+ case_id=item['case_id'],
725
+ sample_idx=item['sample_idx'],
726
+ rater=rater,
727
+ scores={k: int(v) for k, v in scores.items()},
728
+ ),
729
+ )
730
+
731
+
732
+ class _BaseHandler(http.server.BaseHTTPRequestHandler):
733
+ """Shared plumbing for the rate + rank apps: silent logging, byte
734
+ responses, static asset serving, and safe artifact-file streaming."""
735
+
736
+ def log_message(self, *args):
737
+ pass # silence the default per-request stderr logging
738
+
739
+ def _send(self, code, body, content_type='application/json'):
740
+ data = body if isinstance(body, bytes) else body.encode('utf-8')
741
+ self.send_response(code)
742
+ self.send_header('Content-Type', content_type)
743
+ self.send_header('Content-Length', str(len(data)))
744
+ self.end_headers()
745
+ self.wfile.write(data)
746
+
747
+ def _serve_file(self, path: str) -> None:
748
+ ctype = _CONTENT_TYPES.get(
749
+ pathlib.Path(path).suffix.lower(), 'application/octet-stream'
750
+ )
751
+ with open(path, 'rb') as handle:
752
+ self._send(200, handle.read(), ctype)
753
+
754
+
755
+ class _Handler(_BaseHandler):
756
+ app: _RatingApp
757
+
758
+ def do_GET(self):
759
+ import urllib.parse
760
+
761
+ parsed = urllib.parse.urlparse(self.path)
762
+ query = urllib.parse.parse_qs(parsed.query)
763
+ if parsed.path == '/':
764
+ config = json.dumps(
765
+ {'scale': self.app.scale, 'dims': self.app.dimensions}
766
+ )
767
+ page = _PAGE.replace('__CONFIG__', config)
768
+ self._send(200, page, 'text/html; charset=utf-8')
769
+ elif parsed.path == '/api/queue':
770
+ rater = (query.get('rater') or ['anon'])[0]
771
+ self._send(200, json.dumps(self.app.queue_for(rater)))
772
+ elif parsed.path == '/api/artifact':
773
+ idx = int((query.get('id') or ['-1'])[0])
774
+ view = int((query.get('v') or ['-1'])[0])
775
+ try:
776
+ self._serve_file(self.app.items[idx]['files'][view])
777
+ except OSError, IndexError, KeyError:
778
+ self._send(404, b'', 'application/octet-stream')
779
+ else:
780
+ self._send(404, '{}')
781
+
782
+ def do_POST(self):
783
+ if self.path != '/api/rate':
784
+ self._send(404, '{}')
785
+ return
786
+ length = int(self.headers.get('Content-Length', 0))
787
+ payload = json.loads(self.rfile.read(length) or b'{}')
788
+ self.app.record(
789
+ int(payload['id']), payload['rater'], payload['scores']
790
+ )
791
+ self._send(200, '{"ok": true}')
792
+
793
+
794
+ def serve(
795
+ runs: list[models.RunResult],
796
+ ratings_path: str,
797
+ dimensions: list[str],
798
+ *,
799
+ scale: int = 5,
800
+ content_ref: str | None = None,
801
+ screenshot_ref: str | None = 'artifacts.screenshot',
802
+ views: list[dict] | None = None,
803
+ host: str = '127.0.0.1',
804
+ port: int = 8900,
805
+ open_browser: bool = True,
806
+ ) -> None:
807
+ """Run the blind rating web app until interrupted (Ctrl-C).
808
+
809
+ Artifacts are rendered by type. ``views`` is the general form: a list of
810
+ ``{label, ref, kind?}`` where ``kind`` is image/pdf/html/json/text
811
+ (inferred when omitted). ``content_ref``/``screenshot_ref`` remain as a
812
+ shorthand for the common html+screenshot case. With neither, each item
813
+ falls back to its output fields, so text- or JSON-only results still
814
+ render.
815
+ """
816
+ _Handler.app = _RatingApp(
817
+ runs,
818
+ ratings_path,
819
+ dimensions,
820
+ scale,
821
+ content_ref=content_ref,
822
+ screenshot_ref=screenshot_ref,
823
+ views=views,
824
+ )
825
+ server = http.server.ThreadingHTTPServer((host, port), _Handler)
826
+ url = f'http://{host}:{port}/?rater=anon'
827
+ n = len(_Handler.app.items)
828
+ print(f'blind rating: {n} items on {url} (Ctrl-C to stop)')
829
+ print(f'ratings -> {ratings_path}')
830
+ if open_browser:
831
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
832
+ try:
833
+ server.serve_forever()
834
+ except KeyboardInterrupt:
835
+ pass
836
+ finally:
837
+ server.shutdown()
838
+
839
+
840
+ # -- the blind side-by-side ranking web app ---------------------------------
841
+
842
+ # The A-vs-B human analog of the rate app: two outputs for the same case are
843
+ # shown as neutral "Option 1"/"Option 2" columns and the rater picks a winner
844
+ # overall and per dimension. ``__CONFIG__`` is replaced with ``{dims}``.
845
+ _RANK_PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
846
+ <meta name="viewport" content="width=device-width, initial-scale=1">
847
+ <title>evalkit &middot; blind ranking</title><style>
848
+ :root{
849
+ --bg:#f5f6f8;--panel:#fff;--panel-2:#fafbfc;--ink:#1a1d21;--muted:#68707a;
850
+ --line:#e3e6ea;--brand:#2f6fed;--brand-ink:#fff;--on:#1f8f5f;--on-ink:#fff;
851
+ --shadow:0 1px 3px rgba(20,25,35,.08),0 8px 24px rgba(20,25,35,.06);--frame:#fff;
852
+ }
853
+ @media (prefers-color-scheme:dark){:root{
854
+ --bg:#0d0f12;--panel:#16191e;--panel-2:#1b1f25;--ink:#eceef1;--muted:#9aa3ad;
855
+ --line:#2a2f37;--brand:#4f8bff;--brand-ink:#06101f;--on:#3ecf8e;--on-ink:#052012;
856
+ --shadow:0 1px 2px rgba(0,0,0,.4),0 12px 30px rgba(0,0,0,.35);--frame:#f4f5f7;
857
+ }}
858
+ *{box-sizing:border-box}
859
+ body{margin:0;background:var(--bg);color:var(--ink);
860
+ font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
861
+ header{position:sticky;top:0;z-index:5;background:var(--panel);
862
+ border-bottom:1px solid var(--line);backdrop-filter:blur(6px)}
863
+ .hrow{display:flex;align-items:center;gap:14px;max-width:1280px;margin:0 auto;
864
+ padding:12px 20px}
865
+ .brand{font-weight:700;letter-spacing:.2px}
866
+ .brand small{font-weight:500;color:var(--muted);margin-left:8px}
867
+ .who{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--muted);
868
+ font-size:13px}
869
+ .chip{background:var(--panel-2);border:1px solid var(--line);border-radius:999px;
870
+ padding:3px 10px;color:var(--ink);font-weight:600}
871
+ .progress{height:4px;background:var(--line)}
872
+ .progress i{display:block;height:100%;width:0;background:var(--brand);
873
+ transition:width .25s ease}
874
+ main{max-width:1280px;margin:0 auto;padding:22px 20px 80px}
875
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;
876
+ box-shadow:var(--shadow);overflow:hidden}
877
+ .cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin:0 0 16px}
878
+ @media (max-width:820px){.cols{grid-template-columns:1fr}}
879
+ .col{display:flex;flex-direction:column;gap:12px}
880
+ .ch{font-weight:700;font-size:12px;color:var(--muted);text-transform:uppercase;
881
+ letter-spacing:.7px;padding:2px}
882
+ .pane{background:var(--panel);border:1px solid var(--line);border-radius:14px;
883
+ box-shadow:var(--shadow);overflow:hidden}
884
+ .phd{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--panel);
885
+ border-bottom:1px solid var(--line)}
886
+ .plabel{font-weight:700;font-size:13px}
887
+ .pkind{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);
888
+ border:1px solid var(--line);border-radius:999px;padding:1px 8px}
889
+ .pacts{margin-left:auto;display:flex;gap:6px}
890
+ .pacts button{padding:4px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);
891
+ border-radius:6px;cursor:pointer;background:var(--panel-2);color:var(--ink)}
892
+ .pacts button.active{background:var(--brand);color:var(--brand-ink);border-color:var(--brand)}
893
+ .pbody{background:var(--frame)}
894
+ .pbody .rendered iframe{display:block;width:100%;height:560px;border:0;background:var(--frame)}
895
+ .pbody .embed{display:block;width:100%;height:520px;border:0;background:var(--frame)}
896
+ .pbody img{display:block;width:100%;height:auto}
897
+ .pbody pre.src{margin:0;padding:14px 16px;max-height:520px;overflow:auto;
898
+ white-space:pre-wrap;word-break:break-word;tab-size:2;
899
+ font:12.5px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
900
+ background:var(--panel)}
901
+ .hidden{display:none}
902
+ .body{padding:18px 20px}
903
+ details{margin:0 0 16px;border:1px solid var(--line);border-radius:10px;
904
+ background:var(--panel-2)}
905
+ details summary{cursor:pointer;padding:10px 14px;color:var(--muted);
906
+ font-size:13px;font-weight:600;user-select:none}
907
+ details pre{margin:0;padding:0 14px 12px;white-space:pre-wrap;word-break:break-word;
908
+ font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink)}
909
+ .prow{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--line)}
910
+ .prow.head{border-top:0;color:var(--muted);font-weight:600;padding-bottom:2px}
911
+ .prow:first-of-type{border-top:0}
912
+ .plab{min-width:160px;font-weight:600}
913
+ .picks{display:flex;gap:8px;flex-wrap:wrap}
914
+ .picks button{min-width:118px;height:40px;padding:0 12px;border:1px solid var(--line);
915
+ background:var(--panel-2);color:var(--ink);border-radius:10px;cursor:pointer;
916
+ font-size:14px;font-weight:600;transition:transform .05s,background .12s,border-color .12s}
917
+ .picks button:hover{border-color:var(--brand)}
918
+ .picks button:active{transform:translateY(1px)}
919
+ .picks button.on{background:var(--brand);color:var(--brand-ink);border-color:var(--brand)}
920
+ .actions{position:sticky;bottom:0;display:flex;align-items:center;gap:12px;
921
+ padding:14px 20px;background:var(--panel);border-top:1px solid var(--line)}
922
+ .actions .rem{color:var(--muted);font-size:13px;margin-right:auto}
923
+ button.primary,button.ghost{border-radius:10px;font-size:15px;font-weight:600;
924
+ cursor:pointer;padding:11px 20px;border:1px solid var(--line)}
925
+ button.primary{background:var(--brand);color:var(--brand-ink);border-color:var(--brand)}
926
+ button.primary:disabled{opacity:.45;cursor:not-allowed}
927
+ button.ghost{background:transparent;color:var(--muted)}
928
+ .kbd{font:12px ui-monospace,monospace;background:var(--panel-2);border:1px solid var(--line);
929
+ border-radius:6px;padding:1px 6px;color:var(--muted)}
930
+ .empty{text-align:center;padding:70px 20px;color:var(--muted)}
931
+ .empty h2{color:var(--ink);margin:0 0 8px}
932
+ #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%) translateY(20px);
933
+ background:#c0392b;color:#fff;padding:10px 16px;border-radius:8px;opacity:0;
934
+ transition:.2s;pointer-events:none}
935
+ #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
936
+ .foot{max-width:1280px;margin:14px auto 0;padding:0 20px;color:var(--muted);
937
+ font-size:12px;display:flex;gap:16px;flex-wrap:wrap}
938
+ </style></head><body>
939
+ <header>
940
+ <div class="hrow">
941
+ <span class="brand">evalkit<small>blind ranking</small></span>
942
+ <span class="who">rater <span class="chip" id="rater"></span>
943
+ <span id="pos"></span></span>
944
+ </div>
945
+ <div class="progress"><i id="pbar"></i></div>
946
+ </header>
947
+ <main><div id="card"><div class="empty">Loading queue&hellip;</div></div>
948
+ <div class="foot" id="foot"></div>
949
+ </main>
950
+ <div id="toast"></div>
951
+ <script>
952
+ const CONFIG=__CONFIG__, dims=CONFIG.dims;
953
+ const q=new URLSearchParams(location.search), rater=q.get('rater')||'anon';
954
+ let queue=[], i=0, cur={winner:null,dims:{}}, total=0;
955
+ document.getElementById('rater').textContent=rater;
956
+ const pretty=d=>d.replace(/[_-]+/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase());
957
+ const isDoc=h=>/<(?:!doctype|html|head|body|style)[\\s>]/i.test(h);
958
+ const FRAME='<!doctype html><html><head><meta charset="utf-8">'+
959
+ '<meta name="viewport" content="width=device-width,initial-scale=1"><style>'+
960
+ 'html{color-scheme:light}body{margin:0;padding:28px;background:#f6f7f9;'+
961
+ 'font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#1a1d21}'+
962
+ 'h1,h2,h3{margin:0 0 14px;line-height:1.25}'+
963
+ 'form{max-width:480px;margin:0 auto;background:#fff;border:1px solid #e3e6ea;'+
964
+ 'border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:12px;'+
965
+ 'box-shadow:0 1px 3px rgba(20,25,35,.06)}'+
966
+ 'label{font-size:13px;font-weight:600;color:#57606a}'+
967
+ 'input,select,textarea{width:100%;padding:10px 12px;font-size:15px;'+
968
+ 'border:1px solid #cbd2da;border-radius:8px;background:#fff;color:#1a1d21}'+
969
+ 'button{padding:11px 16px;font-size:15px;font-weight:600;border:0;'+
970
+ 'border-radius:8px;background:#2f6fed;color:#fff;cursor:pointer}'+
971
+ 'p{color:#57606a;margin:2px 0;font-size:13px}</style></head><body>';
972
+ const frameDoc=html=>
973
+ (isDoc(html)?html:FRAME+html+'</body></html>').replace(/"/g,'&quot;');
974
+ const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
975
+ function artURL(id,side,v){
976
+ return '/api/artifact?id='+id+'&side='+side+'&v='+v+
977
+ '&rater='+encodeURIComponent(rater);
978
+ }
979
+ function panelHTML(views,side,id){
980
+ if(!views||!views.length)
981
+ return '<section class="pane"><div class="pbody"><pre class="src">'+
982
+ '(no renderable output)</pre></div></section>';
983
+ return views.map((vw,vi)=>{
984
+ let body='',acts='';
985
+ if(vw.kind==='image'){
986
+ body='<img alt="'+esc(vw.label)+'" src="'+artURL(id,side,vw.v)+'">';
987
+ }else if(vw.kind==='pdf'){
988
+ body='<iframe class="embed" title="'+esc(vw.label)+'" src="'+
989
+ artURL(id,side,vw.v)+'"></iframe>';
990
+ }else if(vw.kind==='html'){
991
+ body='<div class="rendered"><iframe sandbox title="'+esc(vw.label)+
992
+ '" srcdoc="'+frameDoc(vw.value)+'"></iframe></div>'+
993
+ '<div class="source hidden"><pre class="src">'+esc(vw.value)+'</pre></div>';
994
+ acts='<button data-t="rendered" class="active">Rendered</button>'+
995
+ '<button data-t="source">Source</button>';
996
+ }else if(vw.kind==='json'){
997
+ body='<pre class="src">'+esc(JSON.stringify(vw.value,null,2))+'</pre>';
998
+ }else{
999
+ body='<pre class="src">'+esc(vw.value)+'</pre>';
1000
+ }
1001
+ return '<section class="pane" data-vi="'+vi+'"><div class="phd">'+
1002
+ '<span class="plabel">'+esc(vw.label)+'</span>'+
1003
+ '<span class="pkind">'+vw.kind+'</span><div class="pacts">'+acts+
1004
+ '</div></div><div class="pbody">'+body+'</div></section>';
1005
+ }).join('');
1006
+ }
1007
+ function toast(msg){const t=document.getElementById('toast');t.textContent=msg;
1008
+ t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2600);}
1009
+ function setProgress(){const done=total-queue.length+i;
1010
+ document.getElementById('pbar').style.width=total?(100*done/total)+'%':'0';
1011
+ document.getElementById('pos').textContent=total?((done+1>total?total:done+1)+' / '+total):'';}
1012
+ async function load(){
1013
+ try{queue=await (await fetch('/api/queue?rater='+encodeURIComponent(rater))).json();}
1014
+ catch(e){toast('Could not load the queue');return;}
1015
+ total=queue.length;i=0;render();
1016
+ }
1017
+ function done(){
1018
+ document.getElementById('card').innerHTML=
1019
+ '<div class="empty"><h2>All done &mdash; thank you.</h2>'+
1020
+ '<p>'+total+' pair'+(total===1?'':'s')+' ranked as <b>'+rater+'</b>.</p>'+
1021
+ '<p>Reopen this page anytime to resume where you left off.</p></div>';
1022
+ document.getElementById('pbar').style.width='100%';
1023
+ document.getElementById('pos').textContent=total+' / '+total;
1024
+ document.getElementById('foot').innerHTML='';
1025
+ }
1026
+ function pickRow(key,label){
1027
+ return '<div class="prow" data-row="'+key+'"><span class="plab">'+label+
1028
+ '</span><div class="picks">'+
1029
+ '<button data-pick="left">&#9664; Option 1</button>'+
1030
+ '<button data-pick="tie">Tie</button>'+
1031
+ '<button data-pick="right">Option 2 &#9654;</button></div></div>';
1032
+ }
1033
+ function render(){
1034
+ const card=document.getElementById('card');
1035
+ if(i>=queue.length){done();return;}
1036
+ const it=queue[i];cur={winner:null,dims:{}};setProgress();
1037
+ const cols='<div class="cols">'+
1038
+ '<div class="col"><div class="ch">Option 1</div>'+
1039
+ panelHTML(it.left,'left',it.id)+'</div>'+
1040
+ '<div class="col"><div class="ch">Option 2</div>'+
1041
+ panelHTML(it.right,'right',it.id)+'</div></div>';
1042
+ const req=it.input?'<details><summary>Prompt / input</summary><pre>'+
1043
+ JSON.stringify(it.input,null,2).replace(/</g,'&lt;')+'</pre></details>':'';
1044
+ const rows=pickRow('__overall__','Overall')+
1045
+ dims.map(d=>pickRow(d,pretty(d))).join('');
1046
+ card.innerHTML=cols+
1047
+ '<div class="card"><div class="body">'+req+
1048
+ '<div class="prow head"><span class="plab">Which is better?</span></div>'+
1049
+ rows+'</div>'+
1050
+ '<div class="actions"><span class="rem" id="rem"></span>'+
1051
+ '<button class="ghost" id="skip">Skip <span class="kbd">S</span></button>'+
1052
+ '<button class="primary" id="next" disabled>Submit <span class="kbd">Enter</span></button>'+
1053
+ '</div></div>';
1054
+ card.querySelectorAll('.prow[data-row]').forEach(row=>{
1055
+ row.querySelectorAll('[data-pick]').forEach(b=>b.onclick=()=>{
1056
+ const key=row.dataset.row,val=b.dataset.pick;
1057
+ if(key==='__overall__')cur.winner=val; else cur.dims[key]=val;
1058
+ row.querySelectorAll('[data-pick]').forEach(x=>x.classList.toggle('on',x===b));
1059
+ updateRem();
1060
+ });
1061
+ });
1062
+ card.querySelectorAll('.pane').forEach(pane=>{
1063
+ pane.querySelectorAll('.pacts [data-t]').forEach(b=>b.onclick=()=>{
1064
+ const t=b.dataset.t;
1065
+ pane.querySelector('.rendered').classList.toggle('hidden',t!=='rendered');
1066
+ pane.querySelector('.source').classList.toggle('hidden',t!=='source');
1067
+ pane.querySelectorAll('[data-t]').forEach(x=>x.classList.toggle('active',x===b));
1068
+ });
1069
+ });
1070
+ document.getElementById('next').onclick=submit;
1071
+ document.getElementById('skip').onclick=()=>{i++;render();};
1072
+ updateRem();
1073
+ document.getElementById('foot').innerHTML=
1074
+ 'Option 1 / Option 2 sides are shuffled per rater and un-blinded '+
1075
+ 'server-side &middot; <span class="kbd">Enter</span> submit &middot; '+
1076
+ '<span class="kbd">S</span> skip';
1077
+ }
1078
+ function updateRem(){
1079
+ const left=(cur.winner===null?1:0)+dims.filter(d=>!(d in cur.dims)).length;
1080
+ document.getElementById('next').disabled=left>0;
1081
+ document.getElementById('rem').textContent=left
1082
+ ?left+' pick'+(left===1?'':'s')+' left':'ready to submit';
1083
+ }
1084
+ async function submit(){
1085
+ if(cur.winner===null||dims.some(d=>!(d in cur.dims)))return;
1086
+ try{
1087
+ const r=await fetch('/api/rank',{method:'POST',
1088
+ headers:{'content-type':'application/json'},
1089
+ body:JSON.stringify({id:queue[i].id,rater:rater,
1090
+ winner:cur.winner,dims:cur.dims})});
1091
+ if(!r.ok)throw 0;
1092
+ }catch(e){toast('Save failed - not recorded');return;}
1093
+ i++;render();
1094
+ }
1095
+ document.addEventListener('keydown',e=>{
1096
+ if(i>=queue.length)return;
1097
+ if(e.key==='Enter'){if(!document.getElementById('next').disabled)submit();return;}
1098
+ if(e.key==='s'||e.key==='S'){i++;render();}
1099
+ });
1100
+ load();
1101
+ </script></body></html>"""
1102
+
1103
+
1104
+ class _RankApp:
1105
+ """Server-side state for the blind side-by-side ranking app.
1106
+
1107
+ Aligns two runs by ``(case_id, sample_idx)``, shows each pair as neutral
1108
+ "Option 1"/"Option 2" columns whose A/B orientation is seeded per
1109
+ ``(rater, item)`` - so position bias is counterbalanced across raters -
1110
+ and un-blinds each pick back to *variant* terms server-side before it is
1111
+ written as a :class:`~evalkit.models.Preference`.
1112
+ """
1113
+
1114
+ def __init__(
1115
+ self,
1116
+ run_a: models.RunResult,
1117
+ run_b: models.RunResult,
1118
+ preferences_path,
1119
+ dimensions,
1120
+ content_ref=None,
1121
+ screenshot_ref=None,
1122
+ views=None,
1123
+ ):
1124
+ self.preferences_path = preferences_path
1125
+ self.dimensions = dimensions
1126
+ self.variant_a = run_a.scorecard.variant.name
1127
+ self.variant_b = run_b.scorecard.variant.name
1128
+ specs = views or _default_view_specs(content_ref, screenshot_ref)
1129
+ a_by = {
1130
+ (r.case.id, r.sample_idx): r
1131
+ for r in run_a.results
1132
+ if not r.output.error
1133
+ }
1134
+ b_by = {
1135
+ (r.case.id, r.sample_idx): r
1136
+ for r in run_b.results
1137
+ if not r.output.error
1138
+ }
1139
+ # Only cases both variants produced are comparable (like pairwise).
1140
+ self.items = []
1141
+ for key in sorted(a_by.keys() & b_by.keys()):
1142
+ ra, rb = a_by[key], b_by[key]
1143
+ panels_a, files_a = _derive_panels(ra.output, specs)
1144
+ panels_b, files_b = _derive_panels(rb.output, specs)
1145
+ self.items.append(
1146
+ {
1147
+ 'case_id': key[0],
1148
+ 'sample_idx': key[1],
1149
+ 'input': ra.case.input,
1150
+ 'a': {'panels': panels_a, 'files': files_a},
1151
+ 'b': {'panels': panels_b, 'files': files_b},
1152
+ }
1153
+ )
1154
+
1155
+ def _a_on_left(self, rater: str, idx: int) -> bool:
1156
+ """Whether variant A is shown on the left for this (rater, item).
1157
+
1158
+ Deterministic, so orientation is stable across reloads (resumable)
1159
+ yet varies across raters - counterbalancing position bias in the
1160
+ aggregate without any server-side session state.
1161
+ """
1162
+ # Non-crypto: a presentation coin-flip, not a secret.
1163
+ return random.Random(f'{rater}:{idx}').random() < 0.5 # noqa: S311
1164
+
1165
+ def _to_variant(self, pick: str, a_on_left: bool) -> str:
1166
+ """Map a display pick ('left'/'right'/'tie') to variant terms."""
1167
+ if pick not in ('left', 'right'):
1168
+ return 'tie'
1169
+ left_is_a = a_on_left
1170
+ if pick == 'left':
1171
+ return 'a' if left_is_a else 'b'
1172
+ return 'b' if left_is_a else 'a'
1173
+
1174
+ def _ranked_keys(self, rater: str) -> set:
1175
+ """(case_id, sample_idx) this rater already ranked for THIS pair.
1176
+
1177
+ Read back from the preferences file so a reopened session resumes;
1178
+ filtered to this A/B orientation so an unrelated comparison in the
1179
+ same file doesn't hide items.
1180
+ """
1181
+ return {
1182
+ (p.case_id, p.sample_idx)
1183
+ for p in store.read_preferences(self.preferences_path)
1184
+ if p.rater == rater
1185
+ and p.variant_a == self.variant_a
1186
+ and p.variant_b == self.variant_b
1187
+ }
1188
+
1189
+ def queue_for(self, rater: str) -> list[dict]:
1190
+ """Seeded-shuffled blind payload: opaque id + both columns, no
1191
+ variant/model identity and no server file paths."""
1192
+ order = list(range(len(self.items)))
1193
+ # Non-crypto: a stable per-rater presentation order, not a secret.
1194
+ random.Random(rater).shuffle(order) # noqa: S311
1195
+ ranked = self._ranked_keys(rater)
1196
+ blind = []
1197
+ for idx in order:
1198
+ item = self.items[idx]
1199
+ if (item['case_id'], item['sample_idx']) in ranked:
1200
+ continue
1201
+ a_left = self._a_on_left(rater, idx)
1202
+ left = item['a'] if a_left else item['b']
1203
+ right = item['b'] if a_left else item['a']
1204
+ blind.append(
1205
+ {
1206
+ 'id': idx,
1207
+ 'input': item['input'],
1208
+ 'left': left['panels'],
1209
+ 'right': right['panels'],
1210
+ }
1211
+ )
1212
+ return blind
1213
+
1214
+ def file_path(self, idx: int, side: str, rater: str, view: int) -> str:
1215
+ """Resolve a display-side artifact request back to its file path."""
1216
+ a_left = self._a_on_left(rater, idx)
1217
+ if side == 'left':
1218
+ which = 'a' if a_left else 'b'
1219
+ else:
1220
+ which = 'b' if a_left else 'a'
1221
+ return self.items[idx][which]['files'][view]
1222
+
1223
+ def record(self, idx: int, rater: str, winner: str, dims: dict) -> None:
1224
+ item = self.items[idx]
1225
+ a_left = self._a_on_left(rater, idx)
1226
+ store.append_preference(
1227
+ self.preferences_path,
1228
+ models.Preference(
1229
+ case_id=item['case_id'],
1230
+ sample_idx=item['sample_idx'],
1231
+ variant_a=self.variant_a,
1232
+ variant_b=self.variant_b,
1233
+ rater=rater,
1234
+ winner=self._to_variant(winner, a_left),
1235
+ dims={d: self._to_variant(p, a_left) for d, p in dims.items()},
1236
+ ),
1237
+ )
1238
+
1239
+
1240
+ class _RankHandler(_BaseHandler):
1241
+ app: _RankApp
1242
+
1243
+ def do_GET(self):
1244
+ import urllib.parse
1245
+
1246
+ parsed = urllib.parse.urlparse(self.path)
1247
+ query = urllib.parse.parse_qs(parsed.query)
1248
+ if parsed.path == '/':
1249
+ config = json.dumps({'dims': self.app.dimensions})
1250
+ page = _RANK_PAGE.replace('__CONFIG__', config)
1251
+ self._send(200, page, 'text/html; charset=utf-8')
1252
+ elif parsed.path == '/api/queue':
1253
+ rater = (query.get('rater') or ['anon'])[0]
1254
+ self._send(200, json.dumps(self.app.queue_for(rater)))
1255
+ elif parsed.path == '/api/artifact':
1256
+ idx = int((query.get('id') or ['-1'])[0])
1257
+ side = (query.get('side') or ['left'])[0]
1258
+ view = int((query.get('v') or ['-1'])[0])
1259
+ rater = (query.get('rater') or ['anon'])[0]
1260
+ try:
1261
+ self._serve_file(self.app.file_path(idx, side, rater, view))
1262
+ except OSError, IndexError, KeyError:
1263
+ self._send(404, b'', 'application/octet-stream')
1264
+ else:
1265
+ self._send(404, '{}')
1266
+
1267
+ def do_POST(self):
1268
+ if self.path != '/api/rank':
1269
+ self._send(404, '{}')
1270
+ return
1271
+ length = int(self.headers.get('Content-Length', 0))
1272
+ payload = json.loads(self.rfile.read(length) or b'{}')
1273
+ self.app.record(
1274
+ int(payload['id']),
1275
+ payload['rater'],
1276
+ payload.get('winner', 'tie'),
1277
+ payload.get('dims', {}),
1278
+ )
1279
+ self._send(200, '{"ok": true}')
1280
+
1281
+
1282
+ def serve_rank(
1283
+ run_a: models.RunResult,
1284
+ run_b: models.RunResult,
1285
+ preferences_path: str,
1286
+ dimensions: list[str],
1287
+ *,
1288
+ content_ref: str | None = None,
1289
+ screenshot_ref: str | None = 'artifacts.screenshot',
1290
+ views: list[dict] | None = None,
1291
+ host: str = '127.0.0.1',
1292
+ port: int = 8901,
1293
+ open_browser: bool = True,
1294
+ ) -> None:
1295
+ """Run the blind side-by-side ranking web app until interrupted (Ctrl-C).
1296
+
1297
+ ``run_a``/``run_b`` are the two variants' saved runs; the app aligns them
1298
+ by ``(case_id, sample_idx)`` and appends one
1299
+ :class:`~evalkit.models.Preference` per ranked pair to
1300
+ ``preferences_path``. Panels are derived exactly like :func:`serve`.
1301
+ """
1302
+ _RankHandler.app = _RankApp(
1303
+ run_a,
1304
+ run_b,
1305
+ preferences_path,
1306
+ dimensions,
1307
+ content_ref=content_ref,
1308
+ screenshot_ref=screenshot_ref,
1309
+ views=views,
1310
+ )
1311
+ server = http.server.ThreadingHTTPServer((host, port), _RankHandler)
1312
+ url = f'http://{host}:{port}/?rater=anon'
1313
+ n = len(_RankHandler.app.items)
1314
+ print(f'blind ranking: {n} pairs on {url} (Ctrl-C to stop)')
1315
+ print(f'preferences -> {preferences_path}')
1316
+ if open_browser:
1317
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
1318
+ try:
1319
+ server.serve_forever()
1320
+ except KeyboardInterrupt:
1321
+ pass
1322
+ finally:
1323
+ server.shutdown()