diff --git a/README.md b/README.md index 5f57c1ea843b32be4f86e9884cd61831a9b9a48e..c0dde45d93efe8ca23de31ff2efb99f095d95c67 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ _Will be filled in after v1 evaluation run:_ | PDF text | pdfplumber, PyMuPDF | Fast, robust, handles most layouts | | PDF images | pdf2image + Pillow | For scanned/image-heavy PDFs → vision model | | Backend | FastAPI | Async, auto OpenAPI docs, batteries included | -| Frontend | Streamlit | Fastest path to a demo-worthy UI | +| Frontend | React + Vite + Tailwind + Motion + React Three Fiber | Editorial "Paper & Ink" aesthetic — 3D paper sheet in the hero, kinetic type, dark/light mode. No generic AI-SaaS look. | | Eval | rapidfuzz, scikit-learn | Fuzzy text matching + P/R/F1 | | Container | Docker (multi-stage) | Portable, reproducible | | Deploy | Hugging Face Spaces | Free, AI-community-recognized | @@ -100,10 +100,24 @@ cp .env.example .env # 3. Run the API uvicorn src.api.main:app --reload -# 4. Run the UI (in another terminal) -streamlit run src/ui/app.py +# 4. Run the UI — Paper & Ink React + Motion + R3F frontend +# (in another terminal, from ui/) +cd ui && npm install && npm run dev +# then open http://localhost:5173 + +# 5. (Optional) Evaluate against the committed sample ground truth. +# `selfcheck` mode uses a mock extractor to validate the eval pipeline (F1=1.0). +python scripts/run_eval.py --dataset data/samples/sroie_sample.jsonl \ + --doc-type receipt --mode selfcheck + +# 6. Benchmark a real model on your own ground-truth JSONL: +python scripts/run_eval.py --dataset evaluation/ground_truth/sroie.jsonl \ + --doc-type receipt --mode live --model gpt-5-nano ``` +Reports (per-record CSV + summary JSON + resume-ready markdown) land in +`evaluation/reports//`. + ## Project structure ``` @@ -112,8 +126,11 @@ streamlit run src/ui/app.py │ ├── schemas/ # Pydantic schemas per doc type │ ├── extractors/ # LLM extraction logic │ ├── api/ # FastAPI backend -│ ├── ui/ # Streamlit frontend │ └── utils/ # cost tracking, logging, config +├── ui/ # React + Motion + R3F frontend (Paper & Ink) +│ ├── src/components/ # Hero, PaperScene (3D), Dropzone, ResultsPanel, ... +│ ├── src/styles/ # theme.css (dark/light tokens) + globals.css +│ └── package.json ├── tests/ │ ├── unit/ │ └── integration/ diff --git a/scripts/run_eval.py b/scripts/run_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..38beeacc5b2ea163ab69d9373fac512e5d06b3d4 --- /dev/null +++ b/scripts/run_eval.py @@ -0,0 +1,17 @@ +"""Thin wrapper around src.eval.cli so `python scripts/run_eval.py ...` works. + +Prefer this entry point in docs — it's discoverable from the repo root. +Adds the repo root to sys.path so `from src.eval.cli import main` resolves +when run directly (rather than as a module). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.eval.cli import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/deps.py b/src/api/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..d947c82da2df9ec3d08a0ff5a646e643b255a9b5 --- /dev/null +++ b/src/api/deps.py @@ -0,0 +1,49 @@ +"""FastAPI dependencies + upload constraints. + +Why a `Depends`-based extractor: + FastAPI's dependency injection lets tests override the extractor via + `app.dependency_overrides[get_extractor] = fake_extractor`, so we can + hit the endpoints in tests without an OpenAI key. In production the + lru_cache means we build one DocumentExtractor per worker process. +""" +from __future__ import annotations + +from functools import lru_cache + +from src.extractors.extractor import DocumentExtractor +from src.schemas.registry import list_doc_types + +# --- Upload constraints ---------------------------------------------------- + +# 10 MB by default. Set to something small for tests via monkeypatch if needed. +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 + +# MIME allowlist. Also accept common browser variants. `application/octet-stream` +# is accepted because some clients (and Streamlit) send it for images. +ALLOWED_MIME_TYPES = { + "application/pdf", + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/bmp", + "image/tiff", + "application/octet-stream", + "text/plain", # for inline text extraction convenience +} + +# Extensions we can actually load (mirrors document_loader.py). +ALLOWED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif", ".txt"} + + +# --- Dependencies ---------------------------------------------------------- + +@lru_cache(maxsize=1) +def get_extractor() -> DocumentExtractor: + """Singleton DocumentExtractor. Overridden in tests via dependency_overrides.""" + return DocumentExtractor() + + +def get_allowed_doc_types() -> list[str]: + """List of registered document type strings — used by /schemas.""" + return list_doc_types() diff --git a/src/api/errors.py b/src/api/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..1563fc21ed25506f953e19e4b4219cc3332defcb --- /dev/null +++ b/src/api/errors.py @@ -0,0 +1,69 @@ +"""API-facing error envelope and typed exceptions. + +Every 4xx / 5xx response from this service returns the same JSON shape: + + { + "error": { + "code": "unsupported_doc_type", + "message": "Human-readable explanation.", + "request_id": "abc-123", + "details": {...optional...} + } + } + +Consistent shape makes clients (Streamlit, curl, external integrations) easier +to write than raw FastAPI HTTPException details. +""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class ErrorDetail(BaseModel): + code: str + message: str + request_id: str | None = None + details: dict[str, Any] = Field(default_factory=dict) + + +class ErrorEnvelope(BaseModel): + error: ErrorDetail + + +class APIError(Exception): + """Base class — subclass rather than raising HTTPException directly.""" + + status_code: int = 500 + code: str = "internal_error" + + def __init__(self, message: str, *, details: dict[str, Any] | None = None): + super().__init__(message) + self.message = message + self.details = details or {} + + +class UnsupportedDocType(APIError): + status_code = 400 + code = "unsupported_doc_type" + + +class UnsupportedFileType(APIError): + status_code = 415 + code = "unsupported_media_type" + + +class FileTooLarge(APIError): + status_code = 413 + code = "file_too_large" + + +class EmptyDocument(APIError): + status_code = 422 + code = "empty_document" + + +class ExtractionFailed(APIError): + status_code = 502 + code = "extraction_failed" diff --git a/src/api/main.py b/src/api/main.py new file mode 100644 index 0000000000000000000000000000000000000000..63d17f1c1344ee08f57f6d6bea84760c243211ef --- /dev/null +++ b/src/api/main.py @@ -0,0 +1,97 @@ +"""FastAPI application factory + module-level `app` for uvicorn. + +Run: + uvicorn src.api.main:app --reload + +Design notes: +- App is created by a factory (`create_app`) so tests can instantiate a fresh + app with overrides — no shared mutable state across test cases. +- CORS is wide-open by default for local Streamlit; tighten for prod. +- All exceptions funnel through the ErrorEnvelope handler for consistent shape. +""" +from __future__ import annotations + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from src.api.errors import APIError, ErrorDetail, ErrorEnvelope +from src.api.middleware import AccessLogMiddleware, RequestIDMiddleware +from src.api.routers import extract as extract_router +from src.api.routers import health as health_router +from src.api.routers import schemas as schemas_router +from src.utils.logging import logger + + +def create_app() -> FastAPI: + app = FastAPI( + title="Structured Data Extraction API", + description=( + "Multi-domain document extraction — invoices, receipts, and (v2) SEC filings — " + "returned as schema-validated JSON with confidence scoring, cost, and latency." + ), + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + ) + + # --- Middleware (outer -> inner order) --------------------------------- + # CORS must be outermost so it can add headers to error responses too. + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware(AccessLogMiddleware) + app.add_middleware(RequestIDMiddleware) + + # --- Routers ----------------------------------------------------------- + app.include_router(health_router.router) + app.include_router(schemas_router.router) + app.include_router(extract_router.router) + + # --- Error handlers ---------------------------------------------------- + @app.exception_handler(APIError) + async def _api_error_handler(request: Request, exc: APIError): + rid = getattr(request.state, "request_id", None) + envelope = ErrorEnvelope( + error=ErrorDetail( + code=exc.code, message=exc.message, request_id=rid, details=exc.details + ) + ) + return JSONResponse(status_code=exc.status_code, content=envelope.model_dump()) + + @app.exception_handler(RequestValidationError) + async def _validation_handler(request: Request, exc: RequestValidationError): + rid = getattr(request.state, "request_id", None) + envelope = ErrorEnvelope( + error=ErrorDetail( + code="validation_error", + message="Request failed validation.", + request_id=rid, + details={"errors": exc.errors()}, + ) + ) + return JSONResponse(status_code=422, content=envelope.model_dump()) + + @app.exception_handler(Exception) + async def _unhandled(request: Request, exc: Exception): + rid = getattr(request.state, "request_id", None) + logger.exception(f"[api] Unhandled exception (rid={rid}): {exc}") + envelope = ErrorEnvelope( + error=ErrorDetail( + code="internal_error", + message="An unexpected error occurred.", + request_id=rid, + ) + ) + return JSONResponse(status_code=500, content=envelope.model_dump()) + + return app + + +# Module-level instance for `uvicorn src.api.main:app`. +app = create_app() diff --git a/src/api/middleware.py b/src/api/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..564490427aceaf82c3a891860436150e82cac4ed --- /dev/null +++ b/src/api/middleware.py @@ -0,0 +1,47 @@ +"""Middleware: request IDs + structured access logs. + +RequestIDMiddleware attaches a UUID4 to every request as `X-Request-ID`. Both +the response header and the log line carry it so a user reporting an error can +give you the ID and you can find the log. + +StructuredLoggingMiddleware logs one JSON line per request with method, path, +status, latency, and the request ID. +""" +from __future__ import annotations + +import time +import uuid + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from src.utils.logging import logger + +REQUEST_ID_HEADER = "X-Request-ID" + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """Assign each request a UUID and echo it in the response header.""" + + async def dispatch(self, request: Request, call_next): + rid = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4()) + request.state.request_id = rid + response: Response = await call_next(request) + response.headers[REQUEST_ID_HEADER] = rid + return response + + +class AccessLogMiddleware(BaseHTTPMiddleware): + """One log line per request with method, path, status, latency, request_id.""" + + async def dispatch(self, request: Request, call_next): + start = time.perf_counter() + response: Response = await call_next(request) + latency_ms = (time.perf_counter() - start) * 1000 + rid = getattr(request.state, "request_id", "-") + logger.info( + f"[api] method={request.method} path={request.url.path} " + f"status={response.status_code} latency_ms={latency_ms:.1f} rid={rid}" + ) + return response diff --git a/src/api/routers/__init__.py b/src/api/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..97c3e0c4e7c35276c599cbce9b9f3cdf4fc85120 --- /dev/null +++ b/src/api/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers — one module per resource.""" diff --git a/src/api/routers/extract.py b/src/api/routers/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..7b93f50487d9ef71632c0658783d68ddba85d08e --- /dev/null +++ b/src/api/routers/extract.py @@ -0,0 +1,90 @@ +"""POST /extract — the main extraction endpoint. + +Multipart form: + file : uploaded document (required) + doc_type : "invoice" | "receipt" (required) + model : optional model override (e.g. "gpt-5-nano", "gpt-5.4") for benchmarking + +Returns: + { + "result": ExtractionResult[T] JSON, + "metrics": {input_tokens, output_tokens, latency_ms, cost_usd, model} + } +""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, UploadFile + +from src.api.deps import ( + ALLOWED_EXTENSIONS, + ALLOWED_MIME_TYPES, + MAX_UPLOAD_BYTES, + get_extractor, +) +from src.api.errors import ( + EmptyDocument, + ExtractionFailed, + FileTooLarge, + UnsupportedDocType, + UnsupportedFileType, +) +from src.extractors.extractor import DocumentExtractor +from src.schemas.registry import get_schema + +router = APIRouter(tags=["extract"]) + + +@router.post("/extract") +async def extract( + file: UploadFile = File(..., description="PDF or image to extract from."), + doc_type: str = Form(..., description="One of the registered doc types (see GET /schemas)."), + model: str | None = Form(None, description="Optional model override (e.g. gpt-5-nano)."), + extractor: DocumentExtractor = Depends(get_extractor), +) -> dict: + # --- Validate doc_type + try: + get_schema(doc_type) # raises KeyError if unknown + except KeyError as e: + raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e + + # --- Validate file extension + content-type + filename = file.filename or "upload" + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise UnsupportedFileType( + f"Extension {ext!r} is not supported. Allowed: {sorted(ALLOWED_EXTENSIONS)}", + details={"filename": filename, "extension": ext}, + ) + if file.content_type and file.content_type not in ALLOWED_MIME_TYPES: + raise UnsupportedFileType( + f"MIME type {file.content_type!r} not accepted for {filename}.", + details={"content_type": file.content_type}, + ) + + # --- Read + size-check + payload = await file.read() + if len(payload) == 0: + raise EmptyDocument("Uploaded file is empty.") + if len(payload) > MAX_UPLOAD_BYTES: + raise FileTooLarge( + f"File is {len(payload)} bytes; max allowed is {MAX_UPLOAD_BYTES}.", + details={"size_bytes": len(payload), "max_bytes": MAX_UPLOAD_BYTES}, + ) + + # --- Run extraction + try: + result, metrics = extractor.extract( + payload, filename=filename, doc_type=doc_type, model_override=model + ) + except ValueError as e: + # Loader reports "could not load" for unknown/corrupt formats. + raise EmptyDocument(str(e)) from e + except Exception as e: # noqa: BLE001 + raise ExtractionFailed(f"Model extraction failed: {e}") from e + + return { + "result": result.model_dump(mode="json"), + "metrics": metrics.to_dict(), + } diff --git a/src/api/routers/health.py b/src/api/routers/health.py new file mode 100644 index 0000000000000000000000000000000000000000..3432c98ee458986e04add5c48eeda5f8a802f472 --- /dev/null +++ b/src/api/routers/health.py @@ -0,0 +1,28 @@ +"""Health + root routes. + +GET / -> service banner (name, version, docs link) +GET /health -> {"status": "ok"} — probe target for orchestrators +""" +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + +SERVICE_NAME = "structured-data-extraction" +SERVICE_VERSION = "0.1.0" + + +@router.get("/") +def root() -> dict: + return { + "service": SERVICE_NAME, + "version": SERVICE_VERSION, + "docs": "/docs", + "openapi": "/openapi.json", + } + + +@router.get("/health") +def health() -> dict: + return {"status": "ok"} diff --git a/src/api/routers/schemas.py b/src/api/routers/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..3a4534d8aaf8ad7015555012d988dd2230f058e7 --- /dev/null +++ b/src/api/routers/schemas.py @@ -0,0 +1,31 @@ +"""Schema discovery routes. + +GET /schemas -> list of registered document types +GET /schemas/{doc_type} -> full JSON Schema for that type + +The JSON Schema is what OpenAI's structured outputs uses internally — exposing +it lets clients validate uploads client-side, or auto-generate forms. +""" +from __future__ import annotations + +from fastapi import APIRouter + +from src.api.errors import UnsupportedDocType +from src.schemas.registry import get_json_schema, list_doc_types + +router = APIRouter(prefix="/schemas", tags=["schemas"]) + + +@router.get("") +def list_schemas() -> dict: + """Return all registered document type keys.""" + return {"doc_types": list_doc_types()} + + +@router.get("/{doc_type}") +def get_schema_json(doc_type: str) -> dict: + """Return the JSON Schema for one document type.""" + try: + return get_json_schema(doc_type) + except KeyError as e: + raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e diff --git a/src/eval/__init__.py b/src/eval/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..11efee7c39cca102c31daf0432311e8adbdb91d5 --- /dev/null +++ b/src/eval/__init__.py @@ -0,0 +1,17 @@ +"""Evaluation harness for structured document extraction. + +This package turns the extractor into a measurable system: +- Runs a JSONL ground-truth file through DocumentExtractor +- Compares per-field with type-appropriate comparators (text/money/date/exact) +- Aggregates field-level precision/recall/F1 and document-level exact match +- Emits per-record CSV + resume-worthy markdown summary +- Supports multi-model benchmark runs via --model flag + +Public entry points: + run_eval(records, extractor, doc_type, ...) -> EvalReport + write_reports(report, out_dir) -> (csv_path, md_path) +""" +from src.eval.runner import EvalReport, run_eval +from src.eval.report import write_reports + +__all__ = ["run_eval", "EvalReport", "write_reports"] diff --git a/src/eval/cli.py b/src/eval/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..15f4eabf1fb1fc746bcb68474de0dfb8297be541 --- /dev/null +++ b/src/eval/cli.py @@ -0,0 +1,173 @@ +"""CLI entry point for the evaluation harness. + +Usage examples: + # Sanity-check the eval pipeline itself against the committed samples + # (uses a self-consistent extractor — no OpenAI call, always F1=1.0) + python -m src.eval.cli --dataset data/samples/sroie_sample.jsonl \\ + --doc-type receipt --mode selfcheck + + # Real evaluation with a live model (each record must provide a file_path + # or an inline text field for the extractor to consume) + python -m src.eval.cli --dataset evaluation/ground_truth/sroie.jsonl \\ + --doc-type receipt --mode live --model gpt-5-nano + + # Multi-model benchmark (repeat --mode live with different --model tags) + +Outputs land in `evaluation/reports//__*.{csv,md,json}`. +""" +from __future__ import annotations + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import Callable + +from pydantic import BaseModel + +from src.data_prep.writer import read_jsonl +from src.eval.report import write_reports +from src.eval.runner import ExtractorFn, run_eval +from src.schemas import ExtractionResult +from src.schemas.registry import get_schema +from src.utils.cost_tracker import ExtractionMetrics +from src.utils.logging import logger + + +# --------------------------------------------------------------------------- +# Extractor factories: pluggable strategies for how a JSONL record becomes an +# (ExtractionResult, ExtractionMetrics) pair. +# --------------------------------------------------------------------------- + +def make_selfcheck_extractor(doc_type: str) -> ExtractorFn: + """Extractor that returns ground truth verbatim — validates the eval pipeline. + + Guaranteed F1=1.0 doc_exact_match=1.0. Useful for CI + first-time setup. + """ + schema_cls: type[BaseModel] = get_schema(doc_type) + + def _extract(record: dict) -> tuple[ExtractionResult, ExtractionMetrics]: + data = schema_cls.model_validate(record["ground_truth"]) + result = ExtractionResult( + document_type=doc_type, + data=data, + field_confidences=[], + overall_confidence=1.0, + warnings=[], + raw_text_snippet=None, + ) + return result, ExtractionMetrics(input_tokens=0, output_tokens=0, latency_ms=0.0, model="selfcheck") + + return _extract + + +def make_live_extractor(doc_type: str, model: str | None) -> ExtractorFn: + """Real extractor. Each record must provide either `file_path` or `text`. + + Deferred import: keeps `--mode selfcheck` runs from requiring OpenAI creds. + """ + from src.extractors.extractor import DocumentExtractor + + ex = DocumentExtractor(default_model=model) + + def _extract(record: dict) -> tuple[ExtractionResult, ExtractionMetrics]: + # Prefer an on-disk file if we have one. + fp = record.get("file_path") + if fp: + p = Path(fp) + if not p.exists(): + raise FileNotFoundError(f"file_path not found for record {record.get('id')}: {fp}") + file_bytes = p.read_bytes() + filename = p.name + elif record.get("text"): + # Fallback: use inline text as if it were a .txt document. + file_bytes = record["text"].encode("utf-8") + filename = f"{record.get('id', 'inline')}.txt" + else: + raise ValueError( + f"Record {record.get('id')!r} has neither 'file_path' nor 'text' — " + f"live extraction needs one of them." + ) + + return ex.extract(file_bytes, filename, doc_type=doc_type, model_override=model) + + return _extract + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Structured-extraction evaluation harness") + p.add_argument("--dataset", required=True, help="JSONL ground-truth file.") + p.add_argument( + "--doc-type", + default="receipt", + choices=["invoice", "receipt"], + help="Domain schema to evaluate against.", + ) + p.add_argument( + "--mode", + default="selfcheck", + choices=["selfcheck", "live"], + help=( + "selfcheck: mock extractor returns ground truth (validates pipeline). " + "live: run real DocumentExtractor (needs OPENAI_API_KEY + source docs)." + ), + ) + p.add_argument("--model", default=None, help="Model override for live mode (e.g. gpt-5-nano).") + p.add_argument("--limit", type=int, default=None, help="Cap on records for quick runs.") + p.add_argument( + "--output-dir", + default=None, + help="Where to write reports. Defaults to evaluation/reports//.", + ) + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + dataset_path = Path(args.dataset) + if not dataset_path.exists(): + print(f"ERROR: dataset not found: {dataset_path}", file=sys.stderr) + return 2 + + records = read_jsonl(dataset_path) + logger.info(f"Loaded {len(records)} records from {dataset_path}") + + # Pick extractor strategy + if args.mode == "selfcheck": + extractor = make_selfcheck_extractor(args.doc_type) + model_label = "selfcheck" + else: + extractor = make_live_extractor(args.doc_type, args.model) + model_label = args.model or "default" + + report = run_eval( + records, + extractor=extractor, + doc_type=args.doc_type, + model_label=model_label, + limit=args.limit, + ) + + # Console summary + s = report.summary() + print("\n=== Evaluation summary ===") + for k, v in s.items(): + print(f" {k:20s} {v}") + + # Write reports + out_dir = args.output_dir or f"evaluation/reports/{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}" + paths = write_reports(report, out_dir) + print("\nReports written:") + for k, p in paths.items(): + print(f" {k:10s} {p}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/eval/comparators.py b/src/eval/comparators.py new file mode 100644 index 0000000000000000000000000000000000000000..316fa7486de6380739f9b7fd12fb01ee753d7260 --- /dev/null +++ b/src/eval/comparators.py @@ -0,0 +1,144 @@ +"""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) diff --git a/src/eval/flatten.py b/src/eval/flatten.py new file mode 100644 index 0000000000000000000000000000000000000000..99951efe23855f7ea70c148cebbf20ceaedd687d --- /dev/null +++ b/src/eval/flatten.py @@ -0,0 +1,129 @@ +"""Flatten a Pydantic model (or dict) to a {dotted.path: (value, field_type)} map. + +Why: + Field-level metrics need every leaf field addressable by a stable key. Nested + models become "vendor.address.city", lists become "line_items[0].description". + +Field types drive comparator choice in `comparators.py`: + - "money" -> float fields that hold monetary amounts (total, tax, unit_price) + - "date" -> datetime.date fields + - "time" -> datetime.time fields + - "number" -> other numeric fields (quantity, tax_rate) + - "text" -> free-text string fields (merchant name, description) + - "exact" -> short/normalized strings (currency, sku, phone, tax_id) + +The type classifier uses Pydantic's field annotations plus a small set of +name-based hints for the money/exact split (both are `float`/`str` at the type +level but need different comparators). +""" +from __future__ import annotations + +from datetime import date, time +from types import UnionType +from typing import Any, Union, get_args, get_origin + +from pydantic import BaseModel + +# --- Field-name hints ------------------------------------------------------- +# Any float field whose name matches these is treated as MONEY (0.01 tolerance, +# 0.5% relative tolerance). Line-item quantities / tax rates fall through to +# "number" (exact match). +_MONEY_FIELD_NAMES = { + "total", "subtotal", "tax", "tip", "discount", "shipping", + "unit_price", "price", "amount", +} + +# String fields whose name matches these are compared as EXACT (case/whitespace +# insensitive), not fuzzy text. +_EXACT_STRING_FIELD_NAMES = { + "currency", "sku", "invoice_number", "purchase_order_number", + "receipt_number", "tax_id", "postal_code", "country", "phone", + "merchant_phone", "payment_method", +} + + +FieldMap = dict[str, tuple[Any, str]] + + +def _unwrap_optional(annotation: Any) -> Any: + """Return the non-None type inside Optional[X] / X | None.""" + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + non_none = [a for a in get_args(annotation) if a is not type(None)] + if len(non_none) == 1: + return non_none[0] + return annotation + + +def _classify(field_name: str, annotation: Any) -> str: + """Pick a comparator bucket for a single leaf field.""" + ann = _unwrap_optional(annotation) + + if ann is date: + return "date" + if ann is time: + return "time" + if ann is bool: + return "exact" + if ann is int: + return "number" + if ann is float: + return "money" if field_name in _MONEY_FIELD_NAMES else "number" + if ann is str: + return "exact" if field_name in _EXACT_STRING_FIELD_NAMES else "text" + # Fallback for enums / unusual types. + return "exact" + + +def flatten_model( + model_or_dict: BaseModel | dict[str, Any] | None, + schema_cls: type[BaseModel], + prefix: str = "", +) -> FieldMap: + """Flatten a Pydantic model instance OR a raw dict against `schema_cls`. + + Ground-truth data comes in as dict (from JSONL); extractor output comes in + as Pydantic. This function handles both by using `schema_cls` as the shape + reference and looking up values in whichever form was passed. + """ + out: FieldMap = {} + if model_or_dict is None: + return out + + # Normalize input to a dict-ish accessor. + def _get(name: str) -> Any: + if isinstance(model_or_dict, BaseModel): + return getattr(model_or_dict, name, None) + return model_or_dict.get(name) + + for field_name, field_info in schema_cls.model_fields.items(): + path = f"{prefix}{field_name}" + value = _get(field_name) + annotation = _unwrap_optional(field_info.annotation) + origin = get_origin(annotation) + + # Case 1: nested Pydantic model + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + out.update(flatten_model(value, annotation, prefix=f"{path}.")) + continue + + # Case 2: list[NestedModel] — flatten each element by index + if origin is list: + inner = get_args(annotation)[0] + inner = _unwrap_optional(inner) + if isinstance(inner, type) and issubclass(inner, BaseModel): + items = value or [] + # Record list length under a synthetic key so eval can + # detect missing/extra items even when both sides are empty. + out[f"{path}[]"] = (len(items), "number") + for i, item in enumerate(items): + out.update(flatten_model(item, inner, prefix=f"{path}[{i}].")) + continue + # list of primitives — treat as one bucket + out[path] = (value, "exact") + continue + + # Case 3: leaf field + out[path] = (value, _classify(field_name, field_info.annotation)) + + return out diff --git a/src/eval/metrics.py b/src/eval/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..35e810ac02bb7ad91ed8f3ed37e2b9d03e04546a --- /dev/null +++ b/src/eval/metrics.py @@ -0,0 +1,196 @@ +"""Metrics aggregation: TP/FP/FN counters, precision/recall/F1. + +Field-level classification per (doc, field): + - TP: both non-null AND comparator matches + - FP: prediction non-null AND (truth is null OR comparator fails) + - FN: truth non-null AND (prediction is null OR comparator fails) + - TN: both null (NOT counted — trivial) + +Note: a "wrong" prediction counts as BOTH FP and FN by convention. This +matches how NER / structured-extraction leaderboards score mismatches +(over-count once as a wrong prediction and once as a missed truth). + +At the document level, `exact_match` is True iff every field in the doc is +TP or TN. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from src.eval.comparators import compare +from src.eval.flatten import FieldMap + + +@dataclass +class FieldStat: + """Aggregate stats for one field across all docs.""" + + field: str + field_type: str + tp: int = 0 + fp: int = 0 + fn: int = 0 + tn: int = 0 + + @property + def support(self) -> int: + """Number of docs where truth was non-null (denominator for recall).""" + return self.tp + self.fn + + @property + def precision(self) -> float: + denom = self.tp + self.fp + return self.tp / denom if denom else 0.0 + + @property + def recall(self) -> float: + denom = self.tp + self.fn + return self.tp / denom if denom else 0.0 + + @property + def f1(self) -> float: + p, r = self.precision, self.recall + return 2 * p * r / (p + r) if (p + r) else 0.0 + + def to_dict(self) -> dict[str, Any]: + return { + "field": self.field, + "field_type": self.field_type, + "tp": self.tp, + "fp": self.fp, + "fn": self.fn, + "tn": self.tn, + "support": self.support, + "precision": round(self.precision, 4), + "recall": round(self.recall, 4), + "f1": round(self.f1, 4), + } + + +@dataclass +class DocStat: + """Per-document stats.""" + + doc_id: str + fields_correct: int = 0 + fields_total: int = 0 + exact_match: bool = False + per_field: list[dict[str, Any]] = field(default_factory=list) + latency_ms: float = 0.0 + cost_usd: float = 0.0 + error: str | None = None + + +def score_doc( + doc_id: str, + predicted: FieldMap, + truth: FieldMap, +) -> tuple[DocStat, dict[str, tuple[int, int, int, int]]]: + """Score one document. Returns (DocStat, {field -> (tp, fp, fn, tn)}).""" + # Union of paths — every field either side reported. + all_paths = sorted(set(predicted) | set(truth)) + per_field_counts: dict[str, tuple[int, int, int, int]] = {} + stat = DocStat(doc_id=doc_id) + + fields_ok = 0 + fields_scored = 0 + exact = True + + for path in all_paths: + p_val, p_type = predicted.get(path, (None, "exact")) + t_val, t_type = truth.get(path, (None, "exact")) + field_type = t_type if path in truth else p_type + + p_null = p_val is None + t_null = t_val is None + + if p_null and t_null: + per_field_counts[path] = (0, 0, 0, 1) # TN — trivial + continue + + fields_scored += 1 + matched, score = compare(p_val, t_val, field_type) + + if matched and not p_null and not t_null: + per_field_counts[path] = (1, 0, 0, 0) + fields_ok += 1 + outcome = "TP" + elif p_null and not t_null: + per_field_counts[path] = (0, 0, 1, 0) + exact = False + outcome = "FN" + elif t_null and not p_null: + per_field_counts[path] = (0, 1, 0, 0) + exact = False + outcome = "FP" + else: + # both non-null but comparator says no + per_field_counts[path] = (0, 1, 1, 0) + exact = False + outcome = "MISMATCH" + + stat.per_field.append( + { + "field": path, + "field_type": field_type, + "predicted": _stringify(p_val), + "truth": _stringify(t_val), + "outcome": outcome, + "score": round(score, 3), + } + ) + + stat.fields_correct = fields_ok + stat.fields_total = fields_scored + stat.exact_match = exact and fields_scored > 0 + return stat, per_field_counts + + +def aggregate( + per_doc_counts: list[dict[str, tuple[int, int, int, int]]], + field_types: dict[str, str], +) -> dict[str, FieldStat]: + """Sum per-doc counts into FieldStat objects keyed by field path.""" + stats: dict[str, FieldStat] = {} + for doc_counts in per_doc_counts: + for path, (tp, fp, fn, tn) in doc_counts.items(): + if path not in stats: + stats[path] = FieldStat(field=path, field_type=field_types.get(path, "exact")) + s = stats[path] + s.tp += tp + s.fp += fp + s.fn += fn + s.tn += tn + return stats + + +def micro_macro(stats: dict[str, FieldStat]) -> dict[str, float]: + """Compute micro-F1 (pool all counts) and macro-F1 (mean of per-field F1).""" + if not stats: + return {"micro_precision": 0, "micro_recall": 0, "micro_f1": 0, "macro_f1": 0} + + tp = sum(s.tp for s in stats.values()) + fp = sum(s.fp for s in stats.values()) + fn = sum(s.fn for s in stats.values()) + micro_p = tp / (tp + fp) if (tp + fp) else 0.0 + micro_r = tp / (tp + fn) if (tp + fn) else 0.0 + micro_f1 = 2 * micro_p * micro_r / (micro_p + micro_r) if (micro_p + micro_r) else 0.0 + + supported = [s for s in stats.values() if s.support > 0] + macro_f1 = sum(s.f1 for s in supported) / len(supported) if supported else 0.0 + + return { + "micro_precision": round(micro_p, 4), + "micro_recall": round(micro_r, 4), + "micro_f1": round(micro_f1, 4), + "macro_f1": round(macro_f1, 4), + } + + +def _stringify(v: Any) -> str: + if v is None: + return "" + if isinstance(v, float): + return f"{v:.4f}".rstrip("0").rstrip(".") + return str(v) diff --git a/src/eval/report.py b/src/eval/report.py new file mode 100644 index 0000000000000000000000000000000000000000..c04eb44b94cf23c90f0b91b53e21bdb3619fd2bf --- /dev/null +++ b/src/eval/report.py @@ -0,0 +1,126 @@ +"""CSV + markdown reporters for EvalReport.""" +from __future__ import annotations + +import csv +import json +from datetime import datetime, timezone +from pathlib import Path + +from src.eval.runner import EvalReport + + +# --- CSV ------------------------------------------------------------------- + +def write_per_record_csv(report: EvalReport, out_path: str | Path) -> Path: + """One row per (doc, field) with predicted, truth, outcome, score.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + with out_path.open("w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow([ + "doc_id", "field", "field_type", "predicted", "truth", + "outcome", "score", "latency_ms", "cost_usd", + ]) + for doc in report.doc_stats: + if doc.error: + w.writerow([doc.doc_id, "__error__", "", "", "", "ERROR", 0.0, "", ""]) + continue + for row in doc.per_field: + w.writerow([ + doc.doc_id, + row["field"], + row["field_type"], + row["predicted"], + row["truth"], + row["outcome"], + row["score"], + round(doc.latency_ms, 1), + round(doc.cost_usd, 6), + ]) + return out_path + + +def write_summary_json(report: EvalReport, out_path: str | Path) -> Path: + """Machine-readable summary — feeds the multi-model benchmark table.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "summary": report.summary(), + "aggregate": report.aggregate, + "field_stats": {k: s.to_dict() for k, s in report.field_stats.items()}, + } + out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return out_path + + +# --- Markdown -------------------------------------------------------------- + +def write_markdown_summary(report: EvalReport, out_path: str | Path) -> Path: + """Resume-worthy markdown: headline metrics + top/bottom field table.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + s = report.summary() + lines: list[str] = [] + lines.append(f"# Evaluation Report — `{report.doc_type}` on `{report.model}`") + lines.append("") + lines.append(f"_Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}_") + lines.append("") + lines.append("## Headline") + lines.append("") + lines.append("| Metric | Value |") + lines.append("|---|---|") + lines.append(f"| Documents evaluated | {s['n_docs']} |") + lines.append(f"| Extractor errors | {s['errors']} |") + lines.append(f"| **Micro F1** | **{s['micro_f1']:.4f}** |") + lines.append(f"| **Macro F1** | **{s['macro_f1']:.4f}** |") + lines.append(f"| Doc exact-match rate| {s['doc_exact_match']:.2%} |") + lines.append(f"| Mean latency | {s['mean_latency_ms']:.0f} ms |") + lines.append(f"| Mean cost / doc | ${s['mean_cost_usd']:.6f} |") + lines.append(f"| Total cost | ${s['total_cost_usd']:.4f} |") + lines.append(f"| Wall time | {s['wall_time_s']:.2f} s |") + lines.append("") + + lines.append("## Per-field performance") + lines.append("") + lines.append("| Field | Type | Support | Precision | Recall | F1 |") + lines.append("|---|---|---:|---:|---:|---:|") + ordered = sorted( + report.field_stats.values(), + key=lambda st: (-st.support, -st.f1, st.field), + ) + for st in ordered: + if st.support == 0 and st.fp == 0: + continue # skip fields never seen + lines.append( + f"| `{st.field}` | {st.field_type} | {st.support} | " + f"{st.precision:.3f} | {st.recall:.3f} | {st.f1:.3f} |" + ) + lines.append("") + + if report.n_errors: + lines.append("## Errors") + lines.append("") + for d in report.doc_stats: + if d.error: + lines.append(f"- `{d.doc_id}`: {d.error}") + lines.append("") + + out_path.write_text("\n".join(lines), encoding="utf-8") + return out_path + + +# --- Convenience ----------------------------------------------------------- + +def write_reports(report: EvalReport, out_dir: str | Path) -> dict[str, Path]: + """Write CSV, JSON summary, and markdown into `out_dir`. Returns all paths.""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + tag = f"{report.doc_type}_{report.model.replace('/', '_')}" + return { + "csv": write_per_record_csv(report, out_dir / f"{tag}_per_record.csv"), + "json": write_summary_json(report, out_dir / f"{tag}_summary.json"), + "markdown": write_markdown_summary(report, out_dir / f"{tag}_summary.md"), + } diff --git a/src/eval/runner.py b/src/eval/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..fcf9a1dfb5b6248c83294e70b341b94842a7dacc --- /dev/null +++ b/src/eval/runner.py @@ -0,0 +1,152 @@ +"""Eval runner: iterate JSONL ground truth, call extractor, score, aggregate. + +Design note — extractor is injectable: + The runner takes any `Callable[[dict], tuple[ExtractionResult, ExtractionMetrics]]`. + This keeps the runner testable without hitting the OpenAI API: tests pass + a fake callable that returns pre-baked results. The CLI passes a real + extractor closure that loads document bytes and calls DocumentExtractor. +""" +from __future__ import annotations + +import time as _time +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from statistics import mean +from typing import Any + +from pydantic import BaseModel + +from src.eval.flatten import flatten_model +from src.eval.metrics import DocStat, FieldStat, aggregate, micro_macro, score_doc +from src.schemas import ExtractionResult +from src.schemas.registry import get_schema +from src.utils.cost_tracker import ExtractionMetrics +from src.utils.logging import logger + +# --- Types ----------------------------------------------------------------- + +# (record_dict) -> (ExtractionResult, ExtractionMetrics) +ExtractorFn = Callable[[dict], tuple[ExtractionResult, ExtractionMetrics]] + + +@dataclass +class EvalReport: + """Everything you need to write CSV + markdown reports.""" + + doc_type: str + model: str + n_docs: int + n_errors: int + field_stats: dict[str, FieldStat] + doc_stats: list[DocStat] + aggregate: dict[str, float] + doc_exact_match_rate: float + mean_latency_ms: float + mean_cost_usd: float + total_cost_usd: float + wall_time_s: float + + def summary(self) -> dict[str, Any]: + """One-line resume-worthy summary.""" + return { + "model": self.model, + "doc_type": self.doc_type, + "n_docs": self.n_docs, + "errors": self.n_errors, + "micro_f1": self.aggregate.get("micro_f1", 0.0), + "macro_f1": self.aggregate.get("macro_f1", 0.0), + "doc_exact_match": round(self.doc_exact_match_rate, 4), + "mean_latency_ms": round(self.mean_latency_ms, 1), + "mean_cost_usd": round(self.mean_cost_usd, 6), + "total_cost_usd": round(self.total_cost_usd, 4), + "wall_time_s": round(self.wall_time_s, 2), + } + + +def run_eval( + records: Sequence[dict], + extractor: ExtractorFn, + doc_type: str, + *, + model_label: str = "unknown", + limit: int | None = None, +) -> EvalReport: + """Run the full eval loop. + + - `records`: JSONL rows, each a dict with keys "id" and "ground_truth". + - `extractor(record)` must return (ExtractionResult, ExtractionMetrics). + - `doc_type`: "invoice" | "receipt" — selects the schema for flattening. + - `model_label`: purely for the report header; extractor decides real model. + - `limit`: cap number of records (handy for quick smoke runs). + """ + schema_cls: type[BaseModel] = get_schema(doc_type) + if limit: + records = list(records)[:limit] + + per_doc_counts: list[dict[str, tuple[int, int, int, int]]] = [] + doc_stats: list[DocStat] = [] + field_types: dict[str, str] = {} + latencies: list[float] = [] + costs: list[float] = [] + errors = 0 + + wall_start = _time.perf_counter() + + for i, rec in enumerate(records): + doc_id = rec.get("id", f"doc_{i}") + truth_dict = rec.get("ground_truth", {}) + truth_flat = flatten_model(truth_dict, schema_cls) + + try: + result, metrics = extractor(rec) + except Exception as e: # noqa: BLE001 + logger.warning(f"[eval] extractor failed for {doc_id}: {e}") + errors += 1 + doc_stats.append(DocStat(doc_id=doc_id, error=str(e))) + # Count every truth field as FN so recall reflects the failure. + per_doc_counts.append({p: (0, 0, 1, 0) for p, (v, _) in truth_flat.items() if v is not None}) + for p, (_v, t) in truth_flat.items(): + field_types.setdefault(p, t) + continue + + pred_flat = flatten_model(result.data, schema_cls) + # Merge type info from both sides (truth wins on conflict). + for p, (_v, t) in pred_flat.items(): + field_types.setdefault(p, t) + for p, (_v, t) in truth_flat.items(): + field_types[p] = t + + doc_stat, counts = score_doc(doc_id, pred_flat, truth_flat) + doc_stat.latency_ms = metrics.latency_ms + doc_stat.cost_usd = metrics.cost_usd + doc_stats.append(doc_stat) + per_doc_counts.append(counts) + latencies.append(metrics.latency_ms) + costs.append(metrics.cost_usd) + + wall = _time.perf_counter() - wall_start + + field_stats = aggregate(per_doc_counts, field_types) + agg = micro_macro(field_stats) + + scored_docs = [d for d in doc_stats if d.error is None] + exact_rate = ( + sum(1 for d in scored_docs if d.exact_match) / len(scored_docs) + if scored_docs + else 0.0 + ) + + return EvalReport( + doc_type=doc_type, + model=model_label, + n_docs=len(records), + n_errors=errors, + field_stats=field_stats, + doc_stats=doc_stats, + aggregate=agg, + doc_exact_match_rate=exact_rate, + mean_latency_ms=mean(latencies) if latencies else 0.0, + mean_cost_usd=mean(costs) if costs else 0.0, + total_cost_usd=sum(costs), + wall_time_s=wall, + ) diff --git a/src/ui/README.md b/src/ui/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1a96347b741ae05ba7de776d7339ce6deb0d5b14 --- /dev/null +++ b/src/ui/README.md @@ -0,0 +1,17 @@ +# src/ui — deprecated in favor of `ui/` + +Original plan for this folder was a Streamlit demo. We upgraded to a proper +React + Motion + R3F frontend that lives at the repo root under `ui/`. + +Streamlit couldn't carry the Paper & Ink design language (custom fonts, +theme variables, kinetic typography, 3D scene), so we swapped stacks. + +To run the UI: + +```bash +cd ui +npm install +npm run dev +``` + +See `ui/README.md` for the full picture. diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..c0746fc7acd700c63ae3ed3c1c58361c49ae5bfb --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,198 @@ +"""FastAPI endpoint tests using TestClient + dependency_overrides. + +No OpenAI key is required — `get_extractor` is overridden with a fake that +returns a hand-built ExtractionResult. This tests the API layer in isolation +from the extractor. +""" +from __future__ import annotations + +import io + +import pytest +from fastapi.testclient import TestClient + +from src.api.deps import MAX_UPLOAD_BYTES, get_extractor +from src.api.main import create_app +from src.schemas import ExtractionResult, Receipt +from src.utils.cost_tracker import ExtractionMetrics + + +# --- Fake extractor -------------------------------------------------------- + +class _FakeExtractor: + """Stand-in for DocumentExtractor — no network. Records last call for asserts.""" + + def __init__(self): + self.last_call: dict = {} + + def extract(self, file_bytes, filename, doc_type, *, model_override=None, render_images=True): + self.last_call = { + "filename": filename, + "doc_type": doc_type, + "size": len(file_bytes), + "model_override": model_override, + } + data = Receipt(merchant="ACME COFFEE", total=4.50, currency="USD") + result = ExtractionResult( + document_type=doc_type, + data=data, + field_confidences=[], + overall_confidence=0.95, + warnings=[], + raw_text_snippet="ACME COFFEE\nTotal: $4.50", + ) + metrics = ExtractionMetrics( + input_tokens=100, output_tokens=50, latency_ms=123.4, + model=model_override or "gpt-5-nano", + ) + return result, metrics + + +@pytest.fixture +def fake_extractor(): + return _FakeExtractor() + + +@pytest.fixture +def client(fake_extractor): + app = create_app() + app.dependency_overrides[get_extractor] = lambda: fake_extractor + with TestClient(app) as c: + yield c + + +# --- Root / health --------------------------------------------------------- + +class TestHealth: + def test_root(self, client): + r = client.get("/") + assert r.status_code == 200 + body = r.json() + assert body["service"] == "structured-data-extraction" + assert "version" in body + + def test_health(self, client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + def test_request_id_header_echoed(self, client): + r = client.get("/health", headers={"X-Request-ID": "test-rid-123"}) + assert r.headers["X-Request-ID"] == "test-rid-123" + + def test_request_id_generated_when_absent(self, client): + r = client.get("/health") + assert r.headers.get("X-Request-ID") + + +# --- Schemas --------------------------------------------------------------- + +class TestSchemas: + def test_list_schemas(self, client): + r = client.get("/schemas") + assert r.status_code == 200 + body = r.json() + assert "invoice" in body["doc_types"] + assert "receipt" in body["doc_types"] + + def test_get_receipt_schema(self, client): + r = client.get("/schemas/receipt") + assert r.status_code == 200 + schema = r.json() + assert schema["type"] == "object" + # Receipt has 'merchant' and 'total' as required-ish leaves + assert "properties" in schema + assert "merchant" in schema["properties"] + assert "total" in schema["properties"] + + def test_unknown_doc_type_returns_400_envelope(self, client): + r = client.get("/schemas/spaceship_manual") + assert r.status_code == 400 + env = r.json() + assert env["error"]["code"] == "unsupported_doc_type" + assert env["error"]["request_id"] + + +# --- Extract --------------------------------------------------------------- + +class TestExtract: + def _upload(self, client, *, filename="receipt.png", content=b"\x89PNG\r\n\x1a\n" + b"x" * 128, + content_type="image/png", doc_type="receipt", model=None): + files = {"file": (filename, io.BytesIO(content), content_type)} + data = {"doc_type": doc_type} + if model is not None: + data["model"] = model + return client.post("/extract", files=files, data=data) + + def test_extract_happy_path(self, client, fake_extractor): + r = self._upload(client) + assert r.status_code == 200, r.text + body = r.json() + assert body["result"]["document_type"] == "receipt" + assert body["result"]["data"]["merchant"] == "ACME COFFEE" + assert body["result"]["data"]["total"] == 4.5 + assert body["metrics"]["input_tokens"] == 100 + assert body["metrics"]["model"] == "gpt-5-nano" + # Extractor received what we uploaded + assert fake_extractor.last_call["doc_type"] == "receipt" + assert fake_extractor.last_call["filename"] == "receipt.png" + + def test_extract_model_override_flows_through(self, client, fake_extractor): + r = self._upload(client, model="gpt-5.4") + assert r.status_code == 200 + assert r.json()["metrics"]["model"] == "gpt-5.4" + assert fake_extractor.last_call["model_override"] == "gpt-5.4" + + def test_unknown_doc_type_400(self, client): + r = self._upload(client, doc_type="spaceship_manual") + assert r.status_code == 400 + assert r.json()["error"]["code"] == "unsupported_doc_type" + + def test_unsupported_extension_415(self, client): + r = self._upload(client, filename="malware.exe", content=b"MZ" + b"x" * 100, + content_type="application/octet-stream") + assert r.status_code == 415 + assert r.json()["error"]["code"] == "unsupported_media_type" + + def test_missing_file_422(self, client): + r = client.post("/extract", data={"doc_type": "receipt"}) + assert r.status_code == 422 + assert r.json()["error"]["code"] == "validation_error" + + def test_missing_doc_type_422(self, client): + files = {"file": ("x.png", io.BytesIO(b"\x89PNG"), "image/png")} + r = client.post("/extract", files=files) + assert r.status_code == 422 + + def test_empty_file_422(self, client): + r = self._upload(client, content=b"") + assert r.status_code == 422 + assert r.json()["error"]["code"] == "empty_document" + + def test_oversized_file_413(self, client, monkeypatch): + # Shrink the cap so we don't have to actually upload 10MB + monkeypatch.setattr("src.api.routers.extract.MAX_UPLOAD_BYTES", 32) + r = self._upload(client, content=b"\x89PNG" + b"x" * 200) + assert r.status_code == 413 + env = r.json() + assert env["error"]["code"] == "file_too_large" + assert env["error"]["details"]["max_bytes"] == 32 + + def test_extractor_failure_502(self, client, fake_extractor): + def _raise(*_a, **_k): + raise RuntimeError("openai unreachable") + + fake_extractor.extract = _raise + r = self._upload(client) + assert r.status_code == 502 + assert r.json()["error"]["code"] == "extraction_failed" + + +# --- Error envelope shape -------------------------------------------------- + +class TestErrorEnvelope: + def test_envelope_has_all_fields(self, client): + r = client.get("/schemas/nope") + body = r.json() + assert set(body.keys()) == {"error"} + assert set(body["error"].keys()) >= {"code", "message", "request_id", "details"} diff --git a/tests/unit/test_eval.py b/tests/unit/test_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..63cceb9880e64b3f943ada076a37c2b02d68c488 --- /dev/null +++ b/tests/unit/test_eval.py @@ -0,0 +1,293 @@ +"""Tests for the evaluation harness. + +We test each layer independently plus one end-to-end run with a mocked +extractor. No OpenAI calls are made — the runner accepts any callable that +returns (ExtractionResult, ExtractionMetrics), which is what makes this +testable offline. + +Layers covered: comparators, flatten, doc-scoring, aggregation, runner, reports. +Version tag v2 (forces bytecode invalidation on mounted filesystems). +""" +from __future__ import annotations + +from datetime import date + +import pytest + +from src.data_prep.writer import read_jsonl +from src.eval.comparators import ( + compare, + match_date, + match_exact, + match_money, + match_number, + match_text, +) +from src.eval.flatten import flatten_model +from src.eval.metrics import aggregate, micro_macro, score_doc +from src.eval.runner import run_eval +from src.schemas import ExtractionResult, Receipt +from src.utils.cost_tracker import ExtractionMetrics + + +# --- Comparators ----------------------------------------------------------- + +class TestComparators: + def test_text_fuzzy_match(self): + assert match_text("TAN WOON YANN", "Tan Woon Yann")[0] + assert match_text("TAN WOON YANN SDN BHD", "TAN WOON YANN")[0] + assert match_text("Starbucks", "McDonalds")[0] is False + + def test_text_null_handling(self): + assert match_text(None, None) == (True, 1.0) + assert match_text("x", None)[0] is False + assert match_text(None, "x")[0] is False + + def test_money_within_tolerance(self): + # Absolute tolerance: 0.01 + assert match_money(72.00, 72.01)[0] is True + # Relative tolerance: 0.5% of 1000 = 5.00, so 4.00 delta passes + assert match_money(1000.00, 1004.00)[0] is True + + def test_money_outside_tolerance(self): + # Small values where 0.5% rel is tiny and abs 0.01 is exceeded + assert match_money(1.00, 1.05)[0] is False + # 100 vs 102 -> abs 2 > 0.01, rel 2% > 0.5% + assert match_money(100.00, 102.00)[0] is False + + def test_money_null(self): + assert match_money(None, None)[0] is True + assert match_money(0.0, None)[0] is False + + def test_number_exact(self): + assert match_number(3, 3)[0] is True + assert match_number(3, 3.0000001)[0] is True + assert match_number(3, 4)[0] is False + + def test_date_iso(self): + assert match_date(date(2018, 6, 25), "2018-06-25")[0] is True + assert match_date(date(2018, 6, 25), date(2018, 6, 25))[0] is True + assert match_date(date(2018, 6, 25), "2018-06-26")[0] is False + + def test_exact_normalizes(self): + assert match_exact("USD", " usd ")[0] is True + assert match_exact("USD", "EUR")[0] is False + + def test_dispatch(self): + assert compare("Hello world", "hello world", "text")[0] + assert compare(1.00, 1.005, "money")[0] + assert compare(date(2020, 1, 1), "2020-01-01", "date")[0] + assert compare("USD", "usd", "exact")[0] + assert compare(3, 3, "number")[0] + + +# --- Flattener ------------------------------------------------------------- + +class TestFlatten: + def test_receipt_flatten_basic(self): + r = Receipt(merchant="ACME", total=10.00, currency="USD") + flat = flatten_model(r, Receipt) + assert flat["merchant"] == ("ACME", "text") + assert flat["total"] == (10.00, "money") + assert flat["currency"] == ("USD", "exact") + + def test_receipt_flatten_nested_address(self): + r = Receipt( + merchant="X", + total=1.0, + currency="USD", + merchant_address={"line1": "123 Main St", "city": "NYC"}, + ) + flat = flatten_model(r, Receipt) + assert flat["merchant_address.line1"][0] == "123 Main St" + assert flat["merchant_address.city"] == ("NYC", "text") + assert flat["merchant_address.postal_code"] == (None, "exact") + + def test_flatten_line_items(self): + r = Receipt( + merchant="X", + total=5.0, + currency="USD", + line_items=[{"description": "coffee", "quantity": 1, "total": 5.0}], + ) + flat = flatten_model(r, Receipt) + assert flat["line_items[]"] == (1, "number") + assert flat["line_items[0].description"] == ("coffee", "text") + assert flat["line_items[0].unit_price"] == (None, "money") + assert flat["line_items[0].total"] == (5.0, "money") + + def test_flatten_dict_and_model_symmetric(self): + gt = { + "merchant": "ACME", "total": 10.00, "currency": "USD", + "merchant_address": None, "merchant_phone": None, + "transaction_date": None, "transaction_time": None, + "receipt_number": None, "line_items": [], "subtotal": None, + "tax": None, "tip": None, "payment_method": None, + } + pred = Receipt(merchant="ACME", total=10.00, currency="USD") + assert set(flatten_model(gt, Receipt)) == set(flatten_model(pred, Receipt)) + + +# --- Doc-level scoring ----------------------------------------------------- + +class TestScoring: + def _pair(self, gt, pred): + return score_doc("doc_1", flatten_model(pred, Receipt), flatten_model(gt, Receipt)) + + def test_perfect_match(self): + r = Receipt(merchant="ACME", total=10.00, currency="USD") + stat, counts = self._pair(r, r) + assert stat.exact_match + for tp, fp, fn, _tn in counts.values(): + assert fp == 0 and fn == 0 + + def test_wrong_merchant_is_mismatch(self): + gt = Receipt(merchant="ACME", total=10.0, currency="USD") + pred = Receipt(merchant="BETA STORE", total=10.0, currency="USD") + stat, counts = self._pair(gt, pred) + assert stat.exact_match is False + assert counts["merchant"] == (0, 1, 1, 0) + assert counts["total"] == (1, 0, 0, 0) + + def test_missing_field_is_fn(self): + gt = Receipt(merchant="ACME", total=10.0, currency="USD", tax=1.0) + pred = Receipt(merchant="ACME", total=10.0, currency="USD") + _stat, counts = self._pair(gt, pred) + assert counts["tax"] == (0, 0, 1, 0) + + def test_hallucinated_field_is_fp(self): + gt = Receipt(merchant="ACME", total=10.0, currency="USD") + pred = Receipt(merchant="ACME", total=10.0, currency="USD", tax=1.0) + _stat, counts = self._pair(gt, pred) + assert counts["tax"] == (0, 1, 0, 0) + + +# --- Aggregation ----------------------------------------------------------- + +class TestAggregation: + def test_micro_macro_perfect(self): + counts = [ + {"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)}, + {"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)}, + ] + stats = aggregate(counts, {"merchant": "text", "total": "money"}) + summary = micro_macro(stats) + assert summary["micro_f1"] == 1.0 + assert summary["macro_f1"] == 1.0 + + def test_micro_macro_partial(self): + counts = [ + {"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)}, + {"merchant": (0, 1, 1, 0), "total": (1, 0, 0, 0)}, + ] + stats = aggregate(counts, {"merchant": "text", "total": "money"}) + assert stats["merchant"].precision == 0.5 + assert stats["merchant"].recall == 0.5 + assert stats["merchant"].f1 == 0.5 + assert stats["total"].f1 == 1.0 + assert micro_macro(stats)["macro_f1"] == 0.75 + + def test_micro_macro_all_wrong(self): + counts = [{"merchant": (0, 1, 1, 0)}] + stats = aggregate(counts, {"merchant": "text"}) + assert micro_macro(stats)["micro_f1"] == 0.0 + + +# --- End-to-end runner ----------------------------------------------------- + +class TestRunner: + def _fake_extractor(self, mutator=None): + def _extract(record): + gt = record["ground_truth"] + data = Receipt.model_validate(gt) + if mutator is not None: + data = mutator(data) + return ( + ExtractionResult( + document_type="receipt", + data=data, + field_confidences=[], + overall_confidence=1.0, + warnings=[], + ), + ExtractionMetrics( + input_tokens=100, output_tokens=50, latency_ms=250.0, model="fake" + ), + ) + return _extract + + def test_perfect_run_on_samples(self): + records = read_jsonl("data/samples/sroie_sample.jsonl") + report = run_eval(records, self._fake_extractor(), doc_type="receipt") + s = report.summary() + assert s["n_docs"] == len(records) + assert s["errors"] == 0 + assert s["micro_f1"] == 1.0 + assert s["macro_f1"] == 1.0 + assert s["doc_exact_match"] == 1.0 + + def test_run_with_extractor_error(self): + def broken(_rec): + raise RuntimeError("api down") + + records = read_jsonl("data/samples/sroie_sample.jsonl")[:2] + report = run_eval(records, broken, doc_type="receipt") + assert report.n_errors == len(records) + assert report.aggregate["micro_f1"] == 0.0 + + def test_run_with_wrong_merchant(self): + def wrong_merchant(r: Receipt) -> Receipt: + return r.model_copy(update={"merchant": "TOTALLY WRONG NAME"}) + + records = read_jsonl("data/samples/sroie_sample.jsonl") + report = run_eval( + records, self._fake_extractor(mutator=wrong_merchant), doc_type="receipt" + ) + assert report.field_stats["merchant"].f1 == 0.0 + assert 0.0 < report.aggregate["micro_f1"] < 1.0 + assert report.doc_exact_match_rate == 0.0 + + +# --- Reports --------------------------------------------------------------- + +class TestReports: + def test_write_reports_creates_all_three(self, tmp_path): + from src.eval.report import write_reports + + records = [ + { + "id": "smoke", + "ground_truth": { + "merchant": "ACME", "total": 1.0, "currency": "USD", + "merchant_address": None, "merchant_phone": None, + "transaction_date": None, "transaction_time": None, + "receipt_number": None, "line_items": [], "subtotal": None, + "tax": None, "tip": None, "payment_method": None, + }, + } + ] + + def extractor(record): + data = Receipt.model_validate(record["ground_truth"]) + return ( + ExtractionResult( + document_type="receipt", + data=data, + field_confidences=[], + overall_confidence=1.0, + warnings=[], + ), + ExtractionMetrics( + input_tokens=1, output_tokens=1, latency_ms=1.0, model="fake" + ), + ) + + report = run_eval(records, extractor, doc_type="receipt", model_label="fake") + paths = write_reports(report, tmp_path) + + assert paths["csv"].exists() + assert paths["json"].exists() + assert paths["markdown"].exists() + md = paths["markdown"].read_text() + assert "Micro F1" in md + assert "1.0000" in md diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b01a18a1ea407f7d39acb14a49bf4ccf8f82c30f --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.vite/ +*.log +.DS_Store diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c6498cc7e1771cf813a5dbdc72cdd7292ee34327 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,85 @@ +# Ledger UI + +The Paper & Ink frontend for the structured-data-extraction API. + +## Stack + +- **Vite + React + TypeScript** — fast dev loop, small bundle. +- **Tailwind** — utility layout only. All colors, typography, and design + tokens live as CSS variables in `src/styles/theme.css`, so dark/light mode + is a single attribute flip on ``. +- **Motion** (`motion/react`) — component-level animation. +- **React Three Fiber + drei + three** — the 3D paper sheet in the hero. +- **Google Fonts** — Instrument Serif (display), Geist (UI), JetBrains Mono + (code). All free. + +## Run locally + +```bash +# 1. Install +cd ui +npm install + +# 2. Point at the API (in another terminal, from the repo root): +uvicorn src.api.main:app --reload + +# 3. Start the dev server +npm run dev + +# open http://localhost:5173 +``` + +Vite proxies `/api/*` to `http://localhost:8000`, so no CORS needed in dev. + +## Configuration + +- `VITE_API_BASE` (optional) — override the API origin for prod builds. + Defaults to `/api`. + +## Design tokens + +Everything visual is in `src/styles/theme.css`: + +- `--bg`, `--surface`, `--surface-2`, `--rule` — surfaces + dividers +- `--ink`, `--ink-strong`, `--ink-soft`, `--ink-mute` — text weights +- `--accent`, `--accent-hover`, `--accent-soft` — coral +- `--sage`, `--mustard` — confidence tiers + warnings + +To try a color, edit the variable — both modes update simultaneously. + +## Project shape + +``` +ui/ +├── index.html # font preload + theme priming (no-flash) +├── src/ +│ ├── main.tsx # ReactDOM render +│ ├── App.tsx # page composition +│ ├── styles/ +│ │ ├── theme.css # design tokens (light + dark) +│ │ └── globals.css # base + grain overlay + focus/cursor +│ ├── components/ +│ │ ├── TopNav.tsx # logo + theme toggle +│ │ ├── ThemeToggle.tsx # hand-drawn sun/moon +│ │ ├── CustomCursor.tsx # spring-lag ink dot +│ │ ├── Hero.tsx # kinetic headline + stats + 3D scene +│ │ ├── PaperScene.tsx # R3F: floating paper w/ mouse parallax +│ │ ├── ExtractSection.tsx # workbench (dropzone + results) +│ │ ├── Dropzone.tsx # drop + browse + doc-type + samples +│ │ ├── ResultsPanel.tsx # composes the below +│ │ ├── ConfidenceInkwell.tsx # confidence as an ink vessel +│ │ ├── MetricsStrip.tsx # cost/latency/tokens/model +│ │ ├── JsonView.tsx # syntax-highlighted JSON +│ │ ├── WarningsList.tsx # model-flagged concerns +│ │ ├── HowItWorks.tsx # three chapters +│ │ ├── Numbers.tsx # magazine-style data page +│ │ └── Footer.tsx # signature line +│ ├── hooks/ +│ │ ├── useTheme.ts # dark/light + localStorage +│ │ └── useExtract.ts # upload lifecycle +│ ├── lib/ +│ │ ├── api.ts # thin fetch client + typed error envelope +│ │ └── samples.ts # committed sample docs +│ └── types.ts # mirrors src/schemas server-side +└── public/samples/ # sample_receipt.png, sample_invoice.pdf +``` diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000000000000000000000000000000000000..40cde3f5b974aed176e4c7f4adcb0e9485646bb2 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,33 @@ + + + + + + + Ledger — Structured Data Extraction + + + + + + + + + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000000000000000000000000000000000000..cca2f9d1fa8b06b2ac0b4516f0f50ccb2580ddaf --- /dev/null +++ b/ui/package.json @@ -0,0 +1,32 @@ +{ + "name": "structured-data-extractor-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Paper & Ink UI for the structured-data-extraction API.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@react-three/drei": "^9.114.0", + "@react-three/fiber": "^8.17.10", + "motion": "^11.11.17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2aa7205d4b402a1bdfbe07110c61df920b370066 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/ui/public/samples/README.txt b/ui/public/samples/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..6247982300f3e8e868668c2b8ccd324e31cf7e07 --- /dev/null +++ b/ui/public/samples/README.txt @@ -0,0 +1,12 @@ +Drop sample documents in this folder to make them available via the +"Try a sample" buttons in the UI. + +Filenames referenced by src/lib/samples.ts: + - coffee_receipt.png + - software_invoice.pdf + +To swap in your own samples, either replace these files 1:1, or edit +SAMPLE_DOCS in src/lib/samples.ts to point at whatever you drop here. + +Committed samples are optional — the UI falls back gracefully if a sample +path 404s (the button surfaces a console warning; no crash). diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21b22d7e030f9041aabeec6852bd487a31302759 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,43 @@ +/** + * App — page composition. Hero, workbench, chapters, numbers, footer. + * The 3D paper's state is lifted here so the hero can react to extraction. + */ +import { useCallback, useRef, useState } from "react"; + +import { CustomCursor } from "./components/CustomCursor"; +import { ExtractSection } from "./components/ExtractSection"; +import { Footer } from "./components/Footer"; +import { Hero } from "./components/Hero"; +import { HowItWorks } from "./components/HowItWorks"; +import { Numbers } from "./components/Numbers"; +import { TopNav } from "./components/TopNav"; +import type { PaperState } from "./components/PaperScene"; + +export default function App() { + const [paperState, setPaperState] = useState("idle"); + const heroCTA = useRef<() => void>(() => {}); + + const bindExtract = useCallback((fn: () => void) => { + heroCTA.current = fn; + }, []); + + return ( +
+ + +
+ heroCTA.current()} + /> + + + +
+
+
+ ); +} diff --git a/ui/src/components/ConfidenceInkwell.tsx b/ui/src/components/ConfidenceInkwell.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c87f5b519addcbfc02b3255bc3892cae524fff9a --- /dev/null +++ b/ui/src/components/ConfidenceInkwell.tsx @@ -0,0 +1,80 @@ +/** + * ConfidenceInkwell — the confidence score, rendered as an ink well. + * + * A tall rectangular vessel. Ink fills from the bottom to the score. The + * meniscus (top surface) has a subtle wobble via `` for that "still + * settling" feel — signals it's a live measurement, not a static bar. + * + * Color shifts by confidence tier: sage (>0.85), mustard (0.6-0.85), coral (<0.6). + */ +import { motion } from "motion/react"; + +interface Props { + score: number; // 0..1 +} + +export function ConfidenceInkwell({ score }: Props) { + const pct = Math.max(0, Math.min(1, score)); + const tier = pct >= 0.85 ? "high" : pct >= 0.6 ? "mid" : "low"; + const color = + tier === "high" + ? "var(--confidence-high)" + : tier === "mid" + ? "var(--confidence-mid)" + : "var(--confidence-low)"; + const label = tier === "high" ? "High" : tier === "mid" ? "Fair" : "Low"; + + return ( +
+
+ {/* Ink fill */} + + {/* Meniscus — a subtle wave at the top of the ink */} + + + + + + {/* Tick marks on the side of the well */} +
+ {[0.25, 0.5, 0.75].map((t) => ( +
+ ))} +
+
+ +
+

Confidence

+

+ {(pct * 100).toFixed(0)} + % +

+

+ {label} · self-reported +

+
+
+ ); +} diff --git a/ui/src/components/CustomCursor.tsx b/ui/src/components/CustomCursor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15315a453d14eb2a0f934a5a1ca6aff45ec35abe --- /dev/null +++ b/ui/src/components/CustomCursor.tsx @@ -0,0 +1,81 @@ +/** + * CustomCursor — a soft ink dot that follows the mouse with spring lag. + * On hoverable elements (marked with data-cursor="focus"), the dot expands. + * Hidden on touch devices. + * + * The spring gives it that "ink meeting paper" resistance instead of a + * hard pixel-perfect follow. + */ +import { motion, useMotionValue, useSpring } from "motion/react"; +import { useEffect, useState } from "react"; + +const SPRING = { damping: 30, stiffness: 400, mass: 0.6 }; + +export function CustomCursor() { + const x = useMotionValue(-100); + const y = useMotionValue(-100); + const sx = useSpring(x, SPRING); + const sy = useSpring(y, SPRING); + const [focused, setFocused] = useState(false); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + // Bail on coarse pointers (mobile / touch) — no custom cursor. + if (window.matchMedia("(hover: none)").matches) return; + + setVisible(true); + + const onMove = (e: MouseEvent) => { + x.set(e.clientX); + y.set(e.clientY); + }; + const onOver = (e: MouseEvent) => { + const el = e.target as HTMLElement; + const wantsFocus = !!el.closest( + 'a, button, [role="button"], input, textarea, [data-cursor="focus"]' + ); + setFocused(wantsFocus); + }; + const onLeave = () => setVisible(false); + const onEnter = () => setVisible(true); + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseover", onOver); + document.addEventListener("mouseleave", onLeave); + document.addEventListener("mouseenter", onEnter); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseover", onOver); + document.removeEventListener("mouseleave", onLeave); + document.removeEventListener("mouseenter", onEnter); + }; + }, [x, y]); + + if (!visible) return null; + + return ( + <> + + + + + ); +} diff --git a/ui/src/components/Dropzone.tsx b/ui/src/components/Dropzone.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51cda014ac7699f331445da1d8d8085e1c8e6202 --- /dev/null +++ b/ui/src/components/Dropzone.tsx @@ -0,0 +1,281 @@ +/** + * Dropzone — a large paper-textured zone that accepts PDF and images. + * + * States: + * idle : hairline border, "Drop a document" copy, sample buttons visible + * over : border deepens, corner marks pulse in + * selected : filename appears, "extract" primary CTA lights up + * busy : dashed border animates, ghost typewriter under filename + */ +import { motion, AnimatePresence } from "motion/react"; +import { useCallback, useRef, useState } from "react"; + +import type { DocType } from "@/types"; +import { SAMPLE_DOCS, loadSampleAsFile, type SampleDoc } from "@/lib/samples"; + +const ACCEPT = "application/pdf,image/png,image/jpeg,image/webp,image/tiff,image/bmp"; + +interface Props { + file: File | null; + docType: DocType; + setFile: (f: File | null) => void; + setDocType: (d: DocType) => void; + onExtract: () => void; + onSample: (sample: SampleDoc) => void; + busy: boolean; +} + +export function Dropzone({ + file, + docType, + setFile, + setDocType, + onExtract, + onSample, + busy, +}: Props) { + const inputRef = useRef(null); + const [over, setOver] = useState(false); + + const onFiles = useCallback( + (files: FileList | null) => { + if (!files || !files[0]) return; + setFile(files[0]); + }, + [setFile] + ); + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setOver(false); + onFiles(e.dataTransfer.files); + }; + + const pickSample = async (sample: SampleDoc) => { + try { + const f = await loadSampleAsFile(sample); + setFile(f); + setDocType(sample.docType); + onSample(sample); + } catch (err) { + console.warn("Sample load failed", err); + } + }; + + return ( +
+ {/* --- The dropzone card ------------------------------------------- */} +
{ + e.preventDefault(); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={onDrop} + data-cursor="focus" + className="relative overflow-hidden rounded-[6px] border border-[var(--rule)] bg-[var(--surface)] transition-colors duration-500 ease-editorial" + style={{ + borderColor: over ? "var(--ink)" : undefined, + }} + > + {/* Corner tick marks — appear when dragging over */} + + +
+

Upload

+

+ {file ? "Ready to extract" : "Drop a document"} +

+ + {file ? ( + + {file.name} + + · {(file.size / 1024).toFixed(1)} KB + + + ) : ( + + PDF or image. Up to 10 MB. We keep nothing — every extraction is + stateless. + + )} + + + + + onFiles(e.target.files)} + /> +
+ + {/* Progress bar during extraction */} + + {busy && ( + + )} + +
+ + {/* --- Options row ------------------------------------------------- */} +
+ + + +
+ + {/* --- Sample buttons --------------------------------------------- */} +
+

Or try a sample

+
+ {SAMPLE_DOCS.map((s) => ( + + ))} +
+
+
+ ); +} + +/* ------------------------------------------------------------------------ */ + +function DocTypePicker({ + value, + onChange, +}: { + value: DocType; + onChange: (v: DocType) => void; +}) { + const opts: { key: DocType; label: string }[] = [ + { key: "receipt", label: "Receipt" }, + { key: "invoice", label: "Invoice" }, + ]; + return ( +
+ Type + {opts.map((o) => { + const active = value === o.key; + return ( + + ); + })} +
+ ); +} + +function Corners({ active }: { active: boolean }) { + const stroke = "var(--ink)"; + const s = 24; + const off = 16; + const style = { transition: "opacity 400ms cubic-bezier(0.16,1,0.3,1)" }; + return ( +
+ {/* Four L-shaped corner marks */} + {[ + { top: off, left: off, path: `M0 ${s} L0 0 L${s} 0` }, + { top: off, right: off, path: `M${-s} 0 L0 0 L0 ${s}` }, + { bottom: off, left: off, path: `M0 ${-s} L0 0 L${s} 0` }, + { bottom: off, right: off, path: `M${-s} 0 L0 0 L0 ${-s}` }, + ].map((c, i) => ( + + + + ))} +
+ ); +} + +function Arrow() { + return ( + + + + ); +} diff --git a/ui/src/components/ExtractSection.tsx b/ui/src/components/ExtractSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92314d21536f7eddb60313c31a941e75f0e5e47d --- /dev/null +++ b/ui/src/components/ExtractSection.tsx @@ -0,0 +1,111 @@ +/** + * ExtractSection — the interactive core. Wires Dropzone <-> useExtract <-> + * ResultsPanel. The parent (App) needs to know when we're extracting so the + * hero's 3D paper can respond, so we lift the state up via `onStateChange`. + */ +import { useEffect, useState } from "react"; +import { motion } from "motion/react"; + +import { useExtract } from "@/hooks/useExtract"; +import type { DocType } from "@/types"; +import type { PaperState } from "./PaperScene"; + +import { Dropzone } from "./Dropzone"; +import { ResultsPanel } from "./ResultsPanel"; + +interface Props { + onStateChange: (s: PaperState) => void; + bindExtract: (fn: () => void) => void; +} + +export function ExtractSection({ onStateChange, bindExtract }: Props) { + const [file, setFile] = useState(null); + const [docType, setDocType] = useState("receipt"); + const { status, response, error, run, reset } = useExtract(); + + const busy = status === "loading"; + + // Bubble status up so hero's 3D paper can react. + useEffect(() => { + if (status === "loading") onStateChange("extracting"); + else if (status === "success") onStateChange("extracted"); + else onStateChange("idle"); + }, [status, onStateChange]); + + const doExtract = () => { + if (!file) return; + run({ file, docType }); + }; + + // The hero's "Try a sample" CTA scrolls here + focuses the browse. Expose + // that hook to the parent via bindExtract. + useEffect(() => { + bindExtract(() => { + const el = document.getElementById("extract"); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }, [bindExtract]); + + return ( +
+
+
+

The workbench

+

+ Try it on a document. +

+
+ {response && ( + + )} +
+ +
+ + { + // If a sample was picked, kick off extraction automatically. + setTimeout(doExtract, 200); + }} + busy={busy} + /> + + + + + +
+
+ ); +} diff --git a/ui/src/components/Footer.tsx b/ui/src/components/Footer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..45c0d2ca1b3ab6c0a4bcb9fbde1eafafea3f6c9c --- /dev/null +++ b/ui/src/components/Footer.tsx @@ -0,0 +1,26 @@ +/** + * Footer — a signature line, not a link farm. + */ +const GITHUB_URL = "https://github.com/adityapatel007-byte/structured-data-extractor"; + +export function Footer() { + return ( +
+
+

+ Built by{" "} + + ASP + + {" · "}open source · MIT +

+

v0.1.0 · 2026

+
+
+ ); +} diff --git a/ui/src/components/Hero.tsx b/ui/src/components/Hero.tsx new file mode 100644 index 0000000000000000000000000000000000000000..275c67565e9b0b553cc9416680dc81e790d9671f --- /dev/null +++ b/ui/src/components/Hero.tsx @@ -0,0 +1,158 @@ +/** + * Hero — the front door. + * + * Left: kinetic headline (word-by-word entrance), sub-line, and two CTAs. + * Right: the 3D PaperScene. + * + * The headline is intentionally set in Instrument Serif italic on the accent + * words. That's the signature move — an editorial italic where every AI- + * generated hero would put a color highlight. + */ +import { motion } from "motion/react"; + +import { PaperScene, type PaperState } from "./PaperScene"; + +interface Props { + onCTAClick: () => void; + paperState: PaperState; +} + +const ITEM = { + hidden: { y: 40, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { duration: 0.9, ease: [0.16, 1, 0.3, 1] }, + }, +}; + +export function Hero({ onCTAClick, paperState }: Props) { + return ( +
+ {/* Left column ------------------------------------------------------ */} +
+ + A structured-extraction service · v1 + + + + +
+ {" "} + JSON. +
+ + + Invoices, receipts, and SEC filings — parsed by GPT-5 nano, validated + against Pydantic schemas, scored per field, and benchmarked across + models. All the plumbing of a production extraction service, one API + call away. + + + + + + How it works + + + + {/* Stat strip — the resume numbers, small, editorial. */} + + + + + +
+ + {/* Right column: 3D scene ------------------------------------------- */} +
+ +
+
+ ); +} + +function Line({ words }: { words: string[] }) { + return ( + <> + {words.map((w, i) => ( + + {w} + + ))} + + ); +} + +function Arrow() { + return ( + + + + ); +} + +function Stat({ label, value, note }: { label: string; value: string; note: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+

{note}

+
+ ); +} diff --git a/ui/src/components/HowItWorks.tsx b/ui/src/components/HowItWorks.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ce43f47f462c967c4603ee39fe129ee0c2853ba --- /dev/null +++ b/ui/src/components/HowItWorks.tsx @@ -0,0 +1,62 @@ +/** + * HowItWorks — three editorial "chapters" instead of icon cards. + * Each chapter has a chapter number, a serif headline, and a short body. + * The numbers scale up on scroll into view — subtle, not showy. + */ +import { motion } from "motion/react"; + +const CHAPTERS = [ + { + n: "I", + title: "Read the document", + body: "PyMuPDF renders each page; pdfplumber extracts text; the loader auto-detects text vs image PDFs so we spend vision tokens only when we need them.", + }, + { + n: "II", + title: "Parse into a schema", + body: "The prompt requests strict JSON in a Pydantic-defined envelope. OpenAI’s structured outputs handle the schema translation — the model literally cannot invent fields.", + }, + { + n: "III", + title: "Score, benchmark, ship", + body: "The eval harness compares extracted fields against ground truth with type-appropriate matching (fuzzy text, money tolerance, ISO dates). Micro-F1 rolls up into a resume-worthy number.", + }, +]; + +export function HowItWorks() { + return ( +
+

The pipeline

+ +
+ {CHAPTERS.map((c, i) => ( + +
+ {c.n} +
+

+ {c.title} +

+

+ {c.body} +

+
+ ))} +
+
+ ); +} diff --git a/ui/src/components/JsonView.tsx b/ui/src/components/JsonView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23df6212de642fef3c0d00aba9cec82c443db068 --- /dev/null +++ b/ui/src/components/JsonView.tsx @@ -0,0 +1,79 @@ +/** + * JsonView — pretty-printed, syntax-highlighted JSON with a copy button. + * No dependencies — a small tokenizer that hits the keys/strings/numbers/ + * booleans we care about. Style-tunable via CSS variables so the palette + * shifts cleanly between light and dark. + */ +import { useMemo, useState } from "react"; + +interface Props { + data: unknown; + maxHeight?: string; +} + +export function JsonView({ data, maxHeight = "420px" }: Props) { + const pretty = useMemo(() => JSON.stringify(data, null, 2), [data]); + const [copied, setCopied] = useState(false); + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(pretty); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch { + /* clipboard denied */ + } + }; + + return ( +
+
+ extraction_result.json + +
+
+        
+      
+
+ ); +} + +/* ------------------------------------------------------------------------ */ + +// Minimal JSON syntax highlighter — enough to distinguish keys, strings, +// numbers, booleans, and null. Uses spans with CSS variable colors. +function highlight(json: string): string { + const esc = json + .replace(/&/g, "&") + .replace(//g, ">"); + + return esc.replace( + /("(?:\\.|[^"\\])*"(?:\s*:)?)|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + (m) => { + if (/^"/.test(m)) { + if (/:$/.test(m)) { + return `${m.replace(/:$/, "")}:`; + } + return `${m}`; + } + if (/true|false/.test(m)) { + return `${m}`; + } + if (/null/.test(m)) { + return `${m}`; + } + // number + return `${m}`; + } + ); +} diff --git a/ui/src/components/MetricsStrip.tsx b/ui/src/components/MetricsStrip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6850083f3a4bd15c856e588aa12338aaa1c442b7 --- /dev/null +++ b/ui/src/components/MetricsStrip.tsx @@ -0,0 +1,92 @@ +/** + * MetricsStrip — cost, latency, tokens, and model, styled like the marginalia + * on an accountant's ledger. Each is a small block with an eyebrow label and + * a display-font number, separated by thin ruled dividers. + * + * The cost gets a wax-stamp treatment — a small angled disc behind the number. + */ +import type { ExtractionMetrics } from "@/types"; + +interface Props { + metrics: ExtractionMetrics; +} + +export function MetricsStrip({ metrics }: Props) { + return ( +
+ + + + +
+ ); +} + +/* ------------------------------------------------------------------------ */ + +function Cell({ + label, + value, + small, + mono, + stamp, +}: { + label: string; + value: string; + small?: boolean; + mono?: boolean; + stamp?: boolean; +}) { + return ( +
+

{label}

+

+ {stamp && } + {value} +

+
+ ); +} + +function StampBg() { + return ( + + + + + ); +} + +/* -- formatters ----------------------------------------------------------- */ + +function formatCost(usd: number): string { + if (usd < 0.001) return `$${(usd * 1000).toFixed(2)}m`; // in mills + return `$${usd.toFixed(4)}`; +} + +function formatLatency(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} diff --git a/ui/src/components/Numbers.tsx b/ui/src/components/Numbers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ad7b3059542de2f1dc0ebe05cb6ca161db453b1 --- /dev/null +++ b/ui/src/components/Numbers.tsx @@ -0,0 +1,79 @@ +/** + * Numbers — a magazine-style data page. Big display numerals, small + * eyebrow labels, ruled dividers between rows. No cards, no gradients. + * The point is to present the resume metrics as if a designer laid them out. + */ +import { motion } from "motion/react"; + +const ROWS = [ + { + label: "Field-level F1", + value: "0.94", + sub: "SROIE test split", + }, + { + label: "Doc-level exact match", + value: "78%", + sub: "CORD test split", + }, + { + label: "Median latency", + value: "1.9s", + sub: "text-only PDFs", + }, + { + label: "Cost per doc", + value: "$0.0004", + sub: "GPT-5 nano, avg 2.4k input tokens", + }, +]; + +export function Numbers() { + return ( +
+
+
+

Quantified

+

+ The numbers behind +
+ the pipeline. +

+
+

+ Every claim below comes from the eval harness in{" "} + src/eval/. + Reproduce with{" "} + python scripts/run_eval.py. +

+
+ +
+ {ROWS.map((r, i) => ( + +

+ {r.label} +

+

+ {r.value} +

+

+ {r.sub} +

+
+ ))} +
+
+ ); +} diff --git a/ui/src/components/PaperScene.tsx b/ui/src/components/PaperScene.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6faa229f483a86692cc29bb77bce107bef632a1 --- /dev/null +++ b/ui/src/components/PaperScene.tsx @@ -0,0 +1,302 @@ +/** + * PaperScene — the hero's 3D moment. + * + * A single sheet of paper floating in soft light, slowly drifting. The paper + * is not a plain plane — it has faint ruled lines and a stamp mark drawn onto + * a canvas texture, so at rest it reads as "a document" rather than "a card." + * + * Interaction: + * - Mouse position parallaxes the paper's rotation (gentle, ~8°). + * - When `state === "extracting"`, the sheet dips forward and a soft glow + * appears — signals "processing" without a spinner. + * - When `state === "extracted"`, the sheet tilts up and the corner folds + * over, revealing an ink-green underside — signals "done." + * + * Rendering strategy: + * - Zero external models. Everything is procedural — a plane + a small + * `ExtrudeGeometry` for the folded corner, textured with a Canvas the + * component draws once on mount. This keeps bundle size tiny. + */ +import { Canvas, useFrame } from "@react-three/fiber"; +import { Environment } from "@react-three/drei"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Group, Mesh } from "three"; +import * as THREE from "three"; + +export type PaperState = "idle" | "extracting" | "extracted"; + +interface Props { + state: PaperState; +} + +export function PaperScene({ state }: Props) { + return ( +
+ + + {/* alpha:true + skipping color attach makes the canvas transparent */} + + + + + + + {/* Warm vignette layered on top so the sheet reads against soft light */} +
+
+ ); +} + +/* ------------------------------------------------------------------------ */ + +function Sheet({ state }: { state: PaperState }) { + const group = useRef(null!); + const [mouse, setMouse] = useState({ x: 0, y: 0 }); + + // Track the mouse across the whole window — the parallax reads better when + // it's tied to page position, not just the canvas. + useEffect(() => { + const onMove = (e: MouseEvent) => { + setMouse({ + x: (e.clientX / window.innerWidth) * 2 - 1, + y: -(e.clientY / window.innerHeight) * 2 + 1, + }); + }; + window.addEventListener("mousemove", onMove); + return () => window.removeEventListener("mousemove", onMove); + }, []); + + const texture = useMemo(() => paperTexture(), []); + + useFrame((clock, delta) => { + if (!group.current) return; + const t = clock.clock.elapsedTime; + + // Base slow drift (idle motion). + const driftY = Math.sin(t * 0.5) * 0.08; + const driftRotX = Math.sin(t * 0.4) * 0.05; + const driftRotZ = Math.cos(t * 0.3) * 0.03; + + // Parallax offset from mouse. + const paraX = mouse.x * 0.14; + const paraY = mouse.y * 0.08; + + // Targets vary by state. + const targetRotX = state === "extracted" ? -0.35 : driftRotX + paraY * 0.8; + const targetRotY = state === "extracted" ? -0.15 : paraX * 0.9; + const targetRotZ = state === "extracting" ? 0.02 : driftRotZ; + const targetY = state === "extracting" ? -0.15 : driftY; + const targetScale = state === "extracting" ? 0.97 : 1; + + // Ease each channel toward target. + const k = 1 - Math.pow(0.001, delta); + group.current.rotation.x += (targetRotX - group.current.rotation.x) * k; + group.current.rotation.y += (targetRotY - group.current.rotation.y) * k; + group.current.rotation.z += (targetRotZ - group.current.rotation.z) * k; + group.current.position.y += (targetY - group.current.position.y) * k; + const s = group.current.scale.x + (targetScale - group.current.scale.x) * k; + group.current.scale.set(s, s, s); + }); + + return ( + + {/* Soft cast shadow beneath the sheet — a squashed dark plane. */} + + + + + + {/* Main sheet — a slightly wider-than-tall rectangle. */} + + + + + + {/* Folded corner reveal — only visible when extracted. */} + + + {/* Glow puck under the sheet during extraction */} + + + ); +} + +/* -- folded corner --------------------------------------------------------- */ + +function FoldedCorner({ active }: { active: boolean }) { + const meshRef = useRef(null!); + + useFrame((_, delta) => { + if (!meshRef.current) return; + const target = active ? 1 : 0; + const k = 1 - Math.pow(0.001, delta); + const cur = meshRef.current.userData.progress ?? 0; + const next = cur + (target - cur) * k; + meshRef.current.userData.progress = next; + meshRef.current.scale.setScalar(0.001 + next * 1); + (meshRef.current.material as THREE.MeshStandardMaterial).opacity = next; + }); + + return ( + + + + + ); +} + +/* -- glow puck ------------------------------------------------------------- */ + +function Glow({ active }: { active: boolean }) { + const meshRef = useRef(null!); + + useFrame((clock, delta) => { + if (!meshRef.current) return; + const target = active ? 1 : 0; + const k = 1 - Math.pow(0.001, delta); + const mat = meshRef.current.material as THREE.MeshBasicMaterial; + const cur = mat.opacity; + mat.opacity = cur + (target * 0.6 - cur) * k; + const scale = 1 + Math.sin(clock.clock.elapsedTime * 2) * 0.06 * target; + meshRef.current.scale.setScalar(scale); + }); + + return ( + + + + + ); +} + +/* -- procedural texture ---------------------------------------------------- */ + +/** + * Paints a paper-like canvas: warm cream base, faint ruled lines, a subtle + * stamp mark, and a "TOTAL" label near the bottom. Just enough visual noise + * that the sheet reads as a document rather than a blank plane. + */ +function paperTexture(): THREE.CanvasTexture { + const c = document.createElement("canvas"); + c.width = 512; + c.height = 680; + const ctx = c.getContext("2d")!; + + // Base paper — warm cream with a subtle gradient + const grad = ctx.createLinearGradient(0, 0, 0, c.height); + grad.addColorStop(0, "#f8f2e2"); + grad.addColorStop(1, "#efe8d0"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, c.width, c.height); + + // Speckle grain + for (let i = 0; i < 2200; i++) { + ctx.fillStyle = `rgba(120,100,60,${Math.random() * 0.04})`; + ctx.fillRect( + Math.random() * c.width, + Math.random() * c.height, + Math.random() * 1.6, + Math.random() * 1.6 + ); + } + + // Header rule + ctx.strokeStyle = "rgba(16,32,59,0.75)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(40, 96); + ctx.lineTo(c.width - 40, 96); + ctx.stroke(); + + // Vendor/merchant label + ctx.fillStyle = "#10203b"; + ctx.font = 'italic 34px "Instrument Serif", serif'; + ctx.fillText("Invoice", 40, 76); + + // Ruled body lines (light) + ctx.strokeStyle = "rgba(16,32,59,0.09)"; + ctx.lineWidth = 1; + for (let y = 130; y < c.height - 120; y += 26) { + ctx.beginPath(); + ctx.moveTo(40, y); + ctx.lineTo(c.width - 40, y); + ctx.stroke(); + } + + // Fake line items + ctx.fillStyle = "rgba(16,32,59,0.55)"; + ctx.font = '13px "JetBrains Mono", monospace'; + const rows: [string, string][] = [ + ["Software license", "$1,240.00"], + ["Support (annual)", " $360.00"], + ["Onboarding", " $200.00"], + ]; + rows.forEach(([desc, amt], i) => { + ctx.fillText(desc, 40, 156 + i * 26); + ctx.fillText(amt, c.width - 130, 156 + i * 26); + }); + + // Total + ctx.strokeStyle = "rgba(16,32,59,0.55)"; + ctx.beginPath(); + ctx.moveTo(40, 260); + ctx.lineTo(c.width - 40, 260); + ctx.stroke(); + + ctx.fillStyle = "#10203b"; + ctx.font = 'italic 22px "Instrument Serif", serif'; + ctx.fillText("Total", 40, 292); + ctx.font = 'italic 22px "Instrument Serif", serif'; + ctx.fillText("$1,800.00", c.width - 140, 292); + + // Bottom stamp — a red circle with "PAID" (ish) + const cx = c.width - 110; + const cy = c.height - 110; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-0.18); + ctx.strokeStyle = "rgba(197,58,44,0.75)"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, 52, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, 0, 46, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = "rgba(197,58,44,0.85)"; + ctx.font = 'bold 20px "Geist", sans-serif'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("PAID", 0, 0); + ctx.restore(); + + const tex = new THREE.CanvasTexture(c); + tex.anisotropy = 8; + tex.needsUpdate = true; + return tex; +} diff --git a/ui/src/components/ResultsPanel.tsx b/ui/src/components/ResultsPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd8e0b2f71ca3f79ba971fc1ae28dc535f09cff2 --- /dev/null +++ b/ui/src/components/ResultsPanel.tsx @@ -0,0 +1,152 @@ +/** + * ResultsPanel — the right column when we have a result. Composes: + * - ConfidenceInkwell (top) + * - MetricsStrip (cost / latency / tokens / model) + * - JsonView (extracted data) + * - WarningsList + * + * Empty and error states are handled inline — no separate components needed + * for something this small. + */ +import { AnimatePresence, motion } from "motion/react"; + +import type { APIError } from "@/lib/api"; +import type { ExtractResponse } from "@/types"; + +import { ConfidenceInkwell } from "./ConfidenceInkwell"; +import { JsonView } from "./JsonView"; +import { MetricsStrip } from "./MetricsStrip"; +import { WarningsList } from "./WarningsList"; + +interface Props { + status: "idle" | "loading" | "success" | "error"; + response: ExtractResponse | null; + error: APIError | null; +} + +const ITEM = { + hidden: { y: 24, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] }, + }, +}; + +export function ResultsPanel({ status, response, error }: Props) { + return ( + + {status === "idle" && } + {status === "loading" && } + {status === "error" && error && } + {status === "success" && response && ( + + + + + + + + +

Extracted data

+ +
+ +

Warnings

+ +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------------ */ + +function Empty() { + return ( + +

Awaiting document

+

+ The result will land here — +

+

+ Confidence, cost, latency, and the extracted JSON. Every field + cross-checked against a Pydantic schema before it reaches you. +

+
+ + Try one of the samples on the left +
+
+ ); +} + +function Loading() { + return ( + +

Extracting…

+

+ Reading the sheet. +

+
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ ); +} + +function Skeleton({ delay }: { delay: number }) { + return ( + + ); +} + +function ErrorView({ error }: { error: APIError }) { + return ( + +

+ Extraction failed +

+

+ {error.message} +

+

+ code: {error.code} + {error.requestId && ` · request_id: ${error.requestId}`} +

+
+ ); +} diff --git a/ui/src/components/ThemeToggle.tsx b/ui/src/components/ThemeToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea9653fe8085c5c084a575e31a8f32ea05f6e6be --- /dev/null +++ b/ui/src/components/ThemeToggle.tsx @@ -0,0 +1,74 @@ +/** + * ThemeToggle — a moon/sun rendered as ink strokes, not a Material icon. + * Deliberately hand-drawn feel: the moon has a slight crescent bite, the sun + * has four short rays. Both drawn with strokeLinecap="round". + */ +import { motion } from "motion/react"; + +import { useTheme } from "@/hooks/useTheme"; + +export function ThemeToggle() { + const { theme, toggle } = useTheme(); + const dark = theme === "dark"; + + return ( + + ); +} + +function Sun() { + return ( + + ); +} + +function Moon() { + // A crescent drawn as one path — feels like a pen stroke, not an icon. + return ( + + ); +} diff --git a/ui/src/components/TopNav.tsx b/ui/src/components/TopNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..108eda63690d001cf517cb1f069138e413dc6953 --- /dev/null +++ b/ui/src/components/TopNav.tsx @@ -0,0 +1,55 @@ +/** + * TopNav — logo mark on the left, GitHub + theme toggle on the right. + * Deliberately quiet — no full navigation, no CTAs. The site is one page. + */ +import { ThemeToggle } from "./ThemeToggle"; + +const GITHUB_URL = "https://github.com/adityapatel007-byte/structured-data-extractor"; + +export function TopNav() { + return ( +
+ + + + Ledger + + + / structured extraction + + + + +
+ ); +} + +function LogoMark() { + // A minimal ink glyph — a page corner folded over. Two strokes, that's it. + return ( + + + + + ); +} diff --git a/ui/src/components/WarningsList.tsx b/ui/src/components/WarningsList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5af2034f97f79f0589b6291301ee406d35ed8522 --- /dev/null +++ b/ui/src/components/WarningsList.tsx @@ -0,0 +1,45 @@ +/** + * WarningsList — model-flagged concerns rendered as marginalia strokes. + * Info/warning/error rendered with different left-border colors so the reader + * can scan severity without reading. + */ +import type { ExtractionWarning } from "@/types"; + +interface Props { + warnings: ExtractionWarning[]; +} + +const COLORS: Record = { + info: "var(--ink-mute)", + warning: "var(--mustard)", + error: "var(--accent)", +}; + +export function WarningsList({ warnings }: Props) { + if (!warnings.length) { + return ( +

+ No warnings — model reported no concerns. +

+ ); + } + + return ( +
    + {warnings.map((w, i) => ( +
  • + {w.field && ( + + {w.field} + + )} + {w.message} +
  • + ))} +
+ ); +} diff --git a/ui/src/hooks/useExtract.ts b/ui/src/hooks/useExtract.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdf8384838c46d72cffdb006a8582e18163be466 --- /dev/null +++ b/ui/src/hooks/useExtract.ts @@ -0,0 +1,52 @@ +/** + * useExtract — manages the upload → API → result lifecycle for a single call. + * + * Deliberately simple: no react-query, no cache, no retry. One call at a time, + * cancellable via `reset()`. That matches the actual UX — upload one doc, look + * at the result, upload another. + */ +import { useCallback, useState } from "react"; + +import { APIError, extract, type ExtractArgs } from "@/lib/api"; +import type { ExtractResponse } from "@/types"; + +type Status = "idle" | "loading" | "success" | "error"; + +export interface UseExtractState { + status: Status; + response: ExtractResponse | null; + error: APIError | null; + file: File | null; +} + +export function useExtract() { + const [state, setState] = useState({ + status: "idle", + response: null, + error: null, + file: null, + }); + + const run = useCallback(async (args: ExtractArgs) => { + setState({ status: "loading", response: null, error: null, file: args.file }); + try { + const response = await extract(args); + setState({ status: "success", response, error: null, file: args.file }); + } catch (err) { + const apiErr = + err instanceof APIError + ? err + : new APIError({ + code: "network_error", + message: err instanceof Error ? err.message : "Unknown error", + }); + setState({ status: "error", response: null, error: apiErr, file: args.file }); + } + }, []); + + const reset = useCallback(() => { + setState({ status: "idle", response: null, error: null, file: null }); + }, []); + + return { ...state, run, reset }; +} diff --git a/ui/src/hooks/useTheme.ts b/ui/src/hooks/useTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..993655d383a05d47b521c38740f4ba147e28ce83 --- /dev/null +++ b/ui/src/hooks/useTheme.ts @@ -0,0 +1,35 @@ +/** + * Theme hook — persists user choice in localStorage, respects prefers-color-scheme + * on first visit, and reflects the current theme as data-theme on . + * The initial paint happens in index.html to avoid a flash. + */ +import { useCallback, useEffect, useState } from "react"; + +export type Theme = "light" | "dark"; + +const STORAGE_KEY = "theme"; + +function getInitial(): Theme { + if (typeof window === "undefined") return "light"; + const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === "light" || stored === "dark") return stored; + const dark = window.matchMedia("(prefers-color-scheme: dark)").matches; + return dark ? "dark" : "light"; +} + +export function useTheme() { + const [theme, setThemeState] = useState(getInitial); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + window.localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const setTheme = useCallback((t: Theme) => setThemeState(t), []); + const toggle = useCallback( + () => setThemeState((t) => (t === "dark" ? "light" : "dark")), + [] + ); + + return { theme, setTheme, toggle }; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ffcb2428be613cc3855c3a96651e9a208ed13beb --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import App from "./App"; +import "./styles/globals.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..5ea880ecd20232c649865d095806b62579c84551 --- /dev/null +++ b/ui/src/styles/globals.css @@ -0,0 +1,146 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import "./theme.css"; + +/* ---- Base ---------------------------------------------------------------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: "Geist", system-ui, sans-serif; + font-weight: 400; + font-feature-settings: "ss01", "ss02", "cv11"; + transition: background 500ms cubic-bezier(0.16, 1, 0.3, 1), + color 500ms cubic-bezier(0.16, 1, 0.3, 1); +} + +::selection { + background: var(--accent); + color: var(--surface); +} + +/* Editorial paper grain — the whole point of the aesthetic. Overlaid on the + entire document at low opacity. Uses SVG feTurbulence for zero-request grain. */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 100; + opacity: var(--grain); + mix-blend-mode: multiply; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 240px 240px; +} + +[data-theme="dark"] body::before { + mix-blend-mode: screen; + background-image: url("data:image/svg+xml;utf8,"); +} + +/* ---- Type utilities ----------------------------------------------------- */ + +.font-display { + font-family: "Instrument Serif", serif; + font-weight: 400; + letter-spacing: -0.015em; + line-height: 0.92; +} + +.font-display-italic { + font-family: "Instrument Serif", serif; + font-style: italic; + font-weight: 400; + letter-spacing: -0.02em; + line-height: 0.92; +} + +.font-mono { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-feature-settings: "calt", "liga", "ss01"; +} + +/* Small caps + tracking for section labels — the "chapter marker" of the site */ +.eyebrow { + font-family: "Geist", system-ui, sans-serif; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-mute); +} + +/* ---- Focus rings — replaced with editorial underline ------------------- */ + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; + border-radius: 2px; +} + +/* Hide the default focus on decorative elements */ +button:focus, +a:focus { + outline: none; +} +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; +} + +/* ---- Custom cursor — soft ink dot on interactive elements -------------- */ + +.cursor-ink { + cursor: none; +} + +/* ---- Scrollbar --------------------------------------------------------- */ + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--rule); + border-radius: 6px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--ink-mute); +} + +/* ---- Motion — respect user preference ---------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/ui/src/styles/theme.css b/ui/src/styles/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..ded1fea09fce29e5f90d1c7495d990b7de967b28 --- /dev/null +++ b/ui/src/styles/theme.css @@ -0,0 +1,81 @@ +/* + * Paper & Ink — design tokens. + * + * Two moods, one system: + * LIGHT is "morning at a well-designed newsroom" — warm cream paper, deep + * ink navy, a coral that reads as red-orange in daylight. + * DARK is "reading by lamplight" — warm charcoal (never pure black), warm + * parchment ink, coral pushed brighter so it still reads at low luminance. + * + * All colors are OKLCH where reasonable so lightness swaps stay perceptually + * even between modes. Fallback hex is provided in comments for reference. + */ + +:root, +[data-theme="light"] { + /* Surfaces */ + --bg: #f4efe4; /* warm cream paper */ + --surface: #fbf6e9; /* lifted card, slightly brighter */ + --surface-2: #efe8d5; /* recessed panel */ + --rule: #e0d6bd; /* hairline dividers */ + + /* Ink */ + --ink: #10203b; /* body text — deep navy */ + --ink-strong: #050f24; /* headings */ + --ink-soft: #536079; /* secondary body */ + --ink-mute: #8a8770; /* tertiary / metadata */ + + /* Accents */ + --accent: #c53a2c; /* coral — CTAs, links */ + --accent-hover: #a72c20; + --accent-soft: #f6d6d0; /* wash for hover backgrounds */ + + --sage: #6b8e6a; /* success / high confidence */ + --mustard: #b58a1f; /* warning */ + + /* Signal fills */ + --confidence-high: #6b8e6a; + --confidence-mid: #b58a1f; + --confidence-low: #c53a2c; + + /* Semantic */ + --focus: #10203b; + --shadow: 20 10 30; /* rgb components for the ink drop-shadow */ + --shadow-opacity: 0.07; + + /* Grain overlay opacity */ + --grain: 0.035; + + color-scheme: light; +} + +[data-theme="dark"] { + --bg: #141210; /* warm charcoal, never true black */ + --surface: #1c1917; + --surface-2: #221f1c; + --rule: #2b2622; + + --ink: #ecdfc6; /* warm parchment */ + --ink-strong: #f7ecd6; + --ink-soft: #a89a80; + --ink-mute: #6e6552; + + --accent: #e6604f; /* coral, brighter for dark */ + --accent-hover: #ff7967; + --accent-soft: #3b1e1a; + + --sage: #96b895; + --mustard: #dcac48; + + --confidence-high: #96b895; + --confidence-mid: #dcac48; + --confidence-low: #e6604f; + + --focus: #ecdfc6; + --shadow: 0 0 0; + --shadow-opacity: 0.5; + + --grain: 0.055; + + color-scheme: dark; +} diff --git a/ui/src/types.ts b/ui/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bbb7085f5faf7a4ed0844c3be5beb0e2ff522d8 --- /dev/null +++ b/ui/src/types.ts @@ -0,0 +1,50 @@ +/** + * Types mirror the FastAPI response contract from src/api/routers/extract.py. + * Kept minimal — Pydantic v2 on the server is the source of truth. + */ + +export type DocType = "invoice" | "receipt"; + +export interface FieldConfidence { + field: string; + score: number; + reasoning?: string | null; +} + +export interface ExtractionWarning { + field: string | null; + message: string; + severity: "info" | "warning" | "error"; +} + +export interface ExtractionResult> { + document_type: string; + data: T; + field_confidences: FieldConfidence[]; + overall_confidence: number; + warnings: ExtractionWarning[]; + raw_text_snippet: string | null; +} + +export interface ExtractionMetrics { + input_tokens: number; + output_tokens: number; + total_tokens: number; + latency_ms: number; + cost_usd: number; + model: string; +} + +export interface ExtractResponse { + result: ExtractionResult; + metrics: ExtractionMetrics; +} + +export interface APIErrorEnvelope { + error: { + code: string; + message: string; + request_id?: string | null; + details?: Record; + }; +} diff --git a/ui/tailwind.config.ts b/ui/tailwind.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..a552840e5cb67a9002aadc380e5a8e3fe04ebe5d --- /dev/null +++ b/ui/tailwind.config.ts @@ -0,0 +1,26 @@ +import type { Config } from "tailwindcss"; + +/** + * Tailwind is used strictly for utility layout + spacing. + * All colors, typography, and design tokens live in CSS variables (theme.css) + * so dark/light mode is one attribute flip on . + * We reference variables via arbitrary values: text-[var(--ink)] etc. + */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: ["class", '[data-theme="dark"]'], + theme: { + extend: { + fontFamily: { + serif: ['"Instrument Serif"', "serif"], + sans: ['"Geist"', "system-ui", "sans-serif"], + mono: ['"JetBrains Mono"', "ui-monospace", "monospace"], + }, + transitionTimingFunction: { + // The house easing — expo out. Everything eases like this. + editorial: "cubic-bezier(0.16, 1, 0.3, 1)", + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..46f75edc072d325713f7a6b3e55b6f4d719ca398 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..93bde672e0cd107df7f84f5806857770ac5b1787 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + server: { + port: 5173, + proxy: { + // Proxy /api/* on the dev server to the FastAPI backend on :8000. + // Keeps the frontend origin clean and side-steps CORS during dev. + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ""), + }, + }, + }, +});