torchdocs-agent / eval /run_agentic.py
Claude
Make the agentic eval completable: bounded sample + 120m timeout
ee4b6fa unverified
Raw
History Blame Contribute Delete
4.51 kB
"""Agentic benchmark: does the AGENT LOOP assemble complete answers?
Usage: python -m eval.run_agentic (needs NEON_URL + an LLM key)
Catalog / compare / recipe questions (eval/agentic_v1.jsonl) can't be scored
by single-shot retrieval β€” their answer is spread across several pages, which
is exactly what the agent loop (search β†’ plan β†’ search again β†’ answer) is for.
So we score the FINISHED answer's CITATIONS against expected_any: each group
is a source a complete answer must ground on; coverage = matched groups /
groups.
The headline metric is a DELTA, not an absolute: the same questions are also
run through the single-shot grounded path (answer_grounded, one retrieval
pass). agentic_coverage βˆ’ single_shot_coverage is the quantified value of the
loop β€” if the loop assembles more of the catalog than one search, the number
shows it; if not, that's an honest negative result.
No LLM judge: coverage is an objective URL/substring match. Results β†’
eval/results/agentic_v1.jsonl.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
EVAL = Path(__file__).parent
QUESTIONS = EVAL / "agentic_v1.jsonl"
# The full loop runs many LLM calls per question (plan β†’ search β†’ search β†’
# answer) for BOTH the agentic and single-shot paths; on free-tier models the
# 20-question set blows past a CI timeout on rate-limit backoff alone. LIMIT
# runs the first N questions so the loop-vs-single-shot delta is measurable in a
# bounded run β€” raise it (or set 0 for all) once a faster provider is wired.
LIMIT = int(os.environ.get("TORCHDOCS_AGENTIC_LIMIT", "0"))
# a partial run must not overwrite (masquerade as) the full-set results file
RESULTS = EVAL / "results" / (f"agentic_v1_first{LIMIT}.jsonl" if LIMIT else "agentic_v1.jsonl")
def citation_haystacks(answer) -> list[str]:
"""One lowercased url+anchor+title string per citation the answer grounded on."""
return [
f"{c.url} {c.anchor} {c.title}".lower() for c in getattr(answer, "citations", [])
]
def answer_coverage(expected_any: list[list[str]], answer) -> float:
"""Share of expected source-groups that appear in the answer's citations."""
if not expected_any:
return 0.0
hay = citation_haystacks(answer)
matched = 0
for group in expected_any:
if any(sub.lower() in h for sub in group for h in hay):
matched += 1
return matched / len(expected_any)
def main() -> int:
load_dotenv()
from agent.grounded import answer_grounded
from agent.loop import answer_agentic
questions = [json.loads(line) for line in QUESTIONS.open()]
if LIMIT:
questions = questions[:LIMIT]
print(f"(limited to first {LIMIT} of the agentic questions)")
RESULTS.parent.mkdir(exist_ok=True)
records, agentic_cov, single_cov = [], [], []
print(f"{'id':<6}{'kind':<10}{'agentic':<9}{'1-shot':<8}delta")
for q in questions:
exp = q["expected_any"]
try:
a_ans = answer_agentic(q["question"])
s_ans = answer_grounded(q["question"])
except Exception as exc: # noqa: BLE001 β€” record and continue, never lose the run
print(f"{q['id']:<6}{q['kind']:<10}failed: {type(exc).__name__}: {exc}")
records.append({"id": q["id"], "error": str(exc)})
continue
a_c, s_c = answer_coverage(exp, a_ans), answer_coverage(exp, s_ans)
agentic_cov.append(a_c)
single_cov.append(s_c)
delta = a_c - s_c
flag = " βœ…" if delta > 0 else (" ⚠️" if delta < 0 else "")
print(f"{q['id']:<6}{q['kind']:<10}{a_c:<9.2f}{s_c:<8.2f}{delta:+.2f}{flag}")
records.append(
{
"id": q["id"], "kind": q["kind"], "agentic_coverage": a_c,
"single_shot_coverage": s_c,
"citations": [c.url for c in a_ans.citations],
}
)
with RESULTS.open("w") as out:
for r in records:
out.write(json.dumps(r, ensure_ascii=False) + "\n")
if agentic_cov:
n = len(agentic_cov)
ma, ms = sum(agentic_cov) / n, sum(single_cov) / n
print(f"\naggregate over {n}: agentic={ma:.3f} single-shot={ms:.3f} "
f"delta={ma - ms:+.3f}")
print("(delta > 0 means the loop assembled more of the answer than one search)")
print(f"results β†’ {RESULTS}")
return 0
if __name__ == "__main__":
sys.exit(main())