benchmaker 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.
- benchmaker/__init__.py +152 -0
- benchmaker/bundle.py +193 -0
- benchmaker/cli.py +382 -0
- benchmaker/collect.py +178 -0
- benchmaker/config.py +448 -0
- benchmaker/env.py +87 -0
- benchmaker/load.py +326 -0
- benchmaker/metrics.py +234 -0
- benchmaker/monitors.py +228 -0
- benchmaker/runner.py +275 -0
- benchmaker/trace.py +217 -0
- benchmaker/types.py +98 -0
- benchmaker/workloads/__init__.py +53 -0
- benchmaker/workloads/agent.py +308 -0
- benchmaker/workloads/base.py +79 -0
- benchmaker/workloads/datasets.py +156 -0
- benchmaker/workloads/eval.py +504 -0
- benchmaker/workloads/hf.py +382 -0
- benchmaker/workloads/http.py +77 -0
- benchmaker/workloads/llm.py +258 -0
- benchmaker/workloads/sandbox.py +470 -0
- benchmaker-0.1.0.dist-info/METADATA +214 -0
- benchmaker-0.1.0.dist-info/RECORD +26 -0
- benchmaker-0.1.0.dist-info/WHEEL +5 -0
- benchmaker-0.1.0.dist-info/entry_points.txt +2 -0
- benchmaker-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Correctness / accuracy evaluation as a composable plugin.
|
|
2
|
+
|
|
3
|
+
Layered design (matches the rest of bench-maker):
|
|
4
|
+
|
|
5
|
+
* `EvalWorkloadType` wraps any base WorkloadType. It strips an
|
|
6
|
+
eval-only reference field (default `"reference"`) out of each dict item
|
|
7
|
+
before delegating to the base, and stashes the reference on `Request.meta`
|
|
8
|
+
so post-hooks can read it.
|
|
9
|
+
|
|
10
|
+
* `correctness_hook(scorer, ...)` returns a `PostResponseHook` that extracts
|
|
11
|
+
the model output from the `Response`, calls a `scorer(reference, prediction)`
|
|
12
|
+
function, merges the returned scores into `Sample.extra`, and (optionally)
|
|
13
|
+
fails the sample when a `gate_key` score is <= 0.
|
|
14
|
+
|
|
15
|
+
* Stock scorers — `exact_match`, `contains`, `regex_match`, `json_valid`,
|
|
16
|
+
`multiple_choice`, `judge_llm` — cover the usual graders. Custom scorers
|
|
17
|
+
are just `(reference, prediction) -> dict[str, float]` (sync or async).
|
|
18
|
+
|
|
19
|
+
Because everything lands in `Sample.extra`, the `MetricsAggregator` summarises
|
|
20
|
+
accuracy (`correct.mean`, `judge_score.p50`, ...) generically — no changes to
|
|
21
|
+
the core required.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any, Awaitable, Callable, Optional, Union
|
|
30
|
+
|
|
31
|
+
from benchmaker.types import (
|
|
32
|
+
PostResponseHook,
|
|
33
|
+
Request,
|
|
34
|
+
Response,
|
|
35
|
+
Sample,
|
|
36
|
+
TicketContext,
|
|
37
|
+
maybe_await,
|
|
38
|
+
)
|
|
39
|
+
from benchmaker.workloads.base import WorkloadType
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
# Reference plumbing: EvalWorkloadType wrapper
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EvalWorkloadType(WorkloadType):
|
|
48
|
+
"""Wrap a base WorkloadType to carry eval references through to post-hooks.
|
|
49
|
+
|
|
50
|
+
Items are typically dicts like `{"prompt": "...", "reference": "..."}`.
|
|
51
|
+
The named `reference_key` (and any `extra_meta_keys`) are stripped from the
|
|
52
|
+
item before it reaches the base workload-type — so the base can't accidentally
|
|
53
|
+
forward them to the service — and copied onto `Request.meta` so a downstream
|
|
54
|
+
post-hook (e.g. `correctness_hook`) can score the response against them.
|
|
55
|
+
|
|
56
|
+
The wrapper relies on `WorkloadType.run_ticket`'s default flow
|
|
57
|
+
(one `make_request` -> one fire -> one `make_sample`). Workload-types with
|
|
58
|
+
a custom `run_ticket` (e.g. sandbox lifecycle) should add a bespoke
|
|
59
|
+
post-hook instead of wrapping.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
base: WorkloadType,
|
|
65
|
+
reference_key: str = "reference",
|
|
66
|
+
extra_meta_keys: tuple[str, ...] = (),
|
|
67
|
+
name: Optional[str] = None,
|
|
68
|
+
):
|
|
69
|
+
self._base = base
|
|
70
|
+
self._reference_key = reference_key
|
|
71
|
+
self._extra_meta_keys = tuple(extra_meta_keys)
|
|
72
|
+
self.name = name or base.name
|
|
73
|
+
self.streaming = base.streaming
|
|
74
|
+
|
|
75
|
+
def _split(self, item: Any) -> tuple[Any, dict[str, Any]]:
|
|
76
|
+
"""Return (clean_item, eval_meta). For non-dict items, no split."""
|
|
77
|
+
if not isinstance(item, dict):
|
|
78
|
+
return item, {}
|
|
79
|
+
keys = {self._reference_key, *self._extra_meta_keys}
|
|
80
|
+
eval_meta = {k: item[k] for k in keys if k in item}
|
|
81
|
+
if not eval_meta:
|
|
82
|
+
return item, {}
|
|
83
|
+
clean = {k: v for k, v in item.items() if k not in keys}
|
|
84
|
+
return clean, eval_meta
|
|
85
|
+
|
|
86
|
+
async def make_request(self, item: Any) -> Request:
|
|
87
|
+
clean, eval_meta = self._split(item)
|
|
88
|
+
req = await self._base.make_request(clean)
|
|
89
|
+
for k, v in eval_meta.items():
|
|
90
|
+
req.meta.setdefault(k, v)
|
|
91
|
+
return req
|
|
92
|
+
|
|
93
|
+
async def make_sample(self, item: Any, request: Request, response: Response,
|
|
94
|
+
start_ts: float) -> Sample:
|
|
95
|
+
clean, _ = self._split(item)
|
|
96
|
+
return await self._base.make_sample(clean, request, response, start_ts)
|
|
97
|
+
|
|
98
|
+
async def aclose(self) -> None:
|
|
99
|
+
await self._base.aclose()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
# Response -> text extraction
|
|
104
|
+
# --------------------------------------------------------------------------- #
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_openai_text(response: Response) -> str:
|
|
108
|
+
"""Concatenate assistant text from an OpenAI chat-completions response.
|
|
109
|
+
|
|
110
|
+
Handles both streaming (`stream_chunks` of SSE bytes) and non-streaming
|
|
111
|
+
(`body` containing a single JSON object) forms. Returns "" if nothing
|
|
112
|
+
parses out — callers can fall back to `extract_raw_text` if they prefer
|
|
113
|
+
the raw body.
|
|
114
|
+
"""
|
|
115
|
+
parts: list[str] = []
|
|
116
|
+
chunks = response.stream_chunks
|
|
117
|
+
if chunks:
|
|
118
|
+
for raw in chunks:
|
|
119
|
+
for line in raw.splitlines():
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if line.startswith(b"data:"):
|
|
122
|
+
line = line[5:].strip()
|
|
123
|
+
if not line or line == b"[DONE]":
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
obj = json.loads(line)
|
|
127
|
+
except Exception:
|
|
128
|
+
continue
|
|
129
|
+
_collect_openai_text(obj, parts)
|
|
130
|
+
return "".join(parts)
|
|
131
|
+
|
|
132
|
+
if not response.body:
|
|
133
|
+
return ""
|
|
134
|
+
try:
|
|
135
|
+
obj = json.loads(response.body)
|
|
136
|
+
except Exception:
|
|
137
|
+
return ""
|
|
138
|
+
_collect_openai_text(obj, parts)
|
|
139
|
+
return "".join(parts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _collect_openai_text(obj: Any, parts: list[str]) -> None:
|
|
143
|
+
if not isinstance(obj, dict):
|
|
144
|
+
return
|
|
145
|
+
for ch in obj.get("choices") or []:
|
|
146
|
+
delta = ch.get("delta") or {}
|
|
147
|
+
if isinstance(delta.get("content"), str):
|
|
148
|
+
parts.append(delta["content"])
|
|
149
|
+
msg = ch.get("message") or {}
|
|
150
|
+
if isinstance(msg.get("content"), str):
|
|
151
|
+
parts.append(msg["content"])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def extract_raw_text(response: Response) -> str:
|
|
155
|
+
"""Decode `response.body` as UTF-8 (best effort)."""
|
|
156
|
+
if not response.body:
|
|
157
|
+
return ""
|
|
158
|
+
return response.body.decode("utf-8", errors="replace")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def extract_text(response: Response) -> str:
|
|
162
|
+
"""Default extractor: try OpenAI-chat shape, fall back to raw body."""
|
|
163
|
+
text = extract_openai_text(response)
|
|
164
|
+
if text:
|
|
165
|
+
return text
|
|
166
|
+
return extract_raw_text(response)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
Extractor = Callable[[Response], str]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
# Scorer protocol + correctness hook
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# A scorer takes (reference, prediction) and returns a dict of numeric scores,
|
|
178
|
+
# either sync or async. Reference may be None when the workload didn't supply one.
|
|
179
|
+
Scorer = Callable[
|
|
180
|
+
[Any, str],
|
|
181
|
+
Union[dict[str, float], Awaitable[dict[str, float]]],
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def correctness_hook(
|
|
186
|
+
scorer: Scorer,
|
|
187
|
+
*,
|
|
188
|
+
reference_key: str = "reference",
|
|
189
|
+
extractor: Optional[Extractor] = None,
|
|
190
|
+
gate_key: Optional[str] = "correct",
|
|
191
|
+
prefix: str = "",
|
|
192
|
+
require_reference: bool = True,
|
|
193
|
+
max_prediction_chars: Optional[int] = 2048,
|
|
194
|
+
) -> PostResponseHook:
|
|
195
|
+
"""Build a post-response hook that scores each request's output.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
scorer: `(reference, prediction) -> dict[str, float]` (sync or async).
|
|
199
|
+
The returned scores are merged into `Sample.extra` (with `prefix`
|
|
200
|
+
prepended). Reference comes from `Request.meta[reference_key]`,
|
|
201
|
+
populated by `EvalWorkloadType`.
|
|
202
|
+
reference_key: meta key carrying the gold reference.
|
|
203
|
+
extractor: maps `Response` -> prediction string. Defaults to
|
|
204
|
+
`extract_text` (OpenAI chat first, raw body fallback).
|
|
205
|
+
gate_key: if set and present in the scorer's output, the sample is
|
|
206
|
+
marked `ok=False` when that score is <= 0. Set to None to disable
|
|
207
|
+
gating (correctness still recorded, but doesn't affect goodput).
|
|
208
|
+
prefix: prepended to every extra key (e.g. `"eval_"`).
|
|
209
|
+
require_reference: if True and no reference is on the request, the
|
|
210
|
+
sample is left untouched and a `<prefix>missing_reference=1` flag is
|
|
211
|
+
added. If False, the scorer is still called with reference=None.
|
|
212
|
+
max_prediction_chars: cap on the prediction copy stored in
|
|
213
|
+
`Sample.meta["<prefix>prediction"]` (saved into `samples.jsonl`).
|
|
214
|
+
Default 2048 chars to keep bundles small; set to `None` (or 0) to
|
|
215
|
+
store the full output, or a smaller integer to truncate further.
|
|
216
|
+
"""
|
|
217
|
+
extractor = extractor or extract_text
|
|
218
|
+
|
|
219
|
+
async def hook(req: Request, resp: Response, sample: Sample) -> Sample:
|
|
220
|
+
if not resp.ok:
|
|
221
|
+
# Don't grade a failed request — keep the failure visible.
|
|
222
|
+
return sample
|
|
223
|
+
reference = req.meta.get(reference_key)
|
|
224
|
+
if reference is None and require_reference:
|
|
225
|
+
sample.extra[f"{prefix}missing_reference"] = 1.0
|
|
226
|
+
return sample
|
|
227
|
+
try:
|
|
228
|
+
prediction = extractor(resp)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
sample.extra[f"{prefix}extractor_error"] = 1.0
|
|
231
|
+
sample.meta[f"{prefix}extractor_error_msg"] = f"{type(e).__name__}: {e}"
|
|
232
|
+
return sample
|
|
233
|
+
try:
|
|
234
|
+
result = scorer(reference, prediction)
|
|
235
|
+
result = await maybe_await(result)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
sample.extra[f"{prefix}score_error"] = 1.0
|
|
238
|
+
sample.meta[f"{prefix}score_error_msg"] = f"{type(e).__name__}: {e}"
|
|
239
|
+
return sample
|
|
240
|
+
if not isinstance(result, dict):
|
|
241
|
+
sample.extra[f"{prefix}score_error"] = 1.0
|
|
242
|
+
sample.meta[f"{prefix}score_error_msg"] = (
|
|
243
|
+
f"scorer returned {type(result).__name__}, expected dict"
|
|
244
|
+
)
|
|
245
|
+
return sample
|
|
246
|
+
for k, v in result.items():
|
|
247
|
+
try:
|
|
248
|
+
sample.extra[f"{prefix}{k}"] = float(v)
|
|
249
|
+
except (TypeError, ValueError):
|
|
250
|
+
sample.meta[f"{prefix}{k}"] = v
|
|
251
|
+
if gate_key is not None and gate_key in result:
|
|
252
|
+
try:
|
|
253
|
+
if float(result[gate_key]) <= 0.0:
|
|
254
|
+
sample.ok = False
|
|
255
|
+
sample.error = sample.error or f"failed-{gate_key}"
|
|
256
|
+
except (TypeError, ValueError):
|
|
257
|
+
pass
|
|
258
|
+
# Stash the prediction for offline inspection (capped to keep bundles small).
|
|
259
|
+
if max_prediction_chars in (None, 0):
|
|
260
|
+
saved = prediction
|
|
261
|
+
else:
|
|
262
|
+
saved = prediction[:max_prediction_chars]
|
|
263
|
+
sample.meta.setdefault(f"{prefix}prediction", saved)
|
|
264
|
+
return sample
|
|
265
|
+
|
|
266
|
+
return hook
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --------------------------------------------------------------------------- #
|
|
270
|
+
# Stock scorers
|
|
271
|
+
# --------------------------------------------------------------------------- #
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _normalize(s: str, *, strip: bool, case_insensitive: bool) -> str:
|
|
275
|
+
if strip:
|
|
276
|
+
s = s.strip()
|
|
277
|
+
if case_insensitive:
|
|
278
|
+
s = s.lower()
|
|
279
|
+
return s
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def exact_match(*, strip: bool = True, case_insensitive: bool = False) -> Scorer:
|
|
283
|
+
"""`correct=1` iff prediction == reference (after optional strip/lower)."""
|
|
284
|
+
|
|
285
|
+
def _score(reference: Any, prediction: str) -> dict[str, float]:
|
|
286
|
+
a = _normalize(str(prediction), strip=strip, case_insensitive=case_insensitive)
|
|
287
|
+
b = _normalize(str(reference), strip=strip, case_insensitive=case_insensitive)
|
|
288
|
+
return {"correct": 1.0 if a == b else 0.0}
|
|
289
|
+
|
|
290
|
+
return _score
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def contains(*, strip: bool = True, case_insensitive: bool = True) -> Scorer:
|
|
294
|
+
"""`correct=1` iff reference substring appears in prediction."""
|
|
295
|
+
|
|
296
|
+
def _score(reference: Any, prediction: str) -> dict[str, float]:
|
|
297
|
+
a = _normalize(str(prediction), strip=strip, case_insensitive=case_insensitive)
|
|
298
|
+
b = _normalize(str(reference), strip=strip, case_insensitive=case_insensitive)
|
|
299
|
+
return {"correct": 1.0 if b and b in a else 0.0}
|
|
300
|
+
|
|
301
|
+
return _score
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def regex_match(pattern: str, *, group: int = 0,
|
|
305
|
+
case_insensitive: bool = False) -> Scorer:
|
|
306
|
+
"""Match `pattern` against the prediction.
|
|
307
|
+
|
|
308
|
+
If `reference` is None, `correct=1` whenever the pattern matches at all.
|
|
309
|
+
Otherwise `correct=1` only when the captured `group` equals `str(reference)`
|
|
310
|
+
(after stripping). The captured string is also recorded in `extra` as a
|
|
311
|
+
side-channel for debugging.
|
|
312
|
+
"""
|
|
313
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
314
|
+
rx = re.compile(pattern, flags)
|
|
315
|
+
|
|
316
|
+
def _score(reference: Any, prediction: str) -> dict[str, float]:
|
|
317
|
+
m = rx.search(prediction or "")
|
|
318
|
+
if not m:
|
|
319
|
+
return {"correct": 0.0, "matched": 0.0}
|
|
320
|
+
try:
|
|
321
|
+
captured = m.group(group)
|
|
322
|
+
except IndexError:
|
|
323
|
+
captured = ""
|
|
324
|
+
if reference is None:
|
|
325
|
+
return {"correct": 1.0, "matched": 1.0}
|
|
326
|
+
ok = 1.0 if str(reference).strip() == (captured or "").strip() else 0.0
|
|
327
|
+
return {"correct": ok, "matched": 1.0}
|
|
328
|
+
|
|
329
|
+
return _score
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def json_valid(*, required_keys: Optional[tuple[str, ...]] = None) -> Scorer:
|
|
333
|
+
"""`valid_json=1` if the prediction parses as JSON.
|
|
334
|
+
|
|
335
|
+
If `required_keys` is given and the parsed value is a dict, `correct=1`
|
|
336
|
+
only when every key is present. When `required_keys` is None, `correct`
|
|
337
|
+
mirrors `valid_json`.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def _score(reference: Any, prediction: str) -> dict[str, float]:
|
|
341
|
+
try:
|
|
342
|
+
obj = json.loads(prediction)
|
|
343
|
+
except Exception:
|
|
344
|
+
return {"valid_json": 0.0, "correct": 0.0}
|
|
345
|
+
if required_keys is None:
|
|
346
|
+
return {"valid_json": 1.0, "correct": 1.0}
|
|
347
|
+
if not isinstance(obj, dict):
|
|
348
|
+
return {"valid_json": 1.0, "correct": 0.0}
|
|
349
|
+
for k in required_keys:
|
|
350
|
+
if k not in obj:
|
|
351
|
+
return {"valid_json": 1.0, "correct": 0.0}
|
|
352
|
+
return {"valid_json": 1.0, "correct": 1.0}
|
|
353
|
+
|
|
354
|
+
return _score
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def multiple_choice(*, choices: tuple[str, ...] = ("A", "B", "C", "D"),
|
|
358
|
+
case_insensitive: bool = True) -> Scorer:
|
|
359
|
+
"""Find the first choice letter mentioned in the prediction; match vs reference.
|
|
360
|
+
|
|
361
|
+
Looks for one of `choices` as a word boundary token. Reference should be
|
|
362
|
+
one of the choices.
|
|
363
|
+
"""
|
|
364
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
365
|
+
pattern = re.compile(
|
|
366
|
+
r"\b(" + "|".join(re.escape(c) for c in choices) + r")\b", flags
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _score(reference: Any, prediction: str) -> dict[str, float]:
|
|
370
|
+
m = pattern.search(prediction or "")
|
|
371
|
+
if not m:
|
|
372
|
+
return {"correct": 0.0, "answered": 0.0}
|
|
373
|
+
chosen = m.group(1)
|
|
374
|
+
ref = str(reference).strip()
|
|
375
|
+
if case_insensitive:
|
|
376
|
+
ok = 1.0 if chosen.lower() == ref.lower() else 0.0
|
|
377
|
+
else:
|
|
378
|
+
ok = 1.0 if chosen == ref else 0.0
|
|
379
|
+
return {"correct": ok, "answered": 1.0}
|
|
380
|
+
|
|
381
|
+
return _score
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------------------------------------------------------- #
|
|
385
|
+
# LLM-as-judge scorer
|
|
386
|
+
# --------------------------------------------------------------------------- #
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
JudgeSend = Callable[[str], Awaitable[str]]
|
|
390
|
+
JudgeTemplate = Union[str, Callable[[Any, str], str]]
|
|
391
|
+
JudgeParse = Callable[[str], dict[str, float]]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
_DEFAULT_JUDGE_TEMPLATE = (
|
|
395
|
+
"You are grading a model's answer.\n\n"
|
|
396
|
+
"Question reference / expected answer:\n{reference}\n\n"
|
|
397
|
+
"Model answer:\n{prediction}\n\n"
|
|
398
|
+
"Reply with a single integer from 0 to 10 measuring how correct the model "
|
|
399
|
+
"answer is. Output ONLY the integer."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _default_judge_parse(text: str, *, pass_threshold: int = 7) -> dict[str, float]:
|
|
404
|
+
m = re.search(r"-?\d+", text or "")
|
|
405
|
+
if not m:
|
|
406
|
+
return {"judge_score": 0.0, "correct": 0.0, "judge_parsed": 0.0}
|
|
407
|
+
score = max(0, min(10, int(m.group(0))))
|
|
408
|
+
return {
|
|
409
|
+
"judge_score": float(score),
|
|
410
|
+
"correct": 1.0 if score >= pass_threshold else 0.0,
|
|
411
|
+
"judge_parsed": 1.0,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def judge_llm(
|
|
416
|
+
send: JudgeSend,
|
|
417
|
+
*,
|
|
418
|
+
template: JudgeTemplate = _DEFAULT_JUDGE_TEMPLATE,
|
|
419
|
+
parse: Optional[JudgeParse] = None,
|
|
420
|
+
max_concurrency: int = 4,
|
|
421
|
+
) -> Scorer:
|
|
422
|
+
"""LLM-as-judge scorer.
|
|
423
|
+
|
|
424
|
+
`send(prompt) -> awaitable[str]` is the user's hook into a judge endpoint —
|
|
425
|
+
the caller is responsible for opening / closing any HTTP client. Use the
|
|
426
|
+
convenience constructor below if you just want to talk to an OpenAI-compat
|
|
427
|
+
chat endpoint.
|
|
428
|
+
|
|
429
|
+
`template` is either a format string (with `{reference}` and `{prediction}`)
|
|
430
|
+
or a callable `(reference, prediction) -> str`.
|
|
431
|
+
|
|
432
|
+
`parse(text) -> dict[str, float]` extracts numeric scores from the judge's
|
|
433
|
+
reply. Default: parse a 0..10 integer, set `correct=1` when >= 7.
|
|
434
|
+
"""
|
|
435
|
+
parse = parse or _default_judge_parse
|
|
436
|
+
sem = asyncio.Semaphore(max_concurrency)
|
|
437
|
+
|
|
438
|
+
if isinstance(template, str):
|
|
439
|
+
tmpl_str = template
|
|
440
|
+
|
|
441
|
+
def _render(ref: Any, pred: str) -> str:
|
|
442
|
+
return tmpl_str.format(reference=ref, prediction=pred)
|
|
443
|
+
else:
|
|
444
|
+
_render = template # type: ignore[assignment]
|
|
445
|
+
|
|
446
|
+
async def _score(reference: Any, prediction: str) -> dict[str, float]:
|
|
447
|
+
prompt = _render(reference, prediction)
|
|
448
|
+
async with sem:
|
|
449
|
+
reply = await send(prompt)
|
|
450
|
+
return parse(reply)
|
|
451
|
+
|
|
452
|
+
return _score
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def openai_chat_judge(
|
|
456
|
+
url: str,
|
|
457
|
+
model: str,
|
|
458
|
+
*,
|
|
459
|
+
api_key: Optional[str] = None,
|
|
460
|
+
temperature: float = 0.0,
|
|
461
|
+
max_tokens: int = 8,
|
|
462
|
+
timeout_s: float = 60.0,
|
|
463
|
+
) -> tuple[JudgeSend, Callable[[], Awaitable[None]]]:
|
|
464
|
+
"""Convenience: returns `(send, aclose)` for an OpenAI-compat chat endpoint.
|
|
465
|
+
|
|
466
|
+
Opens a dedicated aiohttp session on first call. The caller should
|
|
467
|
+
`await aclose()` at the end of the run (or wire it into a workload-type's
|
|
468
|
+
`aclose` chain).
|
|
469
|
+
"""
|
|
470
|
+
import aiohttp
|
|
471
|
+
|
|
472
|
+
state: dict[str, Any] = {"session": None}
|
|
473
|
+
headers = {"Content-Type": "application/json"}
|
|
474
|
+
if api_key:
|
|
475
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
476
|
+
|
|
477
|
+
async def _send(prompt: str) -> str:
|
|
478
|
+
if state["session"] is None:
|
|
479
|
+
state["session"] = aiohttp.ClientSession(
|
|
480
|
+
timeout=aiohttp.ClientTimeout(total=timeout_s)
|
|
481
|
+
)
|
|
482
|
+
sess: aiohttp.ClientSession = state["session"]
|
|
483
|
+
body = {
|
|
484
|
+
"model": model,
|
|
485
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
486
|
+
"temperature": temperature,
|
|
487
|
+
"max_tokens": max_tokens,
|
|
488
|
+
"stream": False,
|
|
489
|
+
}
|
|
490
|
+
async with sess.post(url, headers=headers, json=body) as resp:
|
|
491
|
+
data = await resp.json()
|
|
492
|
+
choices = data.get("choices") or []
|
|
493
|
+
if not choices:
|
|
494
|
+
return ""
|
|
495
|
+
msg = choices[0].get("message") or {}
|
|
496
|
+
return msg.get("content") or ""
|
|
497
|
+
|
|
498
|
+
async def _aclose() -> None:
|
|
499
|
+
sess = state["session"]
|
|
500
|
+
if sess is not None:
|
|
501
|
+
await sess.close()
|
|
502
|
+
state["session"] = None
|
|
503
|
+
|
|
504
|
+
return _send, _aclose
|