spancheck 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.
spancheck/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """spancheck — score a grounded-answer system on citation accuracy, abstention correctness,
2
+ hallucination rate, cost and latency, and emit an audit log a compliance reviewer can read.
3
+
4
+ Public API (Phase 1 — the ported measurement core + the thin adapter contract):
5
+
6
+ from spancheck import Case, evaluate, adapter, Run, Scorecard, gate, diff
7
+
8
+ Deterministic graders live in `spancheck.graders`. Citation-span verification (the capability this build exists to
9
+ add) lands in Phase 2; the versioned audit log in Phase 3; the calibrated LLM-judge in Phase 4. See TODO.md.
10
+
11
+ Nothing here hard-codes a model provider or depends on any other project's internals (CLAUDE.md design pins).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from .adapter import Output, adapter, normalize
16
+ from .core import (
17
+ Case,
18
+ GradeResult,
19
+ CaseResult,
20
+ Run,
21
+ Scorecard,
22
+ evaluate,
23
+ run_eval,
24
+ diff,
25
+ gate,
26
+ default_graders,
27
+ )
28
+ from .span import citation_accuracy, verify_citation, CitationVerdict
29
+ from .audit import build_audit_log, cost_latency, SCHEMA_VERSION
30
+ from .judge import judge_entailment, judge_support, llm_judge, load_prompt
31
+ from .calibrate import calibrate_judge
32
+ from . import graders
33
+
34
+ __version__ = "0.0.1"
35
+
36
+ __all__ = [
37
+ # the declared public surface
38
+ "Case", "evaluate", "adapter", "Run", "Scorecard", "gate", "diff",
39
+ # citation-span verification (the capability spancheck is named for)
40
+ "citation_accuracy", "verify_citation", "CitationVerdict",
41
+ # cost/latency + the versioned audit log
42
+ "build_audit_log", "cost_latency", "SCHEMA_VERSION",
43
+ # the calibrated LLM-judge (opt-in; stub by default, no provider lock-in)
44
+ "judge_entailment", "judge_support", "llm_judge", "load_prompt", "calibrate_judge",
45
+ # supporting types + the grader library
46
+ "Output", "normalize", "GradeResult", "CaseResult", "run_eval", "default_graders", "graders",
47
+ ]
spancheck/_env.py ADDED
@@ -0,0 +1,25 @@
1
+ """Tiny, dependency-free .env loader.
2
+
3
+ Reads KEY=VALUE lines from a .env file into os.environ **without overwriting** anything already set (a real
4
+ environment variable always wins). Called at CLI/calibration startup so a key placed in `.env` "just works" — no
5
+ python-dotenv dependency, no vendor lock-in. `.env` is gitignored; a key never enters the repo.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+
12
+ def load_dotenv(path: str = ".env") -> None:
13
+ try:
14
+ with open(path, encoding="utf-8") as f:
15
+ for line in f:
16
+ line = line.strip()
17
+ if not line or line.startswith("#") or "=" not in line:
18
+ continue
19
+ key, _, value = line.partition("=")
20
+ key = key.strip()
21
+ value = value.strip().strip('"').strip("'")
22
+ if key and key not in os.environ:
23
+ os.environ[key] = value
24
+ except FileNotFoundError:
25
+ pass
spancheck/_text.py ADDED
@@ -0,0 +1,29 @@
1
+ """Shared text helpers used by graders and the citation-span verifier.
2
+
3
+ Kept in one place so the deterministic checks agree on what a "word" is and how text is normalised.
4
+ Stdlib only.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ WORD = re.compile(r"[a-z0-9]+")
11
+
12
+ STOP = set("the a an and or of to in on for is are was were be been it its this that with as at by from "
13
+ "your you our we i they he she them his her their not no do does did have has had will would can "
14
+ "could should may might must shall into over under about than then so if but".split())
15
+
16
+
17
+ def norm(s) -> str:
18
+ """Lowercase and collapse all whitespace to single spaces — the normalisation used for span provenance."""
19
+ return re.sub(r"\s+", " ", (s or "")).strip().lower()
20
+
21
+
22
+ def words(s) -> set:
23
+ """All word tokens (lowercased) — used as the haystack when checking coverage."""
24
+ return set(WORD.findall((s or "").lower()))
25
+
26
+
27
+ def content_words(s):
28
+ """Meaningful tokens: lowercased words that aren't stopwords and are longer than two characters."""
29
+ return [w for w in WORD.findall((s or "").lower()) if w not in STOP and len(w) > 2]
spancheck/adapter.py ADDED
@@ -0,0 +1,90 @@
1
+ """The thin adapter contract — the seam between spancheck and any system under test.
2
+
3
+ Design pin #2 (no provider lock-in) and #3 (every metric computable offline from a captured run) both live here:
4
+ a system's answer is normalised **once** into a typed `Output` that carries everything the metrics need
5
+ (the answer, the retrieved contexts, the citations, token/cost usage, and latency), so scoring never has to
6
+ call the system again.
7
+
8
+ `spancheck` never hard-codes a vendor. A caller supplies a plain `input -> answer` callable; `adapter()` wraps it,
9
+ times it, and normalises whatever it returns. The return may be:
10
+
11
+ - a **str** — just the answer text;
12
+ - a **dict** — with any of `answer`/`text`, `contexts`/`context`, `citations`, `usage`, `latency_ms`, `abstained`;
13
+ - an **Output** — already normalised (idempotent).
14
+
15
+ Rewritten from evallab's loose "str-or-dict" handling into a pinned dataclass so cost/latency and citations are
16
+ first-class and always present (see LINEAGE.md, DECISIONS.md AB-DEC 006).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from dataclasses import dataclass, field, asdict
22
+
23
+
24
+ @dataclass
25
+ class Output:
26
+ """The normalised result of one system call — the single shape every grader reads.
27
+
28
+ `citations` is carried through as-is in Phase 1; its shape is firmed up by the citation-span verifier
29
+ (Phase 2). `abstained` is an explicit signal when the system provides one; when it is None the graders
30
+ infer abstention from the answer text.
31
+ """
32
+ answer: str = ""
33
+ contexts: list = field(default_factory=list) # retrieved context passages the answer should be grounded in
34
+ citations: list = field(default_factory=list) # citations backing the answer (span shape fixed in Phase 2)
35
+ usage: dict = field(default_factory=dict) # e.g. {"input_tokens", "output_tokens", "cost_usd"}
36
+ latency_ms: float | None = None # wall-clock for the call; filled by adapter/evaluate if absent
37
+ abstained: bool | None = None # explicit abstention signal, if the system emits one
38
+ raw: object = None # the untouched original return, for the audit log
39
+
40
+ def to_dict(self) -> dict:
41
+ d = asdict(self)
42
+ # `raw` may not be JSON-serialisable; keep a string fallback for the audit log rather than crash.
43
+ try:
44
+ import json
45
+ json.dumps(d["raw"])
46
+ except (TypeError, ValueError):
47
+ d["raw"] = repr(self.raw)
48
+ return d
49
+
50
+
51
+ def normalize(value) -> Output:
52
+ """Coerce a system's return (str | dict | Output) into an Output. Idempotent on Output."""
53
+ if isinstance(value, Output):
54
+ return value
55
+ if isinstance(value, dict):
56
+ answer = value.get("answer", value.get("text", ""))
57
+ ctx = value.get("contexts", value.get("context", []))
58
+ if not isinstance(ctx, list):
59
+ ctx = [ctx] if ctx else []
60
+ citations = value.get("citations", [])
61
+ if not isinstance(citations, list):
62
+ citations = [citations]
63
+ return Output(
64
+ answer="" if answer is None else str(answer),
65
+ contexts=[str(c) for c in ctx],
66
+ citations=citations,
67
+ usage=dict(value.get("usage", {}) or {}),
68
+ latency_ms=value.get("latency_ms"),
69
+ abstained=value.get("abstained"),
70
+ raw=value,
71
+ )
72
+ # a bare string (or anything else) is just the answer text
73
+ return Output(answer="" if value is None else str(value), raw=value)
74
+
75
+
76
+ def adapter(fn, *, capture_time: bool = True):
77
+ """Wrap a caller's `input -> result` callable so it returns a normalised, timed `Output`.
78
+
79
+ Using `adapter()` is optional — `evaluate()` normalises and times its system either way — but it lets a caller
80
+ attach usage/citations at the source and hand `evaluate` a system that already speaks the contract.
81
+ """
82
+ def run(inp) -> Output:
83
+ start = time.perf_counter()
84
+ result = fn(inp)
85
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
86
+ out = normalize(result)
87
+ if capture_time and out.latency_ms is None:
88
+ out.latency_ms = round(elapsed_ms, 3)
89
+ return out
90
+ return run
spancheck/audit.py ADDED
@@ -0,0 +1,135 @@
1
+ """Cost/latency aggregation and the versioned, compliance-readable audit log (Phase 3).
2
+
3
+ Everything here is computed **offline** from a captured run — no system call, no network (design pin #3). The audit
4
+ log is a contract with a reviewer: its shape is versioned (`schema_version`), and a breaking change to it is a major
5
+ version bump (design pin #4). The schema is documented in docs/AUDIT-LOG.md.
6
+
7
+ Stdlib only.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from datetime import datetime, timezone
13
+
14
+ from .adapter import Output
15
+ from .span import verify_citation
16
+
17
+ # Bump the MAJOR component only on a breaking change to the audit-log shape (design pin #4).
18
+ SCHEMA_VERSION = "1.0"
19
+
20
+
21
+ def _percentile(sorted_vals, pct):
22
+ """Nearest-rank percentile on an already-sorted list. Handles n=1. pct in [0,100]."""
23
+ if not sorted_vals:
24
+ return None
25
+ if len(sorted_vals) == 1:
26
+ return sorted_vals[0]
27
+ k = max(0, min(len(sorted_vals) - 1, round((pct / 100.0) * (len(sorted_vals) - 1))))
28
+ return sorted_vals[k]
29
+
30
+
31
+ def cost_latency(run, pricing=None):
32
+ """Aggregate cost and latency from a run's captured outputs.
33
+
34
+ latency: total / mean / p50 / p95 / max (ms), over cases that recorded a latency.
35
+ cost: summed input/output/total tokens and any explicit `cost_usd` in usage. If `pricing`
36
+ ({"input_per_1k", "output_per_1k"}) is given, a dollar cost is computed from tokens; otherwise
37
+ `cost_usd` is only the sum of explicit per-call costs (or None if none were provided).
38
+ """
39
+ lats, in_tok, out_tok, tot_tok, explicit_cost = [], 0, 0, 0, []
40
+ for cr in run.results:
41
+ out = cr.output
42
+ if not isinstance(out, Output):
43
+ continue
44
+ if out.latency_ms is not None:
45
+ lats.append(out.latency_ms)
46
+ u = out.usage or {}
47
+ in_tok += int(u.get("input_tokens", 0) or 0)
48
+ out_tok += int(u.get("output_tokens", 0) or 0)
49
+ tot_tok += int(u.get("total_tokens", 0) or (int(u.get("input_tokens", 0) or 0) + int(u.get("output_tokens", 0) or 0)))
50
+ if "cost_usd" in u and u["cost_usd"] is not None:
51
+ explicit_cost.append(float(u["cost_usd"]))
52
+
53
+ lats_sorted = sorted(lats)
54
+ latency = {
55
+ "n": len(lats),
56
+ "total_ms": round(sum(lats), 3) if lats else None,
57
+ "mean_ms": round(sum(lats) / len(lats), 3) if lats else None,
58
+ "p50_ms": _percentile(lats_sorted, 50),
59
+ "p95_ms": _percentile(lats_sorted, 95),
60
+ "max_ms": max(lats) if lats else None,
61
+ }
62
+
63
+ cost_usd = None
64
+ if pricing:
65
+ cost_usd = round(
66
+ (in_tok / 1000.0) * float(pricing.get("input_per_1k", 0.0))
67
+ + (out_tok / 1000.0) * float(pricing.get("output_per_1k", 0.0)),
68
+ 6,
69
+ )
70
+ elif explicit_cost:
71
+ cost_usd = round(sum(explicit_cost), 6)
72
+
73
+ cost = {
74
+ "input_tokens": in_tok,
75
+ "output_tokens": out_tok,
76
+ "total_tokens": tot_tok,
77
+ "cost_usd": cost_usd, # None when no pricing and no explicit cost was supplied (not invented)
78
+ "pricing_applied": bool(pricing),
79
+ }
80
+ return {"latency": latency, "cost": cost}
81
+
82
+
83
+ def _citation_verdicts(out: Output):
84
+ """Per-citation verdicts recomputed from the cached output — the 'why' a reviewer needs."""
85
+ verdicts = []
86
+ for c in (out.citations or []):
87
+ v = verify_citation(c, out.contexts, out.answer)
88
+ verdicts.append({
89
+ "span": v.span, "span_found": v.span_found, "claim_supported": v.claim_supported,
90
+ "ok": v.ok, "support": v.support, "reason": v.reason,
91
+ })
92
+ return verdicts
93
+
94
+
95
+ def build_audit_log(run, path=None, *, pricing=None, verify_citations=True, spancheck_version=None):
96
+ """Build the versioned audit log (a superset of Run.to_dict). Writes JSON to `path` if given; returns the dict."""
97
+ if spancheck_version is None:
98
+ from . import __version__ as spancheck_version
99
+
100
+ sc = run.scorecard()
101
+ cases = []
102
+ for cr in run.results:
103
+ out = cr.output
104
+ entry = {
105
+ "case_id": cr.case_id,
106
+ "category": cr.category,
107
+ "input": cr.input,
108
+ "expected": cr.expected,
109
+ "meta": cr.meta,
110
+ "error": cr.error,
111
+ "output": cr.output_dict(),
112
+ "grades": [{"grader": g.grader, "score": g.score, "passed": g.passed, "detail": g.detail}
113
+ for g in cr.grades],
114
+ "citations": _citation_verdicts(out) if (verify_citations and isinstance(out, Output)) else [],
115
+ }
116
+ cases.append(entry)
117
+
118
+ doc = {
119
+ "schema_version": SCHEMA_VERSION,
120
+ "spancheck_version": spancheck_version,
121
+ "created_at": datetime.now(timezone.utc).isoformat(),
122
+ "summary": {
123
+ "n_cases": len(run.results),
124
+ "overall_pass_rate": sc.overall_pass_rate(),
125
+ "by_grader": sc.by_grader(),
126
+ "by_category": sc.by_category(),
127
+ **cost_latency(run, pricing=pricing),
128
+ },
129
+ "cases": cases,
130
+ }
131
+
132
+ if path is not None:
133
+ with open(path, "w", encoding="utf-8") as f:
134
+ json.dump(doc, f, indent=2)
135
+ return doc
spancheck/calibrate.py ADDED
@@ -0,0 +1,98 @@
1
+ """Calibrate the LLM-judge against a human-labelled gold set — a MEASURED agreement number, not an asserted one.
2
+
3
+ The judge is only trusted after this reports how often it agrees with human labels (AB-DEC 009). With no provider the
4
+ stub is measured (a deterministic baseline that catches negation but misses paraphrase — visible in the confusion
5
+ counts). The real number comes from a keyed run:
6
+
7
+ python -m spancheck.calibrate --real --model claude-sonnet-5 # needs ANTHROPIC_API_KEY
8
+
9
+ Stdlib only. The gold set (calibration/entailment_gold.jsonl) is synthetic and public.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ from .judge import judge_entailment
16
+
17
+
18
+ def calibrate_judge(gold, provider=None, prompt_version="entailment_v1", threshold=0.5, model=None):
19
+ """Run the judge over labelled (claim, span, label) examples; return agreement + confusion + per-row detail."""
20
+ tp = fp = tn = fn = correct = 0
21
+ rows = []
22
+ for ex in gold:
23
+ v = judge_entailment(ex["claim"], ex["span"], provider=provider, prompt_version=prompt_version, model=model)
24
+ pred = v["score"] >= threshold
25
+ label = bool(ex["label"])
26
+ ok = pred == label
27
+ correct += int(ok)
28
+ if pred and label:
29
+ tp += 1
30
+ elif pred and not label:
31
+ fp += 1
32
+ elif not pred and not label:
33
+ tn += 1
34
+ else:
35
+ fn += 1
36
+ rows.append({"claim": ex["claim"], "span": ex["span"], "label": label,
37
+ "score": v["score"], "pred": pred, "correct": ok, "reason": v["reason"]})
38
+ n = len(gold)
39
+ return {
40
+ "n": n,
41
+ "agreement": round(correct / n, 3) if n else 0.0,
42
+ "threshold": threshold,
43
+ "confusion": {"tp": tp, "fp": fp, "tn": tn, "fn": fn},
44
+ "model": model or ("stub" if provider is None else getattr(provider, "model", "unknown")),
45
+ "prompt_version": prompt_version,
46
+ "rows": rows,
47
+ }
48
+
49
+
50
+ def load_gold(path):
51
+ with open(path, encoding="utf-8") as f:
52
+ return [json.loads(line) for line in f if line.strip()]
53
+
54
+
55
+ def main(argv=None) -> int:
56
+ import argparse
57
+ import sys
58
+ try:
59
+ sys.stdout.reconfigure(encoding="utf-8")
60
+ except Exception:
61
+ pass
62
+
63
+ p = argparse.ArgumentParser(prog="spancheck.calibrate",
64
+ description="Measure judge-vs-human agreement on a gold set.")
65
+ p.add_argument("--gold", default="calibration/entailment_gold.jsonl")
66
+ p.add_argument("--threshold", type=float, default=0.5)
67
+ p.add_argument("--real", action="store_true", help="use the Anthropic provider (needs ANTHROPIC_API_KEY)")
68
+ p.add_argument("--model", default="claude-sonnet-5")
69
+ a = p.parse_args(argv)
70
+
71
+ from ._env import load_dotenv
72
+ load_dotenv() # pick up ANTHROPIC_API_KEY from a local .env if present (never overwrites a real env var)
73
+
74
+ gold = load_gold(a.gold)
75
+ provider = None
76
+ model = None
77
+ if a.real:
78
+ from .provider import anthropic_provider
79
+ provider = anthropic_provider(model=a.model)
80
+ model = a.model
81
+
82
+ r = calibrate_judge(gold, provider=provider, threshold=a.threshold, model=model)
83
+ print(f"judge calibration — {r['model']} vs {r['n']} human-labelled cases (prompt {r['prompt_version']})")
84
+ print(f" agreement: {r['agreement']} (threshold {r['threshold']})")
85
+ c = r["confusion"]
86
+ print(f" confusion: tp={c['tp']} fp={c['fp']} tn={c['tn']} fn={c['fn']}")
87
+ for row in r["rows"]:
88
+ mark = "ok" if row["correct"] else "XX"
89
+ print(f" [{mark}] label={str(row['label']):5} pred={str(row['pred']):5} score={row['score']:<5} "
90
+ f"{row['claim'][:44]!r} ⟵ {row['span'][:44]!r}")
91
+ if not a.real:
92
+ print("\n(stub judge — a deterministic offline baseline. For the real agreement number: "
93
+ "python -m spancheck.calibrate --real)")
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())
spancheck/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """spancheck CLI — a thin wrapper over the library (design pin #1: library-first, CLI-second).
2
+
3
+ Three commands, each a few lines over the Python API:
4
+
5
+ spancheck run cases.jsonl --target module:callable --out audit.json # run a system, write an audit log
6
+ spancheck score audit.json # recompute metrics from a cached run
7
+ spancheck gate audit.json citation_accuracy=1.0 [--baseline base.json] # pass/fail for CI (exit 1 on fail)
8
+
9
+ `run` and `score` never touch the network beyond whatever the target itself does; `score` and `gate` are fully offline.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import importlib
15
+ import json
16
+ import sys
17
+
18
+ from . import __version__
19
+ from .core import Case, Run, evaluate, gate, default_graders
20
+
21
+
22
+ def _load_cases(path):
23
+ cases = []
24
+ with open(path, "r", encoding="utf-8") as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if not line:
28
+ continue
29
+ d = json.loads(line)
30
+ cases.append(Case(d.get("id") or d.get("case_id"), d.get("input"),
31
+ d.get("category", "default"), d.get("expected"), d.get("meta") or {}))
32
+ return cases
33
+
34
+
35
+ def _load_target(spec):
36
+ """Load a system callable from a 'module:attribute' spec (imports arbitrary user code, like a test runner)."""
37
+ if ":" not in spec:
38
+ raise SystemExit(f"--target must be 'module:callable', got {spec!r}")
39
+ mod_name, attr = spec.split(":", 1)
40
+ obj = importlib.import_module(mod_name)
41
+ for part in attr.split("."):
42
+ obj = getattr(obj, part)
43
+ return obj
44
+
45
+
46
+ def _pricing(a):
47
+ if getattr(a, "price_in", None) is None and getattr(a, "price_out", None) is None:
48
+ return None
49
+ return {"input_per_1k": a.price_in or 0.0, "output_per_1k": a.price_out or 0.0}
50
+
51
+
52
+ def _print_summary(doc):
53
+ s = doc["summary"]
54
+ print(f"cases: {s['n_cases']} overall pass rate: {s['overall_pass_rate']}")
55
+ for g, v in s["by_grader"].items():
56
+ print(f" {g:<20} pass_rate={v['pass_rate']} score={v['score']} n={v['n']}")
57
+ lat, cost = s["latency"], s["cost"]
58
+ print(f" latency: mean={lat['mean_ms']}ms p95={lat['p95_ms']}ms "
59
+ f"tokens={cost['total_tokens']} cost_usd={cost['cost_usd']}")
60
+
61
+
62
+ def cmd_run(a):
63
+ cases = _load_cases(a.cases)
64
+ system = _load_target(a.target)
65
+ run = evaluate(cases, system)
66
+ doc = run.audit_log(a.out, pricing=_pricing(a))
67
+ print(f"wrote {a.out}")
68
+ _print_summary(doc)
69
+ return 0
70
+
71
+
72
+ def cmd_score(a):
73
+ run = Run.from_audit_log(a.audit)
74
+ if a.regrade:
75
+ run = run.rescore(default_graders()) # re-grade cached outputs offline, no system call
76
+ _print_summary(run.audit_log(pricing=_pricing(a)))
77
+ return 0
78
+
79
+
80
+ def cmd_gate(a):
81
+ run = Run.from_audit_log(a.audit)
82
+ thresholds = {}
83
+ for t in a.thresholds:
84
+ k, _, v = t.partition("=")
85
+ if not _:
86
+ raise SystemExit(f"threshold must be 'grader=min_pass_rate', got {t!r}")
87
+ thresholds[k] = float(v)
88
+ baseline = Run.from_audit_log(a.baseline) if a.baseline else None
89
+ ok, reasons = gate(run, thresholds, baseline=baseline, max_regression=a.max_regression)
90
+ if ok:
91
+ print("gate: PASS")
92
+ else:
93
+ print("gate: FAIL")
94
+ for r in reasons:
95
+ print(" - " + r)
96
+ return 0 if ok else 1
97
+
98
+
99
+ def _build_parser() -> argparse.ArgumentParser:
100
+ p = argparse.ArgumentParser(
101
+ prog="spancheck",
102
+ description="Score a grounded-answer system on citation accuracy, abstention correctness, "
103
+ "hallucination rate, cost and latency — with a compliance-readable audit log.",
104
+ )
105
+ p.add_argument("--version", action="version", version=f"spancheck {__version__}")
106
+ sub = p.add_subparsers(dest="command")
107
+
108
+ run = sub.add_parser("run", help="run a test set against a target and write an audit log")
109
+ run.add_argument("cases", help="path to the cases file (JSONL: one {id,input,category?,expected?,meta?} per line)")
110
+ run.add_argument("--target", required=True, help="module:callable adapter for the system under test")
111
+ run.add_argument("--out", default="audit.json", help="where to write the audit log")
112
+ run.add_argument("--price-in", type=float, default=None, help="USD per 1k input tokens (enables cost)")
113
+ run.add_argument("--price-out", type=float, default=None, help="USD per 1k output tokens (enables cost)")
114
+ run.set_defaults(func=cmd_run)
115
+
116
+ score = sub.add_parser("score", help="recompute metrics from a cached audit log (no network)")
117
+ score.add_argument("audit", help="path to a captured audit log")
118
+ score.add_argument("--regrade", action="store_true", help="re-run the default graders on the cached outputs")
119
+ score.add_argument("--price-in", type=float, default=None)
120
+ score.add_argument("--price-out", type=float, default=None)
121
+ score.set_defaults(func=cmd_score)
122
+
123
+ g = sub.add_parser("gate", help="pass/fail a run against thresholds (exit 1 on fail; for CI)")
124
+ g.add_argument("audit", help="path to a captured audit log")
125
+ g.add_argument("thresholds", nargs="*", help="grader=min_pass_rate (e.g. citation_accuracy=1.0 groundedness=0.9)")
126
+ g.add_argument("--baseline", default="", help="optional baseline audit log for regression gating")
127
+ g.add_argument("--max-regression", type=float, default=0.0, help="max allowed pass_rate drop vs baseline")
128
+ g.set_defaults(func=cmd_gate)
129
+ return p
130
+
131
+
132
+ def main(argv=None) -> int:
133
+ try:
134
+ sys.stdout.reconfigure(encoding="utf-8")
135
+ except Exception:
136
+ pass
137
+ from ._env import load_dotenv
138
+ load_dotenv() # a target that calls a model can read its key from a local .env (never overwrites a real env var)
139
+ parser = _build_parser()
140
+ args = parser.parse_args(argv)
141
+ if not getattr(args, "command", None):
142
+ parser.print_help()
143
+ return 0
144
+ return args.func(args)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())