| """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"] | |
| def _parse_radius_levels(cls, v): | |
| if isinstance(v, str): | |
| return [int(x.strip()) for x in v.split(",") if x.strip()] | |
| return v | |
| def _parse_cors(cls, v): | |
| if isinstance(v, str): | |
| return [x.strip() for x in v.split(",") if x.strip()] | |
| return v | |
| 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 | |
| def media_path(self) -> Path: | |
| return Path(self.media_dir).resolve() | |
| def max_image_bytes(self) -> int: | |
| return self.max_image_mb * 1024 * 1024 | |
| def get_settings() -> Settings: | |
| return Settings() | |
| settings = get_settings() | |