evalstone 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.
- evalstone/__init__.py +131 -0
- evalstone/__main__.py +3 -0
- evalstone/_git.py +22 -0
- evalstone/_json.py +59 -0
- evalstone/checks/__init__.py +41 -0
- evalstone/checks/_base.py +153 -0
- evalstone/checks/comparison.py +154 -0
- evalstone/checks/deterministic.py +279 -0
- evalstone/checks/human.py +124 -0
- evalstone/checks/judge.py +219 -0
- evalstone/checks/rag.py +70 -0
- evalstone/checks/safety.py +131 -0
- evalstone/checks/trajectory.py +144 -0
- evalstone/cli.py +411 -0
- evalstone/config.py +267 -0
- evalstone/dataset.py +184 -0
- evalstone/gate.py +84 -0
- evalstone/mcp_server.py +329 -0
- evalstone/output.py +36 -0
- evalstone/providers/__init__.py +185 -0
- evalstone/providers/_http.py +58 -0
- evalstone/providers/anthropic_.py +81 -0
- evalstone/providers/gemini.py +55 -0
- evalstone/providers/openai_compat.py +67 -0
- evalstone/pytest_plugin.py +203 -0
- evalstone/run.py +336 -0
- evalstone/score.py +124 -0
- evalstone/suite.py +135 -0
- evalstone/target.py +289 -0
- evalstone-0.1.0.dist-info/METADATA +125 -0
- evalstone-0.1.0.dist-info/RECORD +34 -0
- evalstone-0.1.0.dist-info/WHEEL +4 -0
- evalstone-0.1.0.dist-info/entry_points.txt +5 -0
- evalstone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Built-in deterministic checks.
|
|
2
|
+
|
|
3
|
+
Pure code, no model, free, repeatable. Each factory returns a ``FunctionCheck``
|
|
4
|
+
you can hand to a suite or register under a key of your choosing. Where a
|
|
5
|
+
check compares against a reference, the reference comes from
|
|
6
|
+
``sample.expected`` — the whole value, or a dotted ``path`` into it — unless a
|
|
7
|
+
literal ``expected``/``needles`` is given.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable, Iterable
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
import jsonschema
|
|
19
|
+
from jsonschema.validators import validator_for
|
|
20
|
+
|
|
21
|
+
from evalstone.checks._base import CheckFn, FunctionCheck
|
|
22
|
+
from evalstone.score import CheckKind, Score
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from evalstone.dataset import Sample
|
|
26
|
+
from evalstone.output import Output
|
|
27
|
+
|
|
28
|
+
_MISSING = object()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dig(value: Any, path: str | None) -> Any:
|
|
32
|
+
"""Follow a dotted path into dicts/lists; ``_MISSING`` when absent."""
|
|
33
|
+
if path is None:
|
|
34
|
+
return value
|
|
35
|
+
cur = value
|
|
36
|
+
for part in path.split("."):
|
|
37
|
+
if isinstance(cur, dict) and part in cur:
|
|
38
|
+
cur = cur[part]
|
|
39
|
+
elif isinstance(cur, list) and part.isdecimal() and int(part) < len(cur):
|
|
40
|
+
cur = cur[int(part)]
|
|
41
|
+
else:
|
|
42
|
+
return _MISSING
|
|
43
|
+
return cur
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reference(sample: Sample, path: str | None, literal: Any) -> Any:
|
|
47
|
+
if literal is not _MISSING:
|
|
48
|
+
return literal
|
|
49
|
+
ref = _dig(sample.expected, path)
|
|
50
|
+
if ref is _MISSING or ref is None:
|
|
51
|
+
where = f"expected.{path}" if path else "expected"
|
|
52
|
+
raise ValueError(f"sample {sample.id} has no {where}")
|
|
53
|
+
return ref
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _as_json(output: Output) -> Any:
|
|
57
|
+
"""The output as a JSON value: parse ``str`` values, pass everything else through."""
|
|
58
|
+
if isinstance(output.value, str):
|
|
59
|
+
return json.loads(output.value)
|
|
60
|
+
return output.value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _as_number(value: Any) -> float:
|
|
64
|
+
if isinstance(value, bool):
|
|
65
|
+
raise TypeError("boolean is not a number")
|
|
66
|
+
if isinstance(value, int | float):
|
|
67
|
+
return float(value)
|
|
68
|
+
if isinstance(value, str):
|
|
69
|
+
return float(value.strip())
|
|
70
|
+
raise TypeError(f"not a number: {value!r}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── factories ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def exact(
|
|
77
|
+
*,
|
|
78
|
+
key: str = "exact",
|
|
79
|
+
expected: Any = _MISSING,
|
|
80
|
+
path: str | None = None,
|
|
81
|
+
case_sensitive: bool = True,
|
|
82
|
+
strip: bool = True,
|
|
83
|
+
) -> FunctionCheck:
|
|
84
|
+
"""The output equals the reference. Strings are compared as text (optionally
|
|
85
|
+
case-insensitive and stripped); anything else by JSON equality."""
|
|
86
|
+
|
|
87
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
88
|
+
ref = _reference(sample, path, expected)
|
|
89
|
+
if isinstance(ref, str):
|
|
90
|
+
got = output.text
|
|
91
|
+
if strip:
|
|
92
|
+
got, ref = got.strip(), ref.strip()
|
|
93
|
+
if not case_sensitive:
|
|
94
|
+
got, ref = got.lower(), ref.lower()
|
|
95
|
+
ok = got == ref
|
|
96
|
+
else:
|
|
97
|
+
try:
|
|
98
|
+
ok = _as_json(output) == ref
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return Score.fail(f"output is not valid JSON; expected {ref!r}")
|
|
101
|
+
return Score.from_bool(ok, "" if ok else f"expected {ref!r}, got {output.text!r}")
|
|
102
|
+
|
|
103
|
+
return FunctionCheck(key, fn, kind=CheckKind.DETERMINISTIC)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def contains(
|
|
107
|
+
*,
|
|
108
|
+
key: str = "contains",
|
|
109
|
+
needles: Iterable[str] | str = _MISSING, # type: ignore[assignment]
|
|
110
|
+
path: str | None = "must_include",
|
|
111
|
+
require_all: bool = True,
|
|
112
|
+
case_sensitive: bool = False,
|
|
113
|
+
) -> FunctionCheck:
|
|
114
|
+
"""Every (or any) needle appears in the output text. Needles default to
|
|
115
|
+
``sample.expected.must_include`` (a string or a list). An empty needle set
|
|
116
|
+
passes vacuously."""
|
|
117
|
+
literal: Any = _MISSING
|
|
118
|
+
if needles is not _MISSING: # materialise once: an iterator must not be consumed per sample
|
|
119
|
+
literal = [needles] if isinstance(needles, str) else [str(n) for n in needles]
|
|
120
|
+
|
|
121
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
122
|
+
ref = _reference(sample, path, literal)
|
|
123
|
+
wanted = [ref] if isinstance(ref, str) else [str(n) for n in ref]
|
|
124
|
+
if not wanted:
|
|
125
|
+
return Score.ok("no needles")
|
|
126
|
+
hay = output.text if case_sensitive else output.text.lower()
|
|
127
|
+
hits = [n for n in wanted if (n if case_sensitive else n.lower()) in hay]
|
|
128
|
+
missing = [n for n in wanted if n not in hits]
|
|
129
|
+
ok = not missing if require_all else bool(hits)
|
|
130
|
+
value = len(hits) / len(wanted)
|
|
131
|
+
reason = "" if ok else f"missing {missing!r}"
|
|
132
|
+
return Score(value=value, passed=ok, reason=reason)
|
|
133
|
+
|
|
134
|
+
return FunctionCheck(key, fn, kind=CheckKind.DETERMINISTIC)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def regex(
|
|
138
|
+
pattern: str,
|
|
139
|
+
*,
|
|
140
|
+
key: str = "regex",
|
|
141
|
+
flags: int = 0,
|
|
142
|
+
must_match: bool = True,
|
|
143
|
+
) -> FunctionCheck:
|
|
144
|
+
"""The output text matches (or, with ``must_match=False``, does not match) ``pattern``."""
|
|
145
|
+
compiled = re.compile(pattern, flags)
|
|
146
|
+
|
|
147
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
148
|
+
found = compiled.search(output.text) is not None
|
|
149
|
+
ok = found if must_match else not found
|
|
150
|
+
verb = "did not match" if must_match else "matched"
|
|
151
|
+
return Score.from_bool(ok, "" if ok else f"{verb} /{pattern}/")
|
|
152
|
+
|
|
153
|
+
return FunctionCheck(key, fn, kind=CheckKind.DETERMINISTIC)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def json_schema(schema: dict[str, Any] | type, *, key: str = "json_schema") -> FunctionCheck:
|
|
157
|
+
"""The output is valid JSON and conforms to ``schema`` — a JSON Schema dict (the draft
|
|
158
|
+
named by ``$schema``, 2020-12 when absent) or a pydantic model class. A malformed
|
|
159
|
+
schema is rejected here, not once per sample at run time."""
|
|
160
|
+
validate: Callable[[Any], None]
|
|
161
|
+
if isinstance(schema, dict):
|
|
162
|
+
cls = validator_for(schema, default=jsonschema.Draft202012Validator)
|
|
163
|
+
try:
|
|
164
|
+
cls.check_schema(schema)
|
|
165
|
+
except jsonschema.SchemaError as exc:
|
|
166
|
+
raise ValueError(f"json_schema {key!r}: invalid schema: {exc.message}") from None
|
|
167
|
+
validator = cls(schema)
|
|
168
|
+
|
|
169
|
+
def validate(value: Any) -> None:
|
|
170
|
+
errors = sorted(validator.iter_errors(value), key=lambda e: list(e.path))
|
|
171
|
+
if errors:
|
|
172
|
+
first = errors[0]
|
|
173
|
+
where = "/".join(str(p) for p in first.path) or "<root>"
|
|
174
|
+
raise ValueError(f"{where}: {first.message}")
|
|
175
|
+
|
|
176
|
+
else:
|
|
177
|
+
from pydantic import TypeAdapter, ValidationError
|
|
178
|
+
|
|
179
|
+
adapter = TypeAdapter(schema)
|
|
180
|
+
|
|
181
|
+
def validate(value: Any) -> None:
|
|
182
|
+
try:
|
|
183
|
+
adapter.validate_python(value)
|
|
184
|
+
except ValidationError as exc:
|
|
185
|
+
first = exc.errors()[0]
|
|
186
|
+
where = "/".join(str(p) for p in first["loc"]) or "<root>"
|
|
187
|
+
raise ValueError(f"{where}: {first['msg']}") from None
|
|
188
|
+
|
|
189
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
190
|
+
try:
|
|
191
|
+
value = _as_json(output)
|
|
192
|
+
except json.JSONDecodeError as exc:
|
|
193
|
+
return Score.fail(f"not valid JSON: {exc.msg}")
|
|
194
|
+
try:
|
|
195
|
+
validate(value)
|
|
196
|
+
except ValueError as exc:
|
|
197
|
+
return Score.fail(f"schema: {exc}")
|
|
198
|
+
return Score.ok()
|
|
199
|
+
|
|
200
|
+
return FunctionCheck(key, fn, kind=CheckKind.DETERMINISTIC)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def numeric_tolerance(
|
|
204
|
+
*,
|
|
205
|
+
key: str = "numeric_tolerance",
|
|
206
|
+
expected: Any = _MISSING,
|
|
207
|
+
path: str | None = None,
|
|
208
|
+
output_path: str | None = None,
|
|
209
|
+
abs_tol: float = 0.0,
|
|
210
|
+
rel_tol: float = 0.0,
|
|
211
|
+
) -> FunctionCheck:
|
|
212
|
+
"""The output number is within ``abs_tol`` / ``rel_tol`` of the reference number.
|
|
213
|
+
``output_path`` digs into a JSON output; otherwise the whole output is the number."""
|
|
214
|
+
|
|
215
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
216
|
+
ref = _as_number(_reference(sample, path, expected))
|
|
217
|
+
if output_path is None:
|
|
218
|
+
raw = output.value
|
|
219
|
+
else:
|
|
220
|
+
try:
|
|
221
|
+
raw = _dig(_as_json(output), output_path)
|
|
222
|
+
except json.JSONDecodeError as exc:
|
|
223
|
+
return Score.fail(f"not valid JSON: {exc.msg}")
|
|
224
|
+
if raw is _MISSING:
|
|
225
|
+
return Score.fail(f"output has no {output_path}")
|
|
226
|
+
try:
|
|
227
|
+
got = _as_number(raw)
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
return Score.fail(f"output is not a number: {raw!r}")
|
|
230
|
+
ok = math.isclose(got, ref, rel_tol=rel_tol, abs_tol=abs_tol)
|
|
231
|
+
reason = "" if ok else f"expected {ref} ± ({abs_tol} abs, {rel_tol} rel), got {got}"
|
|
232
|
+
return Score.from_bool(ok, reason)
|
|
233
|
+
|
|
234
|
+
return FunctionCheck(key, fn, kind=CheckKind.DETERMINISTIC)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def length(
|
|
238
|
+
*,
|
|
239
|
+
key: str = "length",
|
|
240
|
+
max_chars: int | None = None,
|
|
241
|
+
max_words: int | None = None,
|
|
242
|
+
min_chars: int = 0,
|
|
243
|
+
) -> FunctionCheck:
|
|
244
|
+
"""The output text is at least ``min_chars`` long (0 by default — pass ``min_chars=1``
|
|
245
|
+
to reject empty output) and under the given limits."""
|
|
246
|
+
|
|
247
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
248
|
+
text = output.text.strip()
|
|
249
|
+
chars, words = len(text), len(text.split())
|
|
250
|
+
if chars < min_chars:
|
|
251
|
+
return Score.fail(f"{chars} chars < min {min_chars}")
|
|
252
|
+
if max_chars is not None and chars > max_chars:
|
|
253
|
+
return Score.fail(f"{chars} chars > max {max_chars}")
|
|
254
|
+
if max_words is not None and words > max_words:
|
|
255
|
+
return Score.fail(f"{words} words > max {max_words}")
|
|
256
|
+
return Score.ok()
|
|
257
|
+
|
|
258
|
+
return FunctionCheck(key, fn, kind=CheckKind.DETERMINISTIC)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def python(
|
|
262
|
+
fn: CheckFn,
|
|
263
|
+
key: str | None = None,
|
|
264
|
+
*,
|
|
265
|
+
kind: CheckKind | str = CheckKind.DETERMINISTIC,
|
|
266
|
+
version: int = 1,
|
|
267
|
+
) -> FunctionCheck:
|
|
268
|
+
"""Any ``fn(sample, output[, ctx]) -> Score | bool | float`` as a check, unregistered.
|
|
269
|
+
|
|
270
|
+
The key defaults to the function's name; lambdas, partials and callable
|
|
271
|
+
objects have no usable name, so they need an explicit ``key``.
|
|
272
|
+
"""
|
|
273
|
+
name = getattr(fn, "__name__", None)
|
|
274
|
+
if key is None and (not name or name == "<lambda>"):
|
|
275
|
+
raise ValueError("python(): give an explicit key for a lambda, partial or callable object")
|
|
276
|
+
return FunctionCheck(key or name, fn, kind=CheckKind(kind), version=version)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
__all__ = ["contains", "exact", "json_schema", "length", "numeric_tolerance", "python", "regex"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Human labels — the check of last resort, and the ground truth judges are calibrated against.
|
|
2
|
+
|
|
3
|
+
A label is a person's verdict on one (sample, output) pair, stored as a JSONL
|
|
4
|
+
line and keyed by the sample id **and a hash of the output text**, so a label
|
|
5
|
+
never silently applies to a changed answer. ``evalstone label <report>`` writes
|
|
6
|
+
them; the ``human`` check reads them: a labelled output gets the label's
|
|
7
|
+
score, an unlabelled one is an error (never a pass).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from evalstone._json import sha256
|
|
20
|
+
from evalstone.checks._base import FunctionCheck
|
|
21
|
+
from evalstone.dataset import Sample
|
|
22
|
+
from evalstone.output import Output
|
|
23
|
+
from evalstone.score import CheckKind, Score
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def output_hash(output: Output | str) -> str:
|
|
27
|
+
text = output if isinstance(output, str) else output.text
|
|
28
|
+
return sha256(text)[:16]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Label(BaseModel):
|
|
32
|
+
"""One human verdict."""
|
|
33
|
+
|
|
34
|
+
sample_id: str
|
|
35
|
+
output_hash: str
|
|
36
|
+
value: float = Field(ge=0.0, le=1.0)
|
|
37
|
+
passed: bool
|
|
38
|
+
notes: str = ""
|
|
39
|
+
check: str | None = None # which rubric/check the grade is for; None = overall
|
|
40
|
+
labeler: str = ""
|
|
41
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
42
|
+
raw: Any = None # the original grade (e.g. 4 on a 1-5 scale)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def key(self) -> tuple[str, str, str | None]:
|
|
46
|
+
return (self.sample_id, self.output_hash, self.check)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LabelStore:
|
|
50
|
+
"""Labels in a JSONL file. Later lines win for the same (sample, output, check)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: str | Path):
|
|
53
|
+
self.path = Path(path)
|
|
54
|
+
self._labels: dict[tuple[str, str, str | None], Label] = {}
|
|
55
|
+
if self.path.exists():
|
|
56
|
+
with self.path.open(encoding="utf-8") as fh:
|
|
57
|
+
for lineno, line in enumerate(fh, start=1):
|
|
58
|
+
if not line.strip():
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
label = Label.model_validate_json(line)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise ValueError(f"{self.path}:{lineno}: invalid label: {exc}") from exc
|
|
64
|
+
self._labels[label.key] = label
|
|
65
|
+
|
|
66
|
+
def __len__(self) -> int:
|
|
67
|
+
return len(self._labels)
|
|
68
|
+
|
|
69
|
+
def __iter__(self) -> Iterable[Label]:
|
|
70
|
+
return iter(self._labels.values())
|
|
71
|
+
|
|
72
|
+
def get(self, sample_id: str, output: Output | str, check: str | None = None) -> Label | None:
|
|
73
|
+
return self._labels.get((sample_id, output_hash(output), check))
|
|
74
|
+
|
|
75
|
+
def add(self, label: Label) -> None:
|
|
76
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
78
|
+
fh.write(label.model_dump_json() + "\n")
|
|
79
|
+
self._labels[label.key] = label
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def human(
|
|
83
|
+
*, key: str = "human", labels: str | Path = "labels.jsonl", check: str | None = None
|
|
84
|
+
) -> FunctionCheck:
|
|
85
|
+
"""Score from a person's label for this (sample, output); unlabelled → error.
|
|
86
|
+
|
|
87
|
+
``check`` selects labels recorded for a specific rubric (``evalstone label
|
|
88
|
+
--check``); ``None`` uses the overall grade.
|
|
89
|
+
"""
|
|
90
|
+
store_path = Path(labels)
|
|
91
|
+
cached: dict[str, Any] = {}
|
|
92
|
+
|
|
93
|
+
def store() -> LabelStore:
|
|
94
|
+
# One parse per file version: a stat per call (so labels added while a
|
|
95
|
+
# long-lived server runs are seen), a re-read only when the file changed.
|
|
96
|
+
try:
|
|
97
|
+
st = store_path.stat()
|
|
98
|
+
stamp: tuple[int, int] | None = (st.st_mtime_ns, st.st_size)
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
stamp = None
|
|
101
|
+
if cached.get("stamp", ...) != stamp:
|
|
102
|
+
cached["store"] = LabelStore(store_path)
|
|
103
|
+
cached["stamp"] = stamp
|
|
104
|
+
return cached["store"]
|
|
105
|
+
|
|
106
|
+
def fn(sample: Sample, output: Output) -> Score:
|
|
107
|
+
label = store().get(sample.id, output, check)
|
|
108
|
+
if label is None:
|
|
109
|
+
return Score.errored(
|
|
110
|
+
f"no human label for {sample.id} @ {output_hash(output)} "
|
|
111
|
+
f"(run `evalstone label <report> --out {store_path}`)"
|
|
112
|
+
)
|
|
113
|
+
reason = label.notes or ("labelled pass" if label.passed else "labelled fail")
|
|
114
|
+
return Score(
|
|
115
|
+
value=label.value,
|
|
116
|
+
passed=label.passed,
|
|
117
|
+
reason=reason,
|
|
118
|
+
raw={"labeler": label.labeler, "grade": label.raw, "at": label.created_at.isoformat()},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return FunctionCheck(key, fn, kind=CheckKind.HUMAN)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["Label", "LabelStore", "human", "output_hash"]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""LLM-as-judge: a rubric, a small scale, structured JSON back, cached by content.
|
|
2
|
+
|
|
3
|
+
Follows the practice in Evals 101 chapter 6: specific criteria, a defined
|
|
4
|
+
scale, the reference included only when asked for, temperature left at the
|
|
5
|
+
provider default (the cache makes re-runs free anyway), and **an error is
|
|
6
|
+
never a pass** (timeouts, refusals, unparseable verdicts → ``Score.errored``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from evalstone._json import dumps, sha256
|
|
16
|
+
from evalstone.checks._base import FunctionCheck
|
|
17
|
+
from evalstone.dataset import Sample
|
|
18
|
+
from evalstone.output import Output
|
|
19
|
+
from evalstone.providers import ChatMessage, ProviderError
|
|
20
|
+
from evalstone.score import CheckKind, Context, Score
|
|
21
|
+
|
|
22
|
+
PROMPT_VERSION = 1
|
|
23
|
+
|
|
24
|
+
SYSTEM_TEMPLATE = """You are a strict evaluator grading one answer produced by an AI system.
|
|
25
|
+
|
|
26
|
+
RUBRIC:
|
|
27
|
+
{rubric}
|
|
28
|
+
|
|
29
|
+
Score the answer from 1 to {scale} according to the rubric ({scale} = fully meets it, 1 = fails it).
|
|
30
|
+
Ignore formatting and length unless the rubric mentions them.
|
|
31
|
+
Reply with ONLY a JSON object: {{"score": <integer 1-{scale}>, "reason": "<one sentence>"}}"""
|
|
32
|
+
|
|
33
|
+
USER_TEMPLATE = """INPUT:
|
|
34
|
+
{input}
|
|
35
|
+
{reference}
|
|
36
|
+
ANSWER:
|
|
37
|
+
{output}"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _json_text(value: Any) -> str:
|
|
41
|
+
return value if isinstance(value, str) else dumps(value, indent=2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_json(text: str) -> dict[str, Any]:
|
|
45
|
+
"""The first JSON object in a model reply, tolerating prose around it."""
|
|
46
|
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
|
47
|
+
if not match:
|
|
48
|
+
raise ValueError(f"no JSON object in judge reply: {text[:120]!r}")
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(match.group(0))
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
raise ValueError(f"invalid JSON in judge reply: {exc.msg}") from None
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise ValueError(f"judge reply is not a JSON object: {text[:120]!r}")
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_verdict(text: str, scale: int) -> tuple[int, str]:
|
|
59
|
+
"""Pull ``{"score": n, "reason": ...}`` out of a model reply."""
|
|
60
|
+
data = extract_json(text)
|
|
61
|
+
try:
|
|
62
|
+
score = round(float(data["score"]))
|
|
63
|
+
except (KeyError, TypeError, ValueError):
|
|
64
|
+
raise ValueError(f"judge reply has no numeric score: {data!r}"[:200]) from None
|
|
65
|
+
if not 1 <= score <= scale:
|
|
66
|
+
raise ValueError(f"judge score {score} outside 1..{scale}")
|
|
67
|
+
return score, str(data.get("reason", "")).strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class JudgeCheck(FunctionCheck):
|
|
71
|
+
"""A ``FunctionCheck`` whose function calls a model; exposes the judge version hash."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
key: str,
|
|
76
|
+
fn: Any,
|
|
77
|
+
*,
|
|
78
|
+
version_hash: str,
|
|
79
|
+
model: str | None,
|
|
80
|
+
rubric: str,
|
|
81
|
+
scale: int,
|
|
82
|
+
pass_at: float,
|
|
83
|
+
):
|
|
84
|
+
super().__init__(key, fn, kind=CheckKind.JUDGE)
|
|
85
|
+
# rubric + scale + prompt version; the resolved model is folded in at run time
|
|
86
|
+
self.version_hash = version_hash
|
|
87
|
+
self.model = model
|
|
88
|
+
self.rubric = rubric
|
|
89
|
+
self.scale = scale
|
|
90
|
+
self.pass_at = pass_at
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def judge(
|
|
94
|
+
key: str,
|
|
95
|
+
rubric: str,
|
|
96
|
+
*,
|
|
97
|
+
model: str | None = None,
|
|
98
|
+
scale: int = 5,
|
|
99
|
+
pass_level: int | None = None,
|
|
100
|
+
threshold: float | None = None,
|
|
101
|
+
include_reference: bool = False,
|
|
102
|
+
max_tokens: int = 256,
|
|
103
|
+
) -> JudgeCheck:
|
|
104
|
+
"""Grade an output against ``rubric`` with a model.
|
|
105
|
+
|
|
106
|
+
The model answers with a level ``n`` in ``1..scale``; ``value = (n-1)/(scale-1)``.
|
|
107
|
+
The verdict passes at or above ``pass_level`` (a rubric level; default: the
|
|
108
|
+
second-highest level — 4 on a 1-5 scale — and the top level on a 1-2 yes/no
|
|
109
|
+
scale) or, equivalently, at or above ``threshold`` (a 0..1 fraction of the
|
|
110
|
+
normalised value). Give one or the other.
|
|
111
|
+
``include_reference`` adds ``sample.expected`` to the prompt — only do this when
|
|
112
|
+
similarity to the reference is the criterion.
|
|
113
|
+
"""
|
|
114
|
+
if scale < 2:
|
|
115
|
+
raise ValueError("scale must be at least 2")
|
|
116
|
+
if pass_level is not None and threshold is not None:
|
|
117
|
+
raise ValueError("give pass_level (a rubric level) or threshold (0..1), not both")
|
|
118
|
+
if threshold is not None:
|
|
119
|
+
if not 0.0 <= threshold <= 1.0:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"threshold must be a 0..1 fraction of the normalised score, got {threshold}; "
|
|
122
|
+
"to pass at a rubric level use pass_level="
|
|
123
|
+
)
|
|
124
|
+
pass_at = threshold
|
|
125
|
+
else:
|
|
126
|
+
level = pass_level if pass_level is not None else (scale - 1 if scale >= 3 else scale)
|
|
127
|
+
if not 1 <= level <= scale:
|
|
128
|
+
raise ValueError(f"pass_level must be within 1..{scale}, got {level}")
|
|
129
|
+
pass_at = (level - 1) / (scale - 1)
|
|
130
|
+
identity = {"v": PROMPT_VERSION, "rubric": rubric, "scale": scale, "ref": include_reference}
|
|
131
|
+
version_hash = sha256(identity)[:16]
|
|
132
|
+
|
|
133
|
+
def fn(sample: Sample, output: Output, ctx: Context) -> Score:
|
|
134
|
+
providers = ctx.providers
|
|
135
|
+
if providers is None:
|
|
136
|
+
return Score.errored("judge: no providers configured (ctx.providers is None)")
|
|
137
|
+
try:
|
|
138
|
+
resolved = "/".join(providers.split(model))
|
|
139
|
+
except ProviderError as exc:
|
|
140
|
+
return Score.errored(str(exc))
|
|
141
|
+
judge_version = sha256({**identity, "model": resolved})[:16]
|
|
142
|
+
reference = ""
|
|
143
|
+
if include_reference and sample.expected is not None:
|
|
144
|
+
reference = (
|
|
145
|
+
f"\nREFERENCE (what a good answer must satisfy):\n{_json_text(sample.expected)}\n"
|
|
146
|
+
)
|
|
147
|
+
user = USER_TEMPLATE.format(
|
|
148
|
+
input=_json_text(sample.input), reference=reference, output=output.text
|
|
149
|
+
)
|
|
150
|
+
system = SYSTEM_TEMPLATE.format(rubric=rubric.strip(), scale=scale)
|
|
151
|
+
|
|
152
|
+
def to_score(n: int, reason: str, *, raw: dict[str, Any], cost: float, ms: float) -> Score:
|
|
153
|
+
# value/passed are derived here, so a cached raw verdict serves every threshold.
|
|
154
|
+
value = (n - 1) / (scale - 1)
|
|
155
|
+
return Score(
|
|
156
|
+
value=value,
|
|
157
|
+
passed=value >= pass_at,
|
|
158
|
+
reason=f"{n}/{scale}: {reason}" if reason else f"{n}/{scale}",
|
|
159
|
+
cost_usd=cost,
|
|
160
|
+
latency_ms=ms,
|
|
161
|
+
raw={**raw, "score": n, "scale": scale, "judge_version": judge_version},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# The cache holds the model's raw verdict (level + reason), keyed by the
|
|
165
|
+
# resolved judge version and the exact prompt — never a derived pass/fail.
|
|
166
|
+
cache_key = sha256({"j": judge_version, "u": user})
|
|
167
|
+
cached = ctx.cache.get(cache_key) if ctx.cache is not None else None
|
|
168
|
+
if cached is not None and cached.raw and "score" in cached.raw:
|
|
169
|
+
raw = {k: v for k, v in cached.raw.items() if k not in {"score", "scale"}}
|
|
170
|
+
return to_score(
|
|
171
|
+
int(cached.raw["score"]),
|
|
172
|
+
_verdict_reason(cached),
|
|
173
|
+
raw={**raw, "cached": True},
|
|
174
|
+
cost=0.0,
|
|
175
|
+
ms=0.0,
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
resp = providers.complete(
|
|
179
|
+
model,
|
|
180
|
+
{
|
|
181
|
+
"system": system,
|
|
182
|
+
"messages": [ChatMessage(role="user", content=user)],
|
|
183
|
+
"max_tokens": max_tokens,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
except ProviderError as exc:
|
|
187
|
+
return Score.errored(str(exc))
|
|
188
|
+
try:
|
|
189
|
+
n, reason = parse_verdict(resp.text, scale)
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
return Score.errored(
|
|
192
|
+
f"judge: {exc}", cost_usd=resp.cost_usd or 0.0, latency_ms=resp.latency_ms
|
|
193
|
+
)
|
|
194
|
+
score = to_score(
|
|
195
|
+
n,
|
|
196
|
+
reason,
|
|
197
|
+
raw={
|
|
198
|
+
"model": resp.model,
|
|
199
|
+
"tokens": [resp.input_tokens, resp.output_tokens],
|
|
200
|
+
"unpriced": resp.cost_usd is None,
|
|
201
|
+
"verdict_reason": reason,
|
|
202
|
+
},
|
|
203
|
+
cost=resp.cost_usd or 0.0,
|
|
204
|
+
ms=resp.latency_ms,
|
|
205
|
+
)
|
|
206
|
+
if ctx.cache is not None:
|
|
207
|
+
ctx.cache.set(cache_key, score)
|
|
208
|
+
return score
|
|
209
|
+
|
|
210
|
+
return JudgeCheck(
|
|
211
|
+
key, fn, version_hash=version_hash, model=model, rubric=rubric, scale=scale, pass_at=pass_at
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _verdict_reason(score: Score) -> str:
|
|
216
|
+
return str((score.raw or {}).get("verdict_reason", ""))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
__all__ = ["PROMPT_VERSION", "JudgeCheck", "extract_json", "judge", "parse_verdict"]
|
evalstone/checks/rag.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""RAG checks — the three questions a retrieval-augmented answer can fail.
|
|
2
|
+
|
|
3
|
+
Each is a ``judge`` with a narrow rubric. The sample input is expected to carry
|
|
4
|
+
the question and the retrieved context (``{"question": …, "context": …}`` by
|
|
5
|
+
default; the field names are configurable). Measuring them separately tells
|
|
6
|
+
you whether to fix the retriever or the prompt (Evals 101, chapter 4).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from evalstone.checks.judge import JudgeCheck, judge
|
|
12
|
+
|
|
13
|
+
_FAITHFULNESS = """You are checking FAITHFULNESS of an answer to its source material.
|
|
14
|
+
The INPUT contains the field `{context}` — the only material the answer may rely on.
|
|
15
|
+
Grade whether every factual claim in the ANSWER is supported by `{context}`.
|
|
16
|
+
5 = every claim supported · 4 = one minor unsupported detail · 3 = one substantive unsupported
|
|
17
|
+
claim · 2 = several unsupported claims · 1 = the answer contradicts the context or is fabricated.
|
|
18
|
+
Ignore style, tone and whether the answer is complete; judge only support by the context."""
|
|
19
|
+
|
|
20
|
+
_CONTEXT_RELEVANCE = """You are checking CONTEXT RELEVANCE for a retrieval system.
|
|
21
|
+
The INPUT contains `{question}` (what was asked) and `{context}` (what retrieval returned).
|
|
22
|
+
Grade whether `{context}` contains the information needed to answer `{question}`.
|
|
23
|
+
5 = fully answers it · 4 = answers it with minor gaps · 3 = partially relevant · 2 = tangential ·
|
|
24
|
+
1 = unrelated. Ignore the ANSWER entirely; judge only the retrieved context."""
|
|
25
|
+
|
|
26
|
+
_ANSWER_RELEVANCE = """You are checking ANSWER RELEVANCE.
|
|
27
|
+
The INPUT contains `{question}`. Grade whether the ANSWER actually addresses that question.
|
|
28
|
+
5 = directly and completely addresses it · 4 = addresses it with a minor omission ·
|
|
29
|
+
3 = partially addresses it · 2 = mostly off-topic · 1 = does not address it.
|
|
30
|
+
Ignore factual accuracy (another check covers that); judge only whether the question was
|
|
31
|
+
answered."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def faithfulness(
|
|
35
|
+
key: str = "faithfulness",
|
|
36
|
+
*,
|
|
37
|
+
context: str = "context",
|
|
38
|
+
model: str | None = None,
|
|
39
|
+
threshold: float | None = None,
|
|
40
|
+
) -> JudgeCheck:
|
|
41
|
+
"""Every claim in the answer is supported by ``sample.input[context]``."""
|
|
42
|
+
return judge(key, _FAITHFULNESS.format(context=context), model=model, threshold=threshold)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def context_relevance(
|
|
46
|
+
key: str = "context_relevance",
|
|
47
|
+
*,
|
|
48
|
+
question: str = "question",
|
|
49
|
+
context: str = "context",
|
|
50
|
+
model: str | None = None,
|
|
51
|
+
threshold: float | None = None,
|
|
52
|
+
) -> JudgeCheck:
|
|
53
|
+
"""The retrieved ``context`` can answer the ``question``."""
|
|
54
|
+
rubric = _CONTEXT_RELEVANCE.format(question=question, context=context)
|
|
55
|
+
return judge(key, rubric, model=model, threshold=threshold)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def answer_relevance(
|
|
59
|
+
key: str = "answer_relevance",
|
|
60
|
+
*,
|
|
61
|
+
question: str = "question",
|
|
62
|
+
model: str | None = None,
|
|
63
|
+
threshold: float | None = None,
|
|
64
|
+
) -> JudgeCheck:
|
|
65
|
+
"""The answer addresses the ``question``."""
|
|
66
|
+
rubric = _ANSWER_RELEVANCE.format(question=question)
|
|
67
|
+
return judge(key, rubric, model=model, threshold=threshold)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["answer_relevance", "context_relevance", "faithfulness"]
|