Spaces:
Running
Running
| """OCR output-quality + document-analysis benchmark. | |
| For each available OCR backend, over a set of scanned samples with ground truth, we | |
| measure two quality dimensions and capture logs + metrics: | |
| 1. OCR text quality — Character Error Rate (CER) and Word Error Rate (WER) of the | |
| transcribed text vs a reference (the `.txt` sidecar that ships with each scan). | |
| 2. Document-analysis quality — field exact-match and F1 of the FULL pipeline | |
| (OCR → classify → extract → validate) vs the document's ground-truth JSON. | |
| Plus latency, cost, model name/size. Results are published (file + endpoint + optional | |
| HF upload). Pure-Python edit distance, no extra deps. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| import time | |
| from pathlib import Path | |
| from ..observability import log_event | |
| # scanned samples that have BOTH a .txt sidecar (reference text) and a gt.json, AND | |
| # that genuinely exercise each OCR engine independently (different CER per backend). | |
| DEFAULT_SAMPLES = [ | |
| "invoice_scanned_basic", "receipt_scanned", "po_scanned", | |
| "contract_scanned", "subscription_memo_scanned", | |
| ] | |
| # field-accuracy-only (no sidecar reference text). The extreme tier | |
| # (scripts/generate_extreme_docs.py — perspective photo, image collage, degraded fax) is a | |
| # VISION-extraction stress set: on those images the OCR engines fall back to a shared text | |
| # source (identical CER across backends), so they are excluded from the per-backend CER | |
| # benchmark and scored on field accuracy only. complex_invoice_messy requires a real VLM. | |
| FIELD_ONLY_SAMPLES = ["complex_invoice_messy"] | |
| def _norm(s: str) -> str: | |
| return re.sub(r"\s+", " ", (s or "").strip().lower()) | |
| def _lev(a, b) -> int: | |
| """Levenshtein distance over any sequences (str or list).""" | |
| if a == b: | |
| return 0 | |
| la, lb = len(a), len(b) | |
| if la == 0: | |
| return lb | |
| if lb == 0: | |
| return la | |
| prev = list(range(lb + 1)) | |
| for i in range(1, la + 1): | |
| cur = [i] | |
| ca = a[i - 1] | |
| for j in range(1, lb + 1): | |
| cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != b[j - 1]))) | |
| prev = cur | |
| return prev[lb] | |
| def cer(hyp: str, ref: str): | |
| ref, hyp = _norm(ref), _norm(hyp) | |
| if not ref: | |
| return None | |
| return round(min(1.0, _lev(hyp, ref) / len(ref)), 4) | |
| def wer(hyp: str, ref: str): | |
| rw, hw = _norm(ref).split(), _norm(hyp).split() | |
| if not rw: | |
| return None | |
| return round(min(1.0, _lev(hw, rw) / len(rw)), 4) | |
| def _model_size(settings, backend_name: str): | |
| """Map an OCR backend to its model name + size from the model registry.""" | |
| from ..models_registry import model_catalog | |
| cat = model_catalog(settings) | |
| flat = [{**m, "lab": lab["lab"]} for lab in cat["labs"] for m in lab["models"]] | |
| if backend_name == "minicpm": | |
| m = next((x for x in flat if x["name"].startswith("MiniCPM-V")), None) | |
| elif backend_name == "cohere": | |
| m = next((x for x in flat if x["name"].startswith("Aya-Vision-8")), None) | |
| else: | |
| m = None | |
| if m: | |
| return {"model": m["name"], "params_b": m["params_b"], "size_gb": m["size_gb_int4"], "lab": m["lab"]} | |
| return {"model": backend_name, "params_b": None, "size_gb": None, "lab": "classic"} | |
| def run_ocr_quality(settings, ocr_registry, router, metrics, db=None, rag_store=None, | |
| samples=None) -> dict: | |
| from ..pipeline import process_document | |
| from evals import scorers | |
| samples = samples or DEFAULT_SAMPLES | |
| ds = settings.evals_dataset_dir | |
| available = [n for n in ocr_registry.available_names() if n != "sidecar"] + ["sidecar"] | |
| log_event("info", "OCR quality benchmark started", | |
| backends=available, samples=samples + FIELD_ONLY_SAMPLES) | |
| backend_rows = [] | |
| for bname in available: | |
| backend = ocr_registry.get(bname) | |
| if not backend or not backend.available(): | |
| continue | |
| cers, wers, exacts, f1s, lats, costs = [], [], [], [], [], [] | |
| per_sample = [] | |
| for sid in samples + FIELD_ONLY_SAMPLES: | |
| doc = _find(ds, sid) | |
| if not doc: | |
| continue | |
| gt = _load_gt(ds, sid) | |
| t0 = time.perf_counter() | |
| run = process_document(doc, router=router, settings=settings, metrics=metrics, | |
| ocr_registry=ocr_registry, ocr_backend=bname, | |
| db=db, rag_store=rag_store, doc_id=f"q-{bname}-{sid}", | |
| mode="quality") | |
| st = run["_state"] | |
| ocr_text = (st.get("ocr") or {}).get("text", "") | |
| ref = _ref_text(ds, sid) | |
| c = cer(ocr_text, ref) if ref else None | |
| w = wer(ocr_text, ref) if ref else None | |
| score = scorers.score_document(st.get("extracted") or {}, | |
| {k: v for k, v in gt.items() if not k.startswith("_")}) if gt else {} | |
| case = {"sample": sid, "cer": c, "wer": w, | |
| "field_exact": score.get("exact_match"), "field_f1": score.get("field_f1"), | |
| "latency_ms": round((time.perf_counter() - t0) * 1000, 1), | |
| "cost_usd": run.get("total_cost_usd", 0.0), | |
| "confidence": st.get("confidence")} | |
| per_sample.append(case) | |
| if c is not None: | |
| cers.append(c); wers.append(w) | |
| if score.get("exact_match") is not None: | |
| exacts.append(score["exact_match"]); f1s.append(score["field_f1"] or 0) | |
| lats.append(case["latency_ms"]); costs.append(case["cost_usd"]) | |
| log_event("info", f"OCR quality: {bname} on {sid}", | |
| cer=c, wer=w, field_exact=score.get("exact_match")) | |
| size = _model_size(settings, bname) | |
| row = { | |
| "backend": bname, **size, | |
| "is_reference": bname == "sidecar", | |
| "cer": _avg(cers), "wer": _avg(wers), | |
| "field_exact_match": _avg(exacts), "field_f1": _avg(f1s), | |
| "avg_latency_ms": _avg(lats), "avg_cost_usd": round(_avg(costs) or 0, 6), | |
| "samples_scored": len(per_sample), "per_sample": per_sample, | |
| } | |
| backend_rows.append(row) | |
| # rank: best OCR text quality (lowest CER among real engines), best analysis (highest exact) | |
| real = [r for r in backend_rows if not r["is_reference"] and r["cer"] is not None] | |
| best_ocr = min(real, key=lambda r: r["cer"])["backend"] if real else None | |
| # rank only real engines for "best analysis" — sidecar is the reference text, not a | |
| # competing OCR backend, so it must never be crowned best. | |
| scored = [r for r in backend_rows | |
| if not r["is_reference"] and r["field_exact_match"] is not None] | |
| best_analysis = max(scored, key=lambda r: r["field_exact_match"])["backend"] if scored else None | |
| report = { | |
| "generated_at": time.time(), | |
| "note": "CER/WER vs .txt sidecar reference; field accuracy vs gt.json. " | |
| "sidecar = reference text source (CER≈0 by construction).", | |
| "models": _model_size_table(settings), | |
| "backends": backend_rows, | |
| "best_ocr_quality": best_ocr, | |
| "best_document_analysis": best_analysis, | |
| } | |
| if db is not None: | |
| try: | |
| db.audit("ocr_quality_benchmark", | |
| detail={"best_ocr": best_ocr, "best_analysis": best_analysis, | |
| "backends": [r["backend"] for r in backend_rows]}) | |
| except Exception: | |
| pass | |
| log_event("info", "OCR quality benchmark complete", | |
| best_ocr=best_ocr, best_analysis=best_analysis) | |
| return report | |
| def _model_size_table(settings): | |
| from ..models_registry import model_catalog | |
| return [{"lab": lab["lab"], **{k: m[k] for k in ("name", "params_b", "size_gb_int4", "modality", "role", "available")}} | |
| for lab in model_catalog(settings)["labs"] for m in lab["models"]] | |
| def _avg(xs): | |
| xs = [x for x in xs if x is not None] | |
| return round(sum(xs) / len(xs), 4) if xs else None | |
| def _find(ds: Path, sid: str): | |
| for ext in (".png", ".jpg", ".jpeg", ".pdf"): | |
| p = ds / f"{sid}{ext}" | |
| if p.exists(): | |
| return p | |
| return None | |
| def _ref_text(ds: Path, sid: str): | |
| p = ds / f"{sid}.txt" | |
| return p.read_text(encoding="utf-8", errors="ignore") if p.exists() else None | |
| def _load_gt(ds: Path, sid: str): | |
| p = ds / f"{sid}.gt.json" | |
| return json.loads(p.read_text()) if p.exists() else None | |