"""Lightweight, dependency-free geocoding. We deliberately avoid calling an external geocoding API per-article (rate limits, latency, privacy) and avoid a heavyweight NER model. Instead we match article text against a curated gazetteer of cities/regions/countries (app/data/gazetteer.csv), preferring the most specific (longest) name found. This is a heuristic: it will miss unlisted places and can occasionally match the wrong entity for very generic names. Extend data/gazetteer.csv to improve coverage. """ import csv import re from dataclasses import dataclass from functools import lru_cache from .config import settings @dataclass(frozen=True) class Place: name: str lat: float lon: float type: str country: str def _load_gazetteer() -> list[Place]: places: list[Place] = [] with open(settings.gazetteer_file, newline="", encoding="utf-8") as f: for row in csv.DictReader(f): places.append( Place( name=row["name"], lat=float(row["lat"]), lon=float(row["lon"]), type=row["type"], country=row["country"], ) ) # Longest name first so "Gaza Strip" wins over "Gaza", "New York" over "York", etc. places.sort(key=lambda p: len(p.name), reverse=True) return places @lru_cache(maxsize=1) def _gazetteer() -> list[tuple[re.Pattern, Place]]: compiled = [] for place in _load_gazetteer(): pattern = re.compile(r"\b" + re.escape(place.name) + r"\b", re.IGNORECASE) compiled.append((pattern, place)) return compiled def locate(text: str) -> Place | None: """Return the most specific gazetteer place mentioned in `text`, if any.""" if not text: return None for pattern, place in _gazetteer(): if pattern.search(text): return place return None def cluster_key_for(lat: float, lon: float) -> str: """Grid-snap coordinates (~11km cells) so nearby stories share a bucket.""" return f"{round(lat, 1)}:{round(lon, 1)}"