Extract stage: vision title + edition-cue extraction, offline-tested
Per-photo raw results cached under data/extract_raw/ (gitignored) so re-runs are free, --only re-extracts a single photo, and titles.json is rebuilt with dedupe that keeps conflicting-edition sightings separate. HEIC converts via pillow-heif; images downscale to <=1568px long edge; model JSON parsed defensively (code fences, surrounding prose). Vision callable is injectable — tests use a local fake; the real one uses the anthropic SDK (approved) with the model from config.toml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""Extract-stage tests. The vision callable is always a local fake — the
|
||||
Anthropic API is never touched here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.extract import (
|
||||
dedupe_entries,
|
||||
parse_vision_json,
|
||||
prepare_image,
|
||||
run_extract,
|
||||
)
|
||||
|
||||
|
||||
def _write_image(path, size=(400, 300), fmt="JPEG", color=(200, 30, 30)):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", size, color)
|
||||
if fmt == "HEIC":
|
||||
from pillow_heif import register_heif_opener
|
||||
|
||||
register_heif_opener()
|
||||
img.save(path, format="HEIF")
|
||||
else:
|
||||
img.save(path, format=fmt)
|
||||
return path
|
||||
|
||||
|
||||
def _vision_stub(payload):
|
||||
"""A VisionFn returning a canned response, recording call count."""
|
||||
calls = []
|
||||
|
||||
def vision(image_b64, media_type):
|
||||
calls.append(media_type)
|
||||
return payload
|
||||
|
||||
return vision, calls
|
||||
|
||||
|
||||
# -- image preparation --------------------------------------------------
|
||||
|
||||
|
||||
def test_prepare_image_downscales_long_edge(tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
photo = _write_image(tmp_path / "big.jpg", size=(4000, 3000))
|
||||
b64, media_type = prepare_image(photo)
|
||||
assert media_type == "image/jpeg"
|
||||
out = Image.open(io.BytesIO(base64.standard_b64decode(b64)))
|
||||
assert max(out.size) == 1568
|
||||
assert out.size == (1568, 1176) # aspect ratio preserved
|
||||
|
||||
|
||||
def test_prepare_image_keeps_small_images(tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
photo = _write_image(tmp_path / "small.png", size=(640, 480), fmt="PNG")
|
||||
b64, _ = prepare_image(photo)
|
||||
out = Image.open(io.BytesIO(base64.standard_b64decode(b64)))
|
||||
assert out.size == (640, 480)
|
||||
assert out.format == "JPEG" # PNG converted to JPEG
|
||||
|
||||
|
||||
def test_prepare_image_converts_heic(tmp_path):
|
||||
photo = _write_image(tmp_path / "iphone.heic", size=(2000, 1500), fmt="HEIC")
|
||||
b64, media_type = prepare_image(photo)
|
||||
assert media_type == "image/jpeg"
|
||||
assert len(b64) > 0
|
||||
|
||||
|
||||
# -- defensive JSON parsing ---------------------------------------------
|
||||
|
||||
|
||||
def test_parse_strips_code_fences():
|
||||
text = '```json\n[{"title_raw": "Catan", "confidence": "high"}]\n```'
|
||||
assert parse_vision_json(text)[0]["title_raw"] == "Catan"
|
||||
|
||||
|
||||
def test_parse_tolerates_prose_around_array():
|
||||
text = 'Here are the games:\n[{"title_raw": "Wingspan"}]\nLet me know!'
|
||||
assert parse_vision_json(text)[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
|
||||
|
||||
|
||||
def test_parse_raises_on_garbage():
|
||||
with pytest.raises(ValueError):
|
||||
parse_vision_json("I couldn't see any games clearly.")
|
||||
|
||||
|
||||
# -- dedupe with edition cues -------------------------------------------
|
||||
|
||||
|
||||
def _entry(title, photo, **cues):
|
||||
return {
|
||||
"title_raw": title,
|
||||
"confidence": cues.pop("confidence", "high"),
|
||||
"publisher_hint": cues.pop("publisher_hint", ""),
|
||||
"edition_hint": cues.pop("edition_hint", ""),
|
||||
"year_hint": cues.pop("year_hint", None),
|
||||
"language_hint": cues.pop("language_hint", ""),
|
||||
"art_notes": cues.pop("art_notes", ""),
|
||||
"source_photos": [photo],
|
||||
}
|
||||
|
||||
|
||||
def test_dedupe_merges_same_title_compatible_cues():
|
||||
deduped = dedupe_entries(
|
||||
[
|
||||
_entry("Wingspan", "a.jpg", publisher_hint="Stonemaier Games"),
|
||||
_entry("WINGSPAN", "b.jpg"), # no cues -> compatible
|
||||
]
|
||||
)
|
||||
assert len(deduped) == 1
|
||||
assert deduped[0]["source_photos"] == ["a.jpg", "b.jpg"]
|
||||
assert deduped[0]["publisher_hint"] == "Stonemaier Games" # provenance kept
|
||||
|
||||
|
||||
def test_dedupe_keeps_conflicting_editions_separate():
|
||||
deduped = dedupe_entries(
|
||||
[
|
||||
_entry(
|
||||
"Cosmic Encounter", "a.jpg", edition_hint="42nd Anniversary Edition"
|
||||
),
|
||||
_entry("Cosmic Encounter", "b.jpg", edition_hint="Eon 1977 edition"),
|
||||
]
|
||||
)
|
||||
assert len(deduped) == 2 # different editions stay separate entries
|
||||
|
||||
|
||||
def test_dedupe_conflicting_years_stay_separate():
|
||||
deduped = dedupe_entries(
|
||||
[
|
||||
_entry("Catan", "a.jpg", year_hint=1995),
|
||||
_entry("Catan", "b.jpg", year_hint=2015),
|
||||
]
|
||||
)
|
||||
assert len(deduped) == 2
|
||||
|
||||
|
||||
def test_dedupe_upgrades_confidence():
|
||||
deduped = dedupe_entries(
|
||||
[
|
||||
_entry("Azul", "a.jpg", confidence="low"),
|
||||
_entry("Azul", "b.jpg", confidence="high"),
|
||||
]
|
||||
)
|
||||
assert deduped[0]["confidence"] == "high"
|
||||
|
||||
|
||||
# -- run_extract orchestration ------------------------------------------
|
||||
|
||||
|
||||
def _cfg(tmp_path):
|
||||
return Config(photos_dir=tmp_path / "photos", data_dir=tmp_path / "data")
|
||||
|
||||
|
||||
def test_run_extract_is_idempotent_and_resumable(tmp_path):
|
||||
cfg = _cfg(tmp_path)
|
||||
cfg.photos_dir.mkdir()
|
||||
_write_image(cfg.photos_dir / "shelf1.jpg")
|
||||
_write_image(cfg.photos_dir / "shelf2.jpg")
|
||||
vision, calls = _vision_stub('[{"title_raw": "Catan", "confidence": "high"}]')
|
||||
|
||||
result = run_extract(cfg, vision=vision)
|
||||
assert len(calls) == 2 # one vision call per photo
|
||||
assert len(result) == 1 # deduped across photos
|
||||
assert result[0]["source_photos"] == ["shelf1.jpg", "shelf2.jpg"]
|
||||
assert cfg.titles_path.exists()
|
||||
|
||||
run_extract(cfg, vision=vision)
|
||||
assert len(calls) == 2 # second run: everything cached, no vision calls
|
||||
|
||||
|
||||
def test_run_extract_only_reprocesses_one_photo(tmp_path):
|
||||
cfg = _cfg(tmp_path)
|
||||
cfg.photos_dir.mkdir()
|
||||
_write_image(cfg.photos_dir / "shelf1.jpg")
|
||||
_write_image(cfg.photos_dir / "blurry.jpg")
|
||||
vision, calls = _vision_stub('[{"title_raw": "Azul"}]')
|
||||
run_extract(cfg, vision=vision)
|
||||
|
||||
retake_vision, retake_calls = _vision_stub(
|
||||
'[{"title_raw": "Azul: Summer Pavilion"}]'
|
||||
)
|
||||
result = run_extract(cfg, only="blurry.jpg", vision=retake_vision)
|
||||
assert retake_calls == ["image/jpeg"] # exactly one re-extraction
|
||||
titles = {e["title_raw"] for e in result}
|
||||
assert titles == {"Azul", "Azul: Summer Pavilion"}
|
||||
|
||||
|
||||
def test_run_extract_empty_photos_dir_exits(tmp_path):
|
||||
cfg = _cfg(tmp_path)
|
||||
cfg.photos_dir.mkdir()
|
||||
vision, _ = _vision_stub("[]")
|
||||
with pytest.raises(typer.Exit):
|
||||
run_extract(cfg, vision=vision)
|
||||
Reference in New Issue
Block a user