szwendaczjakomaj Claude Sonnet 4.6 commited on
Commit
bd6f3d2
·
1 Parent(s): 816f700

chore: remove CLAUDE.md and tests from HF Space

Browse files

Not needed in deployment; .gitignore prevents accidental re-add.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. .gitignore +2 -0
  2. CLAUDE.md +0 -51
  3. tests/test_app.py +0 -249
  4. tests/test_inventory.py +0 -46
  5. tests/test_server.py +0 -32
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ CLAUDE.md
2
+ tests/
CLAUDE.md DELETED
@@ -1,51 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Setup & running
6
-
7
- ```bash
8
- source venv/bin/activate
9
- python ui.py # Gradio UI at http://localhost:7860
10
- ```
11
-
12
- The Radxa inference box (`192.168.68.119:8088`) must be reachable on the LAN. Start llama-server on it with:
13
- ```bash
14
- taskset -c 4-7 llama-server --threads 4 --jinja --ctx-size 4096 --host 0.0.0.0 --port 8088 \
15
- -m <model.gguf> --mmproj <mmproj.gguf>
16
- ```
17
-
18
- ## Architecture
19
-
20
- Two files: `app.py` (logic) + `ui.py` (CSS, layout). Pipeline:
21
-
22
- 1. `encode_image` — PIL resize to 960px longest side → base64 JPEG (A/B tested 2026-06-08: 640px loses numeric codes on small-print instruction sheet photos; 960px = 100% recall, +~60s vs 640; 1280px identical recall at +90s — not worth it. Shop screenshots are already ≤640px so unaffected.)
23
- 2. `call_llama` — POST to `llama-server` OpenAI-compatible endpoint (`/v1/chat/completions`) with image as base64 data URI; CPU inference, ~90–180s depending on image type
24
- 3. `extract_codes` — json_schema constraint → json.loads → numeric extraction; falls back to tolerant regex for non-json modes
25
- 4. Gradio `gr.Blocks` UI — single-page, upload → analyze → display raw output + extracted codes
26
-
27
- Each request must be a fresh conversation (no message history) — otherwise llama-server reuses the previous image encoding.
28
-
29
- ## Model
30
-
31
- InternVL3.5-2B Q6_K_L GGUF + mmproj f16 GGUF on Radxa Dragon Q6A (CPU-only, Ubuntu).
32
-
33
- ## Pending (in priority order)
34
-
35
- 1. ~~Humbrol → Tamiya conversion table~~ — done (CSV lookup + mojehobby.pl shop links)
36
- 2. Structured model output (JSON-in-prompt or GBNF grammar instead of free-text regex)
37
- 3. ~~Brand autodetection~~ — solved by brand dropdown (deterministic, no extra work needed)
38
- 4. ~~HF Space deployment~~ — done (`build-small-hackathon/paint_match`, branch `hf-deploy`); Cloudflare Tunnel pending (do weekendu)
39
- 5. Airfix Dogfighter aesthetic UI (deferred until pipeline is solid)
40
- 6. ~~Paint inventory integration~~ — done (gspread + service account; `GOOGLE_SERVICE_ACCOUNT_JSON` + `GOOGLE_SHEET_ID` env vars; sheet "Hakaton", sheet1; owned badge "✓ ISSUED" replaces shop link)
41
-
42
- ## Hackathon constraints
43
-
44
- "Off-the-grid": inference must stay on the Radxa — no hosted LLM APIs. Cloudflare Tunnel and HF Space are acceptable front-door plumbing. Deadline: 2026-06-15.
45
-
46
- ## Known pitfalls
47
-
48
- - Port 8080 on the Radxa is taken; llama-server uses 8088.
49
- - First request after server start is warmup — disregard its timing.
50
- - macOS `base64` is BSD: no `-w0`. Use `base64 -i file` or `base64 < file`.
51
- - `hf` not `huggingface-cli` in this venv.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_app.py DELETED
@@ -1,249 +0,0 @@
1
- import sys, os
2
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
3
-
4
- from app import tamiya_shop_url, _load_humbrol_tamiya
5
-
6
-
7
- def test_url_xf_series():
8
- assert tamiya_shop_url("XF17", "Sea Blue") == \
9
- "https://www.mojehobby.pl/products/XF-17-Sea-Blue.html"
10
-
11
-
12
- def test_url_x_series():
13
- assert tamiya_shop_url("X5", "Green") == \
14
- "https://www.mojehobby.pl/products/X-5-Green.html"
15
-
16
-
17
- def test_url_multiword_name():
18
- assert tamiya_shop_url("XF25", "Light Sea Grey") == \
19
- "https://www.mojehobby.pl/products/XF-25-Light-Sea-Grey.html"
20
-
21
-
22
- def test_url_dotted_name():
23
- assert tamiya_shop_url("XF12", "J.N. Grey") == \
24
- "https://www.mojehobby.pl/products/XF-12-JN-Grey.html"
25
-
26
-
27
- def test_loader_returns_name():
28
- table = _load_humbrol_tamiya()
29
- code, name = table["104"]
30
- assert code == "XF17"
31
- assert name == "Sea Blue"
32
-
33
-
34
- def test_loader_empty_tamiya_returns_empty_strings():
35
- table = _load_humbrol_tamiya()
36
- code, name = table["9"]
37
- assert code == ""
38
- assert name == ""
39
-
40
-
41
- def test_loader_path_uses_data_dir():
42
- """_load_humbrol_tamiya must read from data/ subdirectory."""
43
- import inspect
44
- source = inspect.getsource(_load_humbrol_tamiya)
45
- assert '"data"' in source, "loader must reference data/ subdirectory"
46
- table = _load_humbrol_tamiya()
47
- assert len(table) > 100
48
-
49
-
50
- def test_shop_format_parses_plain_code_list():
51
- """Regression: the model returns a plain '- code: name' list even under the
52
- shop format. The unified regex must parse it (the old shop regex matched none)."""
53
- from app import FORMATS, strip_parens, parse_entries
54
- raw = ("The paint codes from the product list are:\n\n"
55
- "- 11: Silver\n- 33: Matt Black\n"
56
- "- 118+34: Matt Desert Sand (4 parts 118, 3 parts 34)")
57
- entries = parse_entries(strip_parens(raw), FORMATS["airfix_shop"]["regex"])
58
- assert [e["code"] for e in entries] == ["11", "33", "118+34"]
59
-
60
-
61
- def test_parser_handles_noisy_shop_output():
62
- """Real shop-screenshot output: the model echoes the full product line and
63
- appends a literal 'CODE: N - Name'. The tolerant parser must still extract
64
- code+name and dedupe the repeated code on each line."""
65
- from app import FORMATS, strip_parens, parse_entries, expand_codes
66
- raw = (
67
- "- Acrylic Paint 11 - Silver - Metallic - (14ml) - CODE: 11 - Silver\n"
68
- "- Acrylic Paint 33 - Black - Matt - (14ml) - CODE: 33 - Black\n"
69
- "- Acrylic Paint 168 - Hemp/Camouflage Beige - Matt - (14ml) - CODE: 168 - Hemp/Camouflage Beige"
70
- )
71
- expanded = expand_codes(parse_entries(strip_parens(raw), FORMATS["airfix_shop"]["regex"]))
72
- by_code = {e["code"]: e["name"] for e in expanded}
73
- assert by_code == {"11": "Silver", "33": "Black", "168": "Hemp/Camouflage Beige"}
74
-
75
-
76
- def test_json_parser_extracts_code_name():
77
- """json_schema mode returns a JSON array; parse_json_entries pulls code+name."""
78
- from app import parse_json_entries
79
- raw = '[{"code": "11", "name": "Silver"}, {"code": "118+34", "name": "Desert Sand"}]'
80
- entries = parse_json_entries(raw)
81
- assert [(e["code"], e["name"]) for e in entries] == \
82
- [("11", "Silver"), ("118+34", "Desert Sand")]
83
-
84
-
85
- def test_json_parser_drops_malformed_codes():
86
- """Non-numeric / junk codes are filtered; a non-array yields nothing."""
87
- from app import parse_json_entries
88
- raw = '[{"code": "33", "name": "Black"}, {"code": "n/a"}, {"name": "no code"}]'
89
- assert [e["code"] for e in parse_json_entries(raw)] == ["33"]
90
- assert parse_json_entries('not json at all') == []
91
- assert parse_json_entries('{"code": "11"}') == [] # object, not array
92
-
93
-
94
- def test_json_parser_extracts_code_from_polluted_field():
95
- """json_schema enforces shape but not semantics: the 2B model dumps the whole
96
- echoed product line into `code`. The parser must pull the numeric code out."""
97
- from app import parse_json_entries
98
- raw = ('[{"code": "Acrylic Paint 11 - Silver - Metallic", "name": "Silver - Metallic"},'
99
- ' {"code": "Acrylic Paint 118+34 - Desert Sand", "name": "Desert Sand"}]')
100
- assert [e["code"] for e in parse_json_entries(raw)] == ["11", "118+34"]
101
-
102
-
103
- def test_json_parser_feeds_expand_codes():
104
- """The JSON path plugs into the shared downstream: composite split + dedupe."""
105
- from app import parse_json_entries, expand_codes
106
- raw = '[{"code": "118+34", "name": "Mix"}, {"code": "11", "name": "Silver"}]'
107
- expanded = expand_codes(parse_json_entries(raw))
108
- assert [e["code"] for e in expanded] == ["118", "34", "11"]
109
-
110
-
111
- def test_is_paint_name_filters_decals_and_junk():
112
- """Decal/stencil refs, bare numbers and years are not paints; colour names and
113
- a missing name (code still matters) are kept."""
114
- from app import is_paint_name
115
- assert is_paint_name("Silver - Metallic") is True
116
- assert is_paint_name("Gun Metal") is True
117
- assert is_paint_name(None) is True
118
- assert is_paint_name("") is True
119
- assert is_paint_name("Decal No. 87 May 1991") is False
120
- assert is_paint_name("Stencil 12") is False
121
- assert is_paint_name("11") is False # name is just a number
122
- assert is_paint_name("May 1991") is False # year
123
-
124
-
125
- def test_extract_entries_drops_decal_entries():
126
- """End-to-end on a JSON payload mimicking paper.JPG: decal/junk rows fall out,
127
- real paint rows survive."""
128
- import app
129
- raw = ('[{"code": "87", "name": "Decal No. 87 May 1991"},'
130
- ' {"code": "70", "name": "11"},'
131
- ' {"code": "53", "name": "Gun Metal"},'
132
- ' {"code": "11", "name": "Silver"}]')
133
- orig = app.OUTPUT_MODE
134
- app.OUTPUT_MODE = "json"
135
- try:
136
- entries = app.extract_entries(raw, app.CODE_REGEX)
137
- finally:
138
- app.OUTPUT_MODE = orig
139
- assert [e["code"] for e in entries] == ["53", "11"]
140
-
141
-
142
- def test_json_parser_salvages_truncated_array():
143
- """max_tokens can cut the JSON mid-entry; the parser must still recover the
144
- complete objects before the break instead of losing everything."""
145
- from app import parse_json_entries
146
- truncated = ('[\n {"code": "11", "name": "Silver"},\n'
147
- ' {"code": "33", "name": "Black"},\n'
148
- ' {"code": "Acrylic Paint') # cut off mid-entry, no closing ]
149
- assert [e["code"] for e in parse_json_entries(truncated)] == ["11", "33"]
150
-
151
-
152
- def test_meng_format_dropped():
153
- """Meng is out of scope for the hackathon (codes don't map to Tamiya)."""
154
- from app import FORMATS
155
- assert "meng_color_reference" not in FORMATS
156
-
157
-
158
- def test_analyze_final_yield_is_two_value_tuple():
159
- """analyze() is a generator (status updates, then a final (raw_str, html_str))."""
160
- from unittest.mock import patch
161
- from PIL import Image
162
- import numpy as np
163
- from app import analyze
164
-
165
- img = Image.fromarray(np.zeros((10, 10, 3), dtype=np.uint8))
166
- with patch("app.call_llama", return_value="- 33: Flat Black\n- 98: Sky Grey"):
167
- updates = list(analyze(img, "airfix_instruction"))
168
-
169
- final = updates[-1]
170
- assert isinstance(final, tuple)
171
- assert len(final) == 2
172
- raw, html = final
173
- assert isinstance(raw, str)
174
- assert isinstance(html, str)
175
-
176
-
177
- def test_encode_image_applies_exif_orientation():
178
- """Phone photos carry an EXIF orientation flag; encode_image must straighten
179
- them. server.py opens raw files (PIL.Image.open) which bypass Gradio's own
180
- exif_transpose, so the correction has to live in encode_image itself."""
181
- import base64, io
182
- from PIL import Image
183
- from app import encode_image
184
-
185
- # Portrait 100x200 stored sideways with orientation=6 (rotate to view as landscape).
186
- img = Image.new("RGB", (100, 200), "white")
187
- exif = img.getexif()
188
- exif[274] = 6 # tag 274 = Orientation
189
- buf = io.BytesIO()
190
- img.save(buf, format="JPEG", exif=exif)
191
- buf.seek(0)
192
- reopened = Image.open(buf) # mirrors PIL.Image.open(path) in server.py
193
-
194
- decoded = Image.open(io.BytesIO(base64.b64decode(encode_image(reopened))))
195
- # After exif_transpose the displayed image is landscape: width > height.
196
- assert decoded.width > decoded.height
197
-
198
-
199
- def test_llama_server_defaults_to_lan(monkeypatch):
200
- import importlib, app
201
- monkeypatch.delenv("LLAMA_SERVER", raising=False)
202
- importlib.reload(app)
203
- assert app.LLAMA_SERVER == "http://192.168.68.119:8088"
204
-
205
-
206
- def test_llama_server_reads_env(monkeypatch):
207
- import importlib, app
208
- monkeypatch.setenv("LLAMA_SERVER", "https://tunnel.example.com")
209
- try:
210
- importlib.reload(app)
211
- assert app.LLAMA_SERVER == "https://tunnel.example.com"
212
- finally:
213
- monkeypatch.delenv("LLAMA_SERVER", raising=False)
214
- importlib.reload(app)
215
-
216
-
217
- def test_manifest_shows_badge_for_owned_paint(monkeypatch):
218
- """When a Tamiya code is in OWNED, the manifest li must carry class='owned'."""
219
- import inventory, app
220
- monkeypatch.setattr(inventory, "OWNED", frozenset({"XF17"})) # no dash, matches lookup format
221
-
222
- from unittest.mock import patch as mpatch
223
- from PIL import Image
224
- img = Image.new("RGB", (10, 10))
225
-
226
- # JSON_SCHEMA produces a bare array. Humbrol 104 → XF17 Sea Blue.
227
- fake_raw = '[{"code": "104", "name": "Sea Blue"}]'
228
- with mpatch.object(app, "call_llama", return_value=fake_raw):
229
- raw, html = app._run_analysis(img, "airfix_shop")
230
-
231
- assert 'class="owned"' in html, f"Expected owned badge, got: {html}"
232
- assert "mojehobby" not in html, f"Shop link should not appear for owned paint"
233
-
234
-
235
- def test_manifest_shows_shop_link_for_unowned_paint(monkeypatch):
236
- """When a Tamiya code is NOT in OWNED, the manifest li must contain a shop link."""
237
- import inventory, app
238
- monkeypatch.setattr(inventory, "OWNED", frozenset())
239
-
240
- from unittest.mock import patch as mpatch
241
- from PIL import Image
242
- img = Image.new("RGB", (10, 10))
243
-
244
- fake_raw = '[{"code": "104", "name": "Sea Blue"}]'
245
- with mpatch.object(app, "call_llama", return_value=fake_raw):
246
- raw, html = app._run_analysis(img, "airfix_shop")
247
-
248
- assert "mojehobby" in html, f"Expected shop link, got: {html}"
249
- assert 'class="owned"' not in html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_inventory.py DELETED
@@ -1,46 +0,0 @@
1
- import sys, os
2
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
3
-
4
- import importlib
5
- from unittest.mock import MagicMock, patch
6
-
7
-
8
- def make_sheet_rows(*codes):
9
- return [[c] for c in codes]
10
-
11
-
12
- def test_owned_is_frozenset():
13
- import inventory
14
- assert isinstance(inventory.OWNED, frozenset)
15
-
16
-
17
- def test_owned_empty_when_env_missing(monkeypatch):
18
- monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_JSON", raising=False)
19
- monkeypatch.delenv("GOOGLE_SHEET_ID", raising=False)
20
- import inventory
21
- importlib.reload(inventory)
22
- assert inventory.OWNED == frozenset()
23
-
24
-
25
- def test_owned_normalises_to_uppercase(monkeypatch):
26
- monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_JSON", '{"type":"service_account"}')
27
- monkeypatch.setenv("GOOGLE_SHEET_ID", "fake-id")
28
- fake_ws = MagicMock()
29
- fake_ws.get_all_values.return_value = make_sheet_rows("Kod", "x-25", "XF-24", " XF-2 ")
30
- fake_gc = MagicMock()
31
- fake_gc.open_by_key.return_value.sheet1 = fake_ws
32
- with patch("gspread.service_account_from_dict", return_value=fake_gc):
33
- import inventory
34
- importlib.reload(inventory)
35
- assert "X25" in inventory.OWNED
36
- assert "XF24" in inventory.OWNED
37
- assert "XF2" in inventory.OWNED
38
-
39
-
40
- def test_owned_empty_on_auth_error(monkeypatch):
41
- monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_JSON", '{"type":"service_account"}')
42
- monkeypatch.setenv("GOOGLE_SHEET_ID", "fake-id")
43
- with patch("gspread.service_account_from_dict", side_effect=Exception("auth failed")):
44
- import inventory
45
- importlib.reload(inventory)
46
- assert inventory.OWNED == frozenset()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_server.py DELETED
@@ -1,32 +0,0 @@
1
- import sys, os
2
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
3
-
4
- import PIL.Image
5
- import server
6
-
7
-
8
- def test_analyze_opens_filedata_dict_and_returns_manifest(monkeypatch, tmp_path):
9
- # gr.Server delivers the image as a FileData dict {"path": ...}, not a PIL image.
10
- # This covers server.analyze's dict-unwrap branch (its only novel logic).
11
- # Force the regex parser so the line-format mock parses regardless of the
12
- # default OUTPUT_MODE — this test is about the dict unwrap, not the constraint.
13
- monkeypatch.setattr("app.OUTPUT_MODE", "free")
14
- monkeypatch.setattr(
15
- "app.call_llama",
16
- lambda img_b64, prompt: "- 33: Black",
17
- )
18
- img_path = tmp_path / "swatch.png"
19
- PIL.Image.new("RGB", (32, 32), "white").save(img_path)
20
-
21
- updates = list(server.analyze({"path": str(img_path)}, "airfix_shop"))
22
-
23
- assert len(updates) >= 1
24
- raw, manifest = updates[-1]
25
- assert "33" in raw
26
- assert "Humbrol 33" in manifest
27
- assert manifest.startswith("<ul>")
28
-
29
-
30
- def test_analyze_handles_no_image():
31
- updates = list(server.analyze(None, "airfix_shop"))
32
- assert updates[-1] == ("No image provided.", "")