navi-places: name-based wikivoyage article discovery (#30)

Places without an OSM wikivoyage tag (the entire current dataset -- 0 of placex
rows carry the tag, vs 32,862 wikipedia) never got a local Wikivoyage link even
when the article exists in the mirror by name. This implements the long-standing
discover_wikivoyage_article stub (a never-finished placeholder ported verbatim
from recon -- not disabled for any flaw) so a place name can resolve to a local
Kiwix article.

- wiki_rewrite.py: discover_wikivoyage_article(name, ...) normalizes the name to
  a MediaWiki title and runs it through the existing check_kiwix_has_article
  ('wikivoyage', ...) path -- same catalog discovery, HEAD probe, and positive
  cache as tag rewriting. Returns (kiwix_url, "local") on a HEAD 200, else
  (None, None). No public fallback: without an OSM tag we can't confirm a public
  Wikivoyage article exists, so a name miss yields no link rather than a guessed
  (possibly dead) public URL.
- place_detail._enrich_wiki_links: after the tag-rewrite loop, when extratags has
  no wikivoyage value, attempt name-based discovery on result["name"] and, on a
  local hit, set extratags["wikivoyage"] + sources.wiki_rewrites["wikivoyage"] =
  "local". Tag rewrite always wins when a tag is present (discovery only fills the
  gap). Gated by the existing has_wiki_rewriting flag (discovery lives inside that
  already-enabled, flag-gated function) -- no new flag / cross-repo profile edit;
  the docstring-only has_wiki_discovery flag was never defined in any profile.

Tests (test_place.py): finds-local (HEAD 200 -> local URL + source), 404 ->
no link / no source, and runs-only-when-tag-missing (tag present -> tag rewrite
wins, discovery not consulted). Full navi-places suite: 18 passed.

Co-authored-by: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-26 09:55:48 -06:00 committed by GitHub
commit ea495dd45e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 89 additions and 5 deletions

View file

@ -208,6 +208,15 @@ def _enrich_wiki_links(result):
if status and status != 'original':
extratags[tag] = url
sources_wr[tag] = status
# Name-based Wikivoyage discovery: many travel destinations are in the local
# Wikivoyage mirror without carrying an OSM wikivoyage tag (e.g. Twin Falls).
# Only when the tag is absent, try the place name against the wikivoyage ZIM.
if not extratags.get('wikivoyage'):
url, status = wiki_rewrite.discover_wikivoyage_article(result.get('name'))
if status == 'local' and url:
extratags['wikivoyage'] = url
sources_wr['wikivoyage'] = status
return result

View file

@ -215,6 +215,67 @@ def test_wikivoyage_tag_rewrites_to_local(tmp_path, monkeypatch):
assert d['sources']['wiki_rewrites']['wikivoyage'] == 'local'
# ── name-based wikivoyage discovery (place has no OSM wikivoyage tag) ──
def _seed_wikivoyage_zim(monkeypatch, head_status):
"""Seed the wikivoyage ZIM map and mock the Kiwix HEAD probe for discovery."""
wr = pd.wiki_rewrite
wr.reset()
monkeypatch.setattr(wr, '_ensure_zim_map', lambda: None)
monkeypatch.setattr(wr, '_zim_map', {'wikivoyage': 'wikivoyage_en_all_maxi_2026-03'})
class FakeKiwixHTTP:
def head(self, url, **kw):
return FakeResp(head_status)
monkeypatch.setattr(wr, 'http_requests', FakeKiwixHTTP())
def test_name_based_discovery_finds_local(tmp_path, monkeypatch):
monkeypatch.setenv('NAVI_PLACE_CACHE_DB', str(tmp_path / 'pc.db'))
monkeypatch.setenv('NAVI_WIKI_CACHE_DB', str(tmp_path / 'wc.db'))
_flags(monkeypatch, enabled=('has_wiki_rewriting',))
_seed_wikivoyage_zim(monkeypatch, head_status=200)
# No OSM wikivoyage tag; the place name ("Twin Falls") drives discovery.
nom = {**NOMINATIM_CAFE, 'localname': 'Twin Falls', 'extratags': {}}
monkeypatch.setattr(pd, 'http_requests', FakeHTTP(get=lambda url, **kw: FakeResp(200, nom)))
d = create_app().test_client().get('/api/place/W/123').get_json()
assert d['extratags']['wikivoyage'] == \
'https://wiki.echo6.co/content/wikivoyage_en_all_maxi_2026-03/Twin_Falls'
assert d['sources']['wiki_rewrites']['wikivoyage'] == 'local'
def test_name_based_discovery_404_falls_back(tmp_path, monkeypatch):
monkeypatch.setenv('NAVI_PLACE_CACHE_DB', str(tmp_path / 'pc.db'))
monkeypatch.setenv('NAVI_WIKI_CACHE_DB', str(tmp_path / 'wc.db'))
_flags(monkeypatch, enabled=('has_wiki_rewriting',))
_seed_wikivoyage_zim(monkeypatch, head_status=404)
nom = {**NOMINATIM_CAFE, 'localname': 'Nowhere Town', 'extratags': {}}
monkeypatch.setattr(pd, 'http_requests', FakeHTTP(get=lambda url, **kw: FakeResp(200, nom)))
d = create_app().test_client().get('/api/place/W/123').get_json()
# No local article -> no link emitted (no public guess), no source recorded.
assert d['extratags'].get('wikivoyage') is None
assert 'wikivoyage' not in d.get('sources', {}).get('wiki_rewrites', {})
def test_name_based_discovery_runs_only_when_tag_missing(tmp_path, monkeypatch):
monkeypatch.setenv('NAVI_PLACE_CACHE_DB', str(tmp_path / 'pc.db'))
_flags(monkeypatch, enabled=('has_wiki_rewriting',))
# Place DOES carry an OSM wikivoyage tag -> tag rewrite wins, discovery skipped.
nom = {**NOMINATIM_CAFE, 'localname': 'Twin Falls',
'extratags': {'wikivoyage': 'en:Twin Falls'}}
monkeypatch.setattr(pd, 'http_requests', FakeHTTP(get=lambda url, **kw: FakeResp(200, nom)))
monkeypatch.setattr(pd.wiki_rewrite, 'rewrite_wiki_link',
lambda tag, value: ('https://wiki.echo6.co/content/TAG/Twin_Falls', 'local'))
def _no_discovery(*a, **k):
raise AssertionError('discovery must not run when the wikivoyage tag is present')
monkeypatch.setattr(pd.wiki_rewrite, 'discover_wikivoyage_article', _no_discovery)
d = create_app().test_client().get('/api/place/W/123').get_json()
assert d['extratags']['wikivoyage'] == 'https://wiki.echo6.co/content/TAG/Twin_Falls'
assert d['sources']['wiki_rewrites']['wikivoyage'] == 'local'
# ── wiki index summary via local wiki_index.db ──
def test_wiki_enrich_via_local_db_merges_fields(tmp_path, monkeypatch):

View file

@ -330,12 +330,26 @@ def rewrite_wiki_link(tag_name, value):
# ── Discovery stubs (disabled, for future activation) ───────────────────
def discover_wikivoyage_article(name, category, lat, lon):
def discover_wikivoyage_article(name, category=None, lat=None, lon=None):
"""Find a local Wikivoyage article by place NAME, for places that lack an OSM
wikivoyage tag (e.g. Twin Falls). Returns (kiwix_url, "local") when an article
titled ``name`` exists in the wikivoyage ZIM verified via the same catalog +
HEAD + positive-cache path as tag rewriting else (None, None).
No public fallback: without an OSM tag we cannot confirm a public Wikivoyage
article exists, so guessing a public URL from the name would risk dead links;
a miss simply yields no Wikivoyage link. category/lat/lon are reserved for
future disambiguation.
"""
Discover a related Wikivoyage article for a place.
Enabled by has_wiki_discovery. Currently returns None.
"""
return None
if not name or not isinstance(name, str):
return (None, None)
article_id = _normalize_article_id(name.strip())
if not article_id:
return (None, None)
found, kiwix_url = check_kiwix_has_article('wikivoyage', article_id)
if found and kiwix_url:
return (kiwix_url, 'local')
return (None, None)
def discover_appropedia_article(name, category):