diff --git a/.env.example b/.env.example index 5e21bca..94d5fff 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,6 @@ BGG_USERNAME= BGG_PASSWORD= # Anthropic API key for the vision extract stage +# default vision provider; not needed for an openai-compatible/local +# setup — see the [vision.*] blocks in config.toml ANTHROPIC_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 774a965..a2b18ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel - `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`). - `uv run bggpipe web` — the app: seven pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Help) in a shared sidebar shell (responsive: hamburger nav + stacked tables under 900px); stage runs execute one-at-a-time in a background job. `--lan` binds 0.0.0.0 behind a per-device access key: persisted in `data/.lan_key` (gitignored), printed as a QR at startup, cookie-paired for a year, required on EVERY network request (loopback clients and `/static/*` are exempt; the Host/Origin guard still applies). Phone camera uploads (generic `image.jpg` names) get minted `shelf-` names — only explicitly-named files trigger the replace-to-reshoot flow. -- `uv run bggpipe ` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path. +- `uv run bggpipe ` — run a pipeline stage. Non-secret settings come from `config.toml` (dirs, rate limit, `vision_provider` + per-provider `[vision.*]` blocks — "anthropic" or any OpenAI-compatible endpoint incl. local Ollama); `--config` overrides the path. - `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`. - `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format. diff --git a/README.md b/README.md index 9ea65d1..0af376c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ BGG has no bulk import and no write API. Cataloging a few hundred games by hand ## How it works -1. **extract** — Shelf photos go to the Anthropic API (Claude vision), which reads game titles off spines and boxes along with edition cues: publisher, edition wording, print year, language. +1. **extract** — Shelf photos go to a vision model (Claude by default; any OpenAI-compatible endpoint or a local Ollama model works), which reads game titles off spines and boxes along with edition cues: publisher, edition wording, print year, language. 2. **resolve** — Titles are matched to BGG game IDs via the [XML API2](https://boardgamegeek.com/wiki/page/BGG_XML_API2) (exact + fuzzy matching, popularity tiebreaks), then edition cues are matched against BGG's version list for each game. Anything uncertain is flagged rather than guessed. 3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection. 4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own). @@ -93,7 +93,7 @@ Secrets live in environment variables only, never in config files, code, or logs | `BGG_USERNAME` | diff, upload, enrich | Your BGG username (public, but kept in `.env` so it lives in one place) | | `BGG_PASSWORD` | upload (website login) | Your BGG password | -Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) live in `config.toml`. From here you can drive everything from the browser: +Non-secret knobs (`photos_dir`, `data_dir`, the per-provider vision blocks, the rate limit) live in `config.toml`. From here you can drive everything from the browser: ```sh uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser diff --git a/src/bggpipe/init_wizard.py b/src/bggpipe/init_wizard.py index b2cb4a3..f66729d 100644 --- a/src/bggpipe/init_wizard.py +++ b/src/bggpipe/init_wizard.py @@ -28,8 +28,22 @@ CONFIG_TEMPLATE = """\ photos_dir = "photos" data_dir = "data" -model = "claude-sonnet-5" rate_limit_seconds = 2.0 + +# Which vision backend reads your shelf photos. Both recipes below stay +# on file; this line picks one. +vision_provider = "anthropic" + +[vision.anthropic] +# 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 = "" """ ENV_HEADER = """\ @@ -159,9 +173,33 @@ def run_init( typer.echo(f" found {config_path}") # -- credentials ---------------------------------------------------- + # the vision key follows the configured provider: an openai-compatible + # setup prompts for ITS key env (or none, for a local endpoint) + from bggpipe.config import load_config + + active = load_config(config_path if config_path.exists() else None) + env_keys = list(ENV_KEYS) + if active.vision_provider != "anthropic": + env_keys = [k for k in env_keys if k[0] != "ANTHROPIC_API_KEY"] + if active.vision_key_env: + env_keys.insert( + 0, + ( + active.vision_key_env, + True, + "vision extraction via " + + (active.vision_base_url or "the configured endpoint"), + "your vision provider's console", + ), + ) + else: + typer.echo( + " vision: openai-compatible endpoint with no key configured " + "(local runtime) — nothing to prompt for" + ) env_path = project_dir / ".env" in_file = _env_file_keys(env_path) - for key, secret, why, where in ENV_KEYS: + for key, secret, why, where in env_keys: if os.environ.get(key) or key in in_file: report.keys_ready.append(key) typer.echo(f" {key}: set") diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index 42bcd17..a6449a9 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -12,7 +12,7 @@

Six pipeline stages and two checkpoints that are yours. Stages run from the Pipeline page (or the CLI — both share all state and either can pick up where the other left off):

    -
  1. extract — every photo goes to Claude vision once; what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become reshoot tickets on the Photos page.
  2. +
  3. extract — every photo goes to the vision model once (Claude by default; a local model works too — see the README); what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become reshoot tickets on the Photos page.
  4. proofread (you, on Titles) — fix misread titles, add cues you know, split multi-box lines, remove non-games, details below. Worth doing before resolve: a fix made now is one BGG search done right; a fix made later sends the title back through resolve again.
  5. resolve — titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged, never guessed. Without a BGG API token, titles wait as awaiting BGG and are picked up automatically once the token exists.
  6. review (you, on Review) — decide the flagged ones: which game, which edition, whether two reads are one box. Every decision saves immediately.
  7. diff --git a/tests/test_init.py b/tests/test_init.py index 41678c5..62775a6 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -147,3 +147,46 @@ def test_env_file_is_owner_only_from_creation(tmp_path, monkeypatch): _clear_env(monkeypatch) _run(tmp_path, answers={"BGG_API_TOKEN": "tok-123"}) assert _os.stat(tmp_path / ".env").st_mode & 0o777 == 0o600 + + +def test_vision_key_prompt_follows_the_configured_provider(tmp_path, monkeypatch): + _clear_env(monkeypatch) + monkeypatch.delenv("MY_VISION_KEY", raising=False) + # a local, keyless openai-compatible setup: no Anthropic prompt at all + (tmp_path / "config.toml").write_text( + """ +vision_provider = "openai-compatible" + +[vision."openai-compatible"] +base_url = "http://localhost:11434/v1" +model = "qwen2.5vl:7b" +key_env = "" +""" + ) + report = _run(tmp_path, interactive=False) + assert "ANTHROPIC_API_KEY" not in report.keys_missing + + # a keyed endpoint prompts for ITS env var instead + (tmp_path / "config.toml").write_text( + """ +vision_provider = "openai-compatible" + +[vision."openai-compatible"] +base_url = "https://openrouter.ai/api/v1" +model = "some/vision-model" +key_env = "MY_VISION_KEY" +""" + ) + report = _run(tmp_path, interactive=False) + assert "MY_VISION_KEY" in report.keys_missing + assert "ANTHROPIC_API_KEY" not in report.keys_missing + + +def test_fresh_config_template_carries_both_vision_blocks(tmp_path, monkeypatch): + _clear_env(monkeypatch) + _run(tmp_path, interactive=False) + from bggpipe.config import load_config + + cfg = load_config(tmp_path / "config.toml") + assert cfg.vision_provider == "anthropic" + assert cfg.model == "claude-sonnet-5" # active block applied cleanly