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.
@@ -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"]
@@ -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"]