Skip to content

Seo geo prerender#20

Merged
bdevloed merged 41 commits into
mainfrom
seo-geo-prerender
Jun 20, 2026
Merged

Seo geo prerender#20
bdevloed merged 41 commits into
mainfrom
seo-geo-prerender

Conversation

@bdevloed

Copy link
Copy Markdown
Contributor

No description provided.

bdevloed and others added 30 commits June 3, 2026 21:42
…uards

Externalize the inline JS data out of each locale homepage so the HTML shell is
small and the data caches across pages — the prerequisite for the
per-appellation pre-rendered pages in later phases.

- map_template.render(): the ~12.3 MB AOCS + ~0.6 MB grape-tooltip blobs are no
  longer inlined; they ship in a hashed, render-blocking
  /data/aocs.<lang>.<hash>.js that assigns window.__OWM_DATA before the app runs
  (read synchronously, no boot re-thread). render() now returns
  (html, data_filename, data_bytes). Homepage HTML 13.3 MB -> 449 KB (-96.6%).
- grapes_info is serialized with sort_keys so the cache-busting hash is
  deterministic across runs (its dict order is set-derived); aocs keeps its
  build order, which drives the sidebar appellation list. Reproducible-build /
  no-op-rerun contract holds.
- 04_build_maps.py: write the per-locale bundle to wiki/data/ and prune stale
  hashed files.
- deploy.py: set Content-Type by file extension on upload (html/json/js/xml/
  webmanifest/...; pmtiles and others keep octet-stream), and a non-fatal
  post-deploy check that warns if the apex host isn't 301-redirecting to www
  (guards the Search Console "Duplicate without user-selected canonical" issue;
  the redirect itself is a Bunny edge rule, not in this repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add scripts/_lib/content_block.py — a faithful Python port of the STABLE subset
of the JS renderAocCard pipeline, so a future per-appellation page can ship a
crawlable, server-rendered <article> built from the same `aocs` record the
client panel uses. Covers: name (+native/Latin), meta line (country chip(s)
incl. cross-border aliases, region, kind, commune count), DGC / approx / stub
lines, style chips, grape pills with the cahier-vs-canonical bracket logic,
the PT role disclaimer, terroir facts (bullet + verbatim modes), summary
(shown only when there are no facts), the cross-border note, and the sources
block. Each <article> carries a data-ssr-sig (hash of its normalised text) for
a runtime JS cross-check in a later phase.

Deliberately narrower than the JS panel: the volatile HU dűlők / IT menzioni
chip sections are LEFT to the client panel (omitted server-side). Purely
additive — not yet imported or emitted by stage 04; Phase 3 injects it into the
per-appellation pages.

Verified: renders all 2908 records with 0 crashes / 0 leftover placeholders;
tests/test_content_block.py (15 cases) pins canonical-bracket show/suppress,
sub-denomination line, stub message, cross-border chips, verbatim facts,
bullet-facts grouping + summary suppression, native+Latin name, dulok/menzioni
omission, escaping, and deterministic output. Mirrors one pre-existing JS quirk
faithfully (communes_matched == -1 renders "-1 commune(s)" in both renderers;
a `> 0` guard is deferred to a shared fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-render a crawlable HTML page per appellation for a gated pilot set (23
well-known parents x 4 locales = 92 pages), reusing each locale's shell + the
shared Phase-1 data bundle so the pages cost almost nothing on the CDN.

map_template.render():
- Parameterised the <head> (title / description / hreflang / og / json-ld) and
  added a {ssr_content} body slot; split the format call into a shared
  shell_kwargs (constant across a locale's pages) + per-page slots. The homepage
  output is unchanged except the new SSR-removal JS.
- entity_slugs path emits {slug: html} per pilot slug, each with a
  self-referential canonical, a per-slug hreflang cluster (x-default -> EN),
  a templated (NON-verbatim) title + meta description, Place/AdministrativeArea
  + BreadcrumbList JSON-LD (GeoShape box derived from bbox, lat/lng order), and
  the Phase-2 content_block injected as <article id="ssr-content"> (carrying
  data-ssr-sig). EN entities live at /en/<slug>; fr/es/nl at /<locale>/<slug>.
- The app JS removes #ssr-content on boot — the interactive panel supersedes the
  no-JS / crawler fallback article.

04_build_maps.py:
- gate_classify folds sub-denominations + stubs + no-geometry records; an
  indexable record needs resolved geometry plus its own grapes/summary/terroir.
  A hand-picked pilot allowlist (intersected with the gate) + a dry-run counter;
  writes wiki/<locale>/<slug>/index.html; sitemap expanded to per-slug 4-locale
  hreflang clusters (96 URLs: 4 home + 92 entity).

Determinism: the inline VIVC-derived blobs (VIVC_SIBLINGS / SLUG_TO_CANONICAL /
GRAPE_SYNONYMS) and GRAPE_SEARCH_INDEX were non-deterministic across builds
(set-iteration order) — fixed by sorting vivc_groups member lists, sort_keys on
the three dicts, and a slug tiebreaker on the index sort. The homepage is now
byte-identical across builds (no-op rerun / reproducibility holds).

Verified: 92 entity pages; self-canonical + reciprocal hreflang; valid
Place/BreadcrumbList JSON-LD; SSR article present pre-JS; node --check on every
generated script; FR/ES/NL locale chrome correct; full test suite + dup-keys
green.

Staging gates (NOT verifiable locally, must confirm before relying on
indexation): (1) Bunny must serve the origin /<locale>/<slug>/index.html for the
no-trailing-slash URL (origin-file-wins over the SPA rewrite) — the canonical
uses the no-slash form matching the shipped client deep-link; (2) facet "open"
buttons are still buttons, so pilot pages are sitemap-discoverable but not yet
internally linked (Phase 3.5 follow-up); (3) the communes_matched == -1 display
quirk is shared with the JS panel (a > 0 guard is a deferred shared fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-appellation entity pages set a server-rendered <title>, but the SPA
never updated document.title as the user navigated, so the tab title got stuck
on whichever appellation the user landed on or first opened (e.g. stayed
"Priorat" after closing it or opening another).

Add docTitleFor() — mirrors the server _build_entity_meta title format
("<Name> — <kind> · <region>, <country> · Open Wine Map") — and set
document.title on every panel open / focus-change (renderPanelStack), resetting
to the locale homepage title (DEFAULT_TITLE = LABELS.page_title) when the panel
closes (closePanel). So the tab title now tracks the current view: identical to
the server <title> when landing on a pre-rendered /<locale>/<slug> page, updated
when cycling overlapping appellations, and reset on close.

Document-level <title> tags are unchanged (homepage generic, entity per-slug);
this is a client-side UX fix only. node --check + test suite + dup-keys green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the 23-slug pilot with a full-corpus gate: every record gets a
prerendered /<locale>/<slug>/index.html so the CDN can serve any deep-link.
1638 indexable records get a full page (self-canonical, Place/BreadcrumbList
JSON-LD, server-rendered card); 1270 folded records (sub-denominations,
stubs, no-geometry, thin) get a lightweight noindex,follow + self-canonical
shell with empty SSR — on the map for the deep-link, kept out of the index
as near-duplicates of the parent.

- render() streams each page to disk instead of returning a dict (the full
  corpus would be multi-GB in memory); returns (html, data_filename,
  data_bytes, n_index, n_fold).
- folded pages: noindex + SELF-canonical, not canonical->parent (Google
  treats noindex + cross-canonical as contradictory; 53 folded parents were
  themselves noindex). og:url follows canonical_url.
- sitemap lists indexable slugs only (4 home + 1638x4 = 6556 URLs).

Verified: 11,632 pages + 4 homepages; every deep-linkable slug has a file in
all 4 locales; locale-parity, slug-safety, valid sitemap. Pre-deploy
adversarial audit (HCU/duplicate, JSON-LD, head/meta, routing) clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the monolithic page template at module load into (page, css, app):
the <style> block ships as one shared content-hashed /assets/style.<hash>.css
for the whole corpus, and the ~2,000-line app <script> as a per-locale
/assets/app.<locale>.<hash>.js. Each of the ~11.6k pages is now a ~13-22 KB
shell referencing three cached bundles (data + app + style) instead of
inlining ~450 KB.

- _split_template() lifts the two blocks by unique delimiters with a
  lossless-reconstruction assert (fires at import on drift).
- render() splits shell_kwargs into script_kwargs (fill the app JS) and
  page_shell (fill the page; the {labels[...]} item-access fields keep labels
  as a page field); CSS has no fields (replace-unescape). Both assets are
  content-hashed; render returns an assets dict {data, style, app}.
- stage 04 writes + prunes all three bundles per locale.

Verified behavior-preserving: re-inlining the external assets reproduces the
pre-externalization pages byte-for-byte (homepage + index + fold; en/fr/es).
Deploy footprint 5.1 GB -> ~196 MB. Gate/sitemap/canonical invariants
unchanged; 16 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The externalized app.<lang>.js (~407KB cached fetch) takes ~190ms to boot,
during which the server-rendered #ssr-content card was visible before the map
replaced it — a white-card flash. Gate it: the pre-paint inline script (which
already sets view-mode/theme classes before paint) now also adds `js` to
<html>, and `html.js #ssr-content{display:none}` hides the card from first
paint for JS visitors (the app removes it on boot anyway). Non-JS visitors and
non-rendering crawlers still receive the card in the HTML, so SEO +
accessibility are unchanged.

Verified locally: with JS the card is never visible during boot (no flash);
the #ssr-content article remains in the raw HTML source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The <urlset> namespace used a hyphen (.../schemas/sitemap-0.9) instead of the
official sitemaps.org slash form (.../schemas/sitemap/0.9). Google tolerates
it, but strict XSD validators reject the sitemap. One-char fix; the deployed
sitemap.xml was patched + re-uploaded in place (no full rebuild needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y headers

- list_remote fans out one-GET-per-directory across BUNNY_LIST_WORKERS (32)
  threads, eliminating the ~30min sequential walk over ~11k leaf dirs
- HTTPAdapter with backoff retry on 429/5xx, pool sized to widest concurrency
- ensure_security_headers: idempotent Bunny Edge Rules (HSTS, nosniff,
  X-Frame-Options, Referrer-Policy, Permissions-Policy)
- gitignore logs/ and .hermes/
…ity pages

- 02i_fetch_wikidata_qids.py: cached incremental stage resolving each record
  to a Wikidata QID via P9854 (eAmbrosia ID) join + Wikipedia-title fallback;
  writes raw/wikidata/qids-by-slug.json
- _lib/wikidata.py: QID->URL helper + resolver
- entity JSON-LD restructured to a single schema.org @graph
  (WebSite -> WebPage -> Place/AdministrativeArea -> BreadcrumbList) with
  stable fragment @ids; sameAs identity cluster (Wikidata/Wikipedia/regulator),
  isBasedOn source docs, additionalType wine-region; drops invalid
  country-only breadcrumb ListItem
- SRI integrity + crossorigin on unpkg maplibre/pmtiles tags (verified)
- h1/h2 heading-hierarchy fix (brand_tag demotes to <p> on SSR card pages)
- content_block: suppress 'N communes' when count is 0/None
- add unidecode dep; tests for jsonld, headings, wikidata resolver
…g pins

- VIVC ambiguous-slug queue closed 2026-06-03 (9 ES/AT/IT slugs pinned with
  vivc_id + colour + provenance via grape-colour-researcher)
- note SSR content-block gap: HU dulok / IT menzioni not yet server-rendered
Diffs two build snapshots, normalising content-hash filenames (foo.<hash>.js
-> foo.js) so a renamed-but-identical asset is not a false diff. Stage 04 is
deterministic by design, so a move-only refactor must report 'identical'.
The golden baseline lives at tmp/golden-v2 (gitignored).
All 24 were identical-value duplicates within their dict literal (zero data
conflicts — Python kept the last value, which equalled the first, so the
resolved GRAPE_ALIAS/DEFAULT_COLOUR mappings are unchanged). Deleted the later
occurrence of each; lifted 5 informative VIVC-number/identity comments onto the
surviving line. Guarded by tests/test_no_duplicate_keys.py.
All 30 were the single-char 'l': language/locale loop vars -> lang, candidate
provider keys in benchmark_providers -> cand, lost-slug list in
audit_extraction_diff -> removed. Loop-body references updated too (F821 guard
caught two I'd missed in the 02b translate stages).
select the set actually driven clean; E501 excluded (900 long lines, many in
template strings). per-file-ignore E402 for scripts/ — they bootstrap sys.path
before importing _lib, so those imports are intentionally not top-of-file
(previously held by inline noqa, which isort-splitting could drop).
Runs uv sync --group dev, then the pinned ruff check and pytest. Only the
raw/-independent test suite runs in CI (all 43 current tests qualify); a test
that needs raw/ should be marked and excluded here.
Move the 1255-char 'Last reconciled' paragraph into docs/reconciliation-log.md
as a dated entry; CURATOR_TODO.md keeps a one-line pointer. No open items
(❌/🟡/⏳) touched.
10 tests over scripts/02_extract_cahiers.py: pure helpers (slug, normalize_name,
candidate_keys alias-splitting, parse_communes parenthetical/soft-hyphen
handling) + extract_sections routing. Two are regression guards for the f1ad98c
section-X drops (case-insensitive CHAPITRE_RE truncation; \x0c page-break before
a Roman heading) — verified they fail on the pre-fix regexes. Adds tests/conftest.py
(fixture_text loader) + tests/fixtures/ conventions.
10 tests over scripts/_lib/es/national_pliego.py covering the regional-format
variation documented inline: strong/weak section-6 headers, penedes' dropped
'DE' linker, TOC dot-leader rejection, mentrida 6->7 stop, los-cerrillos
same-prefix-stop-by-title, and the _normalise_token rejection rules (length,
digits, word-count, _DROP noise) that fire before the rapidfuzz vocab matcher.
find_variety_section verified against real cached pliegos for penedes/la-mancha/
castilla.
Vendor maplibre-gl@4.7.1 + pmtiles@3.2.0 as tracked git inputs under
scripts/_lib/vendor/; stage 04 copy_brand_assets() mirrors them to
/assets/vendor/ and map_template.py references the same-origin paths
(SRI/crossorigin dropped — first-party now). The fetched bytes' sha384
match the previously-shipped SRI hashes exactly, so behaviour is
unchanged. Golden comparator vs tmp/golden-v2: the only output diffs are
the 3-tag swap in every HTML page + the 3 new vendor assets; data
bundles, app.js, style.css, sitemap, robots are byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…parser-fixture-writer)

These were authored by the improvements-plan run but left untracked;
.claude/ is a tracked dir here (national-spec-layer, research-gaps,
grape-colour-researcher are all committed), so they belong in git for
the plan's tooling to be reproducible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-locale aocs bundle carried both map-critical fields and the heavy
panel payload (summary, terroir facts, sources, grape display-names, …),
~13.2MB raw / 1.9MB gz per locale. Now the render-blocking startup bundle
ships only STARTUP_AOCS_FIELDS (sidebar list, search, facets, fly-to,
active-filter chips, map-click geom_source dedup) — 3.2MB raw / 0.42MB gz —
and the panel payload emits per slug as /data/d/<locale>/<slug>.json (the
field complement). The JS fetches it on first panel open (hydratePanel ->
Object.assign into AOCS[slug], deduped + graceful-degrade), with a skeleton
placeholder; repeat opens are instant. grapes_info stays inline (a startup
dependency via the active-filter chips).

Server-rendered entity cards use the full record unchanged, so crawlers see
identical content. Golden comparator vs a post-Phase-2 baseline: the only
output changes are data/aocs.*.js (shrank), assets/app.*.js (lazy-fetch),
assets/style.css (skeleton CSS); every entity HTML, sitemap and robots are
byte-identical (asset-hash refs aside). node --check passes on all 4 app.js;
per-slug JSON serves 200 with the panel fields; startup blob carries only the
14 startup keys. 63 tests pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… source)

The ~96 KB map application JS lived as an escaped string inside _TEMPLATE
(doubled braces, 23 {slot} format fields). It now lives in a real .js file;
_render_app_js fills __OWM_<slot>__ tokens (sitting exactly where the format
fields were) per locale. Output is byte-for-byte unchanged: the golden
comparator reports 'identical' vs the post-Phase-3 baseline, and all four
emitted app.<locale>.<hash>.js match the prior bytes (same hashes). node
--check passes; eslint.config.js (flat, advisory, token globals self-derived)
reports 0 errors; a non-blocking eslint CI job runs it. _split_template now
lifts only the CSS. 63 tests + ruff clean.

The byte-identity was machine-verified during extraction (a sentinel fill of
the tokenised source must equal _APP_JS.format() with the same sentinel), so
hand-transcription error across 2123 lines is excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/ (no-op)

Phase 6 foundation + first slice. The 13 slug-keyed provenance caches that the
national-spec augmenters populate and _sources_for()/the panel-blob phase read
back move to _lib/augment/_shared.py, so both writer and readers reference the
SAME dict objects across the module split (the augmenters mutate in place —
.clear()/[slug]=, never reassign — so the imported names stay bound). The
ES/SI/HR augmenters + their sidecar-dir constants move to _lib/augment/{es,si,
hr}.py verbatim. 10 augmenters (+ IT extras, CZ helper, the 02f lexicon/geom
blocks) remain in 04 for follow-up.

Verified move-only: golden comparator vs the post-Phase-3/4 baseline reports
'identical' after a full stage-04 rebuild; 63 tests + ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes Phase 6.1: bg/cy/gr/sk/ro/hu/de + it (4 fns: masaf, _backfill,
regional-registers, synthesize-sottozone) + cz (with its colour-block map +
in-function grape-lexicon import) move verbatim to _lib/augment/<cc>.py.
_IT_REGISTER_BY_SLUG joins the shared caches; _IT_MENZIONI_BY_SLUG stays in 04
(only 04's menzioni-harvest touches it). HU/CZ sidecar dirs stay importable
from _shared (04 reads them outside the augmenter). synthesize_it_sottozone's
call order in main() is preserved. 04_build_maps.py is now ~1400 lines lighter.

Verified move-only: golden comparator vs the (yesterday) baseline differs ONLY
in sitemap.xml, and ONLY in <lastmod> (the build date rolled 06-10→06-11;
grep -vc lastmod = 0). Every HTML page, app.js, aocs.js, data and robots are
byte-identical; write_seo_files was untouched. 63 tests + ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….py (no-op)

Phase 6.2 part 1. The self-contained lexicon cluster (load/merge grape + style
lexicons, VIVC loaders, build_grapes_info + its private corpus/translation
helpers, _truncate_extract, _latin_form_or_empty) moves verbatim to
_lib/lexicon_loading.py along with its 5 path constants + _DISAMBIG_SUFFIX. No
stage-04 function dependencies; 04 imports back the 6 externally-called names
(build_grapes_info, load_style_lexicon, merge_style_lexicon, _load_vivc_by_slug,
_load_vivc_colour_by_slug, _latin_form_or_empty). The geometry constant
DEPT_NAME_TO_CODE, which physically sat between the lexicon and geometry blocks,
stays in 04 with the geometry code (ruff F821 caught the greedy slice).

Verified move-only: golden comparator vs today's baseline reports 'identical'
after a full stage-04 rebuild. 63 tests + ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bdevloed and others added 11 commits June 11, 2026 12:52
…chain.py (no-op)

Completes Phase 6.2. The geometry resolution cluster — normalize_commune,
load_commune_index, cahier_insee, union_for_appellation, union_from_insee,
_find_sibling_umbrella, the DGCGeomResult dataclass, resolve_dgc_geometry, and
the ES fallbacks (_resolve_es_igp_fallback, _resolve_es_sigpac) + DEPT_NAME_TO
_CODE — moves verbatim to _lib/geom_chain.py. The members are scattered
non-contiguously in 04 and interleaved with non-geometry helpers (_join_set,
_geojson_bounds, communes_containing), so each is extracted individually by
name; the interleaved helpers stay in 04 (the cluster has no 04-function deps).
The 11 names the main build loop calls are imported back.

Verified move-only: golden comparator vs today's baseline reports 'identical'
after a full stage-04 rebuild. 63 tests + ruff clean. Phase 6 done:
04_build_maps.py is ~2300 lines lighter (augmenters + lexicon + geometry now
in _lib/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flags appellations whose geometry is the Bétard 2022 figshare-pdo fallback but
whose GI was registered/amended after the dataset's Nov-2021 data snapshot — the
polygon may not reflect a post-snapshot boundary change (or a brand-new GI is on
the map only via an incidental file-number match). Cross-references the compact
aocs startup blob (geom_source) against each raw/<cc>/eambrosia index's
eu_protection_date/modification_date; buckets FLAGGED/REVIEWED/OK/NO-DATE like
the sibling geometry audits, with a betard_delta_overrides.json whitelist and
--strict / --cutoff flags. Read-only detector. v1 run: 473 Bétard-tier records →
345 OK, 128 FLAGGED, 0 NO-DATE. Phase 7.1; documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stages a ready-to-send email asking JKI whether Open Wine Map may display VIVC
synonym strings verbatim under attribution (today it ships only IDs + prime
names). Human sends; reply recorded in the draft + folded into CLAUDE.md's VIVC
section. CLAUDE.md points at the draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds fixture-based regression tests for the RO (document_unic + ONVPV caiet +
commune) and IT (sottozona + menzione + MASAF article carving) parsers, written
by the parser-fixture-writer agent against redacted, licence-clear excerpts of
cached regulator docs. Suite 63 → 103 (+19 RO, +21 IT); ruff clean. A
.gitignore negation lets the redacted OJ-page .html fixtures commit past the
blanket *.html ignore.

The tests pin CURRENT parser behaviour (not idealised), so two latent data bugs
they surfaced are now locked in + will flip-flag when fixed:
  - RO: the iana caiet '- soiuri roşii/roze:' colour header glues 'roze:' onto
    the first red variety, dropping Cabernet Sauvignon.
  - IT: menzione's _NAME_TOKEN_RE rejects a lowercase connector inside a name,
    so Chianti Classico's 11 UGAs parse as 10 (San Donato in Poggio dropped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bunny rejects an empty Triggers list ("At least one condition is required"),
so each SetResponseHeader edge rule now carries a catch-all Url trigger
(Type 0, pattern "*") to apply unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two latent parser bugs pinned by Phase-5 regression tests; both tests
flip to assert the corrected behaviour.

- RO `_lib/ro/caiet.py`: the colour header "- soiuri roşii/roze:" left
  "roze:" glued onto the first red variety, so its name never resolved.
  `_COLOUR_HEADER_RE` now consumes the "/roze" (and "/rose") second-colour
  suffix; bucket colour stays the first captured colour (roşii → noir).
  Recovers Cabernet Sauvignon in iana (11→12) and dealurile-transilvaniei
  (16→17); no wine loses a grape.

- IT `_lib/it/menzione.py`: `_NAME_TOKEN_RE` rejected a lowercase Italian
  connector inside a name, dropping "San Donato in Poggio" from the
  numbered-list parser. The token regex now allows a connector
  (in/di/del/della/…) glued to a following capitalised word. Recovers
  Chianti Classico's 11th UGA, plus "Corte del Durlo" / "Monte di
  Colognola" in the Soave family (29→31 each); no name removed or
  corrupted (connector only accepted before a capitalised word).

Verified end-to-end by regenerating the RO 02f sidecars + IT 02
extractions over the real cached regulator documents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se 7.2

Adds the 6th official Italian regional production-zone layer, moving 20
Umbrian appellations off the Bétard whole-municipality fallback onto the
Regione Umbria delimited zones (CC-BY 4.0).

- zone_sources.py: promote `umbria` from todo → active with a new
  `fetch_type: "ckan_shapefiles"` shape — the region publishes one
  per-appellation shapefile per CKAN dataset (19 of them, a mix of .zip
  and .7z) instead of a single WFS/zip layer. Carries the CKAN catalog
  endpoint, the assigned CRS (EPSG:3004 — the shapefiles ship no .prj),
  dotted tier-prefix stripping, an "X o Y" alternate-name split, and a
  curated extra_names map (the combined DOC+DOCG datasets cover the DOCG
  sub-name on the shared delimited area).
- stage 00 `fetch_ckan_shapefiles()`: enumerate the catalog, download +
  extract each archive into raw/it/regional-zones/umbria/<dataset>/.
  py7zr (the `bootstrap` group, used by MASAF 02f) is imported lazily and
  gated like the Playwright WAF bootstrap — absent → the .7z datasets are
  skipped with a loud warning, the .zip ones still harvest.
- ITZoneIndex: split the loader into `_load_layers` (the existing
  WFS/zip path, unchanged) + `_load_ckan` (glob extracted .shp, assign
  the declared CRS, strip the dotted ZONE tier prefix, index every name
  variant). The `ZONE` field is per-feature, so the existing connector-
  stripped normalisation matching still applies.

Geometry verified: an isolated with-vs-without-Umbria rebuild differs on
exactly 20 slugs — all Umbrian (14 figshare-pdo + 5 gisco-* + Orvieto
lazio→lazio+umbria), zero collateral. Sagrantino, Torgiano Rosso Riserva
and Rosso Orvietano all resolve; only Narni (no published shapefile)
stays on its commune-union fallback. audit_it_regions green; overlap
audit shows only thin border slivers (Italian zones overlap by design).
tests/test_it_zones.py locks in the tier-strip / alt-name / DOCG-alias
matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Phase 5)

Completes Phase 5 — FR/ES/RO/IT were already done; this adds redacted-
fixture regression tests for every remaining country parser, written by
the parser-fixture-writer agent (one country each) and mirroring the
existing tests/test_{ro,it}_parser.py style: STRUCTURE-only assertions,
the shared fixture_text conftest fixture, and inline pinning of ACTUAL
parser behaviour (so a test flips and flags the day a quirk is fixed).

+196 tests (109 → 305), all green, ruff clean. Coverage:
- AT (19): einziges_dokument section guard + dash-synonym variety split.
- DE (28): einziges_dokument + all four BLE Produktspezifikation
  templates (A/B/C/D) incl. the §3.2-principal vs §8-flat role split.
- HU (16): the monotonic-number + nested-subsection guard (section-4
  "Bor – …" decoys must not shadow sections 5–9).
- GR (16): greek_norm final-sigma fold (ς→σ) + OIV colour-letter codes.
- PT (20): caderno_sections 3 variants + subregião patterns A & B +
  commune_list concelho shapes.
- BG (24): edinen_dokument nested-subsection guard + IAVV specifikacija
  colour split + Cyrillic-preserving commune casefold.
- HR (18): jedinstveni_dokument + the lettered-section specifikacija
  (form-feed fold, forward-only letter guard, colour-adjective fallback).
- SI (19): enotni_dokument em-dash split + all three specifikacija
  templates incl. the 2007 priporočene/dovoljene role split.
- CZ (22): national_spec variety table + rowspan commune-tree walker +
  SZPI CHZO terroir/style parser.
- SK (14): jednotny_dokument old+new title variants + the ÚPV §f
  two-column left-Odroda-only extraction (synonym never leaks).

Fixtures are short redacted excerpts (≤103 lines) with source-file
provenance comments; nothing from raw/ is committed. The agents also
pinned several pre-existing latent bugs as current behaviour (GR 2024
country decoy routes as geo_area; BG section-9 drop; PT distrito leak)
— each test flips when the parser is fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…less CI

Two independent CI failures (31 total), both reproduced locally by reducing
raw/ to its tracked files (CI's actual state):

1. test_brand_heading_tag_is_parameterized — the search-rail refactor moved
   the brand wordmark out of _PAGE_TEMPLATE into _SIDEBAR (composed via the
   %%SIDEBAR%% placeholder). Point the assertions at _SIDEBAR; keep the
   no-hard-coded-<h1> invariant checked against both constants.

2. 30 parser-fixture tests (AT/BG/DE/GR/HR/HU/IT/RO/SI/SK) — the grape
   matcher builds its vocabulary from gitignored raw/vivc/by-slug +
   raw/*-extracted, absent on CI, so VIVC synonym surfaces vanish and grapes
   fuzzy-mismatch (Weißer Riesling -> raeuschling, dropped feteasca-alba,
   KeyError on empty colour buckets). Fix test-side only (production matcher
   untouched): a committed vocab snapshot fed to _load_vocabulary via an
   autouse conftest fixture, but only when raw/vivc/by-slug is absent.

Verified: CI-state full suite 312 passed (was 31 failed); normal local
(raw present) 312 passed; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bdevloed bdevloed merged commit 76861d9 into main Jun 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant