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.
- evalcore-0.1.0.dist-info/METADATA +828 -0
- evalcore-0.1.0.dist-info/RECORD +34 -0
- evalcore-0.1.0.dist-info/WHEEL +4 -0
- evalcore-0.1.0.dist-info/entry_points.txt +2 -0
- evalcore-0.1.0.dist-info/licenses/LICENSE +28 -0
- evalkit/__init__.py +39 -0
- evalkit/adapters/__init__.py +15 -0
- evalkit/adapters/_env.py +24 -0
- evalkit/adapters/base.py +40 -0
- evalkit/adapters/http.py +96 -0
- evalkit/adapters/replay.py +41 -0
- evalkit/cli.py +655 -0
- evalkit/compare.py +137 -0
- evalkit/graders/__init__.py +18 -0
- evalkit/graders/base.py +77 -0
- evalkit/graders/classification.py +107 -0
- evalkit/graders/deterministic.py +127 -0
- evalkit/graders/judge.py +635 -0
- evalkit/graders/numeric.py +91 -0
- evalkit/loader.py +129 -0
- evalkit/models.py +390 -0
- evalkit/pairwise.py +324 -0
- evalkit/py.typed +0 -0
- evalkit/rating.py +1323 -0
- evalkit/refs.py +71 -0
- evalkit/report.py +186 -0
- evalkit/reporters/__init__.py +33 -0
- evalkit/reporters/base.py +159 -0
- evalkit/reporters/html.py +426 -0
- evalkit/reporters/markdown.py +74 -0
- evalkit/retry.py +85 -0
- evalkit/runner.py +283 -0
- evalkit/store.py +286 -0
- evalkit/sweep.py +93 -0
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 · 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…</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,'"');
|
|
514
|
+
const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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 — 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,'<')+'</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 · '+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>–<span class="kbd">'+scale+
|
|
606
|
+
'</span> rate · <span class="kbd">Tab</span> next field · '+
|
|
607
|
+
'<span class="kbd">Enter</span> submit · <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 · 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…</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,'"');
|
|
974
|
+
const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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 — 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">◀ Option 1</button>'+
|
|
1030
|
+
'<button data-pick="tie">Tie</button>'+
|
|
1031
|
+
'<button data-pick="right">Option 2 ▶</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,'<')+'</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 · <span class="kbd">Enter</span> submit · '+
|
|
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()
|