PawTrace / backend /app /config.py
Elliott Duke
Cleanup of the system and documentation
d7d2eaf
Raw
History Blame Contribute Delete
8.21 kB
"""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()