# backend/ — agent instructions The Thermograph **API service**: a FastAPI app serving the graded-climate API (`/api/v2/...`), user accounts, notifications, and the SSR-content JSON API the frontend renders pages from. It owns the climate data pipeline and the derived-payload cache. It serves **no HTML/CSS/JS** — that is `frontend/`. Read the root `CLAUDE.md` first for the branch model, deploy contract and image names. ## Build & verify - `make test` — builds a py3.12 venv and runs the hermetic pytest suite (`scripts/test.sh`; pass `ARGS=...`). Tests are hermetic per `tests/conftest.py`: no real Open-Meteo/Nominatim/GeoNames calls, throwaway SQLite, notifier thread disabled. - `make smoke` — boots the built image plus a throwaway db and asserts `/healthz` + `/api/version`. - CI runs this suite **inside the built image** (`build.yml`), so the exact interpreter and deps that ship are what gets tested. `app.py` at the root is a one-line re-export shim for `web/app.py`, kept stable for systemd/CI callers. Entry target is `app:app`. ## Contracts that break silently if you change them - **`GET /api/version`** → `{backend_version, min_frontend, payload_ver}`, driven by `API_CONTRACT_VERSION` and `MIN_SUPPORTED_FRONTEND` in `web/app.py`. A breaking change ships as a new `v3` router mounted **alongside** `v2`, re-registering only changed handlers, with the constant bumped in the same PR. `/api` and `/api/v1` stay mounted as aliases. - **`PAYLOAD_VER`** (`api/payloads.py`) is separate from URL versioning — it is the cache/ETag invalidation token. Bump it whenever a response payload's shape changes, so one bump atomically orphans every pre-upgrade cached row. - **ETag / `If-None-Match`** — every graded payload is cached against a validity token that doubles as a weak ETag. `expose_headers=["ETag"]` in the CORS middleware matters as much as `allow_origins`: without it the frontend's `cache.js` reads `null` cross-origin and never revalidates. Don't change the ETag derivation or that list without checking `frontend/static/cache.js`. - **`data/grading.py::pct_ordinal()`** is mirrored by `frontend`'s `server/internal/format::PctOrdinal` (SSR) and `static/shared.js::pctOrd()` (browser) — floor a percentile into `1..99`, never 0 or 100. `TEMP_BANDS`/`RAIN_BANDS` are the source of truth for tier names and thresholds; changing them without the frontend produces tiers drawn in colours that disagree with the labels the API returns. - **Fahrenheit country set** — `api/content_payloads.py`'s `F_COUNTRIES` is canonical; `frontend`'s `server/internal/format::FCountries` and `frontend/static/units.js`'s `F_REGIONS` must stay identical to it. Both are asserted by tests in `frontend/server/internal/format/format_test.go` — but note the backend cross-check **skips in CI** (the frontend image's build context is `frontend/`, so this file is unreachable from the builder stage, which is the only place CI runs those tests). It fires on a full checkout. The `units.js` check does run in the image build. `frontend/format.py`'s copy is the superseded Python service's and is not deployed. - **`/healthz` and `/api/version` are deliberately I/O-free** so they stay cheap under tight healthcheck intervals. ## Layout `accounts/` (fastapi-users + alembic), `api/` (payload builders + SSR content routes), `core/` (metrics, audit, singleton helper), `data/` (grid, climate fetch/cache, grading/scoring, places/cities, derived-payload store), `notifications/` (push, email, Discord bot, digest, scheduler), `web/app.py` (the real app), `daemon/`. `paths.py` resolves every filesystem location from the repo root — don't reintroduce `__file__`-relative paths in a module; it breaks the moment a module moves. `deploy/entrypoint.sh` is the container entrypoint (alembic migrate, then serve, with a Swarm-secrets-as-files shim). ## Commits & PRs Concise and technical. Never mention AI, assistants or automated authorship.