aditya0103 commited on
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 files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +21 -4
  2. scripts/run_eval.py +17 -0
  3. src/api/deps.py +49 -0
  4. src/api/errors.py +69 -0
  5. src/api/main.py +97 -0
  6. src/api/middleware.py +47 -0
  7. src/api/routers/__init__.py +1 -0
  8. src/api/routers/extract.py +90 -0
  9. src/api/routers/health.py +28 -0
  10. src/api/routers/schemas.py +31 -0
  11. src/eval/__init__.py +17 -0
  12. src/eval/cli.py +173 -0
  13. src/eval/comparators.py +144 -0
  14. src/eval/flatten.py +129 -0
  15. src/eval/metrics.py +196 -0
  16. src/eval/report.py +126 -0
  17. src/eval/runner.py +152 -0
  18. src/ui/README.md +17 -0
  19. tests/unit/test_api.py +198 -0
  20. tests/unit/test_eval.py +293 -0
  21. ui/.gitignore +5 -0
  22. ui/README.md +85 -0
  23. ui/index.html +33 -0
  24. ui/package.json +32 -0
  25. ui/postcss.config.js +6 -0
  26. ui/public/samples/README.txt +12 -0
  27. ui/src/App.tsx +43 -0
  28. ui/src/components/ConfidenceInkwell.tsx +80 -0
  29. ui/src/components/CustomCursor.tsx +81 -0
  30. ui/src/components/Dropzone.tsx +281 -0
  31. ui/src/components/ExtractSection.tsx +111 -0
  32. ui/src/components/Footer.tsx +26 -0
  33. ui/src/components/Hero.tsx +158 -0
  34. ui/src/components/HowItWorks.tsx +62 -0
  35. ui/src/components/JsonView.tsx +79 -0
  36. ui/src/components/MetricsStrip.tsx +92 -0
  37. ui/src/components/Numbers.tsx +79 -0
  38. ui/src/components/PaperScene.tsx +302 -0
  39. ui/src/components/ResultsPanel.tsx +152 -0
  40. ui/src/components/ThemeToggle.tsx +74 -0
  41. ui/src/components/TopNav.tsx +55 -0
  42. ui/src/components/WarningsList.tsx +45 -0
  43. ui/src/hooks/useExtract.ts +52 -0
  44. ui/src/hooks/useTheme.ts +35 -0
  45. ui/src/main.tsx +11 -0
  46. ui/src/styles/globals.css +146 -0
  47. ui/src/styles/theme.css +81 -0
  48. ui/src/types.ts +50 -0
  49. ui/tailwind.config.ts +26 -0
  50. 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 | Streamlit | Fastest path to a demo-worthy UI |
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 (in another terminal)
104
- streamlit run src/ui/app.py
 
 
 
 
 
 
 
 
 
 
 
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, "&amp;")
57
+ .replace(/</g, "&lt;")
58
+ .replace(/>/g, "&gt;");
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
+ }