Spaces:
Sleeping
Sleeping
| """Evaluation harness — field-level accuracy and anomaly precision/recall. | |
| Spec §7/§8: *"`eval/` runs the labelled set and prints field-level accuracy — | |
| your resume numbers come from here."* | |
| What it does, in order: | |
| 1. Materialises `eval/testset/` as **real files** (PDFs and degraded scans) with a | |
| `labels.json` of ground truth computed from the generator's specs, never read | |
| back from the rendered document. | |
| 2. Pushes every file through the **real pipeline** in dependency order, so the | |
| duplicate detector sees the same history the product would. | |
| 3. Scores each field with a type-aware comparison — money to the cent, dates as | |
| calendar dates, vendor names normalised the way the duplicate detector | |
| normalises them — and scores anomalies as precision / recall / F1 against the | |
| labels. | |
| Numbers printed here are the numbers that belong on a resume, and the report | |
| records which model produced them so a live figure is never confused with an | |
| offline-baseline one. | |
| python eval/run_eval.py # score the labelled set | |
| python eval/run_eval.py --regenerate # rebuild the documents first | |
| python eval/run_eval.py --json out.json # machine-readable report | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import json | |
| import sys | |
| import time | |
| from dataclasses import dataclass, field | |
| from datetime import UTC, date, datetime | |
| from decimal import Decimal | |
| from pathlib import Path | |
| from typing import Any | |
| _API_ROOT = Path(__file__).resolve().parent.parent / "apps" / "api" | |
| sys.path.insert(0, str(_API_ROOT)) | |
| from sqlalchemy import text # noqa: E402 | |
| from app.core.bootstrap import init_schema # noqa: E402 | |
| from app.core.db import dispose_engine, init_engine, transaction # noqa: E402 | |
| from app.core.logging import configure_logging # noqa: E402 | |
| from app.core.settings import get_settings # noqa: E402 | |
| from app.core.tracing import get_tracer # noqa: E402 | |
| from app.deps import get_claude_client # noqa: E402 | |
| from app.devtools.corpus import build_eval_corpus # noqa: E402 | |
| from app.devtools.documents import degrade_to_scan, render_invoice_pdf # noqa: E402 | |
| from app.models.enums import AnomalyType # noqa: E402 | |
| from app.models.schemas import parse_date, parse_money # noqa: E402 | |
| from app.pipeline.anomaly import normalise_vendor # noqa: E402 | |
| from app.pipeline.orchestrator import PipelineOrchestrator # noqa: E402 | |
| TESTSET_DIR = Path(__file__).resolve().parent / "testset" | |
| RESULTS_DIR = Path(__file__).resolve().parent / "results" | |
| LABELS_PATH = TESTSET_DIR / "labels.json" | |
| SCALAR_FIELDS = ( | |
| "vendor", | |
| "invoice_number", | |
| "issue_date", | |
| "due_date", | |
| "subtotal", | |
| "tax", | |
| "total", | |
| "currency", | |
| "payment_terms", | |
| ) | |
| MONEY_FIELDS = frozenset({"subtotal", "tax", "total"}) | |
| DATE_FIELDS = frozenset({"issue_date", "due_date"}) | |
| _CENT = Decimal("0.01") | |
| # --------------------------------------------------------------------------- | |
| # Scoring | |
| # --------------------------------------------------------------------------- | |
| def _norm_text(value: Any) -> str: | |
| return " ".join(str(value).strip().lower().split()) if value is not None else "" | |
| def field_matches(name: str, expected: Any, observed: Any) -> bool: | |
| """Type-aware comparison. | |
| Money is compared to the cent rather than as a string, so `1200` and | |
| `"1,200.00"` agree. Vendor names go through the same normalisation the | |
| duplicate detector uses, so `GULF METALS L.L.C.` and `Gulf Metals LLC` agree — | |
| marking those wrong would understate accuracy for a difference the product | |
| itself treats as identical. | |
| """ | |
| if expected in (None, "") and observed in (None, ""): | |
| return True | |
| if expected in (None, "") or observed in (None, ""): | |
| return False | |
| if name in MONEY_FIELDS: | |
| left, right = parse_money(expected), parse_money(observed) | |
| if left is None or right is None: | |
| return False | |
| return abs(left - right) <= _CENT | |
| if name in DATE_FIELDS: | |
| left_date, right_date = parse_date(expected), parse_date(observed) | |
| return left_date is not None and left_date == right_date | |
| if name == "vendor": | |
| return normalise_vendor(str(expected)) == normalise_vendor(str(observed)) | |
| if name == "invoice_number": | |
| return _norm_text(expected).replace(" ", "") == _norm_text(observed).replace(" ", "") | |
| return _norm_text(expected) == _norm_text(observed) | |
| def score_line_items(expected: list[dict[str, Any]], observed: list[dict[str, Any]]) -> tuple[int, int]: | |
| """Positionally matched line items: (matched, expected_total). | |
| A line counts only when description, quantity and amount all agree — a | |
| partial row is not a usable row for an accounts-payable team. | |
| """ | |
| matched = 0 | |
| for index, want in enumerate(expected): | |
| if index >= len(observed): | |
| break | |
| got = observed[index] | |
| if ( | |
| _norm_text(want.get("description")) == _norm_text(got.get("description")) | |
| and field_matches("qty_num", parse_money(want.get("qty")), parse_money(got.get("qty"))) | |
| and field_matches("amount", want.get("amount"), got.get("amount")) | |
| ): | |
| matched += 1 | |
| return matched, len(expected) | |
| class FieldTally: | |
| correct: int = 0 | |
| total: int = 0 | |
| def accuracy(self) -> float: | |
| return self.correct / self.total if self.total else 0.0 | |
| class DocumentResult: | |
| filename: str | |
| status: str | |
| latency_ms: int | |
| cost_usd: float | |
| lane: str | None | |
| correct_fields: int | |
| total_fields: int | |
| line_items_matched: int | |
| line_items_expected: int | |
| expected_anomalies: set[str] | |
| observed_anomalies: set[str] | |
| mismatches: list[str] = field(default_factory=list) | |
| unreadable: bool = False | |
| #: "generated" (this repository rendered it) or "real" (it did not). Scored | |
| #: separately, because one number over both would describe neither. | |
| population: str = "generated" | |
| def accuracy(self) -> float: | |
| return self.correct_fields / self.total_fields if self.total_fields else 0.0 | |
| # --------------------------------------------------------------------------- | |
| # Test set materialisation | |
| # --------------------------------------------------------------------------- | |
| def materialise_testset(*, force: bool) -> list[dict[str, Any]]: | |
| """Write the labelled documents to disk and return their manifest.""" | |
| TESTSET_DIR.mkdir(parents=True, exist_ok=True) | |
| corpus = build_eval_corpus() | |
| if LABELS_PATH.exists() and not force: | |
| manifest: list[dict[str, Any]] = json.loads(LABELS_PATH.read_text()) | |
| if all((TESTSET_DIR / entry["filename"]).exists() for entry in manifest): | |
| return manifest | |
| manifest = [] | |
| for item in corpus: | |
| pdf = render_invoice_pdf(item.spec) | |
| payload = degrade_to_scan(pdf) if item.as_scan else pdf | |
| (TESTSET_DIR / item.filename).write_bytes(payload) | |
| manifest.append( | |
| { | |
| "filename": item.filename, | |
| "content_type": "image/jpeg" if item.as_scan else "application/pdf", | |
| "is_scan": item.as_scan, | |
| "expected_anomalies": sorted(str(a) for a in item.expected_anomalies), | |
| "tags": list(item.spec.tags), | |
| "ground_truth": item.spec.ground_truth(), | |
| } | |
| ) | |
| LABELS_PATH.write_text(json.dumps(manifest, indent=2)) | |
| return manifest | |
| PRIVATE_DIR = TESTSET_DIR / "private" | |
| PRIVATE_LABELS = PRIVATE_DIR / "labels.json" | |
| def load_private_manifest() -> list[dict[str, Any]]: | |
| """Real documents, scored from outside version control. | |
| The generated corpus establishes that the pipeline is correct on the format it | |
| was built for. It cannot say anything about invoices this repository did not | |
| author — one renderer, one layout, and the offline parser's patterns written | |
| against the captions that renderer prints. | |
| A real invoice cannot be committed to answer that. It carries a vendor, an | |
| address, a tax number, line items and the recipient's own details, and this | |
| repository is public; `labels.json` would publish the contents in plaintext | |
| even where the file itself is ignored. So real documents live here, outside | |
| git, and are scored as a **separate population**. Averaging them together with | |
| the generated set would produce a single number describing neither. | |
| Absent directory means absent population — `make eval` stays reproducible for | |
| anyone who clones this. | |
| """ | |
| if not PRIVATE_LABELS.exists(): | |
| return [] | |
| entries: list[dict[str, Any]] = json.loads(PRIVATE_LABELS.read_text()) | |
| missing = [e["filename"] for e in entries if not (PRIVATE_DIR / e["filename"]).exists()] | |
| if missing: | |
| raise SystemExit( | |
| f"{PRIVATE_LABELS} references files that are not present: {', '.join(missing)}" | |
| ) | |
| return entries | |
| def _population_scores(results: list[DocumentResult]) -> dict[str, tuple[int, int, int]]: | |
| """(correct_fields, total_fields, documents) per population.""" | |
| scores: dict[str, tuple[int, int, int]] = {} | |
| for result in results: | |
| correct, total, docs = scores.get(result.population, (0, 0, 0)) | |
| scores[result.population] = ( | |
| correct + result.correct_fields, | |
| total + result.total_fields, | |
| docs + 1, | |
| ) | |
| return scores | |
| def _public_document_entries(results: list[DocumentResult]) -> list[dict[str, Any]]: | |
| """Per-document rows for the persisted report. | |
| The report is committed; the private documents deliberately are not. A real | |
| invoice's *filename* can name a supplier, and `mismatches` quote extracted | |
| values verbatim — so for the real population both are withheld and the row | |
| carries scores under a positional alias instead. Nothing is lost in practice: | |
| the console printed the full detail during the run, which is where a mismatch | |
| gets debugged, and that output is not written anywhere. | |
| """ | |
| entries: list[dict[str, Any]] = [] | |
| private_seen = 0 | |
| for result in results: | |
| public = result.population == "generated" | |
| if not public: | |
| private_seen += 1 | |
| entries.append( | |
| { | |
| "filename": result.filename if public else f"private-{private_seen:02d}", | |
| "population": result.population, | |
| "status": result.status, | |
| "lane": result.lane, | |
| "field_accuracy": round(result.accuracy, 4), | |
| "line_items": f"{result.line_items_matched}/{result.line_items_expected}", | |
| "expected_anomalies": sorted(result.expected_anomalies), | |
| "observed_anomalies": sorted(result.observed_anomalies), | |
| "unreadable_in_mode": result.unreadable, | |
| "mismatches": result.mismatches if public else [], | |
| } | |
| ) | |
| return entries | |
| def _blocked_expectations( | |
| results: list[DocumentResult], | |
| manifest: list[dict[str, Any]], | |
| min_history_for_zscore: int, | |
| ) -> dict[str, set[str]]: | |
| """Labelled anomalies whose supporting evidence this run could not read. | |
| Returns `{filename: {anomaly_type, ...}}`. These are excluded from recall so | |
| the metric reflects detector quality rather than which lanes were configured. | |
| """ | |
| # Every scored document, both populations. Keyed on filename because that is | |
| # what a DocumentResult carries; a private entry with a labelled anomaly used | |
| # to miss this map entirely and raise KeyError below. | |
| truth_by_file = {entry["filename"]: entry for entry in manifest} | |
| readable_names = {r.filename for r in results if not r.unreadable} | |
| blocked: dict[str, set[str]] = {} | |
| for position, result in enumerate(results): | |
| if result.unreadable or not result.expected_anomalies: | |
| continue | |
| entry = truth_by_file[result.filename] | |
| vendor = entry["ground_truth"].get("vendor") | |
| priors = [r for r in results[:position]] | |
| for kind in result.expected_anomalies: | |
| if kind == str(AnomalyType.AMOUNT_ZSCORE): | |
| same_vendor_readable = sum( | |
| 1 | |
| for prior in priors | |
| if prior.filename in readable_names | |
| and truth_by_file[prior.filename]["ground_truth"].get("vendor") == vendor | |
| ) | |
| if same_vendor_readable < min_history_for_zscore: | |
| blocked.setdefault(result.filename, set()).add(kind) | |
| elif kind == str(AnomalyType.DUPLICATE): | |
| # The pair is emitted original-then-copy, so the original is the | |
| # most recent readable prior from the same vendor. | |
| original_readable = any( | |
| prior.filename in readable_names | |
| and truth_by_file[prior.filename]["ground_truth"].get("vendor") == vendor | |
| for prior in priors | |
| ) | |
| if not original_readable: | |
| blocked.setdefault(result.filename, set()).add(kind) | |
| return blocked | |
| # --------------------------------------------------------------------------- | |
| # Runner | |
| # --------------------------------------------------------------------------- | |
| async def _reset_ledger() -> None: | |
| async with transaction() as session: | |
| await session.execute( | |
| text( | |
| "TRUNCATE anomalies, extractions, llm_traces, audit_log, failed_jobs, documents " | |
| "RESTART IDENTITY CASCADE" | |
| ) | |
| ) | |
| async def run_eval(*, regenerate: bool, keep_ledger: bool) -> dict[str, Any]: | |
| settings = get_settings() | |
| configure_logging("ERROR") | |
| manifest = materialise_testset(force=regenerate) | |
| private = load_private_manifest() | |
| documents = [(TESTSET_DIR, entry, "generated") for entry in manifest] | |
| documents += [(PRIVATE_DIR, entry, "real") for entry in private] | |
| engine = init_engine(settings) | |
| await init_schema(engine) | |
| if not keep_ledger: | |
| await _reset_ledger() | |
| client = get_claude_client() | |
| orchestrator = PipelineOrchestrator(client=client, settings=settings, tracer=get_tracer()) | |
| banner = f"{len(manifest)} generated" | |
| if private: | |
| banner += f" + {len(private)} real" | |
| print(f"\nLedgerLens evaluation — {banner} labelled documents") | |
| print(f"mode={client.mode} router={settings.model_router} extractor={settings.model_extractor}") | |
| print("=" * 100) | |
| tallies: dict[str, FieldTally] = {name: FieldTally() for name in SCALAR_FIELDS} | |
| results: list[DocumentResult] = [] | |
| started = time.perf_counter() | |
| for source_dir, entry, population in documents: | |
| payload = (source_dir / entry["filename"]).read_bytes() | |
| outcome = await orchestrator.ingest( | |
| data=payload, | |
| filename=entry["filename"], | |
| declared_content_type=entry["content_type"], | |
| ) | |
| await orchestrator.process( | |
| document_id=outcome.document_id, | |
| data=payload, | |
| filename=entry["filename"], | |
| media_type=outcome.media_type, | |
| ) | |
| async with transaction() as session: | |
| row = ( | |
| await session.execute( | |
| text( | |
| "SELECT d.status, d.latency_ms, d.cost_usd, d.lane, " | |
| " e.vendor, e.invoice_number, e.issue_date, e.due_date, e.subtotal, " | |
| " e.tax, e.total, e.currency, e.payment_terms, e.line_items " | |
| "FROM documents d LEFT JOIN extractions e ON e.document_id = d.id " | |
| "WHERE d.id = :id" | |
| ), | |
| {"id": outcome.document_id}, | |
| ) | |
| ).one() | |
| flags = [ | |
| str(value) | |
| for (value,) in ( | |
| await session.execute( | |
| text("SELECT anomaly_type FROM anomalies WHERE document_id = :id"), | |
| {"id": outcome.document_id}, | |
| ) | |
| ).all() | |
| ] | |
| truth = entry["ground_truth"] | |
| mismatches: list[str] = [] | |
| field_scores: list[tuple[str, bool]] = [] | |
| correct = 0 | |
| for name in SCALAR_FIELDS: | |
| expected = truth.get(name) | |
| observed = getattr(row, name, None) | |
| matched = field_matches(name, expected, observed) | |
| if matched: | |
| correct += 1 | |
| else: | |
| mismatches.append(f"{name}: expected {expected!r}, got {observed!r}") | |
| field_scores.append((name, matched)) | |
| matched_lines, expected_lines = score_line_items( | |
| truth.get("line_items", []), list(row.line_items or []) | |
| ) | |
| # A scan has no text layer. Without a vision model there is nothing to | |
| # read, and the pipeline correctly routes it to NEEDS_REVIEW rather than | |
| # inventing fields. Scoring that as an extraction error would blame the | |
| # extractor for a capability the run was not configured to have, so it is | |
| # counted separately and called out explicitly. | |
| unreadable = ( | |
| client.mode == "offline" and bool(entry["is_scan"]) and row.vendor is None | |
| ) | |
| # Tally only documents this run could actually read. | |
| if not unreadable: | |
| for name, matched in field_scores: | |
| tallies[name].total += 1 | |
| if matched: | |
| tallies[name].correct += 1 | |
| results.append( | |
| DocumentResult( | |
| filename=entry["filename"], | |
| status=row.status, | |
| latency_ms=int(row.latency_ms or 0), | |
| cost_usd=float(row.cost_usd or 0), | |
| lane=row.lane, | |
| correct_fields=correct, | |
| total_fields=len(SCALAR_FIELDS), | |
| line_items_matched=matched_lines, | |
| line_items_expected=expected_lines, | |
| expected_anomalies=set(entry["expected_anomalies"]), | |
| observed_anomalies=set(flags), | |
| mismatches=mismatches, | |
| unreadable=unreadable, | |
| population=population, | |
| ) | |
| ) | |
| elapsed = time.perf_counter() - started | |
| # ---- Report --------------------------------------------------------- | |
| print(f"{'document':24} {'status':13} {'lane':7} {'fields':>8} {'lines':>8} {'ms':>6} anomalies") | |
| print("-" * 100) | |
| for result in results: | |
| flags = ",".join(sorted(result.observed_anomalies)) or "-" | |
| print( | |
| f"{result.filename:24} {result.status:13} {result.lane or '-':7} " | |
| f"{result.correct_fields}/{result.total_fields:<6} " | |
| f"{result.line_items_matched}/{result.line_items_expected:<6} " | |
| f"{result.latency_ms:>6} {flags}" | |
| ) | |
| for problem in result.mismatches: | |
| print(f"{'':24} └─ {problem}") | |
| readable = [r for r in results if not r.unreadable] | |
| unreadable = [r for r in results if r.unreadable] | |
| total_fields = sum(r.total_fields for r in readable) | |
| correct_fields = sum(r.correct_fields for r in readable) | |
| total_lines = sum(r.line_items_expected for r in readable) | |
| matched_lines = sum(r.line_items_matched for r in readable) | |
| # Anomaly scoring uses readable documents only, and additionally discounts | |
| # expectations whose *evidence* was unreadable. A duplicate cannot be matched | |
| # against an original the run could not read, and a per-vendor z-score needs a | |
| # minimum number of readable priors before it is even computed. Counting those | |
| # as misses would measure the absent vision model, not the detector. | |
| blocked = _blocked_expectations( | |
| results, manifest + private, settings.min_history_for_zscore | |
| ) | |
| true_positive = sum(len(r.expected_anomalies & r.observed_anomalies) for r in readable) | |
| false_positive = sum(len(r.observed_anomalies - r.expected_anomalies) for r in readable) | |
| false_negative = sum( | |
| len(r.expected_anomalies - r.observed_anomalies - blocked.get(r.filename, set())) | |
| for r in readable | |
| ) | |
| precision = true_positive / (true_positive + false_positive) if (true_positive + false_positive) else 1.0 | |
| recall = true_positive / (true_positive + false_negative) if (true_positive + false_negative) else 1.0 | |
| f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 | |
| blocked_expectations = sum(len(r.expected_anomalies) for r in unreadable) + sum( | |
| len(kinds) for kinds in blocked.values() | |
| ) | |
| latencies = sorted(r.latency_ms for r in results) | |
| p95 = latencies[min(int(len(latencies) * 0.95), len(latencies) - 1)] if latencies else 0 | |
| total_cost = sum(r.cost_usd for r in results) | |
| print("=" * 100) | |
| if unreadable: | |
| names = ", ".join(r.filename for r in unreadable) | |
| print( | |
| f"\n{len(unreadable)} document(s) could not be read in this mode and are" | |
| f" excluded from scoring:\n {names}\n" | |
| " These are scans with no text layer. The pipeline routed them to" | |
| " NEEDS_REVIEW rather than\n inventing fields, which is the correct" | |
| " behaviour. Configure ANTHROPIC_API_KEY to score them\n through the" | |
| " Claude vision lane." | |
| ) | |
| print("\nFIELD-LEVEL ACCURACY" + (f" (over {len(readable)} readable documents)" if unreadable else "")) | |
| for name in SCALAR_FIELDS: | |
| tally = tallies[name] | |
| bar = "#" * int(tally.accuracy * 24) | |
| print(f" {name:16} {tally.accuracy:7.1%} {tally.correct:>2}/{tally.total:<2} {bar}") | |
| overall = correct_fields / total_fields if total_fields else 0.0 | |
| line_accuracy = matched_lines / total_lines if total_lines else 0.0 | |
| print(f" {'-' * 16}") | |
| print(f" {'OVERALL':16} {overall:7.1%} {correct_fields}/{total_fields}") | |
| print(f" {'line items':16} {line_accuracy:7.1%} {matched_lines}/{total_lines}") | |
| populations = _population_scores(readable) | |
| if len(populations) > 1: | |
| # Deliberately not averaged. A combined figure over documents this | |
| # repository rendered and documents it did not would describe neither, and | |
| # the real number is the one worth quoting. | |
| print("\n by population") | |
| for name, (correct, total, docs) in sorted(populations.items()): | |
| share = correct / total if total else 0.0 | |
| print(f" {name:12} {share:7.1%} {correct}/{total} over {docs} document(s)") | |
| print("\nANOMALY DETECTION") | |
| print(f" precision {precision:6.1%} recall {recall:6.1%} F1 {f1:6.1%}") | |
| print( | |
| f" true positives {true_positive}, false positives {false_positive}, " | |
| f"false negatives {false_negative}" | |
| ) | |
| if blocked_expectations: | |
| detail = "; ".join( | |
| f"{name}: {', '.join(sorted(kinds))} (evidence unreadable in this mode)" | |
| for name, kinds in sorted(blocked.items()) | |
| ) | |
| print( | |
| f" {blocked_expectations} labelled anomaly/anomalies were not scorable in this mode" | |
| ) | |
| if detail: | |
| print(f" {detail}") | |
| print("\nCOST & LATENCY") | |
| print(f" documents {len(results)}") | |
| print(f" mean latency {sum(latencies) / len(latencies):.0f} ms" if latencies else "") | |
| print(f" p95 latency {p95} ms") | |
| print(f" total cost ${total_cost:.4f}") | |
| print(f" cost/document ${total_cost / len(results):.5f}" if results else "") | |
| print(f" wall clock {elapsed:.1f}s") | |
| if client.mode == "offline": | |
| print( | |
| "\nNOTE: these are OFFLINE BASELINE numbers from the deterministic\n" | |
| "rule-based extractor. Set ANTHROPIC_API_KEY and re-run to measure the\n" | |
| "Claude vision + tool-use pipeline. Both are recorded in the report." | |
| ) | |
| report: dict[str, Any] = { | |
| "generated_at": datetime.now(UTC).isoformat(), | |
| "mode": client.mode, | |
| "router_model": settings.model_router, | |
| "extractor_model": settings.model_extractor, | |
| "documents": len(results), | |
| "documents_scored": len(readable), | |
| "documents_unreadable_in_mode": [r.filename for r in unreadable], | |
| "populations": { | |
| name: { | |
| "documents": docs, | |
| "correct_fields": correct, | |
| "total_fields": total, | |
| "field_accuracy": round(correct / total, 4) if total else 0.0, | |
| } | |
| for name, (correct, total, docs) in _population_scores(readable).items() | |
| }, | |
| "field_accuracy": {name: round(tallies[name].accuracy, 4) for name in SCALAR_FIELDS}, | |
| "overall_field_accuracy": round(overall, 4), | |
| "line_item_accuracy": round(line_accuracy, 4), | |
| "anomaly": { | |
| # Keyed on filename, so a private document would be named here too. | |
| "blocked_by_mode": { | |
| name: sorted(kinds) | |
| for name, kinds in blocked.items() | |
| if name in {r.filename for r in results if r.population == "generated"} | |
| }, | |
| "blocked_by_mode_private": sum( | |
| len(kinds) | |
| for name, kinds in blocked.items() | |
| if name not in {r.filename for r in results if r.population == "generated"} | |
| ), | |
| "precision": round(precision, 4), | |
| "recall": round(recall, 4), | |
| "f1": round(f1, 4), | |
| "true_positives": true_positive, | |
| "false_positives": false_positive, | |
| "false_negatives": false_negative, | |
| }, | |
| "latency_ms": { | |
| "mean": round(sum(latencies) / len(latencies), 1) if latencies else 0, | |
| "p95": p95, | |
| }, | |
| "cost_usd": { | |
| "total": round(total_cost, 6), | |
| "per_document": round(total_cost / len(results), 6) if results else 0, | |
| }, | |
| "per_document": _public_document_entries(results), | |
| } | |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) | |
| stamped = RESULTS_DIR / f"eval-{client.mode}-{date.today().isoformat()}.json" | |
| stamped.write_text(json.dumps(report, indent=2)) | |
| print(f"\nReport written to {stamped.relative_to(Path.cwd()) if stamped.is_relative_to(Path.cwd()) else stamped}\n") | |
| await dispose_engine() | |
| return report | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description="Score LedgerLens on the labelled test set.") | |
| parser.add_argument("--regenerate", action="store_true", help="Rebuild the documents first.") | |
| parser.add_argument( | |
| "--keep-ledger", | |
| action="store_true", | |
| help="Do not truncate the ledger first (scores against existing history).", | |
| ) | |
| parser.add_argument("--json", type=Path, help="Also write the report to this path.") | |
| args = parser.parse_args() | |
| report = asyncio.run(run_eval(regenerate=args.regenerate, keep_ledger=args.keep_ledger)) | |
| if args.json: | |
| args.json.write_text(json.dumps(report, indent=2)) | |
| # Exit non-zero if the labelled anomalies were not all found: a regression in | |
| # the detector should fail CI, not print a lower number nobody reads. | |
| return 0 if report["anomaly"]["recall"] >= 1.0 else 1 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |