drowzeys commited on
Commit
2edb151
·
verified ·
1 Parent(s): 97503e9

v1.0 alpha: keys-Auto Receipts Studio (iPhone / may add Autonomous Lamp Skill)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.env.example ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # All local. No cloud keys. Copy to .env
2
+
3
+ # Gemma 4 12B Unified omni (vision + embed). Lamp: set URLs to the GPU box.
4
+ # Does not fit on the Lamp (6 GB). GPU util cap 0.85.
5
+ RECEIPT_LLM_BACKEND=gemma
6
+ RECEIPT_LLM_BASE_URL=http://127.0.0.1:8080/v1
7
+ RECEIPT_LLM_MODEL=google/gemma-4-12B-it
8
+ RECEIPT_LLM_ACCEPTS_IMAGES=true
9
+ RECEIPT_LLM_MAX_TOKENS=8192
10
+
11
+ RECEIPT_EMBED_BACKEND=omni
12
+ RECEIPT_EMBED_BASE_URL=http://127.0.0.1:8080/v1
13
+ RECEIPT_EMBED_MODEL=google/gemma-4-12B-it
14
+ RECEIPT_EMBED_DIM=3840
15
+
16
+ # Optional OCR assist (vision extract does not need it)
17
+ RECEIPT_OCR_BACKEND=none
18
+
19
+ # Lamp HAL camera
20
+ RECEIPT_CAMERA_URL=http://127.0.0.1:5001
21
+ RECEIPT_SNAPSHOT_WIDTH=1280
22
+ RECEIPT_SNAPSHOT_QUALITY=85
23
+
24
+ # UI / inbox
25
+ RECEIPT_UI_HOST=127.0.0.1
26
+ RECEIPT_UI_PORT=7860
27
+ RECEIPT_UI_SHARE_LAN=false
28
+ RECEIPT_IDLE_SECONDS=30
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .env
10
+ *.db
11
+ *.db-wal
12
+ *.db-shm
13
+ data/*
14
+ !data/.gitkeep
15
+ inbox/*
16
+ !inbox/.gitkeep
17
+ processing/*
18
+ !processing/.gitkeep
19
+ processed/*
20
+ !processed/.gitkeep
21
+ failed/*
22
+ !failed/.gitkeep
23
+ exports/*
24
+ !exports/.gitkeep
25
+ .DS_Store
26
+ Thumbs.db
27
+ dist/
28
+ build/
29
+ *.log
30
+ .grok/sessions/
.grok/skills/keys-receipt-scanner/SKILL.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: keys-receipt-scanner
3
+ description: Scan receipts/docs via Lamp camera or inbox, Gemma 4 12B Unified omni extract+embed, sqlite-vec store. Use when the user mentions receipts, invoices, Lamp scan, Autonomous OS skill, expense capture, or /keys-receipt-scanner.
4
+ ---
5
+
6
+ # keys-receipt-scanner (Grok)
7
+
8
+ Work in `keys-automatic-receipt-doc-scanner`. Architecture is in `AGENTS.md`.
9
+
10
+ - Lamp 6 GB: skill + camera only. Gemma 4 12B Unified stays on the GPU box.
11
+ - Default LLM/embed: Gemma 4 12B Unified, dim 3840, `embed_backend=omni`.
12
+ - Qwen3.8-27B ADay777 is the vision fallback (`llm_backend=nvidia`). Lightning is text-only — never send images.
13
+ - Do not download weights. Do not raise GPU util above 0.85.
14
+ - Tests: `pytest` with mocked HTTP, no live models.
AGENTS.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # keys-automatic-receipt-doc-scanner
2
+
3
+ Local receipt/document scanner for **Autonomous OS + Autonomous Lamp**.
4
+ Lamp camera captures; a GPU box runs the model. Nothing cloud.
5
+
6
+ ## Hardware split (do not blur this)
7
+
8
+ | Where | Fits | Does not fit |
9
+ |---|---|---|
10
+ | **Lamp** (ARM64, **6 GB RAM**) | This skill (`SKILL.md` + `scripts/`), HAL snapshot, SQLite | Gemma 4 12B Unified, Qwen3.8-27B, Nemotron 3.5 Lightning |
11
+ | **GPU box** (DGX Spark / Omen) | Gemma 4 12B Unified (omni: vision + embed) or Qwen3.8-27B VLM + Embed-1B | — |
12
+
13
+ Gemma 4 12B Unified is ~12B dense (`hidden_size` **3840**, `Gemma4UnifiedForConditionalGeneration`). Weights alone exceed the Lamp's 6 GB even in NVFP4. Treat the Lamp as eyes/hands; treat the GPU box as the brain.
14
+
15
+ ## Default brain: Gemma 4 12B Unified (omni)
16
+
17
+ Encoder-free VLM. One OpenAI-compatible server does:
18
+
19
+ 1. **Vision extract** — `POST /v1/chat/completions` with `image_url` data URI (receipt/doc JPEG)
20
+ 2. **Embed** — `POST /v1/embeddings` against the **same** server (mean-pool / convert-embed). Dim **3840**.
21
+
22
+ Never mix 3840 (Gemma) and 2048 (Nemotron-3-Embed-1B) in one sqlite-vec index.
23
+
24
+ ## Fallback brains (same skill, env only)
25
+
26
+ - **Qwen3.8-27B ADay777** VLM at `:8078` (`qwen38-nvfp4`) for extract; Nemotron-3-Embed-1B 2048-d for embed
27
+ - **Lightning** is text-only. Never send images to it.
28
+
29
+ Fleet GB10: `--gpu-memory-utilization` **0.85** hard cap.
30
+
31
+ ## Pipeline
32
+
33
+ ```
34
+ voice / phone / drop → Lamp camera or inbox/
35
+ → optional OCR assist
36
+ → Gemma4 (or Qwen) vision JSON extract + category
37
+ → omni embed (or Nemotron-3-Embed-1B)
38
+ → sqlite-vec vendor/SKU/category match
39
+ → review / speak summary
40
+ ```
41
+
42
+ ## Rules
43
+
44
+ - No cloud APIs. Backends behind `OCRBackend` / `LLMBackend` / `EmbedBackend`.
45
+ - Lightning: `accepts_images=False`. Never attach image parts.
46
+ - Python 3.12, typed, pytest. No notebooks. Don't vendor weights.
47
+ - Idle-batch inbox 30s. Dedup sha256.
48
+
49
+ ## Extract JSON
50
+
51
+ ```json
52
+ {
53
+ "doc_kind": "receipt|invoice|document",
54
+ "category": "groceries|dining|transport|household|health|entertainment|utilities|office|travel|other",
55
+ "vendor": "string|null",
56
+ "date": "YYYY-MM-DD|null",
57
+ "tax": "number|null",
58
+ "total": "number|null",
59
+ "currency": "string|null",
60
+ "line_items": [
61
+ {"description": "string", "qty": "number|null", "unit_price": "number|null",
62
+ "amount": "number|null", "sku": "string|null"}
63
+ ]
64
+ }
65
+ ```
66
+
67
+ Money stored as integer cents.
68
+
69
+ ## Match (cosine similarity = 1 - sqlite-vec distance)
70
+
71
+ | | Auto | Review | Unmatched |
72
+ |---|---|---|---|
73
+ | SKU / line | ≥ 0.88 | 0.72–0.88 | < 0.72 |
74
+ | Vendor | ≥ 0.82 | 0.65–0.82 | < 0.65 |
75
+
76
+ Exact catalog SKU wins first.
77
+
78
+ ## Layout
79
+
80
+ ```
81
+ app/ config, schemas, media, camera, extract, embed, db, match,
82
+ pipeline, watcher, cli, ui
83
+ backends/ base, openai_compat, gemma, nvidia, ollama, apple, cpu
84
+ skills/keys-receipt-scanner/ Autonomous OS built-in skill (Lamp)
85
+ inbox/ processing/ processed/ failed/ exports/
86
+ ```
87
+
88
+ ## Autonomous OS skill
89
+
90
+ `skills/keys-receipt-scanner/` is a **built-in skill** in Autonomous OS format:
91
+
92
+ - `SKILL.md` + `skill.json` (`capabilities: ["vision"]`)
93
+ - Installs on any body that declares vision (Lamp, Reachy Mini — not Intern)
94
+ - Acts via HAL `GET :5001/camera/snapshot` then `python -m app.cli scan --image PATH`
95
+ - Does not load 12B weights on the robot
LICENSE ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 keys
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # keys-Auto Receipts Studio (iPhone / may add Autonomous Lamp Skill)
2
+
3
+ **v1.0 alpha**
4
+
5
+ iPhone (or desktop) photo → local **Gemma 4 12B-it** vision extract → SQLite. Optional [Autonomous OS](https://github.com/autonomous-ai/autonomous-os) Lamp skill (camera on the robot, 12B on the GPU box — the Lamp’s 6 GB cannot hold 12B).
6
+
7
+ GitHub: `drowzeys/keys-Auto-Receipts-Studio`
8
+ Hugging Face: `drowzeys/keys-Auto-Receipts-Studio`
9
+
10
+ ## One-shot (Linux GPU box — NVIDIA + vLLM)
11
+
12
+ This is the Spark/Omen path. It installs the app, fetches **google/gemma-4-12B-it** if missing, starts vLLM at **`--gpu-memory-utilization 0.15`** (FP8, max-model-len 8192, never above 0.85), and opens the UI.
13
+
14
+ ```bash
15
+ git clone https://github.com/drowzeys/keys-Auto-Receipts-Studio.git
16
+ cd keys-Auto-Receipts-Studio
17
+ bash oneshot.sh
18
+ ```
19
+
20
+ When it prints READY:
21
+
22
+ | | |
23
+ |---|---|
24
+ | Review | http://127.0.0.1:7860 |
25
+ | iPhone (same Wi‑Fi, **Safari**) | http://&lt;this-pc-lan-ip&gt;:7860/phone |
26
+
27
+ `hf auth login` once if the Gemma weights are not already at `~/models-gemma4-12b-it`.
28
+
29
+ Desktop icon after that: `bash scripts/install-launcher.sh`
30
+
31
+ ## Windows (PC)
32
+
33
+ 12B does **not** run on a typical Windows GPU from this script. Run Gemma on a Linux NVIDIA box (`oneshot.sh` there), then this PC is the UI + iPhone hotspot.
34
+
35
+ 1. Install [Python 3.12](https://www.python.org/downloads/) — check **Add python.exe to PATH**.
36
+ 2. Double-click `oneshot.bat` (or `scripts\start-ui.bat`).
37
+ 3. Edit `.env`:
38
+
39
+ ```
40
+ RECEIPT_LLM_BASE_URL=http://<spark-lan-ip>:8080/v1
41
+ RECEIPT_EMBED_BASE_URL=http://<spark-lan-ip>:8080/v1
42
+ RECEIPT_LLM_MODEL=google/gemma-4-12B-it
43
+ RECEIPT_EMBED_MODEL=google/gemma-4-12B-it
44
+ RECEIPT_EMBED_DIM=3840
45
+ ```
46
+
47
+ 4. Phone: `http://<this-windows-lan-ip>:7860/phone`
48
+
49
+ If this PC **is** an NVIDIA box with `vllm` on PATH, set the same `.env` to `127.0.0.1:8080` and run `bash scripts/serve-gemma.sh` from Git Bash, or install WSL2 and use the Linux one-shot.
50
+
51
+ ## macOS
52
+
53
+ Same split: Gemma on the Linux GPU box; Mac is UI + iPhone.
54
+
55
+ ```bash
56
+ git clone https://github.com/drowzeys/keys-Auto-Receipts-Studio.git
57
+ cd keys-Auto-Receipts-Studio
58
+ python3 -m venv .venv
59
+ .venv/bin/pip install -e ".[dev]"
60
+ cp .env.example .env
61
+ # point RECEIPT_LLM_BASE_URL at the Spark, then:
62
+ bash scripts/install-launcher.sh
63
+ ```
64
+
65
+ Double-click **Desktop → Receipt Studio.command** (first time: right-click → **Open**).
66
+
67
+ Apple Silicon will not load 12B next to this app as vLLM-NVIDIA. Use the Spark.
68
+
69
+ ## Linux without NVIDIA
70
+
71
+ Same as Mac: `bash oneshot.sh` will install the UI and skip/fail vLLM if `vllm` is missing. Point `.env` at a machine that already serves Gemma.
72
+
73
+ ---
74
+
75
+ Built-in **Autonomous OS** skill for **Autonomous Lamp**: hold up a receipt; Lamp snapshots; the GPU box runs Gemma 4 12B-it. Also a Mac / Windows / Linux inbox (phone upload + Gradio).
76
+
77
+ ## Does Gemma 4 12B Unified fit on the Lamp?
78
+
79
+ **No.** Lamp is 8-core ARM64 with **6 GB RAM**. Gemma 4 12B Unified is a 12B dense omni model (`Gemma4UnifiedForConditionalGeneration`, hidden size **3840**). Weights alone will not boot beside Autonomous OS + HAL.
80
+
81
+ | Piece | Fits on Lamp? | Fits on GPU box? |
82
+ |---|---|---|
83
+ | `skills/keys-receipt-scanner/` (this skill) | yes — built-in skill format | yes |
84
+ | HAL `GET /camera/snapshot` | yes | n/a |
85
+ | SQLite + sqlite-vec + HTTP client | yes | yes |
86
+ | **Gemma 4 12B Unified weights** | **no** | yes (vLLM, util **0.15**) |
87
+ | Qwen3.8-27B ADay777 VLM | no | yes |
88
+ | Nemotron-3-Embed-1B | no | yes |
89
+
90
+ The skill is **built-in to Autonomous OS** (markdown + `skill.json` `capabilities: ["vision"]`). The brain is remote. Intern has no camera, so this skill will not install there.
91
+
92
+ ## Built-in skill (Lamp)
93
+
94
+ ```
95
+ skills/keys-receipt-scanner/
96
+ SKILL.md # agent instructions + HAL camera contract
97
+ skill.json # {"capabilities": ["vision"]}
98
+ scripts/scan.py
99
+ ```
100
+
101
+ Install onto a robot (no reboot):
102
+
103
+ ```bash
104
+ make push-skill SKILL=./skills/keys-receipt-scanner TARGET=pi@lamp-xxxx.local
105
+ ```
106
+
107
+ On the Lamp, point the brain at the GPU box:
108
+
109
+ ```bash
110
+ export RECEIPT_LLM_BASE_URL=http://<spark-or-omen>:8080/v1
111
+ export RECEIPT_EMBED_BASE_URL=http://<spark-or-omen>:8080/v1
112
+ export RECEIPT_LLM_MODEL=google/gemma-4-12B-it
113
+ export RECEIPT_EMBED_MODEL=google/gemma-4-12B-it
114
+ export RECEIPT_EMBED_DIM=3840
115
+ export RECEIPT_EMBED_BACKEND=omni
116
+ ```
117
+
118
+ Say **“scan this receipt”** while holding paper to the camera.
119
+
120
+ To ship it in a fork of [autonomous-os](https://github.com/autonomous-ai/autonomous-os): copy `skills/keys-receipt-scanner/` into `skills/`, run their `python skills/skill-creator/scripts/quick_validate.py`, `make skills-catalog`, open the PR.
121
+
122
+ ## GPU box — Gemma 4 12B Unified (omni)
123
+
124
+ One server for vision extract **and** embeddings. Do not raise util above **0.85**.
125
+
126
+ ```bash
127
+ bash scripts/serve-gemma.sh
128
+ # util 0.15, FP8, max-model-len 8192 (or: bash oneshot.sh)
129
+ ```
130
+
131
+ If `/v1/embeddings` 404s on a generate-only runner, either:
132
+
133
+ - serve a pooling convert on another port and set `RECEIPT_EMBED_BASE_URL`, or
134
+ - set `RECEIPT_EMBED_BACKEND=nvidia`, `RECEIPT_EMBED_DIM=2048`, and run Nemotron-3-Embed-1B. **Never mix 3840 and 2048 in one DB.**
135
+
136
+ ### Fallback: Qwen3.8-27B ADay777 (vision only)
137
+
138
+ ```bash
139
+ export RECEIPT_LLM_BACKEND=nvidia
140
+ export RECEIPT_LLM_BASE_URL=http://127.0.0.1:8078/v1
141
+ export RECEIPT_LLM_MODEL=qwen38-nvfp4
142
+ # thinking off is sent automatically
143
+ ```
144
+
145
+ Lightning is **text-only**. This skill will not attach images to it.
146
+
147
+ ## One-click (Windows / macOS / Linux)
148
+
149
+ Does **not** start Gemma. Point `.env` at the GPU box, then double-click:
150
+
151
+ | OS | One-click |
152
+ |---|---|
153
+ | **Linux** | `bash scripts/install-launcher.sh` once → Desktop **Receipt Studio** |
154
+ | **macOS** | `bash scripts/install-launcher.sh` once → Desktop **Receipt Studio.command** (first time: right-click → Open) |
155
+ | **Windows** | Copy `scripts/start-ui.bat` to the Desktop (or double-click it in the repo). First run creates `.venv`. |
156
+
157
+ Same entry from a terminal:
158
+
159
+ ```bash
160
+ # Linux / macOS
161
+ ./scripts/start-ui.sh
162
+
163
+ # Windows
164
+ scripts\start-ui.bat
165
+ ```
166
+
167
+ That starts the UI on the LAN if needed and opens **Review** in the browser. Phone page: `http://<this-machine-lan-ip>:7860/phone`.
168
+
169
+ On a **CUDA box** (this Spark), the same click also starts Gemma 4 12B if `:8080` is down:
170
+
171
+ - `--gpu-memory-utilization **0.15**` (~18.3 GiB of 121.7 GiB; never above **0.85**)
172
+ - **FP8** — BF16 weights are ~23GB and cannot fit in that pool
173
+ - `--max-model-len **8192**` (receipt photo is ~280 vision tokens + JSON)
174
+
175
+ Context at util 0.15 (after ~12.5GB FP8 weights):
176
+
177
+ | Estimate | Tokens |
178
+ |---|---|
179
+ | Conservative (all 48 layers full attn) | **~12k** |
180
+ | Hybrid (8 full + 40× sliding-1024) | **~65k** |
181
+ | Model native window | 262,144 (not at 0.15) |
182
+
183
+ A receipt scan uses ~1–2k tokens. Raise `RECEIPT_VLLM_MAX_MODEL_LEN` only after a boot log shows `GPU KV cache size` large enough.
184
+
185
+ If Gemma is already running, the launcher leaves it alone. To apply the 15GB cap, stop the current `vllm` process, then click again.
186
+
187
+ ```bash
188
+ # apply 15GB cap (stops the current unconstrained serve)
189
+ pkill -x vllm # only if you intend to restart it
190
+ ./scripts/start-ui.sh
191
+ ```
192
+
193
+ On a Mac/Windows laptop with no GPU, set in `.env`:
194
+
195
+ ```
196
+ RECEIPT_LLM_BASE_URL=http://<spark-lan-ip>:8080/v1
197
+ RECEIPT_EMBED_BASE_URL=http://<spark-lan-ip>:8080/v1
198
+ ```
199
+
200
+ ## Desktop / phone (same pipeline)
201
+
202
+ ```bash
203
+ python -m venv .venv && source .venv/bin/activate
204
+ pip install -e ".[dev]"
205
+ cp .env.example .env
206
+ python -m app.cli ui
207
+ ```
208
+
209
+ - Inbox watcher: files idle **30s** in `inbox/` then process
210
+ - Phone on LAN: `RECEIPT_UI_SHARE_LAN=true` → `http://<lan-ip>:7860/phone`
211
+ - Syncthing: phone camera/share folder → `inbox/`
212
+
213
+ ```bash
214
+ python -m app.cli scan --image path/to/receipt.jpg
215
+ python -m app.cli query --category groceries
216
+ pytest
217
+ ```
218
+
219
+ ## What you still run yourself
220
+
221
+ - Serve Gemma 4 12B Unified (or Qwen) on the GPU box
222
+ - Pair Lamp on Wi-Fi via the Autonomous app
223
+ - `make push-skill` (or Skill Store / PR) for the built-in skill
224
+ - Optional: Syncthing on iOS/Android
225
+
226
+ No model weights in this repo. No PyInstaller in this release.
app/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """keys-automatic-receipt-doc-scanner"""
2
+
3
+ __version__ = "1.0.0a1"
app/__main__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
app/camera.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ from app.config import Settings
8
+
9
+
10
+ class CameraError(RuntimeError):
11
+ pass
12
+
13
+
14
+ def snapshot(settings: Settings, *, client: httpx.Client | None = None) -> Path:
15
+ """HAL snapshot used by Autonomous Lamp. Returns the saved JPEG path."""
16
+ url = (
17
+ f"{settings.camera_url.rstrip('/')}/camera/snapshot"
18
+ f"?save=true&width={settings.snapshot_width}&quality={settings.snapshot_quality}"
19
+ )
20
+ own = client is None
21
+ http = client or httpx.Client(timeout=30.0)
22
+ try:
23
+ response = http.get(url)
24
+ response.raise_for_status()
25
+ payload = response.json()
26
+ except httpx.HTTPError as exc:
27
+ raise CameraError(f"Lamp camera snapshot failed: {exc}") from exc
28
+ finally:
29
+ if own:
30
+ http.close()
31
+ path = payload.get("path") if isinstance(payload, dict) else None
32
+ if not path:
33
+ raise CameraError(f"snapshot JSON missing path: {payload!r}")
34
+ return Path(path)
app/cli.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from app.camera import snapshot
10
+ from app.config import load_settings
11
+ from app.db import get_receipt, list_line_items, list_receipts, open_db
12
+ from app.pipeline import process_file
13
+ from app.schemas import ProcessResult
14
+
15
+
16
+ def _print_result(result: ProcessResult) -> None:
17
+ payload = result.model_dump(mode="json")
18
+ json.dump(payload, sys.stdout, indent=2)
19
+ sys.stdout.write("\n")
20
+
21
+
22
+ def cmd_scan(args: argparse.Namespace) -> int:
23
+ settings = load_settings()
24
+ if args.image:
25
+ image = Path(args.image)
26
+ else:
27
+ image = snapshot(settings)
28
+ if args.inbox:
29
+ dest = settings.inbox_dir / image.name
30
+ shutil.copy2(image, dest)
31
+ image = dest
32
+ result = process_file(image, settings)
33
+ _print_result(result)
34
+ return 0 if result.status.value not in {"failed"} else 1
35
+
36
+
37
+ def cmd_query(args: argparse.Namespace) -> int:
38
+ settings = load_settings()
39
+ con = open_db(settings)
40
+ try:
41
+ rows = list_receipts(con, status=args.status, limit=args.limit)
42
+ out = []
43
+ for row in rows:
44
+ item = dict(row)
45
+ if args.category and item.get("category") != args.category:
46
+ continue
47
+ if args.vendor and (item.get("vendor") or "").lower().find(args.vendor.lower()) < 0:
48
+ continue
49
+ item["line_items"] = [dict(x) for x in list_line_items(con, int(row["id"]))]
50
+ out.append(item)
51
+ json.dump(out, sys.stdout, indent=2, default=str)
52
+ sys.stdout.write("\n")
53
+ finally:
54
+ con.close()
55
+ return 0
56
+
57
+
58
+ def cmd_show(args: argparse.Namespace) -> int:
59
+ settings = load_settings()
60
+ con = open_db(settings)
61
+ try:
62
+ row = get_receipt(con, args.id)
63
+ if row is None:
64
+ print(f"not found: {args.id}", file=sys.stderr)
65
+ return 1
66
+ payload = dict(row)
67
+ payload["line_items"] = [dict(x) for x in list_line_items(con, args.id)]
68
+ json.dump(payload, sys.stdout, indent=2, default=str)
69
+ sys.stdout.write("\n")
70
+ finally:
71
+ con.close()
72
+ return 0
73
+
74
+
75
+ def cmd_snapshot(_args: argparse.Namespace) -> int:
76
+ settings = load_settings()
77
+ path = snapshot(settings)
78
+ print(path)
79
+ return 0
80
+
81
+
82
+ def cmd_ui(_args: argparse.Namespace) -> int:
83
+ from app.ui import main
84
+
85
+ main()
86
+ return 0
87
+
88
+
89
+ def build_parser() -> argparse.ArgumentParser:
90
+ parser = argparse.ArgumentParser(prog="keys-scan")
91
+ sub = parser.add_subparsers(dest="cmd", required=True)
92
+
93
+ scan = sub.add_parser("scan", help="process one image (Lamp camera if omitted)")
94
+ scan.add_argument("--image", help="path to jpeg/png/pdf; omit to snapshot Lamp camera")
95
+ scan.add_argument("--inbox", action="store_true", help="copy into inbox/ first")
96
+ scan.set_defaults(func=cmd_scan)
97
+
98
+ query = sub.add_parser("query", help="list stored receipts")
99
+ query.add_argument("--status")
100
+ query.add_argument("--category")
101
+ query.add_argument("--vendor")
102
+ query.add_argument("--limit", type=int, default=50)
103
+ query.set_defaults(func=cmd_query)
104
+
105
+ show = sub.add_parser("show", help="show one receipt")
106
+ show.add_argument("id", type=int)
107
+ show.set_defaults(func=cmd_show)
108
+
109
+ snap = sub.add_parser("snapshot", help="HAL camera snapshot only")
110
+ snap.set_defaults(func=cmd_snapshot)
111
+
112
+ ui = sub.add_parser("ui", help="Gradio review UI")
113
+ ui.set_defaults(func=cmd_ui)
114
+ return parser
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> None:
118
+ parser = build_parser()
119
+ args = parser.parse_args(argv)
120
+ raise SystemExit(args.func(args))
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
app/config.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ ROOT = Path(__file__).resolve().parent.parent
8
+
9
+ CATEGORIES = (
10
+ "groceries",
11
+ "dining",
12
+ "transport",
13
+ "household",
14
+ "health",
15
+ "entertainment",
16
+ "utilities",
17
+ "office",
18
+ "travel",
19
+ "other",
20
+ )
21
+
22
+ DOC_KINDS = ("receipt", "invoice", "document")
23
+
24
+ ACCEPTED_SUFFIXES = {
25
+ ".jpg",
26
+ ".jpeg",
27
+ ".png",
28
+ ".webp",
29
+ ".heic",
30
+ ".heif",
31
+ ".tif",
32
+ ".tiff",
33
+ ".pdf",
34
+ ".txt",
35
+ }
36
+
37
+
38
+ class Settings(BaseSettings):
39
+ model_config = SettingsConfigDict(
40
+ env_prefix="RECEIPT_",
41
+ env_file=str(ROOT / ".env"),
42
+ env_file_encoding="utf-8",
43
+ extra="ignore",
44
+ )
45
+
46
+ root_dir: Path = ROOT
47
+ data_dir: Path = ROOT / "data"
48
+ inbox_dir: Path = ROOT / "inbox"
49
+ processing_dir: Path = ROOT / "processing"
50
+ processed_dir: Path = ROOT / "processed"
51
+ failed_dir: Path = ROOT / "failed"
52
+ exports_dir: Path = ROOT / "exports"
53
+
54
+ idle_seconds: float = 30.0
55
+
56
+ # Gemma 4 12B Unified is the default omni brain (vision extract + embed).
57
+ # It does not fit on the Lamp (6 GB). Point these URLs at the GPU box.
58
+ llm_backend: str = "gemma"
59
+ llm_base_url: str = "http://127.0.0.1:8080/v1"
60
+ llm_model: str = "google/gemma-4-12B-it"
61
+ llm_api_key: str = "local"
62
+ llm_accepts_images: bool = True
63
+ llm_max_tokens: int = 8192
64
+ llm_timeout_s: float = 180.0
65
+
66
+ embed_backend: str = "omni"
67
+ embed_base_url: str = ""
68
+ embed_model: str = "google/gemma-4-12B-it"
69
+ embed_dim: int = 3840
70
+ embed_api_key: str = "local"
71
+ embed_timeout_s: float = 120.0
72
+ embed_prefix: bool = True
73
+
74
+ ocr_backend: str = "none"
75
+ ocr_base_url: str = "http://127.0.0.1:11434/v1"
76
+ ocr_model: str = ""
77
+ ocr_api_key: str = "local"
78
+
79
+ camera_url: str = "http://127.0.0.1:5001"
80
+ snapshot_width: int = 1280
81
+ snapshot_quality: int = 85
82
+
83
+ sku_auto: float = 0.88
84
+ sku_review: float = 0.72
85
+ vendor_auto: float = 0.82
86
+ vendor_review: float = 0.65
87
+
88
+ ui_host: str = "127.0.0.1"
89
+ ui_port: int = 7860
90
+ ui_share_lan: bool = False
91
+
92
+ jpeg_max_edge: int = 2048
93
+
94
+ def model_post_init(self, _context: object) -> None:
95
+ if not self.embed_base_url:
96
+ object.__setattr__(self, "embed_base_url", self.llm_base_url)
97
+ if not self.embed_model:
98
+ object.__setattr__(self, "embed_model", self.llm_model)
99
+
100
+ @property
101
+ def db_path(self) -> Path:
102
+ return self.data_dir / "receipts.db"
103
+
104
+ def ensure_dirs(self) -> None:
105
+ for path in (
106
+ self.data_dir,
107
+ self.inbox_dir,
108
+ self.processing_dir,
109
+ self.processed_dir,
110
+ self.failed_dir,
111
+ self.exports_dir,
112
+ ):
113
+ path.mkdir(parents=True, exist_ok=True)
114
+
115
+
116
+ def load_settings(**overrides: object) -> Settings:
117
+ settings = Settings(**overrides)
118
+ settings.ensure_dirs()
119
+ return settings
app/db.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Iterator
8
+
9
+ from app.config import Settings
10
+ from app.schemas import MatchHit, ReceiptExtract, ReceiptStatus, to_cents
11
+
12
+ try:
13
+ import sqlite_vec
14
+ from sqlite_vec import serialize_float32
15
+ except ImportError: # pragma: no cover
16
+ sqlite_vec = None
17
+ serialize_float32 = None # type: ignore[assignment]
18
+
19
+
20
+ class VecLoadError(RuntimeError):
21
+ pass
22
+
23
+
24
+ class EmbedIndexError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _utc_now() -> str:
29
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
30
+
31
+
32
+ def connect(path: Path) -> sqlite3.Connection:
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ con = sqlite3.connect(str(path))
35
+ con.row_factory = sqlite3.Row
36
+ con.execute("PRAGMA foreign_keys = ON")
37
+ if sqlite_vec is None:
38
+ raise VecLoadError("sqlite-vec is not installed")
39
+ try:
40
+ con.enable_load_extension(True)
41
+ sqlite_vec.load(con)
42
+ con.enable_load_extension(False)
43
+ except Exception as exc:
44
+ con.close()
45
+ raise VecLoadError(f"sqlite-vec load failed: {exc}") from exc
46
+ return con
47
+
48
+
49
+ def _create_vec_table(con: sqlite3.Connection, name: str, pk: str, dim: int) -> None:
50
+ ddl = (
51
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS {name} USING vec0("
52
+ f"{pk} INTEGER PRIMARY KEY, embedding float[{dim}] distance_metric=cosine)"
53
+ )
54
+ try:
55
+ con.execute(ddl)
56
+ except sqlite3.OperationalError:
57
+ con.execute(
58
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS {name} USING vec0("
59
+ f"{pk} INTEGER PRIMARY KEY, embedding float[{dim}])"
60
+ )
61
+
62
+
63
+ def init_schema(con: sqlite3.Connection, settings: Settings) -> None:
64
+ con.executescript(
65
+ """
66
+ CREATE TABLE IF NOT EXISTS meta (
67
+ key TEXT PRIMARY KEY,
68
+ value TEXT NOT NULL
69
+ );
70
+ CREATE TABLE IF NOT EXISTS receipts (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ source_path TEXT NOT NULL,
73
+ sha256 TEXT NOT NULL UNIQUE,
74
+ status TEXT NOT NULL,
75
+ doc_kind TEXT,
76
+ category TEXT,
77
+ vendor TEXT,
78
+ receipt_date TEXT,
79
+ tax_cents INTEGER,
80
+ total_cents INTEGER,
81
+ currency TEXT,
82
+ ocr_text TEXT,
83
+ extract_json TEXT,
84
+ error TEXT,
85
+ created_at TEXT NOT NULL,
86
+ updated_at TEXT NOT NULL
87
+ );
88
+ CREATE INDEX IF NOT EXISTS idx_receipts_category ON receipts(category);
89
+ CREATE INDEX IF NOT EXISTS idx_receipts_vendor ON receipts(vendor);
90
+ CREATE INDEX IF NOT EXISTS idx_receipts_date ON receipts(receipt_date);
91
+ CREATE TABLE IF NOT EXISTS line_items (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ receipt_id INTEGER NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
94
+ description TEXT NOT NULL,
95
+ qty REAL,
96
+ unit_price_cents INTEGER,
97
+ amount_cents INTEGER,
98
+ sku TEXT,
99
+ match_catalog_id INTEGER,
100
+ match_score REAL,
101
+ match_status TEXT
102
+ );
103
+ CREATE TABLE IF NOT EXISTS catalog (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ sku TEXT,
106
+ vendor TEXT,
107
+ description TEXT NOT NULL,
108
+ size TEXT,
109
+ unit_price_cents INTEGER,
110
+ metadata_json TEXT
111
+ );
112
+ """
113
+ )
114
+ _create_vec_table(con, "receipt_vec", "receipt_id", settings.embed_dim)
115
+ _create_vec_table(con, "catalog_vec", "catalog_id", settings.embed_dim)
116
+ _check_or_set_meta(con, settings)
117
+ con.commit()
118
+
119
+
120
+ def _meta(con: sqlite3.Connection, key: str) -> str | None:
121
+ row = con.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
122
+ return None if row is None else str(row["value"])
123
+
124
+
125
+ def _check_or_set_meta(con: sqlite3.Connection, settings: Settings) -> None:
126
+ stored_model = _meta(con, "embed_model")
127
+ stored_dim = _meta(con, "embed_dim")
128
+ if stored_model is None:
129
+ con.execute(
130
+ "INSERT INTO meta(key, value) VALUES ('embed_model', ?), ('embed_dim', ?)",
131
+ (settings.embed_model, str(settings.embed_dim)),
132
+ )
133
+ return
134
+ if stored_model != settings.embed_model or stored_dim != str(settings.embed_dim):
135
+ raise EmbedIndexError(
136
+ f"index is {stored_model} dim={stored_dim}; config is "
137
+ f"{settings.embed_model} dim={settings.embed_dim}. Never mix embedding models."
138
+ )
139
+
140
+
141
+ def open_db(settings: Settings) -> sqlite3.Connection:
142
+ con = connect(settings.db_path)
143
+ init_schema(con, settings)
144
+ return con
145
+
146
+
147
+ def get_by_sha(con: sqlite3.Connection, sha256: str) -> sqlite3.Row | None:
148
+ return con.execute("SELECT * FROM receipts WHERE sha256 = ?", (sha256,)).fetchone()
149
+
150
+
151
+ def insert_receipt(
152
+ con: sqlite3.Connection,
153
+ *,
154
+ source_path: str,
155
+ sha256: str,
156
+ status: ReceiptStatus,
157
+ extract: ReceiptExtract | None = None,
158
+ ocr_text: str | None = None,
159
+ error: str | None = None,
160
+ ) -> int:
161
+ now = _utc_now()
162
+ cur = con.execute(
163
+ """
164
+ INSERT INTO receipts (
165
+ source_path, sha256, status, doc_kind, category, vendor, receipt_date,
166
+ tax_cents, total_cents, currency, ocr_text, extract_json, error,
167
+ created_at, updated_at
168
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
169
+ """,
170
+ (
171
+ source_path,
172
+ sha256,
173
+ status.value,
174
+ None if extract is None else extract.doc_kind,
175
+ None if extract is None else extract.category,
176
+ None if extract is None else extract.vendor,
177
+ None if extract is None else (extract.date.isoformat() if extract.date else None),
178
+ None if extract is None else to_cents(extract.tax),
179
+ None if extract is None else to_cents(extract.total),
180
+ None if extract is None else extract.currency,
181
+ ocr_text,
182
+ None if extract is None else extract.model_dump_json(),
183
+ error,
184
+ now,
185
+ now,
186
+ ),
187
+ )
188
+ receipt_id = int(cur.lastrowid)
189
+ if extract is not None:
190
+ for item in extract.line_items:
191
+ con.execute(
192
+ """
193
+ INSERT INTO line_items (
194
+ receipt_id, description, qty, unit_price_cents, amount_cents, sku
195
+ ) VALUES (?, ?, ?, ?, ?, ?)
196
+ """,
197
+ (
198
+ receipt_id,
199
+ item.description,
200
+ item.qty,
201
+ to_cents(item.unit_price),
202
+ to_cents(item.amount),
203
+ item.sku,
204
+ ),
205
+ )
206
+ con.commit()
207
+ return receipt_id
208
+
209
+
210
+ def update_receipt_status(
211
+ con: sqlite3.Connection,
212
+ receipt_id: int,
213
+ status: ReceiptStatus,
214
+ *,
215
+ error: str | None = None,
216
+ ) -> None:
217
+ con.execute(
218
+ "UPDATE receipts SET status = ?, error = ?, updated_at = ? WHERE id = ?",
219
+ (status.value, error, _utc_now(), receipt_id),
220
+ )
221
+ con.commit()
222
+
223
+
224
+ def update_extract(
225
+ con: sqlite3.Connection,
226
+ receipt_id: int,
227
+ extract: ReceiptExtract,
228
+ status: ReceiptStatus,
229
+ ) -> None:
230
+ con.execute("DELETE FROM line_items WHERE receipt_id = ?", (receipt_id,))
231
+ con.execute(
232
+ """
233
+ UPDATE receipts SET
234
+ status = ?, doc_kind = ?, category = ?, vendor = ?, receipt_date = ?,
235
+ tax_cents = ?, total_cents = ?, currency = ?, extract_json = ?,
236
+ error = NULL, updated_at = ?
237
+ WHERE id = ?
238
+ """,
239
+ (
240
+ status.value,
241
+ extract.doc_kind,
242
+ extract.category,
243
+ extract.vendor,
244
+ extract.date.isoformat() if extract.date else None,
245
+ to_cents(extract.tax),
246
+ to_cents(extract.total),
247
+ extract.currency,
248
+ extract.model_dump_json(),
249
+ _utc_now(),
250
+ receipt_id,
251
+ ),
252
+ )
253
+ for item in extract.line_items:
254
+ con.execute(
255
+ """
256
+ INSERT INTO line_items (
257
+ receipt_id, description, qty, unit_price_cents, amount_cents, sku
258
+ ) VALUES (?, ?, ?, ?, ?, ?)
259
+ """,
260
+ (
261
+ receipt_id,
262
+ item.description,
263
+ item.qty,
264
+ to_cents(item.unit_price),
265
+ to_cents(item.amount),
266
+ item.sku,
267
+ ),
268
+ )
269
+ con.commit()
270
+
271
+
272
+ def delete_receipt(con: sqlite3.Connection, receipt_id: int, *, unlink_file: bool = True) -> bool:
273
+ row = con.execute(
274
+ "SELECT source_path FROM receipts WHERE id = ?", (receipt_id,)
275
+ ).fetchone()
276
+ if row is None:
277
+ return False
278
+ try:
279
+ con.execute("DELETE FROM receipt_vec WHERE receipt_id = ?", (receipt_id,))
280
+ except sqlite3.Error:
281
+ pass
282
+ con.execute("DELETE FROM receipts WHERE id = ?", (receipt_id,))
283
+ con.commit()
284
+ if unlink_file:
285
+ path = Path(row["source_path"] or "")
286
+ if path.is_file():
287
+ try:
288
+ path.unlink()
289
+ except OSError:
290
+ pass
291
+ return True
292
+
293
+
294
+ def set_line_match(
295
+ con: sqlite3.Connection,
296
+ line_id: int,
297
+ hit: MatchHit,
298
+ ) -> None:
299
+ con.execute(
300
+ """
301
+ UPDATE line_items SET match_catalog_id = ?, match_score = ?, match_status = ?
302
+ WHERE id = ?
303
+ """,
304
+ (hit.catalog_id, hit.similarity, hit.band.value, line_id),
305
+ )
306
+ con.commit()
307
+
308
+
309
+ def list_receipts(con: sqlite3.Connection, *, status: str | None = None, limit: int = 50) -> list[sqlite3.Row]:
310
+ if status:
311
+ return list(
312
+ con.execute(
313
+ "SELECT * FROM receipts WHERE status = ? ORDER BY id DESC LIMIT ?",
314
+ (status, limit),
315
+ )
316
+ )
317
+ return list(con.execute("SELECT * FROM receipts ORDER BY id DESC LIMIT ?", (limit,)))
318
+
319
+
320
+ def get_receipt(con: sqlite3.Connection, receipt_id: int) -> sqlite3.Row | None:
321
+ return con.execute("SELECT * FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
322
+
323
+
324
+ def list_line_items(con: sqlite3.Connection, receipt_id: int) -> list[sqlite3.Row]:
325
+ return list(
326
+ con.execute("SELECT * FROM line_items WHERE receipt_id = ? ORDER BY id", (receipt_id,))
327
+ )
328
+
329
+
330
+ def add_catalog_item(
331
+ con: sqlite3.Connection,
332
+ *,
333
+ description: str,
334
+ sku: str | None = None,
335
+ vendor: str | None = None,
336
+ size: str | None = None,
337
+ unit_price_cents: int | None = None,
338
+ metadata: dict[str, Any] | None = None,
339
+ ) -> int:
340
+ cur = con.execute(
341
+ """
342
+ INSERT INTO catalog (sku, vendor, description, size, unit_price_cents, metadata_json)
343
+ VALUES (?, ?, ?, ?, ?, ?)
344
+ """,
345
+ (
346
+ sku,
347
+ vendor,
348
+ description,
349
+ size,
350
+ unit_price_cents,
351
+ None if metadata is None else json.dumps(metadata),
352
+ ),
353
+ )
354
+ con.commit()
355
+ return int(cur.lastrowid)
356
+
357
+
358
+ def list_catalog(con: sqlite3.Connection, limit: int = 200) -> list[sqlite3.Row]:
359
+ return list(con.execute("SELECT * FROM catalog ORDER BY id DESC LIMIT ?", (limit,)))
360
+
361
+
362
+ def find_catalog_by_sku(con: sqlite3.Connection, sku: str) -> sqlite3.Row | None:
363
+ return con.execute(
364
+ "SELECT * FROM catalog WHERE sku = ? COLLATE NOCASE LIMIT 1", (sku,)
365
+ ).fetchone()
366
+
367
+
368
+ def upsert_vector(con: sqlite3.Connection, table: str, pk_col: str, pk: int, vec: list[float]) -> None:
369
+ if serialize_float32 is None:
370
+ raise VecLoadError("sqlite-vec missing")
371
+ blob = serialize_float32(vec)
372
+ con.execute(f"DELETE FROM {table} WHERE {pk_col} = ?", (pk,))
373
+ con.execute(
374
+ f"INSERT INTO {table}({pk_col}, embedding) VALUES (?, ?)",
375
+ (pk, blob),
376
+ )
377
+ con.commit()
378
+
379
+
380
+ def knn(
381
+ con: sqlite3.Connection,
382
+ table: str,
383
+ pk_col: str,
384
+ query: list[float],
385
+ *,
386
+ k: int = 5,
387
+ ) -> list[tuple[int, float]]:
388
+ if serialize_float32 is None:
389
+ raise VecLoadError("sqlite-vec missing")
390
+ blob = serialize_float32(query)
391
+ rows = con.execute(
392
+ f"""
393
+ SELECT {pk_col} AS id, distance
394
+ FROM {table}
395
+ WHERE embedding MATCH ?
396
+ AND k = ?
397
+ """,
398
+ (blob, k),
399
+ ).fetchall()
400
+ return [(int(row["id"]), float(row["distance"])) for row in rows]
401
+
402
+
403
+ def receipt_to_extract(row: sqlite3.Row) -> ReceiptExtract | None:
404
+ raw = row["extract_json"]
405
+ if not raw:
406
+ return None
407
+ return ReceiptExtract.model_validate_json(raw)
408
+
409
+
410
+ def iter_rows(rows: list[sqlite3.Row]) -> Iterator[dict[str, Any]]:
411
+ for row in rows:
412
+ yield dict(row)
app/embed.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.config import Settings
4
+ from app.schemas import LineItem, ReceiptExtract
5
+ from backends.base import EmbedBackend, InputType
6
+ from backends.openai_compat import apply_embed_prefix
7
+
8
+
9
+ def format_embed_input(text: str, input_type: InputType, *, enabled: bool = True) -> str:
10
+ return apply_embed_prefix(text, input_type, enabled=enabled)
11
+
12
+
13
+ def line_query_text(extract: ReceiptExtract, item: LineItem) -> str:
14
+ parts = [
15
+ extract.vendor or "",
16
+ item.sku or "",
17
+ item.description,
18
+ f"qty {item.qty}" if item.qty is not None else "",
19
+ f"amount {item.amount}" if item.amount is not None else "",
20
+ ]
21
+ return " | ".join(p for p in parts if p)
22
+
23
+
24
+ def catalog_passage_text(
25
+ *,
26
+ vendor: str | None,
27
+ sku: str | None,
28
+ description: str,
29
+ size: str | None = None,
30
+ ) -> str:
31
+ parts = [vendor or "", sku or "", description, size or ""]
32
+ return " | ".join(p for p in parts if p)
33
+
34
+
35
+ def vendor_query_text(vendor: str) -> str:
36
+ return vendor.strip()
37
+
38
+
39
+ def embed_texts(
40
+ backend: EmbedBackend,
41
+ texts: list[str],
42
+ *,
43
+ input_type: InputType,
44
+ settings: Settings | None = None,
45
+ ) -> list[list[float]]:
46
+ del settings
47
+ return backend.embed(texts, input_type=input_type)
app/extract.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.config import CATEGORIES, DOC_KINDS, Settings
4
+ from app.schemas import ReceiptExtract, parse_extract_json
5
+ from backends.base import LLMBackend
6
+
7
+ SYSTEM = (
8
+ "You extract structured data from a photo of a receipt, invoice, or paper document. "
9
+ "Reply with a single JSON object only — no markdown, no commentary, no trailing text."
10
+ )
11
+
12
+ _SCHEMA_HINT = f"""
13
+ Required keys:
14
+ doc_kind: one of {list(DOC_KINDS)}
15
+ category: one of {list(CATEGORIES)}
16
+ vendor: string or null
17
+ date: YYYY-MM-DD or null
18
+ tax: number or null
19
+ total: number or null
20
+ currency: string or null (ISO 4217 if known)
21
+ line_items: array of objects with description (string), qty (number|null),
22
+ unit_price (number|null), amount (number|null), sku (string|null)
23
+
24
+ Rules:
25
+ - Money as numbers, not strings. Unknown fields must be null.
26
+ - Do not invent SKUs, vendors, or totals. If unreadable, use null.
27
+ - Prefer the printed total over summing line items when they disagree.
28
+ - category is the spend bucket (groceries, dining, …), not the store name.
29
+ """.strip()
30
+
31
+
32
+ def build_user_prompt(*, ocr_text: str | None, hint: str | None = None) -> str:
33
+ parts = [_SCHEMA_HINT]
34
+ if ocr_text:
35
+ parts.append("OCR assist (may be noisy):\n" + ocr_text.strip()[:8000])
36
+ if hint:
37
+ parts.append(hint)
38
+ parts.append("Extract the JSON now.")
39
+ return "\n\n".join(parts)
40
+
41
+
42
+ def extract_receipt(
43
+ llm: LLMBackend,
44
+ *,
45
+ settings: Settings,
46
+ image_jpeg: bytes | None,
47
+ ocr_text: str | None,
48
+ ) -> ReceiptExtract:
49
+ del settings
50
+ if image_jpeg and not llm.accepts_images:
51
+ image_jpeg = None
52
+ if image_jpeg is None and not (ocr_text and ocr_text.strip()):
53
+ raise ValueError("need an image (vision LLM) or OCR/text to extract")
54
+ user = build_user_prompt(ocr_text=ocr_text)
55
+ raw = llm.complete_json(system=SYSTEM, user=user, image_jpeg=image_jpeg)
56
+ try:
57
+ return parse_extract_json(raw)
58
+ except (ValueError, Exception) as first:
59
+ retry = build_user_prompt(
60
+ ocr_text=ocr_text,
61
+ hint=f"Previous output failed validation: {first}. Return corrected JSON only.",
62
+ )
63
+ raw2 = llm.complete_json(system=SYSTEM, user=retry, image_jpeg=image_jpeg)
64
+ return parse_extract_json(raw2)
app/launch.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-click start: optional Gemma vLLM (≤15GB), then LAN UI + browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ import webbrowser
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(__file__).resolve().parent.parent
17
+ HOST = "127.0.0.1"
18
+ PORT = int(os.environ.get("RECEIPT_UI_PORT", "7860"))
19
+ VLLM_PORT = int(os.environ.get("RECEIPT_VLLM_PORT", "8080"))
20
+ START_VLLM = os.environ.get("RECEIPT_START_VLLM", "1").lower() not in {"0", "false", "no"}
21
+ MAX_GB = os.environ.get("RECEIPT_VLLM_MAX_GB", "15") # unused if UTIL is set in serve-gemma.sh
22
+
23
+
24
+ def _lan_ip() -> str:
25
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
26
+ try:
27
+ sock.connect(("192.0.2.1", 1))
28
+ return sock.getsockname()[0]
29
+ except OSError:
30
+ return "127.0.0.1"
31
+ finally:
32
+ sock.close()
33
+
34
+
35
+ def _port_up(port: int) -> bool:
36
+ sock = socket.socket()
37
+ sock.settimeout(0.4)
38
+ try:
39
+ sock.connect((HOST, port))
40
+ return True
41
+ except OSError:
42
+ return False
43
+ finally:
44
+ sock.close()
45
+
46
+
47
+ def _vllm_ready() -> bool:
48
+ try:
49
+ with urllib.request.urlopen(
50
+ f"http://127.0.0.1:{VLLM_PORT}/v1/models", timeout=2
51
+ ) as response:
52
+ return response.status == 200
53
+ except (urllib.error.URLError, TimeoutError, OSError):
54
+ return False
55
+
56
+
57
+ def _can_serve_gemma() -> bool:
58
+ if shutil.which("vllm") is None:
59
+ return False
60
+ model = Path(os.environ.get("RECEIPT_GEMMA_PATH", str(Path.home() / "models-gemma4-12b-it")))
61
+ return (model / "config.json").is_file()
62
+
63
+
64
+ def _spawn(cmd: list[str], log: Path, *, bash: bool = False) -> None:
65
+ log.parent.mkdir(parents=True, exist_ok=True)
66
+ creation = 0
67
+ if sys.platform == "win32":
68
+ creation = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
69
+ argv = cmd
70
+ if bash:
71
+ argv = ["bash", *cmd]
72
+ with log.open("a", encoding="utf-8") as handle:
73
+ subprocess.Popen(
74
+ argv,
75
+ cwd=str(ROOT),
76
+ env=os.environ.copy(),
77
+ stdout=handle,
78
+ stderr=handle,
79
+ creationflags=creation,
80
+ start_new_session=sys.platform != "win32",
81
+ )
82
+
83
+
84
+ def _ensure_vllm() -> None:
85
+ if not START_VLLM:
86
+ return
87
+ if _vllm_ready():
88
+ print(f"Gemma already up on :{VLLM_PORT} (not restarted; 15GB cap applies on a fresh serve).")
89
+ return
90
+ if not _can_serve_gemma():
91
+ print("No local vLLM/Gemma — skip serve. Point .env at the GPU box.")
92
+ return
93
+ script = ROOT / "scripts" / "serve-gemma.sh"
94
+ if not script.is_file():
95
+ print(f"missing {script}", file=sys.stderr)
96
+ return
97
+ os.environ.setdefault("RECEIPT_VLLM_MAX_GB", MAX_GB)
98
+ print("Starting Gemma 4 12B vLLM at gpu_memory_utilization=0.15 (FP8, max-model-len 8192)…")
99
+ _spawn([str(script)], ROOT / "data" / "vllm-gemma.log", bash=True)
100
+ for _ in range(120):
101
+ if _vllm_ready():
102
+ print("Gemma ready.")
103
+ return
104
+ time.sleep(5)
105
+ print(
106
+ f"vLLM still starting. Watch {ROOT / 'data' / 'vllm-gemma.log'}",
107
+ file=sys.stderr,
108
+ )
109
+
110
+
111
+ def _spawn_ui() -> None:
112
+ env = os.environ.copy()
113
+ env["RECEIPT_UI_SHARE_LAN"] = "true"
114
+ env.setdefault("RECEIPT_IDLE_SECONDS", "5")
115
+ log = ROOT / "data" / "ui.log"
116
+ log.parent.mkdir(parents=True, exist_ok=True)
117
+ creation = 0
118
+ if sys.platform == "win32":
119
+ creation = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
120
+ with log.open("a", encoding="utf-8") as handle:
121
+ subprocess.Popen(
122
+ [sys.executable, "-m", "app.cli", "ui"],
123
+ cwd=str(ROOT),
124
+ env=env,
125
+ stdout=handle,
126
+ stderr=handle,
127
+ creationflags=creation,
128
+ start_new_session=sys.platform != "win32",
129
+ )
130
+
131
+
132
+ def main() -> None:
133
+ os.chdir(ROOT)
134
+ _ensure_vllm()
135
+ if not _port_up(PORT):
136
+ print("Starting Receipt Studio UI…")
137
+ _spawn_ui()
138
+ for _ in range(40):
139
+ if _port_up(PORT):
140
+ break
141
+ time.sleep(0.25)
142
+ else:
143
+ print(f"UI did not bind :{PORT}. See {ROOT / 'data' / 'ui.log'}", file=sys.stderr)
144
+ raise SystemExit(1)
145
+ lan = _lan_ip()
146
+ review = f"http://127.0.0.1:{PORT}"
147
+ phone = f"http://{lan}:{PORT}/phone"
148
+ print(f"Review: {review}")
149
+ print(f"Phone: {phone}")
150
+ webbrowser.open(review)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
app/match.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ from app.config import Settings
6
+ from app.db import find_catalog_by_sku, knn
7
+ from app.embed import catalog_passage_text, embed_texts, line_query_text, vendor_query_text
8
+ from app.schemas import LineItem, MatchBand, MatchHit, ReceiptExtract
9
+ from backends.base import EmbedBackend
10
+
11
+
12
+ def distance_to_similarity(distance: float) -> float:
13
+ return 1.0 - distance
14
+
15
+
16
+ def band_for(similarity: float, *, auto: float, review: float) -> MatchBand:
17
+ if similarity >= auto:
18
+ return MatchBand.auto
19
+ if similarity >= review:
20
+ return MatchBand.review
21
+ return MatchBand.unmatched
22
+
23
+
24
+ def unmatched(reason: str) -> MatchHit:
25
+ return MatchHit(similarity=0.0, band=MatchBand.unmatched, reason=reason)
26
+
27
+
28
+ def match_line_item(
29
+ con: sqlite3.Connection,
30
+ settings: Settings,
31
+ embed: EmbedBackend,
32
+ extract: ReceiptExtract,
33
+ item: LineItem,
34
+ *,
35
+ k: int = 5,
36
+ ) -> MatchHit:
37
+ if item.sku:
38
+ row = find_catalog_by_sku(con, item.sku)
39
+ if row is not None:
40
+ return MatchHit(
41
+ catalog_id=int(row["id"]),
42
+ sku=row["sku"],
43
+ vendor=row["vendor"],
44
+ description=row["description"],
45
+ similarity=1.0,
46
+ band=MatchBand.exact,
47
+ reason="exact sku",
48
+ )
49
+ catalog_count = con.execute("SELECT COUNT(*) AS n FROM catalog").fetchone()["n"]
50
+ if catalog_count == 0:
51
+ return unmatched("empty catalog")
52
+ query_vec = embed_texts(
53
+ embed, [line_query_text(extract, item)], input_type="query", settings=settings
54
+ )[0]
55
+ hits = knn(con, "catalog_vec", "catalog_id", query_vec, k=k)
56
+ if not hits:
57
+ return unmatched("no vectors")
58
+ catalog_id, distance = hits[0]
59
+ similarity = distance_to_similarity(distance)
60
+ row = con.execute("SELECT * FROM catalog WHERE id = ?", (catalog_id,)).fetchone()
61
+ return MatchHit(
62
+ catalog_id=catalog_id,
63
+ sku=None if row is None else row["sku"],
64
+ vendor=None if row is None else row["vendor"],
65
+ description=None if row is None else row["description"],
66
+ similarity=similarity,
67
+ band=band_for(similarity, auto=settings.sku_auto, review=settings.sku_review),
68
+ reason="knn",
69
+ )
70
+
71
+
72
+ def match_vendor(
73
+ con: sqlite3.Connection,
74
+ settings: Settings,
75
+ embed: EmbedBackend,
76
+ vendor: str,
77
+ *,
78
+ k: int = 5,
79
+ ) -> MatchHit:
80
+ if not vendor.strip():
81
+ return unmatched("no vendor")
82
+ exact = con.execute(
83
+ "SELECT * FROM catalog WHERE vendor = ? COLLATE NOCASE LIMIT 1", (vendor,)
84
+ ).fetchone()
85
+ if exact is not None:
86
+ return MatchHit(
87
+ catalog_id=int(exact["id"]),
88
+ sku=exact["sku"],
89
+ vendor=exact["vendor"],
90
+ description=exact["description"],
91
+ similarity=1.0,
92
+ band=MatchBand.exact,
93
+ reason="exact vendor",
94
+ )
95
+ query_vec = embed_texts(
96
+ embed, [vendor_query_text(vendor)], input_type="query", settings=settings
97
+ )[0]
98
+ hits = knn(con, "catalog_vec", "catalog_id", query_vec, k=k)
99
+ if not hits:
100
+ return unmatched("no vectors")
101
+ catalog_id, distance = hits[0]
102
+ similarity = distance_to_similarity(distance)
103
+ row = con.execute("SELECT * FROM catalog WHERE id = ?", (catalog_id,)).fetchone()
104
+ return MatchHit(
105
+ catalog_id=catalog_id,
106
+ sku=None if row is None else row["sku"],
107
+ vendor=None if row is None else row["vendor"],
108
+ description=None if row is None else row["description"],
109
+ similarity=similarity,
110
+ band=band_for(
111
+ similarity, auto=settings.vendor_auto, review=settings.vendor_review
112
+ ),
113
+ reason="vendor knn",
114
+ )
115
+
116
+
117
+ def match_receipt(
118
+ con: sqlite3.Connection,
119
+ settings: Settings,
120
+ embed: EmbedBackend,
121
+ extract: ReceiptExtract,
122
+ ) -> list[MatchHit]:
123
+ hits = [match_line_item(con, settings, embed, extract, item) for item in extract.line_items]
124
+ if extract.vendor:
125
+ hits.append(match_vendor(con, settings, embed, extract.vendor))
126
+ return hits
127
+
128
+
129
+ def embed_catalog_row(
130
+ con: sqlite3.Connection,
131
+ settings: Settings,
132
+ embed: EmbedBackend,
133
+ catalog_id: int,
134
+ ) -> None:
135
+ from app.db import upsert_vector
136
+
137
+ row = con.execute("SELECT * FROM catalog WHERE id = ?", (catalog_id,)).fetchone()
138
+ if row is None:
139
+ return
140
+ text = catalog_passage_text(
141
+ vendor=row["vendor"],
142
+ sku=row["sku"],
143
+ description=row["description"],
144
+ size=row["size"],
145
+ )
146
+ vec = embed_texts(embed, [text], input_type="passage", settings=settings)[0]
147
+ upsert_vector(con, "catalog_vec", "catalog_id", catalog_id, vec)
app/media.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from pathlib import Path
5
+
6
+ from PIL import Image
7
+
8
+ from app.config import Settings
9
+
10
+ try:
11
+ from pillow_heif import register_heif_opener
12
+
13
+ register_heif_opener()
14
+ except ImportError:
15
+ pass
16
+
17
+
18
+ def _load_heif() -> None:
19
+ try:
20
+ from pillow_heif import register_heif_opener
21
+
22
+ register_heif_opener()
23
+ except ImportError:
24
+ return
25
+
26
+
27
+ def pdf_first_page_jpeg(path: Path, max_edge: int) -> bytes:
28
+ import pypdfium2 as pdfium
29
+
30
+ pdf = pdfium.PdfDocument(str(path))
31
+ try:
32
+ page = pdf[0]
33
+ bitmap = page.render(scale=150 / 72)
34
+ image = bitmap.to_pil().convert("RGB")
35
+ finally:
36
+ pdf.close()
37
+ return _pil_to_jpeg(image, max_edge)
38
+
39
+
40
+ def _pil_to_jpeg(image: Image.Image, max_edge: int) -> bytes:
41
+ rgb = image.convert("RGB")
42
+ rgb.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
43
+ buf = io.BytesIO()
44
+ rgb.save(buf, format="JPEG", quality=85, optimize=True)
45
+ return buf.getvalue()
46
+
47
+
48
+ def to_jpeg_bytes(path: Path, settings: Settings) -> bytes | None:
49
+ suffix = path.suffix.lower()
50
+ if suffix == ".txt":
51
+ return None
52
+ if suffix == ".pdf":
53
+ return pdf_first_page_jpeg(path, settings.jpeg_max_edge)
54
+ if suffix in {".heic", ".heif"}:
55
+ _load_heif()
56
+ with Image.open(path) as image:
57
+ return _pil_to_jpeg(image, settings.jpeg_max_edge)
app/ocr.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from backends.base import OCRBackend, OCRResult
6
+
7
+
8
+ def maybe_ocr(backend: OCRBackend | None, path: Path) -> OCRResult | None:
9
+ if backend is None:
10
+ sidecar = path.with_suffix(".txt")
11
+ if sidecar.is_file():
12
+ return OCRResult(text=sidecar.read_text(encoding="utf-8", errors="replace"), engine="sidecar")
13
+ if path.suffix.lower() == ".txt":
14
+ return OCRResult(text=path.read_text(encoding="utf-8", errors="replace"), engine="plaintext")
15
+ return None
16
+ return backend.ocr(path)
app/pipeline.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import shutil
5
+ import threading
6
+ from pathlib import Path
7
+
8
+ from app.config import Settings
9
+ from app.db import (
10
+ get_by_sha,
11
+ insert_receipt,
12
+ open_db,
13
+ set_line_match,
14
+ update_receipt_status,
15
+ upsert_vector,
16
+ )
17
+ from app.embed import embed_texts
18
+ from app.extract import extract_receipt
19
+ from app.match import match_receipt
20
+ from app.media import to_jpeg_bytes
21
+ from app.ocr import maybe_ocr
22
+ from app.schemas import ProcessResult, ReceiptStatus
23
+ from backends import build_embed, build_llm, build_ocr
24
+
25
+ _LOCK = threading.Lock()
26
+
27
+
28
+ def sha256_file(path: Path) -> str:
29
+ digest = hashlib.sha256()
30
+ with path.open("rb") as handle:
31
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
32
+ digest.update(chunk)
33
+ return digest.hexdigest()
34
+
35
+
36
+ def _safe_move(src: Path, dest_dir: Path) -> Path:
37
+ dest_dir.mkdir(parents=True, exist_ok=True)
38
+ dest = dest_dir / src.name
39
+ stem, suffix = dest.stem, dest.suffix
40
+ n = 1
41
+ while dest.exists():
42
+ dest = dest_dir / f"{stem}-{n}{suffix}"
43
+ n += 1
44
+ shutil.move(str(src), str(dest))
45
+ return dest
46
+
47
+
48
+ def process_file(path: Path, settings: Settings) -> ProcessResult:
49
+ with _LOCK:
50
+ return _process_file_locked(path, settings)
51
+
52
+
53
+ def _process_file_locked(path: Path, settings: Settings) -> ProcessResult:
54
+ settings.ensure_dirs()
55
+ src = Path(path)
56
+ digest = sha256_file(src)
57
+ con = open_db(settings)
58
+ try:
59
+ existing = get_by_sha(con, digest)
60
+ if existing is not None:
61
+ if src.parent.resolve() == settings.inbox_dir.resolve():
62
+ _safe_move(src, settings.processed_dir)
63
+ return ProcessResult(
64
+ receipt_id=int(existing["id"]),
65
+ status=ReceiptStatus.duplicate,
66
+ source_path=str(src),
67
+ error="duplicate sha256",
68
+ )
69
+ working = src
70
+ if src.parent.resolve() == settings.inbox_dir.resolve():
71
+ working = _safe_move(src, settings.processing_dir)
72
+
73
+ ocr_backend = build_ocr(settings)
74
+ llm = build_llm(settings)
75
+ embed = build_embed(settings)
76
+ ocr = maybe_ocr(ocr_backend, working)
77
+ ocr_text = None if ocr is None else ocr.text
78
+ try:
79
+ image = to_jpeg_bytes(working, settings)
80
+ except Exception as exc:
81
+ failed = _safe_move(working, settings.failed_dir)
82
+ rid = insert_receipt(
83
+ con,
84
+ source_path=str(failed),
85
+ sha256=digest,
86
+ status=ReceiptStatus.failed,
87
+ ocr_text=ocr_text,
88
+ error=str(exc),
89
+ )
90
+ return ProcessResult(
91
+ receipt_id=rid,
92
+ status=ReceiptStatus.failed,
93
+ source_path=str(failed),
94
+ error=str(exc),
95
+ )
96
+
97
+ if image is None and not ocr_text:
98
+ rid = insert_receipt(
99
+ con,
100
+ source_path=str(working),
101
+ sha256=digest,
102
+ status=ReceiptStatus.needs_ocr,
103
+ )
104
+ return ProcessResult(
105
+ receipt_id=rid,
106
+ status=ReceiptStatus.needs_ocr,
107
+ source_path=str(working),
108
+ error="no image/text for extract",
109
+ )
110
+
111
+ try:
112
+ extract = extract_receipt(
113
+ llm, settings=settings, image_jpeg=image, ocr_text=ocr_text
114
+ )
115
+ except Exception as exc:
116
+ final = _safe_move(working, settings.processed_dir)
117
+ rid = insert_receipt(
118
+ con,
119
+ source_path=str(final),
120
+ sha256=digest,
121
+ status=ReceiptStatus.needs_extract,
122
+ ocr_text=ocr_text,
123
+ error=str(exc),
124
+ )
125
+ return ProcessResult(
126
+ receipt_id=rid,
127
+ status=ReceiptStatus.needs_extract,
128
+ source_path=str(final),
129
+ error=str(exc),
130
+ )
131
+
132
+ final = _safe_move(working, settings.processed_dir)
133
+ rid = insert_receipt(
134
+ con,
135
+ source_path=str(final),
136
+ sha256=digest,
137
+ status=ReceiptStatus.needs_review,
138
+ extract=extract,
139
+ ocr_text=ocr_text,
140
+ )
141
+ try:
142
+ if extract.vendor or extract.line_items:
143
+ blob = " | ".join(
144
+ [
145
+ extract.doc_kind,
146
+ extract.category,
147
+ extract.vendor or "",
148
+ extract.date.isoformat() if extract.date else "",
149
+ *(item.description for item in extract.line_items[:12]),
150
+ ]
151
+ )
152
+ vec = embed_texts(embed, [blob], input_type="passage", settings=settings)[0]
153
+ upsert_vector(con, "receipt_vec", "receipt_id", rid, vec)
154
+ matches = match_receipt(con, settings, embed, extract)
155
+ line_rows = con.execute(
156
+ "SELECT id FROM line_items WHERE receipt_id = ? ORDER BY id", (rid,)
157
+ ).fetchall()
158
+ for row, hit in zip(line_rows, matches, strict=False):
159
+ if hit.reason != "vendor knn":
160
+ set_line_match(con, int(row["id"]), hit)
161
+ except Exception:
162
+ matches = []
163
+ return ProcessResult(
164
+ receipt_id=rid,
165
+ status=ReceiptStatus.needs_review,
166
+ source_path=str(final),
167
+ extract=extract,
168
+ matches=matches,
169
+ )
170
+ finally:
171
+ con.close()
app/schemas.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import date as Date
6
+ from datetime import datetime as DateTime
7
+ from decimal import Decimal, ROUND_HALF_UP
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+
13
+ from app.config import CATEGORIES, DOC_KINDS
14
+
15
+ _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE)
16
+
17
+
18
+ class ReceiptStatus(StrEnum):
19
+ queued = "queued"
20
+ processing = "processing"
21
+ needs_ocr = "needs_ocr"
22
+ needs_extract = "needs_extract"
23
+ needs_review = "needs_review"
24
+ confirmed = "confirmed"
25
+ failed = "failed"
26
+ duplicate = "duplicate"
27
+
28
+
29
+ class MatchBand(StrEnum):
30
+ exact = "exact"
31
+ auto = "auto"
32
+ review = "review"
33
+ unmatched = "unmatched"
34
+ confirmed = "confirmed"
35
+
36
+
37
+ class LineItem(BaseModel):
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ description: str
41
+ qty: float | None = None
42
+ unit_price: Decimal | None = None
43
+ amount: Decimal | None = None
44
+ sku: str | None = None
45
+
46
+
47
+ class ReceiptExtract(BaseModel):
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ doc_kind: str = "receipt"
51
+ category: str = "other"
52
+ vendor: str | None = None
53
+ date: Date | None = None
54
+ tax: Decimal | None = None
55
+ total: Decimal | None = None
56
+ currency: str | None = None
57
+ line_items: list[LineItem] = Field(default_factory=list)
58
+
59
+ @field_validator("doc_kind")
60
+ @classmethod
61
+ def _doc_kind(cls, value: str) -> str:
62
+ kind = (value or "receipt").strip().lower()
63
+ return kind if kind in DOC_KINDS else "document"
64
+
65
+ @field_validator("category")
66
+ @classmethod
67
+ def _category(cls, value: str) -> str:
68
+ cat = (value or "other").strip().lower()
69
+ return cat if cat in CATEGORIES else "other"
70
+
71
+ @field_validator("date", mode="before")
72
+ @classmethod
73
+ def _date(cls, value: object) -> object:
74
+ if value in (None, "", "null"):
75
+ return None
76
+ if isinstance(value, Date):
77
+ return value
78
+ text = str(value).strip()[:10]
79
+ return DateTime.strptime(text, "%Y-%m-%d").date()
80
+
81
+
82
+ class MatchHit(BaseModel):
83
+ catalog_id: int | None = None
84
+ sku: str | None = None
85
+ vendor: str | None = None
86
+ description: str | None = None
87
+ similarity: float
88
+ band: MatchBand
89
+ reason: str
90
+
91
+
92
+ class ProcessResult(BaseModel):
93
+ receipt_id: int | None = None
94
+ status: ReceiptStatus
95
+ source_path: str
96
+ extract: ReceiptExtract | None = None
97
+ matches: list[MatchHit] = Field(default_factory=list)
98
+ error: str | None = None
99
+
100
+
101
+ def to_cents(value: Decimal | float | int | None) -> int | None:
102
+ if value is None:
103
+ return None
104
+ quantized = (Decimal(str(value)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
105
+ return int(quantized)
106
+
107
+
108
+ def cents_to_decimal(cents: int | None) -> Decimal | None:
109
+ if cents is None:
110
+ return None
111
+ return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"))
112
+
113
+
114
+ def extract_json_object(text: str) -> str:
115
+ stripped = text.strip()
116
+ fenced = _FENCE_RE.search(stripped)
117
+ if fenced:
118
+ stripped = fenced.group(1).strip()
119
+ start = stripped.find("{")
120
+ end = stripped.rfind("}")
121
+ if start < 0 or end <= start:
122
+ raise ValueError("no JSON object in model output")
123
+ return stripped[start : end + 1]
124
+
125
+
126
+ def parse_extract_json(text: str) -> ReceiptExtract:
127
+ payload = json.loads(extract_json_object(text))
128
+ if not isinstance(payload, dict):
129
+ raise ValueError("extract JSON must be an object")
130
+ if "line_items" not in payload or payload["line_items"] is None:
131
+ payload["line_items"] = []
132
+ return ReceiptExtract.model_validate(payload)
133
+
134
+
135
+ EXTRACT_JSON_SCHEMA: dict[str, Any] = ReceiptExtract.model_json_schema()
app/ui.py ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import socket
6
+ import threading
7
+ import uuid
8
+ from pathlib import Path
9
+
10
+ import gradio as gr
11
+ from fastapi import FastAPI, File, UploadFile
12
+ from fastapi.responses import HTMLResponse, JSONResponse
13
+
14
+ from app.config import CATEGORIES, DOC_KINDS, Settings, load_settings
15
+ from app.db import (
16
+ add_catalog_item,
17
+ delete_receipt,
18
+ get_receipt,
19
+ list_catalog,
20
+ list_line_items,
21
+ list_receipts,
22
+ open_db,
23
+ update_extract,
24
+ update_receipt_status,
25
+ )
26
+ from app.pipeline import process_file
27
+ from app.schemas import ReceiptExtract, ReceiptStatus
28
+ from app.watcher import start_inbox_watcher
29
+ from backends import build_embed, build_llm
30
+
31
+ CSS = """
32
+ .gradio-container {max-width: 1200px !important;}
33
+ """
34
+
35
+ PHONE_HTML = """<!doctype html>
36
+ <html lang="en"><head>
37
+ <meta charset="utf-8"/>
38
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/>
39
+ <meta name="apple-mobile-web-app-capable" content="yes"/>
40
+ <title>Scan a receipt</title>
41
+ <style>
42
+ :root { color-scheme: dark; }
43
+ * { -webkit-tap-highlight-color: transparent; }
44
+ body{font-family:-apple-system,system-ui,sans-serif;background:#111814;color:#e8f0e8;margin:0;padding:24px}
45
+ .card{max-width:480px;margin:0 auto;background:#1b2420;border-radius:16px;padding:24px}
46
+ h1{font-size:1.5rem;margin:0 0 8px}
47
+ p{line-height:1.4;color:#c5d4c8}
48
+ /* iOS ignores taps on display:none file inputs. Cover the whole button with an opacity-0 input. */
49
+ .hit{position:relative;display:block;box-sizing:border-box;width:100%;margin:14px 0 0;min-height:56px;
50
+ border-radius:12px;font:inherit;font-weight:600;text-align:center;line-height:56px;
51
+ overflow:hidden;touch-action:manipulation}
52
+ .cam{background:#c4a35a;color:#1b1408}
53
+ .lib{background:#2b3a32;color:#e8f0e8;border:1px solid #4d6556}
54
+ .hit input[type=file]{
55
+ position:absolute;left:0;top:0;width:100%;height:100%;margin:0;padding:0;
56
+ opacity:0;font-size:24px;cursor:pointer;z-index:2}
57
+ .ok{color:#9fdfb2;margin-top:12px;word-break:break-word}
58
+ .err{color:#ff8a80;margin-top:12px;word-break:break-word}
59
+ </style></head>
60
+ <body><div class="card">
61
+ <h1>Scan a receipt</h1>
62
+ <p>Tap <b>Take photo</b> for the camera, or <b>Choose file</b> for Photos / Files. Use Safari.</p>
63
+ <div class="hit cam"><span>Take photo</span>
64
+ <input id="cam" type="file" accept="image/*" capture="environment" tabindex="0"/>
65
+ </div>
66
+ <div class="hit lib"><span>Choose file</span>
67
+ <input id="lib" type="file" accept="image/*,image/heic,image/heif,application/pdf,.heic,.heif,.pdf,.jpg,.jpeg,.png" tabindex="0"/>
68
+ </div>
69
+ <div id="out"></div>
70
+ <script>
71
+ const out = document.getElementById('out');
72
+ function add(cls, msg) {
73
+ const d = document.createElement('div');
74
+ d.className = cls;
75
+ d.textContent = msg;
76
+ out.prepend(d);
77
+ }
78
+ async function waitDone(jobId) {
79
+ add('ok', 'Uploaded. Reading the receipt on the Spark…');
80
+ const deadline = Date.now() + 180000;
81
+ while (Date.now() < deadline) {
82
+ await new Promise(r => setTimeout(r, 2000));
83
+ const r = await fetch('/api/jobs/' + jobId);
84
+ const j = await r.json();
85
+ if (j.state === 'processing') continue;
86
+ if (j.state === 'done') {
87
+ const bits = [];
88
+ if (j.vendor) bits.push(j.vendor);
89
+ if (j.total) bits.push('$' + j.total);
90
+ if (j.category) bits.push(j.category);
91
+ if (j.receipt_id != null) bits.push('#' + j.receipt_id);
92
+ const line = bits.join(' · ') || j.status || 'done';
93
+ if (j.status === 'failed' || j.status === 'needs_extract') {
94
+ add('err', 'Finished with issues: ' + line + (j.error ? ' — ' + j.error : ''));
95
+ } else {
96
+ add('ok', 'Completed: ' + line + '. Confirm it on the desktop Review tab.');
97
+ }
98
+ return;
99
+ }
100
+ add('err', 'Failed: ' + (j.error || j.state));
101
+ return;
102
+ }
103
+ add('err', 'Still processing after 3 minutes — check Review on the desktop.');
104
+ }
105
+ async function upload(file) {
106
+ if (!file) { add('err', 'No file selected'); return; }
107
+ add('ok', 'Uploading ' + (file.name || 'photo') + '…');
108
+ const body = new FormData();
109
+ const name = file.name && file.name !== 'image.jpg' ? file.name : ('iphone-' + Date.now() + '.jpg');
110
+ body.append('file', file, name);
111
+ try {
112
+ const r = await fetch('/api/inbox', { method: 'POST', body });
113
+ const text = await r.text();
114
+ let j;
115
+ try { j = JSON.parse(text); } catch (e) { throw new Error('Server returned ' + r.status + ': ' + text.slice(0, 180)); }
116
+ if (!r.ok || !j.ok) throw new Error(text.slice(0, 180));
117
+ await waitDone(j.job_id);
118
+ } catch (err) {
119
+ add('err', String(err));
120
+ }
121
+ }
122
+ function bind(id) {
123
+ const el = document.getElementById(id);
124
+ el.addEventListener('change', () => {
125
+ const files = el.files;
126
+ if (!files || !files.length) { add('err', 'Picker closed with no file'); return; }
127
+ for (const f of files) upload(f);
128
+ el.value = '';
129
+ });
130
+ }
131
+ bind('cam');
132
+ bind('lib');
133
+ </script></div></body></html>
134
+ """
135
+
136
+
137
+ def _lan_ip() -> str:
138
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
139
+ try:
140
+ sock.connect(("192.0.2.1", 1))
141
+ return sock.getsockname()[0]
142
+ except OSError:
143
+ return "127.0.0.1"
144
+ finally:
145
+ sock.close()
146
+
147
+
148
+ def _as_text(raw: object) -> str:
149
+ if raw is None:
150
+ return ""
151
+ if isinstance(raw, list):
152
+ return "\n".join(str(part) for part in raw)
153
+ return str(raw)
154
+
155
+
156
+ def pretty_json(raw: object) -> str:
157
+ text = _as_text(raw).strip() or "{}"
158
+ return json.dumps(json.loads(text), indent=2)
159
+
160
+
161
+ def parse_rid(value: object) -> int | None:
162
+ if value is None or value == "":
163
+ return None
164
+ try:
165
+ return int(float(str(value)))
166
+ except (TypeError, ValueError):
167
+ return None
168
+
169
+
170
+ def _table_rows(data: object) -> list[list[object]]:
171
+ if data is None:
172
+ return []
173
+ if hasattr(data, "values"):
174
+ return [list(row) for row in data.values]
175
+ return [list(row) for row in data]
176
+
177
+
178
+ def _image_preview(path: str) -> str | None:
179
+ if not path:
180
+ return None
181
+ suffix = Path(path).suffix.lower()
182
+ if suffix in {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"}:
183
+ return path
184
+ return None
185
+
186
+
187
+ def _save_upload(settings: Settings, name: str, data: bytes) -> Path:
188
+ safe = Path(name).name or "upload.bin"
189
+ dest = settings.inbox_dir / safe
190
+ n = 1
191
+ while dest.exists():
192
+ dest = settings.inbox_dir / f"{dest.stem}-{n}{dest.suffix}"
193
+ n += 1
194
+ dest.write_bytes(data)
195
+ return dest
196
+
197
+
198
+ _JOBS: dict[str, dict[str, object]] = {}
199
+ _JOBS_LOCK = threading.Lock()
200
+
201
+
202
+ def build_app(settings: Settings) -> FastAPI:
203
+ settings.ensure_dirs()
204
+ api = FastAPI(title="keys-automatic-receipt-doc-scanner")
205
+
206
+ @api.post("/api/inbox")
207
+ async def api_inbox(file: UploadFile = File(...)) -> JSONResponse:
208
+ data = await file.read()
209
+ dest = _save_upload(settings, file.filename or "iphone.jpg", data)
210
+ job_id = uuid.uuid4().hex
211
+ with _JOBS_LOCK:
212
+ _JOBS[job_id] = {"state": "processing"}
213
+
214
+ def _run() -> None:
215
+ try:
216
+ result = process_file(dest, settings)
217
+ extract = result.extract
218
+ payload: dict[str, object] = {
219
+ "state": "done",
220
+ "status": result.status.value,
221
+ "receipt_id": result.receipt_id,
222
+ "vendor": extract.vendor if extract else None,
223
+ "total": str(extract.total) if extract and extract.total is not None else None,
224
+ "category": extract.category if extract else None,
225
+ "error": result.error,
226
+ }
227
+ except Exception as exc:
228
+ payload = {"state": "error", "error": str(exc)}
229
+ with _JOBS_LOCK:
230
+ _JOBS[job_id] = payload
231
+
232
+ threading.Thread(target=_run, daemon=True, name="inbox-scan").start()
233
+ return JSONResponse(
234
+ {
235
+ "ok": True,
236
+ "job_id": job_id,
237
+ "path": str(dest),
238
+ "bytes": len(data),
239
+ "processing": True,
240
+ }
241
+ )
242
+
243
+ @api.get("/api/jobs/{job_id}")
244
+ async def job_status(job_id: str) -> JSONResponse:
245
+ with _JOBS_LOCK:
246
+ job = _JOBS.get(job_id)
247
+ if job is None:
248
+ return JSONResponse({"state": "error", "error": "unknown job"}, status_code=404)
249
+ return JSONResponse(job)
250
+
251
+ @api.get("/phone", response_class=HTMLResponse)
252
+ async def phone() -> str:
253
+ return PHONE_HTML
254
+
255
+ def health() -> str:
256
+ llm = build_llm(settings)
257
+ embed = build_embed(settings)
258
+ return (
259
+ f"LLM {settings.llm_backend}/{settings.llm_model} @ {settings.llm_base_url} "
260
+ f"vision={llm.accepts_images} health={llm.health()}\n"
261
+ f"Embed {settings.embed_backend}/{settings.embed_model} dim={settings.embed_dim} "
262
+ f"health={embed.health()}\n"
263
+ f"Camera {settings.camera_url} idle={settings.idle_seconds}s"
264
+ )
265
+
266
+ def inbox_list() -> str:
267
+ files = sorted(p.name for p in settings.inbox_dir.iterdir() if p.is_file())
268
+ return "\n".join(files) or "(empty inbox)"
269
+
270
+ def ingest(files: list) -> str:
271
+ if not files:
272
+ return "no files"
273
+ names = []
274
+ for item in files:
275
+ src = Path(item if isinstance(item, str) else item.name)
276
+ dest = settings.inbox_dir / src.name
277
+ shutil.copy2(src, dest)
278
+ names.append(dest.name)
279
+ return "queued: " + ", ".join(names)
280
+
281
+ def review_table() -> list[list[str]]:
282
+ con = open_db(settings)
283
+ try:
284
+ rows = list_receipts(con, limit=40)
285
+ return [
286
+ [
287
+ str(r["id"]),
288
+ r["status"],
289
+ r["doc_kind"] or "",
290
+ r["category"] or "",
291
+ r["vendor"] or "",
292
+ r["receipt_date"] or "",
293
+ "" if r["total_cents"] is None else f"{r['total_cents']/100:.2f}",
294
+ ]
295
+ for r in rows
296
+ ]
297
+ finally:
298
+ con.close()
299
+
300
+ def _empty_load() -> tuple:
301
+ return (
302
+ None,
303
+ "",
304
+ "receipt",
305
+ "other",
306
+ "",
307
+ "",
308
+ "",
309
+ "",
310
+ "",
311
+ "{}",
312
+ [],
313
+ "not found",
314
+ )
315
+
316
+ def load_one(receipt_id: object) -> tuple:
317
+ rid = parse_rid(receipt_id)
318
+ if rid is None:
319
+ return _empty_load()
320
+ con = open_db(settings)
321
+ try:
322
+ row = get_receipt(con, rid)
323
+ if row is None:
324
+ return _empty_load()
325
+ try:
326
+ payload = pretty_json(row["extract_json"] or "{}")
327
+ except json.JSONDecodeError:
328
+ payload = row["extract_json"] or "{}"
329
+ extract = None
330
+ try:
331
+ extract = ReceiptExtract.model_validate_json(payload)
332
+ except Exception:
333
+ extract = None
334
+ lines = [
335
+ [
336
+ item.description,
337
+ "" if item.qty is None else item.qty,
338
+ "" if item.unit_price is None else str(item.unit_price),
339
+ "" if item.amount is None else str(item.amount),
340
+ item.sku or "",
341
+ ]
342
+ for item in (extract.line_items if extract else [])
343
+ ]
344
+ if not lines:
345
+ lines = [
346
+ [
347
+ r["description"],
348
+ r["qty"] if r["qty"] is not None else "",
349
+ "" if r["unit_price_cents"] is None else f"{r['unit_price_cents']/100:.2f}",
350
+ "" if r["amount_cents"] is None else f"{r['amount_cents']/100:.2f}",
351
+ r["sku"] or "",
352
+ ]
353
+ for r in list_line_items(con, rid)
354
+ ]
355
+ return (
356
+ _image_preview(row["source_path"] or ""),
357
+ str(rid),
358
+ (extract.doc_kind if extract else row["doc_kind"]) or "receipt",
359
+ (extract.category if extract else row["category"]) or "other",
360
+ (extract.vendor if extract else row["vendor"]) or "",
361
+ (
362
+ extract.date.isoformat()
363
+ if extract and extract.date
364
+ else (row["receipt_date"] or "")
365
+ ),
366
+ "" if extract is None or extract.tax is None else str(extract.tax),
367
+ "" if extract is None or extract.total is None else str(extract.total),
368
+ (extract.currency if extract else row["currency"]) or "",
369
+ payload,
370
+ lines,
371
+ f"loaded #{rid}",
372
+ )
373
+ finally:
374
+ con.close()
375
+
376
+ def load_from_table(data: object, evt: gr.SelectData) -> tuple:
377
+ rows = _table_rows(data)
378
+ index = evt.index
379
+ row_i = index[0] if isinstance(index, (list, tuple)) else index
380
+ if row_i is None or row_i < 0 or row_i >= len(rows) or not rows[row_i]:
381
+ return _empty_load()
382
+ return load_one(rows[row_i][0])
383
+
384
+ def save_extract(receipt_id: object, raw: object) -> tuple[str, list[list[str]]]:
385
+ rid = parse_rid(receipt_id)
386
+ if rid is None:
387
+ return "pick a receipt (click a row or enter id)", review_table()
388
+ try:
389
+ extract = ReceiptExtract.model_validate_json(pretty_json(raw))
390
+ except Exception as exc:
391
+ return f"invalid JSON: {exc}", review_table()
392
+ con = open_db(settings)
393
+ try:
394
+ update_extract(con, rid, extract, ReceiptStatus.needs_review)
395
+ finally:
396
+ con.close()
397
+ return f"saved JSON for #{rid}", review_table()
398
+
399
+ def save_fields(
400
+ receipt_id: object,
401
+ doc_kind: str,
402
+ category: str,
403
+ vendor: str,
404
+ receipt_date: str,
405
+ tax: str,
406
+ total: str,
407
+ currency: str,
408
+ lines: object,
409
+ ) -> tuple[str, str, list[list[str]]]:
410
+ rid = parse_rid(receipt_id)
411
+ if rid is None:
412
+ return "pick a receipt first", "{}", review_table()
413
+ def _num(val: object) -> str | None:
414
+ text = str(val).strip()
415
+ return None if text in {"", "None", "null"} else text
416
+
417
+ items = []
418
+ for row in _table_rows(lines):
419
+ if not row or not str(row[0]).strip():
420
+ continue
421
+ items.append(
422
+ {
423
+ "description": str(row[0]).strip(),
424
+ "qty": _num(row[1] if len(row) > 1 else None),
425
+ "unit_price": _num(row[2] if len(row) > 2 else None),
426
+ "amount": _num(row[3] if len(row) > 3 else None),
427
+ "sku": (str(row[4]).strip() or None) if len(row) > 4 else None,
428
+ }
429
+ )
430
+ payload = {
431
+ "doc_kind": doc_kind or "receipt",
432
+ "category": category or "other",
433
+ "vendor": vendor.strip() or None,
434
+ "date": receipt_date.strip() or None,
435
+ "tax": tax.strip() or None,
436
+ "total": total.strip() or None,
437
+ "currency": currency.strip() or None,
438
+ "line_items": items,
439
+ }
440
+ raw = json.dumps(payload, indent=2)
441
+ try:
442
+ extract = ReceiptExtract.model_validate(payload)
443
+ except Exception as exc:
444
+ return f"invalid fields: {exc}", raw, review_table()
445
+ con = open_db(settings)
446
+ try:
447
+ update_extract(con, rid, extract, ReceiptStatus.needs_review)
448
+ finally:
449
+ con.close()
450
+ return f"saved receipt #{rid}", pretty_json(extract.model_dump_json()), review_table()
451
+
452
+ def confirm(receipt_id: object) -> tuple[str, list[list[str]]]:
453
+ rid = parse_rid(receipt_id)
454
+ if rid is None:
455
+ return "pick a receipt first", review_table()
456
+ con = open_db(settings)
457
+ try:
458
+ update_receipt_status(con, rid, ReceiptStatus.confirmed)
459
+ finally:
460
+ con.close()
461
+ return f"confirmed #{rid}", review_table()
462
+
463
+ def delete_one(receipt_id: object) -> tuple:
464
+ rid = parse_rid(receipt_id)
465
+ empty = _empty_load()
466
+ if rid is None:
467
+ return (*empty[:-1], "pick a receipt first", review_table())
468
+ con = open_db(settings)
469
+ try:
470
+ found = delete_receipt(con, rid)
471
+ finally:
472
+ con.close()
473
+ if not found:
474
+ return (*empty[:-1], f"not found #{rid}", review_table())
475
+ return (*empty[:-1], f"deleted #{rid}", review_table())
476
+
477
+ def catalog_table() -> list[list[str]]:
478
+ con = open_db(settings)
479
+ try:
480
+ return [
481
+ [str(r["id"]), r["sku"] or "", r["vendor"] or "", r["description"]]
482
+ for r in list_catalog(con)
483
+ ]
484
+ finally:
485
+ con.close()
486
+
487
+ def add_sku(sku: str, vendor: str, description: str) -> str:
488
+ if not description.strip():
489
+ return "description required"
490
+ con = open_db(settings)
491
+ try:
492
+ add_catalog_item(con, sku=sku or None, vendor=vendor or None, description=description)
493
+ finally:
494
+ con.close()
495
+ return "added"
496
+
497
+ def process_now(path: str) -> str:
498
+ if not path:
499
+ return "no path"
500
+ result = process_file(Path(path), settings)
501
+ return result.model_dump_json(indent=2)
502
+
503
+ with gr.Blocks(title="Receipt Studio", theme=gr.themes.Soft(primary_hue="amber"), css=CSS) as demo:
504
+ gr.Markdown(
505
+ "# Receipt Studio\n"
506
+ "Lamp camera · phone upload · Gemma 4 12B Unified on the GPU box (not on the Lamp)."
507
+ )
508
+ with gr.Tab("Inbox"):
509
+ gr.Markdown(
510
+ f"Drop files here or on your phone: `http://{_lan_ip()}:{settings.ui_port}/phone` "
511
+ f"(bind `0.0.0.0` / `RECEIPT_UI_SHARE_LAN=true`). Syncthing can also land in `inbox/`."
512
+ )
513
+ files = gr.File(label="Photos / PDFs", file_count="multiple", type="filepath")
514
+ ingest_btn = gr.Button("Queue in inbox")
515
+ ingest_out = gr.Textbox(label="Queued")
516
+ listing = gr.Textbox(label="Inbox", lines=8)
517
+ refresh = gr.Button("Refresh inbox")
518
+ ingest_btn.click(ingest, inputs=[files], outputs=[ingest_out]).then(
519
+ inbox_list, outputs=[listing]
520
+ )
521
+ refresh.click(inbox_list, outputs=[listing])
522
+ demo.load(inbox_list, outputs=[listing])
523
+ with gr.Tab("Review"):
524
+ gr.Markdown(
525
+ "Click a row to load. Edit **Kind / Category / Vendor / Date / Tax / Total** "
526
+ "(or the JSON), then **Save fields + lines**. "
527
+ "**Delete** removes a bad scan from the database and disk."
528
+ )
529
+ table = gr.Dataframe(
530
+ headers=["id", "status", "kind", "category", "vendor", "date", "total"],
531
+ datatype=["str"] * 7,
532
+ interactive=False,
533
+ wrap=True,
534
+ )
535
+ refresh_r = gr.Button("Refresh queue")
536
+ with gr.Row():
537
+ img = gr.Image(label="Scan", type="filepath")
538
+ with gr.Column():
539
+ rid = gr.Textbox(label="Receipt id")
540
+ kind = gr.Dropdown(choices=list(DOC_KINDS), label="Kind", value="receipt")
541
+ category = gr.Dropdown(choices=list(CATEGORIES), label="Category", value="other")
542
+ vendor = gr.Textbox(label="Vendor")
543
+ receipt_date = gr.Textbox(label="Date (YYYY-MM-DD)")
544
+ tax = gr.Textbox(label="Tax")
545
+ total = gr.Textbox(label="Total")
546
+ currency = gr.Textbox(label="Currency")
547
+ raw = gr.Textbox(
548
+ label="Extract JSON (editable)",
549
+ lines=18,
550
+ max_lines=40,
551
+ interactive=True,
552
+ )
553
+ lines = gr.Dataframe(
554
+ headers=["description", "qty", "unit_price", "amount", "sku"],
555
+ datatype=["str", "str", "str", "str", "str"],
556
+ label="Line items (editable)",
557
+ interactive=True,
558
+ wrap=True,
559
+ )
560
+ with gr.Row():
561
+ load_btn = gr.Button("Load id")
562
+ save_fields_btn = gr.Button("Save fields + lines")
563
+ save_json_btn = gr.Button("Save JSON")
564
+ ok_btn = gr.Button("Confirm")
565
+ del_btn = gr.Button("Delete", variant="stop")
566
+ msg = gr.Textbox(label="Status")
567
+ load_outputs = [
568
+ img,
569
+ rid,
570
+ kind,
571
+ category,
572
+ vendor,
573
+ receipt_date,
574
+ tax,
575
+ total,
576
+ currency,
577
+ raw,
578
+ lines,
579
+ msg,
580
+ ]
581
+ refresh_r.click(review_table, outputs=[table])
582
+ table.select(load_from_table, inputs=[table], outputs=load_outputs)
583
+ load_btn.click(load_one, inputs=[rid], outputs=load_outputs)
584
+ save_fields_btn.click(
585
+ save_fields,
586
+ inputs=[rid, kind, category, vendor, receipt_date, tax, total, currency, lines],
587
+ outputs=[msg, raw, table],
588
+ )
589
+ save_json_btn.click(save_extract, inputs=[rid, raw], outputs=[msg, table])
590
+ ok_btn.click(confirm, inputs=[rid], outputs=[msg, table])
591
+ del_btn.click(delete_one, inputs=[rid], outputs=load_outputs + [table])
592
+ demo.load(review_table, outputs=[table])
593
+ with gr.Tab("Catalog"):
594
+ cat = gr.Dataframe(headers=["id", "sku", "vendor", "description"])
595
+ sku = gr.Textbox(label="SKU")
596
+ vendor = gr.Textbox(label="Vendor")
597
+ desc = gr.Textbox(label="Description")
598
+ add_btn = gr.Button("Add SKU")
599
+ add_msg = gr.Textbox()
600
+ add_btn.click(add_sku, inputs=[sku, vendor, desc], outputs=[add_msg]).then(
601
+ catalog_table, outputs=[cat]
602
+ )
603
+ demo.load(catalog_table, outputs=[cat])
604
+ with gr.Tab("Settings"):
605
+ gr.Markdown(
606
+ "Gemma 4 12B Unified does **not** load on the Lamp (6 GB). "
607
+ "This UI talks to the GPU box URLs in `.env`."
608
+ )
609
+ gr.Textbox(value=health, label="Backends", every=15)
610
+ path = gr.Textbox(label="Process this path now")
611
+ run = gr.Button("Process")
612
+ run_out = gr.Textbox(lines=16)
613
+ run.click(process_now, inputs=[path], outputs=[run_out])
614
+
615
+ return gr.mount_gradio_app(api, demo, path="/")
616
+
617
+
618
+ def main() -> None:
619
+ settings = load_settings()
620
+ if settings.ui_share_lan:
621
+ settings.ui_host = "0.0.0.0"
622
+ start_inbox_watcher(settings)
623
+ import uvicorn
624
+
625
+ uvicorn.run(
626
+ build_app(settings),
627
+ host=settings.ui_host,
628
+ port=settings.ui_port,
629
+ log_level="info",
630
+ )
631
+
632
+
633
+ if __name__ == "__main__":
634
+ main()
app/watcher.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
10
+ from watchdog.observers import Observer
11
+
12
+ from app.config import ACCEPTED_SUFFIXES, Settings
13
+ from app.pipeline import process_file
14
+
15
+ OnBatch = Callable[[list[Path]], None]
16
+
17
+
18
+ def is_ignored(path: Path) -> bool:
19
+ name = path.name
20
+ if name.startswith("."):
21
+ return True
22
+ if name.endswith(".tmp") or name.endswith(".part"):
23
+ return True
24
+ if ".syncthing." in name or name.startswith(".syncthing"):
25
+ return True
26
+ return path.suffix.lower() not in ACCEPTED_SUFFIXES
27
+
28
+
29
+ @dataclass
30
+ class _State:
31
+ sig: tuple[int, float]
32
+ last_change: float
33
+
34
+
35
+ class IdleBatchWatcher:
36
+ """Settle files until size+mtime are unchanged for idle_seconds, then batch."""
37
+
38
+ def __init__(
39
+ self,
40
+ inbox: Path,
41
+ *,
42
+ idle_seconds: float = 30.0,
43
+ on_batch: OnBatch | None = None,
44
+ ) -> None:
45
+ self.inbox = Path(inbox)
46
+ self.idle_seconds = idle_seconds
47
+ self.on_batch = on_batch
48
+ self._state: dict[Path, _State] = {}
49
+ self._lock = threading.Lock()
50
+ self._running = False
51
+ self._observer: Observer | None = None
52
+ self._thread: threading.Thread | None = None
53
+
54
+ def note(self, path: Path, now: float) -> None:
55
+ if not path.is_file() or is_ignored(path):
56
+ return
57
+ stat = path.stat()
58
+ sig = (stat.st_size, stat.st_mtime)
59
+ with self._lock:
60
+ prev = self._state.get(path)
61
+ if prev is None or prev.sig != sig:
62
+ self._state[path] = _State(sig=sig, last_change=now)
63
+
64
+ def tick(self, now: float | None = None) -> list[Path]:
65
+ clock = time.monotonic() if now is None else now
66
+ self.inbox.mkdir(parents=True, exist_ok=True)
67
+ for path in self.inbox.iterdir():
68
+ self.note(path, clock)
69
+ ready: list[Path] = []
70
+ with self._lock:
71
+ for path, state in list(self._state.items()):
72
+ if not path.is_file():
73
+ self._state.pop(path, None)
74
+ continue
75
+ if clock - state.last_change >= self.idle_seconds:
76
+ ready.append(path)
77
+ self._state.pop(path, None)
78
+ if ready and self.on_batch is not None:
79
+ self.on_batch(ready)
80
+ return ready
81
+
82
+ def start(self) -> None:
83
+ if self._running:
84
+ return
85
+ self._running = True
86
+ handler = _Handler(self)
87
+ observer = Observer()
88
+ observer.schedule(handler, str(self.inbox), recursive=False)
89
+ observer.start()
90
+ self._observer = observer
91
+ self._thread = threading.Thread(target=self._loop, daemon=True)
92
+ self._thread.start()
93
+
94
+ def stop(self) -> None:
95
+ self._running = False
96
+ if self._observer is not None:
97
+ self._observer.stop()
98
+ self._observer.join(timeout=2)
99
+ self._observer = None
100
+
101
+ def _loop(self) -> None:
102
+ while self._running:
103
+ self.tick()
104
+ time.sleep(min(0.25, max(0.05, self.idle_seconds / 4)))
105
+
106
+
107
+ class _Handler(FileSystemEventHandler):
108
+ def __init__(self, watcher: IdleBatchWatcher) -> None:
109
+ self.watcher = watcher
110
+
111
+ def on_any_event(self, event: FileSystemEvent) -> None:
112
+ if event.is_directory:
113
+ return
114
+ path = Path(str(event.src_path))
115
+ self.watcher.note(path, time.monotonic())
116
+
117
+
118
+ def process_batch(paths: list[Path], settings: Settings) -> None:
119
+ for path in paths:
120
+ try:
121
+ process_file(path, settings)
122
+ except Exception:
123
+ continue
124
+
125
+
126
+ def start_inbox_watcher(settings: Settings) -> IdleBatchWatcher:
127
+ watcher = IdleBatchWatcher(
128
+ settings.inbox_dir,
129
+ idle_seconds=settings.idle_seconds,
130
+ on_batch=lambda paths: process_batch(paths, settings),
131
+ )
132
+ watcher.start()
133
+ return watcher
backends/__init__.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.config import Settings
4
+ from backends.apple import AppleEmbed, AppleLLM, AppleOCR
5
+ from backends.base import EmbedBackend, LLMBackend, OCRBackend
6
+ from backends.cpu import CpuOCR
7
+ from backends.gemma import GemmaEmbed, GemmaLLM
8
+ from backends.nvidia import NvidiaEmbed, NvidiaLLM, NvidiaOCR
9
+ from backends.ollama import OllamaEmbed, OllamaLLM, OllamaOCR
10
+
11
+ __all__ = [
12
+ "EmbedBackend",
13
+ "LLMBackend",
14
+ "OCRBackend",
15
+ "build_embed",
16
+ "build_llm",
17
+ "build_ocr",
18
+ ]
19
+
20
+
21
+ def build_llm(settings: Settings, *, client=None) -> LLMBackend:
22
+ name = settings.llm_backend.lower().strip()
23
+ if name in {"gemma", "gemma4", "unified"}:
24
+ return GemmaLLM(settings, client=client)
25
+ if name in {"nvidia", "vllm", "qwen", "qwen38"}:
26
+ return NvidiaLLM(settings, client=client)
27
+ if name in {"ollama", "lightning"}:
28
+ return OllamaLLM(settings, client=client)
29
+ if name == "apple":
30
+ return AppleLLM()
31
+ raise ValueError(f"unknown llm_backend: {settings.llm_backend}")
32
+
33
+
34
+ def build_embed(settings: Settings, *, client=None) -> EmbedBackend:
35
+ name = settings.embed_backend.lower().strip()
36
+ if name in {"omni", "gemma", "gemma4"}:
37
+ return GemmaEmbed(settings, client=client)
38
+ if name in {"nvidia", "nemotron", "vllm"}:
39
+ return NvidiaEmbed(settings, client=client)
40
+ if name in {"openai", "ollama"}:
41
+ return OllamaEmbed(settings, client=client)
42
+ if name == "apple":
43
+ return AppleEmbed()
44
+ raise ValueError(f"unknown embed_backend: {settings.embed_backend}")
45
+
46
+
47
+ def build_ocr(settings: Settings, *, client=None) -> OCRBackend | None:
48
+ name = settings.ocr_backend.lower().strip()
49
+ if name in {"", "none", "off"}:
50
+ return None
51
+ if name == "ollama":
52
+ return OllamaOCR(settings, client=client)
53
+ if name == "nvidia":
54
+ return NvidiaOCR()
55
+ if name == "apple":
56
+ return AppleOCR()
57
+ if name == "cpu":
58
+ return CpuOCR()
59
+ raise ValueError(f"unknown ocr_backend: {settings.ocr_backend}")
backends/apple.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from backends.base import InputType, OCRResult
6
+
7
+
8
+ class _Stub:
9
+ name = "apple"
10
+
11
+ def health(self) -> bool:
12
+ return False
13
+
14
+
15
+ class AppleLLM(_Stub):
16
+ accepts_images = True
17
+
18
+ def complete_json(self, *, system: str, user: str, image_jpeg: bytes | None = None) -> str:
19
+ raise NotImplementedError("Apple Vision / Foundation Models backend is Phase 2.")
20
+
21
+
22
+ class AppleEmbed(_Stub):
23
+ dim = 0
24
+
25
+ def embed(self, texts: list[str], *, input_type: InputType) -> list[list[float]]:
26
+ raise NotImplementedError("Apple embed backend is Phase 2.")
27
+
28
+
29
+ class AppleOCR(_Stub):
30
+ def ocr(self, path: Path) -> OCRResult:
31
+ raise NotImplementedError(f"Apple Vision OCR is Phase 2 (path={path}).")
backends/base.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal, Protocol, runtime_checkable
5
+
6
+ InputType = Literal["query", "passage"]
7
+
8
+
9
+ class OCRResult:
10
+ __slots__ = ("text", "engine")
11
+
12
+ def __init__(self, text: str, engine: str) -> None:
13
+ self.text = text
14
+ self.engine = engine
15
+
16
+
17
+ @runtime_checkable
18
+ class LLMBackend(Protocol):
19
+ accepts_images: bool
20
+ name: str
21
+
22
+ def complete_json(
23
+ self,
24
+ *,
25
+ system: str,
26
+ user: str,
27
+ image_jpeg: bytes | None = None,
28
+ ) -> str: ...
29
+
30
+ def health(self) -> bool: ...
31
+
32
+
33
+ @runtime_checkable
34
+ class EmbedBackend(Protocol):
35
+ name: str
36
+ dim: int
37
+
38
+ def embed(self, texts: list[str], *, input_type: InputType) -> list[list[float]]: ...
39
+
40
+ def health(self) -> bool: ...
41
+
42
+
43
+ @runtime_checkable
44
+ class OCRBackend(Protocol):
45
+ name: str
46
+
47
+ def ocr(self, path: Path) -> OCRResult: ...
backends/cpu.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from backends.base import OCRResult
6
+
7
+
8
+ class CpuOCR:
9
+ name = "cpu"
10
+
11
+ def ocr(self, path: Path) -> OCRResult:
12
+ raise NotImplementedError(
13
+ f"RapidOCR CPU fallback is Phase 2 and must not auto-download weights (path={path})."
14
+ )
backends/gemma.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from app.config import Settings
6
+ from backends.openai_compat import OpenAICompatEmbed, OpenAICompatLLM
7
+
8
+
9
+ class GemmaLLM(OpenAICompatLLM):
10
+ """Gemma 4 12B Unified on vLLM — vision extract. Runs on the GPU box, not the Lamp."""
11
+
12
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
13
+ super().__init__(
14
+ settings,
15
+ name="gemma4-unified",
16
+ accepts_images=settings.llm_accepts_images,
17
+ extra_body={},
18
+ client=client,
19
+ )
20
+
21
+
22
+ class GemmaEmbed(OpenAICompatEmbed):
23
+ """Same Gemma 4 12B Unified server, /v1/embeddings (omni). Dim 3840."""
24
+
25
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
26
+ super().__init__(settings, name="gemma4-omni-embed", client=client)
backends/nvidia.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ from app.config import Settings
8
+ from backends.base import OCRResult
9
+ from backends.openai_compat import OpenAICompatEmbed, OpenAICompatLLM
10
+
11
+
12
+ class NvidiaLLM(OpenAICompatLLM):
13
+ """vLLM Qwen3.8-27B ADay777 (or other NVIDIA VLM). Thinking off."""
14
+
15
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
16
+ super().__init__(
17
+ settings,
18
+ name="nvidia-vllm",
19
+ accepts_images=settings.llm_accepts_images,
20
+ extra_body={"chat_template_kwargs": {"enable_thinking": False}},
21
+ client=client,
22
+ )
23
+
24
+
25
+ class NvidiaEmbed(OpenAICompatEmbed):
26
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
27
+ super().__init__(settings, name="nemotron-embed", client=client)
28
+
29
+
30
+ class NvidiaOCR:
31
+ name = "nvidia-ocr"
32
+
33
+ def ocr(self, path: Path) -> OCRResult:
34
+ raise NotImplementedError(
35
+ "Nemotron OCR v2 is not in this skill. Use Gemma/Qwen vision extract "
36
+ f"(path={path})."
37
+ )
backends/ollama.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+
8
+ from app.config import Settings
9
+ from backends.base import OCRResult
10
+ from backends.openai_compat import OpenAICompatEmbed, OpenAICompatLLM
11
+
12
+
13
+ def _is_lightning(model: str) -> bool:
14
+ return "lightning" in model.lower()
15
+
16
+
17
+ class OllamaLLM(OpenAICompatLLM):
18
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
19
+ accepts = settings.llm_accepts_images and not _is_lightning(settings.llm_model)
20
+ super().__init__(
21
+ settings,
22
+ name="ollama",
23
+ accepts_images=accepts,
24
+ extra_body={},
25
+ client=client,
26
+ )
27
+
28
+
29
+ class OllamaEmbed(OpenAICompatEmbed):
30
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
31
+ super().__init__(settings, name="ollama-embed", client=client)
32
+
33
+
34
+ class OllamaOCR:
35
+ name = "ollama-ocr"
36
+
37
+ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) -> None:
38
+ self.settings = settings
39
+ self._client = client
40
+
41
+ def ocr(self, path: Path) -> OCRResult:
42
+ if not self.settings.ocr_model:
43
+ raise NotImplementedError("set RECEIPT_OCR_MODEL for Ollama vision OCR")
44
+ jpeg = path.read_bytes()
45
+ b64 = base64.b64encode(jpeg).decode("ascii")
46
+ own = self._client is None
47
+ http = self._client or httpx.Client(
48
+ base_url=self.settings.ocr_base_url.rstrip("/"),
49
+ timeout=self.settings.llm_timeout_s,
50
+ headers={"Authorization": f"Bearer {self.settings.ocr_api_key}"},
51
+ )
52
+ try:
53
+ response = http.post(
54
+ "/chat/completions",
55
+ json={
56
+ "model": self.settings.ocr_model,
57
+ "messages": [
58
+ {
59
+ "role": "user",
60
+ "content": [
61
+ {
62
+ "type": "image_url",
63
+ "image_url": {
64
+ "url": f"data:image/jpeg;base64,{b64}"
65
+ },
66
+ },
67
+ {
68
+ "type": "text",
69
+ "text": "Transcribe this document verbatim.",
70
+ },
71
+ ],
72
+ }
73
+ ],
74
+ "temperature": 0,
75
+ "max_tokens": 4096,
76
+ },
77
+ )
78
+ response.raise_for_status()
79
+ text = response.json()["choices"][0]["message"]["content"]
80
+ finally:
81
+ if own:
82
+ http.close()
83
+ return OCRResult(text=text or "", engine=self.name)
backends/openai_compat.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import math
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from app.config import Settings
10
+ from backends.base import InputType
11
+
12
+
13
+ class EmbedDimensionError(ValueError):
14
+ pass
15
+
16
+
17
+ class OpenAICompatError(RuntimeError):
18
+ pass
19
+
20
+
21
+ def _normalize_base(url: str) -> str:
22
+ return url.rstrip("/")
23
+
24
+
25
+ class OpenAICompatClient:
26
+ def __init__(
27
+ self,
28
+ *,
29
+ base_url: str,
30
+ api_key: str,
31
+ timeout_s: float,
32
+ client: httpx.Client | None = None,
33
+ ) -> None:
34
+ self.base_url = _normalize_base(base_url)
35
+ self.api_key = api_key
36
+ self._owns = client is None
37
+ self._client = client or httpx.Client(
38
+ base_url=self.base_url,
39
+ timeout=timeout_s,
40
+ headers={"Authorization": f"Bearer {api_key}"},
41
+ )
42
+
43
+ def close(self) -> None:
44
+ if self._owns:
45
+ self._client.close()
46
+
47
+ def health(self) -> bool:
48
+ try:
49
+ response = self._client.get("/models")
50
+ return response.status_code < 500
51
+ except httpx.HTTPError:
52
+ return False
53
+
54
+ def chat_completions(self, body: dict[str, Any]) -> dict[str, Any]:
55
+ response = self._client.post("/chat/completions", json=body)
56
+ try:
57
+ response.raise_for_status()
58
+ except httpx.HTTPStatusError as exc:
59
+ raise OpenAICompatError(
60
+ f"chat/completions {exc.response.status_code}: {exc.response.text[:500]}"
61
+ ) from exc
62
+ return response.json()
63
+
64
+ def embeddings(self, body: dict[str, Any]) -> dict[str, Any]:
65
+ response = self._client.post("/embeddings", json=body)
66
+ if response.status_code == 404:
67
+ # vLLM pooling runner
68
+ response = self._client.post("/pooling", json={**body, "task": "embed"})
69
+ try:
70
+ response.raise_for_status()
71
+ except httpx.HTTPStatusError as exc:
72
+ raise OpenAICompatError(
73
+ f"embeddings {exc.response.status_code}: {exc.response.text[:500]}"
74
+ ) from exc
75
+ return response.json()
76
+
77
+
78
+ class OpenAICompatLLM:
79
+ def __init__(
80
+ self,
81
+ settings: Settings,
82
+ *,
83
+ name: str,
84
+ accepts_images: bool,
85
+ extra_body: dict[str, Any] | None = None,
86
+ client: httpx.Client | None = None,
87
+ ) -> None:
88
+ self.name = name
89
+ self.accepts_images = accepts_images
90
+ self.model = settings.llm_model
91
+ self.max_tokens = settings.llm_max_tokens
92
+ self.extra_body = extra_body or {}
93
+ self._http = OpenAICompatClient(
94
+ base_url=settings.llm_base_url,
95
+ api_key=settings.llm_api_key,
96
+ timeout_s=settings.llm_timeout_s,
97
+ client=client,
98
+ )
99
+
100
+ def health(self) -> bool:
101
+ return self._http.health()
102
+
103
+ def complete_json(
104
+ self,
105
+ *,
106
+ system: str,
107
+ user: str,
108
+ image_jpeg: bytes | None = None,
109
+ ) -> str:
110
+ if self.accepts_images and image_jpeg:
111
+ b64 = base64.b64encode(image_jpeg).decode("ascii")
112
+ user_content: Any = [
113
+ {
114
+ "type": "image_url",
115
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"},
116
+ },
117
+ {"type": "text", "text": user},
118
+ ]
119
+ else:
120
+ user_content = user
121
+ body: dict[str, Any] = {
122
+ "model": self.model,
123
+ "messages": [
124
+ {"role": "system", "content": system},
125
+ {"role": "user", "content": user_content},
126
+ ],
127
+ "temperature": 0,
128
+ "max_tokens": self.max_tokens,
129
+ "response_format": {"type": "json_object"},
130
+ }
131
+ body.update(self.extra_body)
132
+ payload = self._http.chat_completions(body)
133
+ try:
134
+ return str(payload["choices"][0]["message"]["content"] or "")
135
+ except (KeyError, IndexError, TypeError) as exc:
136
+ raise OpenAICompatError(f"unexpected chat response: {payload!r}") from exc
137
+
138
+
139
+ def apply_embed_prefix(text: str, input_type: InputType, *, enabled: bool) -> str:
140
+ if not enabled:
141
+ return text
142
+ prefix = "query: " if input_type == "query" else "passage: "
143
+ stripped = text.lstrip()
144
+ if stripped.startswith("query:") or stripped.startswith("passage:"):
145
+ return text
146
+ return prefix + text
147
+
148
+
149
+ def l2_normalize(vec: list[float]) -> list[float]:
150
+ norm = math.sqrt(sum(x * x for x in vec)) or 1.0
151
+ return [x / norm for x in vec]
152
+
153
+
154
+ def parse_embedding_payload(payload: dict[str, Any]) -> list[list[float]]:
155
+ if "data" in payload:
156
+ rows = sorted(payload["data"], key=lambda row: row.get("index", 0))
157
+ return [list(map(float, row["embedding"])) for row in rows]
158
+ if "embeddings" in payload:
159
+ embeddings = payload["embeddings"]
160
+ if isinstance(embeddings, dict) and "float" in embeddings:
161
+ embeddings = embeddings["float"]
162
+ return [list(map(float, row)) for row in embeddings]
163
+ raise OpenAICompatError(f"unexpected embed response keys: {list(payload)}")
164
+
165
+
166
+ class OpenAICompatEmbed:
167
+ def __init__(
168
+ self,
169
+ settings: Settings,
170
+ *,
171
+ name: str,
172
+ client: httpx.Client | None = None,
173
+ ) -> None:
174
+ self.name = name
175
+ self.dim = settings.embed_dim
176
+ self.model = settings.embed_model
177
+ self.prefix = settings.embed_prefix
178
+ self._http = OpenAICompatClient(
179
+ base_url=settings.embed_base_url or settings.llm_base_url,
180
+ api_key=settings.embed_api_key or settings.llm_api_key,
181
+ timeout_s=settings.embed_timeout_s,
182
+ client=client,
183
+ )
184
+
185
+ def health(self) -> bool:
186
+ return self._http.health()
187
+
188
+ def embed(self, texts: list[str], *, input_type: InputType) -> list[list[float]]:
189
+ if not texts:
190
+ return []
191
+ prefixed = [
192
+ apply_embed_prefix(text, input_type, enabled=self.prefix) for text in texts
193
+ ]
194
+ body = {
195
+ "model": self.model,
196
+ "input": prefixed,
197
+ "encoding_format": "float",
198
+ "input_type": input_type,
199
+ }
200
+ payload = self._http.embeddings(body)
201
+ vectors = [l2_normalize(vec) for vec in parse_embedding_payload(payload)]
202
+ for vec in vectors:
203
+ if len(vec) != self.dim:
204
+ raise EmbedDimensionError(
205
+ f"embed dim {len(vec)} != configured {self.dim}. "
206
+ "Never mix Gemma-3840 and Nemotron-2048 in one index."
207
+ )
208
+ return vectors
oneshot.bat ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Windows: Gemma 4 12B-it must already be served (this PC or a Spark).
3
+ REM This script installs the app venv and opens the UI.
4
+ cd /d "%~dp0"
5
+ py -3.12 -m venv .venv 2>nul || python -m venv .venv
6
+ .venv\Scripts\python.exe -m pip install -q -U pip
7
+ .venv\Scripts\python.exe -m pip install -q -e ".[dev]"
8
+ if not exist .env copy .env.example .env
9
+ echo If Gemma is on another box, set RECEIPT_LLM_BASE_URL in .env to http://SPARK:8080/v1
10
+ .venv\Scripts\python.exe -m app.launch
11
+ if errorlevel 1 pause
oneshot.sh ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # ONE-SHOT: keys-Auto Receipts Studio (iPhone / may add Autonomous Lamp Skill)
4
+ # 1. Python 3.12 venv + app
5
+ # 2. Gemma 4 12B-it weights (skip if already on disk)
6
+ # 3. vLLM serve --gpu-memory-utilization 0.15 FP8 max-model-len 8192
7
+ # 4. Gradio UI on the LAN + print phone URL
8
+ # Idempotent. Re-run anytime. Never raises GPU util above 0.85.
9
+ # =============================================================================
10
+ set -euo pipefail
11
+ ROOT="$(cd "$(dirname "$0")" && pwd)"
12
+ cd "$ROOT"
13
+
14
+ MODEL_ID="${RECEIPT_HF_MODEL:-google/gemma-4-12B-it}"
15
+ MODEL_DIR="${RECEIPT_GEMMA_PATH:-$HOME/models-gemma4-12b-it}"
16
+ PORT_LLM="${RECEIPT_VLLM_PORT:-8080}"
17
+ PORT_UI="${RECEIPT_UI_PORT:-7860}"
18
+ UTIL="${RECEIPT_GPU_MEMORY_UTILIZATION:-0.15}"
19
+
20
+ say(){ printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
21
+ die(){ printf '\n\033[1;31mFAILED: %s\033[0m\n' "$*" >&2; exit 1; }
22
+
23
+ python3 -c 'import sys; assert sys.version_info >= (3,12), sys.version' \
24
+ || die "Python 3.12+ required"
25
+
26
+ if python3 -c "u=float('$UTIL'); assert u<=0.85" 2>/dev/null; then :; else
27
+ die "gpu_memory_utilization $UTIL > 0.85 hard cap"
28
+ fi
29
+
30
+ say "1/5 venv + install"
31
+ if [[ ! -x .venv/bin/python ]]; then
32
+ python3 -m venv .venv
33
+ fi
34
+ .venv/bin/pip install -q -U pip
35
+ .venv/bin/pip install -q -e ".[dev]"
36
+ [[ -f .env ]] || cp .env.example .env
37
+
38
+ say "2/5 Gemma 4 12B-it weights → $MODEL_DIR"
39
+ if [[ -f "$MODEL_DIR/config.json" ]] && ls "$MODEL_DIR"/*.safetensors >/dev/null 2>&1; then
40
+ echo " present"
41
+ else
42
+ command -v hf >/dev/null || .venv/bin/pip install -q huggingface_hub
43
+ mkdir -p "$MODEL_DIR"
44
+ hf download "$MODEL_ID" --local-dir "$MODEL_DIR" \
45
+ || python3 - "$MODEL_ID" "$MODEL_DIR" <<'PY' || die "weight download failed (hf auth login)"
46
+ import sys
47
+ from huggingface_hub import snapshot_download
48
+ snapshot_download(sys.argv[1], local_dir=sys.argv[2])
49
+ print(" downloaded")
50
+ PY
51
+ fi
52
+
53
+ say "3/5 vLLM Gemma (util=$UTIL FP8, :$PORT_LLM)"
54
+ if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
55
+ echo " already serving"
56
+ else
57
+ command -v vllm >/dev/null || die "vllm not on PATH (pip install vllm, or use this Spark's install)"
58
+ mkdir -p data
59
+ nohup bash "$ROOT/scripts/serve-gemma.sh" >> data/vllm-gemma.log 2>&1 &
60
+ echo " pid $! log data/vllm-gemma.log"
61
+ fi
62
+
63
+ say "4/5 wait until Gemma answers /v1/models (first boot compiles kernels)"
64
+ ok=0
65
+ for i in $(seq 1 120); do
66
+ if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
67
+ echo " healthy ($i)"
68
+ ok=1
69
+ break
70
+ fi
71
+ sleep 5
72
+ done
73
+ [[ "$ok" = 1 ]] || die "vLLM not healthy — tail data/vllm-gemma.log"
74
+
75
+ say "5/5 UI on LAN :$PORT_UI"
76
+ export RECEIPT_UI_SHARE_LAN=true
77
+ export RECEIPT_LLM_BASE_URL="http://127.0.0.1:${PORT_LLM}/v1"
78
+ export RECEIPT_EMBED_BASE_URL="http://127.0.0.1:${PORT_LLM}/v1"
79
+ export RECEIPT_LLM_MODEL="$MODEL_ID"
80
+ export RECEIPT_EMBED_MODEL="$MODEL_ID"
81
+ export RECEIPT_EMBED_DIM=3840
82
+ export RECEIPT_EMBED_BACKEND=omni
83
+ if curl -sf -m2 "http://127.0.0.1:$PORT_UI/phone" >/dev/null 2>&1; then
84
+ echo " UI already up"
85
+ else
86
+ nohup .venv/bin/python -m app.cli ui >> data/ui.log 2>&1 &
87
+ echo " pid $!"
88
+ for i in $(seq 1 40); do
89
+ curl -sf -m2 "http://127.0.0.1:$PORT_UI/phone" >/dev/null 2>&1 && break
90
+ sleep 0.25
91
+ done
92
+ fi
93
+
94
+ LAN="$(python3 - <<'PY'
95
+ import socket
96
+ s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
97
+ try:
98
+ s.connect(("192.0.2.1",1)); print(s.getsockname()[0])
99
+ except OSError:
100
+ print("127.0.0.1")
101
+ finally:
102
+ s.close()
103
+ PY
104
+ )"
105
+
106
+ printf '\n\033[1;32m✅ READY\033[0m keys-Auto Receipts Studio\n'
107
+ printf ' Review (this machine): http://127.0.0.1:%s\n' "$PORT_UI"
108
+ printf ' iPhone Safari: http://%s:%s/phone\n' "$LAN" "$PORT_UI"
109
+ printf ' Gemma /v1: http://127.0.0.1:%s/v1 model %s util=%s\n' "$PORT_LLM" "$MODEL_ID" "$UTIL"
110
+ printf ' Desktop launcher: bash scripts/install-launcher.sh\n'
111
+ printf '\n Hold a receipt up → Take photo on the phone page.\n'
112
+ printf ' Lamp skill (optional): skills/keys-receipt-scanner/ — 12B does not fit in 6GB RAM.\n'
pyproject.toml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keys-automatic-receipt-doc-scanner"
7
+ version = "1.0.0a1"
8
+ description = "Lamp camera + Qwen3.8 vision extract + sqlite-vec receipt/doc scanner"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "keys" }]
13
+ dependencies = [
14
+ "watchdog>=4.0",
15
+ "httpx>=0.27",
16
+ "pydantic>=2.8",
17
+ "pydantic-settings>=2.4",
18
+ "gradio>=4.44",
19
+ "sqlite-vec>=0.1.6",
20
+ "pillow>=10.4",
21
+ "pillow-heif>=0.18",
22
+ "pypdfium2>=4.30",
23
+ "uvicorn>=0.30",
24
+ "python-multipart>=0.0.9",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8.3"]
29
+
30
+ [project.scripts]
31
+ keys-scan = "app.cli:main"
32
+ receipt-studio = "app.ui:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ include = ["app*", "backends*"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ pythonpath = ["."]
40
+ addopts = "-q"
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8.3
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ watchdog>=4.0
2
+ httpx>=0.27
3
+ pydantic>=2.8
4
+ pydantic-settings>=2.4
5
+ gradio>=4.44
6
+ sqlite-vec>=0.1.6
7
+ pillow>=10.4
8
+ pillow-heif>=0.18
9
+ pypdfium2>=4.30
10
+ uvicorn>=0.30
11
+ python-multipart>=0.0.9
scripts/Receipt-Studio.desktop ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Desktop Entry]
2
+ Version=1.0
3
+ Type=Application
4
+ Name=Receipt Studio
5
+ Comment=Open the receipt scanner Review UI (phone upload on LAN)
6
+ Exec=bash -c '"%k"'
7
+ # %k is unreliable across desktops; the installer copies a wrapper that cds to the repo.
8
+ TryExec=
9
+ Icon=applications-office
10
+ Terminal=false
11
+ Categories=Office;Utility;
12
+ StartupNotify=true
scripts/install-launcher.sh ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Linux: one-click icon on the Desktop. macOS: aliases start-ui.command.
3
+ set -euo pipefail
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ chmod +x "$ROOT/scripts/start-ui.sh" "$ROOT/scripts/start-ui.command"
6
+
7
+ if [[ "$(uname -s)" == "Darwin" ]]; then
8
+ DEST="$HOME/Desktop/Receipt Studio.command"
9
+ ln -sf "$ROOT/scripts/start-ui.command" "$DEST"
10
+ chmod +x "$DEST"
11
+ echo "Mac: double-click Desktop/Receipt Studio.command (first time: right-click → Open)"
12
+ exit 0
13
+ fi
14
+
15
+ DESKTOP="${XDG_DESKTOP_DIR:-$HOME/Desktop}"
16
+ mkdir -p "$DESKTOP" "$HOME/.local/share/applications"
17
+ APP="$DESKTOP/Receipt Studio.desktop"
18
+ cat > "$APP" <<EOF
19
+ [Desktop Entry]
20
+ Version=1.0
21
+ Type=Application
22
+ Name=Receipt Studio
23
+ Comment=Open the receipt scanner Review UI
24
+ Exec=$ROOT/scripts/start-ui.sh
25
+ Path=$ROOT
26
+ Icon=applications-office
27
+ Terminal=false
28
+ Categories=Office;Utility;
29
+ StartupNotify=true
30
+ EOF
31
+ chmod +x "$APP"
32
+ cp "$APP" "$HOME/.local/share/applications/receipt-studio.desktop"
33
+ echo "Linux: double-click Desktop/Receipt Studio.desktop (Mark as trusted / Allow Launching if asked)"
scripts/serve-gemma.sh ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Gemma 4 12B Unified. GPU util 0.15 (~18.3GiB of 121.7GiB). Never above 0.85.
3
+ # BF16 weights ~23GB cannot fit; FP8 ~12.5GB + KV in the rest.
4
+ set -euo pipefail
5
+ MODEL="${RECEIPT_GEMMA_PATH:-$HOME/models-gemma4-12b-it}"
6
+ NAME="${RECEIPT_LLM_MODEL:-google/gemma-4-12B-it}"
7
+ HOST="${RECEIPT_VLLM_HOST:-0.0.0.0}"
8
+ PORT="${RECEIPT_VLLM_PORT:-8080}"
9
+ # User-set 0.15. Fleet hard cap 0.85.
10
+ UTIL="${RECEIPT_GPU_MEMORY_UTILIZATION:-0.15}"
11
+ MAX_LEN="${RECEIPT_VLLM_MAX_MODEL_LEN:-8192}"
12
+
13
+ if [[ ! -f "$MODEL/config.json" ]]; then
14
+ echo "Gemma checkpoint not found: $MODEL" >&2
15
+ exit 1
16
+ fi
17
+
18
+ python3 - "$UTIL" <<'PY'
19
+ import sys
20
+ util = float(sys.argv[1])
21
+ if util > 0.85:
22
+ raise SystemExit(f"gpu_memory_utilization {util} > 0.85 hard cap")
23
+ print(f"util={util:.4f} pool~{util*121.69:.1f}GiB of 121.7GiB")
24
+ print("context: max-model-len default 8192 (receipts). KV estimate at 0.15:")
25
+ print(" conservative (48-layer full attn fp16): ~12k tokens")
26
+ print(" hybrid (8 full + 40 sliding-1024): ~65k tokens")
27
+ print(" model native max_position_embeddings: 262144 (not reachable at 0.15)")
28
+ PY
29
+
30
+ exec vllm serve "$MODEL" \
31
+ --served-model-name "$NAME" \
32
+ --host "$HOST" \
33
+ --port "$PORT" \
34
+ --gpu-memory-utilization "$UTIL" \
35
+ --max-model-len "$MAX_LEN" \
36
+ --max-num-seqs 2 \
37
+ --max-num-batched-tokens 2048 \
38
+ --quantization fp8 \
39
+ --enforce-eager
scripts/start-ui.bat ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Windows: double-click this file.
3
+ cd /d "%~dp0\.."
4
+ if not exist ".venv\Scripts\python.exe" (
5
+ echo Creating .venv (one time)...
6
+ py -3.12 -m venv .venv || python -m venv .venv
7
+ .venv\Scripts\python.exe -m pip install -e ".[dev]"
8
+ )
9
+ .venv\Scripts\python.exe -m app.launch
10
+ if errorlevel 1 pause
scripts/start-ui.command ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ #!/bin/bash
2
+ # macOS: double-click this file (first time: right-click → Open).
3
+ cd "$(dirname "$0")"
4
+ exec ./start-ui.sh
scripts/start-ui.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Linux / macOS: double-click start-ui.command on Mac, or run this script.
3
+ set -euo pipefail
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ cd "$ROOT"
6
+ if [[ ! -x .venv/bin/python ]]; then
7
+ echo "Creating .venv (one time)…"
8
+ python3 -m venv .venv
9
+ .venv/bin/pip install -e ".[dev]"
10
+ fi
11
+ exec .venv/bin/python -m app.launch
skills/keys-receipt-scanner/SKILL.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: keys-receipt-scanner
3
+ description: Scan receipts and paper documents with the Lamp camera, extract totals/line items, categorize, and store them. Use when the user says scan this, scan the receipt, snap this invoice, log this expense, what did I just buy, hold this up, look at this receipt, capture this document, add this to expenses, or shows paper to the camera.
4
+ ---
5
+
6
+ # keys-receipt-scanner
7
+
8
+ Built-in Autonomous OS skill. The **Lamp camera** is the eye. The **GPU box** runs Gemma 4 12B Unified (omni vision + embed). Do **not** load 12B weights on the robot — Lamp has 6 GB RAM.
9
+
10
+ ## When to use
11
+
12
+ - User holds up a receipt, invoice, statement, or letter
13
+ - "Scan this", "log this expense", "what did this cost", "save this document"
14
+ - Phone/Syncthing drop is handled by the same `scan` CLI on the GPU box; on Lamp, still snapshot then scan
15
+
16
+ Do **not** use for "what do you see" about the room (that is `camera`) or privacy toggles (`camera` disable/enable).
17
+
18
+ ## Capture (Lamp HAL)
19
+
20
+ Reuse `[vision-image] <path>` if this turn already has one. Otherwise:
21
+
22
+ ```bash
23
+ curl -s "http://127.0.0.1:5001/camera/snapshot?save=true&width=1280&quality=85"
24
+ ```
25
+
26
+ Read `path` from the JSON. Receipts need **1280** px, not 768 — small print.
27
+
28
+ If they say it is on the desk: curl `POST http://127.0.0.1:5001/servo/aim` with `{"direction":"down"}` **before** snapshot (`[HW:…]` would move after the photo).
29
+
30
+ Then:
31
+
32
+ ```
33
+ [HW:/emotion:{"emotion":"curious","intensity":0.6}]
34
+ ```
35
+
36
+ ## Extract + store
37
+
38
+ From the skill checkout / install prefix (repo root on the GPU box, or `/opt/keys-receipt-scanner` on Lamp if you copied the package):
39
+
40
+ ```bash
41
+ python -m app.cli scan --image "$SNAP_PATH"
42
+ ```
43
+
44
+ The CLI POSTs the JPEG to Gemma 4 12B Unified (`RECEIPT_LLM_BASE_URL`, default `http://127.0.0.1:8080/v1`) with `image_url`, categorizes, embeds on the same omni server (dim 3840), writes SQLite.
45
+
46
+ On the Lamp, set `RECEIPT_LLM_BASE_URL` / `RECEIPT_EMBED_BASE_URL` to the GPU box (LAN). Never `api.x.ai`. Never Lightning with an image.
47
+
48
+ ## Speak
49
+
50
+ After JSON comes back, say in the user's language:
51
+
52
+ - kind + category + vendor
53
+ - date and total (with currency)
54
+ - 1–2 notable line items
55
+ - match band if SKU auto/review
56
+
57
+ Then:
58
+
59
+ ```
60
+ [HW:/emotion:{"emotion":"acknowledge","intensity":0.7}]
61
+ ```
62
+
63
+ If `status` is `needs_extract` / `failed`, say you could not read it and ask them to hold it flatter / closer. Do not invent totals.
64
+
65
+ ## Query
66
+
67
+ ```bash
68
+ python -m app.cli query --category groceries --limit 10
69
+ python -m app.cli show <id>
70
+ ```
71
+
72
+ ## Privacy
73
+
74
+ Only snapshot when they asked to scan paper. Camera off stays a `camera` skill concern. `/camera/snapshot` auto-enables for the frame.
75
+
76
+ ## Fit
77
+
78
+ | Piece | Runs on |
79
+ |---|---|
80
+ | This SKILL.md + snapshot curl | Lamp (Autonomous OS) |
81
+ | `app.cli scan` HTTP client + SQLite | Lamp **or** GPU box |
82
+ | Gemma 4 12B Unified weights | GPU box only |
skills/keys-receipt-scanner/references/hardware.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Hardware split
2
+
3
+ Lamp: 6 GB RAM. This skill + HAL snapshot only.
4
+
5
+ Gemma 4 12B Unified (`hidden_size` 3840) and Qwen3.8-27B run on the GPU box.
6
+
7
+ Camera: `GET http://127.0.0.1:5001/camera/snapshot?save=true&width=1280&quality=85`
skills/keys-receipt-scanner/scripts/scan.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Lamp/agent entry: python skills/keys-receipt-scanner/scripts/scan.py --image PATH"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parents[3]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from app.cli import main
14
+
15
+ if __name__ == "__main__":
16
+ argv = sys.argv[1:]
17
+ if not argv or argv[0] not in {"scan", "query", "show", "snapshot", "ui"}:
18
+ argv = ["scan", *argv]
19
+ main(argv)
skills/keys-receipt-scanner/skill.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "keys-receipt-scanner",
3
+ "capabilities": ["vision"],
4
+ "category": "Productivity",
5
+ "tags": ["receipt", "document", "camera", "expense", "ocr", "scanner"]
6
+ }
tests/conftest.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from PIL import Image
8
+
9
+ from app.config import Settings, load_settings
10
+
11
+
12
+ def tiny_jpeg() -> bytes:
13
+ image = Image.new("RGB", (16, 16), (240, 240, 240))
14
+ buf = io.BytesIO()
15
+ image.save(buf, format="JPEG")
16
+ return buf.getvalue()
17
+
18
+
19
+ @pytest.fixture
20
+ def settings(tmp_path: Path) -> Settings:
21
+ return load_settings(
22
+ root_dir=tmp_path,
23
+ data_dir=tmp_path / "data",
24
+ inbox_dir=tmp_path / "inbox",
25
+ processing_dir=tmp_path / "processing",
26
+ processed_dir=tmp_path / "processed",
27
+ failed_dir=tmp_path / "failed",
28
+ exports_dir=tmp_path / "exports",
29
+ idle_seconds=0.05,
30
+ llm_backend="gemma",
31
+ llm_base_url="http://llm.test/v1",
32
+ llm_model="google/gemma-4-12B-it",
33
+ embed_backend="omni",
34
+ embed_base_url="http://llm.test/v1",
35
+ embed_model="google/gemma-4-12B-it",
36
+ embed_dim=8,
37
+ embed_prefix=True,
38
+ )
tests/test_camera.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ from app.camera import snapshot
8
+ from app.config import Settings
9
+
10
+
11
+ def test_snapshot_reads_path(settings: Settings, tmp_path: Path) -> None:
12
+ saved = tmp_path / "snap.jpg"
13
+ saved.write_bytes(b"x")
14
+
15
+ def handler(request: httpx.Request) -> httpx.Response:
16
+ assert "width=1280" in str(request.url)
17
+ return httpx.Response(200, json={"path": str(saved)})
18
+
19
+ client = httpx.Client(transport=httpx.MockTransport(handler), base_url="http://hal.test")
20
+ settings = settings.model_copy(update={"camera_url": "http://hal.test"})
21
+ assert snapshot(settings, client=client) == saved
tests/test_db.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from app.config import Settings
6
+ from app.db import (
7
+ EmbedIndexError,
8
+ VecLoadError,
9
+ connect,
10
+ delete_receipt,
11
+ init_schema,
12
+ insert_receipt,
13
+ )
14
+ from app.schemas import ReceiptExtract, ReceiptStatus
15
+
16
+
17
+ def test_schema_and_meta(settings: Settings) -> None:
18
+ try:
19
+ con = connect(settings.db_path)
20
+ init_schema(con, settings)
21
+ except VecLoadError:
22
+ pytest.skip("sqlite-vec not loadable")
23
+ tables = {
24
+ row[0]
25
+ for row in con.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view')")
26
+ }
27
+ assert "receipts" in tables
28
+ assert "catalog" in tables
29
+ rid = insert_receipt(
30
+ con,
31
+ source_path="x.jpg",
32
+ sha256="abc",
33
+ status=ReceiptStatus.needs_review,
34
+ extract=ReceiptExtract(vendor="A", category="dining"),
35
+ )
36
+ assert rid == 1
37
+ other = settings.model_copy(update={"embed_dim": 3840, "embed_model": "other"})
38
+ with pytest.raises(EmbedIndexError):
39
+ init_schema(con, other)
40
+ assert delete_receipt(con, rid, unlink_file=False) is True
41
+ assert con.execute("SELECT COUNT(*) AS n FROM receipts").fetchone()["n"] == 0
42
+ assert delete_receipt(con, rid, unlink_file=False) is False
43
+ con.close()
tests/test_embed_prefix.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from app.config import Settings
6
+ from backends.gemma import GemmaEmbed
7
+ from backends.openai_compat import apply_embed_prefix, EmbedDimensionError
8
+ import pytest
9
+
10
+
11
+ def test_prefix_query_and_passage() -> None:
12
+ assert apply_embed_prefix("milk", "query", enabled=True) == "query: milk"
13
+ assert apply_embed_prefix("milk 2%", "passage", enabled=True) == "passage: milk 2%"
14
+ assert apply_embed_prefix("query: already", "query", enabled=True) == "query: already"
15
+
16
+
17
+ def test_embed_request_body_has_prefix_and_input_type(settings: Settings) -> None:
18
+ recorded: list[tuple[str, dict]] = []
19
+
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ recorded.append((request.url.path, request.read().decode()))
22
+ return httpx.Response(
23
+ 200,
24
+ json={
25
+ "data": [
26
+ {"index": 0, "embedding": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]}
27
+ ]
28
+ },
29
+ )
30
+
31
+ client = httpx.Client(transport=httpx.MockTransport(handler), base_url="http://llm.test/v1")
32
+ embed = GemmaEmbed(settings, client=client)
33
+ vecs = embed.embed(["milk"], input_type="query")
34
+ assert len(vecs[0]) == 8
35
+ path, body = recorded[0]
36
+ assert path.endswith("/embeddings")
37
+ assert "query: milk" in body
38
+ assert '"input_type": "query"' in body or '"input_type":"query"' in body
39
+
40
+
41
+ def test_wrong_dim_rejected(settings: Settings) -> None:
42
+ def handler(_request: httpx.Request) -> httpx.Response:
43
+ return httpx.Response(200, json={"data": [{"index": 0, "embedding": [1.0, 0.0]}]})
44
+
45
+ client = httpx.Client(transport=httpx.MockTransport(handler), base_url="http://llm.test/v1")
46
+ embed = GemmaEmbed(settings, client=client)
47
+ with pytest.raises(EmbedDimensionError):
48
+ embed.embed(["x"], input_type="passage")
tests/test_extract_vision.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+
7
+ from app.config import Settings
8
+ from app.extract import extract_receipt
9
+ from backends.gemma import GemmaLLM
10
+ from backends.ollama import OllamaLLM
11
+ from tests.conftest import tiny_jpeg
12
+
13
+ EXTRACT = {
14
+ "doc_kind": "receipt",
15
+ "category": "dining",
16
+ "vendor": "Cafe",
17
+ "date": "2026-08-21",
18
+ "tax": 0.5,
19
+ "total": 8.0,
20
+ "currency": "USD",
21
+ "line_items": [],
22
+ }
23
+
24
+
25
+ def _chat_handler(sink: list[dict]):
26
+ def handler(request: httpx.Request) -> httpx.Response:
27
+ payload = json.loads(request.content)
28
+ sink.append(payload)
29
+ return httpx.Response(
30
+ 200,
31
+ json={"choices": [{"message": {"content": json.dumps(EXTRACT)}}]},
32
+ )
33
+
34
+ return handler
35
+
36
+
37
+ def test_gemma_sends_image_url(settings: Settings) -> None:
38
+ sink: list[dict] = []
39
+ client = httpx.Client(
40
+ transport=httpx.MockTransport(_chat_handler(sink)),
41
+ base_url="http://llm.test/v1",
42
+ )
43
+ llm = GemmaLLM(settings, client=client)
44
+ extract = extract_receipt(
45
+ llm, settings=settings, image_jpeg=tiny_jpeg(), ocr_text=None
46
+ )
47
+ assert extract.vendor == "Cafe"
48
+ content = sink[0]["messages"][1]["content"]
49
+ assert isinstance(content, list)
50
+ kinds = {part["type"] for part in content}
51
+ assert "image_url" in kinds
52
+ url = next(part["image_url"]["url"] for part in content if part["type"] == "image_url")
53
+ assert url.startswith("data:image/jpeg;base64,")
54
+
55
+
56
+ def test_lightning_never_sends_image(settings: Settings) -> None:
57
+ settings = settings.model_copy(
58
+ update={
59
+ "llm_backend": "ollama",
60
+ "llm_model": "nemotron-3.5-lightning",
61
+ "llm_accepts_images": False,
62
+ }
63
+ )
64
+ sink: list[dict] = []
65
+ client = httpx.Client(
66
+ transport=httpx.MockTransport(_chat_handler(sink)),
67
+ base_url="http://llm.test/v1",
68
+ )
69
+ llm = OllamaLLM(settings, client=client)
70
+ assert llm.accepts_images is False
71
+ extract = extract_receipt(
72
+ llm, settings=settings, image_jpeg=tiny_jpeg(), ocr_text="Cafe 8.00"
73
+ )
74
+ assert extract.total is not None
75
+ content = sink[0]["messages"][1]["content"]
76
+ assert isinstance(content, str)
77
+ dumped = json.dumps(sink[0])
78
+ assert "image_url" not in dumped
79
+ assert "data:image" not in dumped