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 +47 -0
- spancheck/_env.py +25 -0
- spancheck/_text.py +29 -0
- spancheck/adapter.py +90 -0
- spancheck/audit.py +135 -0
- spancheck/calibrate.py +98 -0
- spancheck/cli.py +148 -0
- spancheck/core.py +267 -0
- spancheck/demo.py +43 -0
- spancheck/graders.py +127 -0
- spancheck/judge.py +102 -0
- spancheck/prompts/entailment_v1.txt +15 -0
- spancheck/provider.py +32 -0
- spancheck/span.py +126 -0
- spancheck-0.1.0.dist-info/METADATA +180 -0
- spancheck-0.1.0.dist-info/RECORD +20 -0
- spancheck-0.1.0.dist-info/WHEEL +5 -0
- spancheck-0.1.0.dist-info/entry_points.txt +2 -0
- spancheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- spancheck-0.1.0.dist-info/top_level.txt +1 -0
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())
|