""" Guardrail: the two deployment options must not break each other. This project ships in exactly two deployment shapes, differentiated by environment configuration over the SAME codebase: 1) Hosted free web (HF Spaces / Render / …) OLLAMA_ENABLED=false, HOSTED=1, NOMINATIM_URL= - Ollama is never used (no local model server on a shared host) — requests fall back to Claude (BYOK) / the free chatbot path. - Bulk geocoding against PUBLIC OSM is refused (would get the IP banned). 2) Local Docker (localhost) OLLAMA_ENABLED=true, HOSTED unset, public OSM Nominatim - The Ollama provider is honored. - Bulk geocoding against public OSM is allowed (single user, ~1 req/s). These two columns are the contract. A code change that breaks either column fails this test — that's the guardrail. The behaviour is encoded in two pure helpers so it stays testable without a server or the network: web.app.resolve_provider(...) — provider/model selection pipeline.geocode.public_bulk_blocked — geocoder hosted guard Run: python3 tests/test_deployment_modes.py """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) import xml.etree.ElementTree as ET from pipeline.chatbot import get_prompt from pipeline.export import build_kml from pipeline.extract import DEFAULT_MODEL, DEFAULT_OLLAMA_MODEL from pipeline.geocode import public_bulk_blocked from web.app import resolve_provider, safe_err, valid_latlng PUBLIC = "https://nominatim.openstreetmap.org" SELF_HOSTED = "http://nominatim:8080" failures: list[str] = [] def check(label: str, got, expected) -> None: if got != expected: failures.append(f"{label}: expected {expected!r}, got {got!r}") # ── Deployment 1: Hosted free web (OLLAMA_ENABLED=false, HOSTED=1) ───────────── # Ollama must NEVER be selected on a hosted deploy — fall back to Claude. check("hosted: ollama request falls back to Claude", resolve_provider("ollama", DEFAULT_MODEL, "qwen2.5", ollama_enabled=False), ("anthropic", DEFAULT_MODEL)) check("hosted: explicit Claude model honored", resolve_provider("anthropic", "claude-haiku-4-5", DEFAULT_OLLAMA_MODEL, ollama_enabled=False), ("anthropic", "claude-haiku-4-5")) check("hosted: unknown Claude model defaults", resolve_provider("anthropic", "made-up-model", DEFAULT_OLLAMA_MODEL, ollama_enabled=False), ("anthropic", DEFAULT_MODEL)) # Bulk geocoding against public OSM must be refused; a self-hosted endpoint is OK. check("hosted: public OSM bulk geocode blocked", public_bulk_blocked("1", PUBLIC), True) check("hosted: 'true'/'yes' variants also block", public_bulk_blocked("true", PUBLIC) and public_bulk_blocked("yes", PUBLIC), True) check("hosted: self-hosted endpoint is allowed even when HOSTED=1", public_bulk_blocked("1", SELF_HOSTED), False) # ── Deployment 2: Local Docker (OLLAMA_ENABLED=true, HOSTED unset) ───────────── # The Ollama provider is honored locally. check("local: ollama request honored", resolve_provider("ollama", DEFAULT_MODEL, "qwen2.5", ollama_enabled=True), ("ollama", "qwen2.5")) check("local: unknown ollama model defaults", resolve_provider("ollama", DEFAULT_MODEL, "made-up-model", ollama_enabled=True), ("ollama", DEFAULT_OLLAMA_MODEL)) check("local: anthropic still selectable", resolve_provider("anthropic", "claude-opus-4-8", DEFAULT_OLLAMA_MODEL, ollama_enabled=True), ("anthropic", "claude-opus-4-8")) # Geocoding against public OSM is allowed when HOSTED is unset. check("local: public OSM geocode allowed (HOSTED unset)", public_bulk_blocked(None, PUBLIC), False) check("local: public OSM geocode allowed (HOSTED empty string)", public_bulk_blocked("", PUBLIC), False) # ── Client-side geocoding contract (hosted mode, option B) ──────────────────── # Hosted mode geocodes in the browser: the server is "geocode-blocked", which is # exactly what flips web.app.CLIENT_GEOCODE on. Same predicate, asserted as the # client-geocode trigger. check("client-geocode ON when server bulk-blocked (hosted)", public_bulk_blocked("1", PUBLIC), True) check("client-geocode OFF for local Docker (server geocodes)", public_bulk_blocked(None, PUBLIC), False) # /set-coords must only accept well-formed, in-range coordinates from the browser. check("set-coords accepts valid lat/lng", valid_latlng("37.7749", "-122.4194"), (37.7749, -122.4194)) check("set-coords rejects non-numeric", valid_latlng("abc", "1"), None) check("set-coords rejects out-of-range lat", valid_latlng("91", "0"), None) check("set-coords rejects out-of-range lng", valid_latlng("0", "181"), None) # Errors surfaced to the client must not leak an API key (BYOK). check("safe_err redacts an Anthropic key", "sk-ant-" in safe_err(Exception("boom sk-ant-abc123DEF_key tail")) and "abc123DEF_key" not in safe_err(Exception("boom sk-ant-abc123DEF_key tail")), True) # ── Free chatbot path (primary no-key hosted provider) ──────────────────────── # get_prompt must not crash on the literal JSON braces in the template # (regression: it used str.format() and KeyError'd) and must NOT hardcode a post # count (regression: the prefiltered total mismatched chunked files). try: _prompt = get_prompt(217) check("chatbot get_prompt renders a string", isinstance(_prompt, str) and len(_prompt) > 100, True) check("chatbot get_prompt keeps JSON example", '"post_number"' in _prompt, True) check("chatbot get_prompt has no leftover {post_count} token", "{post_count}" not in _prompt, True) check("chatbot get_prompt embeds no fixed count", "217" not in _prompt, True) check("chatbot get_prompt refers to the attached file", "attached file" in _prompt.lower(), True) except Exception as exc: # noqa: BLE001 failures.append(f"chatbot get_prompt raised: {exc!r}") # ── KML export must stay valid XML (Google My Maps rejects invalid XML) ──────── # Caption/name data can carry control chars that escape() doesn't strip; the KML # must still parse. (Regression: "invalid or unsupported data" on My Maps import.) try: _kml = build_kml([{ "name": "Bad\x07Café", "city": "Lisbon", "country": "Portugal", "category": "Cafe", "lat": "38.71", "lng": "-9.14", "instagram_url": "https://instagram.com/reel/AB?x=1&y=2", }]) ET.fromstring(_kml) # raises if not well-formed check("KML is valid XML even with control chars", "\x07" not in _kml, True) except Exception as exc: # noqa: BLE001 failures.append(f"build_kml produced invalid XML: {exc!r}") # ── Report ──────────────────────────────────────────────────────────────────── print("Deployment-mode guardrail\n") if failures: print(f"FAIL — {len(failures)} contract violation(s):") for f in failures: print(f" ✗ {f}") print("\nA change broke one deployment mode's contract. See the docstring " "and DEPLOY.md for the two-column invariant.") sys.exit(1) print("PASS — both deployment modes honor their contract:") print(" ✓ Hosted free web: Ollama hidden → Claude; public-OSM bulk geocode refused") print(" ✓ Local Docker: Ollama honored; public-OSM geocode allowed") sys.exit(0)