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:
+121
-9
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user