llm-ports 0.12.1.dev14__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.
llm_ports/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """Thin Protocols for LLM work, and the one call path around them.
2
+
3
+ The package root exports:
4
+
5
+ - the ports and their value types (`ports`);
6
+ - the call path `call.generate`, which resolves a `Role` from the consumer's tier table (`roles`),
7
+ renders a prompt pack named by its content sha (`packs`), runs per-call graders whose failures
8
+ are recorded as scores (`call_graders`), and ends in a named `Outcome` (`ok`, `gated`,
9
+ `unavailable`, `request_fault`, `truncated` or `invalid`);
10
+ - the data-rights gate (`content.print_gate`) and `GenerationFailed`.
11
+
12
+ Frameworks live behind the ports. `pydantic_ai_generator.PydanticAIGenerator` implements
13
+ `Generator` and `pydantic_ai_embedder.PydanticAIEmbedder` implements `Embedder`; import adapters
14
+ by module path.
15
+
16
+ Invariants:
17
+ - The package root imports no framework.
18
+ - A `Generator` never raises for a model, wire or gate failure.
19
+
20
+ Rationale: ADR-0001.
21
+ """
22
+
23
+ from llm_ports.call import Hooks, generate, json_output, request_for, require_ok, tracer_trace
24
+ from llm_ports.content import Activity, ContentItem, DataClass, GateVerdict, print_gate
25
+ from llm_ports.failure import GenerationFailed
26
+ from llm_ports.ports import (
27
+ Chunk,
28
+ CitedAnswer,
29
+ Claim,
30
+ Embedded,
31
+ Embedder,
32
+ EmbedInput,
33
+ Generated,
34
+ Generation,
35
+ Generator,
36
+ GenRequest,
37
+ Judge,
38
+ JudgeVerdict,
39
+ Outcome,
40
+ Prompt,
41
+ PromptSource,
42
+ Reranked,
43
+ Reranker,
44
+ Seed,
45
+ Tracer,
46
+ )
47
+ from llm_ports.roles import Role, Tier, TierTable, load_tiers
48
+
49
+ __version__ = "0.12.0"
50
+
51
+ __all__ = [
52
+ "Activity",
53
+ "Chunk",
54
+ "CitedAnswer",
55
+ "Claim",
56
+ "ContentItem",
57
+ "DataClass",
58
+ "GateVerdict",
59
+ "EmbedInput",
60
+ "Embedded",
61
+ "Embedder",
62
+ "GenRequest",
63
+ "Generated",
64
+ "Generation",
65
+ "GenerationFailed",
66
+ "Generator",
67
+ "Hooks",
68
+ "Judge",
69
+ "JudgeVerdict",
70
+ "Outcome",
71
+ "Prompt",
72
+ "PromptSource",
73
+ "Reranked",
74
+ "Reranker",
75
+ "Role",
76
+ "Seed",
77
+ "Tier",
78
+ "TierTable",
79
+ "Tracer",
80
+ "__version__",
81
+ "generate",
82
+ "json_output",
83
+ "load_tiers",
84
+ "print_gate",
85
+ "request_for",
86
+ "require_ok",
87
+ "tracer_trace",
88
+ ]
llm_ports/call.py ADDED
@@ -0,0 +1,301 @@
1
+ """The single generation path: role, request, gate, trace, `Generator`, graders.
2
+
3
+ Example:
4
+ res = await call.generate(CATEGORIZE, prompt=text, output=Reply, generator=gen, hooks=HOOKS)
5
+ if res.outcome != "ok": ... # gated | unavailable | request_fault | truncated | invalid
6
+
7
+ Per call, the role resolves to one `GenRequest` (`request_for`), the category is entered with
8
+ `context.llm_feature`, and the `admit` hook runs. A refusal returns `gated` before any trace opens:
9
+ a switched-off category is not an error. Otherwise one trace observation (the `trace` hook) wraps
10
+ the `Generator` call. Every `ok` reply is scored by the graders its role names; each score is
11
+ written on the observation and carried under `stamp["scores"]`, then handed to `on_scores` (a
12
+ gauge) and `on_ok` (a sampler). Feature code carries no model literal, no timeout and no framework
13
+ import: the role carries them, and the `Generator` is passed in (an adapter, a test stub, a
14
+ recording replay).
15
+
16
+ The hooks are where a consumer's own planes plug in: a category switchboard, a tracing backend, a
17
+ metrics sink, an LLM-judge sampler. Every hook is optional. With none, the path is role,
18
+ `Generator`, graders, and the outcome is still named and a grader failure still recorded as a score.
19
+
20
+ Invariants:
21
+ - `generate` never raises for a model, wire or gate failure; the outcome names it.
22
+ - A non-`ok`, non-`gated` outcome ends the observation in error and emits `llm_call_failed`.
23
+ `_NotOk` is raised inside the trace block only and never escapes.
24
+ - A caller whose ledger is an `except` uses `require_ok`.
25
+
26
+ Rationale: ADR-0001.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
34
+ from contextlib import AbstractAsyncContextManager
35
+ from dataclasses import dataclass, replace
36
+ from typing import Any
37
+
38
+ from llm_ports import ports
39
+ from llm_ports.call_graders import CallGrader, CallScore, graders_for_inputs, score_all
40
+ from llm_ports.context import llm_feature
41
+ from llm_ports.failure import GenerationFailed
42
+ from llm_ports.observation import Observation, observe
43
+ from llm_ports.ports import Generated, Generator, GenRequest, Tracer
44
+ from llm_ports.reference import NoopTracer
45
+ from llm_ports.roles import Role
46
+
47
+ OUTPUT_TRACE_MAX = 4000
48
+ _log = logging.getLogger("llm_ports.call")
49
+
50
+
51
+ #: Trace hook: `(role, request, prompt, metadata)` to an async context manager yielding an
52
+ #: `Observation`. On an exception inside the block, the hook ends the observation in error and lets
53
+ #: the exception propagate.
54
+ TraceHook = Callable[[Role, GenRequest, str, Mapping[str, Any]], AbstractAsyncContextManager[Observation]]
55
+ #: Admission hook: `(role)` to None to admit, or to the refusal reason (the outcome becomes `gated`).
56
+ AdmitHook = Callable[[Role], str | None]
57
+ #: Event hook: `(logging level, event, fields)`. Without one, events go to the stdlib logger
58
+ #: `llm_ports.call`.
59
+ EventHook = Callable[[int, str, Mapping[str, Any]], None]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Hooks:
64
+ """Optional consumer hooks for `generate`.
65
+
66
+ Attributes:
67
+ admit: Runs before any trace opens; a returned reason makes the outcome `gated`.
68
+ trace: Opens the call's observation; without one, a sink-less tracer is used.
69
+ graders: The grader table by name; None means `call_graders.GRADERS`.
70
+ on_scores: Awaited with the role and its scores whenever a reply was graded.
71
+ on_ok: Called with the role, prompt and result after an `ok` reply.
72
+ on_event: Receives `llm_call_failed` and `llm_call_graded`; without one, they are logged.
73
+ """
74
+
75
+ admit: AdmitHook | None = None
76
+ trace: TraceHook | None = None
77
+ graders: Mapping[str, CallGrader] | None = None # None means call_graders.GRADERS
78
+ on_scores: Callable[[Role, tuple[CallScore, ...]], Awaitable[Any]] | None = None
79
+ on_ok: Callable[[Role, str, Generated[Any]], Any] | None = None
80
+ on_event: EventHook | None = None
81
+
82
+
83
+ def tracer_trace(tracer: Tracer, *, provider: str = "openai") -> TraceHook:
84
+ """Return a `trace` hook over any `Tracer` port adapter (Langfuse, OTel, a test recorder).
85
+
86
+ The hook opens one generation per call (`observation.observe`), named by the role's category,
87
+ and ends it in error on a non-`ok` outcome.
88
+ """
89
+
90
+ def trace(
91
+ role: Role, req: GenRequest, prompt: str, meta: Mapping[str, Any]
92
+ ) -> AbstractAsyncContextManager[Observation]:
93
+ return observe(
94
+ tracer,
95
+ name=role.category,
96
+ model=req.model,
97
+ provider=provider,
98
+ input=prompt,
99
+ metadata=meta,
100
+ prompt_name=role.pack,
101
+ )
102
+
103
+ return trace
104
+
105
+
106
+ #: The trace used when no hook is given: the port path over the sink-less tracer, so the package
107
+ #: has one null sink (`reference.NoopGeneration`) at either layer.
108
+ _null_trace = tracer_trace(NoopTracer())
109
+
110
+
111
+ class _NotOk(Exception):
112
+ """Ends the observation in error; raised inside the trace block only and never escapes."""
113
+
114
+
115
+ def request_for(
116
+ role: Role,
117
+ *,
118
+ prompt: str,
119
+ system: str = "",
120
+ images: Sequence[tuple[bytes, str]] = (),
121
+ settings: Mapping[str, Any] | None = None,
122
+ ) -> GenRequest:
123
+ """Resolve the role into one wire request.
124
+
125
+ Pure. `settings` override the role's model settings for this call (a seed for a vote, a tighter
126
+ `max_tokens`); the role stays the default.
127
+ """
128
+ tier = role.resolved_tier
129
+ return GenRequest(
130
+ model=role.resolved_model,
131
+ prompt=prompt,
132
+ system=system,
133
+ images=tuple(images),
134
+ settings={**role.model_settings(), **(settings or {})},
135
+ box=tier.box,
136
+ timeout_s=role.resolved_timeout_s,
137
+ output_retries=role.output_retries,
138
+ label=role.name,
139
+ )
140
+
141
+
142
+ def json_output(schema: Mapping[str, Any], name: str) -> type[dict[str, Any]]:
143
+ """Return a hand-written JSON schema as an `output=` type.
144
+
145
+ The schema goes to the model unchanged as the grammar, and the value is a plain dict. `name` is
146
+ what a test stub dispatches on. The result is a port value type (`ports.JsonOutput`); the
147
+ `Generator` adapter translates it.
148
+ """
149
+ return ports.json_output(schema, name)
150
+
151
+
152
+ def require_ok[T](res: Generated[T]) -> T:
153
+ """Return the result's value, or raise its failure.
154
+
155
+ For a caller whose `except Exception` is its ledger: under the never-raise contract a dead model
156
+ would otherwise become an unrecorded None.
157
+
158
+ Raises:
159
+ GenerationFailed: `(outcome, reason)` for any non-`ok` outcome, `gated` included, and for an
160
+ `ok` result without a value.
161
+ """
162
+ if res.outcome != "ok" or res.value is None:
163
+ raise GenerationFailed(res.outcome, res.reason)
164
+ return res.value
165
+
166
+
167
+ def trace_output(value: Any) -> str:
168
+ """Return the observation's output text, capped at `OUTPUT_TRACE_MAX` characters.
169
+
170
+ A pydantic model or a mapping is rendered as JSON; None is the empty string.
171
+ """
172
+ if value is None:
173
+ return ""
174
+ dump = getattr(value, "model_dump_json", None)
175
+ if callable(dump):
176
+ return str(dump())[:OUTPUT_TRACE_MAX]
177
+ if isinstance(value, Mapping):
178
+ return json.dumps(value, default=str)[:OUTPUT_TRACE_MAX]
179
+ return str(value)[:OUTPUT_TRACE_MAX]
180
+
181
+
182
+ def _event(h: Hooks, level: int, event: str, **fields: Any) -> None:
183
+ if h.on_event is not None:
184
+ h.on_event(level, event, fields)
185
+ else:
186
+ _log.log(level, "%s %s", event, fields)
187
+
188
+
189
+ def _grade(role: Role, prompt: str, res: Generated[Any], obs: Observation, h: Hooks) -> Generated[Any]:
190
+ """Score an `ok` reply with the role's graders and write each score on its observation.
191
+
192
+ The scores ride in the returned stamp under `"scores"`. Failed graders are also emitted by name
193
+ as `llm_call_graded`.
194
+ """
195
+ scores = score_all(role, prompt, res.value, h.graders)
196
+ for s in scores:
197
+ obs.score(name=f"{role.name}.{s.name}", value=s.value, comment=s.reason or None)
198
+ failed = [s.name for s in scores if not s.passed]
199
+ if failed:
200
+ _event(
201
+ h,
202
+ logging.INFO,
203
+ "llm_call_graded",
204
+ role=role.name,
205
+ failed=failed,
206
+ reasons={s.name: s.reason for s in scores if not s.passed},
207
+ )
208
+ return replace(res, stamp={**res.stamp, "scores": scores})
209
+
210
+
211
+ async def generate[T](
212
+ role: Role,
213
+ *,
214
+ prompt: str,
215
+ output: type[T] = str, # type: ignore[assignment]
216
+ generator: Generator,
217
+ hooks: Hooks | None = None,
218
+ system: str = "",
219
+ images: Sequence[tuple[bytes, str]] = (),
220
+ validate: Callable[[T], Sequence[Any]] | None = None,
221
+ retry_message: Callable[[Sequence[Any]], str] | None = None,
222
+ settings: Mapping[str, Any] | None = None,
223
+ metadata: Mapping[str, Any] | None = None,
224
+ web: bool = False,
225
+ ) -> Generated[T]:
226
+ """Run one generation for `role` through `generator`.
227
+
228
+ The path and the hooks are described in the module docstring. `web=True` says the role's tier
229
+ fetches pages the prompt never rendered: like an image, an input no text grader sees, so
230
+ `call_graders.TEXT_ONLY_GRADERS` are dropped for this call. The role itself is unchanged.
231
+
232
+ Returns:
233
+ The result, with a named outcome. A model, wire or gate failure is an outcome, never an
234
+ exception.
235
+
236
+ Raises:
237
+ RuntimeError: Only when a trace hook swallowed a generator exception, leaving no result.
238
+ """
239
+ h = hooks or Hooks()
240
+ seen = graders_for_inputs(tuple(role.graders), has_images=bool(images), has_web=web)
241
+ if seen != tuple(role.graders):
242
+ role = replace(role, graders=seen)
243
+ req = request_for(role, prompt=prompt, system=system, images=images, settings=settings)
244
+ with llm_feature(role.category):
245
+ if h.admit is not None:
246
+ refused = h.admit(role)
247
+ if refused is not None:
248
+ return Generated(value=None, outcome="gated", model=req.model, reason=refused[:200])
249
+ res: Generated[T] | None = None
250
+ trace = h.trace or _null_trace
251
+ try:
252
+ async with trace(role, req, prompt, {"role": role.name, **(metadata or {})}) as obs:
253
+ res = await generator.generate(req, output, validate=validate, retry_message=retry_message)
254
+ obs.update(
255
+ output=trace_output(res.value),
256
+ usage_details=dict(res.usage or {}),
257
+ metadata={
258
+ "role": role.name,
259
+ "outcome": res.outcome,
260
+ "retries": res.retries,
261
+ "finish_reason": res.finish_reason,
262
+ "problems": len(res.problems),
263
+ },
264
+ )
265
+ res = replace(res, stamp={**res.stamp, "generation": obs})
266
+ if res.outcome == "ok" and role.graders:
267
+ res = _grade(role, prompt, res, obs, h)
268
+ if res.outcome not in ("ok", "gated"):
269
+ raise _NotOk(f"{res.outcome}: {res.reason}")
270
+ except _NotOk:
271
+ _event(
272
+ h,
273
+ logging.WARNING,
274
+ "llm_call_failed",
275
+ role=role.name,
276
+ outcome=res.outcome if res else None,
277
+ reason=res.reason if res else None,
278
+ )
279
+ if res is None: # reachable only when a trace hook swallowed a generator exception
280
+ raise RuntimeError(f"llm_call {role.name}: the generator returned nothing")
281
+ if res.stamp.get("scores") and h.on_scores is not None:
282
+ await h.on_scores(role, res.stamp["scores"])
283
+ if res.outcome == "ok" and h.on_ok is not None:
284
+ h.on_ok(role, prompt, res)
285
+ return res
286
+
287
+
288
+ __all__ = [
289
+ "OUTPUT_TRACE_MAX",
290
+ "AdmitHook",
291
+ "EventHook",
292
+ "Hooks",
293
+ "Observation",
294
+ "TraceHook",
295
+ "generate",
296
+ "json_output",
297
+ "request_for",
298
+ "require_ok",
299
+ "trace_output",
300
+ "tracer_trace",
301
+ ]
@@ -0,0 +1,297 @@
1
+ """Deterministic graders over one generation: the prompt in, the value out.
2
+
3
+ Pure. A `CallGrader` is `(prompt, value, role) -> CallScore`: a numeric value (what a trace score
4
+ and a dashboard can sum), a pass or fail against the grader's own bar, and a reason that names the
5
+ violated property on failure. `call.generate` runs the graders a `Role.graders` names on every
6
+ `ok` reply and writes each as a score on the call's trace observation. A golden eval runs the same
7
+ functions over a pack's golden set (`board_graders`), so the live score and the offline verdict
8
+ agree on what "complete" or "echoed" means.
9
+
10
+ Built-in graders, for structured outputs (a dict or pydantic value under a grammar):
11
+
12
+ - `schema_complete`: the share of fields that carry a value. A strict grammar admits `""` for
13
+ every required key, so a parsed reply is not necessarily an answered one. An empty list is an
14
+ answer; only None and blank text count as unanswered. On a pydantic value, None on a field whose
15
+ annotation admits None (`float | None`, `Optional[X]`) is an answer too, while None on a required
16
+ field fails. A dict value has no annotations to read, so every None in it is unanswered.
17
+ - `numbers_from_inputs`: figures in the reply's prose that no part of the prompt rendered, digit
18
+ runs by value and spelled numbers alike (`numbers.loose`): the number law, per call.
19
+ - `echo`: the share of the reply's sentences copied verbatim from the prompt.
20
+
21
+ A consumer adds its own graders by passing a table (`{**GRADERS, "mine": fn}`) to `score_all`,
22
+ `board_graders` or `call.Hooks(graders=...)`. This package never mutates `GRADERS`.
23
+
24
+ Invariants:
25
+ - `score_one` and `score_all` never raise. A grader's exception becomes a failed score of value
26
+ `CRASHED` (-1, below every honest value, all of which are >= 0) whose reason names the
27
+ exception, so a crashed grader never reads as a pass.
28
+
29
+ Rationale: ADR-0003.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ import types
36
+ from collections.abc import Callable, Iterator, Mapping
37
+ from dataclasses import dataclass
38
+ from typing import Annotated, Any, Union, get_args, get_origin
39
+
40
+ from pydantic import BaseModel
41
+
42
+ from llm_ports.numbers import digits, loose
43
+ from llm_ports.roles import Role
44
+
45
+ ECHO_MAX_SHARE = 0.5 # `echo` fails at or above this share of copied sentences
46
+ ECHO_MIN_CHARS = 40 # a sentence shorter than this is a label ("unclear", a key), not prose
47
+ CRASHED = -1.0 # the value of a grader that raised; every honest value is >= 0
48
+ _SENTENCE = re.compile(r"[.!?\n]+")
49
+ _WS = re.compile(r"\s+")
50
+ _INDEX = re.compile(r"\[\d+\]") # `items[3].price` -> `items[].price`: the field, not the row
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class CallScore:
55
+ """One grader's verdict on one reply.
56
+
57
+ Attributes:
58
+ name: The grader's name.
59
+ value: The numeric score; `CRASHED` when the grader raised.
60
+ passed: Whether the value meets the grader's own bar.
61
+ reason: Empty on a pass; names the violated property on a failure.
62
+ """
63
+
64
+ name: str
65
+ value: float
66
+ passed: bool
67
+ reason: str = ""
68
+
69
+
70
+ CallGrader = Callable[[str, Any, Role], CallScore]
71
+
72
+
73
+ def leaves(value: Any, path: str = "") -> Iterator[tuple[str, Any]]:
74
+ """Yield `(path, scalar)` for every leaf of a dict, pydantic or list value, in order.
75
+
76
+ Pure. An empty list is itself a leaf.
77
+ """
78
+ dump = getattr(value, "model_dump", None)
79
+ if callable(dump):
80
+ value = dump()
81
+ if isinstance(value, Mapping):
82
+ for k, v in value.items():
83
+ yield from leaves(v, f"{path}.{k}" if path else str(k))
84
+ elif isinstance(value, (list, tuple)):
85
+ if not value:
86
+ yield path, value # an empty list is a leaf: the field answered nothing
87
+ for i, v in enumerate(value):
88
+ yield from leaves(v, f"{path}[{i}]")
89
+ else:
90
+ yield path, value
91
+
92
+
93
+ def prose(value: Any) -> list[str]:
94
+ """Return every string leaf of the reply: what the number law and the echo check read."""
95
+ return [v for _p, v in leaves(value) if isinstance(v, str)]
96
+
97
+
98
+ def _empty(v: Any) -> bool:
99
+ return v is None or (isinstance(v, str) and not v.strip())
100
+
101
+
102
+ def _admits_none(ann: Any) -> bool:
103
+ """Return whether the annotation accepts None (`X | None`, `Optional[X]`, `Annotated[X | None, ...]`)."""
104
+ if ann is None or ann is type(None):
105
+ return True
106
+ origin = get_origin(ann)
107
+ if origin is Annotated:
108
+ return _admits_none(get_args(ann)[0])
109
+ return origin in (Union, types.UnionType) and any(_admits_none(a) for a in get_args(ann))
110
+
111
+
112
+ def _models_in(ann: Any, suffix: str = "") -> Iterator[tuple[str, type[BaseModel]]]:
113
+ """Yield `(path suffix, model)` for every `BaseModel` an annotation reaches.
114
+
115
+ The suffix is `""` for the model itself or through a union, plus `[]` per list, tuple or set
116
+ layer: the spelling `leaves` emits, without indexes.
117
+ """
118
+ if isinstance(ann, type) and issubclass(ann, BaseModel):
119
+ yield suffix, ann
120
+ return
121
+ origin, args = get_origin(ann), get_args(ann)
122
+ if origin is Annotated or origin in (Union, types.UnionType):
123
+ for a in args[:1] if origin is Annotated else args:
124
+ yield from _models_in(a, suffix)
125
+ elif isinstance(origin, type) and issubclass(origin, (list, tuple, set, frozenset)):
126
+ for a in args:
127
+ if a is not Ellipsis:
128
+ yield from _models_in(a, suffix + "[]")
129
+
130
+
131
+ def nullable_paths(model: type[BaseModel], prefix: str = "", _seen: frozenset[type] = frozenset()) -> frozenset[str]:
132
+ """Return the index-free leaf paths of every field whose annotation admits None.
133
+
134
+ Pure. Paths (`items[].price`) run through nested models and lists of models. They are read off
135
+ the class, so they never depend on which rows a reply carried. A self-referential model stops at
136
+ its own repeat.
137
+ """
138
+ out: set[str] = set()
139
+ seen = _seen | {model}
140
+ for name, field in model.model_fields.items():
141
+ path = f"{prefix}.{name}" if prefix else name
142
+ if _admits_none(field.annotation):
143
+ out.add(path)
144
+ for suffix, sub in _models_in(field.annotation):
145
+ if sub not in seen:
146
+ out |= nullable_paths(sub, path + suffix, seen)
147
+ return frozenset(out)
148
+
149
+
150
+ def schema_complete(_prompt: str, value: Any, _role: Role) -> CallScore:
151
+ """Score the share of the reply's fields that carry a value.
152
+
153
+ Passes only when no field is unanswered. None and blank text are unanswered; an empty list is an
154
+ answer, and so is None on a pydantic field whose annotation admits None.
155
+ """
156
+ fields = list(leaves(value))
157
+ if not fields:
158
+ return CallScore("schema_complete", 0.0, False, "the reply carries no fields")
159
+ nullable = nullable_paths(type(value)) if isinstance(value, BaseModel) else frozenset()
160
+ empty = [p for p, v in fields if _empty(v) and _INDEX.sub("[]", p) not in nullable]
161
+ share = round(1 - len(empty) / len(fields), 4)
162
+ return CallScore("schema_complete", share, not empty, f"empty: {empty[:4]}" if empty else "")
163
+
164
+
165
+ def numbers_from_inputs(prompt: str, value: Any, _role: Role) -> CallScore:
166
+ """Count the distinct figures in the reply's prose that the prompt did not render.
167
+
168
+ Digit runs compare by value, and spelled numbers count too (`numbers.loose`). The prompt's own
169
+ text licenses a spelled word, and '%' licenses 'percent'. Passes only at zero.
170
+
171
+ Rationale: ADR-0003.
172
+ """
173
+ have = digits(prompt)
174
+ hits = sorted({n for s in prose(value) for n in loose(s, have, prompt)})
175
+ return CallScore(
176
+ "numbers_from_inputs", float(len(hits)), not hits, f"numbers no input rendered: {hits[:3]}" if hits else ""
177
+ )
178
+
179
+
180
+ def _sentences(text: str) -> list[str]:
181
+ return [s for s in (_WS.sub(" ", p).strip().lower() for p in _SENTENCE.split(text)) if len(s) >= ECHO_MIN_CHARS]
182
+
183
+
184
+ def echo(prompt: str, value: Any, _role: Role) -> CallScore:
185
+ """Score the share of the reply's sentences copied verbatim from the prompt.
186
+
187
+ The compare ignores case and whitespace, and a sentence shorter than `ECHO_MIN_CHARS` is not
188
+ counted; a reply with no counted sentence passes at 0. Fails at or above `ECHO_MAX_SHARE`.
189
+ """
190
+ hay = _WS.sub(" ", prompt).lower()
191
+ sents = [s for p in prose(value) for s in _sentences(p)]
192
+ if not sents:
193
+ return CallScore("echo", 0.0, True)
194
+ copied = [s for s in sents if s in hay]
195
+ share = round(len(copied) / len(sents), 4)
196
+ return CallScore(
197
+ "echo",
198
+ share,
199
+ share < ECHO_MAX_SHARE,
200
+ f"{len(copied)}/{len(sents)} sentences copied from the prompt: {copied[0][:60]!r}"
201
+ if share >= ECHO_MAX_SHARE
202
+ else "",
203
+ )
204
+
205
+
206
+ #: The built-in graders by name; a `Role` names its graders as strings (data, comparable, printable).
207
+ GRADERS: dict[str, CallGrader] = {
208
+ "schema_complete": schema_complete,
209
+ "numbers_from_inputs": numbers_from_inputs,
210
+ "echo": echo,
211
+ }
212
+
213
+
214
+ #: Graders whose verdict holds only when the prompt text is the call's whole input. A grader sees
215
+ #: `(prompt, value, role)`, so an image sent beside the prompt is invisible to it, as are the pages
216
+ #: a web-fetching tier read. `numbers_from_inputs` fails every honest reply on such a call: a chart
217
+ #: read's figures come off the image, a research reply's off the pages. `call.generate` drops these
218
+ #: for any call with `images=` or `web=True`; a text-only call keeps every grader its role names.
219
+ TEXT_ONLY_GRADERS: frozenset[str] = frozenset({"numbers_from_inputs"})
220
+
221
+
222
+ def graders_for_inputs(graders: tuple[str, ...], *, has_images: bool, has_web: bool = False) -> tuple[str, ...]:
223
+ """Return the graders that can see this call's inputs, in their given order.
224
+
225
+ Pure. A text-only call keeps all of them; an image beside the prompt or a web-fetching tier
226
+ removes `TEXT_ONLY_GRADERS`.
227
+ """
228
+ if has_images or has_web:
229
+ return tuple(g for g in graders if g not in TEXT_ONLY_GRADERS)
230
+ return graders
231
+
232
+
233
+ def score_one(
234
+ name: str, prompt: str, value: Any, role: Role, table: Mapping[str, CallGrader] | None = None
235
+ ) -> CallScore:
236
+ """Run one grader by name from `table` (default `GRADERS`).
237
+
238
+ Never raises: an unknown name or a grader exception becomes a failed score of value `CRASHED`.
239
+ """
240
+ fn = (GRADERS if table is None else table).get(name)
241
+ if fn is None:
242
+ return CallScore(name, CRASHED, False, f"no grader named {name!r}")
243
+ try:
244
+ return fn(prompt, value, role)
245
+ except Exception as exc: # a crashed grader must not read as a pass
246
+ return CallScore(name, CRASHED, False, f"grader raised {type(exc).__name__}: {exc}"[:200])
247
+
248
+
249
+ def score_all(
250
+ role: Role, prompt: str, value: Any, table: Mapping[str, CallGrader] | None = None
251
+ ) -> tuple[CallScore, ...]:
252
+ """Run every grader the role names, in the role's order; never raises."""
253
+ return tuple(score_one(n, prompt, value, role, table) for n in role.graders)
254
+
255
+
256
+ def board_graders(
257
+ role: Role,
258
+ table: Mapping[str, CallGrader] | None = None,
259
+ ) -> dict[str, Callable[[dict[str, Any]], tuple[bool, str]]]:
260
+ """Return the same graders in the `Evaluator` row shape (`ports.Grader`).
261
+
262
+ A golden row carries `prompt` and `value`. A row whose model never answered (`value is None`)
263
+ fails every grader with its `reason`, so the board names the outage rather than grading an
264
+ absence.
265
+ """
266
+
267
+ def bind(name: str) -> Callable[[dict[str, Any]], tuple[bool, str]]:
268
+ def grader(row: dict[str, Any]) -> tuple[bool, str]:
269
+ if row.get("value") is None:
270
+ return False, f"no reply: {row.get('reason') or row.get('outcome')}"
271
+ s = score_one(name, str(row.get("prompt") or ""), row["value"], role, table)
272
+ return s.passed, s.reason
273
+
274
+ return grader
275
+
276
+ return {n: bind(n) for n in role.graders}
277
+
278
+
279
+ __all__ = [
280
+ "CRASHED",
281
+ "ECHO_MAX_SHARE",
282
+ "ECHO_MIN_CHARS",
283
+ "GRADERS",
284
+ "TEXT_ONLY_GRADERS",
285
+ "CallGrader",
286
+ "CallScore",
287
+ "board_graders",
288
+ "echo",
289
+ "graders_for_inputs",
290
+ "leaves",
291
+ "nullable_paths",
292
+ "numbers_from_inputs",
293
+ "prose",
294
+ "schema_complete",
295
+ "score_all",
296
+ "score_one",
297
+ ]