thermograph/backend
Emi Griffith 35b58300cb UI events v2: consented session tier, failed-search capture, privacy rewrite
Three operator decisions change v1's premises, so the design is now two tiers
with separate flags:

Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no
identifier, nothing stored on the device, no consent needed, runs for everyone.
It stays the primary signal precisely so a poor consent rate cannot take the
numbers down with it, and so Tier B's rates can be calibrated against it to
measure the consent bias rather than ignoring it.

Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random
bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap,
capped at 200 events, never linked to another session, device, or to the account
(api_event does not read the auth cookie and there is no user column). Rows land
in a 30-day hypertable with minute-granularity timestamps and an in-session
sequence number instead of precise clock times. Storing an identifier engages
ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on
opt-in consent: an equal-weight banner that mints nothing before "Allow",
one-click withdrawal in every footer that drops the live id immediately, and
GPC/DNT treated as a refusal already given.

THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in
api_suggest only -- normalised, rejected outright on any personal-data smell,
stored as a per-day count, and pruned below a three-person floor after a week.

The privacy page is rewritten rather than deferred: "no per-visitor identifier"
becomes false the moment Tier B ships. Tests assert the load-bearing promises so
the copy and the code cannot drift apart silently.

Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but
UI-EVENTS.md states what the app side must do, and records that Caddy's default
JSON log already stores full request URIs -- so every search query is in Loki
with the client IP today, which the search-miss mitigations depend on fixing.

UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why
legitimate interest is not available, and an explicit argument that the session
identifier is the wrong trade at this traffic volume.
2026-07-23 16:11:06 -07:00
..
accounts Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
alembic UI events v2: consented session tier, failed-search capture, privacy rewrite 2026-07-23 16:11:06 -07:00
api Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
core UI events v2: consented session tier, failed-search capture, privacy rewrite 2026-07-23 16:11:06 -07:00
data ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
deploy ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
notifications Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
scripts Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
tests UI events v2: consented session tier, failed-search capture, privacy rewrite 2026-07-23 16:11:06 -07:00
web UI events v2: consented session tier, failed-search capture, privacy rewrite 2026-07-23 16:11:06 -07:00
.gitignore Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
alembic.ini Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
app.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
cities.json Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
cities_flavor.json Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
CLAUDE.md Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
docker-compose.test.yml Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
Dockerfile ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
drift_check.py Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
gen_cities.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
gen_era5_lake.py Parallelize lake tile uploads (#17) 2026-07-23 21:32:58 +00:00
gen_flavor.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
indexnow.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
lake_app.py ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
Makefile Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
migrate.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
migrate_accounts_to_pg.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
migrate_cache_to_pg.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
paths.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
README.md backend: monorepo cutover marker (path-filter probe) 2026-07-23 03:18:03 -07:00
requirements-dev.txt Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
requirements-seed.txt ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
requirements.txt ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
seed_era5.py ERA5 lake: bucket-hosted history primary + SQL indexer service (#15) 2026-07-23 21:20:35 +00:00
warm_cities.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00

thermograph-backend

The Thermograph API service: grades recent local weather against ~45 years of climate history, and hosts the accounts, notification, and SSR-content back-end that the rest of the split Thermograph stack (thermograph-frontend) talks to over HTTP. Split from the emi/thermograph monorepo — this repo owns the API/DB/accounts/notifications layer only; it renders no HTML/CSS/JS of its own.

See CLAUDE.md for the full split topology, deploy flow, and API version contract, and thermograph-docs (a sibling repo) for cross-cutting architecture decisions and operator runbooks.

How it works

  1. Grid — a lat/lon is snapped to a stable ~4 sq mi cell (data/grid.py); longitude spacing is scaled by cos(latitude) so cells stay roughly square at any latitude. The cell id is the cache key, so the same spot always resolves to the same data.
  2. Data (on-demand, cached to parquet) — the first request for a cell fetches the full 1980present daily record (max/min temp, precip) from the free Open-Meteo archive (ERA5) and writes it to data/cache/<cell_id>.parquet (zstd, ~200 KB for 45 years). Later requests read the parquet directly. Recent days come from Open-Meteo's forecast API (past_days), so history and grading share one source.
  3. Percentiles & grading (data/grading.py) — each day is graded against every historical day within ±7 days of it (a 15-day seasonal window, wrapping year-end), as an empirical mid-rank percentile. Temperature uses a symmetric tier ladder (TEMP_BANDS: Near Record / High / Above Normal / Normal / Below Normal / Low / Near Record); precipitation is graded separately (RAIN_BANDS) since most days are dry — a rainy day is ranked only among rain days in its window, dry days are colored by dry-streak length instead.
  4. Caching & ETags — every derived payload (grade/calendar/day/SSR content) is cached in SQLite (data/store.py) under (kind, cell_id, key), validated by a token that only advances when the cell's history actually changes. That token doubles as a weak ETag, so an unchanged request costs a 304 with no payload rebuild (api/payloads.py, web/app.py).

Layout

accounts/       fastapi-users models/schemas/db + api_accounts routes
alembic/        Postgres schema migrations (alembic upgrade head on boot)
api/            versioned payload builders (payloads.py, content_payloads.py)
                + route wiring (content_routes.py, sitemap.py, homepage.py)
core/           metrics, audit/access logging, a singleton helper
data/           grid snapping, climate fetch/cache, grading/scoring,
                places/cities, the derived-payload store
notifications/  push (VAPID), email, Discord bot (interactions + linking),
                monthly digest, the in-process scheduler (city warming, IndexNow)
web/app.py      the FastAPI app (routes, CORS, ETag/versioning, middleware)
deploy/         container entrypoint.sh (alembic migrate, then serve)
app.py          shim re-exporting web.app:app — keeps the launch target
                `app:app` stable regardless of internal package layout
scripts/        one-off admin scripts (Discord slash-command registration)
tests/          pytest suite, hermetic (see tests/conftest.py)
cities.json,
cities_flavor.json   bundled reference data (generated by gen_cities.py /
                     gen_flavor.py), not runtime state

How it fits the split

  • thermograph-frontend calls this service's GET /api/v2/... endpoints (grade, geocode, calendar) and the SSR content endpoints under /content/...; it negotiates compatibility via GET /api/version.
  • thermograph-infra owns the deploy/Compose/Terraform layer — this repo only builds and publishes its own container image (git.thermograph.org/emi/thermograph-backend/app) and hands infra a tag to roll out (SERVICE=backend + BACKEND_IMAGE_TAG into infra's deploy/deploy.sh).
  • thermograph-docs holds the cross-repo architecture/runbook docs; this README only covers what's local to this service.

Build & run

docker build -t thermograph-backend .
docker run -p 8137:8137 --env-file .env thermograph-backend

The image runs deploy/entrypoint.sh: alembic upgrade head against THERMOGRAPH_DATABASE_URL (retried, since a fresh Postgres volume can still be starting up), then uvicorn app:app on $PORT (default 8137) with $WORKERS workers (default 4). /healthz is an I/O-free liveness probe.

For local development without a container, see the "Run / test locally" section of CLAUDE.md — there is no Makefile in this repo yet, so it's a plain venv + pytest/uvicorn invocation.

Notifications: Discord slash commands

notifications/discord_interactions.py answers Discord's HTTP Interactions endpoint (/discord/interactions) for the /grade <city> slash command. Registering (or updating) the command definition with Discord's REST API is a one-off admin action, not part of the running app:

THERMOGRAPH_DISCORD_APP_ID=... THERMOGRAPH_DISCORD_BOT_TOKEN=... \
    python3 scripts/register_discord_commands.py

Global command changes can take up to an hour to propagate.