Spaces:
Running
Running
Download intel/locations.py from Success-dev/venthrive-api: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/Success-dev/venthrive-api/resolve/main/intel/locations.py
- Command line
-
hf download hf://spaces/Success-dev/venthrive-api/intel/locations.py
-
curl -L -o locations.py https://huggingface.co/spaces/Success-dev/venthrive-api/resolve/main/intel/locations.py
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 | |