aditya0103 commited on
Commit
6267e20
Β·
1 Parent(s): 557ab38

chore: pre-CI cleanup + Task #8 Docker + CI

Browse files
.gitattributes ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Normalize line endings across all platforms.
2
+ # Prevents CRLF/LF churn when the same repo is used from Windows + Linux/macOS + CI.
3
+ * text=auto eol=lf
4
+
5
+ # --- Source files: always LF ---
6
+ *.py text eol=lf
7
+ *.pyi text eol=lf
8
+ *.ts text eol=lf
9
+ *.tsx text eol=lf
10
+ *.js text eol=lf
11
+ *.jsx text eol=lf
12
+ *.mjs text eol=lf
13
+ *.cjs text eol=lf
14
+ *.json text eol=lf
15
+ *.jsonc text eol=lf
16
+ *.yml text eol=lf
17
+ *.yaml text eol=lf
18
+ *.toml text eol=lf
19
+ *.md text eol=lf
20
+ *.html text eol=lf
21
+ *.css text eol=lf
22
+ *.svg text eol=lf
23
+ *.sh text eol=lf
24
+ Dockerfile text eol=lf
25
+ .env.example text eol=lf
26
+ .gitignore text eol=lf
27
+ .gitattributes text eol=lf
28
+
29
+ # --- Windows-only files stay CRLF ---
30
+ *.bat text eol=crlf
31
+ *.cmd text eol=crlf
32
+ *.ps1 text eol=crlf
33
+
34
+ # --- Binary: leave alone ---
35
+ *.png binary
36
+ *.jpg binary
37
+ *.jpeg binary
38
+ *.gif binary
39
+ *.webp binary
40
+ *.pdf binary
41
+ *.ico binary
42
+ *.woff binary
43
+ *.woff2 binary
44
+ *.ttf binary
45
+ *.otf binary
46
+ *.zip binary
47
+ *.tar binary
48
+ *.gz binary
.github/workflows/ci.yml ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # CI pipeline
3
+ #
4
+ # On every push and every PR to main we:
5
+ # 1. python-lint-and-test β€” ruff + pytest (95 tests, all no-network)
6
+ # 2. ui-typecheck-and-build β€” tsc --noEmit + vite build
7
+ # 3. docker-build β€” build both images so a broken Dockerfile fails the PR
8
+ #
9
+ # Jobs run in parallel except docker-build, which is gated on both lint jobs
10
+ # passing (no point building images if the code doesn't lint).
11
+ # ============================================================================
12
+ name: CI
13
+
14
+ on:
15
+ push:
16
+ branches: [main]
17
+ pull_request:
18
+ branches: [main]
19
+
20
+ concurrency:
21
+ # Cancel in-flight runs when a new commit lands on the same branch/PR.
22
+ group: ${{ github.workflow }}-${{ github.ref }}
23
+ cancel-in-progress: true
24
+
25
+ jobs:
26
+ # ---------- Python: ruff + pytest ------------------------------------------
27
+ python-lint-and-test:
28
+ name: Python β€” ruff + pytest
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - name: Set up Python 3.11
34
+ uses: actions/setup-python@v5
35
+ with:
36
+ python-version: "3.11"
37
+ cache: pip
38
+ cache-dependency-path: requirements.txt
39
+
40
+ - name: Install dependencies
41
+ run: |
42
+ python -m pip install --upgrade pip
43
+ pip install -r requirements.txt
44
+ pip install ruff pytest pytest-cov
45
+
46
+ - name: Lint (ruff)
47
+ run: ruff check src tests
48
+
49
+ - name: Test (pytest)
50
+ # Tests must not require network. The extractor is stubbed via
51
+ # dependency-injection in the eval + API test suites.
52
+ run: pytest -q --tb=short
53
+
54
+ # ---------- UI: typecheck + build ------------------------------------------
55
+ ui-typecheck-and-build:
56
+ name: UI β€” tsc + vite build
57
+ runs-on: ubuntu-latest
58
+ defaults:
59
+ run:
60
+ working-directory: ui
61
+ steps:
62
+ - uses: actions/checkout@v4
63
+
64
+ - name: Set up Node 20
65
+ uses: actions/setup-node@v4
66
+ with:
67
+ node-version: "20"
68
+ cache: npm
69
+ cache-dependency-path: ui/package-lock.json
70
+
71
+ - name: Install dependencies
72
+ run: npm ci --no-audit --no-fund
73
+
74
+ - name: Typecheck
75
+ run: npx tsc --noEmit
76
+
77
+ - name: Build
78
+ run: npm run build
79
+
80
+ # ---------- Docker: build both images (smoke) ------------------------------
81
+ docker-build:
82
+ name: Docker β€” build API + UI images
83
+ runs-on: ubuntu-latest
84
+ needs: [python-lint-and-test, ui-typecheck-and-build]
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+
88
+ - name: Set up Docker Buildx
89
+ uses: docker/setup-buildx-action@v3
90
+
91
+ - name: Build API image
92
+ uses: docker/build-push-action@v6
93
+ with:
94
+ context: .
95
+ file: docker/api.Dockerfile
96
+ push: false
97
+ load: true
98
+ tags: sdx-api:ci
99
+ cache-from: type=gha,scope=api
100
+ cache-to: type=gha,mode=max,scope=api
101
+
102
+ - name: Build UI image
103
+ uses: docker/build-push-action@v6
104
+ with:
105
+ context: .
106
+ file: docker/ui.Dockerfile
107
+ push: false
108
+ load: true
109
+ tags: sdx-ui:ci
110
+ cache-from: type=gha,scope=ui
111
+ cache-to: type=gha,mode=max,scope=ui
.gitignore CHANGED
@@ -70,6 +70,12 @@ evaluation/reports/*.json
70
  # --- Docker ---
71
  *.log
72
 
 
 
 
 
 
 
73
  # --- Model cache ---
74
  .cache/
75
  models/
 
70
  # --- Docker ---
71
  *.log
72
 
73
+ # --- Frontend (ui/) ---
74
+ ui/node_modules/
75
+ ui/dist/
76
+ ui/.vite/
77
+ ui/*.local
78
+
79
  # --- Model cache ---
80
  .cache/
81
  models/
README.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  > Multi-domain document extraction β€” turn invoices, receipts, and SEC filings into schema-validated JSON with confidence scoring, multi-model benchmarking, and quantified accuracy.
4
 
5
- [![CI](https://img.shields.io/badge/CI-pending-yellow)]()
6
  [![Python](https://img.shields.io/badge/python-3.11+-blue)]()
7
  [![OpenAI](https://img.shields.io/badge/LLM-GPT--5%20nano-green)]()
8
  [![License](https://img.shields.io/badge/license-MIT-lightgrey)]()
 
2
 
3
  > Multi-domain document extraction β€” turn invoices, receipts, and SEC filings into schema-validated JSON with confidence scoring, multi-model benchmarking, and quantified accuracy.
4
 
5
+ [![CI](https://github.com/adityapatel007-byte/structured-data-extractor/actions/workflows/ci.yml/badge.svg)](https://github.com/adityapatel007-byte/structured-data-extractor/actions/workflows/ci.yml)
6
  [![Python](https://img.shields.io/badge/python-3.11+-blue)]()
7
  [![OpenAI](https://img.shields.io/badge/LLM-GPT--5%20nano-green)]()
8
  [![License](https://img.shields.io/badge/license-MIT-lightgrey)]()
docker-compose.yml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Structured Data Extraction β€” local Docker stack
3
+ #
4
+ # docker compose up --build # first run (or after Dockerfile changes)
5
+ # docker compose up # subsequent runs
6
+ # docker compose logs -f api # tail API logs
7
+ # docker compose down # stop + remove containers
8
+ #
9
+ # The UI waits for the API healthcheck before it accepts traffic. Once both
10
+ # are up, browse to http://localhost:5173 β€” the frontend's /api calls are
11
+ # proxied by nginx into the api container over the compose network.
12
+ # ============================================================================
13
+ services:
14
+ api:
15
+ build:
16
+ context: .
17
+ dockerfile: docker/api.Dockerfile
18
+ image: sdx-api:latest
19
+ container_name: sdx-api
20
+ ports:
21
+ - "8000:8000"
22
+ env_file:
23
+ # Only OPENAI_API_KEY is strictly required. Everything else is
24
+ # optional and read via src/utils/config.py.
25
+ - .env
26
+ environment:
27
+ # Fallbacks in case .env is missing keys β€” the app still starts.
28
+ PYTHONPATH: /app
29
+ LOG_LEVEL: ${LOG_LEVEL:-INFO}
30
+ volumes:
31
+ # Mount sample data read-only so the eval CLI can run in-container.
32
+ # Nothing else from the host filesystem leaks in.
33
+ - ./data/samples:/app/data/samples:ro
34
+ healthcheck:
35
+ test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
36
+ interval: 15s
37
+ timeout: 3s
38
+ start_period: 10s
39
+ retries: 3
40
+ restart: unless-stopped
41
+ networks:
42
+ - sdx-net
43
+
44
+ ui:
45
+ build:
46
+ context: .
47
+ dockerfile: docker/ui.Dockerfile
48
+ image: sdx-ui:latest
49
+ container_name: sdx-ui
50
+ ports:
51
+ - "5173:5173"
52
+ depends_on:
53
+ api:
54
+ # Only start once /health returns 200 β€” avoids the flash of proxy
55
+ # errors when the frontend loads before FastAPI is ready.
56
+ condition: service_healthy
57
+ restart: unless-stopped
58
+ networks:
59
+ - sdx-net
60
+
61
+ networks:
62
+ sdx-net:
63
+ driver: bridge
docker/api.Dockerfile ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Structured Data Extraction β€” API image
3
+ #
4
+ # Multi-stage build: builder installs Python deps into a venv, runtime is a
5
+ # slim image with only the venv + source code. Cuts final image size roughly
6
+ # in half vs a single-stage build (no pip cache, no build toolchain).
7
+ # ============================================================================
8
+
9
+ # ---------- Stage 1: builder --------------------------------------------------
10
+ FROM python:3.11-slim AS builder
11
+
12
+ # System build tools β€” only present in the builder stage, not runtime.
13
+ # Needed because pymupdf / Pillow may compile C extensions on some archs.
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ build-essential \
16
+ gcc \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Create an isolated venv so the runtime stage can just copy /opt/venv.
20
+ RUN python -m venv /opt/venv
21
+ ENV PATH="/opt/venv/bin:$PATH"
22
+
23
+ WORKDIR /build
24
+ COPY requirements.txt .
25
+ RUN pip install --no-cache-dir --upgrade pip \
26
+ && pip install --no-cache-dir -r requirements.txt
27
+
28
+ # ---------- Stage 2: runtime --------------------------------------------------
29
+ FROM python:3.11-slim AS runtime
30
+
31
+ # poppler-utils supports pdf2image; libglib is a pymupdf runtime dep.
32
+ RUN apt-get update && apt-get install -y --no-install-recommends \
33
+ poppler-utils \
34
+ libglib2.0-0 \
35
+ curl \
36
+ && rm -rf /var/lib/apt/lists/*
37
+
38
+ # Copy the venv from the builder β€” no dev deps, no pip cache.
39
+ COPY --from=builder /opt/venv /opt/venv
40
+ ENV PATH="/opt/venv/bin:$PATH" \
41
+ PYTHONDONTWRITEBYTECODE=1 \
42
+ PYTHONUNBUFFERED=1 \
43
+ PYTHONPATH=/app
44
+
45
+ # Non-root user β€” hardens against container escapes. FastAPI needs no privs.
46
+ RUN groupadd --system app && useradd --system --gid app --home /app app
47
+ WORKDIR /app
48
+
49
+ # Copy only what the API needs at runtime. src/ contains the entire python
50
+ # package; pyproject.toml is present for tooling that reads project metadata.
51
+ COPY --chown=app:app src/ ./src/
52
+ COPY --chown=app:app pyproject.toml ./
53
+
54
+ USER app
55
+
56
+ EXPOSE 8000
57
+
58
+ # Container healthcheck β€” Docker + docker-compose will use this to gate startup
59
+ # of dependent services (the UI waits on `service_healthy`).
60
+ HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
61
+ CMD curl -fsS http://localhost:8000/health || exit 1
62
+
63
+ # uvicorn's --host 0.0.0.0 makes the port reachable from other compose services.
64
+ # Single worker keeps the FastAPI dependency singleton (DocumentExtractor)
65
+ # consistent; if we ever need horizontal scale we'll switch to gunicorn.
66
+ CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker/nginx.conf ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # nginx config for the UI container.
3
+ #
4
+ # Two jobs:
5
+ # 1. Serve the Vite-built static assets under /
6
+ # 2. Proxy /api/* to the API container (service name "api" on the compose
7
+ # network). This mirrors the Vite dev-server proxy in ui/vite.config.ts
8
+ # so the frontend's fetch("/extract") calls work identically in dev and
9
+ # in the compose stack.
10
+ # ============================================================================
11
+ server {
12
+ listen 5173;
13
+ server_name _;
14
+
15
+ # Compressed responses for text-y assets (JS, CSS, HTML, JSON).
16
+ gzip on;
17
+ gzip_types text/plain text/css application/json application/javascript
18
+ application/xml+rss text/javascript image/svg+xml;
19
+ gzip_min_length 1024;
20
+
21
+ # Static assets β€” long-cache with immutable hint. Vite fingerprints
22
+ # filenames, so a new build cache-busts automatically.
23
+ location /assets/ {
24
+ root /usr/share/nginx/html;
25
+ expires 1y;
26
+ add_header Cache-Control "public, immutable";
27
+ }
28
+
29
+ # API proxy β€” strip the /api prefix so the FastAPI routes stay clean.
30
+ # `service_healthy` in compose guarantees this backend is up before we
31
+ # accept traffic here.
32
+ location /api/ {
33
+ proxy_pass http://api:8000/;
34
+ proxy_http_version 1.1;
35
+ proxy_set_header Host $host;
36
+ proxy_set_header X-Real-IP $remote_addr;
37
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
38
+ proxy_set_header X-Forwarded-Proto $scheme;
39
+
40
+ # Extraction can take 20-60s for large PDFs, so bump the read timeout
41
+ # above the nginx default (60s).
42
+ proxy_connect_timeout 10s;
43
+ proxy_read_timeout 120s;
44
+
45
+ # Big uploads: 10-K filings can be 5+ MB.
46
+ client_max_body_size 20m;
47
+ }
48
+
49
+ # SPA fallback β€” any non-file route falls back to index.html so React
50
+ # Router (or hash routing) can take over.
51
+ location / {
52
+ root /usr/share/nginx/html;
53
+ index index.html;
54
+ try_files $uri $uri/ /index.html;
55
+ }
56
+ }
docker/ui.Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Structured Data Extraction β€” UI image
3
+ #
4
+ # Multi-stage build: stage 1 (node) runs the Vite production build, stage 2
5
+ # (nginx) serves the resulting static bundle and proxies /api to the api
6
+ # container. Nothing from node_modules survives into the runtime image.
7
+ # ============================================================================
8
+
9
+ # ---------- Stage 1: build the Vite bundle -----------------------------------
10
+ FROM node:20-alpine AS builder
11
+
12
+ WORKDIR /ui
13
+
14
+ # Copy package manifests first β€” this lets Docker cache the npm install
15
+ # layer across code-only changes.
16
+ COPY ui/package.json ui/package-lock.json ./
17
+ RUN npm ci --no-audit --no-fund
18
+
19
+ # Copy the rest of the ui source and build.
20
+ COPY ui/ ./
21
+ RUN npm run build
22
+
23
+ # ---------- Stage 2: serve with nginx ----------------------------------------
24
+ FROM nginx:1.27-alpine AS runtime
25
+
26
+ # Custom nginx config β€” proxies /api to the api container.
27
+ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
28
+ # Remove the stock nginx config that listens on :80 β€” we listen on :5173 only.
29
+ RUN rm -f /etc/nginx/conf.d/default.conf.bak && \
30
+ sed -i '/listen 80;/d' /etc/nginx/nginx.conf 2>/dev/null || true
31
+
32
+ # Copy the built bundle.
33
+ COPY --from=builder /ui/dist /usr/share/nginx/html
34
+
35
+ EXPOSE 5173
36
+
37
+ # nginx:alpine runs as root by default β€” nginx workers drop to `nginx` user
38
+ # themselves once bound to the port, so we leave the entrypoint alone.
39
+ HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
40
+ CMD wget -qO- http://localhost:5173/ >/dev/null || exit 1
41
+
42
+ CMD ["nginx", "-g", "daemon off;"]
pyproject.toml CHANGED
@@ -13,7 +13,21 @@ target-version = "py311"
13
 
14
  [tool.ruff.lint]
15
  select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
16
- ignore = ["E501"] # line-length handled by formatter
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  [tool.black]
19
  line-length = 100
 
13
 
14
  [tool.ruff.lint]
15
  select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
16
+ ignore = ["E501", "UP017"] # E501: line-length via formatter. UP017: datetime.UTC (3.11-only) β€” we still support timezone.utc for broader interpreter reach in tooling.
17
+
18
+ [tool.ruff.lint.per-file-ignores]
19
+ # HTTP-error taxonomy β€” these are our own class hierarchy, not surprises.
20
+ # Renaming to `*Error` would leak "Error" everywhere at call sites without
21
+ # adding meaning.
22
+ "src/api/errors.py" = ["N818"]
23
+ # FastAPI convention: File(...) / Depends() go in function default args.
24
+ # The whole framework is built around this pattern.
25
+ "src/api/routers/*.py" = ["B008"]
26
+ # Parser branches carry different explanatory comments β€” a ternary would
27
+ # drop them and hurt readability more than help.
28
+ "src/data_prep/parsers.py" = ["SIM108"]
29
+ # Test tuple-unpacking sometimes leaves parts unused for clarity.
30
+ "tests/**/*.py" = ["B007"]
31
 
32
  [tool.black]
33
  line-length = 100
src/data_prep/cord.py CHANGED
@@ -8,7 +8,8 @@ Reference: https://github.com/clovaai/cord
8
  """
9
  from __future__ import annotations
10
 
11
- from typing import Any, Iterator
 
12
 
13
  from src.data_prep.parsers import clean_text, parse_money
14
  from src.schemas import Receipt, ReceiptLineItem
 
8
  """
9
  from __future__ import annotations
10
 
11
+ from collections.abc import Iterator
12
+ from typing import Any
13
 
14
  from src.data_prep.parsers import clean_text, parse_money
15
  from src.schemas import Receipt, ReceiptLineItem
src/data_prep/sroie.py CHANGED
@@ -12,7 +12,8 @@ Reference: https://rrc.cvc.uab.es/?ch=13
12
  """
13
  from __future__ import annotations
14
 
15
- from typing import Any, Iterator
 
16
 
17
  from src.data_prep.parsers import clean_text, parse_date, parse_money
18
  from src.schemas import Address, Receipt
 
12
  """
13
  from __future__ import annotations
14
 
15
+ from collections.abc import Iterator
16
+ from typing import Any
17
 
18
  from src.data_prep.parsers import clean_text, parse_date, parse_money
19
  from src.schemas import Address, Receipt
src/eval/__init__.py CHANGED
@@ -11,7 +11,7 @@ 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"]
 
11
  run_eval(records, extractor, doc_type, ...) -> EvalReport
12
  write_reports(report, out_dir) -> (csv_path, md_path)
13
  """
 
14
  from src.eval.report import write_reports
15
+ from src.eval.runner import EvalReport, run_eval
16
 
17
  __all__ = ["run_eval", "EvalReport", "write_reports"]
src/eval/cli.py CHANGED
@@ -21,7 +21,6 @@ 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
 
@@ -33,7 +32,6 @@ 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.
 
21
  import sys
22
  from datetime import datetime
23
  from pathlib import Path
 
24
 
25
  from pydantic import BaseModel
26
 
 
32
  from src.utils.cost_tracker import ExtractionMetrics
33
  from src.utils.logging import logger
34
 
 
35
  # ---------------------------------------------------------------------------
36
  # Extractor factories: pluggable strategies for how a JSONL record becomes an
37
  # (ExtractionResult, ExtractionMetrics) pair.
src/eval/report.py CHANGED
@@ -8,7 +8,6 @@ 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:
 
8
 
9
  from src.eval.runner import EvalReport
10
 
 
11
  # --- CSV -------------------------------------------------------------------
12
 
13
  def write_per_record_csv(report: EvalReport, out_path: str | Path) -> Path:
src/eval/runner.py CHANGED
@@ -10,7 +10,7 @@ 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
 
 
10
 
11
  import time as _time
12
  from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass
14
  from statistics import mean
15
  from typing import Any
16
 
src/schemas/invoice.py CHANGED
@@ -106,7 +106,7 @@ class Invoice(StrictModel):
106
  return normalize_currency(v)
107
 
108
  @model_validator(mode="after")
109
- def _sanity_check_totals(self) -> "Invoice":
110
  """Sanity-check: if subtotal + tax + shipping ~= total, we're consistent.
111
 
112
  We don't reject on mismatch (the model may have transcribed one field
 
106
  return normalize_currency(v)
107
 
108
  @model_validator(mode="after")
109
+ def _sanity_check_totals(self) -> Invoice:
110
  """Sanity-check: if subtotal + tax + shipping ~= total, we're consistent.
111
 
112
  We don't reject on mismatch (the model may have transcribed one field
src/utils/cost_tracker.py CHANGED
@@ -38,7 +38,7 @@ class ExtractionMetrics:
38
  class Timer:
39
  """Context manager for measuring wall-clock latency in ms."""
40
 
41
- def __enter__(self) -> "Timer":
42
  self._start = perf_counter()
43
  self.elapsed_ms = 0.0
44
  return self
 
38
  class Timer:
39
  """Context manager for measuring wall-clock latency in ms."""
40
 
41
+ def __enter__(self) -> Timer:
42
  self._start = perf_counter()
43
  self.elapsed_ms = 0.0
44
  return self
tests/unit/test_api.py CHANGED
@@ -11,12 +11,11 @@ import io
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:
 
11
  import pytest
12
  from fastapi.testclient import TestClient
13
 
14
+ from src.api.deps import 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
  # --- Fake extractor --------------------------------------------------------
20
 
21
  class _FakeExtractor:
tests/unit/test_data_prep.py CHANGED
@@ -5,7 +5,6 @@ of SROIE and CORD.
5
  """
6
  from __future__ import annotations
7
 
8
- import json
9
  from datetime import date
10
  from pathlib import Path
11
 
@@ -17,7 +16,6 @@ from src.data_prep.sroie import normalize_sroie_record
17
  from src.data_prep.writer import read_jsonl, write_jsonl
18
  from src.schemas import Receipt
19
 
20
-
21
  # --- Money parsing ---------------------------------------------------------
22
 
23
 
 
5
  """
6
  from __future__ import annotations
7
 
 
8
  from datetime import date
9
  from pathlib import Path
10
 
 
16
  from src.data_prep.writer import read_jsonl, write_jsonl
17
  from src.schemas import Receipt
18
 
 
19
  # --- Money parsing ---------------------------------------------------------
20
 
21
 
tests/unit/test_eval.py CHANGED
@@ -12,8 +12,6 @@ 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,
@@ -29,7 +27,6 @@ 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:
 
12
 
13
  from datetime import date
14
 
 
 
15
  from src.data_prep.writer import read_jsonl
16
  from src.eval.comparators import (
17
  compare,
 
27
  from src.schemas import ExtractionResult, Receipt
28
  from src.utils.cost_tracker import ExtractionMetrics
29
 
 
30
  # --- Comparators -----------------------------------------------------------
31
 
32
  class TestComparators:
tests/unit/test_extractor.py CHANGED
@@ -21,7 +21,6 @@ from src.schemas import (
21
  Receipt,
22
  )
23
 
24
-
25
  # --- Prompt registry -------------------------------------------------------
26
 
27
 
 
21
  Receipt,
22
  )
23
 
 
24
  # --- Prompt registry -------------------------------------------------------
25
 
26
 
tests/unit/test_schemas.py CHANGED
@@ -21,7 +21,6 @@ from src.schemas import (
21
  list_doc_types,
22
  )
23
 
24
-
25
  # --- Invoice ----------------------------------------------------------------
26
 
27
 
 
21
  list_doc_types,
22
  )
23
 
 
24
  # --- Invoice ----------------------------------------------------------------
25
 
26
 
ui/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
ui/package.json CHANGED
@@ -19,6 +19,7 @@
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",
 
19
  "three": "^0.169.0"
20
  },
21
  "devDependencies": {
22
+ "@types/node": "^22.10.0",
23
  "@types/react": "^18.3.12",
24
  "@types/react-dom": "^18.3.1",
25
  "@types/three": "^0.169.0",
ui/src/components/PaperScene.tsx CHANGED
@@ -19,7 +19,7 @@
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
 
@@ -62,18 +62,19 @@ export function PaperScene({ state }: Props) {
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
 
@@ -88,9 +89,9 @@ function Sheet({ state }: { state: PaperState }) {
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;
@@ -117,14 +118,16 @@ function Sheet({ state }: { state: PaperState }) {
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
 
@@ -202,24 +205,35 @@ function Glow({ active }: { active: boolean }) {
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
  );
@@ -230,7 +244,7 @@ function paperTexture(): THREE.CanvasTexture {
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
@@ -241,10 +255,10 @@ function paperTexture(): THREE.CanvasTexture {
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
 
@@ -258,25 +272,25 @@ function paperTexture(): THREE.CanvasTexture {
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);
@@ -296,7 +310,14 @@ function paperTexture(): THREE.CanvasTexture {
296
  ctx.restore();
297
 
298
  const tex = new THREE.CanvasTexture(c);
299
- tex.anisotropy = 8;
 
 
 
 
 
 
 
300
  tex.needsUpdate = true;
301
  return tex;
302
  }
 
19
  */
20
  import { Canvas, useFrame } from "@react-three/fiber";
21
  import { Environment } from "@react-three/drei";
22
+ import { useEffect, useMemo, useRef } from "react";
23
  import type { Group, Mesh } from "three";
24
  import * as THREE from "three";
25
 
 
62
 
63
  function Sheet({ state }: { state: PaperState }) {
64
  const group = useRef<Group>(null!);
65
+ // Mouse position lives in a ref, not state, so pointer movement doesn't
66
+ // force React re-renders at ~60Hz. `useFrame` reads it directly on the next
67
+ // GPU tick, so parallax stays live without touching the render loop.
68
+ const mouse = useRef({ x: 0, y: 0 });
69
 
70
  // Track the mouse across the whole window β€” the parallax reads better when
71
  // it's tied to page position, not just the canvas.
72
  useEffect(() => {
73
  const onMove = (e: MouseEvent) => {
74
+ mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
75
+ mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
 
 
76
  };
77
+ window.addEventListener("mousemove", onMove, { passive: true });
78
  return () => window.removeEventListener("mousemove", onMove);
79
  }, []);
80
 
 
89
  const driftRotX = Math.sin(t * 0.4) * 0.05;
90
  const driftRotZ = Math.cos(t * 0.3) * 0.03;
91
 
92
+ // Parallax offset from mouse (ref-based β€” no React re-renders).
93
+ const paraX = mouse.current.x * 0.14;
94
+ const paraY = mouse.current.y * 0.08;
95
 
96
  // Targets vary by state.
97
  const targetRotX = state === "extracted" ? -0.35 : driftRotX + paraY * 0.8;
 
118
  <meshBasicMaterial color="#000000" transparent opacity={0.12} />
119
  </mesh>
120
 
121
+ {/* Main sheet β€” a slightly wider-than-tall rectangle.
122
+ Uses MeshBasicMaterial so the texture (which already contains its
123
+ own baked-in shading + gradient) reads at full brightness. Standard
124
+ PBR shading was dimming the printed text β€” user flagged this. */}
125
  <mesh castShadow receiveShadow>
126
  <boxGeometry args={[2.55, 3.4, 0.02]} />
127
+ <meshBasicMaterial
128
  map={texture}
 
 
129
  side={THREE.DoubleSide}
130
+ toneMapped={false}
131
  />
132
  </mesh>
133
 
 
205
  * that the sheet reads as a document rather than a blank plane.
206
  */
207
  function paperTexture(): THREE.CanvasTexture {
208
+ // Draw at 2Γ— so the sheet stays sharp when the camera is close.
209
+ // `ctx.scale` lets us keep all layout coordinates in the original 512Γ—680
210
+ // logical space β€” every font-size and offset below is unchanged.
211
+ const SCALE = 2;
212
+ const LOGICAL_W = 512;
213
+ const LOGICAL_H = 680;
214
  const c = document.createElement("canvas");
215
+ c.width = LOGICAL_W * SCALE;
216
+ c.height = LOGICAL_H * SCALE;
217
  const ctx = c.getContext("2d")!;
218
+ ctx.scale(SCALE, SCALE);
219
+ // From here on, treat the drawing surface as if it were LOGICAL_W Γ— LOGICAL_H.
220
+ // Local aliases keep the layout math readable.
221
+ const cw = LOGICAL_W;
222
+ const ch = LOGICAL_H;
223
 
224
  // Base paper β€” warm cream with a subtle gradient
225
+ const grad = ctx.createLinearGradient(0, 0, 0, ch);
226
  grad.addColorStop(0, "#f8f2e2");
227
  grad.addColorStop(1, "#efe8d0");
228
  ctx.fillStyle = grad;
229
+ ctx.fillRect(0, 0, cw, ch);
230
 
231
  // Speckle grain
232
  for (let i = 0; i < 2200; i++) {
233
  ctx.fillStyle = `rgba(120,100,60,${Math.random() * 0.04})`;
234
  ctx.fillRect(
235
+ Math.random() * cw,
236
+ Math.random() * ch,
237
  Math.random() * 1.6,
238
  Math.random() * 1.6
239
  );
 
244
  ctx.lineWidth = 1.2;
245
  ctx.beginPath();
246
  ctx.moveTo(40, 96);
247
+ ctx.lineTo(cw - 40, 96);
248
  ctx.stroke();
249
 
250
  // Vendor/merchant label
 
255
  // Ruled body lines (light)
256
  ctx.strokeStyle = "rgba(16,32,59,0.09)";
257
  ctx.lineWidth = 1;
258
+ for (let y = 130; y < ch - 120; y += 26) {
259
  ctx.beginPath();
260
  ctx.moveTo(40, y);
261
+ ctx.lineTo(cw - 40, y);
262
  ctx.stroke();
263
  }
264
 
 
272
  ];
273
  rows.forEach(([desc, amt], i) => {
274
  ctx.fillText(desc, 40, 156 + i * 26);
275
+ ctx.fillText(amt, cw - 130, 156 + i * 26);
276
  });
277
 
278
  // Total
279
  ctx.strokeStyle = "rgba(16,32,59,0.55)";
280
  ctx.beginPath();
281
  ctx.moveTo(40, 260);
282
+ ctx.lineTo(cw - 40, 260);
283
  ctx.stroke();
284
 
285
  ctx.fillStyle = "#10203b";
286
  ctx.font = 'italic 22px "Instrument Serif", serif';
287
  ctx.fillText("Total", 40, 292);
288
  ctx.font = 'italic 22px "Instrument Serif", serif';
289
+ ctx.fillText("$1,800.00", cw - 140, 292);
290
 
291
  // Bottom stamp β€” a red circle with "PAID" (ish)
292
+ const cx = cw - 110;
293
+ const cy = ch - 110;
294
  ctx.save();
295
  ctx.translate(cx, cy);
296
  ctx.rotate(-0.18);
 
310
  ctx.restore();
311
 
312
  const tex = new THREE.CanvasTexture(c);
313
+ // Sharpness knobs β€” user flagged text as bitty on the hero.
314
+ // β€’ LinearFilter (no mipmap blur) keeps small type crisp at close camera range.
315
+ // β€’ anisotropy 16 keeps text readable at glancing angles as the sheet tilts.
316
+ // β€’ generateMipmaps off β€” we don't need distance LODs for a hero prop.
317
+ tex.minFilter = THREE.LinearFilter;
318
+ tex.magFilter = THREE.LinearFilter;
319
+ tex.anisotropy = 16;
320
+ tex.generateMipmaps = false;
321
  tex.needsUpdate = true;
322
  return tex;
323
  }
ui/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
ui/tsconfig.json CHANGED
@@ -15,6 +15,7 @@
15
  "noUnusedLocals": true,
16
  "noUnusedParameters": true,
17
  "noFallthroughCasesInSwitch": true,
 
18
  "baseUrl": ".",
19
  "paths": {
20
  "@/*": ["src/*"]
 
15
  "noUnusedLocals": true,
16
  "noUnusedParameters": true,
17
  "noFallthroughCasesInSwitch": true,
18
+ "types": ["node", "vite/client"],
19
  "baseUrl": ".",
20
  "paths": {
21
  "@/*": ["src/*"]