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 +88 -0
- llm_ports/call.py +301 -0
- llm_ports/call_graders.py +297 -0
- llm_ports/chunking.py +94 -0
- llm_ports/claim_refute.py +200 -0
- llm_ports/content.py +252 -0
- llm_ports/context.py +77 -0
- llm_ports/failure.py +90 -0
- llm_ports/grounder_http.py +98 -0
- llm_ports/grounding.py +94 -0
- llm_ports/index_order.py +74 -0
- llm_ports/json_schema.py +99 -0
- llm_ports/judge_sampling.py +231 -0
- llm_ports/langfuse_prompt_source.py +172 -0
- llm_ports/langfuse_tracer.py +223 -0
- llm_ports/lettucedetect_grounder.py +100 -0
- llm_ports/manifest.py +115 -0
- llm_ports/numbers.py +180 -0
- llm_ports/observation.py +198 -0
- llm_ports/otel_gen_ai.py +181 -0
- llm_ports/packs.py +218 -0
- llm_ports/ports.py +671 -0
- llm_ports/py.typed +0 -0
- llm_ports/pydantic_ai_embedder.py +209 -0
- llm_ports/pydantic_ai_generator.py +383 -0
- llm_ports/pydantic_ai_replay.py +121 -0
- llm_ports/pydantic_evals_judge.py +205 -0
- llm_ports/reference.py +165 -0
- llm_ports/reranker_http.py +139 -0
- llm_ports/retrieval_metrics.py +66 -0
- llm_ports/roles.py +179 -0
- llm_ports/slots.py +168 -0
- llm_ports/sql_guard.py +214 -0
- llm_ports/testing.py +316 -0
- llm_ports/testing_langfuse.py +167 -0
- llm_ports-0.12.1.dev14.dist-info/METADATA +170 -0
- llm_ports-0.12.1.dev14.dist-info/RECORD +39 -0
- llm_ports-0.12.1.dev14.dist-info/WHEEL +4 -0
- llm_ports-0.12.1.dev14.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|