Tasks 5-7: eval harness, FastAPI backend, Paper & Ink UI- src/eval/ — precision/recall/F1 harness with type-aware comparators,micro/macro F1, CSV + markdown reports, --model benchmark flag- src/api/ — FastAPI backend with /extract, /schemas, /health,request-ID middleware, typed error envelope, injectable extractor- ui/ — Vite + React + TS + Tailwind + Motion + React Three FiberPaper & Ink editorial UI with 3D paper hero, dark/light mode,confidence inkwell, wax-stamp metrics, kinetic typography- 95 passing tests (up from 54); UI is a separate npm workspace
557ab38 | """Per-field comparators. | |
| Each returns (match: bool, score: float) where `score` is a 0-1 similarity | |
| (useful for debugging + partial-credit later). `match` is the boolean the | |
| metrics aggregator counts as TP. | |
| Design choices: | |
| - Text: rapidfuzz `token_set_ratio` >= 85. Handles reordered tokens, extra | |
| whitespace, capitalization. Merchant names on receipts are the classic | |
| motivator (e.g. "TAN WOON YANN" vs "Tan Woon Yann Sdn Bhd"). | |
| - Money: absolute 0.01 OR relative 0.5% — either passes. Real-world receipts | |
| have rounding on tax, so 0.5% covers subtotal/tax legitimate drift, and | |
| 0.01 handles the common integer-cent case exactly. | |
| - Date/time: ISO-format equality. Both sides are already parsed (Pydantic on | |
| the model side; JSON round-trip on ground truth) so a string == suffices. | |
| - Exact: case- and whitespace-insensitive string equality. Used for | |
| currency codes, SKUs, invoice numbers, phones. | |
| - Number: exact numeric equality with tiny float tolerance. | |
| """ | |
| from __future__ import annotations | |
| from datetime import date, time | |
| from typing import Any | |
| from rapidfuzz import fuzz | |
| # --- Thresholds ------------------------------------------------------------ | |
| TEXT_FUZZ_THRESHOLD = 85 # rapidfuzz score out of 100 | |
| MONEY_ABS_TOL = 0.01 # $0.01 or 1¢ | |
| MONEY_REL_TOL = 0.005 # 0.5% | |
| NUMBER_ABS_TOL = 1e-6 | |
| # --- Helpers --------------------------------------------------------------- | |
| def _both_none(a: Any, b: Any) -> bool: | |
| return a is None and b is None | |
| def _one_none(a: Any, b: Any) -> bool: | |
| return (a is None) ^ (b is None) | |
| def _norm_str(v: Any) -> str: | |
| return str(v).strip().lower() | |
| # --- Comparators ----------------------------------------------------------- | |
| def match_text(pred: Any, truth: Any) -> tuple[bool, float]: | |
| """Fuzzy text match — for free-text fields (names, descriptions).""" | |
| if _both_none(pred, truth): | |
| return True, 1.0 | |
| if _one_none(pred, truth): | |
| return False, 0.0 | |
| p, t = _norm_str(pred), _norm_str(truth) | |
| if not p and not t: | |
| return True, 1.0 | |
| score = fuzz.token_set_ratio(p, t) / 100.0 | |
| return score >= (TEXT_FUZZ_THRESHOLD / 100.0), score | |
| def match_exact(pred: Any, truth: Any) -> tuple[bool, float]: | |
| """Case- and whitespace-insensitive equality — for codes/IDs/currency.""" | |
| if _both_none(pred, truth): | |
| return True, 1.0 | |
| if _one_none(pred, truth): | |
| return False, 0.0 | |
| return (_norm_str(pred) == _norm_str(truth)), 1.0 if _norm_str(pred) == _norm_str(truth) else 0.0 | |
| def match_money(pred: Any, truth: Any) -> tuple[bool, float]: | |
| """Money: 0.01 absolute OR 0.5% relative tolerance. Either passes.""" | |
| if _both_none(pred, truth): | |
| return True, 1.0 | |
| if _one_none(pred, truth): | |
| return False, 0.0 | |
| try: | |
| p, t = float(pred), float(truth) | |
| except (TypeError, ValueError): | |
| return False, 0.0 | |
| diff = abs(p - t) | |
| ok = diff <= MONEY_ABS_TOL or (t != 0 and (diff / abs(t)) <= MONEY_REL_TOL) | |
| # score = 1 - normalized error, floored at 0 | |
| denom = max(abs(t), 1.0) | |
| score = max(0.0, 1.0 - diff / denom) | |
| return ok, score | |
| def match_number(pred: Any, truth: Any) -> tuple[bool, float]: | |
| """Numeric equality with tiny float tolerance. For qty, tax_rate, list sizes.""" | |
| if _both_none(pred, truth): | |
| return True, 1.0 | |
| if _one_none(pred, truth): | |
| return False, 0.0 | |
| try: | |
| p, t = float(pred), float(truth) | |
| except (TypeError, ValueError): | |
| return False, 0.0 | |
| ok = abs(p - t) <= NUMBER_ABS_TOL | |
| return ok, 1.0 if ok else 0.0 | |
| def match_date(pred: Any, truth: Any) -> tuple[bool, float]: | |
| """ISO date equality. Accepts date objects or ISO strings on either side.""" | |
| if _both_none(pred, truth): | |
| return True, 1.0 | |
| if _one_none(pred, truth): | |
| return False, 0.0 | |
| p = pred.isoformat() if isinstance(pred, date) else str(pred) | |
| t = truth.isoformat() if isinstance(truth, date) else str(truth) | |
| ok = p == t | |
| return ok, 1.0 if ok else 0.0 | |
| def match_time(pred: Any, truth: Any) -> tuple[bool, float]: | |
| """ISO time equality.""" | |
| if _both_none(pred, truth): | |
| return True, 1.0 | |
| if _one_none(pred, truth): | |
| return False, 0.0 | |
| p = pred.isoformat() if isinstance(pred, time) else str(pred) | |
| t = truth.isoformat() if isinstance(truth, time) else str(truth) | |
| ok = p == t | |
| return ok, 1.0 if ok else 0.0 | |
| # --- Dispatch -------------------------------------------------------------- | |
| _DISPATCH = { | |
| "text": match_text, | |
| "exact": match_exact, | |
| "money": match_money, | |
| "number": match_number, | |
| "date": match_date, | |
| "time": match_time, | |
| } | |
| def compare(pred: Any, truth: Any, field_type: str) -> tuple[bool, float]: | |
| """Dispatch to the right comparator by field type.""" | |
| fn = _DISPATCH.get(field_type, match_exact) | |
| return fn(pred, truth) | |