v1.0 alpha: keys-Auto Receipts Studio (iPhone / may add Autonomous Lamp Skill)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +28 -0
- .gitignore +30 -0
- .grok/skills/keys-receipt-scanner/SKILL.md +14 -0
- AGENTS.md +95 -0
- LICENSE +17 -0
- README.md +226 -0
- app/__init__.py +3 -0
- app/__main__.py +4 -0
- app/camera.py +34 -0
- app/cli.py +124 -0
- app/config.py +119 -0
- app/db.py +412 -0
- app/embed.py +47 -0
- app/extract.py +64 -0
- app/launch.py +154 -0
- app/match.py +147 -0
- app/media.py +57 -0
- app/ocr.py +16 -0
- app/pipeline.py +171 -0
- app/schemas.py +135 -0
- app/ui.py +634 -0
- app/watcher.py +133 -0
- backends/__init__.py +59 -0
- backends/apple.py +31 -0
- backends/base.py +47 -0
- backends/cpu.py +14 -0
- backends/gemma.py +26 -0
- backends/nvidia.py +37 -0
- backends/ollama.py +83 -0
- backends/openai_compat.py +208 -0
- oneshot.bat +11 -0
- oneshot.sh +112 -0
- pyproject.toml +40 -0
- requirements-dev.txt +2 -0
- requirements.txt +11 -0
- scripts/Receipt-Studio.desktop +12 -0
- scripts/install-launcher.sh +33 -0
- scripts/serve-gemma.sh +39 -0
- scripts/start-ui.bat +10 -0
- scripts/start-ui.command +4 -0
- scripts/start-ui.sh +11 -0
- skills/keys-receipt-scanner/SKILL.md +82 -0
- skills/keys-receipt-scanner/references/hardware.md +7 -0
- skills/keys-receipt-scanner/scripts/scan.py +19 -0
- skills/keys-receipt-scanner/skill.json +6 -0
- tests/conftest.py +38 -0
- tests/test_camera.py +21 -0
- tests/test_db.py +43 -0
- tests/test_embed_prefix.py +48 -0
- tests/test_extract_vision.py +79 -0
.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://<this-pc-lan-ip>: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
|