Per-provider vision blocks; parser repairs local models' almost-JSON
config.toml now carries a [vision.<provider>] block per backend — model/base_url/key_env — with vision_provider picking the active one, so the committed file documents every recipe and switching is a one-line flip. Only the active block applies; typo'd block names and keys warn like every other config mistake. First real Ollama run (qwen2.5vl:7b) surfaced what local models emit: almost-JSON with trailing commas. parse_vision_response now makes one cheap repair pass before declaring a response unusable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
0358079da4
commit
118503e4e0
@@ -70,7 +70,7 @@ Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS or Linux, Python 3.12+, [uv](https://docs.astral.sh/uv/)
|
- macOS or Linux, Python 3.12+, [uv](https://docs.astral.sh/uv/)
|
||||||
- A vision model for extraction — an [Anthropic API key](https://console.anthropic.com/) by default, or any OpenAI-compatible endpoint via `config.toml` (`vision_provider = "openai-compatible"` + `vision_base_url`): OpenAI, OpenRouter, or a free local runtime like [Ollama](https://ollama.com/) with a vision-capable model. Local models read spines noticeably worse than frontier ones — expect a longer proofread pass on the Titles page, not a broken pipeline.
|
- A vision model for extraction — an [Anthropic API key](https://console.anthropic.com/) by default, or any OpenAI-compatible endpoint: OpenAI, OpenRouter, or a free local runtime like [Ollama](https://ollama.com/) with a vision-capable model. `config.toml` carries a `[vision.<provider>]` block for each; `vision_provider` picks one. Local models read spines noticeably worse than frontier ones — expect a longer proofread pass on the Titles page, not a broken pipeline.
|
||||||
- A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared.
|
- A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|||||||
+14
-7
@@ -3,12 +3,19 @@
|
|||||||
|
|
||||||
photos_dir = "photos"
|
photos_dir = "photos"
|
||||||
data_dir = "data"
|
data_dir = "data"
|
||||||
model = "claude-sonnet-5"
|
|
||||||
rate_limit_seconds = 2.0
|
rate_limit_seconds = 2.0
|
||||||
|
|
||||||
# Vision backend. The default ("anthropic") reads ANTHROPIC_API_KEY.
|
# Which vision backend reads your shelf photos. Both recipes below stay
|
||||||
# "openai-compatible" covers OpenAI, OpenRouter, and local runtimes:
|
# on file; this line picks one.
|
||||||
# vision_provider = "openai-compatible"
|
vision_provider = "anthropic"
|
||||||
# vision_base_url = "http://localhost:11434/v1" # e.g. Ollama
|
|
||||||
# model = "qwen2.5vl" # a vision-capable model
|
[vision.anthropic]
|
||||||
# vision_key_env = "" # local: no key needed
|
# reads ANTHROPIC_API_KEY from the environment
|
||||||
|
model = "claude-sonnet-5"
|
||||||
|
|
||||||
|
[vision."openai-compatible"]
|
||||||
|
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
|
||||||
|
# key_env names the env var holding the key; "" = endpoint needs none.
|
||||||
|
base_url = "http://localhost:11434/v1"
|
||||||
|
model = "qwen2.5vl:7b"
|
||||||
|
key_env = ""
|
||||||
|
|||||||
+25
-3
@@ -140,17 +140,39 @@ def load_config(path: Path | None = None) -> Config:
|
|||||||
"model": str,
|
"model": str,
|
||||||
"rate_limit_seconds": float,
|
"rate_limit_seconds": float,
|
||||||
"vision_provider": str,
|
"vision_provider": str,
|
||||||
"vision_base_url": str,
|
|
||||||
"vision_key_env": str,
|
|
||||||
}
|
}
|
||||||
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
|
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
|
||||||
if unknown := sorted(raw.keys() - known.keys()):
|
if unknown := sorted(raw.keys() - known.keys() - {"vision"}):
|
||||||
# a typo'd key silently falling back to defaults is a debugging
|
# a typo'd key silently falling back to defaults is a debugging
|
||||||
# trap ("No photos found in photos/") — say so up front
|
# trap ("No photos found in photos/") — say so up front
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f"{p}: ignoring unknown key(s): {', '.join(unknown)}",
|
f"{p}: ignoring unknown key(s): {', '.join(unknown)}",
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
|
# [vision.<provider>] blocks: every provider's recipe can live in
|
||||||
|
# the committed file; only the ACTIVE provider's block applies
|
||||||
|
vision_blocks = raw.get("vision") or {}
|
||||||
|
if stray := sorted(vision_blocks.keys() - set(VISION_PROVIDERS)):
|
||||||
|
warnings.warn(
|
||||||
|
f"{p}: ignoring [vision.*] block(s) for unknown provider(s): "
|
||||||
|
f"{', '.join(stray)}",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
provider = updates.get("vision_provider", cfg.vision_provider)
|
||||||
|
block = vision_blocks.get(provider) or {}
|
||||||
|
block_known = {
|
||||||
|
"model": "model",
|
||||||
|
"base_url": "vision_base_url",
|
||||||
|
"key_env": "vision_key_env",
|
||||||
|
}
|
||||||
|
if odd := sorted(block.keys() - block_known.keys()):
|
||||||
|
warnings.warn(
|
||||||
|
f"{p}: [vision.{provider}]: ignoring unknown key(s): {', '.join(odd)}",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
for key, field in block_known.items():
|
||||||
|
if key in block:
|
||||||
|
updates[field] = str(block[key])
|
||||||
cfg = replace(cfg, **updates)
|
cfg = replace(cfg, **updates)
|
||||||
if cfg.vision_provider not in VISION_PROVIDERS:
|
if cfg.vision_provider not in VISION_PROVIDERS:
|
||||||
# a typo here would surface as a confusing extract failure later
|
# a typo here would surface as a confusing extract failure later
|
||||||
|
|||||||
@@ -126,7 +126,12 @@ def parse_vision_response(text: str) -> tuple[list[dict], list[dict], int]:
|
|||||||
if end == -1:
|
if end == -1:
|
||||||
raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}")
|
raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}")
|
||||||
cleaned = cleaned[start : end + 1]
|
cleaned = cleaned[start : end + 1]
|
||||||
data = json.loads(cleaned)
|
try:
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# local models emit almost-JSON (trailing commas, mostly): one
|
||||||
|
# cheap repair pass before declaring the response unusable
|
||||||
|
data = json.loads(re.sub(r",\s*([}\]])", r"\1", cleaned))
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
titles_raw, unidentified_raw = data, []
|
titles_raw, unidentified_raw = data, []
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
|
|||||||
@@ -52,3 +52,47 @@ def test_unknown_toml_keys_warn(tmp_path, monkeypatch):
|
|||||||
def test_explicit_missing_config_errors_instead_of_silent_defaults(tmp_path):
|
def test_explicit_missing_config_errors_instead_of_silent_defaults(tmp_path):
|
||||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||||
load_config(tmp_path / "nope.toml")
|
load_config(tmp_path / "nope.toml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_blocks_apply_only_for_the_active_provider(tmp_path):
|
||||||
|
p = tmp_path / "config.toml"
|
||||||
|
p.write_text(
|
||||||
|
"""
|
||||||
|
vision_provider = "openai-compatible"
|
||||||
|
|
||||||
|
[vision.anthropic]
|
||||||
|
model = "claude-sonnet-5"
|
||||||
|
|
||||||
|
[vision."openai-compatible"]
|
||||||
|
base_url = "http://localhost:11434/v1"
|
||||||
|
model = "qwen2.5vl:7b"
|
||||||
|
key_env = ""
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cfg = load_config(p)
|
||||||
|
assert cfg.model == "qwen2.5vl:7b" # the active block's model wins
|
||||||
|
assert cfg.vision_base_url == "http://localhost:11434/v1"
|
||||||
|
assert cfg.vision_key_env == ""
|
||||||
|
# flip the provider: the other block applies, this one is inert
|
||||||
|
p.write_text(p.read_text().replace('"openai-compatible"\n', '"anthropic"\n', 1))
|
||||||
|
cfg = load_config(p)
|
||||||
|
assert cfg.model == "claude-sonnet-5"
|
||||||
|
assert cfg.vision_base_url == "" # untouched default
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_block_typos_warn(tmp_path):
|
||||||
|
p = tmp_path / "config.toml"
|
||||||
|
p.write_text(
|
||||||
|
"""
|
||||||
|
[vision.anthropic]
|
||||||
|
modle = "oops"
|
||||||
|
|
||||||
|
[vision.anthorpic]
|
||||||
|
model = "claude-sonnet-5"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
pytest.warns(UserWarning, match="modle"),
|
||||||
|
pytest.warns(UserWarning, match="anthorpic"),
|
||||||
|
):
|
||||||
|
load_config(p)
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ def test_parse_drops_malformed_entries():
|
|||||||
assert len(unidentified) == 1 # empty {} and the bare string are dropped
|
assert len(unidentified) == 1 # empty {} and the bare string are dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_repairs_trailing_commas():
|
||||||
|
# the almost-JSON local vision models emit
|
||||||
|
titles, unidentified, dropped = parse_vision_response(
|
||||||
|
'{"titles": [{"title_raw": "Catan",},], "unidentified": [],}'
|
||||||
|
)
|
||||||
|
assert [t["title_raw"] for t in titles] == ["Catan"]
|
||||||
|
assert unidentified == [] and dropped == 0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_raises_on_garbage():
|
def test_parse_raises_on_garbage():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
parse_vision_response("I couldn't see any games clearly.")
|
parse_vision_response("I couldn't see any games clearly.")
|
||||||
|
|||||||
Reference in New Issue
Block a user