Add editorial city copy for 83 city/record pages, wired through payload + templates
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 12s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m24s
PR build (required check) / build-backend (pull_request) Successful in 1m32s
PR build (required check) / gate (pull_request) Successful in 1s

Introduce backend/city_copy.json: original, researched per-city editorial copy
(a lede, 2-4 body paragraphs, and a records-page intro), keyed by slug next to
the existing Wikipedia cities_flavor.json. Covers 83 of the 90 cities that had
no flavor blurb at all; each entry carries the independent sources it was
written from and reviewed:false pending a human spot-check.

Wire it end to end:
- cities.copy(slug) loader, mirroring cities.flavor() (lazy, tolerant of a
  missing file so pages render fine until copy exists).
- A `copy` field on the city and records content payloads. PAYLOAD_VER p2->p3
  so cached content rows rebuild with the new field instead of serving the old
  shape.
- city.html.j2 renders the editorial lede + body in place of the Wikipedia
  blurb, falling back to the flavor blurb (with its "via Wikipedia" credit)
  wherever copy isn't written yet. records.html.j2 gains a per-city intro line
  it never had before.

Additive and backward-compatible: no API contract bump, and any slug without a
copy entry keeps its existing behavior.
This commit is contained in:
Emi Griffith 2026-07-24 02:04:36 -07:00
parent 0ed7e89401
commit d1ef5be859
8 changed files with 1988 additions and 5 deletions

View file

@ -202,6 +202,7 @@ def city_payload(request_origin: str, base: str, city: dict, history, recent=Non
records = grading.all_time_records(history) records = grading.all_time_records(history)
unit = unit_for_country(city.get("country_code")) unit = unit_for_country(city.get("country_code"))
flavor = cities.flavor(city["slug"]) flavor = cities.flavor(city["slug"])
copy = cities.copy(city["slug"])
event = city_events.get(city["slug"]) event = city_events.get(city["slug"])
today = _today_vs_normal_raw(history, recent) if recent is not None else None today = _today_vs_normal_raw(history, recent) if recent is not None else None
@ -238,7 +239,7 @@ def city_payload(request_origin: str, base: str, city: dict, history, recent=Non
"wettest_month_slug": wettest["slug"] if wettest else None, "wettest_month_slug": wettest["slug"] if wettest else None,
"all_time_records": records, "all_time_records": records,
"today_vs_normal": today, "today_vs_normal": today,
"flavor": flavor, "event": event, "default_unit": unit, "flavor": flavor, "copy": copy, "event": event, "default_unit": unit,
"breadcrumb": breadcrumb, "breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}", "canonical_path": f"/climate/{city['slug']}",
"page_title": _titled(f"{title} climate: daily normals, records & how unusual it is now"), "page_title": _titled(f"{title} climate: daily normals, records & how unusual it is now"),
@ -328,6 +329,7 @@ def records_payload(request_origin: str, base: str, city: dict, history) -> dict
lo_rec = (rec.get("tmin") or {}).get("min") lo_rec = (rec.get("tmin") or {}).get("min")
lo_date = (rec.get("tmin") or {}).get("min_date") lo_date = (rec.get("tmin") or {}).get("min_date")
unit = unit_for_country(city.get("country_code")) unit = unit_for_country(city.get("country_code"))
copy = cities.copy(city["slug"])
rows = [] rows = []
for key, label in METRIC_LABELS: for key, label in METRIC_LABELS:
@ -374,7 +376,7 @@ def records_payload(request_origin: str, base: str, city: dict, history) -> dict
} }
return { return {
"year_range": year_range, "n_years": n_years, "year_range": year_range, "n_years": n_years,
"rows": rows, "rows": rows, "copy": copy,
"all_time_records": rec, "all_time_records": rec,
"monthly": _monthly_records_raw(history), "monthly": _monthly_records_raw(history),
"seasonal": _seasonal_records_raw(history, city["lat"]), "seasonal": _seasonal_records_raw(history, city["lat"]),

View file

@ -26,7 +26,8 @@ OBS_COLS = ("tmax", "tmin", "precip", "feels", "humid", "wind", "gust")
# The version is part of every derived-store validity token, so one bump # The version is part of every derived-store validity token, so one bump
# atomically orphans all pre-upgrade cached payloads instead of letting a stale # atomically orphans all pre-upgrade cached payloads instead of letting a stale
# row whose history_end happens to match keep serving the old shape. # row whose history_end happens to match keep serving the old shape.
PAYLOAD_VER = "p2" # p2: place labels now carry the trailing country component PAYLOAD_VER = "p3" # p3: content-city/records payloads carry an editorial `copy` field
# p2: place labels now carry the trailing country component
CAL_MAX_SPAN_DAYS = 732 # ~2 years — the most a single calendar request will grade CAL_MAX_SPAN_DAYS = 732 # ~2 years — the most a single calendar request will grade
# (the frontend splits longer spans into 2-year chunks) # (the frontend splits longer spans into 2-year chunks)

1956
backend/city_copy.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,11 @@ import paths
_PATH = paths.CITIES_JSON _PATH = paths.CITIES_JSON
_FLAVOR_PATH = paths.CITIES_FLAVOR_JSON _FLAVOR_PATH = paths.CITIES_FLAVOR_JSON
_COPY_PATH = paths.CITY_COPY_JSON
_CITIES: list[dict] | None = None _CITIES: list[dict] | None = None
_BY_SLUG: dict[str, dict] | None = None _BY_SLUG: dict[str, dict] | None = None
_FLAVOR: dict[str, dict] | None = None _FLAVOR: dict[str, dict] | None = None
_COPY: dict[str, dict] | None = None
_DUPES: dict[str, dict[str, int]] | None = None _DUPES: dict[str, dict[str, int]] | None = None
@ -49,6 +51,21 @@ def flavor(slug: str) -> dict | None:
return _FLAVOR.get(slug) return _FLAVOR.get(slug)
def copy(slug: str) -> dict | None:
"""A city's editorial copy {lede, body, records_intro, sources, ...} from
city_copy.json, or None when it hasn't been written yet (the page falls back
to the Wikipedia flavor blurb). Mirrors flavor() lazy, tolerant of a
missing/malformed file."""
global _COPY
if _COPY is None:
try:
with open(_COPY_PATH, encoding="utf-8") as f:
_COPY = json.load(f)
except (OSError, ValueError):
_COPY = {}
return _COPY.get(slug)
def title_name(city: dict) -> str: def title_name(city: dict) -> str:
"""The shortest label that still names this city unambiguously — for page """The shortest label that still names this city unambiguously — for page
titles, where display_name()'s region + country spend the width Google gives titles, where display_name()'s region + country spend the width Google gives

View file

@ -27,3 +27,4 @@ LOGS_DIR = os.environ.get("THERMOGRAPH_LOGS_DIR") or os.path.join(REPO_DIR, "log
# gen_flavor), not runtime state — hence at the repo root, not data/. # gen_flavor), not runtime state — hence at the repo root, not data/.
CITIES_JSON = os.path.join(REPO_DIR, "cities.json") CITIES_JSON = os.path.join(REPO_DIR, "cities.json")
CITIES_FLAVOR_JSON = os.path.join(REPO_DIR, "cities_flavor.json") CITIES_FLAVOR_JSON = os.path.join(REPO_DIR, "cities_flavor.json")
CITY_COPY_JSON = os.path.join(REPO_DIR, "city_copy.json")

View file

@ -198,7 +198,7 @@ def city_page(request: Request, slug: str) -> Response:
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest, "months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": today, "records": records, "today": today,
"tool_hash": f"{j['city']['lat']:.5f},{j['city']['lon']:.5f}", "tool_hash": f"{j['city']['lat']:.5f},{j['city']['lon']:.5f}",
"flavor": j["flavor"], "event": j["event"], "flavor": j["flavor"], "copy": j.get("copy"), "event": j["event"],
"compare_url": f"{BASE}/compare#loc={j['city']['lat']:.4f},{j['city']['lon']:.4f}", "compare_url": f"{BASE}/compare#loc={j['city']['lat']:.4f},{j['city']['lon']:.4f}",
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]), "breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"canonical_path": j["canonical_path"], "canonical_path": j["canonical_path"],
@ -307,6 +307,7 @@ def records_page(request: Request, slug: str) -> Response:
"section": "climate", "city": city, "display": j.get("display", city["name"]), "name": city["name"], "section": "climate", "city": city, "display": j.get("display", city["name"]), "name": city["name"],
"year_range": j["year_range"], "n_years": j["n_years"], "year_range": j["year_range"], "n_years": j["n_years"],
"rows": rows, "monthly": monthly, "seasonal": seasonal, "rows": rows, "monthly": monthly, "seasonal": seasonal,
"copy": j.get("copy"),
"hemisphere": j["hemisphere"], "all_time": j["all_time_records"], "hemisphere": j["hemisphere"], "all_time": j["all_time_records"],
"canonical_path": j["canonical_path"], "canonical_path": j["canonical_path"],
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]), "breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),

View file

@ -16,7 +16,11 @@
<b>{{ warmest.name }}</b>, averaging a high of {{ warmest.high }}, and its coldest is <b>{{ warmest.name }}</b>, averaging a high of {{ warmest.high }}, and its coldest is
<b>{{ coldest.name }}</b>, with an average low of {{ coldest.low }}.{% endif %}</p> <b>{{ coldest.name }}</b>, with an average low of {{ coldest.low }}.{% endif %}</p>
{% if flavor %} {% if copy and copy.lede %}
<p class="city-blurb">{{ copy.lede }}</p>
{% for para in copy.body %}<p class="city-body">{{ para }}</p>
{% endfor %}
{% elif flavor %}
<p class="city-blurb">{{ flavor.extract }}{% if flavor.url %} <p class="city-blurb">{{ flavor.extract }}{% if flavor.url %}
<a class="blurb-src" href="{{ flavor.url }}" rel="nofollow">via Wikipedia</a>{% endif %}</p> <a class="blurb-src" href="{{ flavor.url }}" rel="nofollow">via Wikipedia</a>{% endif %}</p>
{% endif %} {% endif %}

View file

@ -24,6 +24,7 @@
climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached
{{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to {{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to
{{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}</p> {{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}</p>
{% if copy and copy.records_intro %}<p class="records-intro">{{ copy.records_intro }}</p>{% endif %}
<section class="climate-records-section"> <section class="climate-records-section">
<h2>Records by month</h2> <h2>Records by month</h2>