Extract reports unidentifiable boxes for human retakes

The vision prompt now returns {titles, unidentified}: boxes that look
like games but can't be confidently titled are reported (location
relative to identified neighbors, partial text, art notes) instead of
silently omitted. They land in data/unidentified.json keyed by photo,
and the end-of-run summary lists them — plus low-confidence reads —
with instructions to retake a closer photo and re-run. New --force flag
re-extracts everything; pre-feature raw caches (bare arrays) still
parse. Live run on IMG_4499 confirmed the flow and the low-confidence
list correctly flags the known "Hebarceos" misread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 14:24:16 -04:00
parent 58956f626d
commit 4bf7481f9b
8 changed files with 284 additions and 54 deletions
+121 -9
View File
@@ -12,7 +12,7 @@ import typer
from bggpipe.config import Config
from bggpipe.extract import (
dedupe_entries,
parse_vision_json,
parse_vision_response,
prepare_image,
run_extract,
)
@@ -77,25 +77,43 @@ def test_prepare_image_converts_heic(tmp_path):
# -- defensive JSON parsing ---------------------------------------------
def test_parse_strips_code_fences():
def test_parse_object_with_titles_and_unidentified():
text = """```json
{"titles": [{"title_raw": "Catan", "confidence": "high"}],
"unidentified": [{"location": "top shelf, left of Catan",
"partial_text": "WAR", "art_notes": "red spine"}]}
```"""
titles, unidentified = parse_vision_response(text)
assert titles[0]["title_raw"] == "Catan"
assert unidentified[0]["location"] == "top shelf, left of Catan"
def test_parse_legacy_bare_array_still_works():
text = '```json\n[{"title_raw": "Catan", "confidence": "high"}]\n```'
assert parse_vision_json(text)[0]["title_raw"] == "Catan"
titles, unidentified = parse_vision_response(text)
assert titles[0]["title_raw"] == "Catan"
assert unidentified == []
def test_parse_tolerates_prose_around_array():
def test_parse_tolerates_prose_around_json():
text = 'Here are the games:\n[{"title_raw": "Wingspan"}]\nLet me know!'
assert parse_vision_json(text)[0]["title_raw"] == "Wingspan"
titles, _ = parse_vision_response(text)
assert titles[0]["title_raw"] == "Wingspan"
def test_parse_drops_malformed_entries():
text = '[{"title_raw": "Catan"}, {"no_title": true}, "just a string"]'
entries = parse_vision_json(text)
assert len(entries) == 1
text = (
'{"titles": [{"title_raw": "Catan"}, {"no_title": true}, "just a string"],'
' "unidentified": [{}, "not a dict", {"location": "somewhere"}]}'
)
titles, unidentified = parse_vision_response(text)
assert len(titles) == 1
assert len(unidentified) == 1 # empty {} and the bare string are dropped
def test_parse_raises_on_garbage():
with pytest.raises(ValueError):
parse_vision_json("I couldn't see any games clearly.")
parse_vision_response("I couldn't see any games clearly.")
# -- dedupe with edition cues -------------------------------------------
@@ -205,3 +223,97 @@ def test_run_extract_empty_photos_dir_exits(tmp_path):
vision, _ = _vision_stub("[]")
with pytest.raises(typer.Exit):
run_extract(cfg, vision=vision)
# -- unidentified sightings ---------------------------------------------
import json # noqa: E402
OBJECT_PAYLOAD = json.dumps(
{
"titles": [{"title_raw": "Catan", "confidence": "high"}],
"unidentified": [
{
"location": "middle shelf, between Catan and the frame edge",
"partial_text": "EMP",
"art_notes": "tall black box with gold lettering",
}
],
}
)
def test_unidentified_lands_in_artifact_and_summary(tmp_path, capsys):
cfg = _cfg(tmp_path)
cfg.photos_dir.mkdir()
_write_image(cfg.photos_dir / "shelf1.jpg")
vision, _ = _vision_stub(OBJECT_PAYLOAD)
run_extract(cfg, vision=vision)
saved = json.loads(cfg.unidentified_path.read_text())
assert saved == {
"shelf1.jpg": [
{
"location": "middle shelf, between Catan and the frame edge",
"partial_text": "EMP",
"art_notes": "tall black box with gold lettering",
}
]
}
out = capsys.readouterr().out
assert "couldn't identify" in out
assert "between Catan and the frame edge" in out
def test_clean_photo_writes_empty_unidentified(tmp_path):
cfg = _cfg(tmp_path)
cfg.photos_dir.mkdir()
_write_image(cfg.photos_dir / "shelf1.jpg")
vision, _ = _vision_stub('{"titles": [{"title_raw": "Azul"}], "unidentified": []}')
run_extract(cfg, vision=vision)
assert json.loads(cfg.unidentified_path.read_text()) == {}
def test_legacy_array_raw_cache_still_rebuilds(tmp_path):
"""Raw files written before the unidentified feature are bare arrays."""
cfg = _cfg(tmp_path)
cfg.photos_dir.mkdir()
_write_image(cfg.photos_dir / "old.jpg")
raw_dir = cfg.data_dir / "extract_raw"
raw_dir.mkdir(parents=True)
(raw_dir / "old.jpg.json").write_text(json.dumps([_entry("Catan", "old.jpg")]))
vision, calls = _vision_stub("[]")
result = run_extract(cfg, vision=vision)
assert calls == [] # cached photo untouched
assert result[0]["title_raw"] == "Catan"
assert json.loads(cfg.unidentified_path.read_text()) == {}
def test_force_reextracts_cached_photos(tmp_path):
cfg = _cfg(tmp_path)
cfg.photos_dir.mkdir()
_write_image(cfg.photos_dir / "shelf1.jpg")
vision, calls = _vision_stub('[{"title_raw": "Azul"}]')
run_extract(cfg, vision=vision)
run_extract(cfg, vision=vision)
assert len(calls) == 1 # cache hit
vision2, calls2 = _vision_stub(OBJECT_PAYLOAD)
result = run_extract(cfg, force=True, vision=vision2)
assert len(calls2) == 1 # cache ignored
assert result[0]["title_raw"] == "Catan"
def test_low_confidence_reads_are_surfaced(tmp_path, capsys):
cfg = _cfg(tmp_path)
cfg.photos_dir.mkdir()
_write_image(cfg.photos_dir / "shelf1.jpg")
vision, _ = _vision_stub(
'[{"title_raw": "Patchwork", "confidence": "medium"},'
' {"title_raw": "Catan", "confidence": "high"}]'
)
run_extract(cfg, vision=vision)
out = capsys.readouterr().out
assert "Low-confidence reads" in out
assert "'Patchwork' (medium)" in out
assert "'Catan'" not in out.split("Low-confidence reads")[1]