sift-engine 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.
- evals/__init__.py +13 -0
- evals/agent_loop/__init__.py +23 -0
- evals/agent_loop/agent.py +314 -0
- evals/agent_loop/judge.py +228 -0
- evals/agent_loop/questions.py +396 -0
- evals/agent_loop/report.py +197 -0
- evals/agent_loop/runner.py +234 -0
- evals/agent_loop/tools.py +384 -0
- evals/bench/__init__.py +0 -0
- evals/bench/aggregate_report.py +113 -0
- evals/bench/drift.py +301 -0
- evals/bench/fixtures/__init__.py +0 -0
- evals/bench/fixtures/sites.py +440 -0
- evals/bench/full_suite.py +380 -0
- evals/bench/per_stage/__init__.py +0 -0
- evals/bench/per_stage/commit.py +88 -0
- evals/bench/per_stage/extract.py +267 -0
- evals/bench/per_stage/fetch.py +190 -0
- evals/bench/per_stage/mcp.py +83 -0
- evals/bench/per_stage/plan.py +186 -0
- evals/bench/per_stage/publish.py +61 -0
- evals/bench/per_stage/seed.py +189 -0
- evals/bench/report.py +167 -0
- evals/bench/runner.py +103 -0
- evals/bench/scoring/__init__.py +0 -0
- evals/bench/scoring/fidelity.py +118 -0
- evals/bench/scoring/structural.py +121 -0
- evals/bench/scoring/use_case.py +60 -0
- evals/cli.py +620 -0
- evals/determinism.py +135 -0
- evals/efficiency.py +162 -0
- evals/facts_coverage.py +198 -0
- evals/facts_validation.py +150 -0
- evals/llm_judge.py +331 -0
- evals/performance.py +217 -0
- evals/sampler.py +111 -0
- evals/structural.py +237 -0
- sift/__init__.py +40 -0
- sift/_io.py +82 -0
- sift/agent_surface.py +267 -0
- sift/browser.py +420 -0
- sift/classify.py +164 -0
- sift/cli.py +1388 -0
- sift/commit.py +165 -0
- sift/config.py +302 -0
- sift/decide.py +173 -0
- sift/extract.py +669 -0
- sift/extract_code.py +190 -0
- sift/extract_next_state.py +305 -0
- sift/extract_strategy.py +143 -0
- sift/facts.py +334 -0
- sift/fetch.py +459 -0
- sift/index_profile.py +50 -0
- sift/integrity.py +155 -0
- sift/manifest.py +441 -0
- sift/mcp_server.py +1889 -0
- sift/normalize.py +62 -0
- sift/paths.py +136 -0
- sift/plan.py +149 -0
- sift/publish.py +642 -0
- sift/purge.py +58 -0
- sift/registry.py +495 -0
- sift/sites/__init__.py +232 -0
- sift/sites/ato.py +331 -0
- sift/sites/augov.py +186 -0
- sift/sites/generic.py +14 -0
- sift/sites/generic_browser.py +50 -0
- sift/sites/mdn.py +128 -0
- sift/sites/python_docs.py +134 -0
- sift/sites/stripe.py +138 -0
- sift/sources/__init__.py +86 -0
- sift/sources/firecrawl.py +488 -0
- sift/sources/sitemap.py +345 -0
- sift/status.py +141 -0
- sift_engine-0.1.0.dist-info/METADATA +342 -0
- sift_engine-0.1.0.dist-info/RECORD +80 -0
- sift_engine-0.1.0.dist-info/WHEEL +5 -0
- sift_engine-0.1.0.dist-info/entry_points.txt +4 -0
- sift_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- sift_engine-0.1.0.dist-info/top_level.txt +2 -0
evals/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Baseline metrics eval suite for the sift pipeline.
|
|
2
|
+
|
|
3
|
+
The evaluations here are intentionally **deterministic-where-possible** and
|
|
4
|
+
**versioned**: every result is keyed on (run_id, eval_version) so cross-run
|
|
5
|
+
comparisons make sense. The `baseline` orchestrator stitches them together
|
|
6
|
+
into one `baseline_report.json` per index snapshot.
|
|
7
|
+
|
|
8
|
+
Each eval module exports a `run(...)` function that returns a dataclass
|
|
9
|
+
serializable to JSON. The CLI calls them with consistent paths, then writes
|
|
10
|
+
to <root>/evals/<run_id>/<eval-name>.json.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
EVAL_VERSION = "v1"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Agent-in-the-loop eval — measure whether grep over a sift index lets
|
|
2
|
+
an agent answer questions more correctly than parametric knowledge alone
|
|
3
|
+
or live web-fetch.
|
|
4
|
+
|
|
5
|
+
This eval is the validation step for sift's headline claim ("a verified,
|
|
6
|
+
always-fresh standing index for agents"). Without it, all the per-stage
|
|
7
|
+
metrics are component-level — they say the pipeline is deterministic and
|
|
8
|
+
the markdown is structurally faithful, but they don't say whether the
|
|
9
|
+
final product helps an agent get more answers right.
|
|
10
|
+
|
|
11
|
+
Three conditions are compared:
|
|
12
|
+
|
|
13
|
+
* ``closed-book`` — Claude with no tools (parametric knowledge only)
|
|
14
|
+
* ``sift-grep`` — Claude with grep + read tools over a sift index
|
|
15
|
+
* ``web-fetch`` — Claude with a polite HTTP fetcher over the live web
|
|
16
|
+
|
|
17
|
+
A small (~20) hand-curated question set, each tied to URLs known to exist
|
|
18
|
+
in the v1.0 corpus, drives the comparison. An LLM judge scores correctness
|
|
19
|
+
+ citation faithfulness; the aggregate report shows lift per use case
|
|
20
|
+
and per question type.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
AGENT_LOOP_VERSION = "v1"
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Claude tool-use loop — one ``run_agent`` call drives one
|
|
2
|
+
``(question, condition)`` cell of the bench grid.
|
|
3
|
+
|
|
4
|
+
Loop invariants:
|
|
5
|
+
|
|
6
|
+
* **Bounded turns.** ``MAX_TURNS`` caps tool-use iterations; the loop
|
|
7
|
+
returns whatever the model produced on the last turn so partial work
|
|
8
|
+
is still scored, not silently dropped. We've seen models in this kind
|
|
9
|
+
of harness fall into "grep → grep → grep" cycles when the corpus
|
|
10
|
+
doesn't contain the answer; the cap is the deadbolt.
|
|
11
|
+
* **System prompt is condition-aware** but content-stable across all
|
|
12
|
+
questions in a condition, so prompt caching kicks in after the first
|
|
13
|
+
call and per-question marginal cost drops sharply.
|
|
14
|
+
* **Final answer extraction**: we don't trust the model to produce a
|
|
15
|
+
machine-readable answer block — instead, the last assistant
|
|
16
|
+
text-content concatenation IS the answer. Citations are sniffed
|
|
17
|
+
separately by URL-regexing the same body.
|
|
18
|
+
* **Token accounting is per-turn**: usage from every API call is summed
|
|
19
|
+
so the report can show real total spend, including tool-use round-trips,
|
|
20
|
+
not just the final-turn output.
|
|
21
|
+
|
|
22
|
+
The loop is sync (not async) on purpose — questions are run sequentially
|
|
23
|
+
per condition; we don't gain much from parallelism here and async-with-
|
|
24
|
+
tool-use complicates retry/observability without a payoff at N=20.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
from .tools import ToolSpec
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MAX_TURNS = 8
|
|
38
|
+
MAX_TOKENS = 4096
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TurnUsage:
|
|
43
|
+
"""Per-turn usage breakdown (mirrors Anthropic's UsageBlock fields)."""
|
|
44
|
+
input_tokens: int = 0
|
|
45
|
+
output_tokens: int = 0
|
|
46
|
+
cache_read_tokens: int = 0
|
|
47
|
+
cache_write_tokens: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AgentResult:
|
|
52
|
+
"""One end-to-end agent run on one question under one condition.
|
|
53
|
+
|
|
54
|
+
The ``answer`` is the verbatim concatenation of the model's final-turn
|
|
55
|
+
text content; ``cited_urls`` is parsed out by URL regex on that same
|
|
56
|
+
text. Tool-use spans (every tool_use block the model emitted) are
|
|
57
|
+
preserved so the judge can also score "did the agent actually look at
|
|
58
|
+
the gold pages."
|
|
59
|
+
"""
|
|
60
|
+
qid: str
|
|
61
|
+
condition: str
|
|
62
|
+
model: str
|
|
63
|
+
answer: str
|
|
64
|
+
cited_urls: list[str] = field(default_factory=list)
|
|
65
|
+
tool_calls: list[dict] = field(default_factory=list)
|
|
66
|
+
turns: int = 0
|
|
67
|
+
stop_reason: str = ""
|
|
68
|
+
refused: bool = False
|
|
69
|
+
wall_seconds: float = 0.0
|
|
70
|
+
total_input_tokens: int = 0
|
|
71
|
+
total_output_tokens: int = 0
|
|
72
|
+
total_cache_read_tokens: int = 0
|
|
73
|
+
total_cache_write_tokens: int = 0
|
|
74
|
+
error: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# URL extractor used to populate ``cited_urls``. Permissive on the trailing
|
|
78
|
+
# punctuation a model is likely to attach (``.``, ``)``, ``,`` etc.).
|
|
79
|
+
_URL_RE = re.compile(r"https?://[^\s)>,]+", flags=re.IGNORECASE)
|
|
80
|
+
|
|
81
|
+
# Bare strings the model returns when it refuses or punts. Matching this
|
|
82
|
+
# lets the judge tell apart "wrong" from "didn't try". Hand-tuned from
|
|
83
|
+
# Anthropic's safety templates + observed refusal phrasings.
|
|
84
|
+
_REFUSAL_MARKERS = (
|
|
85
|
+
"i don't have information",
|
|
86
|
+
"i cannot answer",
|
|
87
|
+
"i can't answer",
|
|
88
|
+
"i'm unable to",
|
|
89
|
+
"i am unable to",
|
|
90
|
+
"i don't know",
|
|
91
|
+
"i do not know",
|
|
92
|
+
"no information available",
|
|
93
|
+
"cannot determine",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_system_prompt(condition: str) -> list[dict]:
|
|
98
|
+
"""One frozen system prompt per condition, with cache control on the
|
|
99
|
+
text block so subsequent questions in the same run hit cache reads.
|
|
100
|
+
|
|
101
|
+
The prompts are tuned to make the comparison fair:
|
|
102
|
+
|
|
103
|
+
* closed-book — instruct the model to answer from its own knowledge
|
|
104
|
+
and admit uncertainty rather than guess. (Without this, models
|
|
105
|
+
will confidently make up specific numbers, which inflates the
|
|
106
|
+
sift lift artificially.)
|
|
107
|
+
* sift-grep — instruct the model to use grep first, then read,
|
|
108
|
+
then cite the URLs it relied on.
|
|
109
|
+
* web-fetch — same pattern as sift-grep but on the live web.
|
|
110
|
+
|
|
111
|
+
Each prompt's "cite your sources" instruction is identical across the
|
|
112
|
+
two retrieval conditions so we're not gaming citation rates by asking
|
|
113
|
+
one condition for citations and not the other.
|
|
114
|
+
"""
|
|
115
|
+
base = (
|
|
116
|
+
"You are an expert research assistant answering reference questions "
|
|
117
|
+
"about technical and policy documentation. Be precise, concise, and "
|
|
118
|
+
"honest about uncertainty. If you don't know an answer or can't "
|
|
119
|
+
"verify it, say so explicitly rather than guessing — guesses are "
|
|
120
|
+
"worse than 'I don't know' for this evaluation.\n\n"
|
|
121
|
+
"When you do answer, structure the response as:\n"
|
|
122
|
+
" 1. A short direct answer (1-3 sentences)\n"
|
|
123
|
+
" 2. (If a retrieval tool was available) a 'Sources:' line listing "
|
|
124
|
+
"the URLs you relied on, one per line.\n"
|
|
125
|
+
)
|
|
126
|
+
extra = {
|
|
127
|
+
"closed-book": (
|
|
128
|
+
"You do NOT have access to any retrieval tools. Answer from your "
|
|
129
|
+
"training knowledge only. If your training data is too stale or "
|
|
130
|
+
"doesn't cover the question, state that clearly instead of "
|
|
131
|
+
"inventing a specific value."
|
|
132
|
+
),
|
|
133
|
+
"sift-grep": (
|
|
134
|
+
"You have access to a sift-indexed snapshot of the relevant "
|
|
135
|
+
"documentation. Use grep_index first to locate the right page, "
|
|
136
|
+
"then read_page to read the full markdown. Cite the canonical "
|
|
137
|
+
"URLs returned by read_page in your 'Sources:' line."
|
|
138
|
+
),
|
|
139
|
+
"web-fetch": (
|
|
140
|
+
"You have access to a fetch_url tool that performs a plain HTTP "
|
|
141
|
+
"GET. Use it to retrieve the live page when you need specific, "
|
|
142
|
+
"current information. Note: some sites return 403 or bot-block "
|
|
143
|
+
"pages; if a fetch fails, do not make up the answer — say so."
|
|
144
|
+
),
|
|
145
|
+
}[condition]
|
|
146
|
+
text = base + "\n" + extra
|
|
147
|
+
return [{"type": "text", "text": text,
|
|
148
|
+
"cache_control": {"type": "ephemeral"}}]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _spec_to_api(spec: ToolSpec) -> dict:
|
|
152
|
+
return {"name": spec.name,
|
|
153
|
+
"description": spec.description,
|
|
154
|
+
"input_schema": spec.schema}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _collect_text(content: list) -> str:
|
|
158
|
+
"""Concatenate all top-level text blocks in an assistant message."""
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
for block in content:
|
|
161
|
+
if getattr(block, "type", None) == "text":
|
|
162
|
+
parts.append(block.text)
|
|
163
|
+
return "\n".join(p for p in parts if p)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _detect_refusal(text: str) -> bool:
|
|
167
|
+
t = text.lower()
|
|
168
|
+
return any(marker in t for marker in _REFUSAL_MARKERS)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run_agent(
|
|
172
|
+
*,
|
|
173
|
+
question_text: str,
|
|
174
|
+
condition: str,
|
|
175
|
+
tools: list[ToolSpec],
|
|
176
|
+
qid: str,
|
|
177
|
+
model: str = "claude-opus-4-7",
|
|
178
|
+
max_turns: int = MAX_TURNS,
|
|
179
|
+
api_key: Optional[str] = None,
|
|
180
|
+
client=None,
|
|
181
|
+
) -> AgentResult:
|
|
182
|
+
"""Run a single question through one condition. Returns an AgentResult
|
|
183
|
+
that holds the model's answer, tool-use spans, token totals, and
|
|
184
|
+
timing.
|
|
185
|
+
|
|
186
|
+
``client`` is optional so tests can inject a fake Anthropic SDK client
|
|
187
|
+
without hitting the network; in production it's constructed from
|
|
188
|
+
``api_key`` (or the ``ANTHROPIC_API_KEY`` env var).
|
|
189
|
+
"""
|
|
190
|
+
result = AgentResult(qid=qid, condition=condition, model=model, answer="")
|
|
191
|
+
t0 = time.time()
|
|
192
|
+
|
|
193
|
+
if client is None:
|
|
194
|
+
try:
|
|
195
|
+
import anthropic
|
|
196
|
+
except ImportError as e:
|
|
197
|
+
result.error = f"anthropic SDK not installed: {e}"
|
|
198
|
+
result.wall_seconds = round(time.time() - t0, 2)
|
|
199
|
+
return result
|
|
200
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
201
|
+
if not key:
|
|
202
|
+
result.error = "ANTHROPIC_API_KEY not set"
|
|
203
|
+
result.wall_seconds = round(time.time() - t0, 2)
|
|
204
|
+
return result
|
|
205
|
+
client = anthropic.Anthropic(api_key=key)
|
|
206
|
+
|
|
207
|
+
system_blocks = _build_system_prompt(condition)
|
|
208
|
+
tool_specs_api = [_spec_to_api(s) for s in tools]
|
|
209
|
+
tool_fn_by_name = {s.name: s.fn for s in tools}
|
|
210
|
+
|
|
211
|
+
# Conversation grows turn by turn — each turn appends an assistant
|
|
212
|
+
# message and (if tool_use) a user message with tool_result blocks.
|
|
213
|
+
messages: list[dict] = [{"role": "user", "content": question_text}]
|
|
214
|
+
last_text = ""
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
for turn in range(max_turns):
|
|
218
|
+
result.turns = turn + 1
|
|
219
|
+
try:
|
|
220
|
+
resp = client.messages.create(
|
|
221
|
+
model=model,
|
|
222
|
+
max_tokens=MAX_TOKENS,
|
|
223
|
+
system=system_blocks,
|
|
224
|
+
tools=tool_specs_api or [],
|
|
225
|
+
messages=messages,
|
|
226
|
+
)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
# Most likely: 429 from a fresh API key, or a transient
|
|
229
|
+
# network blip. Surface as an error result rather than
|
|
230
|
+
# crash the whole suite.
|
|
231
|
+
result.error = f"api error on turn {turn + 1}: {e}"
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
usage = getattr(resp, "usage", None)
|
|
235
|
+
if usage is not None:
|
|
236
|
+
result.total_input_tokens += getattr(usage, "input_tokens", 0) or 0
|
|
237
|
+
result.total_output_tokens += getattr(usage, "output_tokens", 0) or 0
|
|
238
|
+
result.total_cache_read_tokens += (
|
|
239
|
+
getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
240
|
+
)
|
|
241
|
+
result.total_cache_write_tokens += (
|
|
242
|
+
getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
result.stop_reason = getattr(resp, "stop_reason", "") or ""
|
|
246
|
+
content = list(resp.content or [])
|
|
247
|
+
last_text = _collect_text(content)
|
|
248
|
+
|
|
249
|
+
# Echo the assistant message into the conversation regardless
|
|
250
|
+
# of whether we'll continue — the next turn needs it as context.
|
|
251
|
+
messages.append({"role": "assistant", "content": content})
|
|
252
|
+
|
|
253
|
+
# Done if the model either declared end_turn or didn't ask for
|
|
254
|
+
# any tools this turn.
|
|
255
|
+
tool_uses = [b for b in content if getattr(b, "type", None) == "tool_use"]
|
|
256
|
+
if not tool_uses or result.stop_reason in ("end_turn", "stop_sequence"):
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
# Run each tool_use and collect tool_result blocks for the
|
|
260
|
+
# next-turn user message.
|
|
261
|
+
tool_result_blocks = []
|
|
262
|
+
for tu in tool_uses:
|
|
263
|
+
fn = tool_fn_by_name.get(tu.name)
|
|
264
|
+
if fn is None:
|
|
265
|
+
tool_result = {"error": f"unknown tool {tu.name!r}"}
|
|
266
|
+
else:
|
|
267
|
+
try:
|
|
268
|
+
tool_result = fn(dict(tu.input))
|
|
269
|
+
except Exception as e:
|
|
270
|
+
tool_result = {"error": f"tool {tu.name} crashed: {e}"}
|
|
271
|
+
# Trace what the model called for the eval report.
|
|
272
|
+
result.tool_calls.append({
|
|
273
|
+
"turn": turn + 1,
|
|
274
|
+
"name": tu.name,
|
|
275
|
+
"input": dict(tu.input),
|
|
276
|
+
"output_preview": _preview(tool_result),
|
|
277
|
+
})
|
|
278
|
+
tool_result_blocks.append({
|
|
279
|
+
"type": "tool_result",
|
|
280
|
+
"tool_use_id": tu.id,
|
|
281
|
+
"content": _stringify(tool_result),
|
|
282
|
+
})
|
|
283
|
+
messages.append({"role": "user", "content": tool_result_blocks})
|
|
284
|
+
else:
|
|
285
|
+
# max_turns exhausted without an end_turn — keep the last
|
|
286
|
+
# answer text but flag the truncation in stop_reason.
|
|
287
|
+
if not result.stop_reason:
|
|
288
|
+
result.stop_reason = "max_turns_exhausted"
|
|
289
|
+
|
|
290
|
+
result.answer = last_text or ""
|
|
291
|
+
result.cited_urls = _URL_RE.findall(result.answer)
|
|
292
|
+
result.refused = _detect_refusal(result.answer)
|
|
293
|
+
finally:
|
|
294
|
+
result.wall_seconds = round(time.time() - t0, 2)
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _stringify(payload: dict) -> str:
|
|
299
|
+
import json as _json
|
|
300
|
+
try:
|
|
301
|
+
return _json.dumps(payload, default=str)[:6000]
|
|
302
|
+
except (TypeError, ValueError):
|
|
303
|
+
return str(payload)[:6000]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _preview(payload: dict) -> str:
|
|
307
|
+
"""Compact preview of a tool result for the report — strip body content
|
|
308
|
+
so the JSON dump stays readable."""
|
|
309
|
+
out = {k: v for k, v in payload.items() if k != "content"}
|
|
310
|
+
if "content" in payload:
|
|
311
|
+
body = payload["content"]
|
|
312
|
+
out["content_preview"] = (body[:200] + "…") if isinstance(body, str) else "(non-str)"
|
|
313
|
+
import json as _json
|
|
314
|
+
return _json.dumps(out, default=str)[:600]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""LLM judge for agent-loop answers.
|
|
2
|
+
|
|
3
|
+
Scores each ``(question, condition)`` answer on three axes:
|
|
4
|
+
|
|
5
|
+
* **correctness (1-5)** — does the answer match the gold reference?
|
|
6
|
+
1 = wrong / hallucinated, 5 = matches the gold answer in substance.
|
|
7
|
+
* **citation_present (bool)** — did the agent include URLs at all?
|
|
8
|
+
Sniffed by URL regex on the answer body, NOT judged by the model —
|
|
9
|
+
that keeps the rate cheap and deterministic.
|
|
10
|
+
* **citation_faithful (bool)** — for each URL the agent cited, does the
|
|
11
|
+
gold reference list it (or share a host)? Same deterministic check,
|
|
12
|
+
not a judge call.
|
|
13
|
+
|
|
14
|
+
Design choices:
|
|
15
|
+
|
|
16
|
+
* Use ``messages.parse()`` with a Pydantic schema so we get a structured
|
|
17
|
+
int score back without re-prompting. The schema doubles as runtime
|
|
18
|
+
validation when the model returns an out-of-range integer.
|
|
19
|
+
* Cache the rubric on a separate ``system`` block. The rubric is stable
|
|
20
|
+
across all judge calls in one bench run; cache reads should hit on
|
|
21
|
+
every call after the first.
|
|
22
|
+
* The judge sees BOTH the gold answer and the agent's answer, plus the
|
|
23
|
+
question, plus the citations the agent emitted. It does not see the
|
|
24
|
+
full retrieved corpus — that would let it overrule a "wrong but
|
|
25
|
+
confidently-stated" answer based on its own retrieval rather than
|
|
26
|
+
grading against the gold rubric.
|
|
27
|
+
|
|
28
|
+
Rubric below is intentionally short. Long rubrics drift the model toward
|
|
29
|
+
the rubric examples and away from the gold answer; we want the gold
|
|
30
|
+
answer to dominate.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
from pydantic import BaseModel, Field
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
JUDGE_MODEL = "claude-opus-4-7"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
JUDGE_RUBRIC = """You are evaluating a research agent's answer to a single
|
|
46
|
+
question. You have:
|
|
47
|
+
|
|
48
|
+
* the question text
|
|
49
|
+
* a hand-curated gold reference answer (1-3 sentences)
|
|
50
|
+
* the agent's full answer text
|
|
51
|
+
* the URLs the agent cited (may be empty)
|
|
52
|
+
|
|
53
|
+
Score on a 1-5 integer scale:
|
|
54
|
+
|
|
55
|
+
5 — Agent's answer matches the gold answer in substance and key facts.
|
|
56
|
+
Minor stylistic differences are fine.
|
|
57
|
+
4 — Mostly correct but missing one secondary detail, OR adds a small
|
|
58
|
+
irrelevant claim alongside the correct one.
|
|
59
|
+
3 — Partially correct: the main fact is right but a significant detail
|
|
60
|
+
is wrong or absent.
|
|
61
|
+
2 — Largely wrong: the agent named the right topic but the specifics
|
|
62
|
+
contradict the gold answer.
|
|
63
|
+
1 — Wrong, fabricated, or "I don't know" with no useful content.
|
|
64
|
+
|
|
65
|
+
Additional fields:
|
|
66
|
+
|
|
67
|
+
* ``key_fact_match`` — true if the single most specific fact in the
|
|
68
|
+
gold answer (the number, name, header, or rate that anchors it)
|
|
69
|
+
appears correctly in the agent's answer. Helps the report tell apart
|
|
70
|
+
"right vibe, wrong number" from "right number, missing context."
|
|
71
|
+
* ``hallucinated_specifics`` — true if the agent stated a specific
|
|
72
|
+
value (number, date, name) that contradicts the gold answer.
|
|
73
|
+
* ``brief_reason`` — one short sentence explaining the score. Avoid
|
|
74
|
+
repeating the answer back; just say what's right or wrong.
|
|
75
|
+
|
|
76
|
+
Be strict but fair. The gold answer is authoritative — if the agent says
|
|
77
|
+
something the gold answer doesn't support, that's a deduction even if it
|
|
78
|
+
sounds plausible.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class JudgeScore(BaseModel):
|
|
83
|
+
"""Pydantic schema for ``messages.parse`` structured output."""
|
|
84
|
+
correctness: int = Field(..., ge=1, le=5)
|
|
85
|
+
key_fact_match: bool
|
|
86
|
+
hallucinated_specifics: bool
|
|
87
|
+
brief_reason: str = Field(..., max_length=400)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Judgment:
|
|
92
|
+
qid: str
|
|
93
|
+
condition: str
|
|
94
|
+
correctness: int
|
|
95
|
+
key_fact_match: bool
|
|
96
|
+
hallucinated_specifics: bool
|
|
97
|
+
citation_present: bool
|
|
98
|
+
citation_faithful: bool
|
|
99
|
+
brief_reason: str
|
|
100
|
+
judge_input_tokens: int = 0
|
|
101
|
+
judge_output_tokens: int = 0
|
|
102
|
+
judge_cache_read_tokens: int = 0
|
|
103
|
+
judge_cache_write_tokens: int = 0
|
|
104
|
+
judge_latency_sec: float = 0.0
|
|
105
|
+
error: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _citation_metrics(answer_urls: list[str],
|
|
109
|
+
gold_urls: tuple[str, ...]) -> tuple[bool, bool]:
|
|
110
|
+
"""Return ``(citation_present, citation_faithful)``.
|
|
111
|
+
|
|
112
|
+
A citation is faithful when the agent cited a URL whose host appears in
|
|
113
|
+
the gold set, OR whose full URL matches a gold URL. Host-level matching
|
|
114
|
+
is more forgiving than path-equality (the agent may cite a sub-page
|
|
115
|
+
that the gold answer lives within); without it we'd underreport
|
|
116
|
+
faithful citations on sites like docs.python.org where many pages
|
|
117
|
+
answer the same question.
|
|
118
|
+
"""
|
|
119
|
+
if not answer_urls:
|
|
120
|
+
return False, False
|
|
121
|
+
gold_hosts = {u.split("//", 1)[-1].split("/", 1)[0].lower()
|
|
122
|
+
for u in gold_urls}
|
|
123
|
+
gold_exact = set(gold_urls)
|
|
124
|
+
for u in answer_urls:
|
|
125
|
+
if u in gold_exact:
|
|
126
|
+
return True, True
|
|
127
|
+
host = u.split("//", 1)[-1].split("/", 1)[0].lower()
|
|
128
|
+
if host in gold_hosts:
|
|
129
|
+
return True, True
|
|
130
|
+
return True, False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_user_prompt(question: str, gold_answer: str,
|
|
134
|
+
gold_urls: tuple[str, ...],
|
|
135
|
+
agent_answer: str,
|
|
136
|
+
agent_urls: list[str]) -> str:
|
|
137
|
+
gold_list = "\n".join(f" - {u}" for u in gold_urls) or " (none)"
|
|
138
|
+
agent_list = "\n".join(f" - {u}" for u in agent_urls) or " (none)"
|
|
139
|
+
return (
|
|
140
|
+
f"QUESTION:\n{question}\n\n"
|
|
141
|
+
f"GOLD ANSWER:\n{gold_answer}\n\n"
|
|
142
|
+
f"GOLD REFERENCE URLS:\n{gold_list}\n\n"
|
|
143
|
+
f"AGENT ANSWER:\n{agent_answer}\n\n"
|
|
144
|
+
f"AGENT CITED URLS:\n{agent_list}\n\n"
|
|
145
|
+
"Return your judgment in the structured schema."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def judge_answer(
|
|
150
|
+
*,
|
|
151
|
+
qid: str,
|
|
152
|
+
condition: str,
|
|
153
|
+
question: str,
|
|
154
|
+
gold_answer: str,
|
|
155
|
+
gold_urls: tuple[str, ...],
|
|
156
|
+
agent_answer: str,
|
|
157
|
+
agent_urls: list[str],
|
|
158
|
+
model: str = JUDGE_MODEL,
|
|
159
|
+
api_key: Optional[str] = None,
|
|
160
|
+
client=None,
|
|
161
|
+
) -> Judgment:
|
|
162
|
+
"""Score one agent answer. Network-free when ``client`` is injected."""
|
|
163
|
+
t0 = time.time()
|
|
164
|
+
citation_present, citation_faithful = _citation_metrics(
|
|
165
|
+
agent_urls, gold_urls
|
|
166
|
+
)
|
|
167
|
+
judgment = Judgment(
|
|
168
|
+
qid=qid, condition=condition,
|
|
169
|
+
correctness=0,
|
|
170
|
+
key_fact_match=False,
|
|
171
|
+
hallucinated_specifics=False,
|
|
172
|
+
citation_present=citation_present,
|
|
173
|
+
citation_faithful=citation_faithful,
|
|
174
|
+
brief_reason="",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if client is None:
|
|
178
|
+
try:
|
|
179
|
+
import anthropic
|
|
180
|
+
except ImportError as e:
|
|
181
|
+
judgment.error = f"anthropic SDK not installed: {e}"
|
|
182
|
+
judgment.judge_latency_sec = round(time.time() - t0, 2)
|
|
183
|
+
return judgment
|
|
184
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
185
|
+
if not key:
|
|
186
|
+
judgment.error = "ANTHROPIC_API_KEY not set"
|
|
187
|
+
judgment.judge_latency_sec = round(time.time() - t0, 2)
|
|
188
|
+
return judgment
|
|
189
|
+
client = anthropic.Anthropic(api_key=key)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
resp = client.messages.parse(
|
|
193
|
+
model=model,
|
|
194
|
+
max_tokens=1024,
|
|
195
|
+
system=[{
|
|
196
|
+
"type": "text",
|
|
197
|
+
"text": JUDGE_RUBRIC,
|
|
198
|
+
"cache_control": {"type": "ephemeral"},
|
|
199
|
+
}],
|
|
200
|
+
messages=[{
|
|
201
|
+
"role": "user",
|
|
202
|
+
"content": _build_user_prompt(
|
|
203
|
+
question, gold_answer, gold_urls,
|
|
204
|
+
agent_answer, agent_urls,
|
|
205
|
+
),
|
|
206
|
+
}],
|
|
207
|
+
output_format=JudgeScore,
|
|
208
|
+
)
|
|
209
|
+
score: JudgeScore = resp.parsed_output
|
|
210
|
+
usage = getattr(resp, "usage", None)
|
|
211
|
+
if usage is not None:
|
|
212
|
+
judgment.judge_input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
213
|
+
judgment.judge_output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
214
|
+
judgment.judge_cache_read_tokens = (
|
|
215
|
+
getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
216
|
+
)
|
|
217
|
+
judgment.judge_cache_write_tokens = (
|
|
218
|
+
getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
219
|
+
)
|
|
220
|
+
judgment.correctness = score.correctness
|
|
221
|
+
judgment.key_fact_match = score.key_fact_match
|
|
222
|
+
judgment.hallucinated_specifics = score.hallucinated_specifics
|
|
223
|
+
judgment.brief_reason = score.brief_reason
|
|
224
|
+
except Exception as e:
|
|
225
|
+
judgment.error = f"judge call failed: {e}"
|
|
226
|
+
finally:
|
|
227
|
+
judgment.judge_latency_sec = round(time.time() - t0, 2)
|
|
228
|
+
return judgment
|