ERP-DocIQ / backend /evals /run.py
kenmandal's picture
Deploy latest: ERP DocIQ NLQ chatbot + reasoning models (MiniCPM3-4B/Command R7B) + ERP fine-tuning + extreme OCR docs
082d661 verified
Raw
History Blame Contribute Delete
5.78 kB
"""Eval runner.
Discovers <id>.gt.json files, runs the IDP pipeline on each paired document,
scores the prediction, and prints a per-type/per-difficulty report. Also writes
backend/evals/report.json and records rows in the metrics DB (mode='eval') so the
dashboard's Evals tab renders the same numbers.
Usage:
python -m evals.run # full suite (configured router)
python -m evals.run --type invoice # filter by doc type
python -m evals.run --policy offline # force a routing policy
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# allow `python -m evals.run` from backend/ and `python evals/run.py`
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.config import get_settings # noqa: E402
from app.metrics import MetricsStore # noqa: E402
from app.pipeline import process_document # noqa: E402
from app.providers import build_registry # noqa: E402
from app.router import ModelRouter # noqa: E402
from evals import scorers # noqa: E402
DOC_EXTS = (".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff")
def discover(dataset_dir: Path, type_filter: str | None) -> list[tuple[Path, dict]]:
out = []
for gt_path in sorted(dataset_dir.glob("*.gt.json")):
gt = json.loads(gt_path.read_text())
if gt.get("_meta", {}).get("skip_eval"):
continue # showcase-only docs (e.g. the complex form) aren't scored here
if type_filter and gt.get("doc_type") != type_filter:
continue
stem = gt_path.name[: -len(".gt.json")]
doc = None
for ext in DOC_EXTS:
cand = dataset_dir / f"{stem}{ext}"
if cand.exists():
doc = cand
break
if doc is None:
print(f" ! no document file for {stem}, skipping")
continue
out.append((doc, gt))
return out
def run_suite(type_filter: str | None = None, policy: str | None = None) -> dict:
settings = get_settings()
if policy:
settings.routing_policy = policy
registry = build_registry(settings)
metrics = MetricsStore(settings.metrics_db_path)
router = ModelRouter(registry, settings, metrics)
cases = discover(settings.evals_dataset_dir, type_filter)
if not cases:
print("No eval cases found. Run: python scripts/generate_samples.py")
return {}
results = []
for doc_path, gt in cases:
meta = gt.get("_meta", {})
clean_gt = {k: v for k, v in gt.items() if not k.startswith("_")}
run = process_document(
doc_path, router=router, settings=settings, metrics=metrics,
doc_id=doc_path.stem, channel=meta.get("channel"),
difficulty=meta.get("difficulty"), mode="eval",
# let the classifier do its job; do NOT force the type (we score it)
)
pred = run["_state"]["extracted"] or {}
score = scorers.score_document(pred, clean_gt)
results.append({
"doc_id": doc_path.stem,
"predicted_type": run["_state"]["doc_type"],
"difficulty": meta.get("difficulty", "n/a"),
"channel": meta.get("channel", "n/a"),
"confidence": run["_state"]["confidence"],
"requires_review": run["_state"]["requires_review"],
"cost_usd": run["total_cost_usd"],
"score": score,
})
agg = scorers.aggregate(results)
report = {"aggregate": agg, "documents": results,
"routing_policy": settings.routing_policy,
"active_tier": registry.capabilities()["active_tier"]}
# Write to a writable location (committed copy locally, /tmp on serverless).
for out_path in (settings.eval_report_committed, settings.eval_report_writable):
try:
out_path.write_text(json.dumps(report, indent=2))
break
except OSError:
continue
return report
def _print(report: dict) -> None:
if not report:
return
agg = report["aggregate"]
o = agg["overall"]
print("\n" + "=" * 64)
print(f" IDP EVAL REPORT (tier={report['active_tier']}, policy={report['routing_policy']})")
print("=" * 64)
print(f" documents: {o['documents']}")
print(f" doc-type accuracy: {_pct(o['doc_type_accuracy'])}")
print(f" field exact-match: {_pct(o['exact_match'])}")
print(f" field F1: {_pct(o['field_f1'])}")
print(f" line-item F1: {_pct(o['line_item_f1'])}")
print(f" financial consistency:{_pct(o['financial_consistency_rate'])}")
print("-" * 64)
print(f" {'by type':<18}{'docs':>5}{'exact':>9}{'F1':>9}{'fin-ok':>9}")
for t, g in agg["by_type"].items():
print(f" {t:<18}{g['documents']:>5}{_pct(g['exact_match']):>9}"
f"{_pct(g['field_f1']):>9}{_pct(g['financial_consistency_rate']):>9}")
print("-" * 64)
print(f" {'by difficulty':<18}{'docs':>5}{'exact':>9}{'F1':>9}{'fin-ok':>9}")
for d, g in agg["by_difficulty"].items():
print(f" {d:<18}{g['documents']:>5}{_pct(g['exact_match']):>9}"
f"{_pct(g['field_f1']):>9}{_pct(g['financial_consistency_rate']):>9}")
print("=" * 64)
print(f" report → backend/evals/report.json\n")
def _pct(v) -> str:
return "n/a" if v is None else f"{v*100:.1f}%"
def main() -> None:
ap = argparse.ArgumentParser(description="Run the IDP eval suite.")
ap.add_argument("--type", dest="type_filter", default=None)
ap.add_argument("--policy", dest="policy", default=None,
choices=["auto", "cheap", "smart", "offline"])
args = ap.parse_args()
report = run_suite(args.type_filter, args.policy)
_print(report)
if __name__ == "__main__":
main()