drowzeys commited on
Commit
676f5d4
·
verified ·
1 Parent(s): 177c99a

Upload folder using huggingface_hub

Browse files
.env.example CHANGED
@@ -2,11 +2,19 @@
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
@@ -21,7 +29,8 @@ 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
 
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
+ # Route: auto | direct | hermes | studio
6
  RECEIPT_LLM_BACKEND=gemma
7
+ RECEIPT_LLM_ROUTE=auto
8
  RECEIPT_LLM_BASE_URL=http://127.0.0.1:8080/v1
9
  RECEIPT_LLM_MODEL=google/gemma-4-12B-it
10
  RECEIPT_LLM_ACCEPTS_IMAGES=true
11
  RECEIPT_LLM_MAX_TOKENS=8192
12
+ # Lamp: POST snapshots here (Receipt Studio on the GPU box)
13
+ # RECEIPT_STUDIO_URL=http://192.168.1.10:7860
14
+ # RECEIPT_GPU_HOST=192.168.1.10
15
+ # Hermes custom_providers discovery (optional)
16
+ # RECEIPT_HERMES_BASE_URL=http://127.0.0.1:8080/v1
17
+ # RECEIPT_HERMES_MODEL=google/gemma-4-12B-it
18
 
19
  RECEIPT_EMBED_BACKEND=omni
20
  RECEIPT_EMBED_BASE_URL=http://127.0.0.1:8080/v1
 
29
  RECEIPT_SNAPSHOT_WIDTH=1280
30
  RECEIPT_SNAPSHOT_QUALITY=85
31
 
32
+ # UI / inbox. One-click (app.launch / oneshot) forces SHARE_LAN=true for iPhone.
33
+ # Manual `python -m app.cli ui` needs true or the phone cannot reach this PC.
34
  RECEIPT_UI_HOST=127.0.0.1
35
  RECEIPT_UI_PORT=7860
36
  RECEIPT_UI_SHARE_LAN=false
.gitattributes CHANGED
@@ -36,3 +36,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
36
  docs/screenshots/05-iphone-home.jpg filter=lfs diff=lfs merge=lfs -text
37
  docs/screenshots/06-iphone-upload.jpg filter=lfs diff=lfs merge=lfs -text
38
  docs/screenshots/07-iphone-complete.jpg filter=lfs diff=lfs merge=lfs -text
 
 
36
  docs/screenshots/05-iphone-home.jpg filter=lfs diff=lfs merge=lfs -text
37
  docs/screenshots/06-iphone-upload.jpg filter=lfs diff=lfs merge=lfs -text
38
  docs/screenshots/07-iphone-complete.jpg filter=lfs diff=lfs merge=lfs -text
39
+ docs/promo/Keys-Auto-Receipts-Studio-promo.mp4 filter=lfs diff=lfs merge=lfs -text
.grok/skills/keys-receipt-scanner/SKILL.md CHANGED
@@ -7,7 +7,8 @@ description: Scan receipts/docs via Lamp camera or inbox, Gemma 4 12B Unified om
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.
 
7
 
8
  Work in `keys-automatic-receipt-doc-scanner`. Architecture is in `AGENTS.md`.
9
 
10
+ - Autonomous OS Lamp skill lives in `skills/keys-receipt-scanner/` (push to `/root/.openclaw/workspace/skills/keys-receipt-scanner/`).
11
+ - Lamp 6 GB: skill + camera only. Gemma 4 12B Unified stays on the GPU box (direct vLLM, Hermes `custom_providers`, or Studio `/api/inbox`).
12
  - Default LLM/embed: Gemma 4 12B Unified, dim 3840, `embed_backend=omni`.
13
  - Qwen3.8-27B ADay777 is the vision fallback (`llm_backend=nvidia`). Lightning is text-only — never send images.
14
  - Do not download weights. Do not raise GPU util above 0.85.
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_camera.py::test_aim_posts_direction",
3
+ "tests/test_camera.py::test_snapshot_reads_path",
4
+ "tests/test_db.py::test_schema_and_meta",
5
+ "tests/test_embed_prefix.py::test_embed_request_body_has_prefix_and_input_type",
6
+ "tests/test_embed_prefix.py::test_prefix_query_and_passage",
7
+ "tests/test_embed_prefix.py::test_wrong_dim_rejected",
8
+ "tests/test_extract_vision.py::test_gemma_sends_image_url",
9
+ "tests/test_extract_vision.py::test_lightning_never_sends_image",
10
+ "tests/test_match.py::test_bands",
11
+ "tests/test_match.py::test_exact_sku",
12
+ "tests/test_match.py::test_knn_auto",
13
+ "tests/test_route.py::test_looks_like_gemma",
14
+ "tests/test_route.py::test_parse_hermes_finds_gemma_only",
15
+ "tests/test_route.py::test_parse_live_hermes_config_does_not_crash",
16
+ "tests/test_route.py::test_resolve_auto_studio",
17
+ "tests/test_route.py::test_resolve_direct",
18
+ "tests/test_route.py::test_resolve_hermes",
19
+ "tests/test_scan_script.py::test_post_gemma_sends_image_url",
20
+ "tests/test_scan_script.py::test_post_studio_polls_job",
21
+ "tests/test_schema.py::test_bad_types",
22
+ "tests/test_schema.py::test_parse_fenced",
23
+ "tests/test_schema.py::test_parse_good",
24
+ "tests/test_schema.py::test_unknown_category_falls_back",
25
+ "tests/test_skill_frontmatter.py::test_skill_frontmatter",
26
+ "tests/test_ui_edit.py::test_as_text_joins_code_widget_lists",
27
+ "tests/test_ui_edit.py::test_parse_rid",
28
+ "tests/test_ui_edit.py::test_phone_page_splits_camera_and_library",
29
+ "tests/test_ui_edit.py::test_pretty_json_indents",
30
+ "tests/test_ui_edit.py::test_pretty_json_rejects_garbage",
31
+ "tests/test_ui_edit.py::test_table_rows_from_lists",
32
+ "tests/test_watcher.py::test_ignores_syncthing",
33
+ "tests/test_watcher.py::test_settles_after_idle"
34
+ ]
app/camera.py CHANGED
@@ -32,3 +32,18 @@ def snapshot(settings: Settings, *, client: httpx.Client | None = None) -> Path:
32
  if not path:
33
  raise CameraError(f"snapshot JSON missing path: {payload!r}")
34
  return Path(path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  if not path:
33
  raise CameraError(f"snapshot JSON missing path: {payload!r}")
34
  return Path(path)
35
+
36
+
37
+ def aim(settings: Settings, direction: str = "down", *, client: httpx.Client | None = None) -> None:
38
+ """HAL servo aim. Call before snapshot when the paper is on the desk."""
39
+ url = f"{settings.camera_url.rstrip('/')}/servo/aim"
40
+ own = client is None
41
+ http = client or httpx.Client(timeout=15.0)
42
+ try:
43
+ response = http.post(url, json={"direction": direction})
44
+ response.raise_for_status()
45
+ except httpx.HTTPError as exc:
46
+ raise CameraError(f"Lamp servo aim failed: {exc}") from exc
47
+ finally:
48
+ if own:
49
+ http.close()
app/cli.py CHANGED
@@ -6,7 +6,7 @@ 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
@@ -21,6 +21,8 @@ def _print_result(result: ProcessResult) -> None:
21
 
22
  def cmd_scan(args: argparse.Namespace) -> int:
23
  settings = load_settings()
 
 
24
  if args.image:
25
  image = Path(args.image)
26
  else:
@@ -93,6 +95,7 @@ def build_parser() -> argparse.ArgumentParser:
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")
 
6
  import sys
7
  from pathlib import Path
8
 
9
+ from app.camera import aim, 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
 
21
 
22
  def cmd_scan(args: argparse.Namespace) -> int:
23
  settings = load_settings()
24
+ if getattr(args, "aim_down", False) and not args.image:
25
+ aim(settings, "down")
26
  if args.image:
27
  image = Path(args.image)
28
  else:
 
95
  scan = sub.add_parser("scan", help="process one image (Lamp camera if omitted)")
96
  scan.add_argument("--image", help="path to jpeg/png/pdf; omit to snapshot Lamp camera")
97
  scan.add_argument("--inbox", action="store_true", help="copy into inbox/ first")
98
+ scan.add_argument("--aim-down", action="store_true", help="HAL servo down before snapshot")
99
  scan.set_defaults(func=cmd_scan)
100
 
101
  query = sub.add_parser("query", help="list stored receipts")
app/config.py CHANGED
@@ -62,6 +62,14 @@ class Settings(BaseSettings):
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 = ""
 
62
  llm_accepts_images: bool = True
63
  llm_max_tokens: int = 8192
64
  llm_timeout_s: float = 180.0
65
+ # auto: Studio API if up, else vLLM, else Hermes custom_providers.
66
+ # direct: RECEIPT_LLM_BASE_URL. hermes: Hermes-discovered Gemma. studio: POST /api/inbox.
67
+ llm_route: str = "auto"
68
+ studio_url: str = ""
69
+ gpu_host: str = ""
70
+ hermes_base_url: str = ""
71
+ hermes_model: str = "google/gemma-4-12B-it"
72
+ hermes_config_path: Path = Path.home() / ".hermes" / "config.yaml"
73
 
74
  embed_backend: str = "omni"
75
  embed_base_url: str = ""
app/route.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve Gemma 4 12B Unified: direct vLLM, Hermes custom_providers, or Studio API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Callable
9
+ from urllib.parse import urlparse
10
+
11
+ import httpx
12
+
13
+ from app.config import Settings
14
+
15
+ GEMMA_MODEL_MARKERS = ("gemma-4-12b", "gemma4-12b", "gemma-4-12B", "12b-it", "12B-it")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Brain:
20
+ kind: str # direct | hermes | studio
21
+ base_url: str
22
+ model: str
23
+ source: str
24
+
25
+
26
+ def _norm(url: str) -> str:
27
+ url = url.strip().rstrip("/")
28
+ if url and not url.endswith("/v1") and "/v1" not in urlparse(url).path:
29
+ url = url + "/v1"
30
+ return url
31
+
32
+
33
+ def looks_like_gemma(name: str) -> bool:
34
+ lower = name.lower()
35
+ return any(m.lower() in lower for m in GEMMA_MODEL_MARKERS) or "gemma" in lower
36
+
37
+
38
+ def parse_hermes_providers(text: str) -> list[Brain]:
39
+ """Pull OpenAI-compat custom_providers from a Hermes config.yaml."""
40
+ found: list[Brain] = []
41
+ block = text.split("custom_providers:", 1)
42
+ if len(block) < 2:
43
+ return found
44
+ body = block[1]
45
+ chunks = re.split(r"\n - name:", body)
46
+ for chunk in chunks[1:]:
47
+ name_m = re.match(r"\s*([^\n]+)", chunk)
48
+ url_m = re.search(r"base_url:\s*(\S+)", chunk)
49
+ if not url_m:
50
+ continue
51
+ name = (name_m.group(1).strip() if name_m else "")
52
+ base = url_m.group(1).strip().strip("\"'")
53
+ models = re.findall(r"\n - (\S+)", chunk)
54
+ if looks_like_gemma(name) or any(looks_like_gemma(m) for m in models):
55
+ model = next((m for m in models if looks_like_gemma(m)), models[0] if models else "google/gemma-4-12B-it")
56
+ found.append(
57
+ Brain(kind="hermes", base_url=_norm(base), model=model, source=f"hermes:{name}")
58
+ )
59
+ return found
60
+
61
+
62
+ def brains_from_hermes_file(path: Path) -> list[Brain]:
63
+ if not path.is_file():
64
+ return []
65
+ try:
66
+ return parse_hermes_providers(path.read_text(encoding="utf-8"))
67
+ except OSError:
68
+ return []
69
+
70
+
71
+ def probe_models(base_url: str, *, timeout_s: float = 1.5, client: httpx.Client | None = None) -> list[str]:
72
+ url = _norm(base_url)
73
+ own = client is None
74
+ http = client or httpx.Client(timeout=timeout_s)
75
+ try:
76
+ response = http.get(f"{url}/models")
77
+ if response.status_code >= 500:
78
+ return []
79
+ payload = response.json()
80
+ rows = payload.get("data", payload if isinstance(payload, list) else [])
81
+ ids: list[str] = []
82
+ for row in rows:
83
+ if isinstance(row, dict) and row.get("id"):
84
+ ids.append(str(row["id"]))
85
+ elif isinstance(row, str):
86
+ ids.append(row)
87
+ return ids
88
+ except (httpx.HTTPError, ValueError, TypeError):
89
+ return []
90
+ finally:
91
+ if own:
92
+ http.close()
93
+
94
+
95
+ def probe_studio(studio_url: str, *, timeout_s: float = 1.5, client: httpx.Client | None = None) -> bool:
96
+ url = studio_url.rstrip("/")
97
+ own = client is None
98
+ http = client or httpx.Client(timeout=timeout_s)
99
+ try:
100
+ try:
101
+ response = http.get(f"{url}/api/health")
102
+ if response.status_code < 500:
103
+ return True
104
+ except httpx.HTTPError:
105
+ pass
106
+ try:
107
+ response = http.get(f"{url}/phone")
108
+ return response.status_code < 500
109
+ except httpx.HTTPError:
110
+ return False
111
+ finally:
112
+ if own:
113
+ http.close()
114
+
115
+
116
+ ProbeFn = Callable[[str], list[str]]
117
+ StudioProbeFn = Callable[[str], bool]
118
+
119
+
120
+ def candidate_brains(settings: Settings) -> list[Brain]:
121
+ out: list[Brain] = []
122
+ seen: set[str] = set()
123
+
124
+ def add(brain: Brain) -> None:
125
+ key = f"{brain.kind}|{brain.base_url}|{brain.model}"
126
+ if key not in seen:
127
+ seen.add(key)
128
+ out.append(brain)
129
+
130
+ model = settings.llm_model
131
+ add(Brain("direct", _norm(settings.llm_base_url), model, "RECEIPT_LLM_BASE_URL"))
132
+ if settings.hermes_base_url:
133
+ add(Brain("hermes", _norm(settings.hermes_base_url), settings.hermes_model or model, "RECEIPT_HERMES_BASE_URL"))
134
+ for brain in brains_from_hermes_file(settings.hermes_config_path):
135
+ add(brain)
136
+ add(Brain("direct", "http://127.0.0.1:8080/v1", model, "localhost:8080"))
137
+ host = (settings.gpu_host or "").strip()
138
+ if host:
139
+ add(Brain("direct", _norm(f"http://{host}:8080/v1"), model, f"RECEIPT_GPU_HOST:{host}"))
140
+ return out
141
+
142
+
143
+ def resolve_brain(
144
+ settings: Settings,
145
+ *,
146
+ probe: ProbeFn | None = None,
147
+ studio_probe: StudioProbeFn | None = None,
148
+ ) -> Brain:
149
+ """Pick Gemma 4 12B: studio (Lamp) vs direct vLLM vs Hermes-discovered provider."""
150
+ route = settings.llm_route.lower().strip()
151
+ model = settings.llm_model
152
+
153
+ if route == "studio":
154
+ url = (settings.studio_url or "").rstrip("/")
155
+ if not url:
156
+ raise RuntimeError("RECEIPT_LLM_ROUTE=studio requires RECEIPT_STUDIO_URL")
157
+ return Brain("studio", url, model, "RECEIPT_STUDIO_URL")
158
+
159
+ if route == "direct":
160
+ return Brain("direct", _norm(settings.llm_base_url), model, "RECEIPT_LLM_BASE_URL")
161
+
162
+ if route == "hermes":
163
+ hermes = [
164
+ b
165
+ for b in candidate_brains(settings)
166
+ if b.kind == "hermes" or b.source.startswith("hermes") or b.source == "RECEIPT_HERMES_BASE_URL"
167
+ ]
168
+ if settings.hermes_base_url:
169
+ hermes.insert(
170
+ 0,
171
+ Brain("hermes", _norm(settings.hermes_base_url), settings.hermes_model or model, "RECEIPT_HERMES_BASE_URL"),
172
+ )
173
+ if not hermes:
174
+ hermes = brains_from_hermes_file(settings.hermes_config_path)
175
+ if not hermes:
176
+ raise RuntimeError(
177
+ "no Hermes Gemma 4 12B provider — run scripts/register-hermes-gemma.py "
178
+ "or set RECEIPT_HERMES_BASE_URL"
179
+ )
180
+ check = probe or probe_models
181
+ for brain in hermes:
182
+ ids = check(brain.base_url)
183
+ if ids is None:
184
+ continue
185
+ if not ids or any(looks_like_gemma(i) for i in ids) or brain.model in ids:
186
+ return brain
187
+ return hermes[0]
188
+
189
+ # auto
190
+ studio = (settings.studio_url or "").rstrip("/")
191
+ if studio:
192
+ ok = (studio_probe or (lambda u: probe_studio(u)))(studio)
193
+ if ok:
194
+ return Brain("studio", studio, model, "RECEIPT_STUDIO_URL")
195
+
196
+ check = probe or probe_models
197
+ for brain in candidate_brains(settings):
198
+ ids = check(brain.base_url)
199
+ if not ids:
200
+ continue
201
+ if any(looks_like_gemma(i) for i in ids) or brain.model in ids or brain.kind == "hermes":
202
+ if brain.model not in ids and ids:
203
+ gemma_id = next((i for i in ids if looks_like_gemma(i)), ids[0])
204
+ return Brain(brain.kind, brain.base_url, gemma_id, brain.source)
205
+ return brain
206
+ # OpenAI-compat that lists nothing useful but is up: keep configured model
207
+ if brain.source == "RECEIPT_LLM_BASE_URL":
208
+ return brain
209
+
210
+ # Prefer configured URL even if the probe failed (server may be starting).
211
+ return Brain("direct", _norm(settings.llm_base_url), model, "RECEIPT_LLM_BASE_URL")
app/ui.py CHANGED
@@ -76,7 +76,7 @@ function add(cls, 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));
@@ -240,6 +240,24 @@ def build_app(settings: Settings) -> FastAPI:
240
  }
241
  )
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  @api.get("/api/jobs/{job_id}")
244
  async def job_status(job_id: str) -> JSONResponse:
245
  with _JOBS_LOCK:
 
76
  out.prepend(d);
77
  }
78
  async function waitDone(jobId) {
79
+ add('ok', 'Uploaded. Reading the receipt on the GPU box…');
80
  const deadline = Date.now() + 180000;
81
  while (Date.now() < deadline) {
82
  await new Promise(r => setTimeout(r, 2000));
 
240
  }
241
  )
242
 
243
+ @api.get("/api/health")
244
+ async def api_health() -> JSONResponse:
245
+ from app.route import resolve_brain
246
+
247
+ try:
248
+ brain = resolve_brain(settings)
249
+ payload = {
250
+ "ok": True,
251
+ "route": settings.llm_route,
252
+ "kind": brain.kind,
253
+ "base_url": brain.base_url,
254
+ "model": brain.model,
255
+ "source": brain.source,
256
+ }
257
+ except Exception as exc:
258
+ payload = {"ok": False, "error": str(exc), "route": settings.llm_route}
259
+ return JSONResponse(payload)
260
+
261
  @api.get("/api/jobs/{job_id}")
262
  async def job_status(job_id: str) -> JSONResponse:
263
  with _JOBS_LOCK:
backends/__init__.py CHANGED
@@ -1,6 +1,7 @@
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
@@ -18,9 +19,35 @@ __all__ = [
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)
@@ -32,6 +59,7 @@ def build_llm(settings: Settings, *, client=None) -> LLMBackend:
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)
 
1
  from __future__ import annotations
2
 
3
  from app.config import Settings
4
+ from app.route import resolve_brain
5
  from backends.apple import AppleEmbed, AppleLLM, AppleOCR
6
  from backends.base import EmbedBackend, LLMBackend, OCRBackend
7
  from backends.cpu import CpuOCR
 
19
  ]
20
 
21
 
22
+ def _with_brain(settings: Settings) -> Settings:
23
+ """Point llm/embed URLs at the resolved Gemma 4 12B (direct vLLM or Hermes)."""
24
+ route = settings.llm_route.lower().strip()
25
+ backend = settings.llm_backend.lower().strip()
26
+ if route == "studio":
27
+ return settings
28
+ if backend in {"ollama", "lightning", "apple"}:
29
+ return settings
30
+ try:
31
+ brain = resolve_brain(settings)
32
+ except RuntimeError:
33
+ return settings
34
+ if brain.kind == "studio":
35
+ return settings
36
+ updates: dict[str, object] = {}
37
+ if brain.base_url and brain.base_url != settings.llm_base_url:
38
+ updates["llm_base_url"] = brain.base_url
39
+ updates["embed_base_url"] = brain.base_url
40
+ if brain.model and brain.model != settings.llm_model:
41
+ updates["llm_model"] = brain.model
42
+ if settings.embed_backend.lower() in {"omni", "gemma", "gemma4"}:
43
+ updates["embed_model"] = brain.model
44
+ return settings.model_copy(update=updates) if updates else settings
45
+
46
+
47
  def build_llm(settings: Settings, *, client=None) -> LLMBackend:
48
+ settings = _with_brain(settings)
49
  name = settings.llm_backend.lower().strip()
50
+ if name in {"gemma", "gemma4", "unified", "hermes", "auto"}:
51
  return GemmaLLM(settings, client=client)
52
  if name in {"nvidia", "vllm", "qwen", "qwen38"}:
53
  return NvidiaLLM(settings, client=client)
 
59
 
60
 
61
  def build_embed(settings: Settings, *, client=None) -> EmbedBackend:
62
+ settings = _with_brain(settings)
63
  name = settings.embed_backend.lower().strip()
64
  if name in {"omni", "gemma", "gemma4"}:
65
  return GemmaEmbed(settings, client=client)
docs/promo/Keys-Auto-Receipts-Studio-promo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b2da8185b2ffeeca42e946b1c9a76d4fabeb68b4b2d656b809d7e6fc8f900f83
3
+ size 19601635
oneshot.sh CHANGED
@@ -35,14 +35,22 @@ fi
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])
@@ -53,33 +61,45 @@ fi
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
@@ -106,7 +126,12 @@ PY
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'
 
 
35
  .venv/bin/pip install -q -e ".[dev]"
36
  [[ -f .env ]] || cp .env.example .env
37
 
38
+ HAVE_VLLM=0
39
+ if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
40
+ HAVE_VLLM=1
41
+ elif command -v vllm >/dev/null 2>&1; then
42
+ HAVE_VLLM=1
43
+ fi
44
+
45
  say "2/5 Gemma 4 12B-it weights → $MODEL_DIR"
46
+ if [[ "$HAVE_VLLM" != 1 ]]; then
47
+ echo " skip (no vllm on PATH and nothing on :$PORT_LLM) — UI-only. Point .env at a GPU box."
48
+ elif [[ -f "$MODEL_DIR/config.json" ]] && ls "$MODEL_DIR"/*.safetensors >/dev/null 2>&1; then
49
  echo " present"
50
  else
51
+ .venv/bin/pip install -q huggingface_hub
52
  mkdir -p "$MODEL_DIR"
53
+ .venv/bin/python - "$MODEL_ID" "$MODEL_DIR" <<'PY' || die "weight download failed (run: .venv/bin/hf auth login)"
 
54
  import sys
55
  from huggingface_hub import snapshot_download
56
  snapshot_download(sys.argv[1], local_dir=sys.argv[2])
 
61
  say "3/5 vLLM Gemma (util=$UTIL FP8, :$PORT_LLM)"
62
  if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
63
  echo " already serving"
64
+ elif command -v vllm >/dev/null 2>&1 && [[ -f "$MODEL_DIR/config.json" ]]; then
 
65
  mkdir -p data
66
  nohup bash "$ROOT/scripts/serve-gemma.sh" >> data/vllm-gemma.log 2>&1 &
67
  echo " pid $! log data/vllm-gemma.log"
68
+ else
69
+ echo " skip — no local vLLM/Gemma. Set RECEIPT_LLM_BASE_URL in .env to the GPU box."
70
  fi
71
 
72
  say "4/5 wait until Gemma answers /v1/models (first boot compiles kernels)"
73
+ if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
74
+ echo " already healthy"
75
+ elif command -v vllm >/dev/null 2>&1 && [[ -f "$MODEL_DIR/config.json" ]]; then
76
+ ok=0
77
+ for i in $(seq 1 120); do
78
+ if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
79
+ echo " healthy ($i)"
80
+ ok=1
81
+ break
82
+ fi
83
+ sleep 5
84
+ done
85
+ [[ "$ok" = 1 ]] || die "vLLM not healthy — tail data/vllm-gemma.log"
86
+ else
87
+ echo " skip wait (no local Gemma). UI will still start."
88
+ fi
89
 
90
  say "5/5 UI on LAN :$PORT_UI"
91
+ mkdir -p data
92
  export RECEIPT_UI_SHARE_LAN=true
93
+ # Only pin LLM URLs to this machine when local Gemma is actually up.
94
+ # UI-only boxes must keep .env pointing at the GPU box.
95
+ if curl -sf -m3 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
96
+ export RECEIPT_LLM_BASE_URL="http://127.0.0.1:${PORT_LLM}/v1"
97
+ export RECEIPT_EMBED_BASE_URL="http://127.0.0.1:${PORT_LLM}/v1"
98
+ export RECEIPT_LLM_MODEL="$MODEL_ID"
99
+ export RECEIPT_EMBED_MODEL="$MODEL_ID"
100
+ export RECEIPT_EMBED_DIM=3840
101
+ export RECEIPT_EMBED_BACKEND=omni
102
+ fi
103
  if curl -sf -m2 "http://127.0.0.1:$PORT_UI/phone" >/dev/null 2>&1; then
104
  echo " UI already up"
105
  else
 
126
  printf '\n\033[1;32m✅ READY\033[0m keys-Auto Receipts Studio\n'
127
  printf ' Review (this machine): http://127.0.0.1:%s\n' "$PORT_UI"
128
  printf ' iPhone Safari: http://%s:%s/phone\n' "$LAN" "$PORT_UI"
129
+ if curl -sf -m2 "http://127.0.0.1:$PORT_LLM/v1/models" >/dev/null 2>&1; then
130
+ printf ' Gemma /v1: http://127.0.0.1:%s/v1 model %s util=%s\n' "$PORT_LLM" "$MODEL_ID" "$UTIL"
131
+ else
132
+ printf ' Gemma /v1: not local — set RECEIPT_LLM_BASE_URL in .env to the GPU box\n'
133
+ fi
134
  printf ' Desktop launcher: bash scripts/install-launcher.sh\n'
135
+ printf ' Windows desktop icon: scripts\\install-launcher.bat\n'
136
+ printf '\n Hold a receipt up Take photo on the phone page (Safari).\n'
137
+ printf ' Lamp skill: skills/keys-receipt-scanner/ — 12B does not fit in 6GB RAM.\n'
pyproject.toml CHANGED
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
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" }
 
5
  [project]
6
  name = "keys-automatic-receipt-doc-scanner"
7
  version = "1.0.0a1"
8
+ description = "iPhone + desktop receipt scanner; Gemma 4 12B-it on a GPU box; optional Autonomous Lamp skill"
9
  readme = "README.md"
10
  requires-python = ">=3.12"
11
  license = { text = "Apache-2.0" }
scripts/install-launcher.bat ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Writes a Desktop shortcut that cds to this repo, then starts the UI.
3
+ setlocal
4
+ set "HERE=%~dp0"
5
+ cd /d "%HERE%.."
6
+ set "ROOT=%CD%"
7
+ set "DEST=%USERPROFILE%\Desktop\Receipt Studio.bat"
8
+ > "%DEST%" (
9
+ echo @echo off
10
+ echo cd /d "%ROOT%"
11
+ echo call "%ROOT%\scripts\start-ui.bat"
12
+ )
13
+ echo Wrote %DEST%
14
+ echo Double-click Desktop\Receipt Studio.bat
scripts/install-launcher.sh CHANGED
@@ -6,7 +6,12 @@ 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
 
6
 
7
  if [[ "$(uname -s)" == "Darwin" ]]; then
8
  DEST="$HOME/Desktop/Receipt Studio.command"
9
+ # Write a wrapper (not a symlink): Desktop double-click must not depend on $0.
10
+ cat > "$DEST" <<EOF
11
+ #!/bin/bash
12
+ cd "$ROOT"
13
+ exec "$ROOT/scripts/start-ui.sh"
14
+ EOF
15
  chmod +x "$DEST"
16
  echo "Mac: double-click Desktop/Receipt Studio.command (first time: right-click → Open)"
17
  exit 0
scripts/register-hermes-gemma.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Idempotently add Gemma 4 12B Unified to ~/.hermes/config.yaml custom_providers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ SNIPPET = """
10
+ - name: gemma4-12b-it
11
+ base_url: {base_url}
12
+ api_key: EMPTY
13
+ models:
14
+ - google/gemma-4-12B-it
15
+ """
16
+
17
+
18
+ def main() -> int:
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("--config", default=str(Path.home() / ".hermes" / "config.yaml"))
21
+ parser.add_argument("--base-url", default="http://127.0.0.1:8080/v1")
22
+ parser.add_argument("--dry-run", action="store_true")
23
+ args = parser.parse_args()
24
+ path = Path(args.config)
25
+ text = path.read_text(encoding="utf-8") if path.is_file() else "custom_providers: []\n"
26
+ if "gemma4-12b-it" in text or "google/gemma-4-12B-it" in text:
27
+ print(f"already registered in {path}")
28
+ return 0
29
+ block = SNIPPET.format(base_url=args.base_url.rstrip("/"))
30
+ if "custom_providers:" in text:
31
+ text = text.replace("custom_providers:\n", "custom_providers:\n" + block, 1)
32
+ else:
33
+ text = text.rstrip() + "\ncustom_providers:\n" + block
34
+ if args.dry_run:
35
+ print(block)
36
+ return 0
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ path.write_text(text, encoding="utf-8")
39
+ print(f"added gemma4-12b-it -> {args.base_url} in {path}")
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())
scripts/start-ui.bat CHANGED
@@ -1,6 +1,17 @@
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
 
1
  @echo off
2
+ REM Windows: double-click in the repo (scripts\) or run oneshot.bat at the repo root.
3
+ setlocal
4
+ set "HERE=%~dp0"
5
+ if exist "%HERE%app\cli.py" (
6
+ cd /d "%HERE%"
7
+ ) else if exist "%HERE%..\app\cli.py" (
8
+ cd /d "%HERE%.."
9
+ ) else (
10
+ echo Run this from the cloned repo: scripts\start-ui.bat
11
+ echo Do not copy this file to the Desktop — use scripts\install-launcher.bat
12
+ pause
13
+ exit /b 1
14
+ )
15
  if not exist ".venv\Scripts\python.exe" (
16
  echo Creating .venv (one time)...
17
  py -3.12 -m venv .venv || python -m venv .venv
scripts/start-ui.command CHANGED
@@ -1,4 +1,11 @@
1
  #!/bin/bash
2
  # macOS: double-click this file (first time: right-click → Open).
3
- cd "$(dirname "$0")"
4
- exec ./start-ui.sh
 
 
 
 
 
 
 
 
1
  #!/bin/bash
2
  # macOS: double-click this file (first time: right-click → Open).
3
+ set -euo pipefail
4
+ SOURCE="${BASH_SOURCE[0]:-$0}"
5
+ while [[ -L "$SOURCE" ]]; do
6
+ DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
7
+ SOURCE="$(readlink "$SOURCE")"
8
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
9
+ done
10
+ DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
11
+ exec "$DIR/start-ui.sh"
scripts/start-ui.sh CHANGED
@@ -1,7 +1,13 @@
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)…"
 
1
  #!/usr/bin/env bash
2
  # Linux / macOS: double-click start-ui.command on Mac, or run this script.
3
  set -euo pipefail
4
+ SOURCE="${BASH_SOURCE[0]:-$0}"
5
+ while [[ -L "$SOURCE" ]]; do
6
+ DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
7
+ SOURCE="$(readlink "$SOURCE")"
8
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
9
+ done
10
+ ROOT="$(cd "$(dirname "$SOURCE")/.." && pwd)"
11
  cd "$ROOT"
12
  if [[ ! -x .venv/bin/python ]]; then
13
  echo "Creating .venv (one time)…"
skills/keys-receipt-scanner/SKILL.md CHANGED
@@ -5,78 +5,66 @@ description: Scan receipts and paper documents with the Lamp camera, extract tot
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 |
 
5
 
6
  # keys-receipt-scanner
7
 
8
+ This folder is the Autonomous OS skill. In this repo: `skills/keys-receipt-scanner/`. After `make push-skill` it is `/root/.openclaw/workspace/skills/keys-receipt-scanner/` (or `~/.openclaw/workspace/skills/keys-receipt-scanner/` if the robot user is not root).
9
+
10
+ Lamp = camera only (6 GB). Gemma 4 12B Unified stays on the GPU box. Route via Receipt Studio (`RECEIPT_STUDIO_URL`) or direct/Hermes OpenAI-compat (`RECEIPT_LLM_BASE_URL` / Hermes `custom_providers`).
11
 
12
  ## When to use
13
 
14
+ User holds up a receipt/invoice/statement, or says scan this / log this expense / what did this cost.
15
+
16
+ Do **not** use for "what do you see" about the room (`camera` skill) or privacy toggles.
17
 
18
+ ## Capture + extract
19
 
20
+ Reuse `[vision-image] <path>` if this turn already has one. Else run the skill script (stdlib, no 12B weights):
21
 
22
+ Desk ("it's on the table"):
23
 
24
  ```bash
25
+ python skills/keys-receipt-scanner/scripts/scan.py --aim-down
26
  ```
27
 
28
+ Held up to the camera:
 
 
 
 
29
 
30
+ ```bash
31
+ python skills/keys-receipt-scanner/scripts/scan.py
32
  ```
33
 
34
+ Existing JPEG:
 
 
35
 
36
  ```bash
37
+ python skills/keys-receipt-scanner/scripts/scan.py --image "$PATH"
38
  ```
39
 
40
+ If the workspace cwd is already the skill folder, `python scripts/scan.py` is the same.
 
 
41
 
42
+ The script snapshots HAL `GET :5001/camera/snapshot?save=true&width=1280&quality=85`, then POSTs the JPEG to Receipt Studio on the GPU box (`RECEIPT_STUDIO_URL`, default `http://$RECEIPT_GPU_HOST:7860/api/inbox`). Studio runs Gemma 4 12B (direct vLLM or Hermes-discovered). On a GPU-box checkout it falls back to `python -m app.cli scan`.
 
 
43
 
44
+ If they said it is on the desk, `--aim-down` must run **before** the photo.
 
 
 
45
 
46
  Then:
47
 
48
  ```
49
+ [HW:/emotion:{"emotion":"curious","intensity":0.6}]
50
  ```
51
 
52
+ ## Speak
53
 
54
+ From the JSON, say in the user's language: kind + category + vendor, date and total, 1–2 line items. Do not invent totals. If `state` is `error` or `status` is `failed`, ask them to hold it flatter.
55
 
56
+ ```
57
+ [HW:/emotion:{"emotion":"acknowledge","intensity":0.7}]
 
58
  ```
59
 
60
+ ## Env on the Lamp
61
 
62
+ ```bash
63
+ export RECEIPT_GPU_HOST=<spark-lan-ip>
64
+ export RECEIPT_STUDIO_URL=http://<spark-lan-ip>:7860
65
+ export RECEIPT_CAMERA_URL=http://127.0.0.1:5001
66
+ ```
67
 
68
+ Direct Gemma (skip Studio): GPU box `RECEIPT_LLM_BASE_URL=http://127.0.0.1:8080/v1`. Via Hermes: `python scripts/register-hermes-gemma.py` on the GPU box, then `RECEIPT_LLM_ROUTE=auto` (reads `~/.hermes/config.yaml` `custom_providers`).
69
 
70
+ Never `api.x.ai`. Never Lightning with an image. Never load 12B on the Lamp.
 
 
 
 
skills/keys-receipt-scanner/references/hardware.md CHANGED
@@ -1,7 +1,15 @@
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`
 
 
 
 
 
 
 
 
1
  # Hardware split
2
 
3
+ Repo path: `skills/keys-receipt-scanner/`
4
+ On Lamp after push: `/root/.openclaw/workspace/skills/keys-receipt-scanner/`
5
 
6
+ Lamp (6 GB): this skill + HAL snapshot only. Gemma 4 12B Unified stays on the GPU box.
7
 
8
  Camera: `GET http://127.0.0.1:5001/camera/snapshot?save=true&width=1280&quality=85`
9
+ Desk aim: `POST http://127.0.0.1:5001/servo/aim` `{"direction":"down"}` before snapshot.
10
+
11
+ Brain (pick one):
12
+
13
+ - Studio (Lamp default): `RECEIPT_STUDIO_URL=http://<gpu>:7860` → POST `/api/inbox`
14
+ - Direct vLLM: `RECEIPT_LLM_BASE_URL=http://<gpu>:8080/v1`
15
+ - Hermes: `python scripts/register-hermes-gemma.py` then `RECEIPT_LLM_ROUTE=auto` reads `~/.hermes/config.yaml` `custom_providers`
skills/keys-receipt-scanner/scripts/scan.py CHANGED
@@ -1,19 +1,206 @@
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)
 
1
  #!/usr/bin/env python3
2
+ """Lamp entry. Snapshot HAL, then Gemma 4 12B on the GPU box (direct vLLM, Hermes, or Studio).
3
+
4
+ Never loads 12B on the robot. Stdlib-only so it runs after `make push-skill`.
5
+ """
6
 
7
  from __future__ import annotations
8
 
9
+ import argparse
10
+ import json
11
+ import os
12
  import sys
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
  from pathlib import Path
17
 
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ # Repo checkout: skills/keys-receipt-scanner/scripts/scan.py -> repo root
20
+ REPO = Path(__file__).resolve().parents[3]
21
+
22
+
23
+ def _env(name: str, default: str = "") -> str:
24
+ return os.environ.get(name, default).strip()
25
+
26
+
27
+ def _json_req(url: str, *, data: bytes | None = None, headers: dict[str, str] | None = None, timeout: float = 30.0) -> dict:
28
+ req = urllib.request.Request(url, data=data, headers=headers or {})
29
+ try:
30
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
31
+ raw = resp.read()
32
+ except urllib.error.HTTPError as exc:
33
+ detail = exc.read()[:400].decode("utf-8", "replace")
34
+ raise urllib.error.URLError(f"HTTP {exc.code} {url}: {detail}") from exc
35
+ if not raw:
36
+ return {}
37
+ return json.loads(raw.decode("utf-8"))
38
+
39
+
40
+ def snapshot(camera_url: str, width: int = 1280, quality: int = 85) -> Path:
41
+ url = f"{camera_url.rstrip('/')}/camera/snapshot?save=true&width={width}&quality={quality}"
42
+ payload = _json_req(url, timeout=20.0)
43
+ path = payload.get("path")
44
+ if not path:
45
+ raise SystemExit(f"snapshot missing path: {payload!r}")
46
+ return Path(str(path))
47
+
48
+
49
+ def aim_down(camera_url: str) -> None:
50
+ url = f"{camera_url.rstrip('/')}/servo/aim"
51
+ body = json.dumps({"direction": "down"}).encode()
52
+ try:
53
+ _json_req(url, data=body, headers={"Content-Type": "application/json"}, timeout=15.0)
54
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
55
+ print(f"warn: servo aim failed ({exc})", file=sys.stderr)
56
+
57
+
58
+ EXTRACT_USER = (
59
+ "Extract a receipt/invoice/document as one JSON object only. Keys: "
60
+ "doc_kind (receipt|invoice|document), category (groceries|dining|transport|"
61
+ "household|health|entertainment|utilities|office|travel|other), vendor, "
62
+ "date (YYYY-MM-DD), tax, total, currency, line_items "
63
+ "[{description, qty, unit_price, amount, sku}]. Unknown = null. No markdown."
64
+ )
65
+
66
+
67
+ def post_gemma(base_url: str, model: str, image: Path, api_key: str = "local") -> dict:
68
+ """Direct OpenAI-compat vision call (vLLM or Hermes custom_provider URL)."""
69
+ import base64
70
+
71
+ b64 = base64.b64encode(image.read_bytes()).decode("ascii")
72
+ root = base_url.rstrip("/")
73
+ if not root.endswith("/v1"):
74
+ root = root + "/v1"
75
+ payload = {
76
+ "model": model,
77
+ "messages": [
78
+ {
79
+ "role": "system",
80
+ "content": "You extract structured data from a receipt photo. JSON object only.",
81
+ },
82
+ {
83
+ "role": "user",
84
+ "content": [
85
+ {
86
+ "type": "image_url",
87
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"},
88
+ },
89
+ {"type": "text", "text": EXTRACT_USER},
90
+ ],
91
+ },
92
+ ],
93
+ "temperature": 0,
94
+ "max_tokens": 2048,
95
+ "response_format": {"type": "json_object"},
96
+ }
97
+ body = json.dumps(payload).encode()
98
+ result = _json_req(
99
+ f"{root}/chat/completions",
100
+ data=body,
101
+ headers={
102
+ "Content-Type": "application/json",
103
+ "Authorization": f"Bearer {api_key}",
104
+ },
105
+ timeout=180.0,
106
+ )
107
+ try:
108
+ content = result["choices"][0]["message"]["content"]
109
+ parsed = json.loads(content) if isinstance(content, str) else content
110
+ except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
111
+ raise SystemExit(f"Gemma response was not JSON: {result!r}") from exc
112
+ if not isinstance(parsed, dict):
113
+ raise SystemExit(f"Gemma JSON was not an object: {parsed!r}")
114
+ parsed.setdefault("state", "done")
115
+ parsed.setdefault("route", "direct")
116
+ return parsed
117
+
118
+
119
+ def post_studio(studio_url: str, image: Path, timeout: float = 180.0) -> dict:
120
+ boundary = "----keysreceipt"
121
+ data = image.read_bytes()
122
+ filename = image.name or "snap.jpg"
123
+ parts = (
124
+ f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
125
+ f"Content-Type: image/jpeg\r\n\r\n"
126
+ ).encode() + data + f"\r\n--{boundary}--\r\n".encode()
127
+ inbox = _json_req(
128
+ f"{studio_url.rstrip('/')}/api/inbox",
129
+ data=parts,
130
+ headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
131
+ timeout=30.0,
132
+ )
133
+ job_id = inbox.get("job_id")
134
+ if not job_id:
135
+ return inbox
136
+ deadline = time.time() + timeout
137
+ while time.time() < deadline:
138
+ job = _json_req(f"{studio_url.rstrip('/')}/api/jobs/{job_id}", timeout=15.0)
139
+ if job.get("state") in {"done", "error"}:
140
+ return job
141
+ time.sleep(1.0)
142
+ raise SystemExit(f"studio job {job_id} timed out")
143
+
144
+
145
+ def main(argv: list[str] | None = None) -> int:
146
+ parser = argparse.ArgumentParser(description="Lamp receipt scan → Gemma 4 12B on the GPU box")
147
+ parser.add_argument("--image", help="existing jpeg; omit to snapshot the Lamp camera")
148
+ parser.add_argument("--aim-down", action="store_true", help="aim servo down before snapshot (desk)")
149
+ parser.add_argument("--camera-url", default=_env("RECEIPT_CAMERA_URL", "http://127.0.0.1:5001"))
150
+ parser.add_argument("--studio-url", default=_env("RECEIPT_STUDIO_URL"))
151
+ parser.add_argument("--llm-url", default=_env("RECEIPT_LLM_BASE_URL", "http://127.0.0.1:8080/v1"))
152
+ parser.add_argument("--gpu-host", default=_env("RECEIPT_GPU_HOST"))
153
+ args = parser.parse_args(argv)
154
+
155
+ image = Path(args.image) if args.image else None
156
+ if image is None:
157
+ if args.aim_down:
158
+ aim_down(args.camera_url)
159
+ image = snapshot(args.camera_url)
160
+
161
+ studio = args.studio_url
162
+ if not studio and args.gpu_host:
163
+ studio = f"http://{args.gpu_host}:7860"
164
+
165
+ # 1) Receipt Studio on the GPU box (stores + Gemma extract + embed).
166
+ if studio:
167
+ try:
168
+ result = post_studio(studio, image)
169
+ json.dump(result, sys.stdout, indent=2)
170
+ sys.stdout.write("\n")
171
+ return 0 if result.get("state") != "error" else 1
172
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
173
+ print(f"studio route failed ({exc}); trying local package / direct Gemma", file=sys.stderr)
174
+
175
+ # 2) GPU-box git checkout: full pipeline (direct vLLM or Hermes-discovered Gemma).
176
+ if (REPO / "app" / "cli.py").is_file():
177
+ sys.path.insert(0, str(REPO))
178
+ from app.cli import main as cli_main
179
+
180
+ cli_main(["scan", "--image", str(image)])
181
+ return 0
182
+
183
+ # 3) Lamp without the app package: OpenAI-compat on the GPU box (vLLM or Hermes provider).
184
+ llm = args.llm_url
185
+ if args.gpu_host and ("127.0.0.1" in llm or "localhost" in llm):
186
+ llm = f"http://{args.gpu_host}:8080/v1"
187
+ hermes = _env("RECEIPT_HERMES_BASE_URL")
188
+ if hermes:
189
+ llm = hermes
190
+ model = _env("RECEIPT_LLM_MODEL") or _env("RECEIPT_HERMES_MODEL") or "google/gemma-4-12B-it"
191
+ try:
192
+ result = post_gemma(llm, model, image, api_key=_env("RECEIPT_LLM_API_KEY") or "local")
193
+ json.dump(result, sys.stdout, indent=2)
194
+ sys.stdout.write("\n")
195
+ return 0
196
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
197
+ raise SystemExit(
198
+ f"Gemma 4 12B unreachable at {llm} ({exc}). "
199
+ "On the Lamp set RECEIPT_STUDIO_URL=http://<gpu-box>:7860 "
200
+ "or RECEIPT_LLM_BASE_URL=http://<gpu-box>:8080/v1 "
201
+ "(Hermes: RECEIPT_HERMES_BASE_URL to the same OpenAI-compat URL)."
202
+ ) from exc
203
 
 
204
 
205
  if __name__ == "__main__":
206
+ raise SystemExit(main())
 
 
 
tests/conftest.py CHANGED
@@ -28,6 +28,7 @@ def settings(tmp_path: Path) -> Settings:
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",
 
28
  exports_dir=tmp_path / "exports",
29
  idle_seconds=0.05,
30
  llm_backend="gemma",
31
+ llm_route="direct",
32
  llm_base_url="http://llm.test/v1",
33
  llm_model="google/gemma-4-12B-it",
34
  embed_backend="omni",
tests/test_camera.py CHANGED
@@ -4,7 +4,7 @@ from pathlib import Path
4
 
5
  import httpx
6
 
7
- from app.camera import snapshot
8
  from app.config import Settings
9
 
10
 
@@ -19,3 +19,17 @@ def test_snapshot_reads_path(settings: Settings, tmp_path: Path) -> None:
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  import httpx
6
 
7
+ from app.camera import aim, snapshot
8
  from app.config import Settings
9
 
10
 
 
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
22
+
23
+
24
+ def test_aim_posts_direction(settings: Settings) -> None:
25
+ seen: list[str] = []
26
+
27
+ def handler(request: httpx.Request) -> httpx.Response:
28
+ seen.append(request.method + " " + str(request.url))
29
+ assert b"down" in request.content
30
+ return httpx.Response(200, json={"ok": True})
31
+
32
+ client = httpx.Client(transport=httpx.MockTransport(handler), base_url="http://hal.test")
33
+ settings = settings.model_copy(update={"camera_url": "http://hal.test"})
34
+ aim(settings, "down", client=client)
35
+ assert any("servo/aim" in url for url in seen)
tests/test_route.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from app.config import Settings
6
+ from app.route import (
7
+ Brain,
8
+ looks_like_gemma,
9
+ parse_hermes_providers,
10
+ resolve_brain,
11
+ )
12
+
13
+ HERMES = """
14
+ custom_providers:
15
+ - name: qwen38-nvfp4
16
+ base_url: http://10.100.10.5:8078/v1
17
+ api_key: EMPTY
18
+ models:
19
+ - qwen38-nvfp4
20
+ - name: gemma4-12b-it
21
+ base_url: http://10.100.10.4:8080/v1
22
+ api_key: EMPTY
23
+ models:
24
+ - google/gemma-4-12B-it
25
+ mcp_servers:
26
+ slate:
27
+ command: node
28
+ """
29
+
30
+
31
+ def test_parse_hermes_finds_gemma_only() -> None:
32
+ brains = parse_hermes_providers(HERMES)
33
+ assert len(brains) == 1
34
+ assert brains[0].kind == "hermes"
35
+ assert brains[0].base_url == "http://10.100.10.4:8080/v1"
36
+ assert brains[0].model == "google/gemma-4-12B-it"
37
+ assert "gemma4-12b-it" in brains[0].source
38
+
39
+
40
+ def test_looks_like_gemma() -> None:
41
+ assert looks_like_gemma("google/gemma-4-12B-it")
42
+ assert looks_like_gemma("gemma4-12b-it")
43
+ assert not looks_like_gemma("qwen38-nvfp4")
44
+
45
+
46
+ def test_resolve_direct(settings: Settings) -> None:
47
+ settings = settings.model_copy(update={"llm_route": "direct"})
48
+ brain = resolve_brain(settings, probe=lambda _u: [])
49
+ assert brain.kind == "direct"
50
+ assert brain.base_url == "http://llm.test/v1"
51
+
52
+
53
+ def test_resolve_hermes(settings: Settings, tmp_path: Path) -> None:
54
+ cfg = tmp_path / "config.yaml"
55
+ cfg.write_text(HERMES, encoding="utf-8")
56
+ settings = settings.model_copy(
57
+ update={"llm_route": "hermes", "hermes_config_path": cfg, "hermes_base_url": ""}
58
+ )
59
+ brain = resolve_brain(
60
+ settings,
61
+ probe=lambda url: ["google/gemma-4-12B-it"] if "8080" in url else [],
62
+ )
63
+ assert brain.kind == "hermes"
64
+ assert brain.base_url.endswith(":8080/v1")
65
+
66
+
67
+ def test_parse_live_hermes_config_does_not_crash() -> None:
68
+ from app.route import brains_from_hermes_file
69
+
70
+ path = Path.home() / ".hermes" / "config.yaml"
71
+ brains = brains_from_hermes_file(path)
72
+ assert all(b.kind == "hermes" for b in brains)
73
+ for brain in brains:
74
+ assert "gemma" in brain.model.lower() or "gemma" in brain.source.lower()
75
+
76
+
77
+ def test_resolve_auto_studio(settings: Settings) -> None:
78
+ settings = settings.model_copy(
79
+ update={"llm_route": "auto", "studio_url": "http://gpu.example:7860"}
80
+ )
81
+ brain = resolve_brain(
82
+ settings,
83
+ probe=lambda _u: [],
84
+ studio_probe=lambda _u: True,
85
+ )
86
+ assert brain == Brain("studio", "http://gpu.example:7860", settings.llm_model, "RECEIPT_STUDIO_URL")
tests/test_scan_script.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ SCAN_PATH = ROOT / "skills" / "keys-receipt-scanner" / "scripts" / "scan.py"
9
+
10
+
11
+ def _load_scan():
12
+ spec = importlib.util.spec_from_file_location("lamp_scan", SCAN_PATH)
13
+ assert spec and spec.loader
14
+ mod = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(mod)
16
+ return mod
17
+
18
+
19
+ def test_post_gemma_sends_image_url(tmp_path: Path, monkeypatch) -> None:
20
+ scan = _load_scan()
21
+ jpeg = tmp_path / "r.jpg"
22
+ jpeg.write_bytes(b"fake-jpeg")
23
+ captured: dict = {}
24
+
25
+ def fake_json_req(url, *, data=None, headers=None, timeout=30.0):
26
+ captured["url"] = url
27
+ captured["body"] = json.loads(data.decode())
28
+ captured["headers"] = headers
29
+ return {
30
+ "choices": [
31
+ {
32
+ "message": {
33
+ "content": json.dumps(
34
+ {"doc_kind": "receipt", "vendor": "Cafe", "total": 3.5}
35
+ )
36
+ }
37
+ }
38
+ ]
39
+ }
40
+
41
+ monkeypatch.setattr(scan, "_json_req", fake_json_req)
42
+ out = scan.post_gemma("http://gpu:8080/v1", "google/gemma-4-12B-it", jpeg)
43
+ assert out["vendor"] == "Cafe"
44
+ assert captured["url"].endswith("/v1/chat/completions")
45
+ content = captured["body"]["messages"][1]["content"]
46
+ kinds = {p["type"] for p in content}
47
+ assert "image_url" in kinds
48
+ url = next(p["image_url"]["url"] for p in content if p["type"] == "image_url")
49
+ assert url.startswith("data:image/jpeg;base64,")
50
+
51
+
52
+ def test_post_studio_polls_job(tmp_path: Path, monkeypatch) -> None:
53
+ scan = _load_scan()
54
+ jpeg = tmp_path / "r.jpg"
55
+ jpeg.write_bytes(b"fake-jpeg")
56
+ calls: list[str] = []
57
+
58
+ def fake_json_req(url, *, data=None, headers=None, timeout=30.0):
59
+ calls.append(url)
60
+ if url.endswith("/api/inbox"):
61
+ return {"ok": True, "job_id": "abc"}
62
+ if url.endswith("/api/jobs/abc"):
63
+ return {"state": "done", "vendor": "Whole Foods", "total": "12.00"}
64
+ raise AssertionError(url)
65
+
66
+ monkeypatch.setattr(scan, "_json_req", fake_json_req)
67
+ out = scan.post_studio("http://gpu:7860", jpeg)
68
+ assert out["vendor"] == "Whole Foods"
69
+ assert any(u.endswith("/api/inbox") for u in calls)
70
+ assert any("/api/jobs/abc" in u for u in calls)
tests/test_skill_frontmatter.py CHANGED
@@ -15,6 +15,10 @@ def test_skill_frontmatter() -> None:
15
  assert "name: keys-receipt-scanner" in fm
16
  assert "Lamp camera" in fm or "scan" in fm.lower()
17
  body = parts[2]
 
 
18
  assert "/camera/snapshot" in body
19
  assert "6 GB" in body
20
  assert "Gemma 4 12B" in body
 
 
 
15
  assert "name: keys-receipt-scanner" in fm
16
  assert "Lamp camera" in fm or "scan" in fm.lower()
17
  body = parts[2]
18
+ assert "skills/keys-receipt-scanner" in body
19
+ assert "scripts/scan.py" in body
20
  assert "/camera/snapshot" in body
21
  assert "6 GB" in body
22
  assert "Gemma 4 12B" in body
23
+ assert "RECEIPT_STUDIO_URL" in body
24
+ assert "RECEIPT_GPU_HOST" in body
tests/test_ui_edit.py CHANGED
@@ -47,3 +47,6 @@ def test_phone_page_splits_camera_and_library() -> None:
47
  assert "opacity:0" in PHONE_HTML
48
  assert "/api/jobs/" in PHONE_HTML
49
  assert "Completed:" in PHONE_HTML
 
 
 
 
47
  assert "opacity:0" in PHONE_HTML
48
  assert "/api/jobs/" in PHONE_HTML
49
  assert "Completed:" in PHONE_HTML
50
+ assert "/api/inbox" in PHONE_HTML
51
+ assert "Safari" in PHONE_HTML
52
+ assert "heic" in PHONE_HTML.lower()