venthrive-api / intel /locations.py
GitHub Actions
Sync from GitHub · 2026-10-21 11:53 UTC
b7c46f4
Raw History Blame Contribute Delete
13.1 kB
"""
The gazetteer: country → state → LGA (→ town, later), each with a centre point,
plus an alias table that turns free text ("flooding in Ajah", "Port Harcourt
traders") into a location id.
State and LGA *names* come from the app's own list (nigeria_locations.json,
exported from data/nigeria-locations.ts) so they're exactly what users pick in
onboarding. *Shapes* come from geoBoundaries, whose LGA spellings differ ("Ado
Odo/Ota" vs "Ado-Odo/Ota") and whose ADM2 file carries no state, so each
boundary LGA is first placed in a state by point-in-polygon, then matched to the
app's name within that state.
resolve() is deliberately simple — exact alias matching on word boundaries,
with state context used to disambiguate LGA names that exist in several states
(Surulere, Obi, Bassa, Ifelodun, ...). It's the seed of the Layer 5 search
service, not a replacement for it.
"""
import difflib
import json
import re
import sqlite3
from pathlib import Path
from intel.freshness import record_attempt
from intel.geo import PolygonIndex, centroid
HERE = Path(__file__).resolve().parent
STATES_JSON = HERE / "nigeria_locations.json"
RAW_DIR = HERE / "data" / "raw"
ADM1_PATH = RAW_DIR / "geoBoundaries-NGA-ADM1_simplified.geojson"
ADM2_PATH = RAW_DIR / "geoBoundaries-NGA-ADM2_simplified.geojson"
COUNTRY_ID = "ng"
FCT_APP_NAME = "FCT (Abuja)"
ADM1_NAME_TO_APP = {"Abuja Federal Capital Territory": FCT_APP_NAME}
# Boundary-file LGA spellings that normalise differently from the app's name and
# are too far apart for fuzzy matching to pair safely. Keyed by
# (app state, normalised boundary name) → app LGA name.
BOUNDARY_LGA_TO_APP: dict[tuple[str, str], str] = {
(FCT_APP_NAME, "municipal area council"): "Abuja Municipal (AMAC)",
}
# Common place names in news text that aren't state or LGA names. Kept short and
# only for unambiguous cases; place-data localities add many more automatically.
CURATED_ALIASES = {
"abuja": "ng.fct",
"lekki": "ng.lagos.eti-osa",
"ajah": "ng.lagos.eti-osa",
"victoria island": "ng.lagos.eti-osa",
"yaba": "ng.lagos.lagos-mainland",
"ibadan": "ng.oyo",
"benin city": "ng.edo.oredo",
"onitsha": "ng.anambra",
"aba": "ng.abia",
"warri": "ng.delta",
"jos": "ng.plateau",
"owerri": "ng.imo",
"calabar": "ng.cross-river",
"ilorin": "ng.kwara",
"enugu": "ng.enugu",
"kano": "ng.kano",
# National bodies: a policy from these applies to the whole country.
"federal government": "ng",
"fg": "ng",
"cbn": "ng",
"central bank of nigeria": "ng",
"firs": "ng",
"national assembly": "ng",
"nigerian": "ng",
}
def normalise(name: str) -> str:
s = name.lower().replace("&", " and ")
s = re.sub(r"\(.*?\)", " ", s)
s = re.sub(r"[^a-z0-9]+", " ", s)
return re.sub(r"\s+", " ", s).strip()
def _key(name: str) -> str:
return normalise(name).replace(" ", "")
def slug(name: str) -> str:
return normalise(name).replace(" ", "-")
def state_id(app_state: str) -> str:
return f"{COUNTRY_ID}.fct" if app_state == FCT_APP_NAME else f"{COUNTRY_ID}.{slug(app_state)}"
def lga_id(app_state: str, lga: str) -> str:
return f"{state_id(app_state)}.{slug(lga)}"
def _load_geojson(path: Path) -> list[dict]:
return json.loads(path.read_text(encoding="utf8"))["features"]
def match_boundaries(states: list[dict], adm1: list[dict], adm2: list[dict]) -> tuple[dict, list[str]]:
"""Returns ({(app_state, app_lga): adm2 feature}, problems). Every app LGA
must end up with exactly one boundary — anything else is reported, not
guessed."""
app_states = {s["name"] for s in states}
state_index = PolygonIndex([
(ADM1_NAME_TO_APP.get(f["properties"]["shapeName"], f["properties"]["shapeName"]), f["geometry"]) for f in adm1
])
unknown = {k for k, _, _ in state_index._items} - app_states
if unknown:
raise ValueError(f"Boundary states not in the app list: {sorted(unknown)}")
by_state: dict[str, list[dict]] = {}
problems = []
for f in adm2:
lon, lat = centroid(f["geometry"])
st = state_index.lookup(lon, lat)
if st is None:
problems.append(f"boundary LGA {f['properties']['shapeName']!r}: centre falls in no state")
continue
by_state.setdefault(st, []).append(f)
matched: dict[tuple[str, str], dict] = {}
for s in states:
st, wanted = s["name"], list(s["lgas"])
pool = list(by_state.get(st, []))
def take(app_lga: str, feature: dict) -> None:
matched[(st, app_lga)] = feature
wanted.remove(app_lga)
pool.remove(feature)
for f in list(pool):
k = _key(f["properties"]["shapeName"])
curated = BOUNDARY_LGA_TO_APP.get((st, normalise(f["properties"]["shapeName"])))
exact = [w for w in wanted if _key(w) == k]
if curated and curated in wanted:
take(curated, f)
elif len(exact) == 1:
take(exact[0], f)
# Fuzzy pass for what's left, one-to-one, best pairs first.
pairs = sorted(
((difflib.SequenceMatcher(None, _key(w), _key(f["properties"]["shapeName"])).ratio(), w, f)
for w in wanted for f in pool),
key=lambda p: -p[0],
)
for ratio, w, f in pairs:
if ratio < 0.6:
break
if w in wanted and f in pool:
take(w, f)
for w in wanted:
problems.append(f"{st}: app LGA {w!r} has no boundary")
for f in pool:
problems.append(f"{st}: boundary LGA {f['properties']['shapeName']!r} matched no app LGA")
return matched, problems
def _add_alias(con: sqlite3.Connection, alias: str, location_id: str, origin: str) -> None:
alias = normalise(alias)
if alias:
con.execute("insert or ignore into location_aliases (alias, location_id, origin) values (?, ?, ?)",
(alias, location_id, origin))
def build(con: sqlite3.Connection) -> dict:
"""(Re)builds country/state/LGA rows and their aliases. Idempotent."""
states = json.loads(STATES_JSON.read_text(encoding="utf8"))
adm1, adm2 = _load_geojson(ADM1_PATH), _load_geojson(ADM2_PATH)
matched, problems = match_boundaries(states, adm1, adm2)
if problems:
raise ValueError("Boundary matching failed:\n" + "\n".join(problems))
def upsert(lid, level, name, parent, lon, lat):
con.execute(
"""insert into locations (id, level, name, parent_id, lat, lon, source) values (?, ?, ?, ?, ?, ?, 'geoboundaries')
on conflict (id) do update set level = excluded.level, name = excluded.name,
parent_id = excluded.parent_id, lat = excluded.lat, lon = excluded.lon""",
(lid, level, name, parent, lat, lon),
)
country = {"type": "MultiPolygon", "coordinates": []}
for f in adm1:
g = f["geometry"]
country["coordinates"] += [g["coordinates"]] if g["type"] == "Polygon" else g["coordinates"]
upsert(COUNTRY_ID, "country", "Nigeria", None, *centroid(country))
_add_alias(con, "Nigeria", COUNTRY_ID, "gazetteer")
adm1_by_app = {ADM1_NAME_TO_APP.get(f["properties"]["shapeName"], f["properties"]["shapeName"]): f for f in adm1}
for s in states:
sid = state_id(s["name"])
upsert(sid, "state", s["name"], COUNTRY_ID, *centroid(adm1_by_app[s["name"]]["geometry"]))
_add_alias(con, s["name"], sid, "gazetteer")
_add_alias(con, f"{s['name']} state", sid, "gazetteer")
_add_alias(con, adm1_by_app[s["name"]]["properties"]["shapeName"], sid, "boundary")
for lga in s["lgas"]:
lid = lga_id(s["name"], lga)
f = matched[(s["name"], lga)]
upsert(lid, "lga", lga, sid, *centroid(f["geometry"]))
_add_alias(con, lga, lid, "gazetteer")
_add_alias(con, f["properties"]["shapeName"], lid, "boundary")
# "Abuja Municipal (AMAC)" → also findable as "AMAC".
for inner in re.findall(r"\((.*?)\)", lga):
_add_alias(con, inner, lid, "gazetteer")
_add_alias(con, "FCT", state_id(FCT_APP_NAME), "gazetteer")
for alias, lid in CURATED_ALIASES.items():
_add_alias(con, alias, lid, "curated")
con.commit()
# The boundary files are downloaded by hand (backend/intel/README.md); the
# build is what proves they're present and usable, so it reports to Layer 1.
# No data period: boundaries have no "newest day", only the fetch clock.
record_attempt(con, "geoboundaries", ok=True)
n = con.execute("select level, count(*) from locations group by level").fetchall()
return {r[0]: r[1] for r in n}
def lga_polygons() -> list[tuple[str, dict]]:
"""(lga id, geometry) for point-in-LGA lookups (places ingest)."""
states = json.loads(STATES_JSON.read_text(encoding="utf8"))
matched, problems = match_boundaries(states, _load_geojson(ADM1_PATH), _load_geojson(ADM2_PATH))
if problems:
raise ValueError("Boundary matching failed:\n" + "\n".join(problems))
return [(lga_id(st, lga), f["geometry"]) for (st, lga), f in matched.items()]
def _alias_rows(con: sqlite3.Connection) -> list[sqlite3.Row]:
"""alias, location id, its level, and the state it belongs to (itself for a state)."""
return con.execute(
"""select a.alias, l.id, l.level,
case when l.level = 'state' then l.id when p.level = 'state' then p.id end as state
from location_aliases a join locations l on l.id = a.location_id
left join locations p on p.id = l.parent_id"""
).fetchall()
def resolve(con: sqlite3.Connection, text: str | None) -> str | None:
"""Best location id mentioned in text, or None when there's no mention or
the mention is genuinely ambiguous. Prefers the most specific level, with
two rules:
- a name that is both a state and an LGA ("Bauchi", "Ekiti") means the
state — that's how people write;
- an LGA name shared by several states ("Surulere") only resolves when the
text also names one of those states; otherwise it falls back to their
common state if they share one, else None.
"""
if not text:
return None
hay = f" {normalise(text)} "
hits = [r for r in _alias_rows(con) if f" {r['alias']} " in hay]
state_aliases = {r["alias"] for r in hits if r["level"] == "state"}
hits = [r for r in hits if not (r["level"] in ("lga", "town") and r["alias"] in state_aliases)]
named_states = {r["id"] for r in hits if r["level"] == "state"}
for level in ("town", "lga"):
cands = {(r["id"], r["state"]) for r in hits if r["level"] == level}
if not cands:
continue
narrowed = {c for c in cands if c[1] in named_states}
cands = narrowed or cands
if len(cands) == 1:
return next(iter(cands))[0]
states = {st for _, st in cands}
return next(iter(states)) if len(states) == 1 else None
if len(named_states) == 1:
return next(iter(named_states))
if named_states:
return None
return COUNTRY_ID if any(r["level"] == "country" for r in hits) else None
def add_place_localities(con: sqlite3.Connection, min_places: int = 20, share: float = 0.8) -> int:
"""Turns place-data localities into aliases, by majority vote: a locality
string with enough places, most of them inside one LGA, becomes an alias of
that LGA; failing that, of their common state. Data-derived, so "Ikorodu" or
"Agbara" become resolvable without anyone typing them in."""
rows = con.execute(
"""select lower(trim(locality)) loc, location_id, count(*) n from places
where locality is not null and location_id is not null group by 1, 2"""
).fetchall()
by_loc: dict[str, list[tuple[str, int]]] = {}
for r in rows:
by_loc.setdefault(r["loc"], []).append((r["location_id"], r["n"]))
added = 0
for loc, counts in by_loc.items():
total = sum(n for _, n in counts)
if total < min_places or not normalise(loc):
continue
best, n = max(counts, key=lambda c: c[1])
if n / total >= share:
target = best
else:
state_counts: dict[str, int] = {}
for lid, c in counts:
state_counts[lid.rsplit(".", 1)[0]] = state_counts.get(lid.rsplit(".", 1)[0], 0) + c
st, sn = max(state_counts.items(), key=lambda c: c[1])
if sn / total < share:
continue
target = st
# Never let a derived alias shadow a gazetteer name (e.g. "Lagos" the
# locality must keep meaning Lagos State, not whichever LGA dominates).
if con.execute("select 1 from location_aliases where alias = ? and origin != 'places'", (normalise(loc),)).fetchone():
continue
_add_alias(con, loc, target, "places")
added += 1
con.commit()
return added