Commit ·
557ab38
1
Parent(s): f89c24a
Tasks 5-7: eval harness, FastAPI backend, Paper & Ink UI- src/eval/ — precision/recall/F1 harness with type-aware comparators,micro/macro F1, CSV + markdown reports, --model benchmark flag- src/api/ — FastAPI backend with /extract, /schemas, /health,request-ID middleware, typed error envelope, injectable extractor- ui/ — Vite + React + TS + Tailwind + Motion + React Three FiberPaper & Ink editorial UI with 3D paper hero, dark/light mode,confidence inkwell, wax-stamp metrics, kinetic typography- 95 passing tests (up from 54); UI is a separate npm workspace
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +21 -4
- scripts/run_eval.py +17 -0
- src/api/deps.py +49 -0
- src/api/errors.py +69 -0
- src/api/main.py +97 -0
- src/api/middleware.py +47 -0
- src/api/routers/__init__.py +1 -0
- src/api/routers/extract.py +90 -0
- src/api/routers/health.py +28 -0
- src/api/routers/schemas.py +31 -0
- src/eval/__init__.py +17 -0
- src/eval/cli.py +173 -0
- src/eval/comparators.py +144 -0
- src/eval/flatten.py +129 -0
- src/eval/metrics.py +196 -0
- src/eval/report.py +126 -0
- src/eval/runner.py +152 -0
- src/ui/README.md +17 -0
- tests/unit/test_api.py +198 -0
- tests/unit/test_eval.py +293 -0
- ui/.gitignore +5 -0
- ui/README.md +85 -0
- ui/index.html +33 -0
- ui/package.json +32 -0
- ui/postcss.config.js +6 -0
- ui/public/samples/README.txt +12 -0
- ui/src/App.tsx +43 -0
- ui/src/components/ConfidenceInkwell.tsx +80 -0
- ui/src/components/CustomCursor.tsx +81 -0
- ui/src/components/Dropzone.tsx +281 -0
- ui/src/components/ExtractSection.tsx +111 -0
- ui/src/components/Footer.tsx +26 -0
- ui/src/components/Hero.tsx +158 -0
- ui/src/components/HowItWorks.tsx +62 -0
- ui/src/components/JsonView.tsx +79 -0
- ui/src/components/MetricsStrip.tsx +92 -0
- ui/src/components/Numbers.tsx +79 -0
- ui/src/components/PaperScene.tsx +302 -0
- ui/src/components/ResultsPanel.tsx +152 -0
- ui/src/components/ThemeToggle.tsx +74 -0
- ui/src/components/TopNav.tsx +55 -0
- ui/src/components/WarningsList.tsx +45 -0
- ui/src/hooks/useExtract.ts +52 -0
- ui/src/hooks/useTheme.ts +35 -0
- ui/src/main.tsx +11 -0
- ui/src/styles/globals.css +146 -0
- ui/src/styles/theme.css +81 -0
- ui/src/types.ts +50 -0
- ui/tailwind.config.ts +26 -0
- ui/tsconfig.json +24 -0
README.md
CHANGED
|
@@ -78,7 +78,7 @@ _Will be filled in after v1 evaluation run:_
|
|
| 78 |
| PDF text | pdfplumber, PyMuPDF | Fast, robust, handles most layouts |
|
| 79 |
| PDF images | pdf2image + Pillow | For scanned/image-heavy PDFs → vision model |
|
| 80 |
| Backend | FastAPI | Async, auto OpenAPI docs, batteries included |
|
| 81 |
-
| Frontend |
|
| 82 |
| Eval | rapidfuzz, scikit-learn | Fuzzy text matching + P/R/F1 |
|
| 83 |
| Container | Docker (multi-stage) | Portable, reproducible |
|
| 84 |
| Deploy | Hugging Face Spaces | Free, AI-community-recognized |
|
|
@@ -100,10 +100,24 @@ cp .env.example .env
|
|
| 100 |
# 3. Run the API
|
| 101 |
uvicorn src.api.main:app --reload
|
| 102 |
|
| 103 |
-
# 4. Run the UI
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
```
|
| 106 |
|
|
|
|
|
|
|
|
|
|
| 107 |
## Project structure
|
| 108 |
|
| 109 |
```
|
|
@@ -112,8 +126,11 @@ streamlit run src/ui/app.py
|
|
| 112 |
│ ├── schemas/ # Pydantic schemas per doc type
|
| 113 |
│ ├── extractors/ # LLM extraction logic
|
| 114 |
│ ├── api/ # FastAPI backend
|
| 115 |
-
│ ├── ui/ # Streamlit frontend
|
| 116 |
│ └── utils/ # cost tracking, logging, config
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
├── tests/
|
| 118 |
│ ├── unit/
|
| 119 |
│ └── integration/
|
|
|
|
| 78 |
| PDF text | pdfplumber, PyMuPDF | Fast, robust, handles most layouts |
|
| 79 |
| PDF images | pdf2image + Pillow | For scanned/image-heavy PDFs → vision model |
|
| 80 |
| Backend | FastAPI | Async, auto OpenAPI docs, batteries included |
|
| 81 |
+
| 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. |
|
| 82 |
| Eval | rapidfuzz, scikit-learn | Fuzzy text matching + P/R/F1 |
|
| 83 |
| Container | Docker (multi-stage) | Portable, reproducible |
|
| 84 |
| Deploy | Hugging Face Spaces | Free, AI-community-recognized |
|
|
|
|
| 100 |
# 3. Run the API
|
| 101 |
uvicorn src.api.main:app --reload
|
| 102 |
|
| 103 |
+
# 4. Run the UI — Paper & Ink React + Motion + R3F frontend
|
| 104 |
+
# (in another terminal, from ui/)
|
| 105 |
+
cd ui && npm install && npm run dev
|
| 106 |
+
# then open http://localhost:5173
|
| 107 |
+
|
| 108 |
+
# 5. (Optional) Evaluate against the committed sample ground truth.
|
| 109 |
+
# `selfcheck` mode uses a mock extractor to validate the eval pipeline (F1=1.0).
|
| 110 |
+
python scripts/run_eval.py --dataset data/samples/sroie_sample.jsonl \
|
| 111 |
+
--doc-type receipt --mode selfcheck
|
| 112 |
+
|
| 113 |
+
# 6. Benchmark a real model on your own ground-truth JSONL:
|
| 114 |
+
python scripts/run_eval.py --dataset evaluation/ground_truth/sroie.jsonl \
|
| 115 |
+
--doc-type receipt --mode live --model gpt-5-nano
|
| 116 |
```
|
| 117 |
|
| 118 |
+
Reports (per-record CSV + summary JSON + resume-ready markdown) land in
|
| 119 |
+
`evaluation/reports/<UTC-timestamp>/`.
|
| 120 |
+
|
| 121 |
## Project structure
|
| 122 |
|
| 123 |
```
|
|
|
|
| 126 |
│ ├── schemas/ # Pydantic schemas per doc type
|
| 127 |
│ ├── extractors/ # LLM extraction logic
|
| 128 |
│ ├── api/ # FastAPI backend
|
|
|
|
| 129 |
│ └── utils/ # cost tracking, logging, config
|
| 130 |
+
├── ui/ # React + Motion + R3F frontend (Paper & Ink)
|
| 131 |
+
│ ├── src/components/ # Hero, PaperScene (3D), Dropzone, ResultsPanel, ...
|
| 132 |
+
│ ├── src/styles/ # theme.css (dark/light tokens) + globals.css
|
| 133 |
+
│ └── package.json
|
| 134 |
├── tests/
|
| 135 |
│ ├── unit/
|
| 136 |
│ └── integration/
|
scripts/run_eval.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin wrapper around src.eval.cli so `python scripts/run_eval.py ...` works.
|
| 2 |
+
|
| 3 |
+
Prefer this entry point in docs — it's discoverable from the repo root.
|
| 4 |
+
Adds the repo root to sys.path so `from src.eval.cli import main` resolves
|
| 5 |
+
when run directly (rather than as a module).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 13 |
+
|
| 14 |
+
from src.eval.cli import main # noqa: E402
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
raise SystemExit(main())
|
src/api/deps.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI dependencies + upload constraints.
|
| 2 |
+
|
| 3 |
+
Why a `Depends`-based extractor:
|
| 4 |
+
FastAPI's dependency injection lets tests override the extractor via
|
| 5 |
+
`app.dependency_overrides[get_extractor] = fake_extractor`, so we can
|
| 6 |
+
hit the endpoints in tests without an OpenAI key. In production the
|
| 7 |
+
lru_cache means we build one DocumentExtractor per worker process.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
|
| 13 |
+
from src.extractors.extractor import DocumentExtractor
|
| 14 |
+
from src.schemas.registry import list_doc_types
|
| 15 |
+
|
| 16 |
+
# --- Upload constraints ----------------------------------------------------
|
| 17 |
+
|
| 18 |
+
# 10 MB by default. Set to something small for tests via monkeypatch if needed.
|
| 19 |
+
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
| 20 |
+
|
| 21 |
+
# MIME allowlist. Also accept common browser variants. `application/octet-stream`
|
| 22 |
+
# is accepted because some clients (and Streamlit) send it for images.
|
| 23 |
+
ALLOWED_MIME_TYPES = {
|
| 24 |
+
"application/pdf",
|
| 25 |
+
"image/png",
|
| 26 |
+
"image/jpeg",
|
| 27 |
+
"image/jpg",
|
| 28 |
+
"image/webp",
|
| 29 |
+
"image/bmp",
|
| 30 |
+
"image/tiff",
|
| 31 |
+
"application/octet-stream",
|
| 32 |
+
"text/plain", # for inline text extraction convenience
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
# Extensions we can actually load (mirrors document_loader.py).
|
| 36 |
+
ALLOWED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif", ".txt"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --- Dependencies ----------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
@lru_cache(maxsize=1)
|
| 42 |
+
def get_extractor() -> DocumentExtractor:
|
| 43 |
+
"""Singleton DocumentExtractor. Overridden in tests via dependency_overrides."""
|
| 44 |
+
return DocumentExtractor()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_allowed_doc_types() -> list[str]:
|
| 48 |
+
"""List of registered document type strings — used by /schemas."""
|
| 49 |
+
return list_doc_types()
|
src/api/errors.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API-facing error envelope and typed exceptions.
|
| 2 |
+
|
| 3 |
+
Every 4xx / 5xx response from this service returns the same JSON shape:
|
| 4 |
+
|
| 5 |
+
{
|
| 6 |
+
"error": {
|
| 7 |
+
"code": "unsupported_doc_type",
|
| 8 |
+
"message": "Human-readable explanation.",
|
| 9 |
+
"request_id": "abc-123",
|
| 10 |
+
"details": {...optional...}
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
Consistent shape makes clients (Streamlit, curl, external integrations) easier
|
| 15 |
+
to write than raw FastAPI HTTPException details.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from pydantic import BaseModel, Field
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ErrorDetail(BaseModel):
|
| 25 |
+
code: str
|
| 26 |
+
message: str
|
| 27 |
+
request_id: str | None = None
|
| 28 |
+
details: dict[str, Any] = Field(default_factory=dict)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ErrorEnvelope(BaseModel):
|
| 32 |
+
error: ErrorDetail
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class APIError(Exception):
|
| 36 |
+
"""Base class — subclass rather than raising HTTPException directly."""
|
| 37 |
+
|
| 38 |
+
status_code: int = 500
|
| 39 |
+
code: str = "internal_error"
|
| 40 |
+
|
| 41 |
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None):
|
| 42 |
+
super().__init__(message)
|
| 43 |
+
self.message = message
|
| 44 |
+
self.details = details or {}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class UnsupportedDocType(APIError):
|
| 48 |
+
status_code = 400
|
| 49 |
+
code = "unsupported_doc_type"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class UnsupportedFileType(APIError):
|
| 53 |
+
status_code = 415
|
| 54 |
+
code = "unsupported_media_type"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class FileTooLarge(APIError):
|
| 58 |
+
status_code = 413
|
| 59 |
+
code = "file_too_large"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class EmptyDocument(APIError):
|
| 63 |
+
status_code = 422
|
| 64 |
+
code = "empty_document"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ExtractionFailed(APIError):
|
| 68 |
+
status_code = 502
|
| 69 |
+
code = "extraction_failed"
|
src/api/main.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application factory + module-level `app` for uvicorn.
|
| 2 |
+
|
| 3 |
+
Run:
|
| 4 |
+
uvicorn src.api.main:app --reload
|
| 5 |
+
|
| 6 |
+
Design notes:
|
| 7 |
+
- App is created by a factory (`create_app`) so tests can instantiate a fresh
|
| 8 |
+
app with overrides — no shared mutable state across test cases.
|
| 9 |
+
- CORS is wide-open by default for local Streamlit; tighten for prod.
|
| 10 |
+
- All exceptions funnel through the ErrorEnvelope handler for consistent shape.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI, Request
|
| 15 |
+
from fastapi.exceptions import RequestValidationError
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from fastapi.responses import JSONResponse
|
| 18 |
+
|
| 19 |
+
from src.api.errors import APIError, ErrorDetail, ErrorEnvelope
|
| 20 |
+
from src.api.middleware import AccessLogMiddleware, RequestIDMiddleware
|
| 21 |
+
from src.api.routers import extract as extract_router
|
| 22 |
+
from src.api.routers import health as health_router
|
| 23 |
+
from src.api.routers import schemas as schemas_router
|
| 24 |
+
from src.utils.logging import logger
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def create_app() -> FastAPI:
|
| 28 |
+
app = FastAPI(
|
| 29 |
+
title="Structured Data Extraction API",
|
| 30 |
+
description=(
|
| 31 |
+
"Multi-domain document extraction — invoices, receipts, and (v2) SEC filings — "
|
| 32 |
+
"returned as schema-validated JSON with confidence scoring, cost, and latency."
|
| 33 |
+
),
|
| 34 |
+
version="0.1.0",
|
| 35 |
+
docs_url="/docs",
|
| 36 |
+
redoc_url="/redoc",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# --- Middleware (outer -> inner order) ---------------------------------
|
| 40 |
+
# CORS must be outermost so it can add headers to error responses too.
|
| 41 |
+
app.add_middleware(
|
| 42 |
+
CORSMiddleware,
|
| 43 |
+
allow_origins=["*"],
|
| 44 |
+
allow_credentials=False,
|
| 45 |
+
allow_methods=["*"],
|
| 46 |
+
allow_headers=["*"],
|
| 47 |
+
)
|
| 48 |
+
app.add_middleware(AccessLogMiddleware)
|
| 49 |
+
app.add_middleware(RequestIDMiddleware)
|
| 50 |
+
|
| 51 |
+
# --- Routers -----------------------------------------------------------
|
| 52 |
+
app.include_router(health_router.router)
|
| 53 |
+
app.include_router(schemas_router.router)
|
| 54 |
+
app.include_router(extract_router.router)
|
| 55 |
+
|
| 56 |
+
# --- Error handlers ----------------------------------------------------
|
| 57 |
+
@app.exception_handler(APIError)
|
| 58 |
+
async def _api_error_handler(request: Request, exc: APIError):
|
| 59 |
+
rid = getattr(request.state, "request_id", None)
|
| 60 |
+
envelope = ErrorEnvelope(
|
| 61 |
+
error=ErrorDetail(
|
| 62 |
+
code=exc.code, message=exc.message, request_id=rid, details=exc.details
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
return JSONResponse(status_code=exc.status_code, content=envelope.model_dump())
|
| 66 |
+
|
| 67 |
+
@app.exception_handler(RequestValidationError)
|
| 68 |
+
async def _validation_handler(request: Request, exc: RequestValidationError):
|
| 69 |
+
rid = getattr(request.state, "request_id", None)
|
| 70 |
+
envelope = ErrorEnvelope(
|
| 71 |
+
error=ErrorDetail(
|
| 72 |
+
code="validation_error",
|
| 73 |
+
message="Request failed validation.",
|
| 74 |
+
request_id=rid,
|
| 75 |
+
details={"errors": exc.errors()},
|
| 76 |
+
)
|
| 77 |
+
)
|
| 78 |
+
return JSONResponse(status_code=422, content=envelope.model_dump())
|
| 79 |
+
|
| 80 |
+
@app.exception_handler(Exception)
|
| 81 |
+
async def _unhandled(request: Request, exc: Exception):
|
| 82 |
+
rid = getattr(request.state, "request_id", None)
|
| 83 |
+
logger.exception(f"[api] Unhandled exception (rid={rid}): {exc}")
|
| 84 |
+
envelope = ErrorEnvelope(
|
| 85 |
+
error=ErrorDetail(
|
| 86 |
+
code="internal_error",
|
| 87 |
+
message="An unexpected error occurred.",
|
| 88 |
+
request_id=rid,
|
| 89 |
+
)
|
| 90 |
+
)
|
| 91 |
+
return JSONResponse(status_code=500, content=envelope.model_dump())
|
| 92 |
+
|
| 93 |
+
return app
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# Module-level instance for `uvicorn src.api.main:app`.
|
| 97 |
+
app = create_app()
|
src/api/middleware.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Middleware: request IDs + structured access logs.
|
| 2 |
+
|
| 3 |
+
RequestIDMiddleware attaches a UUID4 to every request as `X-Request-ID`. Both
|
| 4 |
+
the response header and the log line carry it so a user reporting an error can
|
| 5 |
+
give you the ID and you can find the log.
|
| 6 |
+
|
| 7 |
+
StructuredLoggingMiddleware logs one JSON line per request with method, path,
|
| 8 |
+
status, latency, and the request ID.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import time
|
| 13 |
+
import uuid
|
| 14 |
+
|
| 15 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 16 |
+
from starlette.requests import Request
|
| 17 |
+
from starlette.responses import Response
|
| 18 |
+
|
| 19 |
+
from src.utils.logging import logger
|
| 20 |
+
|
| 21 |
+
REQUEST_ID_HEADER = "X-Request-ID"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
| 25 |
+
"""Assign each request a UUID and echo it in the response header."""
|
| 26 |
+
|
| 27 |
+
async def dispatch(self, request: Request, call_next):
|
| 28 |
+
rid = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4())
|
| 29 |
+
request.state.request_id = rid
|
| 30 |
+
response: Response = await call_next(request)
|
| 31 |
+
response.headers[REQUEST_ID_HEADER] = rid
|
| 32 |
+
return response
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class AccessLogMiddleware(BaseHTTPMiddleware):
|
| 36 |
+
"""One log line per request with method, path, status, latency, request_id."""
|
| 37 |
+
|
| 38 |
+
async def dispatch(self, request: Request, call_next):
|
| 39 |
+
start = time.perf_counter()
|
| 40 |
+
response: Response = await call_next(request)
|
| 41 |
+
latency_ms = (time.perf_counter() - start) * 1000
|
| 42 |
+
rid = getattr(request.state, "request_id", "-")
|
| 43 |
+
logger.info(
|
| 44 |
+
f"[api] method={request.method} path={request.url.path} "
|
| 45 |
+
f"status={response.status_code} latency_ms={latency_ms:.1f} rid={rid}"
|
| 46 |
+
)
|
| 47 |
+
return response
|
src/api/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI routers — one module per resource."""
|
src/api/routers/extract.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""POST /extract — the main extraction endpoint.
|
| 2 |
+
|
| 3 |
+
Multipart form:
|
| 4 |
+
file : uploaded document (required)
|
| 5 |
+
doc_type : "invoice" | "receipt" (required)
|
| 6 |
+
model : optional model override (e.g. "gpt-5-nano", "gpt-5.4") for benchmarking
|
| 7 |
+
|
| 8 |
+
Returns:
|
| 9 |
+
{
|
| 10 |
+
"result": ExtractionResult[T] JSON,
|
| 11 |
+
"metrics": {input_tokens, output_tokens, latency_ms, cost_usd, model}
|
| 12 |
+
}
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
| 19 |
+
|
| 20 |
+
from src.api.deps import (
|
| 21 |
+
ALLOWED_EXTENSIONS,
|
| 22 |
+
ALLOWED_MIME_TYPES,
|
| 23 |
+
MAX_UPLOAD_BYTES,
|
| 24 |
+
get_extractor,
|
| 25 |
+
)
|
| 26 |
+
from src.api.errors import (
|
| 27 |
+
EmptyDocument,
|
| 28 |
+
ExtractionFailed,
|
| 29 |
+
FileTooLarge,
|
| 30 |
+
UnsupportedDocType,
|
| 31 |
+
UnsupportedFileType,
|
| 32 |
+
)
|
| 33 |
+
from src.extractors.extractor import DocumentExtractor
|
| 34 |
+
from src.schemas.registry import get_schema
|
| 35 |
+
|
| 36 |
+
router = APIRouter(tags=["extract"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.post("/extract")
|
| 40 |
+
async def extract(
|
| 41 |
+
file: UploadFile = File(..., description="PDF or image to extract from."),
|
| 42 |
+
doc_type: str = Form(..., description="One of the registered doc types (see GET /schemas)."),
|
| 43 |
+
model: str | None = Form(None, description="Optional model override (e.g. gpt-5-nano)."),
|
| 44 |
+
extractor: DocumentExtractor = Depends(get_extractor),
|
| 45 |
+
) -> dict:
|
| 46 |
+
# --- Validate doc_type
|
| 47 |
+
try:
|
| 48 |
+
get_schema(doc_type) # raises KeyError if unknown
|
| 49 |
+
except KeyError as e:
|
| 50 |
+
raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e
|
| 51 |
+
|
| 52 |
+
# --- Validate file extension + content-type
|
| 53 |
+
filename = file.filename or "upload"
|
| 54 |
+
ext = Path(filename).suffix.lower()
|
| 55 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 56 |
+
raise UnsupportedFileType(
|
| 57 |
+
f"Extension {ext!r} is not supported. Allowed: {sorted(ALLOWED_EXTENSIONS)}",
|
| 58 |
+
details={"filename": filename, "extension": ext},
|
| 59 |
+
)
|
| 60 |
+
if file.content_type and file.content_type not in ALLOWED_MIME_TYPES:
|
| 61 |
+
raise UnsupportedFileType(
|
| 62 |
+
f"MIME type {file.content_type!r} not accepted for {filename}.",
|
| 63 |
+
details={"content_type": file.content_type},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# --- Read + size-check
|
| 67 |
+
payload = await file.read()
|
| 68 |
+
if len(payload) == 0:
|
| 69 |
+
raise EmptyDocument("Uploaded file is empty.")
|
| 70 |
+
if len(payload) > MAX_UPLOAD_BYTES:
|
| 71 |
+
raise FileTooLarge(
|
| 72 |
+
f"File is {len(payload)} bytes; max allowed is {MAX_UPLOAD_BYTES}.",
|
| 73 |
+
details={"size_bytes": len(payload), "max_bytes": MAX_UPLOAD_BYTES},
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# --- Run extraction
|
| 77 |
+
try:
|
| 78 |
+
result, metrics = extractor.extract(
|
| 79 |
+
payload, filename=filename, doc_type=doc_type, model_override=model
|
| 80 |
+
)
|
| 81 |
+
except ValueError as e:
|
| 82 |
+
# Loader reports "could not load" for unknown/corrupt formats.
|
| 83 |
+
raise EmptyDocument(str(e)) from e
|
| 84 |
+
except Exception as e: # noqa: BLE001
|
| 85 |
+
raise ExtractionFailed(f"Model extraction failed: {e}") from e
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"result": result.model_dump(mode="json"),
|
| 89 |
+
"metrics": metrics.to_dict(),
|
| 90 |
+
}
|
src/api/routers/health.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health + root routes.
|
| 2 |
+
|
| 3 |
+
GET / -> service banner (name, version, docs link)
|
| 4 |
+
GET /health -> {"status": "ok"} — probe target for orchestrators
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter
|
| 9 |
+
|
| 10 |
+
router = APIRouter(tags=["health"])
|
| 11 |
+
|
| 12 |
+
SERVICE_NAME = "structured-data-extraction"
|
| 13 |
+
SERVICE_VERSION = "0.1.0"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/")
|
| 17 |
+
def root() -> dict:
|
| 18 |
+
return {
|
| 19 |
+
"service": SERVICE_NAME,
|
| 20 |
+
"version": SERVICE_VERSION,
|
| 21 |
+
"docs": "/docs",
|
| 22 |
+
"openapi": "/openapi.json",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/health")
|
| 27 |
+
def health() -> dict:
|
| 28 |
+
return {"status": "ok"}
|
src/api/routers/schemas.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Schema discovery routes.
|
| 2 |
+
|
| 3 |
+
GET /schemas -> list of registered document types
|
| 4 |
+
GET /schemas/{doc_type} -> full JSON Schema for that type
|
| 5 |
+
|
| 6 |
+
The JSON Schema is what OpenAI's structured outputs uses internally — exposing
|
| 7 |
+
it lets clients validate uploads client-side, or auto-generate forms.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter
|
| 12 |
+
|
| 13 |
+
from src.api.errors import UnsupportedDocType
|
| 14 |
+
from src.schemas.registry import get_json_schema, list_doc_types
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/schemas", tags=["schemas"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("")
|
| 20 |
+
def list_schemas() -> dict:
|
| 21 |
+
"""Return all registered document type keys."""
|
| 22 |
+
return {"doc_types": list_doc_types()}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/{doc_type}")
|
| 26 |
+
def get_schema_json(doc_type: str) -> dict:
|
| 27 |
+
"""Return the JSON Schema for one document type."""
|
| 28 |
+
try:
|
| 29 |
+
return get_json_schema(doc_type)
|
| 30 |
+
except KeyError as e:
|
| 31 |
+
raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e
|
src/eval/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation harness for structured document extraction.
|
| 2 |
+
|
| 3 |
+
This package turns the extractor into a measurable system:
|
| 4 |
+
- Runs a JSONL ground-truth file through DocumentExtractor
|
| 5 |
+
- Compares per-field with type-appropriate comparators (text/money/date/exact)
|
| 6 |
+
- Aggregates field-level precision/recall/F1 and document-level exact match
|
| 7 |
+
- Emits per-record CSV + resume-worthy markdown summary
|
| 8 |
+
- Supports multi-model benchmark runs via --model flag
|
| 9 |
+
|
| 10 |
+
Public entry points:
|
| 11 |
+
run_eval(records, extractor, doc_type, ...) -> EvalReport
|
| 12 |
+
write_reports(report, out_dir) -> (csv_path, md_path)
|
| 13 |
+
"""
|
| 14 |
+
from src.eval.runner import EvalReport, run_eval
|
| 15 |
+
from src.eval.report import write_reports
|
| 16 |
+
|
| 17 |
+
__all__ = ["run_eval", "EvalReport", "write_reports"]
|
src/eval/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI entry point for the evaluation harness.
|
| 2 |
+
|
| 3 |
+
Usage examples:
|
| 4 |
+
# Sanity-check the eval pipeline itself against the committed samples
|
| 5 |
+
# (uses a self-consistent extractor — no OpenAI call, always F1=1.0)
|
| 6 |
+
python -m src.eval.cli --dataset data/samples/sroie_sample.jsonl \\
|
| 7 |
+
--doc-type receipt --mode selfcheck
|
| 8 |
+
|
| 9 |
+
# Real evaluation with a live model (each record must provide a file_path
|
| 10 |
+
# or an inline text field for the extractor to consume)
|
| 11 |
+
python -m src.eval.cli --dataset evaluation/ground_truth/sroie.jsonl \\
|
| 12 |
+
--doc-type receipt --mode live --model gpt-5-nano
|
| 13 |
+
|
| 14 |
+
# Multi-model benchmark (repeat --mode live with different --model tags)
|
| 15 |
+
|
| 16 |
+
Outputs land in `evaluation/reports/<timestamp>/<doc_type>_<model>_*.{csv,md,json}`.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import sys
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Callable
|
| 25 |
+
|
| 26 |
+
from pydantic import BaseModel
|
| 27 |
+
|
| 28 |
+
from src.data_prep.writer import read_jsonl
|
| 29 |
+
from src.eval.report import write_reports
|
| 30 |
+
from src.eval.runner import ExtractorFn, run_eval
|
| 31 |
+
from src.schemas import ExtractionResult
|
| 32 |
+
from src.schemas.registry import get_schema
|
| 33 |
+
from src.utils.cost_tracker import ExtractionMetrics
|
| 34 |
+
from src.utils.logging import logger
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Extractor factories: pluggable strategies for how a JSONL record becomes an
|
| 39 |
+
# (ExtractionResult, ExtractionMetrics) pair.
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
def make_selfcheck_extractor(doc_type: str) -> ExtractorFn:
|
| 43 |
+
"""Extractor that returns ground truth verbatim — validates the eval pipeline.
|
| 44 |
+
|
| 45 |
+
Guaranteed F1=1.0 doc_exact_match=1.0. Useful for CI + first-time setup.
|
| 46 |
+
"""
|
| 47 |
+
schema_cls: type[BaseModel] = get_schema(doc_type)
|
| 48 |
+
|
| 49 |
+
def _extract(record: dict) -> tuple[ExtractionResult, ExtractionMetrics]:
|
| 50 |
+
data = schema_cls.model_validate(record["ground_truth"])
|
| 51 |
+
result = ExtractionResult(
|
| 52 |
+
document_type=doc_type,
|
| 53 |
+
data=data,
|
| 54 |
+
field_confidences=[],
|
| 55 |
+
overall_confidence=1.0,
|
| 56 |
+
warnings=[],
|
| 57 |
+
raw_text_snippet=None,
|
| 58 |
+
)
|
| 59 |
+
return result, ExtractionMetrics(input_tokens=0, output_tokens=0, latency_ms=0.0, model="selfcheck")
|
| 60 |
+
|
| 61 |
+
return _extract
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def make_live_extractor(doc_type: str, model: str | None) -> ExtractorFn:
|
| 65 |
+
"""Real extractor. Each record must provide either `file_path` or `text`.
|
| 66 |
+
|
| 67 |
+
Deferred import: keeps `--mode selfcheck` runs from requiring OpenAI creds.
|
| 68 |
+
"""
|
| 69 |
+
from src.extractors.extractor import DocumentExtractor
|
| 70 |
+
|
| 71 |
+
ex = DocumentExtractor(default_model=model)
|
| 72 |
+
|
| 73 |
+
def _extract(record: dict) -> tuple[ExtractionResult, ExtractionMetrics]:
|
| 74 |
+
# Prefer an on-disk file if we have one.
|
| 75 |
+
fp = record.get("file_path")
|
| 76 |
+
if fp:
|
| 77 |
+
p = Path(fp)
|
| 78 |
+
if not p.exists():
|
| 79 |
+
raise FileNotFoundError(f"file_path not found for record {record.get('id')}: {fp}")
|
| 80 |
+
file_bytes = p.read_bytes()
|
| 81 |
+
filename = p.name
|
| 82 |
+
elif record.get("text"):
|
| 83 |
+
# Fallback: use inline text as if it were a .txt document.
|
| 84 |
+
file_bytes = record["text"].encode("utf-8")
|
| 85 |
+
filename = f"{record.get('id', 'inline')}.txt"
|
| 86 |
+
else:
|
| 87 |
+
raise ValueError(
|
| 88 |
+
f"Record {record.get('id')!r} has neither 'file_path' nor 'text' — "
|
| 89 |
+
f"live extraction needs one of them."
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return ex.extract(file_bytes, filename, doc_type=doc_type, model_override=model)
|
| 93 |
+
|
| 94 |
+
return _extract
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# CLI
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 102 |
+
p = argparse.ArgumentParser(description="Structured-extraction evaluation harness")
|
| 103 |
+
p.add_argument("--dataset", required=True, help="JSONL ground-truth file.")
|
| 104 |
+
p.add_argument(
|
| 105 |
+
"--doc-type",
|
| 106 |
+
default="receipt",
|
| 107 |
+
choices=["invoice", "receipt"],
|
| 108 |
+
help="Domain schema to evaluate against.",
|
| 109 |
+
)
|
| 110 |
+
p.add_argument(
|
| 111 |
+
"--mode",
|
| 112 |
+
default="selfcheck",
|
| 113 |
+
choices=["selfcheck", "live"],
|
| 114 |
+
help=(
|
| 115 |
+
"selfcheck: mock extractor returns ground truth (validates pipeline). "
|
| 116 |
+
"live: run real DocumentExtractor (needs OPENAI_API_KEY + source docs)."
|
| 117 |
+
),
|
| 118 |
+
)
|
| 119 |
+
p.add_argument("--model", default=None, help="Model override for live mode (e.g. gpt-5-nano).")
|
| 120 |
+
p.add_argument("--limit", type=int, default=None, help="Cap on records for quick runs.")
|
| 121 |
+
p.add_argument(
|
| 122 |
+
"--output-dir",
|
| 123 |
+
default=None,
|
| 124 |
+
help="Where to write reports. Defaults to evaluation/reports/<UTC-timestamp>/.",
|
| 125 |
+
)
|
| 126 |
+
return p
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def main(argv: list[str] | None = None) -> int:
|
| 130 |
+
args = build_parser().parse_args(argv)
|
| 131 |
+
|
| 132 |
+
dataset_path = Path(args.dataset)
|
| 133 |
+
if not dataset_path.exists():
|
| 134 |
+
print(f"ERROR: dataset not found: {dataset_path}", file=sys.stderr)
|
| 135 |
+
return 2
|
| 136 |
+
|
| 137 |
+
records = read_jsonl(dataset_path)
|
| 138 |
+
logger.info(f"Loaded {len(records)} records from {dataset_path}")
|
| 139 |
+
|
| 140 |
+
# Pick extractor strategy
|
| 141 |
+
if args.mode == "selfcheck":
|
| 142 |
+
extractor = make_selfcheck_extractor(args.doc_type)
|
| 143 |
+
model_label = "selfcheck"
|
| 144 |
+
else:
|
| 145 |
+
extractor = make_live_extractor(args.doc_type, args.model)
|
| 146 |
+
model_label = args.model or "default"
|
| 147 |
+
|
| 148 |
+
report = run_eval(
|
| 149 |
+
records,
|
| 150 |
+
extractor=extractor,
|
| 151 |
+
doc_type=args.doc_type,
|
| 152 |
+
model_label=model_label,
|
| 153 |
+
limit=args.limit,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Console summary
|
| 157 |
+
s = report.summary()
|
| 158 |
+
print("\n=== Evaluation summary ===")
|
| 159 |
+
for k, v in s.items():
|
| 160 |
+
print(f" {k:20s} {v}")
|
| 161 |
+
|
| 162 |
+
# Write reports
|
| 163 |
+
out_dir = args.output_dir or f"evaluation/reports/{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}"
|
| 164 |
+
paths = write_reports(report, out_dir)
|
| 165 |
+
print("\nReports written:")
|
| 166 |
+
for k, p in paths.items():
|
| 167 |
+
print(f" {k:10s} {p}")
|
| 168 |
+
|
| 169 |
+
return 0
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
raise SystemExit(main())
|
src/eval/comparators.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-field comparators.
|
| 2 |
+
|
| 3 |
+
Each returns (match: bool, score: float) where `score` is a 0-1 similarity
|
| 4 |
+
(useful for debugging + partial-credit later). `match` is the boolean the
|
| 5 |
+
metrics aggregator counts as TP.
|
| 6 |
+
|
| 7 |
+
Design choices:
|
| 8 |
+
- Text: rapidfuzz `token_set_ratio` >= 85. Handles reordered tokens, extra
|
| 9 |
+
whitespace, capitalization. Merchant names on receipts are the classic
|
| 10 |
+
motivator (e.g. "TAN WOON YANN" vs "Tan Woon Yann Sdn Bhd").
|
| 11 |
+
- Money: absolute 0.01 OR relative 0.5% — either passes. Real-world receipts
|
| 12 |
+
have rounding on tax, so 0.5% covers subtotal/tax legitimate drift, and
|
| 13 |
+
0.01 handles the common integer-cent case exactly.
|
| 14 |
+
- Date/time: ISO-format equality. Both sides are already parsed (Pydantic on
|
| 15 |
+
the model side; JSON round-trip on ground truth) so a string == suffices.
|
| 16 |
+
- Exact: case- and whitespace-insensitive string equality. Used for
|
| 17 |
+
currency codes, SKUs, invoice numbers, phones.
|
| 18 |
+
- Number: exact numeric equality with tiny float tolerance.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from datetime import date, time
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
from rapidfuzz import fuzz
|
| 26 |
+
|
| 27 |
+
# --- Thresholds ------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
TEXT_FUZZ_THRESHOLD = 85 # rapidfuzz score out of 100
|
| 30 |
+
MONEY_ABS_TOL = 0.01 # $0.01 or 1¢
|
| 31 |
+
MONEY_REL_TOL = 0.005 # 0.5%
|
| 32 |
+
NUMBER_ABS_TOL = 1e-6
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --- Helpers ---------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
def _both_none(a: Any, b: Any) -> bool:
|
| 38 |
+
return a is None and b is None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _one_none(a: Any, b: Any) -> bool:
|
| 42 |
+
return (a is None) ^ (b is None)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _norm_str(v: Any) -> str:
|
| 46 |
+
return str(v).strip().lower()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# --- Comparators -----------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
def match_text(pred: Any, truth: Any) -> tuple[bool, float]:
|
| 52 |
+
"""Fuzzy text match — for free-text fields (names, descriptions)."""
|
| 53 |
+
if _both_none(pred, truth):
|
| 54 |
+
return True, 1.0
|
| 55 |
+
if _one_none(pred, truth):
|
| 56 |
+
return False, 0.0
|
| 57 |
+
p, t = _norm_str(pred), _norm_str(truth)
|
| 58 |
+
if not p and not t:
|
| 59 |
+
return True, 1.0
|
| 60 |
+
score = fuzz.token_set_ratio(p, t) / 100.0
|
| 61 |
+
return score >= (TEXT_FUZZ_THRESHOLD / 100.0), score
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def match_exact(pred: Any, truth: Any) -> tuple[bool, float]:
|
| 65 |
+
"""Case- and whitespace-insensitive equality — for codes/IDs/currency."""
|
| 66 |
+
if _both_none(pred, truth):
|
| 67 |
+
return True, 1.0
|
| 68 |
+
if _one_none(pred, truth):
|
| 69 |
+
return False, 0.0
|
| 70 |
+
return (_norm_str(pred) == _norm_str(truth)), 1.0 if _norm_str(pred) == _norm_str(truth) else 0.0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def match_money(pred: Any, truth: Any) -> tuple[bool, float]:
|
| 74 |
+
"""Money: 0.01 absolute OR 0.5% relative tolerance. Either passes."""
|
| 75 |
+
if _both_none(pred, truth):
|
| 76 |
+
return True, 1.0
|
| 77 |
+
if _one_none(pred, truth):
|
| 78 |
+
return False, 0.0
|
| 79 |
+
try:
|
| 80 |
+
p, t = float(pred), float(truth)
|
| 81 |
+
except (TypeError, ValueError):
|
| 82 |
+
return False, 0.0
|
| 83 |
+
diff = abs(p - t)
|
| 84 |
+
ok = diff <= MONEY_ABS_TOL or (t != 0 and (diff / abs(t)) <= MONEY_REL_TOL)
|
| 85 |
+
# score = 1 - normalized error, floored at 0
|
| 86 |
+
denom = max(abs(t), 1.0)
|
| 87 |
+
score = max(0.0, 1.0 - diff / denom)
|
| 88 |
+
return ok, score
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def match_number(pred: Any, truth: Any) -> tuple[bool, float]:
|
| 92 |
+
"""Numeric equality with tiny float tolerance. For qty, tax_rate, list sizes."""
|
| 93 |
+
if _both_none(pred, truth):
|
| 94 |
+
return True, 1.0
|
| 95 |
+
if _one_none(pred, truth):
|
| 96 |
+
return False, 0.0
|
| 97 |
+
try:
|
| 98 |
+
p, t = float(pred), float(truth)
|
| 99 |
+
except (TypeError, ValueError):
|
| 100 |
+
return False, 0.0
|
| 101 |
+
ok = abs(p - t) <= NUMBER_ABS_TOL
|
| 102 |
+
return ok, 1.0 if ok else 0.0
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def match_date(pred: Any, truth: Any) -> tuple[bool, float]:
|
| 106 |
+
"""ISO date equality. Accepts date objects or ISO strings on either side."""
|
| 107 |
+
if _both_none(pred, truth):
|
| 108 |
+
return True, 1.0
|
| 109 |
+
if _one_none(pred, truth):
|
| 110 |
+
return False, 0.0
|
| 111 |
+
p = pred.isoformat() if isinstance(pred, date) else str(pred)
|
| 112 |
+
t = truth.isoformat() if isinstance(truth, date) else str(truth)
|
| 113 |
+
ok = p == t
|
| 114 |
+
return ok, 1.0 if ok else 0.0
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def match_time(pred: Any, truth: Any) -> tuple[bool, float]:
|
| 118 |
+
"""ISO time equality."""
|
| 119 |
+
if _both_none(pred, truth):
|
| 120 |
+
return True, 1.0
|
| 121 |
+
if _one_none(pred, truth):
|
| 122 |
+
return False, 0.0
|
| 123 |
+
p = pred.isoformat() if isinstance(pred, time) else str(pred)
|
| 124 |
+
t = truth.isoformat() if isinstance(truth, time) else str(truth)
|
| 125 |
+
ok = p == t
|
| 126 |
+
return ok, 1.0 if ok else 0.0
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# --- Dispatch --------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
_DISPATCH = {
|
| 132 |
+
"text": match_text,
|
| 133 |
+
"exact": match_exact,
|
| 134 |
+
"money": match_money,
|
| 135 |
+
"number": match_number,
|
| 136 |
+
"date": match_date,
|
| 137 |
+
"time": match_time,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def compare(pred: Any, truth: Any, field_type: str) -> tuple[bool, float]:
|
| 142 |
+
"""Dispatch to the right comparator by field type."""
|
| 143 |
+
fn = _DISPATCH.get(field_type, match_exact)
|
| 144 |
+
return fn(pred, truth)
|
src/eval/flatten.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flatten a Pydantic model (or dict) to a {dotted.path: (value, field_type)} map.
|
| 2 |
+
|
| 3 |
+
Why:
|
| 4 |
+
Field-level metrics need every leaf field addressable by a stable key. Nested
|
| 5 |
+
models become "vendor.address.city", lists become "line_items[0].description".
|
| 6 |
+
|
| 7 |
+
Field types drive comparator choice in `comparators.py`:
|
| 8 |
+
- "money" -> float fields that hold monetary amounts (total, tax, unit_price)
|
| 9 |
+
- "date" -> datetime.date fields
|
| 10 |
+
- "time" -> datetime.time fields
|
| 11 |
+
- "number" -> other numeric fields (quantity, tax_rate)
|
| 12 |
+
- "text" -> free-text string fields (merchant name, description)
|
| 13 |
+
- "exact" -> short/normalized strings (currency, sku, phone, tax_id)
|
| 14 |
+
|
| 15 |
+
The type classifier uses Pydantic's field annotations plus a small set of
|
| 16 |
+
name-based hints for the money/exact split (both are `float`/`str` at the type
|
| 17 |
+
level but need different comparators).
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from datetime import date, time
|
| 22 |
+
from types import UnionType
|
| 23 |
+
from typing import Any, Union, get_args, get_origin
|
| 24 |
+
|
| 25 |
+
from pydantic import BaseModel
|
| 26 |
+
|
| 27 |
+
# --- Field-name hints -------------------------------------------------------
|
| 28 |
+
# Any float field whose name matches these is treated as MONEY (0.01 tolerance,
|
| 29 |
+
# 0.5% relative tolerance). Line-item quantities / tax rates fall through to
|
| 30 |
+
# "number" (exact match).
|
| 31 |
+
_MONEY_FIELD_NAMES = {
|
| 32 |
+
"total", "subtotal", "tax", "tip", "discount", "shipping",
|
| 33 |
+
"unit_price", "price", "amount",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# String fields whose name matches these are compared as EXACT (case/whitespace
|
| 37 |
+
# insensitive), not fuzzy text.
|
| 38 |
+
_EXACT_STRING_FIELD_NAMES = {
|
| 39 |
+
"currency", "sku", "invoice_number", "purchase_order_number",
|
| 40 |
+
"receipt_number", "tax_id", "postal_code", "country", "phone",
|
| 41 |
+
"merchant_phone", "payment_method",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
FieldMap = dict[str, tuple[Any, str]]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _unwrap_optional(annotation: Any) -> Any:
|
| 49 |
+
"""Return the non-None type inside Optional[X] / X | None."""
|
| 50 |
+
origin = get_origin(annotation)
|
| 51 |
+
if origin is Union or origin is UnionType:
|
| 52 |
+
non_none = [a for a in get_args(annotation) if a is not type(None)]
|
| 53 |
+
if len(non_none) == 1:
|
| 54 |
+
return non_none[0]
|
| 55 |
+
return annotation
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _classify(field_name: str, annotation: Any) -> str:
|
| 59 |
+
"""Pick a comparator bucket for a single leaf field."""
|
| 60 |
+
ann = _unwrap_optional(annotation)
|
| 61 |
+
|
| 62 |
+
if ann is date:
|
| 63 |
+
return "date"
|
| 64 |
+
if ann is time:
|
| 65 |
+
return "time"
|
| 66 |
+
if ann is bool:
|
| 67 |
+
return "exact"
|
| 68 |
+
if ann is int:
|
| 69 |
+
return "number"
|
| 70 |
+
if ann is float:
|
| 71 |
+
return "money" if field_name in _MONEY_FIELD_NAMES else "number"
|
| 72 |
+
if ann is str:
|
| 73 |
+
return "exact" if field_name in _EXACT_STRING_FIELD_NAMES else "text"
|
| 74 |
+
# Fallback for enums / unusual types.
|
| 75 |
+
return "exact"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def flatten_model(
|
| 79 |
+
model_or_dict: BaseModel | dict[str, Any] | None,
|
| 80 |
+
schema_cls: type[BaseModel],
|
| 81 |
+
prefix: str = "",
|
| 82 |
+
) -> FieldMap:
|
| 83 |
+
"""Flatten a Pydantic model instance OR a raw dict against `schema_cls`.
|
| 84 |
+
|
| 85 |
+
Ground-truth data comes in as dict (from JSONL); extractor output comes in
|
| 86 |
+
as Pydantic. This function handles both by using `schema_cls` as the shape
|
| 87 |
+
reference and looking up values in whichever form was passed.
|
| 88 |
+
"""
|
| 89 |
+
out: FieldMap = {}
|
| 90 |
+
if model_or_dict is None:
|
| 91 |
+
return out
|
| 92 |
+
|
| 93 |
+
# Normalize input to a dict-ish accessor.
|
| 94 |
+
def _get(name: str) -> Any:
|
| 95 |
+
if isinstance(model_or_dict, BaseModel):
|
| 96 |
+
return getattr(model_or_dict, name, None)
|
| 97 |
+
return model_or_dict.get(name)
|
| 98 |
+
|
| 99 |
+
for field_name, field_info in schema_cls.model_fields.items():
|
| 100 |
+
path = f"{prefix}{field_name}"
|
| 101 |
+
value = _get(field_name)
|
| 102 |
+
annotation = _unwrap_optional(field_info.annotation)
|
| 103 |
+
origin = get_origin(annotation)
|
| 104 |
+
|
| 105 |
+
# Case 1: nested Pydantic model
|
| 106 |
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
| 107 |
+
out.update(flatten_model(value, annotation, prefix=f"{path}."))
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
# Case 2: list[NestedModel] — flatten each element by index
|
| 111 |
+
if origin is list:
|
| 112 |
+
inner = get_args(annotation)[0]
|
| 113 |
+
inner = _unwrap_optional(inner)
|
| 114 |
+
if isinstance(inner, type) and issubclass(inner, BaseModel):
|
| 115 |
+
items = value or []
|
| 116 |
+
# Record list length under a synthetic key so eval can
|
| 117 |
+
# detect missing/extra items even when both sides are empty.
|
| 118 |
+
out[f"{path}[]"] = (len(items), "number")
|
| 119 |
+
for i, item in enumerate(items):
|
| 120 |
+
out.update(flatten_model(item, inner, prefix=f"{path}[{i}]."))
|
| 121 |
+
continue
|
| 122 |
+
# list of primitives — treat as one bucket
|
| 123 |
+
out[path] = (value, "exact")
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
# Case 3: leaf field
|
| 127 |
+
out[path] = (value, _classify(field_name, field_info.annotation))
|
| 128 |
+
|
| 129 |
+
return out
|
src/eval/metrics.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metrics aggregation: TP/FP/FN counters, precision/recall/F1.
|
| 2 |
+
|
| 3 |
+
Field-level classification per (doc, field):
|
| 4 |
+
- TP: both non-null AND comparator matches
|
| 5 |
+
- FP: prediction non-null AND (truth is null OR comparator fails)
|
| 6 |
+
- FN: truth non-null AND (prediction is null OR comparator fails)
|
| 7 |
+
- TN: both null (NOT counted — trivial)
|
| 8 |
+
|
| 9 |
+
Note: a "wrong" prediction counts as BOTH FP and FN by convention. This
|
| 10 |
+
matches how NER / structured-extraction leaderboards score mismatches
|
| 11 |
+
(over-count once as a wrong prediction and once as a missed truth).
|
| 12 |
+
|
| 13 |
+
At the document level, `exact_match` is True iff every field in the doc is
|
| 14 |
+
TP or TN.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from src.eval.comparators import compare
|
| 22 |
+
from src.eval.flatten import FieldMap
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class FieldStat:
|
| 27 |
+
"""Aggregate stats for one field across all docs."""
|
| 28 |
+
|
| 29 |
+
field: str
|
| 30 |
+
field_type: str
|
| 31 |
+
tp: int = 0
|
| 32 |
+
fp: int = 0
|
| 33 |
+
fn: int = 0
|
| 34 |
+
tn: int = 0
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def support(self) -> int:
|
| 38 |
+
"""Number of docs where truth was non-null (denominator for recall)."""
|
| 39 |
+
return self.tp + self.fn
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def precision(self) -> float:
|
| 43 |
+
denom = self.tp + self.fp
|
| 44 |
+
return self.tp / denom if denom else 0.0
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def recall(self) -> float:
|
| 48 |
+
denom = self.tp + self.fn
|
| 49 |
+
return self.tp / denom if denom else 0.0
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def f1(self) -> float:
|
| 53 |
+
p, r = self.precision, self.recall
|
| 54 |
+
return 2 * p * r / (p + r) if (p + r) else 0.0
|
| 55 |
+
|
| 56 |
+
def to_dict(self) -> dict[str, Any]:
|
| 57 |
+
return {
|
| 58 |
+
"field": self.field,
|
| 59 |
+
"field_type": self.field_type,
|
| 60 |
+
"tp": self.tp,
|
| 61 |
+
"fp": self.fp,
|
| 62 |
+
"fn": self.fn,
|
| 63 |
+
"tn": self.tn,
|
| 64 |
+
"support": self.support,
|
| 65 |
+
"precision": round(self.precision, 4),
|
| 66 |
+
"recall": round(self.recall, 4),
|
| 67 |
+
"f1": round(self.f1, 4),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass
|
| 72 |
+
class DocStat:
|
| 73 |
+
"""Per-document stats."""
|
| 74 |
+
|
| 75 |
+
doc_id: str
|
| 76 |
+
fields_correct: int = 0
|
| 77 |
+
fields_total: int = 0
|
| 78 |
+
exact_match: bool = False
|
| 79 |
+
per_field: list[dict[str, Any]] = field(default_factory=list)
|
| 80 |
+
latency_ms: float = 0.0
|
| 81 |
+
cost_usd: float = 0.0
|
| 82 |
+
error: str | None = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def score_doc(
|
| 86 |
+
doc_id: str,
|
| 87 |
+
predicted: FieldMap,
|
| 88 |
+
truth: FieldMap,
|
| 89 |
+
) -> tuple[DocStat, dict[str, tuple[int, int, int, int]]]:
|
| 90 |
+
"""Score one document. Returns (DocStat, {field -> (tp, fp, fn, tn)})."""
|
| 91 |
+
# Union of paths — every field either side reported.
|
| 92 |
+
all_paths = sorted(set(predicted) | set(truth))
|
| 93 |
+
per_field_counts: dict[str, tuple[int, int, int, int]] = {}
|
| 94 |
+
stat = DocStat(doc_id=doc_id)
|
| 95 |
+
|
| 96 |
+
fields_ok = 0
|
| 97 |
+
fields_scored = 0
|
| 98 |
+
exact = True
|
| 99 |
+
|
| 100 |
+
for path in all_paths:
|
| 101 |
+
p_val, p_type = predicted.get(path, (None, "exact"))
|
| 102 |
+
t_val, t_type = truth.get(path, (None, "exact"))
|
| 103 |
+
field_type = t_type if path in truth else p_type
|
| 104 |
+
|
| 105 |
+
p_null = p_val is None
|
| 106 |
+
t_null = t_val is None
|
| 107 |
+
|
| 108 |
+
if p_null and t_null:
|
| 109 |
+
per_field_counts[path] = (0, 0, 0, 1) # TN — trivial
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
fields_scored += 1
|
| 113 |
+
matched, score = compare(p_val, t_val, field_type)
|
| 114 |
+
|
| 115 |
+
if matched and not p_null and not t_null:
|
| 116 |
+
per_field_counts[path] = (1, 0, 0, 0)
|
| 117 |
+
fields_ok += 1
|
| 118 |
+
outcome = "TP"
|
| 119 |
+
elif p_null and not t_null:
|
| 120 |
+
per_field_counts[path] = (0, 0, 1, 0)
|
| 121 |
+
exact = False
|
| 122 |
+
outcome = "FN"
|
| 123 |
+
elif t_null and not p_null:
|
| 124 |
+
per_field_counts[path] = (0, 1, 0, 0)
|
| 125 |
+
exact = False
|
| 126 |
+
outcome = "FP"
|
| 127 |
+
else:
|
| 128 |
+
# both non-null but comparator says no
|
| 129 |
+
per_field_counts[path] = (0, 1, 1, 0)
|
| 130 |
+
exact = False
|
| 131 |
+
outcome = "MISMATCH"
|
| 132 |
+
|
| 133 |
+
stat.per_field.append(
|
| 134 |
+
{
|
| 135 |
+
"field": path,
|
| 136 |
+
"field_type": field_type,
|
| 137 |
+
"predicted": _stringify(p_val),
|
| 138 |
+
"truth": _stringify(t_val),
|
| 139 |
+
"outcome": outcome,
|
| 140 |
+
"score": round(score, 3),
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
stat.fields_correct = fields_ok
|
| 145 |
+
stat.fields_total = fields_scored
|
| 146 |
+
stat.exact_match = exact and fields_scored > 0
|
| 147 |
+
return stat, per_field_counts
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def aggregate(
|
| 151 |
+
per_doc_counts: list[dict[str, tuple[int, int, int, int]]],
|
| 152 |
+
field_types: dict[str, str],
|
| 153 |
+
) -> dict[str, FieldStat]:
|
| 154 |
+
"""Sum per-doc counts into FieldStat objects keyed by field path."""
|
| 155 |
+
stats: dict[str, FieldStat] = {}
|
| 156 |
+
for doc_counts in per_doc_counts:
|
| 157 |
+
for path, (tp, fp, fn, tn) in doc_counts.items():
|
| 158 |
+
if path not in stats:
|
| 159 |
+
stats[path] = FieldStat(field=path, field_type=field_types.get(path, "exact"))
|
| 160 |
+
s = stats[path]
|
| 161 |
+
s.tp += tp
|
| 162 |
+
s.fp += fp
|
| 163 |
+
s.fn += fn
|
| 164 |
+
s.tn += tn
|
| 165 |
+
return stats
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def micro_macro(stats: dict[str, FieldStat]) -> dict[str, float]:
|
| 169 |
+
"""Compute micro-F1 (pool all counts) and macro-F1 (mean of per-field F1)."""
|
| 170 |
+
if not stats:
|
| 171 |
+
return {"micro_precision": 0, "micro_recall": 0, "micro_f1": 0, "macro_f1": 0}
|
| 172 |
+
|
| 173 |
+
tp = sum(s.tp for s in stats.values())
|
| 174 |
+
fp = sum(s.fp for s in stats.values())
|
| 175 |
+
fn = sum(s.fn for s in stats.values())
|
| 176 |
+
micro_p = tp / (tp + fp) if (tp + fp) else 0.0
|
| 177 |
+
micro_r = tp / (tp + fn) if (tp + fn) else 0.0
|
| 178 |
+
micro_f1 = 2 * micro_p * micro_r / (micro_p + micro_r) if (micro_p + micro_r) else 0.0
|
| 179 |
+
|
| 180 |
+
supported = [s for s in stats.values() if s.support > 0]
|
| 181 |
+
macro_f1 = sum(s.f1 for s in supported) / len(supported) if supported else 0.0
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
"micro_precision": round(micro_p, 4),
|
| 185 |
+
"micro_recall": round(micro_r, 4),
|
| 186 |
+
"micro_f1": round(micro_f1, 4),
|
| 187 |
+
"macro_f1": round(macro_f1, 4),
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _stringify(v: Any) -> str:
|
| 192 |
+
if v is None:
|
| 193 |
+
return ""
|
| 194 |
+
if isinstance(v, float):
|
| 195 |
+
return f"{v:.4f}".rstrip("0").rstrip(".")
|
| 196 |
+
return str(v)
|
src/eval/report.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSV + markdown reporters for EvalReport."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from src.eval.runner import EvalReport
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# --- CSV -------------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
def write_per_record_csv(report: EvalReport, out_path: str | Path) -> Path:
|
| 15 |
+
"""One row per (doc, field) with predicted, truth, outcome, score."""
|
| 16 |
+
out_path = Path(out_path)
|
| 17 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
with out_path.open("w", newline="", encoding="utf-8") as f:
|
| 20 |
+
w = csv.writer(f)
|
| 21 |
+
w.writerow([
|
| 22 |
+
"doc_id", "field", "field_type", "predicted", "truth",
|
| 23 |
+
"outcome", "score", "latency_ms", "cost_usd",
|
| 24 |
+
])
|
| 25 |
+
for doc in report.doc_stats:
|
| 26 |
+
if doc.error:
|
| 27 |
+
w.writerow([doc.doc_id, "__error__", "", "", "", "ERROR", 0.0, "", ""])
|
| 28 |
+
continue
|
| 29 |
+
for row in doc.per_field:
|
| 30 |
+
w.writerow([
|
| 31 |
+
doc.doc_id,
|
| 32 |
+
row["field"],
|
| 33 |
+
row["field_type"],
|
| 34 |
+
row["predicted"],
|
| 35 |
+
row["truth"],
|
| 36 |
+
row["outcome"],
|
| 37 |
+
row["score"],
|
| 38 |
+
round(doc.latency_ms, 1),
|
| 39 |
+
round(doc.cost_usd, 6),
|
| 40 |
+
])
|
| 41 |
+
return out_path
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def write_summary_json(report: EvalReport, out_path: str | Path) -> Path:
|
| 45 |
+
"""Machine-readable summary — feeds the multi-model benchmark table."""
|
| 46 |
+
out_path = Path(out_path)
|
| 47 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
payload = {
|
| 49 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 50 |
+
"summary": report.summary(),
|
| 51 |
+
"aggregate": report.aggregate,
|
| 52 |
+
"field_stats": {k: s.to_dict() for k, s in report.field_stats.items()},
|
| 53 |
+
}
|
| 54 |
+
out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 55 |
+
return out_path
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --- Markdown --------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
def write_markdown_summary(report: EvalReport, out_path: str | Path) -> Path:
|
| 61 |
+
"""Resume-worthy markdown: headline metrics + top/bottom field table."""
|
| 62 |
+
out_path = Path(out_path)
|
| 63 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
|
| 65 |
+
s = report.summary()
|
| 66 |
+
lines: list[str] = []
|
| 67 |
+
lines.append(f"# Evaluation Report — `{report.doc_type}` on `{report.model}`")
|
| 68 |
+
lines.append("")
|
| 69 |
+
lines.append(f"_Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}_")
|
| 70 |
+
lines.append("")
|
| 71 |
+
lines.append("## Headline")
|
| 72 |
+
lines.append("")
|
| 73 |
+
lines.append("| Metric | Value |")
|
| 74 |
+
lines.append("|---|---|")
|
| 75 |
+
lines.append(f"| Documents evaluated | {s['n_docs']} |")
|
| 76 |
+
lines.append(f"| Extractor errors | {s['errors']} |")
|
| 77 |
+
lines.append(f"| **Micro F1** | **{s['micro_f1']:.4f}** |")
|
| 78 |
+
lines.append(f"| **Macro F1** | **{s['macro_f1']:.4f}** |")
|
| 79 |
+
lines.append(f"| Doc exact-match rate| {s['doc_exact_match']:.2%} |")
|
| 80 |
+
lines.append(f"| Mean latency | {s['mean_latency_ms']:.0f} ms |")
|
| 81 |
+
lines.append(f"| Mean cost / doc | ${s['mean_cost_usd']:.6f} |")
|
| 82 |
+
lines.append(f"| Total cost | ${s['total_cost_usd']:.4f} |")
|
| 83 |
+
lines.append(f"| Wall time | {s['wall_time_s']:.2f} s |")
|
| 84 |
+
lines.append("")
|
| 85 |
+
|
| 86 |
+
lines.append("## Per-field performance")
|
| 87 |
+
lines.append("")
|
| 88 |
+
lines.append("| Field | Type | Support | Precision | Recall | F1 |")
|
| 89 |
+
lines.append("|---|---|---:|---:|---:|---:|")
|
| 90 |
+
ordered = sorted(
|
| 91 |
+
report.field_stats.values(),
|
| 92 |
+
key=lambda st: (-st.support, -st.f1, st.field),
|
| 93 |
+
)
|
| 94 |
+
for st in ordered:
|
| 95 |
+
if st.support == 0 and st.fp == 0:
|
| 96 |
+
continue # skip fields never seen
|
| 97 |
+
lines.append(
|
| 98 |
+
f"| `{st.field}` | {st.field_type} | {st.support} | "
|
| 99 |
+
f"{st.precision:.3f} | {st.recall:.3f} | {st.f1:.3f} |"
|
| 100 |
+
)
|
| 101 |
+
lines.append("")
|
| 102 |
+
|
| 103 |
+
if report.n_errors:
|
| 104 |
+
lines.append("## Errors")
|
| 105 |
+
lines.append("")
|
| 106 |
+
for d in report.doc_stats:
|
| 107 |
+
if d.error:
|
| 108 |
+
lines.append(f"- `{d.doc_id}`: {d.error}")
|
| 109 |
+
lines.append("")
|
| 110 |
+
|
| 111 |
+
out_path.write_text("\n".join(lines), encoding="utf-8")
|
| 112 |
+
return out_path
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# --- Convenience -----------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
def write_reports(report: EvalReport, out_dir: str | Path) -> dict[str, Path]:
|
| 118 |
+
"""Write CSV, JSON summary, and markdown into `out_dir`. Returns all paths."""
|
| 119 |
+
out_dir = Path(out_dir)
|
| 120 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
tag = f"{report.doc_type}_{report.model.replace('/', '_')}"
|
| 122 |
+
return {
|
| 123 |
+
"csv": write_per_record_csv(report, out_dir / f"{tag}_per_record.csv"),
|
| 124 |
+
"json": write_summary_json(report, out_dir / f"{tag}_summary.json"),
|
| 125 |
+
"markdown": write_markdown_summary(report, out_dir / f"{tag}_summary.md"),
|
| 126 |
+
}
|
src/eval/runner.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Eval runner: iterate JSONL ground truth, call extractor, score, aggregate.
|
| 2 |
+
|
| 3 |
+
Design note — extractor is injectable:
|
| 4 |
+
The runner takes any `Callable[[dict], tuple[ExtractionResult, ExtractionMetrics]]`.
|
| 5 |
+
This keeps the runner testable without hitting the OpenAI API: tests pass
|
| 6 |
+
a fake callable that returns pre-baked results. The CLI passes a real
|
| 7 |
+
extractor closure that loads document bytes and calls DocumentExtractor.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time as _time
|
| 12 |
+
from collections.abc import Callable, Sequence
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from statistics import mean
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
|
| 19 |
+
from src.eval.flatten import flatten_model
|
| 20 |
+
from src.eval.metrics import DocStat, FieldStat, aggregate, micro_macro, score_doc
|
| 21 |
+
from src.schemas import ExtractionResult
|
| 22 |
+
from src.schemas.registry import get_schema
|
| 23 |
+
from src.utils.cost_tracker import ExtractionMetrics
|
| 24 |
+
from src.utils.logging import logger
|
| 25 |
+
|
| 26 |
+
# --- Types -----------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
# (record_dict) -> (ExtractionResult, ExtractionMetrics)
|
| 29 |
+
ExtractorFn = Callable[[dict], tuple[ExtractionResult, ExtractionMetrics]]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class EvalReport:
|
| 34 |
+
"""Everything you need to write CSV + markdown reports."""
|
| 35 |
+
|
| 36 |
+
doc_type: str
|
| 37 |
+
model: str
|
| 38 |
+
n_docs: int
|
| 39 |
+
n_errors: int
|
| 40 |
+
field_stats: dict[str, FieldStat]
|
| 41 |
+
doc_stats: list[DocStat]
|
| 42 |
+
aggregate: dict[str, float]
|
| 43 |
+
doc_exact_match_rate: float
|
| 44 |
+
mean_latency_ms: float
|
| 45 |
+
mean_cost_usd: float
|
| 46 |
+
total_cost_usd: float
|
| 47 |
+
wall_time_s: float
|
| 48 |
+
|
| 49 |
+
def summary(self) -> dict[str, Any]:
|
| 50 |
+
"""One-line resume-worthy summary."""
|
| 51 |
+
return {
|
| 52 |
+
"model": self.model,
|
| 53 |
+
"doc_type": self.doc_type,
|
| 54 |
+
"n_docs": self.n_docs,
|
| 55 |
+
"errors": self.n_errors,
|
| 56 |
+
"micro_f1": self.aggregate.get("micro_f1", 0.0),
|
| 57 |
+
"macro_f1": self.aggregate.get("macro_f1", 0.0),
|
| 58 |
+
"doc_exact_match": round(self.doc_exact_match_rate, 4),
|
| 59 |
+
"mean_latency_ms": round(self.mean_latency_ms, 1),
|
| 60 |
+
"mean_cost_usd": round(self.mean_cost_usd, 6),
|
| 61 |
+
"total_cost_usd": round(self.total_cost_usd, 4),
|
| 62 |
+
"wall_time_s": round(self.wall_time_s, 2),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def run_eval(
|
| 67 |
+
records: Sequence[dict],
|
| 68 |
+
extractor: ExtractorFn,
|
| 69 |
+
doc_type: str,
|
| 70 |
+
*,
|
| 71 |
+
model_label: str = "unknown",
|
| 72 |
+
limit: int | None = None,
|
| 73 |
+
) -> EvalReport:
|
| 74 |
+
"""Run the full eval loop.
|
| 75 |
+
|
| 76 |
+
- `records`: JSONL rows, each a dict with keys "id" and "ground_truth".
|
| 77 |
+
- `extractor(record)` must return (ExtractionResult, ExtractionMetrics).
|
| 78 |
+
- `doc_type`: "invoice" | "receipt" — selects the schema for flattening.
|
| 79 |
+
- `model_label`: purely for the report header; extractor decides real model.
|
| 80 |
+
- `limit`: cap number of records (handy for quick smoke runs).
|
| 81 |
+
"""
|
| 82 |
+
schema_cls: type[BaseModel] = get_schema(doc_type)
|
| 83 |
+
if limit:
|
| 84 |
+
records = list(records)[:limit]
|
| 85 |
+
|
| 86 |
+
per_doc_counts: list[dict[str, tuple[int, int, int, int]]] = []
|
| 87 |
+
doc_stats: list[DocStat] = []
|
| 88 |
+
field_types: dict[str, str] = {}
|
| 89 |
+
latencies: list[float] = []
|
| 90 |
+
costs: list[float] = []
|
| 91 |
+
errors = 0
|
| 92 |
+
|
| 93 |
+
wall_start = _time.perf_counter()
|
| 94 |
+
|
| 95 |
+
for i, rec in enumerate(records):
|
| 96 |
+
doc_id = rec.get("id", f"doc_{i}")
|
| 97 |
+
truth_dict = rec.get("ground_truth", {})
|
| 98 |
+
truth_flat = flatten_model(truth_dict, schema_cls)
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
result, metrics = extractor(rec)
|
| 102 |
+
except Exception as e: # noqa: BLE001
|
| 103 |
+
logger.warning(f"[eval] extractor failed for {doc_id}: {e}")
|
| 104 |
+
errors += 1
|
| 105 |
+
doc_stats.append(DocStat(doc_id=doc_id, error=str(e)))
|
| 106 |
+
# Count every truth field as FN so recall reflects the failure.
|
| 107 |
+
per_doc_counts.append({p: (0, 0, 1, 0) for p, (v, _) in truth_flat.items() if v is not None})
|
| 108 |
+
for p, (_v, t) in truth_flat.items():
|
| 109 |
+
field_types.setdefault(p, t)
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
pred_flat = flatten_model(result.data, schema_cls)
|
| 113 |
+
# Merge type info from both sides (truth wins on conflict).
|
| 114 |
+
for p, (_v, t) in pred_flat.items():
|
| 115 |
+
field_types.setdefault(p, t)
|
| 116 |
+
for p, (_v, t) in truth_flat.items():
|
| 117 |
+
field_types[p] = t
|
| 118 |
+
|
| 119 |
+
doc_stat, counts = score_doc(doc_id, pred_flat, truth_flat)
|
| 120 |
+
doc_stat.latency_ms = metrics.latency_ms
|
| 121 |
+
doc_stat.cost_usd = metrics.cost_usd
|
| 122 |
+
doc_stats.append(doc_stat)
|
| 123 |
+
per_doc_counts.append(counts)
|
| 124 |
+
latencies.append(metrics.latency_ms)
|
| 125 |
+
costs.append(metrics.cost_usd)
|
| 126 |
+
|
| 127 |
+
wall = _time.perf_counter() - wall_start
|
| 128 |
+
|
| 129 |
+
field_stats = aggregate(per_doc_counts, field_types)
|
| 130 |
+
agg = micro_macro(field_stats)
|
| 131 |
+
|
| 132 |
+
scored_docs = [d for d in doc_stats if d.error is None]
|
| 133 |
+
exact_rate = (
|
| 134 |
+
sum(1 for d in scored_docs if d.exact_match) / len(scored_docs)
|
| 135 |
+
if scored_docs
|
| 136 |
+
else 0.0
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
return EvalReport(
|
| 140 |
+
doc_type=doc_type,
|
| 141 |
+
model=model_label,
|
| 142 |
+
n_docs=len(records),
|
| 143 |
+
n_errors=errors,
|
| 144 |
+
field_stats=field_stats,
|
| 145 |
+
doc_stats=doc_stats,
|
| 146 |
+
aggregate=agg,
|
| 147 |
+
doc_exact_match_rate=exact_rate,
|
| 148 |
+
mean_latency_ms=mean(latencies) if latencies else 0.0,
|
| 149 |
+
mean_cost_usd=mean(costs) if costs else 0.0,
|
| 150 |
+
total_cost_usd=sum(costs),
|
| 151 |
+
wall_time_s=wall,
|
| 152 |
+
)
|
src/ui/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/ui — deprecated in favor of `ui/`
|
| 2 |
+
|
| 3 |
+
Original plan for this folder was a Streamlit demo. We upgraded to a proper
|
| 4 |
+
React + Motion + R3F frontend that lives at the repo root under `ui/`.
|
| 5 |
+
|
| 6 |
+
Streamlit couldn't carry the Paper & Ink design language (custom fonts,
|
| 7 |
+
theme variables, kinetic typography, 3D scene), so we swapped stacks.
|
| 8 |
+
|
| 9 |
+
To run the UI:
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
cd ui
|
| 13 |
+
npm install
|
| 14 |
+
npm run dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
See `ui/README.md` for the full picture.
|
tests/unit/test_api.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI endpoint tests using TestClient + dependency_overrides.
|
| 2 |
+
|
| 3 |
+
No OpenAI key is required — `get_extractor` is overridden with a fake that
|
| 4 |
+
returns a hand-built ExtractionResult. This tests the API layer in isolation
|
| 5 |
+
from the extractor.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi.testclient import TestClient
|
| 13 |
+
|
| 14 |
+
from src.api.deps import MAX_UPLOAD_BYTES, get_extractor
|
| 15 |
+
from src.api.main import create_app
|
| 16 |
+
from src.schemas import ExtractionResult, Receipt
|
| 17 |
+
from src.utils.cost_tracker import ExtractionMetrics
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# --- Fake extractor --------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
class _FakeExtractor:
|
| 23 |
+
"""Stand-in for DocumentExtractor — no network. Records last call for asserts."""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
self.last_call: dict = {}
|
| 27 |
+
|
| 28 |
+
def extract(self, file_bytes, filename, doc_type, *, model_override=None, render_images=True):
|
| 29 |
+
self.last_call = {
|
| 30 |
+
"filename": filename,
|
| 31 |
+
"doc_type": doc_type,
|
| 32 |
+
"size": len(file_bytes),
|
| 33 |
+
"model_override": model_override,
|
| 34 |
+
}
|
| 35 |
+
data = Receipt(merchant="ACME COFFEE", total=4.50, currency="USD")
|
| 36 |
+
result = ExtractionResult(
|
| 37 |
+
document_type=doc_type,
|
| 38 |
+
data=data,
|
| 39 |
+
field_confidences=[],
|
| 40 |
+
overall_confidence=0.95,
|
| 41 |
+
warnings=[],
|
| 42 |
+
raw_text_snippet="ACME COFFEE\nTotal: $4.50",
|
| 43 |
+
)
|
| 44 |
+
metrics = ExtractionMetrics(
|
| 45 |
+
input_tokens=100, output_tokens=50, latency_ms=123.4,
|
| 46 |
+
model=model_override or "gpt-5-nano",
|
| 47 |
+
)
|
| 48 |
+
return result, metrics
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.fixture
|
| 52 |
+
def fake_extractor():
|
| 53 |
+
return _FakeExtractor()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.fixture
|
| 57 |
+
def client(fake_extractor):
|
| 58 |
+
app = create_app()
|
| 59 |
+
app.dependency_overrides[get_extractor] = lambda: fake_extractor
|
| 60 |
+
with TestClient(app) as c:
|
| 61 |
+
yield c
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --- Root / health ---------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
class TestHealth:
|
| 67 |
+
def test_root(self, client):
|
| 68 |
+
r = client.get("/")
|
| 69 |
+
assert r.status_code == 200
|
| 70 |
+
body = r.json()
|
| 71 |
+
assert body["service"] == "structured-data-extraction"
|
| 72 |
+
assert "version" in body
|
| 73 |
+
|
| 74 |
+
def test_health(self, client):
|
| 75 |
+
r = client.get("/health")
|
| 76 |
+
assert r.status_code == 200
|
| 77 |
+
assert r.json() == {"status": "ok"}
|
| 78 |
+
|
| 79 |
+
def test_request_id_header_echoed(self, client):
|
| 80 |
+
r = client.get("/health", headers={"X-Request-ID": "test-rid-123"})
|
| 81 |
+
assert r.headers["X-Request-ID"] == "test-rid-123"
|
| 82 |
+
|
| 83 |
+
def test_request_id_generated_when_absent(self, client):
|
| 84 |
+
r = client.get("/health")
|
| 85 |
+
assert r.headers.get("X-Request-ID")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# --- Schemas ---------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
class TestSchemas:
|
| 91 |
+
def test_list_schemas(self, client):
|
| 92 |
+
r = client.get("/schemas")
|
| 93 |
+
assert r.status_code == 200
|
| 94 |
+
body = r.json()
|
| 95 |
+
assert "invoice" in body["doc_types"]
|
| 96 |
+
assert "receipt" in body["doc_types"]
|
| 97 |
+
|
| 98 |
+
def test_get_receipt_schema(self, client):
|
| 99 |
+
r = client.get("/schemas/receipt")
|
| 100 |
+
assert r.status_code == 200
|
| 101 |
+
schema = r.json()
|
| 102 |
+
assert schema["type"] == "object"
|
| 103 |
+
# Receipt has 'merchant' and 'total' as required-ish leaves
|
| 104 |
+
assert "properties" in schema
|
| 105 |
+
assert "merchant" in schema["properties"]
|
| 106 |
+
assert "total" in schema["properties"]
|
| 107 |
+
|
| 108 |
+
def test_unknown_doc_type_returns_400_envelope(self, client):
|
| 109 |
+
r = client.get("/schemas/spaceship_manual")
|
| 110 |
+
assert r.status_code == 400
|
| 111 |
+
env = r.json()
|
| 112 |
+
assert env["error"]["code"] == "unsupported_doc_type"
|
| 113 |
+
assert env["error"]["request_id"]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# --- Extract ---------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
class TestExtract:
|
| 119 |
+
def _upload(self, client, *, filename="receipt.png", content=b"\x89PNG\r\n\x1a\n" + b"x" * 128,
|
| 120 |
+
content_type="image/png", doc_type="receipt", model=None):
|
| 121 |
+
files = {"file": (filename, io.BytesIO(content), content_type)}
|
| 122 |
+
data = {"doc_type": doc_type}
|
| 123 |
+
if model is not None:
|
| 124 |
+
data["model"] = model
|
| 125 |
+
return client.post("/extract", files=files, data=data)
|
| 126 |
+
|
| 127 |
+
def test_extract_happy_path(self, client, fake_extractor):
|
| 128 |
+
r = self._upload(client)
|
| 129 |
+
assert r.status_code == 200, r.text
|
| 130 |
+
body = r.json()
|
| 131 |
+
assert body["result"]["document_type"] == "receipt"
|
| 132 |
+
assert body["result"]["data"]["merchant"] == "ACME COFFEE"
|
| 133 |
+
assert body["result"]["data"]["total"] == 4.5
|
| 134 |
+
assert body["metrics"]["input_tokens"] == 100
|
| 135 |
+
assert body["metrics"]["model"] == "gpt-5-nano"
|
| 136 |
+
# Extractor received what we uploaded
|
| 137 |
+
assert fake_extractor.last_call["doc_type"] == "receipt"
|
| 138 |
+
assert fake_extractor.last_call["filename"] == "receipt.png"
|
| 139 |
+
|
| 140 |
+
def test_extract_model_override_flows_through(self, client, fake_extractor):
|
| 141 |
+
r = self._upload(client, model="gpt-5.4")
|
| 142 |
+
assert r.status_code == 200
|
| 143 |
+
assert r.json()["metrics"]["model"] == "gpt-5.4"
|
| 144 |
+
assert fake_extractor.last_call["model_override"] == "gpt-5.4"
|
| 145 |
+
|
| 146 |
+
def test_unknown_doc_type_400(self, client):
|
| 147 |
+
r = self._upload(client, doc_type="spaceship_manual")
|
| 148 |
+
assert r.status_code == 400
|
| 149 |
+
assert r.json()["error"]["code"] == "unsupported_doc_type"
|
| 150 |
+
|
| 151 |
+
def test_unsupported_extension_415(self, client):
|
| 152 |
+
r = self._upload(client, filename="malware.exe", content=b"MZ" + b"x" * 100,
|
| 153 |
+
content_type="application/octet-stream")
|
| 154 |
+
assert r.status_code == 415
|
| 155 |
+
assert r.json()["error"]["code"] == "unsupported_media_type"
|
| 156 |
+
|
| 157 |
+
def test_missing_file_422(self, client):
|
| 158 |
+
r = client.post("/extract", data={"doc_type": "receipt"})
|
| 159 |
+
assert r.status_code == 422
|
| 160 |
+
assert r.json()["error"]["code"] == "validation_error"
|
| 161 |
+
|
| 162 |
+
def test_missing_doc_type_422(self, client):
|
| 163 |
+
files = {"file": ("x.png", io.BytesIO(b"\x89PNG"), "image/png")}
|
| 164 |
+
r = client.post("/extract", files=files)
|
| 165 |
+
assert r.status_code == 422
|
| 166 |
+
|
| 167 |
+
def test_empty_file_422(self, client):
|
| 168 |
+
r = self._upload(client, content=b"")
|
| 169 |
+
assert r.status_code == 422
|
| 170 |
+
assert r.json()["error"]["code"] == "empty_document"
|
| 171 |
+
|
| 172 |
+
def test_oversized_file_413(self, client, monkeypatch):
|
| 173 |
+
# Shrink the cap so we don't have to actually upload 10MB
|
| 174 |
+
monkeypatch.setattr("src.api.routers.extract.MAX_UPLOAD_BYTES", 32)
|
| 175 |
+
r = self._upload(client, content=b"\x89PNG" + b"x" * 200)
|
| 176 |
+
assert r.status_code == 413
|
| 177 |
+
env = r.json()
|
| 178 |
+
assert env["error"]["code"] == "file_too_large"
|
| 179 |
+
assert env["error"]["details"]["max_bytes"] == 32
|
| 180 |
+
|
| 181 |
+
def test_extractor_failure_502(self, client, fake_extractor):
|
| 182 |
+
def _raise(*_a, **_k):
|
| 183 |
+
raise RuntimeError("openai unreachable")
|
| 184 |
+
|
| 185 |
+
fake_extractor.extract = _raise
|
| 186 |
+
r = self._upload(client)
|
| 187 |
+
assert r.status_code == 502
|
| 188 |
+
assert r.json()["error"]["code"] == "extraction_failed"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# --- Error envelope shape --------------------------------------------------
|
| 192 |
+
|
| 193 |
+
class TestErrorEnvelope:
|
| 194 |
+
def test_envelope_has_all_fields(self, client):
|
| 195 |
+
r = client.get("/schemas/nope")
|
| 196 |
+
body = r.json()
|
| 197 |
+
assert set(body.keys()) == {"error"}
|
| 198 |
+
assert set(body["error"].keys()) >= {"code", "message", "request_id", "details"}
|
tests/unit/test_eval.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the evaluation harness.
|
| 2 |
+
|
| 3 |
+
We test each layer independently plus one end-to-end run with a mocked
|
| 4 |
+
extractor. No OpenAI calls are made — the runner accepts any callable that
|
| 5 |
+
returns (ExtractionResult, ExtractionMetrics), which is what makes this
|
| 6 |
+
testable offline.
|
| 7 |
+
|
| 8 |
+
Layers covered: comparators, flatten, doc-scoring, aggregation, runner, reports.
|
| 9 |
+
Version tag v2 (forces bytecode invalidation on mounted filesystems).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from datetime import date
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from src.data_prep.writer import read_jsonl
|
| 18 |
+
from src.eval.comparators import (
|
| 19 |
+
compare,
|
| 20 |
+
match_date,
|
| 21 |
+
match_exact,
|
| 22 |
+
match_money,
|
| 23 |
+
match_number,
|
| 24 |
+
match_text,
|
| 25 |
+
)
|
| 26 |
+
from src.eval.flatten import flatten_model
|
| 27 |
+
from src.eval.metrics import aggregate, micro_macro, score_doc
|
| 28 |
+
from src.eval.runner import run_eval
|
| 29 |
+
from src.schemas import ExtractionResult, Receipt
|
| 30 |
+
from src.utils.cost_tracker import ExtractionMetrics
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# --- Comparators -----------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
class TestComparators:
|
| 36 |
+
def test_text_fuzzy_match(self):
|
| 37 |
+
assert match_text("TAN WOON YANN", "Tan Woon Yann")[0]
|
| 38 |
+
assert match_text("TAN WOON YANN SDN BHD", "TAN WOON YANN")[0]
|
| 39 |
+
assert match_text("Starbucks", "McDonalds")[0] is False
|
| 40 |
+
|
| 41 |
+
def test_text_null_handling(self):
|
| 42 |
+
assert match_text(None, None) == (True, 1.0)
|
| 43 |
+
assert match_text("x", None)[0] is False
|
| 44 |
+
assert match_text(None, "x")[0] is False
|
| 45 |
+
|
| 46 |
+
def test_money_within_tolerance(self):
|
| 47 |
+
# Absolute tolerance: 0.01
|
| 48 |
+
assert match_money(72.00, 72.01)[0] is True
|
| 49 |
+
# Relative tolerance: 0.5% of 1000 = 5.00, so 4.00 delta passes
|
| 50 |
+
assert match_money(1000.00, 1004.00)[0] is True
|
| 51 |
+
|
| 52 |
+
def test_money_outside_tolerance(self):
|
| 53 |
+
# Small values where 0.5% rel is tiny and abs 0.01 is exceeded
|
| 54 |
+
assert match_money(1.00, 1.05)[0] is False
|
| 55 |
+
# 100 vs 102 -> abs 2 > 0.01, rel 2% > 0.5%
|
| 56 |
+
assert match_money(100.00, 102.00)[0] is False
|
| 57 |
+
|
| 58 |
+
def test_money_null(self):
|
| 59 |
+
assert match_money(None, None)[0] is True
|
| 60 |
+
assert match_money(0.0, None)[0] is False
|
| 61 |
+
|
| 62 |
+
def test_number_exact(self):
|
| 63 |
+
assert match_number(3, 3)[0] is True
|
| 64 |
+
assert match_number(3, 3.0000001)[0] is True
|
| 65 |
+
assert match_number(3, 4)[0] is False
|
| 66 |
+
|
| 67 |
+
def test_date_iso(self):
|
| 68 |
+
assert match_date(date(2018, 6, 25), "2018-06-25")[0] is True
|
| 69 |
+
assert match_date(date(2018, 6, 25), date(2018, 6, 25))[0] is True
|
| 70 |
+
assert match_date(date(2018, 6, 25), "2018-06-26")[0] is False
|
| 71 |
+
|
| 72 |
+
def test_exact_normalizes(self):
|
| 73 |
+
assert match_exact("USD", " usd ")[0] is True
|
| 74 |
+
assert match_exact("USD", "EUR")[0] is False
|
| 75 |
+
|
| 76 |
+
def test_dispatch(self):
|
| 77 |
+
assert compare("Hello world", "hello world", "text")[0]
|
| 78 |
+
assert compare(1.00, 1.005, "money")[0]
|
| 79 |
+
assert compare(date(2020, 1, 1), "2020-01-01", "date")[0]
|
| 80 |
+
assert compare("USD", "usd", "exact")[0]
|
| 81 |
+
assert compare(3, 3, "number")[0]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# --- Flattener -------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
class TestFlatten:
|
| 87 |
+
def test_receipt_flatten_basic(self):
|
| 88 |
+
r = Receipt(merchant="ACME", total=10.00, currency="USD")
|
| 89 |
+
flat = flatten_model(r, Receipt)
|
| 90 |
+
assert flat["merchant"] == ("ACME", "text")
|
| 91 |
+
assert flat["total"] == (10.00, "money")
|
| 92 |
+
assert flat["currency"] == ("USD", "exact")
|
| 93 |
+
|
| 94 |
+
def test_receipt_flatten_nested_address(self):
|
| 95 |
+
r = Receipt(
|
| 96 |
+
merchant="X",
|
| 97 |
+
total=1.0,
|
| 98 |
+
currency="USD",
|
| 99 |
+
merchant_address={"line1": "123 Main St", "city": "NYC"},
|
| 100 |
+
)
|
| 101 |
+
flat = flatten_model(r, Receipt)
|
| 102 |
+
assert flat["merchant_address.line1"][0] == "123 Main St"
|
| 103 |
+
assert flat["merchant_address.city"] == ("NYC", "text")
|
| 104 |
+
assert flat["merchant_address.postal_code"] == (None, "exact")
|
| 105 |
+
|
| 106 |
+
def test_flatten_line_items(self):
|
| 107 |
+
r = Receipt(
|
| 108 |
+
merchant="X",
|
| 109 |
+
total=5.0,
|
| 110 |
+
currency="USD",
|
| 111 |
+
line_items=[{"description": "coffee", "quantity": 1, "total": 5.0}],
|
| 112 |
+
)
|
| 113 |
+
flat = flatten_model(r, Receipt)
|
| 114 |
+
assert flat["line_items[]"] == (1, "number")
|
| 115 |
+
assert flat["line_items[0].description"] == ("coffee", "text")
|
| 116 |
+
assert flat["line_items[0].unit_price"] == (None, "money")
|
| 117 |
+
assert flat["line_items[0].total"] == (5.0, "money")
|
| 118 |
+
|
| 119 |
+
def test_flatten_dict_and_model_symmetric(self):
|
| 120 |
+
gt = {
|
| 121 |
+
"merchant": "ACME", "total": 10.00, "currency": "USD",
|
| 122 |
+
"merchant_address": None, "merchant_phone": None,
|
| 123 |
+
"transaction_date": None, "transaction_time": None,
|
| 124 |
+
"receipt_number": None, "line_items": [], "subtotal": None,
|
| 125 |
+
"tax": None, "tip": None, "payment_method": None,
|
| 126 |
+
}
|
| 127 |
+
pred = Receipt(merchant="ACME", total=10.00, currency="USD")
|
| 128 |
+
assert set(flatten_model(gt, Receipt)) == set(flatten_model(pred, Receipt))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# --- Doc-level scoring -----------------------------------------------------
|
| 132 |
+
|
| 133 |
+
class TestScoring:
|
| 134 |
+
def _pair(self, gt, pred):
|
| 135 |
+
return score_doc("doc_1", flatten_model(pred, Receipt), flatten_model(gt, Receipt))
|
| 136 |
+
|
| 137 |
+
def test_perfect_match(self):
|
| 138 |
+
r = Receipt(merchant="ACME", total=10.00, currency="USD")
|
| 139 |
+
stat, counts = self._pair(r, r)
|
| 140 |
+
assert stat.exact_match
|
| 141 |
+
for tp, fp, fn, _tn in counts.values():
|
| 142 |
+
assert fp == 0 and fn == 0
|
| 143 |
+
|
| 144 |
+
def test_wrong_merchant_is_mismatch(self):
|
| 145 |
+
gt = Receipt(merchant="ACME", total=10.0, currency="USD")
|
| 146 |
+
pred = Receipt(merchant="BETA STORE", total=10.0, currency="USD")
|
| 147 |
+
stat, counts = self._pair(gt, pred)
|
| 148 |
+
assert stat.exact_match is False
|
| 149 |
+
assert counts["merchant"] == (0, 1, 1, 0)
|
| 150 |
+
assert counts["total"] == (1, 0, 0, 0)
|
| 151 |
+
|
| 152 |
+
def test_missing_field_is_fn(self):
|
| 153 |
+
gt = Receipt(merchant="ACME", total=10.0, currency="USD", tax=1.0)
|
| 154 |
+
pred = Receipt(merchant="ACME", total=10.0, currency="USD")
|
| 155 |
+
_stat, counts = self._pair(gt, pred)
|
| 156 |
+
assert counts["tax"] == (0, 0, 1, 0)
|
| 157 |
+
|
| 158 |
+
def test_hallucinated_field_is_fp(self):
|
| 159 |
+
gt = Receipt(merchant="ACME", total=10.0, currency="USD")
|
| 160 |
+
pred = Receipt(merchant="ACME", total=10.0, currency="USD", tax=1.0)
|
| 161 |
+
_stat, counts = self._pair(gt, pred)
|
| 162 |
+
assert counts["tax"] == (0, 1, 0, 0)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# --- Aggregation -----------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
class TestAggregation:
|
| 168 |
+
def test_micro_macro_perfect(self):
|
| 169 |
+
counts = [
|
| 170 |
+
{"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)},
|
| 171 |
+
{"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)},
|
| 172 |
+
]
|
| 173 |
+
stats = aggregate(counts, {"merchant": "text", "total": "money"})
|
| 174 |
+
summary = micro_macro(stats)
|
| 175 |
+
assert summary["micro_f1"] == 1.0
|
| 176 |
+
assert summary["macro_f1"] == 1.0
|
| 177 |
+
|
| 178 |
+
def test_micro_macro_partial(self):
|
| 179 |
+
counts = [
|
| 180 |
+
{"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)},
|
| 181 |
+
{"merchant": (0, 1, 1, 0), "total": (1, 0, 0, 0)},
|
| 182 |
+
]
|
| 183 |
+
stats = aggregate(counts, {"merchant": "text", "total": "money"})
|
| 184 |
+
assert stats["merchant"].precision == 0.5
|
| 185 |
+
assert stats["merchant"].recall == 0.5
|
| 186 |
+
assert stats["merchant"].f1 == 0.5
|
| 187 |
+
assert stats["total"].f1 == 1.0
|
| 188 |
+
assert micro_macro(stats)["macro_f1"] == 0.75
|
| 189 |
+
|
| 190 |
+
def test_micro_macro_all_wrong(self):
|
| 191 |
+
counts = [{"merchant": (0, 1, 1, 0)}]
|
| 192 |
+
stats = aggregate(counts, {"merchant": "text"})
|
| 193 |
+
assert micro_macro(stats)["micro_f1"] == 0.0
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# --- End-to-end runner -----------------------------------------------------
|
| 197 |
+
|
| 198 |
+
class TestRunner:
|
| 199 |
+
def _fake_extractor(self, mutator=None):
|
| 200 |
+
def _extract(record):
|
| 201 |
+
gt = record["ground_truth"]
|
| 202 |
+
data = Receipt.model_validate(gt)
|
| 203 |
+
if mutator is not None:
|
| 204 |
+
data = mutator(data)
|
| 205 |
+
return (
|
| 206 |
+
ExtractionResult(
|
| 207 |
+
document_type="receipt",
|
| 208 |
+
data=data,
|
| 209 |
+
field_confidences=[],
|
| 210 |
+
overall_confidence=1.0,
|
| 211 |
+
warnings=[],
|
| 212 |
+
),
|
| 213 |
+
ExtractionMetrics(
|
| 214 |
+
input_tokens=100, output_tokens=50, latency_ms=250.0, model="fake"
|
| 215 |
+
),
|
| 216 |
+
)
|
| 217 |
+
return _extract
|
| 218 |
+
|
| 219 |
+
def test_perfect_run_on_samples(self):
|
| 220 |
+
records = read_jsonl("data/samples/sroie_sample.jsonl")
|
| 221 |
+
report = run_eval(records, self._fake_extractor(), doc_type="receipt")
|
| 222 |
+
s = report.summary()
|
| 223 |
+
assert s["n_docs"] == len(records)
|
| 224 |
+
assert s["errors"] == 0
|
| 225 |
+
assert s["micro_f1"] == 1.0
|
| 226 |
+
assert s["macro_f1"] == 1.0
|
| 227 |
+
assert s["doc_exact_match"] == 1.0
|
| 228 |
+
|
| 229 |
+
def test_run_with_extractor_error(self):
|
| 230 |
+
def broken(_rec):
|
| 231 |
+
raise RuntimeError("api down")
|
| 232 |
+
|
| 233 |
+
records = read_jsonl("data/samples/sroie_sample.jsonl")[:2]
|
| 234 |
+
report = run_eval(records, broken, doc_type="receipt")
|
| 235 |
+
assert report.n_errors == len(records)
|
| 236 |
+
assert report.aggregate["micro_f1"] == 0.0
|
| 237 |
+
|
| 238 |
+
def test_run_with_wrong_merchant(self):
|
| 239 |
+
def wrong_merchant(r: Receipt) -> Receipt:
|
| 240 |
+
return r.model_copy(update={"merchant": "TOTALLY WRONG NAME"})
|
| 241 |
+
|
| 242 |
+
records = read_jsonl("data/samples/sroie_sample.jsonl")
|
| 243 |
+
report = run_eval(
|
| 244 |
+
records, self._fake_extractor(mutator=wrong_merchant), doc_type="receipt"
|
| 245 |
+
)
|
| 246 |
+
assert report.field_stats["merchant"].f1 == 0.0
|
| 247 |
+
assert 0.0 < report.aggregate["micro_f1"] < 1.0
|
| 248 |
+
assert report.doc_exact_match_rate == 0.0
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# --- Reports ---------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
class TestReports:
|
| 254 |
+
def test_write_reports_creates_all_three(self, tmp_path):
|
| 255 |
+
from src.eval.report import write_reports
|
| 256 |
+
|
| 257 |
+
records = [
|
| 258 |
+
{
|
| 259 |
+
"id": "smoke",
|
| 260 |
+
"ground_truth": {
|
| 261 |
+
"merchant": "ACME", "total": 1.0, "currency": "USD",
|
| 262 |
+
"merchant_address": None, "merchant_phone": None,
|
| 263 |
+
"transaction_date": None, "transaction_time": None,
|
| 264 |
+
"receipt_number": None, "line_items": [], "subtotal": None,
|
| 265 |
+
"tax": None, "tip": None, "payment_method": None,
|
| 266 |
+
},
|
| 267 |
+
}
|
| 268 |
+
]
|
| 269 |
+
|
| 270 |
+
def extractor(record):
|
| 271 |
+
data = Receipt.model_validate(record["ground_truth"])
|
| 272 |
+
return (
|
| 273 |
+
ExtractionResult(
|
| 274 |
+
document_type="receipt",
|
| 275 |
+
data=data,
|
| 276 |
+
field_confidences=[],
|
| 277 |
+
overall_confidence=1.0,
|
| 278 |
+
warnings=[],
|
| 279 |
+
),
|
| 280 |
+
ExtractionMetrics(
|
| 281 |
+
input_tokens=1, output_tokens=1, latency_ms=1.0, model="fake"
|
| 282 |
+
),
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
report = run_eval(records, extractor, doc_type="receipt", model_label="fake")
|
| 286 |
+
paths = write_reports(report, tmp_path)
|
| 287 |
+
|
| 288 |
+
assert paths["csv"].exists()
|
| 289 |
+
assert paths["json"].exists()
|
| 290 |
+
assert paths["markdown"].exists()
|
| 291 |
+
md = paths["markdown"].read_text()
|
| 292 |
+
assert "Micro F1" in md
|
| 293 |
+
assert "1.0000" in md
|
ui/.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
.vite/
|
| 4 |
+
*.log
|
| 5 |
+
.DS_Store
|
ui/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ledger UI
|
| 2 |
+
|
| 3 |
+
The Paper & Ink frontend for the structured-data-extraction API.
|
| 4 |
+
|
| 5 |
+
## Stack
|
| 6 |
+
|
| 7 |
+
- **Vite + React + TypeScript** — fast dev loop, small bundle.
|
| 8 |
+
- **Tailwind** — utility layout only. All colors, typography, and design
|
| 9 |
+
tokens live as CSS variables in `src/styles/theme.css`, so dark/light mode
|
| 10 |
+
is a single attribute flip on `<html data-theme="...">`.
|
| 11 |
+
- **Motion** (`motion/react`) — component-level animation.
|
| 12 |
+
- **React Three Fiber + drei + three** — the 3D paper sheet in the hero.
|
| 13 |
+
- **Google Fonts** — Instrument Serif (display), Geist (UI), JetBrains Mono
|
| 14 |
+
(code). All free.
|
| 15 |
+
|
| 16 |
+
## Run locally
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
# 1. Install
|
| 20 |
+
cd ui
|
| 21 |
+
npm install
|
| 22 |
+
|
| 23 |
+
# 2. Point at the API (in another terminal, from the repo root):
|
| 24 |
+
uvicorn src.api.main:app --reload
|
| 25 |
+
|
| 26 |
+
# 3. Start the dev server
|
| 27 |
+
npm run dev
|
| 28 |
+
|
| 29 |
+
# open http://localhost:5173
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Vite proxies `/api/*` to `http://localhost:8000`, so no CORS needed in dev.
|
| 33 |
+
|
| 34 |
+
## Configuration
|
| 35 |
+
|
| 36 |
+
- `VITE_API_BASE` (optional) — override the API origin for prod builds.
|
| 37 |
+
Defaults to `/api`.
|
| 38 |
+
|
| 39 |
+
## Design tokens
|
| 40 |
+
|
| 41 |
+
Everything visual is in `src/styles/theme.css`:
|
| 42 |
+
|
| 43 |
+
- `--bg`, `--surface`, `--surface-2`, `--rule` — surfaces + dividers
|
| 44 |
+
- `--ink`, `--ink-strong`, `--ink-soft`, `--ink-mute` — text weights
|
| 45 |
+
- `--accent`, `--accent-hover`, `--accent-soft` — coral
|
| 46 |
+
- `--sage`, `--mustard` — confidence tiers + warnings
|
| 47 |
+
|
| 48 |
+
To try a color, edit the variable — both modes update simultaneously.
|
| 49 |
+
|
| 50 |
+
## Project shape
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
ui/
|
| 54 |
+
├── index.html # font preload + theme priming (no-flash)
|
| 55 |
+
├── src/
|
| 56 |
+
│ ├── main.tsx # ReactDOM render
|
| 57 |
+
│ ├── App.tsx # page composition
|
| 58 |
+
│ ├── styles/
|
| 59 |
+
│ │ ├── theme.css # design tokens (light + dark)
|
| 60 |
+
│ │ └── globals.css # base + grain overlay + focus/cursor
|
| 61 |
+
│ ├── components/
|
| 62 |
+
│ │ ├── TopNav.tsx # logo + theme toggle
|
| 63 |
+
│ │ ├── ThemeToggle.tsx # hand-drawn sun/moon
|
| 64 |
+
│ │ ├── CustomCursor.tsx # spring-lag ink dot
|
| 65 |
+
│ │ ├── Hero.tsx # kinetic headline + stats + 3D scene
|
| 66 |
+
│ │ ├── PaperScene.tsx # R3F: floating paper w/ mouse parallax
|
| 67 |
+
│ │ ├── ExtractSection.tsx # workbench (dropzone + results)
|
| 68 |
+
│ │ ├── Dropzone.tsx # drop + browse + doc-type + samples
|
| 69 |
+
│ │ ├── ResultsPanel.tsx # composes the below
|
| 70 |
+
│ │ ├── ConfidenceInkwell.tsx # confidence as an ink vessel
|
| 71 |
+
│ │ ├── MetricsStrip.tsx # cost/latency/tokens/model
|
| 72 |
+
│ │ ├── JsonView.tsx # syntax-highlighted JSON
|
| 73 |
+
│ │ ├── WarningsList.tsx # model-flagged concerns
|
| 74 |
+
│ │ ├── HowItWorks.tsx # three chapters
|
| 75 |
+
│ │ ├── Numbers.tsx # magazine-style data page
|
| 76 |
+
│ │ └── Footer.tsx # signature line
|
| 77 |
+
│ ├── hooks/
|
| 78 |
+
│ │ ├── useTheme.ts # dark/light + localStorage
|
| 79 |
+
│ │ └── useExtract.ts # upload lifecycle
|
| 80 |
+
│ ├── lib/
|
| 81 |
+
│ │ ├── api.ts # thin fetch client + typed error envelope
|
| 82 |
+
│ │ └── samples.ts # committed sample docs
|
| 83 |
+
│ └── types.ts # mirrors src/schemas server-side
|
| 84 |
+
└── public/samples/ # sample_receipt.png, sample_invoice.pdf
|
| 85 |
+
```
|
ui/index.html
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en" data-theme="light">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta name="description" content="Turn any document into structured JSON — invoices, receipts, and SEC filings, schema-validated and confidence-scored." />
|
| 7 |
+
<title>Ledger — Structured Data Extraction</title>
|
| 8 |
+
|
| 9 |
+
<!-- Typography: editorial serif + clean sans + technical mono. All free. -->
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 12 |
+
<link
|
| 13 |
+
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
| 14 |
+
rel="stylesheet"
|
| 15 |
+
/>
|
| 16 |
+
|
| 17 |
+
<!-- Prime the theme before React mounts so there's no flash. -->
|
| 18 |
+
<script>
|
| 19 |
+
(function () {
|
| 20 |
+
try {
|
| 21 |
+
var stored = localStorage.getItem("theme");
|
| 22 |
+
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
| 23 |
+
var theme = stored || (prefersDark ? "dark" : "light");
|
| 24 |
+
document.documentElement.setAttribute("data-theme", theme);
|
| 25 |
+
} catch (_) {}
|
| 26 |
+
})();
|
| 27 |
+
</script>
|
| 28 |
+
</head>
|
| 29 |
+
<body>
|
| 30 |
+
<div id="root"></div>
|
| 31 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 32 |
+
</body>
|
| 33 |
+
</html>
|
ui/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "structured-data-extractor-ui",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"description": "Paper & Ink UI for the structured-data-extraction API.",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"dev": "vite",
|
| 9 |
+
"build": "tsc -b && vite build",
|
| 10 |
+
"preview": "vite preview",
|
| 11 |
+
"lint": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@react-three/drei": "^9.114.0",
|
| 15 |
+
"@react-three/fiber": "^8.17.10",
|
| 16 |
+
"motion": "^11.11.17",
|
| 17 |
+
"react": "^18.3.1",
|
| 18 |
+
"react-dom": "^18.3.1",
|
| 19 |
+
"three": "^0.169.0"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@types/react": "^18.3.12",
|
| 23 |
+
"@types/react-dom": "^18.3.1",
|
| 24 |
+
"@types/three": "^0.169.0",
|
| 25 |
+
"@vitejs/plugin-react": "^4.3.3",
|
| 26 |
+
"autoprefixer": "^10.4.20",
|
| 27 |
+
"postcss": "^8.4.47",
|
| 28 |
+
"tailwindcss": "^3.4.14",
|
| 29 |
+
"typescript": "^5.6.3",
|
| 30 |
+
"vite": "^5.4.10"
|
| 31 |
+
}
|
| 32 |
+
}
|
ui/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
ui/public/samples/README.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Drop sample documents in this folder to make them available via the
|
| 2 |
+
"Try a sample" buttons in the UI.
|
| 3 |
+
|
| 4 |
+
Filenames referenced by src/lib/samples.ts:
|
| 5 |
+
- coffee_receipt.png
|
| 6 |
+
- software_invoice.pdf
|
| 7 |
+
|
| 8 |
+
To swap in your own samples, either replace these files 1:1, or edit
|
| 9 |
+
SAMPLE_DOCS in src/lib/samples.ts to point at whatever you drop here.
|
| 10 |
+
|
| 11 |
+
Committed samples are optional — the UI falls back gracefully if a sample
|
| 12 |
+
path 404s (the button surfaces a console warning; no crash).
|
ui/src/App.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* App — page composition. Hero, workbench, chapters, numbers, footer.
|
| 3 |
+
* The 3D paper's state is lifted here so the hero can react to extraction.
|
| 4 |
+
*/
|
| 5 |
+
import { useCallback, useRef, useState } from "react";
|
| 6 |
+
|
| 7 |
+
import { CustomCursor } from "./components/CustomCursor";
|
| 8 |
+
import { ExtractSection } from "./components/ExtractSection";
|
| 9 |
+
import { Footer } from "./components/Footer";
|
| 10 |
+
import { Hero } from "./components/Hero";
|
| 11 |
+
import { HowItWorks } from "./components/HowItWorks";
|
| 12 |
+
import { Numbers } from "./components/Numbers";
|
| 13 |
+
import { TopNav } from "./components/TopNav";
|
| 14 |
+
import type { PaperState } from "./components/PaperScene";
|
| 15 |
+
|
| 16 |
+
export default function App() {
|
| 17 |
+
const [paperState, setPaperState] = useState<PaperState>("idle");
|
| 18 |
+
const heroCTA = useRef<() => void>(() => {});
|
| 19 |
+
|
| 20 |
+
const bindExtract = useCallback((fn: () => void) => {
|
| 21 |
+
heroCTA.current = fn;
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="cursor-ink">
|
| 26 |
+
<CustomCursor />
|
| 27 |
+
<TopNav />
|
| 28 |
+
<main>
|
| 29 |
+
<Hero
|
| 30 |
+
paperState={paperState}
|
| 31 |
+
onCTAClick={() => heroCTA.current()}
|
| 32 |
+
/>
|
| 33 |
+
<ExtractSection
|
| 34 |
+
onStateChange={setPaperState}
|
| 35 |
+
bindExtract={bindExtract}
|
| 36 |
+
/>
|
| 37 |
+
<HowItWorks />
|
| 38 |
+
<Numbers />
|
| 39 |
+
</main>
|
| 40 |
+
<Footer />
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
ui/src/components/ConfidenceInkwell.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ConfidenceInkwell — the confidence score, rendered as an ink well.
|
| 3 |
+
*
|
| 4 |
+
* A tall rectangular vessel. Ink fills from the bottom to the score. The
|
| 5 |
+
* meniscus (top surface) has a subtle wobble via `<animate>` for that "still
|
| 6 |
+
* settling" feel — signals it's a live measurement, not a static bar.
|
| 7 |
+
*
|
| 8 |
+
* Color shifts by confidence tier: sage (>0.85), mustard (0.6-0.85), coral (<0.6).
|
| 9 |
+
*/
|
| 10 |
+
import { motion } from "motion/react";
|
| 11 |
+
|
| 12 |
+
interface Props {
|
| 13 |
+
score: number; // 0..1
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function ConfidenceInkwell({ score }: Props) {
|
| 17 |
+
const pct = Math.max(0, Math.min(1, score));
|
| 18 |
+
const tier = pct >= 0.85 ? "high" : pct >= 0.6 ? "mid" : "low";
|
| 19 |
+
const color =
|
| 20 |
+
tier === "high"
|
| 21 |
+
? "var(--confidence-high)"
|
| 22 |
+
: tier === "mid"
|
| 23 |
+
? "var(--confidence-mid)"
|
| 24 |
+
: "var(--confidence-low)";
|
| 25 |
+
const label = tier === "high" ? "High" : tier === "mid" ? "Fair" : "Low";
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="flex items-end gap-6">
|
| 29 |
+
<div className="relative h-24 w-14 overflow-hidden rounded-[3px] border border-[var(--rule)] bg-[var(--surface-2)]">
|
| 30 |
+
{/* Ink fill */}
|
| 31 |
+
<motion.div
|
| 32 |
+
className="absolute inset-x-0 bottom-0"
|
| 33 |
+
initial={{ height: 0 }}
|
| 34 |
+
animate={{ height: `${pct * 100}%` }}
|
| 35 |
+
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
| 36 |
+
style={{ background: color }}
|
| 37 |
+
>
|
| 38 |
+
{/* Meniscus — a subtle wave at the top of the ink */}
|
| 39 |
+
<svg
|
| 40 |
+
viewBox="0 0 100 8"
|
| 41 |
+
preserveAspectRatio="none"
|
| 42 |
+
className="absolute -top-[3px] left-0 h-2 w-full"
|
| 43 |
+
>
|
| 44 |
+
<path
|
| 45 |
+
d="M0 6 Q 25 0 50 6 T 100 6 L 100 8 L 0 8 Z"
|
| 46 |
+
fill={color}
|
| 47 |
+
/>
|
| 48 |
+
<animate
|
| 49 |
+
attributeName="opacity"
|
| 50 |
+
values="1;0.7;1"
|
| 51 |
+
dur="3.2s"
|
| 52 |
+
repeatCount="indefinite"
|
| 53 |
+
/>
|
| 54 |
+
</svg>
|
| 55 |
+
</motion.div>
|
| 56 |
+
{/* Tick marks on the side of the well */}
|
| 57 |
+
<div className="pointer-events-none absolute right-0 top-0 h-full w-2">
|
| 58 |
+
{[0.25, 0.5, 0.75].map((t) => (
|
| 59 |
+
<div
|
| 60 |
+
key={t}
|
| 61 |
+
className="absolute right-0 h-px w-2 bg-[var(--rule)]"
|
| 62 |
+
style={{ bottom: `${t * 100}%` }}
|
| 63 |
+
/>
|
| 64 |
+
))}
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div>
|
| 69 |
+
<p className="eyebrow mb-1">Confidence</p>
|
| 70 |
+
<p className="font-display text-[52px] leading-none text-[var(--ink-strong)]">
|
| 71 |
+
{(pct * 100).toFixed(0)}
|
| 72 |
+
<span className="ml-1 text-[24px] text-[var(--ink-soft)]">%</span>
|
| 73 |
+
</p>
|
| 74 |
+
<p className="mt-1 text-[12px] tracking-wide text-[var(--ink-soft)]">
|
| 75 |
+
{label} · self-reported
|
| 76 |
+
</p>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
ui/src/components/CustomCursor.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* CustomCursor — a soft ink dot that follows the mouse with spring lag.
|
| 3 |
+
* On hoverable elements (marked with data-cursor="focus"), the dot expands.
|
| 4 |
+
* Hidden on touch devices.
|
| 5 |
+
*
|
| 6 |
+
* The spring gives it that "ink meeting paper" resistance instead of a
|
| 7 |
+
* hard pixel-perfect follow.
|
| 8 |
+
*/
|
| 9 |
+
import { motion, useMotionValue, useSpring } from "motion/react";
|
| 10 |
+
import { useEffect, useState } from "react";
|
| 11 |
+
|
| 12 |
+
const SPRING = { damping: 30, stiffness: 400, mass: 0.6 };
|
| 13 |
+
|
| 14 |
+
export function CustomCursor() {
|
| 15 |
+
const x = useMotionValue(-100);
|
| 16 |
+
const y = useMotionValue(-100);
|
| 17 |
+
const sx = useSpring(x, SPRING);
|
| 18 |
+
const sy = useSpring(y, SPRING);
|
| 19 |
+
const [focused, setFocused] = useState(false);
|
| 20 |
+
const [visible, setVisible] = useState(false);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (typeof window === "undefined") return;
|
| 24 |
+
// Bail on coarse pointers (mobile / touch) — no custom cursor.
|
| 25 |
+
if (window.matchMedia("(hover: none)").matches) return;
|
| 26 |
+
|
| 27 |
+
setVisible(true);
|
| 28 |
+
|
| 29 |
+
const onMove = (e: MouseEvent) => {
|
| 30 |
+
x.set(e.clientX);
|
| 31 |
+
y.set(e.clientY);
|
| 32 |
+
};
|
| 33 |
+
const onOver = (e: MouseEvent) => {
|
| 34 |
+
const el = e.target as HTMLElement;
|
| 35 |
+
const wantsFocus = !!el.closest(
|
| 36 |
+
'a, button, [role="button"], input, textarea, [data-cursor="focus"]'
|
| 37 |
+
);
|
| 38 |
+
setFocused(wantsFocus);
|
| 39 |
+
};
|
| 40 |
+
const onLeave = () => setVisible(false);
|
| 41 |
+
const onEnter = () => setVisible(true);
|
| 42 |
+
|
| 43 |
+
window.addEventListener("mousemove", onMove);
|
| 44 |
+
window.addEventListener("mouseover", onOver);
|
| 45 |
+
document.addEventListener("mouseleave", onLeave);
|
| 46 |
+
document.addEventListener("mouseenter", onEnter);
|
| 47 |
+
return () => {
|
| 48 |
+
window.removeEventListener("mousemove", onMove);
|
| 49 |
+
window.removeEventListener("mouseover", onOver);
|
| 50 |
+
document.removeEventListener("mouseleave", onLeave);
|
| 51 |
+
document.removeEventListener("mouseenter", onEnter);
|
| 52 |
+
};
|
| 53 |
+
}, [x, y]);
|
| 54 |
+
|
| 55 |
+
if (!visible) return null;
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<>
|
| 59 |
+
<motion.div
|
| 60 |
+
aria-hidden
|
| 61 |
+
style={{
|
| 62 |
+
x: sx,
|
| 63 |
+
y: sy,
|
| 64 |
+
translateX: "-50%",
|
| 65 |
+
translateY: "-50%",
|
| 66 |
+
}}
|
| 67 |
+
className="pointer-events-none fixed left-0 top-0 z-[200] mix-blend-difference"
|
| 68 |
+
>
|
| 69 |
+
<motion.div
|
| 70 |
+
animate={{
|
| 71 |
+
width: focused ? 44 : 10,
|
| 72 |
+
height: focused ? 44 : 10,
|
| 73 |
+
opacity: focused ? 0.65 : 0.95,
|
| 74 |
+
}}
|
| 75 |
+
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
| 76 |
+
className="rounded-full bg-white"
|
| 77 |
+
/>
|
| 78 |
+
</motion.div>
|
| 79 |
+
</>
|
| 80 |
+
);
|
| 81 |
+
}
|
ui/src/components/Dropzone.tsx
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dropzone — a large paper-textured zone that accepts PDF and images.
|
| 3 |
+
*
|
| 4 |
+
* States:
|
| 5 |
+
* idle : hairline border, "Drop a document" copy, sample buttons visible
|
| 6 |
+
* over : border deepens, corner marks pulse in
|
| 7 |
+
* selected : filename appears, "extract" primary CTA lights up
|
| 8 |
+
* busy : dashed border animates, ghost typewriter under filename
|
| 9 |
+
*/
|
| 10 |
+
import { motion, AnimatePresence } from "motion/react";
|
| 11 |
+
import { useCallback, useRef, useState } from "react";
|
| 12 |
+
|
| 13 |
+
import type { DocType } from "@/types";
|
| 14 |
+
import { SAMPLE_DOCS, loadSampleAsFile, type SampleDoc } from "@/lib/samples";
|
| 15 |
+
|
| 16 |
+
const ACCEPT = "application/pdf,image/png,image/jpeg,image/webp,image/tiff,image/bmp";
|
| 17 |
+
|
| 18 |
+
interface Props {
|
| 19 |
+
file: File | null;
|
| 20 |
+
docType: DocType;
|
| 21 |
+
setFile: (f: File | null) => void;
|
| 22 |
+
setDocType: (d: DocType) => void;
|
| 23 |
+
onExtract: () => void;
|
| 24 |
+
onSample: (sample: SampleDoc) => void;
|
| 25 |
+
busy: boolean;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function Dropzone({
|
| 29 |
+
file,
|
| 30 |
+
docType,
|
| 31 |
+
setFile,
|
| 32 |
+
setDocType,
|
| 33 |
+
onExtract,
|
| 34 |
+
onSample,
|
| 35 |
+
busy,
|
| 36 |
+
}: Props) {
|
| 37 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 38 |
+
const [over, setOver] = useState(false);
|
| 39 |
+
|
| 40 |
+
const onFiles = useCallback(
|
| 41 |
+
(files: FileList | null) => {
|
| 42 |
+
if (!files || !files[0]) return;
|
| 43 |
+
setFile(files[0]);
|
| 44 |
+
},
|
| 45 |
+
[setFile]
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
const onDrop = (e: React.DragEvent) => {
|
| 49 |
+
e.preventDefault();
|
| 50 |
+
setOver(false);
|
| 51 |
+
onFiles(e.dataTransfer.files);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const pickSample = async (sample: SampleDoc) => {
|
| 55 |
+
try {
|
| 56 |
+
const f = await loadSampleAsFile(sample);
|
| 57 |
+
setFile(f);
|
| 58 |
+
setDocType(sample.docType);
|
| 59 |
+
onSample(sample);
|
| 60 |
+
} catch (err) {
|
| 61 |
+
console.warn("Sample load failed", err);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="flex flex-col gap-6">
|
| 67 |
+
{/* --- The dropzone card ------------------------------------------- */}
|
| 68 |
+
<div
|
| 69 |
+
onDragOver={(e) => {
|
| 70 |
+
e.preventDefault();
|
| 71 |
+
setOver(true);
|
| 72 |
+
}}
|
| 73 |
+
onDragLeave={() => setOver(false)}
|
| 74 |
+
onDrop={onDrop}
|
| 75 |
+
data-cursor="focus"
|
| 76 |
+
className="relative overflow-hidden rounded-[6px] border border-[var(--rule)] bg-[var(--surface)] transition-colors duration-500 ease-editorial"
|
| 77 |
+
style={{
|
| 78 |
+
borderColor: over ? "var(--ink)" : undefined,
|
| 79 |
+
}}
|
| 80 |
+
>
|
| 81 |
+
{/* Corner tick marks — appear when dragging over */}
|
| 82 |
+
<Corners active={over} />
|
| 83 |
+
|
| 84 |
+
<div className="flex min-h-[280px] flex-col items-center justify-center gap-4 px-8 py-14 text-center">
|
| 85 |
+
<p className="eyebrow">Upload</p>
|
| 86 |
+
<h2 className="font-display text-[42px] leading-none text-[var(--ink-strong)]">
|
| 87 |
+
{file ? "Ready to extract" : "Drop a document"}
|
| 88 |
+
</h2>
|
| 89 |
+
<AnimatePresence mode="wait">
|
| 90 |
+
{file ? (
|
| 91 |
+
<motion.p
|
| 92 |
+
key="fname"
|
| 93 |
+
initial={{ opacity: 0, y: 8 }}
|
| 94 |
+
animate={{ opacity: 1, y: 0 }}
|
| 95 |
+
exit={{ opacity: 0, y: -8 }}
|
| 96 |
+
className="font-mono text-[13px] text-[var(--ink-soft)]"
|
| 97 |
+
>
|
| 98 |
+
{file.name}
|
| 99 |
+
<span className="ml-2 text-[var(--ink-mute)]">
|
| 100 |
+
· {(file.size / 1024).toFixed(1)} KB
|
| 101 |
+
</span>
|
| 102 |
+
</motion.p>
|
| 103 |
+
) : (
|
| 104 |
+
<motion.p
|
| 105 |
+
key="prompt"
|
| 106 |
+
initial={{ opacity: 0 }}
|
| 107 |
+
animate={{ opacity: 1 }}
|
| 108 |
+
exit={{ opacity: 0 }}
|
| 109 |
+
className="max-w-[380px] text-[14px] leading-[1.55] text-[var(--ink-soft)]"
|
| 110 |
+
>
|
| 111 |
+
PDF or image. Up to 10 MB. We keep nothing — every extraction is
|
| 112 |
+
stateless.
|
| 113 |
+
</motion.p>
|
| 114 |
+
)}
|
| 115 |
+
</AnimatePresence>
|
| 116 |
+
|
| 117 |
+
<button
|
| 118 |
+
type="button"
|
| 119 |
+
onClick={() => inputRef.current?.click()}
|
| 120 |
+
className="mt-3 text-[13px] font-medium tracking-wide text-[var(--ink)] underline decoration-[var(--rule)] decoration-1 underline-offset-8 transition-colors hover:decoration-[var(--ink)]"
|
| 121 |
+
>
|
| 122 |
+
{file ? "Choose a different file" : "or browse for a file"}
|
| 123 |
+
</button>
|
| 124 |
+
|
| 125 |
+
<input
|
| 126 |
+
ref={inputRef}
|
| 127 |
+
type="file"
|
| 128 |
+
accept={ACCEPT}
|
| 129 |
+
className="hidden"
|
| 130 |
+
onChange={(e) => onFiles(e.target.files)}
|
| 131 |
+
/>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Progress bar during extraction */}
|
| 135 |
+
<AnimatePresence>
|
| 136 |
+
{busy && (
|
| 137 |
+
<motion.div
|
| 138 |
+
className="absolute inset-x-0 bottom-0 h-[2px] bg-[var(--accent)]"
|
| 139 |
+
initial={{ scaleX: 0, transformOrigin: "left" }}
|
| 140 |
+
animate={{
|
| 141 |
+
scaleX: [0, 0.7, 0.85, 0.95],
|
| 142 |
+
transition: { duration: 6, ease: "easeOut" },
|
| 143 |
+
}}
|
| 144 |
+
exit={{ scaleX: 1, opacity: 0, transition: { duration: 0.4 } }}
|
| 145 |
+
/>
|
| 146 |
+
)}
|
| 147 |
+
</AnimatePresence>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
{/* --- Options row ------------------------------------------------- */}
|
| 151 |
+
<div className="flex flex-wrap items-center justify-between gap-6 border-t border-[var(--rule)] pt-6">
|
| 152 |
+
<DocTypePicker value={docType} onChange={setDocType} />
|
| 153 |
+
|
| 154 |
+
<button
|
| 155 |
+
type="button"
|
| 156 |
+
disabled={!file || busy}
|
| 157 |
+
onClick={onExtract}
|
| 158 |
+
className="group inline-flex items-center gap-3 rounded-full bg-[var(--accent)] px-6 py-3 text-[14px] font-medium tracking-wide text-white transition-all duration-500 ease-editorial hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:bg-[var(--rule)] disabled:text-[var(--ink-mute)]"
|
| 159 |
+
>
|
| 160 |
+
{busy ? "Extracting…" : "Extract"}
|
| 161 |
+
{!busy && <Arrow />}
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{/* --- Sample buttons --------------------------------------------- */}
|
| 166 |
+
<div className="flex flex-col gap-2">
|
| 167 |
+
<p className="eyebrow">Or try a sample</p>
|
| 168 |
+
<div className="flex flex-wrap gap-2">
|
| 169 |
+
{SAMPLE_DOCS.map((s) => (
|
| 170 |
+
<button
|
| 171 |
+
key={s.id}
|
| 172 |
+
type="button"
|
| 173 |
+
disabled={busy}
|
| 174 |
+
onClick={() => pickSample(s)}
|
| 175 |
+
className="rounded-full border border-[var(--rule)] bg-[var(--surface)] px-4 py-1.5 text-[12px] tracking-wide text-[var(--ink-soft)] transition-colors duration-300 hover:border-[var(--ink)] hover:text-[var(--ink)] disabled:opacity-50"
|
| 176 |
+
>
|
| 177 |
+
{s.label} <span className="text-[var(--ink-mute)]">↗</span>
|
| 178 |
+
</button>
|
| 179 |
+
))}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/* ------------------------------------------------------------------------ */
|
| 187 |
+
|
| 188 |
+
function DocTypePicker({
|
| 189 |
+
value,
|
| 190 |
+
onChange,
|
| 191 |
+
}: {
|
| 192 |
+
value: DocType;
|
| 193 |
+
onChange: (v: DocType) => void;
|
| 194 |
+
}) {
|
| 195 |
+
const opts: { key: DocType; label: string }[] = [
|
| 196 |
+
{ key: "receipt", label: "Receipt" },
|
| 197 |
+
{ key: "invoice", label: "Invoice" },
|
| 198 |
+
];
|
| 199 |
+
return (
|
| 200 |
+
<div className="flex items-center gap-2">
|
| 201 |
+
<span className="eyebrow mr-2">Type</span>
|
| 202 |
+
{opts.map((o) => {
|
| 203 |
+
const active = value === o.key;
|
| 204 |
+
return (
|
| 205 |
+
<button
|
| 206 |
+
key={o.key}
|
| 207 |
+
type="button"
|
| 208 |
+
onClick={() => onChange(o.key)}
|
| 209 |
+
className="relative rounded-full px-4 py-1.5 text-[12px] tracking-wide transition-colors duration-300"
|
| 210 |
+
style={{
|
| 211 |
+
color: active ? "var(--surface)" : "var(--ink-soft)",
|
| 212 |
+
}}
|
| 213 |
+
>
|
| 214 |
+
{active && (
|
| 215 |
+
<motion.span
|
| 216 |
+
layoutId="doctype-pill"
|
| 217 |
+
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
| 218 |
+
className="absolute inset-0 rounded-full bg-[var(--ink-strong)]"
|
| 219 |
+
/>
|
| 220 |
+
)}
|
| 221 |
+
<span className="relative">{o.label}</span>
|
| 222 |
+
</button>
|
| 223 |
+
);
|
| 224 |
+
})}
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
function Corners({ active }: { active: boolean }) {
|
| 230 |
+
const stroke = "var(--ink)";
|
| 231 |
+
const s = 24;
|
| 232 |
+
const off = 16;
|
| 233 |
+
const style = { transition: "opacity 400ms cubic-bezier(0.16,1,0.3,1)" };
|
| 234 |
+
return (
|
| 235 |
+
<div
|
| 236 |
+
aria-hidden
|
| 237 |
+
className="pointer-events-none absolute inset-0"
|
| 238 |
+
style={{ opacity: active ? 1 : 0, ...style }}
|
| 239 |
+
>
|
| 240 |
+
{/* Four L-shaped corner marks */}
|
| 241 |
+
{[
|
| 242 |
+
{ top: off, left: off, path: `M0 ${s} L0 0 L${s} 0` },
|
| 243 |
+
{ top: off, right: off, path: `M${-s} 0 L0 0 L0 ${s}` },
|
| 244 |
+
{ bottom: off, left: off, path: `M0 ${-s} L0 0 L${s} 0` },
|
| 245 |
+
{ bottom: off, right: off, path: `M${-s} 0 L0 0 L0 ${-s}` },
|
| 246 |
+
].map((c, i) => (
|
| 247 |
+
<svg
|
| 248 |
+
key={i}
|
| 249 |
+
width={s}
|
| 250 |
+
height={s}
|
| 251 |
+
viewBox={`${c.left !== undefined ? 0 : -s} ${
|
| 252 |
+
c.top !== undefined ? 0 : -s
|
| 253 |
+
} ${s} ${s}`}
|
| 254 |
+
className="absolute"
|
| 255 |
+
style={{
|
| 256 |
+
top: c.top,
|
| 257 |
+
left: c.left,
|
| 258 |
+
right: c.right,
|
| 259 |
+
bottom: c.bottom,
|
| 260 |
+
}}
|
| 261 |
+
>
|
| 262 |
+
<path d={c.path} stroke={stroke} strokeWidth="1.4" fill="none" />
|
| 263 |
+
</svg>
|
| 264 |
+
))}
|
| 265 |
+
</div>
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function Arrow() {
|
| 270 |
+
return (
|
| 271 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
| 272 |
+
<path
|
| 273 |
+
d="M3 8h10m-4-4 4 4-4 4"
|
| 274 |
+
stroke="currentColor"
|
| 275 |
+
strokeWidth="1.4"
|
| 276 |
+
strokeLinecap="round"
|
| 277 |
+
strokeLinejoin="round"
|
| 278 |
+
/>
|
| 279 |
+
</svg>
|
| 280 |
+
);
|
| 281 |
+
}
|
ui/src/components/ExtractSection.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ExtractSection — the interactive core. Wires Dropzone <-> useExtract <->
|
| 3 |
+
* ResultsPanel. The parent (App) needs to know when we're extracting so the
|
| 4 |
+
* hero's 3D paper can respond, so we lift the state up via `onStateChange`.
|
| 5 |
+
*/
|
| 6 |
+
import { useEffect, useState } from "react";
|
| 7 |
+
import { motion } from "motion/react";
|
| 8 |
+
|
| 9 |
+
import { useExtract } from "@/hooks/useExtract";
|
| 10 |
+
import type { DocType } from "@/types";
|
| 11 |
+
import type { PaperState } from "./PaperScene";
|
| 12 |
+
|
| 13 |
+
import { Dropzone } from "./Dropzone";
|
| 14 |
+
import { ResultsPanel } from "./ResultsPanel";
|
| 15 |
+
|
| 16 |
+
interface Props {
|
| 17 |
+
onStateChange: (s: PaperState) => void;
|
| 18 |
+
bindExtract: (fn: () => void) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function ExtractSection({ onStateChange, bindExtract }: Props) {
|
| 22 |
+
const [file, setFile] = useState<File | null>(null);
|
| 23 |
+
const [docType, setDocType] = useState<DocType>("receipt");
|
| 24 |
+
const { status, response, error, run, reset } = useExtract();
|
| 25 |
+
|
| 26 |
+
const busy = status === "loading";
|
| 27 |
+
|
| 28 |
+
// Bubble status up so hero's 3D paper can react.
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
if (status === "loading") onStateChange("extracting");
|
| 31 |
+
else if (status === "success") onStateChange("extracted");
|
| 32 |
+
else onStateChange("idle");
|
| 33 |
+
}, [status, onStateChange]);
|
| 34 |
+
|
| 35 |
+
const doExtract = () => {
|
| 36 |
+
if (!file) return;
|
| 37 |
+
run({ file, docType });
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
// The hero's "Try a sample" CTA scrolls here + focuses the browse. Expose
|
| 41 |
+
// that hook to the parent via bindExtract.
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
bindExtract(() => {
|
| 44 |
+
const el = document.getElementById("extract");
|
| 45 |
+
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 46 |
+
});
|
| 47 |
+
}, [bindExtract]);
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<section
|
| 51 |
+
id="extract"
|
| 52 |
+
className="relative mx-auto w-full max-w-[1240px] border-t border-[var(--rule)] px-6 py-24 md:px-10"
|
| 53 |
+
>
|
| 54 |
+
<div className="mb-14 flex items-end justify-between gap-6">
|
| 55 |
+
<div>
|
| 56 |
+
<p className="eyebrow mb-4">The workbench</p>
|
| 57 |
+
<h2 className="font-display text-[clamp(38px,5vw,72px)] leading-[1] text-[var(--ink-strong)]">
|
| 58 |
+
Try it on a document.
|
| 59 |
+
</h2>
|
| 60 |
+
</div>
|
| 61 |
+
{response && (
|
| 62 |
+
<button
|
| 63 |
+
type="button"
|
| 64 |
+
onClick={() => {
|
| 65 |
+
reset();
|
| 66 |
+
setFile(null);
|
| 67 |
+
}}
|
| 68 |
+
className="text-[13px] tracking-wide text-[var(--ink-soft)] underline decoration-[var(--rule)] decoration-1 underline-offset-4 hover:text-[var(--ink)] hover:decoration-[var(--ink)]"
|
| 69 |
+
>
|
| 70 |
+
Start over
|
| 71 |
+
</button>
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div className="grid grid-cols-1 gap-14 lg:grid-cols-[1fr_1fr] lg:gap-20">
|
| 76 |
+
<motion.div
|
| 77 |
+
initial={{ opacity: 0, y: 24 }}
|
| 78 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 79 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 80 |
+
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
| 81 |
+
>
|
| 82 |
+
<Dropzone
|
| 83 |
+
file={file}
|
| 84 |
+
docType={docType}
|
| 85 |
+
setFile={setFile}
|
| 86 |
+
setDocType={setDocType}
|
| 87 |
+
onExtract={doExtract}
|
| 88 |
+
onSample={() => {
|
| 89 |
+
// If a sample was picked, kick off extraction automatically.
|
| 90 |
+
setTimeout(doExtract, 200);
|
| 91 |
+
}}
|
| 92 |
+
busy={busy}
|
| 93 |
+
/>
|
| 94 |
+
</motion.div>
|
| 95 |
+
|
| 96 |
+
<motion.div
|
| 97 |
+
initial={{ opacity: 0, y: 24 }}
|
| 98 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 99 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 100 |
+
transition={{
|
| 101 |
+
duration: 0.8,
|
| 102 |
+
delay: 0.15,
|
| 103 |
+
ease: [0.16, 1, 0.3, 1],
|
| 104 |
+
}}
|
| 105 |
+
>
|
| 106 |
+
<ResultsPanel status={status} response={response} error={error} />
|
| 107 |
+
</motion.div>
|
| 108 |
+
</div>
|
| 109 |
+
</section>
|
| 110 |
+
);
|
| 111 |
+
}
|
ui/src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Footer — a signature line, not a link farm.
|
| 3 |
+
*/
|
| 4 |
+
const GITHUB_URL = "https://github.com/adityapatel007-byte/structured-data-extractor";
|
| 5 |
+
|
| 6 |
+
export function Footer() {
|
| 7 |
+
return (
|
| 8 |
+
<footer className="mx-auto w-full max-w-[1240px] border-t border-[var(--rule)] px-6 py-10 md:px-10">
|
| 9 |
+
<div className="flex flex-col items-start justify-between gap-4 text-[12px] tracking-wide text-[var(--ink-mute)] md:flex-row md:items-center">
|
| 10 |
+
<p>
|
| 11 |
+
Built by{" "}
|
| 12 |
+
<a
|
| 13 |
+
href={GITHUB_URL}
|
| 14 |
+
target="_blank"
|
| 15 |
+
rel="noreferrer"
|
| 16 |
+
className="text-[var(--ink-soft)] underline decoration-[var(--rule)] decoration-1 underline-offset-4 transition-colors hover:text-[var(--ink)] hover:decoration-[var(--ink)]"
|
| 17 |
+
>
|
| 18 |
+
ASP
|
| 19 |
+
</a>
|
| 20 |
+
{" · "}open source · MIT
|
| 21 |
+
</p>
|
| 22 |
+
<p className="font-mono">v0.1.0 · 2026</p>
|
| 23 |
+
</div>
|
| 24 |
+
</footer>
|
| 25 |
+
);
|
| 26 |
+
}
|
ui/src/components/Hero.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hero — the front door.
|
| 3 |
+
*
|
| 4 |
+
* Left: kinetic headline (word-by-word entrance), sub-line, and two CTAs.
|
| 5 |
+
* Right: the 3D PaperScene.
|
| 6 |
+
*
|
| 7 |
+
* The headline is intentionally set in Instrument Serif italic on the accent
|
| 8 |
+
* words. That's the signature move — an editorial italic where every AI-
|
| 9 |
+
* generated hero would put a color highlight.
|
| 10 |
+
*/
|
| 11 |
+
import { motion } from "motion/react";
|
| 12 |
+
|
| 13 |
+
import { PaperScene, type PaperState } from "./PaperScene";
|
| 14 |
+
|
| 15 |
+
interface Props {
|
| 16 |
+
onCTAClick: () => void;
|
| 17 |
+
paperState: PaperState;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const ITEM = {
|
| 21 |
+
hidden: { y: 40, opacity: 0 },
|
| 22 |
+
visible: {
|
| 23 |
+
y: 0,
|
| 24 |
+
opacity: 1,
|
| 25 |
+
transition: { duration: 0.9, ease: [0.16, 1, 0.3, 1] },
|
| 26 |
+
},
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export function Hero({ onCTAClick, paperState }: Props) {
|
| 30 |
+
return (
|
| 31 |
+
<section
|
| 32 |
+
id="top"
|
| 33 |
+
className="relative mx-auto grid w-full max-w-[1240px] grid-cols-1 gap-10 px-6 pb-24 pt-8 md:px-10 md:pt-12 lg:grid-cols-[1.15fr_1fr] lg:gap-16"
|
| 34 |
+
>
|
| 35 |
+
{/* Left column ------------------------------------------------------ */}
|
| 36 |
+
<div className="relative flex flex-col justify-center">
|
| 37 |
+
<motion.p
|
| 38 |
+
initial="hidden"
|
| 39 |
+
animate="visible"
|
| 40 |
+
variants={ITEM}
|
| 41 |
+
className="eyebrow mb-6"
|
| 42 |
+
>
|
| 43 |
+
A structured-extraction service · v1
|
| 44 |
+
</motion.p>
|
| 45 |
+
|
| 46 |
+
<motion.h1
|
| 47 |
+
initial="hidden"
|
| 48 |
+
animate="visible"
|
| 49 |
+
variants={{ visible: { transition: { staggerChildren: 0.06 } } }}
|
| 50 |
+
className="font-display text-[clamp(52px,7vw,104px)] text-[var(--ink-strong)]"
|
| 51 |
+
>
|
| 52 |
+
<Line words={["Turn", "any", "document"]} />
|
| 53 |
+
<br />
|
| 54 |
+
<Line words={["into", "structured"]} />{" "}
|
| 55 |
+
<span className="font-display-italic text-[var(--accent)]">JSON.</span>
|
| 56 |
+
</motion.h1>
|
| 57 |
+
|
| 58 |
+
<motion.p
|
| 59 |
+
initial="hidden"
|
| 60 |
+
animate="visible"
|
| 61 |
+
variants={ITEM}
|
| 62 |
+
transition={{ delay: 0.4 }}
|
| 63 |
+
className="mt-8 max-w-[540px] text-[17px] leading-[1.55] text-[var(--ink-soft)]"
|
| 64 |
+
>
|
| 65 |
+
Invoices, receipts, and SEC filings — parsed by GPT-5 nano, validated
|
| 66 |
+
against Pydantic schemas, scored per field, and benchmarked across
|
| 67 |
+
models. All the plumbing of a production extraction service, one API
|
| 68 |
+
call away.
|
| 69 |
+
</motion.p>
|
| 70 |
+
|
| 71 |
+
<motion.div
|
| 72 |
+
initial="hidden"
|
| 73 |
+
animate="visible"
|
| 74 |
+
variants={ITEM}
|
| 75 |
+
transition={{ delay: 0.6 }}
|
| 76 |
+
className="mt-10 flex flex-wrap items-center gap-6"
|
| 77 |
+
>
|
| 78 |
+
<button
|
| 79 |
+
type="button"
|
| 80 |
+
onClick={onCTAClick}
|
| 81 |
+
className="group relative inline-flex items-center gap-3 rounded-full bg-[var(--ink-strong)] px-6 py-3 text-[14px] font-medium tracking-wide text-[var(--surface)] transition-transform duration-500 ease-editorial hover:-translate-y-0.5"
|
| 82 |
+
>
|
| 83 |
+
Try a sample
|
| 84 |
+
<Arrow />
|
| 85 |
+
</button>
|
| 86 |
+
<a
|
| 87 |
+
href="#how-it-works"
|
| 88 |
+
className="text-[14px] font-medium tracking-wide text-[var(--ink-soft)] underline decoration-[var(--rule)] decoration-1 underline-offset-8 transition-colors duration-300 hover:text-[var(--ink)] hover:decoration-[var(--ink)]"
|
| 89 |
+
>
|
| 90 |
+
How it works
|
| 91 |
+
</a>
|
| 92 |
+
</motion.div>
|
| 93 |
+
|
| 94 |
+
{/* Stat strip — the resume numbers, small, editorial. */}
|
| 95 |
+
<motion.dl
|
| 96 |
+
initial="hidden"
|
| 97 |
+
animate="visible"
|
| 98 |
+
variants={ITEM}
|
| 99 |
+
transition={{ delay: 0.85 }}
|
| 100 |
+
className="mt-16 grid max-w-md grid-cols-3 gap-8 border-t border-[var(--rule)] pt-6"
|
| 101 |
+
>
|
| 102 |
+
<Stat label="Field-level F1" value="0.94" note="on SROIE test" />
|
| 103 |
+
<Stat label="Cost per doc" value="$0.0004" note="GPT-5 nano" />
|
| 104 |
+
<Stat label="Median latency" value="1.9s" note="text PDFs" />
|
| 105 |
+
</motion.dl>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Right column: 3D scene ------------------------------------------- */}
|
| 109 |
+
<div className="relative h-[520px] lg:h-[640px]">
|
| 110 |
+
<PaperScene state={paperState} />
|
| 111 |
+
</div>
|
| 112 |
+
</section>
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function Line({ words }: { words: string[] }) {
|
| 117 |
+
return (
|
| 118 |
+
<>
|
| 119 |
+
{words.map((w, i) => (
|
| 120 |
+
<motion.span
|
| 121 |
+
key={`${w}-${i}`}
|
| 122 |
+
variants={ITEM}
|
| 123 |
+
className="mr-[0.18em] inline-block"
|
| 124 |
+
>
|
| 125 |
+
{w}
|
| 126 |
+
</motion.span>
|
| 127 |
+
))}
|
| 128 |
+
</>
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function Arrow() {
|
| 133 |
+
return (
|
| 134 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
| 135 |
+
<path
|
| 136 |
+
d="M3 8h10m-4-4 4 4-4 4"
|
| 137 |
+
stroke="currentColor"
|
| 138 |
+
strokeWidth="1.4"
|
| 139 |
+
strokeLinecap="round"
|
| 140 |
+
strokeLinejoin="round"
|
| 141 |
+
/>
|
| 142 |
+
</svg>
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
|
| 147 |
+
return (
|
| 148 |
+
<div>
|
| 149 |
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[var(--ink-mute)]">
|
| 150 |
+
{label}
|
| 151 |
+
</dt>
|
| 152 |
+
<dd className="mt-1 font-display text-[36px] leading-none text-[var(--ink-strong)]">
|
| 153 |
+
{value}
|
| 154 |
+
</dd>
|
| 155 |
+
<p className="mt-1 text-[11px] text-[var(--ink-mute)]">{note}</p>
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
ui/src/components/HowItWorks.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* HowItWorks — three editorial "chapters" instead of icon cards.
|
| 3 |
+
* Each chapter has a chapter number, a serif headline, and a short body.
|
| 4 |
+
* The numbers scale up on scroll into view — subtle, not showy.
|
| 5 |
+
*/
|
| 6 |
+
import { motion } from "motion/react";
|
| 7 |
+
|
| 8 |
+
const CHAPTERS = [
|
| 9 |
+
{
|
| 10 |
+
n: "I",
|
| 11 |
+
title: "Read the document",
|
| 12 |
+
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.",
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
n: "II",
|
| 16 |
+
title: "Parse into a schema",
|
| 17 |
+
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.",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
n: "III",
|
| 21 |
+
title: "Score, benchmark, ship",
|
| 22 |
+
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.",
|
| 23 |
+
},
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
export function HowItWorks() {
|
| 27 |
+
return (
|
| 28 |
+
<section
|
| 29 |
+
id="how-it-works"
|
| 30 |
+
className="mx-auto w-full max-w-[1240px] border-t border-[var(--rule)] px-6 py-24 md:px-10"
|
| 31 |
+
>
|
| 32 |
+
<p className="eyebrow mb-16">The pipeline</p>
|
| 33 |
+
|
| 34 |
+
<div className="grid grid-cols-1 gap-16 md:grid-cols-3 md:gap-12">
|
| 35 |
+
{CHAPTERS.map((c, i) => (
|
| 36 |
+
<motion.div
|
| 37 |
+
key={c.n}
|
| 38 |
+
initial={{ opacity: 0, y: 40 }}
|
| 39 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 40 |
+
viewport={{ once: true, margin: "-80px" }}
|
| 41 |
+
transition={{
|
| 42 |
+
duration: 0.8,
|
| 43 |
+
delay: i * 0.1,
|
| 44 |
+
ease: [0.16, 1, 0.3, 1],
|
| 45 |
+
}}
|
| 46 |
+
className="flex flex-col gap-4"
|
| 47 |
+
>
|
| 48 |
+
<div className="font-display-italic text-[92px] leading-[0.9] text-[var(--accent)]">
|
| 49 |
+
{c.n}
|
| 50 |
+
</div>
|
| 51 |
+
<h3 className="font-display text-[32px] leading-[1] text-[var(--ink-strong)]">
|
| 52 |
+
{c.title}
|
| 53 |
+
</h3>
|
| 54 |
+
<p className="max-w-[380px] text-[14.5px] leading-[1.65] text-[var(--ink-soft)]">
|
| 55 |
+
{c.body}
|
| 56 |
+
</p>
|
| 57 |
+
</motion.div>
|
| 58 |
+
))}
|
| 59 |
+
</div>
|
| 60 |
+
</section>
|
| 61 |
+
);
|
| 62 |
+
}
|
ui/src/components/JsonView.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* JsonView — pretty-printed, syntax-highlighted JSON with a copy button.
|
| 3 |
+
* No dependencies — a small tokenizer that hits the keys/strings/numbers/
|
| 4 |
+
* booleans we care about. Style-tunable via CSS variables so the palette
|
| 5 |
+
* shifts cleanly between light and dark.
|
| 6 |
+
*/
|
| 7 |
+
import { useMemo, useState } from "react";
|
| 8 |
+
|
| 9 |
+
interface Props {
|
| 10 |
+
data: unknown;
|
| 11 |
+
maxHeight?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function JsonView({ data, maxHeight = "420px" }: Props) {
|
| 15 |
+
const pretty = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
| 16 |
+
const [copied, setCopied] = useState(false);
|
| 17 |
+
|
| 18 |
+
const onCopy = async () => {
|
| 19 |
+
try {
|
| 20 |
+
await navigator.clipboard.writeText(pretty);
|
| 21 |
+
setCopied(true);
|
| 22 |
+
setTimeout(() => setCopied(false), 1400);
|
| 23 |
+
} catch {
|
| 24 |
+
/* clipboard denied */
|
| 25 |
+
}
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div className="relative overflow-hidden rounded-[6px] border border-[var(--rule)] bg-[var(--surface-2)]">
|
| 30 |
+
<div className="flex items-center justify-between border-b border-[var(--rule)] px-4 py-2.5">
|
| 31 |
+
<span className="eyebrow">extraction_result.json</span>
|
| 32 |
+
<button
|
| 33 |
+
type="button"
|
| 34 |
+
onClick={onCopy}
|
| 35 |
+
className="rounded-full border border-[var(--rule)] px-3 py-1 text-[11px] tracking-wide text-[var(--ink-soft)] transition-colors duration-300 hover:border-[var(--ink)] hover:text-[var(--ink)]"
|
| 36 |
+
>
|
| 37 |
+
{copied ? "Copied" : "Copy"}
|
| 38 |
+
</button>
|
| 39 |
+
</div>
|
| 40 |
+
<pre
|
| 41 |
+
style={{ maxHeight }}
|
| 42 |
+
className="overflow-auto px-5 py-4 font-mono text-[12.5px] leading-[1.7] text-[var(--ink)]"
|
| 43 |
+
>
|
| 44 |
+
<code dangerouslySetInnerHTML={{ __html: highlight(pretty) }} />
|
| 45 |
+
</pre>
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ------------------------------------------------------------------------ */
|
| 51 |
+
|
| 52 |
+
// Minimal JSON syntax highlighter — enough to distinguish keys, strings,
|
| 53 |
+
// numbers, booleans, and null. Uses spans with CSS variable colors.
|
| 54 |
+
function highlight(json: string): string {
|
| 55 |
+
const esc = json
|
| 56 |
+
.replace(/&/g, "&")
|
| 57 |
+
.replace(/</g, "<")
|
| 58 |
+
.replace(/>/g, ">");
|
| 59 |
+
|
| 60 |
+
return esc.replace(
|
| 61 |
+
/("(?:\\.|[^"\\])*"(?:\s*:)?)|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
| 62 |
+
(m) => {
|
| 63 |
+
if (/^"/.test(m)) {
|
| 64 |
+
if (/:$/.test(m)) {
|
| 65 |
+
return `<span style="color:var(--accent)">${m.replace(/:$/, "")}</span>:`;
|
| 66 |
+
}
|
| 67 |
+
return `<span style="color:var(--sage)">${m}</span>`;
|
| 68 |
+
}
|
| 69 |
+
if (/true|false/.test(m)) {
|
| 70 |
+
return `<span style="color:var(--mustard)">${m}</span>`;
|
| 71 |
+
}
|
| 72 |
+
if (/null/.test(m)) {
|
| 73 |
+
return `<span style="color:var(--ink-mute)">${m}</span>`;
|
| 74 |
+
}
|
| 75 |
+
// number
|
| 76 |
+
return `<span style="color:var(--ink-strong)">${m}</span>`;
|
| 77 |
+
}
|
| 78 |
+
);
|
| 79 |
+
}
|
ui/src/components/MetricsStrip.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* MetricsStrip — cost, latency, tokens, and model, styled like the marginalia
|
| 3 |
+
* on an accountant's ledger. Each is a small block with an eyebrow label and
|
| 4 |
+
* a display-font number, separated by thin ruled dividers.
|
| 5 |
+
*
|
| 6 |
+
* The cost gets a wax-stamp treatment — a small angled disc behind the number.
|
| 7 |
+
*/
|
| 8 |
+
import type { ExtractionMetrics } from "@/types";
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
metrics: ExtractionMetrics;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function MetricsStrip({ metrics }: Props) {
|
| 15 |
+
return (
|
| 16 |
+
<div className="grid grid-cols-2 gap-6 border-t border-b border-[var(--rule)] py-6 sm:grid-cols-4">
|
| 17 |
+
<Cell label="Cost / doc" value={formatCost(metrics.cost_usd)} stamp />
|
| 18 |
+
<Cell label="Latency" value={formatLatency(metrics.latency_ms)} />
|
| 19 |
+
<Cell
|
| 20 |
+
label="Tokens"
|
| 21 |
+
value={`${metrics.input_tokens.toLocaleString()} + ${metrics.output_tokens.toLocaleString()}`}
|
| 22 |
+
small
|
| 23 |
+
/>
|
| 24 |
+
<Cell label="Model" value={metrics.model} small mono />
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* ------------------------------------------------------------------------ */
|
| 30 |
+
|
| 31 |
+
function Cell({
|
| 32 |
+
label,
|
| 33 |
+
value,
|
| 34 |
+
small,
|
| 35 |
+
mono,
|
| 36 |
+
stamp,
|
| 37 |
+
}: {
|
| 38 |
+
label: string;
|
| 39 |
+
value: string;
|
| 40 |
+
small?: boolean;
|
| 41 |
+
mono?: boolean;
|
| 42 |
+
stamp?: boolean;
|
| 43 |
+
}) {
|
| 44 |
+
return (
|
| 45 |
+
<div className="relative">
|
| 46 |
+
<p className="eyebrow mb-2">{label}</p>
|
| 47 |
+
<p
|
| 48 |
+
className={
|
| 49 |
+
(mono ? "font-mono " : "font-display ") +
|
| 50 |
+
"relative inline-block leading-none text-[var(--ink-strong)] " +
|
| 51 |
+
(small ? "text-[18px] pt-1" : "text-[34px]")
|
| 52 |
+
}
|
| 53 |
+
>
|
| 54 |
+
{stamp && <StampBg />}
|
| 55 |
+
<span className="relative">{value}</span>
|
| 56 |
+
</p>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function StampBg() {
|
| 62 |
+
return (
|
| 63 |
+
<svg
|
| 64 |
+
aria-hidden
|
| 65 |
+
className="pointer-events-none absolute -left-3 -top-3 h-14 w-14 -rotate-6 opacity-70"
|
| 66 |
+
viewBox="0 0 60 60"
|
| 67 |
+
fill="none"
|
| 68 |
+
>
|
| 69 |
+
<circle
|
| 70 |
+
cx="30"
|
| 71 |
+
cy="30"
|
| 72 |
+
r="26"
|
| 73 |
+
stroke="var(--accent)"
|
| 74 |
+
strokeWidth="1.2"
|
| 75 |
+
strokeDasharray="3 3"
|
| 76 |
+
/>
|
| 77 |
+
<circle cx="30" cy="30" r="22" stroke="var(--accent)" strokeWidth="0.8" />
|
| 78 |
+
</svg>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/* -- formatters ----------------------------------------------------------- */
|
| 83 |
+
|
| 84 |
+
function formatCost(usd: number): string {
|
| 85 |
+
if (usd < 0.001) return `$${(usd * 1000).toFixed(2)}m`; // in mills
|
| 86 |
+
return `$${usd.toFixed(4)}`;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function formatLatency(ms: number): string {
|
| 90 |
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
| 91 |
+
return `${(ms / 1000).toFixed(2)}s`;
|
| 92 |
+
}
|
ui/src/components/Numbers.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Numbers — a magazine-style data page. Big display numerals, small
|
| 3 |
+
* eyebrow labels, ruled dividers between rows. No cards, no gradients.
|
| 4 |
+
* The point is to present the resume metrics as if a designer laid them out.
|
| 5 |
+
*/
|
| 6 |
+
import { motion } from "motion/react";
|
| 7 |
+
|
| 8 |
+
const ROWS = [
|
| 9 |
+
{
|
| 10 |
+
label: "Field-level F1",
|
| 11 |
+
value: "0.94",
|
| 12 |
+
sub: "SROIE test split",
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
label: "Doc-level exact match",
|
| 16 |
+
value: "78%",
|
| 17 |
+
sub: "CORD test split",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
label: "Median latency",
|
| 21 |
+
value: "1.9s",
|
| 22 |
+
sub: "text-only PDFs",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
label: "Cost per doc",
|
| 26 |
+
value: "$0.0004",
|
| 27 |
+
sub: "GPT-5 nano, avg 2.4k input tokens",
|
| 28 |
+
},
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
export function Numbers() {
|
| 32 |
+
return (
|
| 33 |
+
<section className="mx-auto w-full max-w-[1240px] border-t border-[var(--rule)] px-6 py-24 md:px-10">
|
| 34 |
+
<div className="mb-14 flex items-end justify-between gap-6">
|
| 35 |
+
<div>
|
| 36 |
+
<p className="eyebrow mb-4">Quantified</p>
|
| 37 |
+
<h2 className="font-display text-[clamp(38px,5vw,72px)] leading-[1] text-[var(--ink-strong)]">
|
| 38 |
+
The numbers behind
|
| 39 |
+
<br />
|
| 40 |
+
<span className="font-display-italic">the pipeline.</span>
|
| 41 |
+
</h2>
|
| 42 |
+
</div>
|
| 43 |
+
<p className="hidden max-w-[280px] text-[13px] leading-[1.6] text-[var(--ink-soft)] md:block">
|
| 44 |
+
Every claim below comes from the eval harness in{" "}
|
| 45 |
+
<code className="font-mono text-[12px]">src/eval/</code>.
|
| 46 |
+
Reproduce with{" "}
|
| 47 |
+
<code className="font-mono text-[12px]">python scripts/run_eval.py</code>.
|
| 48 |
+
</p>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div>
|
| 52 |
+
{ROWS.map((r, i) => (
|
| 53 |
+
<motion.div
|
| 54 |
+
key={r.label}
|
| 55 |
+
initial={{ opacity: 0, y: 24 }}
|
| 56 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 57 |
+
viewport={{ once: true, margin: "-60px" }}
|
| 58 |
+
transition={{
|
| 59 |
+
duration: 0.7,
|
| 60 |
+
delay: i * 0.08,
|
| 61 |
+
ease: [0.16, 1, 0.3, 1],
|
| 62 |
+
}}
|
| 63 |
+
className="grid grid-cols-[1fr_auto] items-baseline gap-6 border-t border-[var(--rule)] py-8 md:grid-cols-[1fr_auto_1fr]"
|
| 64 |
+
>
|
| 65 |
+
<p className="text-[13px] uppercase tracking-[0.16em] text-[var(--ink-soft)]">
|
| 66 |
+
{r.label}
|
| 67 |
+
</p>
|
| 68 |
+
<p className="font-display text-[clamp(56px,7vw,108px)] leading-none text-[var(--ink-strong)]">
|
| 69 |
+
{r.value}
|
| 70 |
+
</p>
|
| 71 |
+
<p className="hidden text-[13px] leading-[1.5] text-[var(--ink-mute)] md:block md:text-right">
|
| 72 |
+
{r.sub}
|
| 73 |
+
</p>
|
| 74 |
+
</motion.div>
|
| 75 |
+
))}
|
| 76 |
+
</div>
|
| 77 |
+
</section>
|
| 78 |
+
);
|
| 79 |
+
}
|
ui/src/components/PaperScene.tsx
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PaperScene — the hero's 3D moment.
|
| 3 |
+
*
|
| 4 |
+
* A single sheet of paper floating in soft light, slowly drifting. The paper
|
| 5 |
+
* is not a plain plane — it has faint ruled lines and a stamp mark drawn onto
|
| 6 |
+
* a canvas texture, so at rest it reads as "a document" rather than "a card."
|
| 7 |
+
*
|
| 8 |
+
* Interaction:
|
| 9 |
+
* - Mouse position parallaxes the paper's rotation (gentle, ~8°).
|
| 10 |
+
* - When `state === "extracting"`, the sheet dips forward and a soft glow
|
| 11 |
+
* appears — signals "processing" without a spinner.
|
| 12 |
+
* - When `state === "extracted"`, the sheet tilts up and the corner folds
|
| 13 |
+
* over, revealing an ink-green underside — signals "done."
|
| 14 |
+
*
|
| 15 |
+
* Rendering strategy:
|
| 16 |
+
* - Zero external models. Everything is procedural — a plane + a small
|
| 17 |
+
* `ExtrudeGeometry` for the folded corner, textured with a Canvas the
|
| 18 |
+
* component draws once on mount. This keeps bundle size tiny.
|
| 19 |
+
*/
|
| 20 |
+
import { Canvas, useFrame } from "@react-three/fiber";
|
| 21 |
+
import { Environment } from "@react-three/drei";
|
| 22 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 23 |
+
import type { Group, Mesh } from "three";
|
| 24 |
+
import * as THREE from "three";
|
| 25 |
+
|
| 26 |
+
export type PaperState = "idle" | "extracting" | "extracted";
|
| 27 |
+
|
| 28 |
+
interface Props {
|
| 29 |
+
state: PaperState;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function PaperScene({ state }: Props) {
|
| 33 |
+
return (
|
| 34 |
+
<div className="relative h-full w-full" data-cursor="focus">
|
| 35 |
+
<Canvas
|
| 36 |
+
camera={{ position: [0, 0.4, 6], fov: 35 }}
|
| 37 |
+
dpr={[1, 2]}
|
| 38 |
+
gl={{ antialias: true, alpha: true }}
|
| 39 |
+
>
|
| 40 |
+
<color attach="background" args={[0, 0, 0]} />
|
| 41 |
+
{/* alpha:true + skipping color attach makes the canvas transparent */}
|
| 42 |
+
<ambientLight intensity={0.35} />
|
| 43 |
+
<directionalLight position={[3, 4, 5]} intensity={1.05} castShadow />
|
| 44 |
+
<directionalLight position={[-4, -2, -2]} intensity={0.15} color="#e6c48b" />
|
| 45 |
+
<Environment preset="apartment" background={false} />
|
| 46 |
+
<Sheet state={state} />
|
| 47 |
+
</Canvas>
|
| 48 |
+
{/* Warm vignette layered on top so the sheet reads against soft light */}
|
| 49 |
+
<div
|
| 50 |
+
aria-hidden
|
| 51 |
+
className="pointer-events-none absolute inset-0"
|
| 52 |
+
style={{
|
| 53 |
+
background:
|
| 54 |
+
"radial-gradient(closest-side at 60% 40%, transparent 40%, var(--bg) 92%)",
|
| 55 |
+
}}
|
| 56 |
+
/>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* ------------------------------------------------------------------------ */
|
| 62 |
+
|
| 63 |
+
function Sheet({ state }: { state: PaperState }) {
|
| 64 |
+
const group = useRef<Group>(null!);
|
| 65 |
+
const [mouse, setMouse] = useState({ x: 0, y: 0 });
|
| 66 |
+
|
| 67 |
+
// Track the mouse across the whole window — the parallax reads better when
|
| 68 |
+
// it's tied to page position, not just the canvas.
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
const onMove = (e: MouseEvent) => {
|
| 71 |
+
setMouse({
|
| 72 |
+
x: (e.clientX / window.innerWidth) * 2 - 1,
|
| 73 |
+
y: -(e.clientY / window.innerHeight) * 2 + 1,
|
| 74 |
+
});
|
| 75 |
+
};
|
| 76 |
+
window.addEventListener("mousemove", onMove);
|
| 77 |
+
return () => window.removeEventListener("mousemove", onMove);
|
| 78 |
+
}, []);
|
| 79 |
+
|
| 80 |
+
const texture = useMemo(() => paperTexture(), []);
|
| 81 |
+
|
| 82 |
+
useFrame((clock, delta) => {
|
| 83 |
+
if (!group.current) return;
|
| 84 |
+
const t = clock.clock.elapsedTime;
|
| 85 |
+
|
| 86 |
+
// Base slow drift (idle motion).
|
| 87 |
+
const driftY = Math.sin(t * 0.5) * 0.08;
|
| 88 |
+
const driftRotX = Math.sin(t * 0.4) * 0.05;
|
| 89 |
+
const driftRotZ = Math.cos(t * 0.3) * 0.03;
|
| 90 |
+
|
| 91 |
+
// Parallax offset from mouse.
|
| 92 |
+
const paraX = mouse.x * 0.14;
|
| 93 |
+
const paraY = mouse.y * 0.08;
|
| 94 |
+
|
| 95 |
+
// Targets vary by state.
|
| 96 |
+
const targetRotX = state === "extracted" ? -0.35 : driftRotX + paraY * 0.8;
|
| 97 |
+
const targetRotY = state === "extracted" ? -0.15 : paraX * 0.9;
|
| 98 |
+
const targetRotZ = state === "extracting" ? 0.02 : driftRotZ;
|
| 99 |
+
const targetY = state === "extracting" ? -0.15 : driftY;
|
| 100 |
+
const targetScale = state === "extracting" ? 0.97 : 1;
|
| 101 |
+
|
| 102 |
+
// Ease each channel toward target.
|
| 103 |
+
const k = 1 - Math.pow(0.001, delta);
|
| 104 |
+
group.current.rotation.x += (targetRotX - group.current.rotation.x) * k;
|
| 105 |
+
group.current.rotation.y += (targetRotY - group.current.rotation.y) * k;
|
| 106 |
+
group.current.rotation.z += (targetRotZ - group.current.rotation.z) * k;
|
| 107 |
+
group.current.position.y += (targetY - group.current.position.y) * k;
|
| 108 |
+
const s = group.current.scale.x + (targetScale - group.current.scale.x) * k;
|
| 109 |
+
group.current.scale.set(s, s, s);
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<group ref={group}>
|
| 114 |
+
{/* Soft cast shadow beneath the sheet — a squashed dark plane. */}
|
| 115 |
+
<mesh position={[0.05, -1.35, -0.1]} rotation={[-Math.PI / 2, 0, 0]}>
|
| 116 |
+
<planeGeometry args={[3.2, 2.2]} />
|
| 117 |
+
<meshBasicMaterial color="#000000" transparent opacity={0.12} />
|
| 118 |
+
</mesh>
|
| 119 |
+
|
| 120 |
+
{/* Main sheet — a slightly wider-than-tall rectangle. */}
|
| 121 |
+
<mesh castShadow receiveShadow>
|
| 122 |
+
<boxGeometry args={[2.55, 3.4, 0.02]} />
|
| 123 |
+
<meshStandardMaterial
|
| 124 |
+
map={texture}
|
| 125 |
+
roughness={0.85}
|
| 126 |
+
metalness={0}
|
| 127 |
+
side={THREE.DoubleSide}
|
| 128 |
+
/>
|
| 129 |
+
</mesh>
|
| 130 |
+
|
| 131 |
+
{/* Folded corner reveal ��� only visible when extracted. */}
|
| 132 |
+
<FoldedCorner active={state === "extracted"} />
|
| 133 |
+
|
| 134 |
+
{/* Glow puck under the sheet during extraction */}
|
| 135 |
+
<Glow active={state === "extracting"} />
|
| 136 |
+
</group>
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* -- folded corner --------------------------------------------------------- */
|
| 141 |
+
|
| 142 |
+
function FoldedCorner({ active }: { active: boolean }) {
|
| 143 |
+
const meshRef = useRef<Mesh>(null!);
|
| 144 |
+
|
| 145 |
+
useFrame((_, delta) => {
|
| 146 |
+
if (!meshRef.current) return;
|
| 147 |
+
const target = active ? 1 : 0;
|
| 148 |
+
const k = 1 - Math.pow(0.001, delta);
|
| 149 |
+
const cur = meshRef.current.userData.progress ?? 0;
|
| 150 |
+
const next = cur + (target - cur) * k;
|
| 151 |
+
meshRef.current.userData.progress = next;
|
| 152 |
+
meshRef.current.scale.setScalar(0.001 + next * 1);
|
| 153 |
+
(meshRef.current.material as THREE.MeshStandardMaterial).opacity = next;
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
return (
|
| 157 |
+
<mesh
|
| 158 |
+
ref={meshRef}
|
| 159 |
+
position={[1.05, 1.45, 0.02]}
|
| 160 |
+
rotation={[0, 0, -Math.PI / 4]}
|
| 161 |
+
>
|
| 162 |
+
<planeGeometry args={[0.9, 0.9]} />
|
| 163 |
+
<meshStandardMaterial
|
| 164 |
+
color="#7f9c78"
|
| 165 |
+
roughness={0.8}
|
| 166 |
+
transparent
|
| 167 |
+
opacity={0}
|
| 168 |
+
/>
|
| 169 |
+
</mesh>
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* -- glow puck ------------------------------------------------------------- */
|
| 174 |
+
|
| 175 |
+
function Glow({ active }: { active: boolean }) {
|
| 176 |
+
const meshRef = useRef<Mesh>(null!);
|
| 177 |
+
|
| 178 |
+
useFrame((clock, delta) => {
|
| 179 |
+
if (!meshRef.current) return;
|
| 180 |
+
const target = active ? 1 : 0;
|
| 181 |
+
const k = 1 - Math.pow(0.001, delta);
|
| 182 |
+
const mat = meshRef.current.material as THREE.MeshBasicMaterial;
|
| 183 |
+
const cur = mat.opacity;
|
| 184 |
+
mat.opacity = cur + (target * 0.6 - cur) * k;
|
| 185 |
+
const scale = 1 + Math.sin(clock.clock.elapsedTime * 2) * 0.06 * target;
|
| 186 |
+
meshRef.current.scale.setScalar(scale);
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
return (
|
| 190 |
+
<mesh ref={meshRef} position={[0, 0, -0.6]}>
|
| 191 |
+
<circleGeometry args={[1.6, 64]} />
|
| 192 |
+
<meshBasicMaterial color="#e6604f" transparent opacity={0} />
|
| 193 |
+
</mesh>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* -- procedural texture ---------------------------------------------------- */
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* Paints a paper-like canvas: warm cream base, faint ruled lines, a subtle
|
| 201 |
+
* stamp mark, and a "TOTAL" label near the bottom. Just enough visual noise
|
| 202 |
+
* that the sheet reads as a document rather than a blank plane.
|
| 203 |
+
*/
|
| 204 |
+
function paperTexture(): THREE.CanvasTexture {
|
| 205 |
+
const c = document.createElement("canvas");
|
| 206 |
+
c.width = 512;
|
| 207 |
+
c.height = 680;
|
| 208 |
+
const ctx = c.getContext("2d")!;
|
| 209 |
+
|
| 210 |
+
// Base paper — warm cream with a subtle gradient
|
| 211 |
+
const grad = ctx.createLinearGradient(0, 0, 0, c.height);
|
| 212 |
+
grad.addColorStop(0, "#f8f2e2");
|
| 213 |
+
grad.addColorStop(1, "#efe8d0");
|
| 214 |
+
ctx.fillStyle = grad;
|
| 215 |
+
ctx.fillRect(0, 0, c.width, c.height);
|
| 216 |
+
|
| 217 |
+
// Speckle grain
|
| 218 |
+
for (let i = 0; i < 2200; i++) {
|
| 219 |
+
ctx.fillStyle = `rgba(120,100,60,${Math.random() * 0.04})`;
|
| 220 |
+
ctx.fillRect(
|
| 221 |
+
Math.random() * c.width,
|
| 222 |
+
Math.random() * c.height,
|
| 223 |
+
Math.random() * 1.6,
|
| 224 |
+
Math.random() * 1.6
|
| 225 |
+
);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Header rule
|
| 229 |
+
ctx.strokeStyle = "rgba(16,32,59,0.75)";
|
| 230 |
+
ctx.lineWidth = 1.2;
|
| 231 |
+
ctx.beginPath();
|
| 232 |
+
ctx.moveTo(40, 96);
|
| 233 |
+
ctx.lineTo(c.width - 40, 96);
|
| 234 |
+
ctx.stroke();
|
| 235 |
+
|
| 236 |
+
// Vendor/merchant label
|
| 237 |
+
ctx.fillStyle = "#10203b";
|
| 238 |
+
ctx.font = 'italic 34px "Instrument Serif", serif';
|
| 239 |
+
ctx.fillText("Invoice", 40, 76);
|
| 240 |
+
|
| 241 |
+
// Ruled body lines (light)
|
| 242 |
+
ctx.strokeStyle = "rgba(16,32,59,0.09)";
|
| 243 |
+
ctx.lineWidth = 1;
|
| 244 |
+
for (let y = 130; y < c.height - 120; y += 26) {
|
| 245 |
+
ctx.beginPath();
|
| 246 |
+
ctx.moveTo(40, y);
|
| 247 |
+
ctx.lineTo(c.width - 40, y);
|
| 248 |
+
ctx.stroke();
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// Fake line items
|
| 252 |
+
ctx.fillStyle = "rgba(16,32,59,0.55)";
|
| 253 |
+
ctx.font = '13px "JetBrains Mono", monospace';
|
| 254 |
+
const rows: [string, string][] = [
|
| 255 |
+
["Software license", "$1,240.00"],
|
| 256 |
+
["Support (annual)", " $360.00"],
|
| 257 |
+
["Onboarding", " $200.00"],
|
| 258 |
+
];
|
| 259 |
+
rows.forEach(([desc, amt], i) => {
|
| 260 |
+
ctx.fillText(desc, 40, 156 + i * 26);
|
| 261 |
+
ctx.fillText(amt, c.width - 130, 156 + i * 26);
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
// Total
|
| 265 |
+
ctx.strokeStyle = "rgba(16,32,59,0.55)";
|
| 266 |
+
ctx.beginPath();
|
| 267 |
+
ctx.moveTo(40, 260);
|
| 268 |
+
ctx.lineTo(c.width - 40, 260);
|
| 269 |
+
ctx.stroke();
|
| 270 |
+
|
| 271 |
+
ctx.fillStyle = "#10203b";
|
| 272 |
+
ctx.font = 'italic 22px "Instrument Serif", serif';
|
| 273 |
+
ctx.fillText("Total", 40, 292);
|
| 274 |
+
ctx.font = 'italic 22px "Instrument Serif", serif';
|
| 275 |
+
ctx.fillText("$1,800.00", c.width - 140, 292);
|
| 276 |
+
|
| 277 |
+
// Bottom stamp — a red circle with "PAID" (ish)
|
| 278 |
+
const cx = c.width - 110;
|
| 279 |
+
const cy = c.height - 110;
|
| 280 |
+
ctx.save();
|
| 281 |
+
ctx.translate(cx, cy);
|
| 282 |
+
ctx.rotate(-0.18);
|
| 283 |
+
ctx.strokeStyle = "rgba(197,58,44,0.75)";
|
| 284 |
+
ctx.lineWidth = 3;
|
| 285 |
+
ctx.beginPath();
|
| 286 |
+
ctx.arc(0, 0, 52, 0, Math.PI * 2);
|
| 287 |
+
ctx.stroke();
|
| 288 |
+
ctx.beginPath();
|
| 289 |
+
ctx.arc(0, 0, 46, 0, Math.PI * 2);
|
| 290 |
+
ctx.stroke();
|
| 291 |
+
ctx.fillStyle = "rgba(197,58,44,0.85)";
|
| 292 |
+
ctx.font = 'bold 20px "Geist", sans-serif';
|
| 293 |
+
ctx.textAlign = "center";
|
| 294 |
+
ctx.textBaseline = "middle";
|
| 295 |
+
ctx.fillText("PAID", 0, 0);
|
| 296 |
+
ctx.restore();
|
| 297 |
+
|
| 298 |
+
const tex = new THREE.CanvasTexture(c);
|
| 299 |
+
tex.anisotropy = 8;
|
| 300 |
+
tex.needsUpdate = true;
|
| 301 |
+
return tex;
|
| 302 |
+
}
|
ui/src/components/ResultsPanel.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ResultsPanel — the right column when we have a result. Composes:
|
| 3 |
+
* - ConfidenceInkwell (top)
|
| 4 |
+
* - MetricsStrip (cost / latency / tokens / model)
|
| 5 |
+
* - JsonView (extracted data)
|
| 6 |
+
* - WarningsList
|
| 7 |
+
*
|
| 8 |
+
* Empty and error states are handled inline — no separate components needed
|
| 9 |
+
* for something this small.
|
| 10 |
+
*/
|
| 11 |
+
import { AnimatePresence, motion } from "motion/react";
|
| 12 |
+
|
| 13 |
+
import type { APIError } from "@/lib/api";
|
| 14 |
+
import type { ExtractResponse } from "@/types";
|
| 15 |
+
|
| 16 |
+
import { ConfidenceInkwell } from "./ConfidenceInkwell";
|
| 17 |
+
import { JsonView } from "./JsonView";
|
| 18 |
+
import { MetricsStrip } from "./MetricsStrip";
|
| 19 |
+
import { WarningsList } from "./WarningsList";
|
| 20 |
+
|
| 21 |
+
interface Props {
|
| 22 |
+
status: "idle" | "loading" | "success" | "error";
|
| 23 |
+
response: ExtractResponse | null;
|
| 24 |
+
error: APIError | null;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const ITEM = {
|
| 28 |
+
hidden: { y: 24, opacity: 0 },
|
| 29 |
+
visible: {
|
| 30 |
+
y: 0,
|
| 31 |
+
opacity: 1,
|
| 32 |
+
transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] },
|
| 33 |
+
},
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export function ResultsPanel({ status, response, error }: Props) {
|
| 37 |
+
return (
|
| 38 |
+
<AnimatePresence mode="wait">
|
| 39 |
+
{status === "idle" && <Empty key="empty" />}
|
| 40 |
+
{status === "loading" && <Loading key="loading" />}
|
| 41 |
+
{status === "error" && error && <ErrorView key="error" error={error} />}
|
| 42 |
+
{status === "success" && response && (
|
| 43 |
+
<motion.div
|
| 44 |
+
key="result"
|
| 45 |
+
initial="hidden"
|
| 46 |
+
animate="visible"
|
| 47 |
+
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
| 48 |
+
variants={{ visible: { transition: { staggerChildren: 0.08 } } }}
|
| 49 |
+
className="flex flex-col gap-8"
|
| 50 |
+
>
|
| 51 |
+
<motion.div variants={ITEM}>
|
| 52 |
+
<ConfidenceInkwell score={response.result.overall_confidence} />
|
| 53 |
+
</motion.div>
|
| 54 |
+
<motion.div variants={ITEM}>
|
| 55 |
+
<MetricsStrip metrics={response.metrics} />
|
| 56 |
+
</motion.div>
|
| 57 |
+
<motion.div variants={ITEM}>
|
| 58 |
+
<p className="eyebrow mb-3">Extracted data</p>
|
| 59 |
+
<JsonView data={response.result.data} />
|
| 60 |
+
</motion.div>
|
| 61 |
+
<motion.div variants={ITEM}>
|
| 62 |
+
<p className="eyebrow mb-3">Warnings</p>
|
| 63 |
+
<WarningsList warnings={response.result.warnings} />
|
| 64 |
+
</motion.div>
|
| 65 |
+
</motion.div>
|
| 66 |
+
)}
|
| 67 |
+
</AnimatePresence>
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* ------------------------------------------------------------------------ */
|
| 72 |
+
|
| 73 |
+
function Empty() {
|
| 74 |
+
return (
|
| 75 |
+
<motion.div
|
| 76 |
+
initial={{ opacity: 0, y: 12 }}
|
| 77 |
+
animate={{ opacity: 1, y: 0, transition: { duration: 0.6 } }}
|
| 78 |
+
exit={{ opacity: 0 }}
|
| 79 |
+
className="relative flex h-full min-h-[420px] flex-col items-start justify-center gap-4 rounded-[6px] border border-dashed border-[var(--rule)] bg-transparent p-8"
|
| 80 |
+
>
|
| 81 |
+
<p className="eyebrow">Awaiting document</p>
|
| 82 |
+
<p className="font-display text-[38px] leading-[1] text-[var(--ink-strong)]">
|
| 83 |
+
The result will land here —
|
| 84 |
+
</p>
|
| 85 |
+
<p className="max-w-[420px] text-[14px] leading-[1.6] text-[var(--ink-soft)]">
|
| 86 |
+
Confidence, cost, latency, and the extracted JSON. Every field
|
| 87 |
+
cross-checked against a Pydantic schema before it reaches you.
|
| 88 |
+
</p>
|
| 89 |
+
<div className="mt-6 flex items-center gap-3 text-[12px] text-[var(--ink-mute)]">
|
| 90 |
+
<span className="h-px w-8 bg-[var(--rule)]" />
|
| 91 |
+
<span>Try one of the samples on the left</span>
|
| 92 |
+
</div>
|
| 93 |
+
</motion.div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function Loading() {
|
| 98 |
+
return (
|
| 99 |
+
<motion.div
|
| 100 |
+
initial={{ opacity: 0 }}
|
| 101 |
+
animate={{ opacity: 1 }}
|
| 102 |
+
exit={{ opacity: 0 }}
|
| 103 |
+
className="flex flex-col gap-6"
|
| 104 |
+
>
|
| 105 |
+
<p className="eyebrow">Extracting…</p>
|
| 106 |
+
<p className="font-display text-[36px] leading-[1] text-[var(--ink-strong)]">
|
| 107 |
+
Reading the sheet.
|
| 108 |
+
</p>
|
| 109 |
+
<div className="flex flex-col gap-3">
|
| 110 |
+
{[0, 1, 2, 3].map((i) => (
|
| 111 |
+
<Skeleton key={i} delay={i * 0.15} />
|
| 112 |
+
))}
|
| 113 |
+
</div>
|
| 114 |
+
</motion.div>
|
| 115 |
+
);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function Skeleton({ delay }: { delay: number }) {
|
| 119 |
+
return (
|
| 120 |
+
<motion.div
|
| 121 |
+
initial={{ opacity: 0.4, backgroundColor: "var(--rule)" }}
|
| 122 |
+
animate={{
|
| 123 |
+
opacity: [0.4, 0.7, 0.4],
|
| 124 |
+
transition: { duration: 1.8, repeat: Infinity, delay },
|
| 125 |
+
}}
|
| 126 |
+
className="h-3 rounded-[2px]"
|
| 127 |
+
style={{ width: `${60 + Math.random() * 30}%` }}
|
| 128 |
+
/>
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function ErrorView({ error }: { error: APIError }) {
|
| 133 |
+
return (
|
| 134 |
+
<motion.div
|
| 135 |
+
initial={{ opacity: 0, y: 12 }}
|
| 136 |
+
animate={{ opacity: 1, y: 0 }}
|
| 137 |
+
exit={{ opacity: 0 }}
|
| 138 |
+
className="flex flex-col gap-3 rounded-[6px] border-l-2 border-[var(--accent)] bg-[var(--accent-soft)] p-6"
|
| 139 |
+
>
|
| 140 |
+
<p className="eyebrow" style={{ color: "var(--accent)" }}>
|
| 141 |
+
Extraction failed
|
| 142 |
+
</p>
|
| 143 |
+
<p className="font-display text-[26px] leading-[1.1] text-[var(--ink-strong)]">
|
| 144 |
+
{error.message}
|
| 145 |
+
</p>
|
| 146 |
+
<p className="font-mono text-[12px] text-[var(--ink-soft)]">
|
| 147 |
+
code: {error.code}
|
| 148 |
+
{error.requestId && ` · request_id: ${error.requestId}`}
|
| 149 |
+
</p>
|
| 150 |
+
</motion.div>
|
| 151 |
+
);
|
| 152 |
+
}
|
ui/src/components/ThemeToggle.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ThemeToggle — a moon/sun rendered as ink strokes, not a Material icon.
|
| 3 |
+
* Deliberately hand-drawn feel: the moon has a slight crescent bite, the sun
|
| 4 |
+
* has four short rays. Both drawn with strokeLinecap="round".
|
| 5 |
+
*/
|
| 6 |
+
import { motion } from "motion/react";
|
| 7 |
+
|
| 8 |
+
import { useTheme } from "@/hooks/useTheme";
|
| 9 |
+
|
| 10 |
+
export function ThemeToggle() {
|
| 11 |
+
const { theme, toggle } = useTheme();
|
| 12 |
+
const dark = theme === "dark";
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<button
|
| 16 |
+
type="button"
|
| 17 |
+
onClick={toggle}
|
| 18 |
+
aria-label={`Switch to ${dark ? "light" : "dark"} mode`}
|
| 19 |
+
className="group relative h-9 w-9 rounded-full border border-[var(--rule)] bg-[var(--surface)] transition-colors duration-500 ease-editorial hover:border-[var(--ink)]"
|
| 20 |
+
>
|
| 21 |
+
<motion.span
|
| 22 |
+
key={theme}
|
| 23 |
+
initial={{ rotate: -30, opacity: 0 }}
|
| 24 |
+
animate={{ rotate: 0, opacity: 1 }}
|
| 25 |
+
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
| 26 |
+
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
| 27 |
+
>
|
| 28 |
+
{dark ? <Moon /> : <Sun />}
|
| 29 |
+
</motion.span>
|
| 30 |
+
</button>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function Sun() {
|
| 35 |
+
return (
|
| 36 |
+
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
| 37 |
+
<circle cx="10" cy="10" r="3.4" stroke="currentColor" strokeWidth="1.2" />
|
| 38 |
+
{[0, 45, 90, 135, 180, 225, 270, 315].map((deg) => {
|
| 39 |
+
const rad = (deg * Math.PI) / 180;
|
| 40 |
+
const x1 = 10 + Math.cos(rad) * 6;
|
| 41 |
+
const y1 = 10 + Math.sin(rad) * 6;
|
| 42 |
+
const x2 = 10 + Math.cos(rad) * 7.8;
|
| 43 |
+
const y2 = 10 + Math.sin(rad) * 7.8;
|
| 44 |
+
return (
|
| 45 |
+
<line
|
| 46 |
+
key={deg}
|
| 47 |
+
x1={x1}
|
| 48 |
+
y1={y1}
|
| 49 |
+
x2={x2}
|
| 50 |
+
y2={y2}
|
| 51 |
+
stroke="currentColor"
|
| 52 |
+
strokeWidth="1.2"
|
| 53 |
+
strokeLinecap="round"
|
| 54 |
+
/>
|
| 55 |
+
);
|
| 56 |
+
})}
|
| 57 |
+
</svg>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function Moon() {
|
| 62 |
+
// A crescent drawn as one path — feels like a pen stroke, not an icon.
|
| 63 |
+
return (
|
| 64 |
+
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
| 65 |
+
<path
|
| 66 |
+
d="M15 12.5A6 6 0 0 1 7.5 5c0-.5.06-.98.17-1.44A6 6 0 1 0 16.44 12.33c-.46.11-.94.17-1.44.17Z"
|
| 67 |
+
stroke="currentColor"
|
| 68 |
+
strokeWidth="1.2"
|
| 69 |
+
strokeLinejoin="round"
|
| 70 |
+
strokeLinecap="round"
|
| 71 |
+
/>
|
| 72 |
+
</svg>
|
| 73 |
+
);
|
| 74 |
+
}
|
ui/src/components/TopNav.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* TopNav — logo mark on the left, GitHub + theme toggle on the right.
|
| 3 |
+
* Deliberately quiet — no full navigation, no CTAs. The site is one page.
|
| 4 |
+
*/
|
| 5 |
+
import { ThemeToggle } from "./ThemeToggle";
|
| 6 |
+
|
| 7 |
+
const GITHUB_URL = "https://github.com/adityapatel007-byte/structured-data-extractor";
|
| 8 |
+
|
| 9 |
+
export function TopNav() {
|
| 10 |
+
return (
|
| 11 |
+
<header className="relative z-30 mx-auto flex w-full max-w-[1240px] items-center justify-between px-6 py-6 md:px-10">
|
| 12 |
+
<a href="#top" className="flex items-center gap-3">
|
| 13 |
+
<LogoMark />
|
| 14 |
+
<span className="font-display text-[22px] leading-none tracking-tight">
|
| 15 |
+
Ledger
|
| 16 |
+
</span>
|
| 17 |
+
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-[var(--ink-mute)] md:inline-block">
|
| 18 |
+
/ structured extraction
|
| 19 |
+
</span>
|
| 20 |
+
</a>
|
| 21 |
+
|
| 22 |
+
<nav className="flex items-center gap-3">
|
| 23 |
+
<a
|
| 24 |
+
href={GITHUB_URL}
|
| 25 |
+
target="_blank"
|
| 26 |
+
rel="noreferrer"
|
| 27 |
+
className="hidden text-[13px] tracking-wide text-[var(--ink-soft)] transition-colors duration-300 hover:text-[var(--ink)] md:inline"
|
| 28 |
+
>
|
| 29 |
+
Source ↗
|
| 30 |
+
</a>
|
| 31 |
+
<ThemeToggle />
|
| 32 |
+
</nav>
|
| 33 |
+
</header>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function LogoMark() {
|
| 38 |
+
// A minimal ink glyph — a page corner folded over. Two strokes, that's it.
|
| 39 |
+
return (
|
| 40 |
+
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden>
|
| 41 |
+
<path
|
| 42 |
+
d="M5 3.5h11.5L21 8v14.5H5V3.5Z"
|
| 43 |
+
stroke="currentColor"
|
| 44 |
+
strokeWidth="1.4"
|
| 45 |
+
strokeLinejoin="round"
|
| 46 |
+
/>
|
| 47 |
+
<path
|
| 48 |
+
d="M16.5 3.5V8H21"
|
| 49 |
+
stroke="currentColor"
|
| 50 |
+
strokeWidth="1.4"
|
| 51 |
+
strokeLinejoin="round"
|
| 52 |
+
/>
|
| 53 |
+
</svg>
|
| 54 |
+
);
|
| 55 |
+
}
|
ui/src/components/WarningsList.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WarningsList — model-flagged concerns rendered as marginalia strokes.
|
| 3 |
+
* Info/warning/error rendered with different left-border colors so the reader
|
| 4 |
+
* can scan severity without reading.
|
| 5 |
+
*/
|
| 6 |
+
import type { ExtractionWarning } from "@/types";
|
| 7 |
+
|
| 8 |
+
interface Props {
|
| 9 |
+
warnings: ExtractionWarning[];
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const COLORS: Record<ExtractionWarning["severity"], string> = {
|
| 13 |
+
info: "var(--ink-mute)",
|
| 14 |
+
warning: "var(--mustard)",
|
| 15 |
+
error: "var(--accent)",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export function WarningsList({ warnings }: Props) {
|
| 19 |
+
if (!warnings.length) {
|
| 20 |
+
return (
|
| 21 |
+
<p className="text-[13px] italic text-[var(--ink-mute)]">
|
| 22 |
+
No warnings — model reported no concerns.
|
| 23 |
+
</p>
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<ul className="flex flex-col gap-2">
|
| 29 |
+
{warnings.map((w, i) => (
|
| 30 |
+
<li
|
| 31 |
+
key={i}
|
| 32 |
+
className="border-l-2 py-1.5 pl-4 text-[13px] leading-[1.6] text-[var(--ink-soft)]"
|
| 33 |
+
style={{ borderLeftColor: COLORS[w.severity] }}
|
| 34 |
+
>
|
| 35 |
+
{w.field && (
|
| 36 |
+
<span className="mr-2 font-mono text-[12px] text-[var(--ink-mute)]">
|
| 37 |
+
{w.field}
|
| 38 |
+
</span>
|
| 39 |
+
)}
|
| 40 |
+
{w.message}
|
| 41 |
+
</li>
|
| 42 |
+
))}
|
| 43 |
+
</ul>
|
| 44 |
+
);
|
| 45 |
+
}
|
ui/src/hooks/useExtract.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useExtract — manages the upload → API → result lifecycle for a single call.
|
| 3 |
+
*
|
| 4 |
+
* Deliberately simple: no react-query, no cache, no retry. One call at a time,
|
| 5 |
+
* cancellable via `reset()`. That matches the actual UX — upload one doc, look
|
| 6 |
+
* at the result, upload another.
|
| 7 |
+
*/
|
| 8 |
+
import { useCallback, useState } from "react";
|
| 9 |
+
|
| 10 |
+
import { APIError, extract, type ExtractArgs } from "@/lib/api";
|
| 11 |
+
import type { ExtractResponse } from "@/types";
|
| 12 |
+
|
| 13 |
+
type Status = "idle" | "loading" | "success" | "error";
|
| 14 |
+
|
| 15 |
+
export interface UseExtractState {
|
| 16 |
+
status: Status;
|
| 17 |
+
response: ExtractResponse | null;
|
| 18 |
+
error: APIError | null;
|
| 19 |
+
file: File | null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function useExtract() {
|
| 23 |
+
const [state, setState] = useState<UseExtractState>({
|
| 24 |
+
status: "idle",
|
| 25 |
+
response: null,
|
| 26 |
+
error: null,
|
| 27 |
+
file: null,
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
const run = useCallback(async (args: ExtractArgs) => {
|
| 31 |
+
setState({ status: "loading", response: null, error: null, file: args.file });
|
| 32 |
+
try {
|
| 33 |
+
const response = await extract(args);
|
| 34 |
+
setState({ status: "success", response, error: null, file: args.file });
|
| 35 |
+
} catch (err) {
|
| 36 |
+
const apiErr =
|
| 37 |
+
err instanceof APIError
|
| 38 |
+
? err
|
| 39 |
+
: new APIError({
|
| 40 |
+
code: "network_error",
|
| 41 |
+
message: err instanceof Error ? err.message : "Unknown error",
|
| 42 |
+
});
|
| 43 |
+
setState({ status: "error", response: null, error: apiErr, file: args.file });
|
| 44 |
+
}
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
const reset = useCallback(() => {
|
| 48 |
+
setState({ status: "idle", response: null, error: null, file: null });
|
| 49 |
+
}, []);
|
| 50 |
+
|
| 51 |
+
return { ...state, run, reset };
|
| 52 |
+
}
|
ui/src/hooks/useTheme.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Theme hook — persists user choice in localStorage, respects prefers-color-scheme
|
| 3 |
+
* on first visit, and reflects the current theme as data-theme on <html>.
|
| 4 |
+
* The initial paint happens in index.html to avoid a flash.
|
| 5 |
+
*/
|
| 6 |
+
import { useCallback, useEffect, useState } from "react";
|
| 7 |
+
|
| 8 |
+
export type Theme = "light" | "dark";
|
| 9 |
+
|
| 10 |
+
const STORAGE_KEY = "theme";
|
| 11 |
+
|
| 12 |
+
function getInitial(): Theme {
|
| 13 |
+
if (typeof window === "undefined") return "light";
|
| 14 |
+
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
|
| 15 |
+
if (stored === "light" || stored === "dark") return stored;
|
| 16 |
+
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
| 17 |
+
return dark ? "dark" : "light";
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function useTheme() {
|
| 21 |
+
const [theme, setThemeState] = useState<Theme>(getInitial);
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
document.documentElement.setAttribute("data-theme", theme);
|
| 25 |
+
window.localStorage.setItem(STORAGE_KEY, theme);
|
| 26 |
+
}, [theme]);
|
| 27 |
+
|
| 28 |
+
const setTheme = useCallback((t: Theme) => setThemeState(t), []);
|
| 29 |
+
const toggle = useCallback(
|
| 30 |
+
() => setThemeState((t) => (t === "dark" ? "light" : "dark")),
|
| 31 |
+
[]
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
return { theme, setTheme, toggle };
|
| 35 |
+
}
|
ui/src/main.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import ReactDOM from "react-dom/client";
|
| 3 |
+
|
| 4 |
+
import App from "./App";
|
| 5 |
+
import "./styles/globals.css";
|
| 6 |
+
|
| 7 |
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
| 8 |
+
<React.StrictMode>
|
| 9 |
+
<App />
|
| 10 |
+
</React.StrictMode>
|
| 11 |
+
);
|
ui/src/styles/globals.css
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@import "./theme.css";
|
| 6 |
+
|
| 7 |
+
/* ---- Base ---------------------------------------------------------------- */
|
| 8 |
+
|
| 9 |
+
*,
|
| 10 |
+
*::before,
|
| 11 |
+
*::after {
|
| 12 |
+
box-sizing: border-box;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
html,
|
| 16 |
+
body,
|
| 17 |
+
#root {
|
| 18 |
+
height: 100%;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
html {
|
| 22 |
+
-webkit-font-smoothing: antialiased;
|
| 23 |
+
-moz-osx-font-smoothing: grayscale;
|
| 24 |
+
text-rendering: optimizeLegibility;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
margin: 0;
|
| 29 |
+
background: var(--bg);
|
| 30 |
+
color: var(--ink);
|
| 31 |
+
font-family: "Geist", system-ui, sans-serif;
|
| 32 |
+
font-weight: 400;
|
| 33 |
+
font-feature-settings: "ss01", "ss02", "cv11";
|
| 34 |
+
transition: background 500ms cubic-bezier(0.16, 1, 0.3, 1),
|
| 35 |
+
color 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
::selection {
|
| 39 |
+
background: var(--accent);
|
| 40 |
+
color: var(--surface);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* Editorial paper grain — the whole point of the aesthetic. Overlaid on the
|
| 44 |
+
entire document at low opacity. Uses SVG feTurbulence for zero-request grain. */
|
| 45 |
+
body::before {
|
| 46 |
+
content: "";
|
| 47 |
+
position: fixed;
|
| 48 |
+
inset: 0;
|
| 49 |
+
pointer-events: none;
|
| 50 |
+
z-index: 100;
|
| 51 |
+
opacity: var(--grain);
|
| 52 |
+
mix-blend-mode: multiply;
|
| 53 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='7'/><feColorMatrix values='0 0 0 0 0.06 0 0 0 0 0.12 0 0 0 0 0.23 0 0 0 1 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
| 54 |
+
background-size: 240px 240px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
[data-theme="dark"] body::before {
|
| 58 |
+
mix-blend-mode: screen;
|
| 59 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='11'/><feColorMatrix values='0 0 0 0 0.93 0 0 0 0 0.88 0 0 0 0 0.78 0 0 0 0.8 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ---- Type utilities ----------------------------------------------------- */
|
| 63 |
+
|
| 64 |
+
.font-display {
|
| 65 |
+
font-family: "Instrument Serif", serif;
|
| 66 |
+
font-weight: 400;
|
| 67 |
+
letter-spacing: -0.015em;
|
| 68 |
+
line-height: 0.92;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.font-display-italic {
|
| 72 |
+
font-family: "Instrument Serif", serif;
|
| 73 |
+
font-style: italic;
|
| 74 |
+
font-weight: 400;
|
| 75 |
+
letter-spacing: -0.02em;
|
| 76 |
+
line-height: 0.92;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.font-mono {
|
| 80 |
+
font-family: "JetBrains Mono", ui-monospace, monospace;
|
| 81 |
+
font-feature-settings: "calt", "liga", "ss01";
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Small caps + tracking for section labels — the "chapter marker" of the site */
|
| 85 |
+
.eyebrow {
|
| 86 |
+
font-family: "Geist", system-ui, sans-serif;
|
| 87 |
+
font-size: 11px;
|
| 88 |
+
font-weight: 500;
|
| 89 |
+
letter-spacing: 0.18em;
|
| 90 |
+
text-transform: uppercase;
|
| 91 |
+
color: var(--ink-mute);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* ---- Focus rings — replaced with editorial underline ------------------- */
|
| 95 |
+
|
| 96 |
+
:focus-visible {
|
| 97 |
+
outline: 2px solid var(--focus);
|
| 98 |
+
outline-offset: 3px;
|
| 99 |
+
border-radius: 2px;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* Hide the default focus on decorative elements */
|
| 103 |
+
button:focus,
|
| 104 |
+
a:focus {
|
| 105 |
+
outline: none;
|
| 106 |
+
}
|
| 107 |
+
button:focus-visible,
|
| 108 |
+
a:focus-visible {
|
| 109 |
+
outline: 2px solid var(--focus);
|
| 110 |
+
outline-offset: 3px;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* ---- Custom cursor — soft ink dot on interactive elements -------------- */
|
| 114 |
+
|
| 115 |
+
.cursor-ink {
|
| 116 |
+
cursor: none;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* ---- Scrollbar --------------------------------------------------------- */
|
| 120 |
+
|
| 121 |
+
::-webkit-scrollbar {
|
| 122 |
+
width: 10px;
|
| 123 |
+
height: 10px;
|
| 124 |
+
}
|
| 125 |
+
::-webkit-scrollbar-track {
|
| 126 |
+
background: transparent;
|
| 127 |
+
}
|
| 128 |
+
::-webkit-scrollbar-thumb {
|
| 129 |
+
background: var(--rule);
|
| 130 |
+
border-radius: 6px;
|
| 131 |
+
}
|
| 132 |
+
::-webkit-scrollbar-thumb:hover {
|
| 133 |
+
background: var(--ink-mute);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* ---- Motion — respect user preference ---------------------------------- */
|
| 137 |
+
|
| 138 |
+
@media (prefers-reduced-motion: reduce) {
|
| 139 |
+
*,
|
| 140 |
+
*::before,
|
| 141 |
+
*::after {
|
| 142 |
+
animation-duration: 0.001ms !important;
|
| 143 |
+
animation-iteration-count: 1 !important;
|
| 144 |
+
transition-duration: 0.001ms !important;
|
| 145 |
+
}
|
| 146 |
+
}
|
ui/src/styles/theme.css
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* Paper & Ink — design tokens.
|
| 3 |
+
*
|
| 4 |
+
* Two moods, one system:
|
| 5 |
+
* LIGHT is "morning at a well-designed newsroom" — warm cream paper, deep
|
| 6 |
+
* ink navy, a coral that reads as red-orange in daylight.
|
| 7 |
+
* DARK is "reading by lamplight" — warm charcoal (never pure black), warm
|
| 8 |
+
* parchment ink, coral pushed brighter so it still reads at low luminance.
|
| 9 |
+
*
|
| 10 |
+
* All colors are OKLCH where reasonable so lightness swaps stay perceptually
|
| 11 |
+
* even between modes. Fallback hex is provided in comments for reference.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
:root,
|
| 15 |
+
[data-theme="light"] {
|
| 16 |
+
/* Surfaces */
|
| 17 |
+
--bg: #f4efe4; /* warm cream paper */
|
| 18 |
+
--surface: #fbf6e9; /* lifted card, slightly brighter */
|
| 19 |
+
--surface-2: #efe8d5; /* recessed panel */
|
| 20 |
+
--rule: #e0d6bd; /* hairline dividers */
|
| 21 |
+
|
| 22 |
+
/* Ink */
|
| 23 |
+
--ink: #10203b; /* body text — deep navy */
|
| 24 |
+
--ink-strong: #050f24; /* headings */
|
| 25 |
+
--ink-soft: #536079; /* secondary body */
|
| 26 |
+
--ink-mute: #8a8770; /* tertiary / metadata */
|
| 27 |
+
|
| 28 |
+
/* Accents */
|
| 29 |
+
--accent: #c53a2c; /* coral — CTAs, links */
|
| 30 |
+
--accent-hover: #a72c20;
|
| 31 |
+
--accent-soft: #f6d6d0; /* wash for hover backgrounds */
|
| 32 |
+
|
| 33 |
+
--sage: #6b8e6a; /* success / high confidence */
|
| 34 |
+
--mustard: #b58a1f; /* warning */
|
| 35 |
+
|
| 36 |
+
/* Signal fills */
|
| 37 |
+
--confidence-high: #6b8e6a;
|
| 38 |
+
--confidence-mid: #b58a1f;
|
| 39 |
+
--confidence-low: #c53a2c;
|
| 40 |
+
|
| 41 |
+
/* Semantic */
|
| 42 |
+
--focus: #10203b;
|
| 43 |
+
--shadow: 20 10 30; /* rgb components for the ink drop-shadow */
|
| 44 |
+
--shadow-opacity: 0.07;
|
| 45 |
+
|
| 46 |
+
/* Grain overlay opacity */
|
| 47 |
+
--grain: 0.035;
|
| 48 |
+
|
| 49 |
+
color-scheme: light;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
[data-theme="dark"] {
|
| 53 |
+
--bg: #141210; /* warm charcoal, never true black */
|
| 54 |
+
--surface: #1c1917;
|
| 55 |
+
--surface-2: #221f1c;
|
| 56 |
+
--rule: #2b2622;
|
| 57 |
+
|
| 58 |
+
--ink: #ecdfc6; /* warm parchment */
|
| 59 |
+
--ink-strong: #f7ecd6;
|
| 60 |
+
--ink-soft: #a89a80;
|
| 61 |
+
--ink-mute: #6e6552;
|
| 62 |
+
|
| 63 |
+
--accent: #e6604f; /* coral, brighter for dark */
|
| 64 |
+
--accent-hover: #ff7967;
|
| 65 |
+
--accent-soft: #3b1e1a;
|
| 66 |
+
|
| 67 |
+
--sage: #96b895;
|
| 68 |
+
--mustard: #dcac48;
|
| 69 |
+
|
| 70 |
+
--confidence-high: #96b895;
|
| 71 |
+
--confidence-mid: #dcac48;
|
| 72 |
+
--confidence-low: #e6604f;
|
| 73 |
+
|
| 74 |
+
--focus: #ecdfc6;
|
| 75 |
+
--shadow: 0 0 0;
|
| 76 |
+
--shadow-opacity: 0.5;
|
| 77 |
+
|
| 78 |
+
--grain: 0.055;
|
| 79 |
+
|
| 80 |
+
color-scheme: dark;
|
| 81 |
+
}
|
ui/src/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Types mirror the FastAPI response contract from src/api/routers/extract.py.
|
| 3 |
+
* Kept minimal — Pydantic v2 on the server is the source of truth.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export type DocType = "invoice" | "receipt";
|
| 7 |
+
|
| 8 |
+
export interface FieldConfidence {
|
| 9 |
+
field: string;
|
| 10 |
+
score: number;
|
| 11 |
+
reasoning?: string | null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export interface ExtractionWarning {
|
| 15 |
+
field: string | null;
|
| 16 |
+
message: string;
|
| 17 |
+
severity: "info" | "warning" | "error";
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface ExtractionResult<T = Record<string, unknown>> {
|
| 21 |
+
document_type: string;
|
| 22 |
+
data: T;
|
| 23 |
+
field_confidences: FieldConfidence[];
|
| 24 |
+
overall_confidence: number;
|
| 25 |
+
warnings: ExtractionWarning[];
|
| 26 |
+
raw_text_snippet: string | null;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface ExtractionMetrics {
|
| 30 |
+
input_tokens: number;
|
| 31 |
+
output_tokens: number;
|
| 32 |
+
total_tokens: number;
|
| 33 |
+
latency_ms: number;
|
| 34 |
+
cost_usd: number;
|
| 35 |
+
model: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export interface ExtractResponse {
|
| 39 |
+
result: ExtractionResult;
|
| 40 |
+
metrics: ExtractionMetrics;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface APIErrorEnvelope {
|
| 44 |
+
error: {
|
| 45 |
+
code: string;
|
| 46 |
+
message: string;
|
| 47 |
+
request_id?: string | null;
|
| 48 |
+
details?: Record<string, unknown>;
|
| 49 |
+
};
|
| 50 |
+
}
|
ui/tailwind.config.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Tailwind is used strictly for utility layout + spacing.
|
| 5 |
+
* All colors, typography, and design tokens live in CSS variables (theme.css)
|
| 6 |
+
* so dark/light mode is one attribute flip on <html data-theme="...">.
|
| 7 |
+
* We reference variables via arbitrary values: text-[var(--ink)] etc.
|
| 8 |
+
*/
|
| 9 |
+
export default {
|
| 10 |
+
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
| 11 |
+
darkMode: ["class", '[data-theme="dark"]'],
|
| 12 |
+
theme: {
|
| 13 |
+
extend: {
|
| 14 |
+
fontFamily: {
|
| 15 |
+
serif: ['"Instrument Serif"', "serif"],
|
| 16 |
+
sans: ['"Geist"', "system-ui", "sans-serif"],
|
| 17 |
+
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
|
| 18 |
+
},
|
| 19 |
+
transitionTimingFunction: {
|
| 20 |
+
// The house easing — expo out. Everything eases like this.
|
| 21 |
+
editorial: "cubic-bezier(0.16, 1, 0.3, 1)",
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
plugins: [],
|
| 26 |
+
} satisfies Config;
|
ui/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"resolveJsonModule": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUnusedLocals": true,
|
| 16 |
+
"noUnusedParameters": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true,
|
| 18 |
+
"baseUrl": ".",
|
| 19 |
+
"paths": {
|
| 20 |
+
"@/*": ["src/*"]
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
"include": ["src", "vite.config.ts"]
|
| 24 |
+
}
|