"""Application configuration. Every tunable value (thresholds, radius levels, limits) is a named setting with a documented default loaded from the environment / `.env`. Defaults that we *expect to tune* are flagged in comments — do not treat them as known-correct (spec §0, §11, §15). """ from __future__ import annotations import logging import secrets from functools import lru_cache from pathlib import Path from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict logger = logging.getLogger(__name__) class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore" ) # Database database_url: str = "sqlite:///./data/app.db" # Storage media_dir: str = "./data/media" storage_backend: str = "local" # local | s3 # Embedder / ML. THE SINGLE SWITCH between one-model and two-model modes: # EMBEDDER=hf -> SINGLE model: the breed model runs ONCE per image and yields BOTH the # matching vector (penultimate features) and the top-K breed labels. # EMBEDDER=reid -> TWO models: the fine-tuned re-ID model (reid_model_path) produces the # matching vector, and the separate breed classifier produces the top-K # breeds. Better matching, at the cost of a second forward pass + model. # (Routing is automatic: with EMBEDDER=reid the embedder != breed model, so images.py takes its # existing two-model path. Set BREED_CLASSIFIER=hf in reid mode to keep real breed labels.) embedder: str = "mock" # mock | hf | reid # HF re-ID embedder: uses the penultimate (pre-classifier) pooled features as the vector. embedder_hf_model: str = "jhoppanne/Dogs-Breed-Image-Classification-V1" # Fine-tuned re-ID checkpoint (EMBEDDER=reid): a train_reid.py best.pt loaded into the breed # backbone. reid_model_version tags stored vectors so matching only compares same-model vectors # (switching modes needs a re-embed — the tag keeps old/new vectors from being mixed). reid_model_path: str = "./best.pt" reid_model_version: str = "v4" # Populate a small demo dataset on startup when the DB is empty (SEED_DEMO=1). For free/mock # deploys so the app isn't blank; safe to leave on (only ever seeds an empty database). seed_demo: bool = False # Public read-only showcase (DEMO_MODE=1): the frontend renders the 3-page demo shell, and the # backend HARD-BLOCKS every write — only the two transient, no-persist photo-search endpoints are # allowed. Nothing can modify the database (not the demo UI, a direct API call, or curl). demo_mode: bool = False # Breed classifier — cheap estimated-breed candidate gate (spec §9.3, extends metadata gate). # Predicts breed *labels only*; never used for similarity. mock = deterministic, no downloads. breed_classifier: str = "mock" # mock | hf breed_model: str = "jhoppanne/Dogs-Breed-Image-Classification-V1" # HF id for the hf impl breed_top_k: int = 7 # top predicted breeds kept per image (5–10; tune vs recall) breed_filter_enabled: bool = True # master switch for the match-time breed gate # Matching thresholds. These govern the CASE matcher (owner opens a lost case -> candidates are # scored, surfaced for review, and a strong hit notifies the owner). The public photo search does # not use them: it returns the top_n ranked results with no score cutoff, so a searcher can see # that every score is low rather than getting an empty page. Retune with scripts/eval_matching.py # against labelled same-dog/different-dog pairs if the case workflow is put into real use. top_n: int = 10 # "top 10 matches" — the narrow-pass result cap review_threshold: float = 0.55 strong_threshold: float = 0.80 # Date plausibility gate (spec §9): a dog can't be FOUND before it was LOST. A found dog is only # compared to a lost dog whose loss date is on/before (found_date + grace). The grace absorbs # imprecise, user-entered dates. Set DATE_FILTER_ENABLED=false to disable. date_filter_enabled: bool = True match_date_grace_days: int = 2 # When a new found dog is reported, optionally re-run matching for open lost cases in range so # their owners can be proactively notified. That's O(cases × candidates) synchronously, so it's # OFF by default (0): the found report's own matching already surfaces + notifies matching lost # dogs, and owners can re-run their case anytime. Raise this (e.g. 25) to opt in for small sets; # a background sweep is the real fix at scale. rematch_max_cases: int = 0 # Radius levels (miles). -1 == nationwide (no distance filter). radius_levels: list[int] = [0, 10, 25, 50, 100, -1] # Image limits. Uploads are downscaled to max_image_longest_side on ingest, so this cap only # exists to bound decode cost — set high enough that ordinary phone photos (often 5-15 MB, and # larger for HEIC bursts or DSLR shots) are accepted and shrunk rather than rejected. max_image_mb: int = 25 max_photos_per_dog: int = 8 max_image_longest_side: int = 1024 # Notifications notifier: str = "console" # console | smtp smtp_host: str = "" smtp_port: int = 1025 smtp_user: str = "" smtp_password: str = "" smtp_from: str = "no-reply@pawtrace.local" # SMS (off by default) sms_enabled: bool = False twilio_account_sid: str = "" twilio_auth_token: str = "" twilio_from: str = "" # Auth. # No usable default: a hardcoded secret in a public repo lets anyone forge a token for any # user id. Left empty, a random secret is generated per process at startup (see the validator # below), which is safe but means tokens do not survive a restart. Set JWT_SECRET in the # environment for any deployment where logins need to persist. jwt_secret: str = "" jwt_algorithm: str = "HS256" access_token_ttl_minutes: int = 1440 # Geo — default resolves to the repo-root data/ file so it's found regardless of CWD # (uvicorn is launched from backend/, but the canonical centroid CSV lives at the repo root). zip_centroid_file: str = str( Path(__file__).resolve().parents[2] / "data" / "zip_centroids.csv" ) # Misc rate_limit_reports_per_minute: int = 10 cors_origins: list[str] = ["http://localhost:5173"] @field_validator("radius_levels", mode="before") @classmethod def _parse_radius_levels(cls, v): if isinstance(v, str): return [int(x.strip()) for x in v.split(",") if x.strip()] return v @field_validator("cors_origins", mode="before") @classmethod def _parse_cors(cls, v): if isinstance(v, str): return [x.strip() for x in v.split(",") if x.strip()] return v @field_validator("jwt_secret", mode="after") @classmethod def _require_real_secret(cls, v: str) -> str: """Never run on a guessable signing key. An empty (or placeholder) JWT_SECRET gets a cryptographically random value generated for this process. Anything a reader of the source could predict would let them mint a token for any user id, so a known default is worse than no default at all. """ placeholders = {"", "change-me-in-production", "changeme", "secret", "dev", "test"} if v.strip().lower() in placeholders: generated = secrets.token_urlsafe(48) logger.warning( "JWT_SECRET is unset or a placeholder; generated a random secret for this " "process. Tokens will not survive a restart. Set JWT_SECRET in the environment " "for any deployment where logins must persist." ) return generated return v @property def media_path(self) -> Path: return Path(self.media_dir).resolve() @property def max_image_bytes(self) -> int: return self.max_image_mb * 1024 * 1024 @lru_cache def get_settings() -> Settings: return Settings() settings = get_settings()