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.
Files changed (80) hide show
  1. evals/__init__.py +13 -0
  2. evals/agent_loop/__init__.py +23 -0
  3. evals/agent_loop/agent.py +314 -0
  4. evals/agent_loop/judge.py +228 -0
  5. evals/agent_loop/questions.py +396 -0
  6. evals/agent_loop/report.py +197 -0
  7. evals/agent_loop/runner.py +234 -0
  8. evals/agent_loop/tools.py +384 -0
  9. evals/bench/__init__.py +0 -0
  10. evals/bench/aggregate_report.py +113 -0
  11. evals/bench/drift.py +301 -0
  12. evals/bench/fixtures/__init__.py +0 -0
  13. evals/bench/fixtures/sites.py +440 -0
  14. evals/bench/full_suite.py +380 -0
  15. evals/bench/per_stage/__init__.py +0 -0
  16. evals/bench/per_stage/commit.py +88 -0
  17. evals/bench/per_stage/extract.py +267 -0
  18. evals/bench/per_stage/fetch.py +190 -0
  19. evals/bench/per_stage/mcp.py +83 -0
  20. evals/bench/per_stage/plan.py +186 -0
  21. evals/bench/per_stage/publish.py +61 -0
  22. evals/bench/per_stage/seed.py +189 -0
  23. evals/bench/report.py +167 -0
  24. evals/bench/runner.py +103 -0
  25. evals/bench/scoring/__init__.py +0 -0
  26. evals/bench/scoring/fidelity.py +118 -0
  27. evals/bench/scoring/structural.py +121 -0
  28. evals/bench/scoring/use_case.py +60 -0
  29. evals/cli.py +620 -0
  30. evals/determinism.py +135 -0
  31. evals/efficiency.py +162 -0
  32. evals/facts_coverage.py +198 -0
  33. evals/facts_validation.py +150 -0
  34. evals/llm_judge.py +331 -0
  35. evals/performance.py +217 -0
  36. evals/sampler.py +111 -0
  37. evals/structural.py +237 -0
  38. sift/__init__.py +40 -0
  39. sift/_io.py +82 -0
  40. sift/agent_surface.py +267 -0
  41. sift/browser.py +420 -0
  42. sift/classify.py +164 -0
  43. sift/cli.py +1388 -0
  44. sift/commit.py +165 -0
  45. sift/config.py +302 -0
  46. sift/decide.py +173 -0
  47. sift/extract.py +669 -0
  48. sift/extract_code.py +190 -0
  49. sift/extract_next_state.py +305 -0
  50. sift/extract_strategy.py +143 -0
  51. sift/facts.py +334 -0
  52. sift/fetch.py +459 -0
  53. sift/index_profile.py +50 -0
  54. sift/integrity.py +155 -0
  55. sift/manifest.py +441 -0
  56. sift/mcp_server.py +1889 -0
  57. sift/normalize.py +62 -0
  58. sift/paths.py +136 -0
  59. sift/plan.py +149 -0
  60. sift/publish.py +642 -0
  61. sift/purge.py +58 -0
  62. sift/registry.py +495 -0
  63. sift/sites/__init__.py +232 -0
  64. sift/sites/ato.py +331 -0
  65. sift/sites/augov.py +186 -0
  66. sift/sites/generic.py +14 -0
  67. sift/sites/generic_browser.py +50 -0
  68. sift/sites/mdn.py +128 -0
  69. sift/sites/python_docs.py +134 -0
  70. sift/sites/stripe.py +138 -0
  71. sift/sources/__init__.py +86 -0
  72. sift/sources/firecrawl.py +488 -0
  73. sift/sources/sitemap.py +345 -0
  74. sift/status.py +141 -0
  75. sift_engine-0.1.0.dist-info/METADATA +342 -0
  76. sift_engine-0.1.0.dist-info/RECORD +80 -0
  77. sift_engine-0.1.0.dist-info/WHEEL +5 -0
  78. sift_engine-0.1.0.dist-info/entry_points.txt +4 -0
  79. sift_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  80. 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