NewsAtlas/backend/app/geocode.py

69 lines
2.1 KiB
Python

"""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)}"