thermograph/backend
Emi Griffith 119c54604c
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 19s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 40s
PR build (required check) / gate (pull_request) Successful in 4s
discord: resolve the city people actually typed
Reported from #general: "@Thermograph Vilnius, Lithuania" came back "I don't
track a city called 'Vilnius, Lithuania' yet." Vilnius has been in cities.json
the whole time, with country "Lithuania" sitting right there in the record. The
resolver matched the query against `name` alone, exact-or-prefix, so the comma
form could never match anything and the country field was never consulted.

The same message also showed the two neighbouring failures: "Vilinus" (one
transposition) fell through to the same refusal, which reads as "we've never
heard of Vilnius" rather than "you typed it wrong"; and "Tell me about weather
in lithuania" was quoted back verbatim as a city we don't track, which is what
made it look broken rather than merely unmatched.

So the resolver now goes, in descending order of confidence: exact name
honouring a "City, Country/Region" qualifier; the same ignoring an unrecognised
qualifier (better to grade Paris and name it in the reply than refuse over
"Paris, Wherever"); prefix; close-enough name for typos; and finally a city or
country named somewhere inside a sentence, so "weather in lithuania" lands on
Vilnius. Accents fold both ways, since people type Zurich and Sao Paulo far more
often than Zürich and São Paulo.

Free-text matching is whole-word and ignores names under four characters, so
ordinary chatter doesn't turn into a weather report -- "how are you today"
still resolves to nothing. And the unmatched reply no longer reads a whole
sentence back as a place name.

This fixes /grade identically; it shared the resolver and the same bug.

Claude-Session: https://claude.ai/code/session_015Z1ebLbhUxeZ9ozpNrVTCP
2026-08-02 01:16:47 +00:00
..
accounts accounts: sign in with Google, on a shared provider-agnostic OAuth engine (#122) 2026-07-27 00:56:43 +00:00
alembic accounts: sign in with Google, on a shared provider-agnostic OAuth engine (#122) 2026-07-27 00:56:43 +00:00
api narrative: precomputed plain-English day summaries (#102) 2026-08-02 01:16:33 +00:00
core Stop logging /healthz + internal SSR hop, truncate logged IPs (#35) 2026-07-24 19:28:47 +00:00
daemon daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
data narrative: precomputed plain-English day summaries (#102) 2026-08-02 01:16:33 +00:00
deploy Stop logging /healthz + internal SSR hop, truncate logged IPs (#35) 2026-07-24 19:28:47 +00:00
notifications discord: resolve the city people actually typed 2026-08-02 01:16:47 +00:00
scripts narrative: precomputed plain-English day summaries (#102) 2026-08-02 01:16:33 +00:00
tests discord: resolve the city people actually typed 2026-08-02 01:16:47 +00:00
web narrative: precomputed plain-English day summaries (#102) 2026-08-02 01:16:33 +00:00
.dockerignore daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00: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 docs: correct file references and the dev reachability claim 2026-08-01 11:49:28 -07:00
docker-compose.test.yml registry: move the registry host to dev.jinemi.com, MCP to mcp.jinemi.com 2026-08-01 16:06:22 -07:00
Dockerfile Lake /query reads era5_daily through Iceberg metadata (#44) 2026-07-24 19:09:22 +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 Survive Contabo PUT throttling in the lake extractor (#22) 2026-07-23 22:47:26 +00:00
gen_flavor.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
indexnow.py daemon: move the Discord gateway and scheduler out of the web process into Go (#21) 2026-07-23 22:49:54 +00:00
lake_app.py Lake /query reads era5_daily through Iceberg metadata (#44) 2026-07-24 19:09:22 +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 docs: correct file references and the dev reachability claim 2026-08-01 11:49:28 -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 Reconcile: merge main (shellcheck guard, Go daemon) into dev 2026-07-23 17:06:52 -07: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 Pre-warm the SEO content derived-store off-request (#49) 2026-07-24 19:31:08 +00: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 Jinemi/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/admin_emi/thermograph-backend/app) and hands infra a tag to roll out (SERVICE=backend + BACKEND_IMAGE_TAG into infra's infra/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.