File size: 13,057 Bytes
b7c46f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""
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