Upload folder using huggingface_hub
Browse files- .env.example +10 -1
- .gitattributes +1 -0
- .grok/skills/keys-receipt-scanner/SKILL.md +2 -1
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +34 -0
- app/camera.py +15 -0
- app/cli.py +4 -1
- app/config.py +8 -0
- app/route.py +211 -0
- app/ui.py +19 -1
- backends/__init__.py +29 -1
- docs/promo/Keys-Auto-Receipts-Studio-promo.mp4 +3 -0
- oneshot.sh +50 -25
- pyproject.toml +1 -1
- scripts/install-launcher.bat +14 -0
- scripts/install-launcher.sh +6 -1
- scripts/register-hermes-gemma.py +44 -0
- scripts/start-ui.bat +13 -2
- scripts/start-ui.command +9 -2
- scripts/start-ui.sh +7 -1
- skills/keys-receipt-scanner/SKILL.md +31 -43
- skills/keys-receipt-scanner/references/hardware.md +10 -2
- skills/keys-receipt-scanner/scripts/scan.py +196 -9
- tests/conftest.py +1 -0
- tests/test_camera.py +15 -1
- tests/test_route.py +86 -0
- tests/test_scan_script.py +70 -0
- tests/test_skill_frontmatter.py +4 -0
- tests/test_ui_edit.py +3 -0
.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 |
-
-
|
|
|
|
| 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
|
| 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 [[
|
|
|
|
|
|
|
| 40 |
echo " present"
|
| 41 |
else
|
| 42 |
-
|
| 43 |
mkdir -p "$MODEL_DIR"
|
| 44 |
-
|
| 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 |
-
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
say "5/5 UI on LAN :$PORT_UI"
|
|
|
|
| 76 |
export RECEIPT_UI_SHARE_LAN=true
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
export
|
| 81 |
-
export
|
| 82 |
-
export
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
printf ' Desktop launcher: bash scripts/install-launcher.sh\n'
|
| 111 |
-
printf '
|
| 112 |
-
printf '
|
|
|
|
|
|
| 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 = "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 9 |
|
| 10 |
## When to use
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
```bash
|
| 23 |
-
|
| 24 |
```
|
| 25 |
|
| 26 |
-
|
| 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 |
-
|
| 34 |
```
|
| 35 |
|
| 36 |
-
|
| 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 -
|
| 42 |
```
|
| 43 |
|
| 44 |
-
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
After JSON comes back, say in the user's language:
|
| 51 |
|
| 52 |
-
-
|
| 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":"
|
| 61 |
```
|
| 62 |
|
| 63 |
-
|
| 64 |
|
| 65 |
-
|
| 66 |
|
| 67 |
-
```
|
| 68 |
-
|
| 69 |
-
python -m app.cli show <id>
|
| 70 |
```
|
| 71 |
|
| 72 |
-
##
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
-
|
| 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 |
-
|
|
|
|
| 4 |
|
| 5 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
import sys
|
|
|
|
|
|
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
from app.cli import main
|
| 14 |
|
| 15 |
if __name__ == "__main__":
|
| 16 |
-
|
| 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()
|