GitHub Actions commited on
Commit
b7c46f4
·
1 Parent(s): a32af27

Sync from GitHub · 2026-10-21 11:53 UTC

Browse files
intel/README.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Intel store (`backend/intel/`)
2
+
3
+ This is Phase 0 and Phase 1 of the predictive intelligence system. The design is in
4
+ [docs/blueprint/intelligence-system.md](../../docs/blueprint/intelligence-system.md), and the decisions
5
+ are in ADR-0017 to ADR-0021. It runs locally against a SQLite file and is not wired into the deployed API.
6
+ The only touchpoint is the news → events hook, which does nothing unless `INTEL_DB_PATH` is set.
7
+
8
+ ## Set up
9
+
10
+ All commands run from `backend/`.
11
+
12
+ 1. Install the dependencies:
13
+
14
+ ```bash
15
+ py -m pip install -r requirements.txt -r requirements-intel.txt
16
+ ```
17
+
18
+ 2. Add the store's path to `backend/.env`:
19
+
20
+ ```
21
+ INTEL_DB_PATH=intel/data/intel.db
22
+ ```
23
+
24
+ 3. Download the raw files into `intel/data/raw/`. The folder is gitignored.
25
+ - **Boundaries** come from geoBoundaries (CC BY 4.0). Use the simplified NGA ADM1 and ADM2 files,
26
+ named `geoBoundaries-NGA-ADM1_simplified.geojson` and `geoBoundaries-NGA-ADM2_simplified.geojson`.
27
+ The download links are on <https://www.geoboundaries.org/countryDownloads.html> (NGA).
28
+ - **Overture places** (CDLA Permissive 2.0) are extracted with duckdb straight from Overture's
29
+ public S3 bucket. It takes about 30 s and produces 156k rows:
30
+
31
+ ```bash
32
+ py -m intel.ingest.overture_extract
33
+ ```
34
+
35
+ ## Build, in order
36
+
37
+ ```bash
38
+ py -m intel.cli init # schema + source/stream registry
39
+ py -m intel.cli locations # gazetteer: 37 states, 774 LGAs, aliases
40
+ py -m intel.cli calendar # holidays and seasons, 2000 to today+10y
41
+ py -m intel.cli places # Overture → places, subsector mapping, locality aliases
42
+ py -m intel.cli weather-forecast # 16-day forecast per state
43
+ py -m intel.cli weather-history # resumable backfill from 2006, 4,500 weighted calls per run
44
+ ```
45
+
46
+ Re-running any of these is safe, because every build is idempotent. The full weather history is about
47
+ 20,000 weighted Open-Meteo calls. The free tier allows 10,000 a day, so run `weather-history` once a day
48
+ until `--estimate` reports 0. Each run resumes where the last one stopped.
49
+
50
+ - **Licence warning:** Open-Meteo's free tier is **non-commercial only**. Commercial use needs a paid
51
+ plan or ECMWF open data (ADR-0020).
52
+ - **One writer at a time:** SQLite takes a single writer, so don't run two commands against the store
53
+ at once.
54
+
55
+ ## Day to day
56
+
57
+ ```bash
58
+ py -m intel.cli status # Layer 1 report; exits 1 if anything isn't fresh
59
+ py -m intel.cli resolve "Ajah" # gazetteer lookup → ng.lagos.eti-osa
60
+ py -m intel.cli run-daily # forecast + history top-up + status, under one run id
61
+ py -m intel.cli export # CSVs + manifest for the Colab notebooks → intel/data/export/
62
+ ```
63
+
64
+ `run-daily` is what a scheduler will call. There is no scheduler yet: the design names a GitHub
65
+ Actions cron or Supabase `pg_cron`, because the Hugging Face Space sleeps when idle.
66
+
67
+ ## Layout
68
+
69
+ | File | Job |
70
+ |---|---|
71
+ | `db.py` | Connection and schema init (`../sql/intel_schema.sql`) |
72
+ | `registry.py` | Sources (cadence, latency, licence) and streams |
73
+ | `handoff.py` | Layer names, handoff receipts, `ACTIVE_LAYERS` |
74
+ | `freshness.py` | Layer 1: the two-clock source grading and overdue handoffs |
75
+ | `geo.py` | Pure-Python centroid, point-in-polygon and polygon index |
76
+ | `locations.py` | Gazetteer build, boundary matching, `resolve()` |
77
+ | `subsector_map.py` | Overture categories → VenThrive's 49 subsectors |
78
+ | `events.py` | News → permanent `events` hook (ADR-0015 addendum) |
79
+ | `export.py` | CSV export for `ml/` |
80
+ | `ingest/` | One module per source |
81
+
82
+ ## Tests
83
+
84
+ ```bash
85
+ py -m pytest tests/test_intel.py -q
86
+ ```
87
+
88
+ The tests are pure and offline: they use an in-memory database and fake HTTP fetches.
intel/__init__.py ADDED
File without changes
intel/cli.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Command line for the intel store (run from backend/ with INTEL_DB_PATH set):
3
+
4
+ py -m intel.cli init create tables, seed sources/streams
5
+ py -m intel.cli locations build the state/LGA gazetteer
6
+ py -m intel.cli calendar holidays + seasons, 2000 → +10 years
7
+ py -m intel.cli places load the Overture extract (run overture_extract first)
8
+ py -m intel.cli weather-history [--budget N] [--start YYYY-MM-DD] resumable backfill
9
+ py -m intel.cli weather-forecast 16-day forecast at state centres
10
+ py -m intel.cli status Layer 1 report (exit 1 if unhealthy)
11
+ py -m intel.cli export [--out DIR] CSVs + manifest for the Colab notebooks
12
+ py -m intel.cli resolve "TEXT" which location does this text mention?
13
+ py -m intel.cli run-daily forecast + history top-up + status, one run id
14
+
15
+ `run-daily` is what a scheduler will call once one exists (none yet — see the
16
+ design doc's "Conflicts").
17
+ """
18
+ import argparse
19
+ import json
20
+ import sys
21
+ import uuid
22
+ from datetime import date
23
+ from pathlib import Path
24
+
25
+ from dotenv import load_dotenv
26
+
27
+ load_dotenv()
28
+
29
+ from intel import db # noqa: E402 (env must be loaded first)
30
+
31
+ EXPORT_DIR = Path(__file__).resolve().parent / "data" / "export"
32
+
33
+
34
+ def _print(obj) -> None:
35
+ print(json.dumps(obj, indent=2, default=str))
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ p = argparse.ArgumentParser(prog="intel")
40
+ sub = p.add_subparsers(dest="cmd", required=True)
41
+ sub.add_parser("init")
42
+ sub.add_parser("locations")
43
+ sub.add_parser("calendar")
44
+ sub.add_parser("places")
45
+ wh = sub.add_parser("weather-history")
46
+ wh.add_argument("--budget", type=float, default=None)
47
+ wh.add_argument("--start", type=date.fromisoformat, default=None)
48
+ wh.add_argument("--estimate", action="store_true", help="only print the weighted calls still needed")
49
+ sub.add_parser("weather-forecast")
50
+ sub.add_parser("status")
51
+ ex = sub.add_parser("export")
52
+ ex.add_argument("--out", type=Path, default=EXPORT_DIR)
53
+ rs = sub.add_parser("resolve")
54
+ rs.add_argument("text")
55
+ sub.add_parser("run-daily")
56
+ args = p.parse_args(argv)
57
+
58
+ con = db.connect()
59
+ run_id = f"{args.cmd}-{uuid.uuid4().hex[:8]}"
60
+ try:
61
+ if args.cmd == "init":
62
+ db.init(con)
63
+ _print({"initialised": db.db_path()})
64
+ elif args.cmd == "locations":
65
+ from intel.locations import build
66
+ _print(build(con))
67
+ elif args.cmd == "calendar":
68
+ from intel.ingest.calendar import ingest
69
+ _print(ingest(con, run_id=run_id))
70
+ elif args.cmd == "places":
71
+ from intel.ingest.places import ingest
72
+ _print(ingest(con, run_id=run_id))
73
+ elif args.cmd == "weather-history":
74
+ from intel.ingest import weather
75
+ start = args.start or weather.DEFAULT_HISTORY_START
76
+ if args.estimate:
77
+ _print({"weighted_calls_needed": round(weather.estimate_backfill_calls(con, start=start), 1)})
78
+ else:
79
+ _print(weather.backfill_history(con, start=start, budget=args.budget or weather.DEFAULT_RUN_BUDGET, run_id=run_id))
80
+ elif args.cmd == "weather-forecast":
81
+ from intel.ingest.weather import fetch_forecast
82
+ _print(fetch_forecast(con, run_id=run_id))
83
+ elif args.cmd == "status":
84
+ from intel.freshness import report
85
+ r = report(con)
86
+ _print(r)
87
+ return 0 if r["healthy"] else 1
88
+ elif args.cmd == "export":
89
+ from intel.export import export
90
+ _print(export(con, args.out)["rows"])
91
+ elif args.cmd == "resolve":
92
+ from intel.locations import resolve
93
+ _print({"text": args.text, "location_id": resolve(con, args.text)})
94
+ elif args.cmd == "run-daily":
95
+ from intel.freshness import report
96
+ from intel.ingest import weather
97
+ out = {"run_id": run_id, "forecast": weather.fetch_forecast(con, run_id=run_id),
98
+ "history": weather.backfill_history(con, run_id=run_id)}
99
+ out["status"] = report(con)
100
+ _print(out)
101
+ return 0 if out["status"]["healthy"] else 1
102
+ finally:
103
+ con.close()
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
intel/db.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Connection to the intel store. Phase 1 runs on a local SQLite file (ADR-0020);
3
+ the schema in backend/sql/intel_schema.sql is portable so the same tables move
4
+ to Postgres/Supabase later.
5
+
6
+ INTEL_DB_PATH selects the file. When it's unset the store is "off": the CLI
7
+ refuses to run and hooks from other systems (the news pipeline's event writer)
8
+ silently do nothing — so the deployed backend, which has no intel store yet,
9
+ behaves exactly as before.
10
+ """
11
+ import os
12
+ import sqlite3
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ SCHEMA_PATH = Path(__file__).resolve().parent.parent / "sql" / "intel_schema.sql"
17
+
18
+
19
+ def db_path() -> str | None:
20
+ return os.environ.get("INTEL_DB_PATH") or None
21
+
22
+
23
+ def enabled() -> bool:
24
+ return db_path() is not None
25
+
26
+
27
+ def connect(path: str | None = None) -> sqlite3.Connection:
28
+ path = path or db_path()
29
+ if not path:
30
+ raise RuntimeError("INTEL_DB_PATH is not set — the intel store is off (see backend/intel/README.md).")
31
+ if path != ":memory:":
32
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
33
+ con = sqlite3.connect(path)
34
+ con.row_factory = sqlite3.Row
35
+ con.execute("pragma foreign_keys = on")
36
+ if path != ":memory:":
37
+ con.execute("pragma journal_mode = wal")
38
+ return con
39
+
40
+
41
+ def init(con: sqlite3.Connection) -> None:
42
+ con.executescript(SCHEMA_PATH.read_text(encoding="utf8"))
43
+ from intel.registry import seed
44
+ seed(con)
45
+ con.commit()
46
+
47
+
48
+ def now_iso() -> str:
49
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
intel/events.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ News → permanent event history. The news pipeline (ADR-0015) decides what users
3
+ *see* and lets items expire from the feed; this keeps every published item as a
4
+ dated, located event because, for the correlation step, past events are the
5
+ history — the reason the `threat` value was removed (no value in showing
6
+ after-the-fact news) doesn't apply to *learning* from it.
7
+
8
+ record_news_item() is called from the news pipeline and the admin publish route.
9
+ It never raises and is a no-op when the intel store is off (INTEL_DB_PATH unset),
10
+ so the deployed backend is unaffected.
11
+ """
12
+ from datetime import date, datetime
13
+
14
+ from intel import db
15
+ from intel.locations import resolve
16
+
17
+ SOURCE_ID = "news_pipeline"
18
+ # news categories → event family; identical names except where noted.
19
+ CATEGORY_TO_FAMILY = {
20
+ "policy": "policy", "weather": "weather", "infrastructure": "infrastructure",
21
+ "funding": "funding", "security": "security", "other": "other",
22
+ }
23
+
24
+
25
+ def _day(value) -> str | None:
26
+ if value is None:
27
+ return None
28
+ if isinstance(value, (date, datetime)):
29
+ return value.date().isoformat() if isinstance(value, datetime) else value.isoformat()
30
+ return str(value)[:10]
31
+
32
+
33
+ def record_news_item(item: dict) -> bool:
34
+ """item: a news_items-shaped dict (url_hash, title, raw_text, category,
35
+ location, published_at, scraped_at, deadline). Returns True if an event was
36
+ written. Only published (status 'classified') items should be passed in."""
37
+ if not db.enabled():
38
+ return False
39
+ try:
40
+ con = db.connect()
41
+ try:
42
+ location_id = resolve(con, item.get("location")) or resolve(con, item.get("title"))
43
+ con.execute(
44
+ """insert into events (id, family, type, location_id, starts_on, ends_on, title, detail,
45
+ is_estimate, source_id, source_ref, ingested_at)
46
+ values (?, ?, 'news', ?, ?, ?, ?, ?, 0, ?, ?, ?)
47
+ on conflict (source_id, source_ref) do update set family = excluded.family,
48
+ location_id = excluded.location_id, title = excluded.title, detail = excluded.detail""",
49
+ (
50
+ f"{SOURCE_ID}:{item['url_hash']}",
51
+ CATEGORY_TO_FAMILY.get(item.get("category") or "other", "other"),
52
+ location_id,
53
+ _day(item.get("published_at")) or _day(item.get("scraped_at")),
54
+ _day(item.get("deadline")),
55
+ item["title"],
56
+ (item.get("raw_text") or "")[:2000] or None,
57
+ SOURCE_ID,
58
+ item["url_hash"],
59
+ db.now_iso(),
60
+ ),
61
+ )
62
+ con.commit()
63
+ from intel.freshness import record_attempt
64
+ record_attempt(con, SOURCE_ID, ok=True, latest_data_period=_day(item.get("published_at")) or _day(item.get("scraped_at")))
65
+ return True
66
+ finally:
67
+ con.close()
68
+ except Exception as e:
69
+ print(f"[intel] could not record news event {item.get('url_hash')}: {e}")
70
+ return False
intel/export.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exports the store as gzipped CSVs plus a manifest, for the Colab notebooks in
3
+ ml/notebooks/. The manifest carries Layer 1's freshness report, so every
4
+ notebook run states what data it was trained on and how old it was.
5
+ """
6
+ import csv
7
+ import gzip
8
+ import json
9
+ import sqlite3
10
+ from pathlib import Path
11
+
12
+ from intel.db import now_iso
13
+ from intel.freshness import report
14
+
15
+ TABLES = {
16
+ "locations": "select id, level, name, parent_id, lat, lon from locations order by id",
17
+ "streams": "select * from streams order by id",
18
+ "observations": """select stream_id, location_id, period_start, value from observations
19
+ where is_forecast = 0 order by stream_id, location_id, period_start""",
20
+ "forecasts": """select stream_id, location_id, period_start, issued_at, value from observations
21
+ where is_forecast = 1 order by issued_at, stream_id, location_id, period_start""",
22
+ "events": """select id, family, type, location_id, starts_on, ends_on, title, is_estimate, source_id
23
+ from events order by starts_on""",
24
+ "places": """select id, name, category, subsector, confidence, operating_status, lat, lon, location_id, locality
25
+ from places order by location_id""",
26
+ }
27
+
28
+
29
+ def export(con: sqlite3.Connection, out_dir: Path) -> dict:
30
+ out_dir.mkdir(parents=True, exist_ok=True)
31
+ counts = {}
32
+ for name, sql in TABLES.items():
33
+ cur = con.execute(sql)
34
+ cols = [d[0] for d in cur.description]
35
+ n = 0
36
+ with gzip.open(out_dir / f"{name}.csv.gz", "wt", newline="", encoding="utf8") as f:
37
+ w = csv.writer(f)
38
+ w.writerow(cols)
39
+ for row in cur:
40
+ w.writerow(row)
41
+ n += 1
42
+ counts[name] = n
43
+ manifest = {"exported_at": now_iso(), "rows": counts, "freshness": report(con)}
44
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf8")
45
+ return manifest
intel/freshness.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Layer 1 — stale awareness. Answers one question: is every source and every
3
+ handoff where it should be right now? It reads; it never fixes anything.
4
+
5
+ A source is judged against its own rhythm (registry.py), on two clocks:
6
+ - fetch clock: when did we last successfully fetch it?
7
+ - data clock: how new is the newest period it gave us, allowing for the
8
+ source's legitimate latency?
9
+ A fetch that succeeds daily but keeps returning the same old period is stale on
10
+ the data clock even though the fetch clock looks fine.
11
+ """
12
+ import sqlite3
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+ from intel.db import now_iso
16
+ from intel.handoff import ACTIVE_LAYERS
17
+
18
+ # A source is 'late' after 1.5× its cadence and 'stale' after 3×. Generous on
19
+ # purpose: a monthly source a few days late is normal, three months late isn't.
20
+ LATE_FACTOR = 1.5
21
+ STALE_FACTOR = 3.0
22
+ # A handoff to an active layer that sits unreceived this long is overdue.
23
+ HANDOFF_OVERDUE = timedelta(hours=24)
24
+
25
+ STATUS_ORDER = ["never", "failing", "stale", "late", "fresh"]
26
+
27
+
28
+ def _parse(ts: str | None) -> datetime | None:
29
+ if not ts:
30
+ return None
31
+ dt = datetime.fromisoformat(ts)
32
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
33
+
34
+
35
+ def record_attempt(con: sqlite3.Connection, source_id: str, ok: bool,
36
+ latest_data_period: str | None = None, error: str | None = None) -> None:
37
+ """Every ingester calls this once per run, success or failure."""
38
+ now = now_iso()
39
+ if ok:
40
+ con.execute(
41
+ """update sources set last_attempt_at = ?, last_success_at = ?, last_error = null,
42
+ latest_data_period = coalesce(?, latest_data_period) where id = ?""",
43
+ (now, now, latest_data_period, source_id),
44
+ )
45
+ else:
46
+ con.execute("update sources set last_attempt_at = ?, last_error = ? where id = ?", (now, error, source_id))
47
+ con.commit()
48
+
49
+
50
+ def _grade(age: timedelta, cadence: timedelta) -> str:
51
+ if age > cadence * STALE_FACTOR:
52
+ return "stale"
53
+ if age > cadence * LATE_FACTOR:
54
+ return "late"
55
+ return "fresh"
56
+
57
+
58
+ def source_status(row: sqlite3.Row, now: datetime) -> dict:
59
+ cadence = timedelta(hours=row["expected_cadence_hours"])
60
+ latency = timedelta(hours=row["latency_hours"])
61
+ last_success = _parse(row["last_success_at"])
62
+ last_attempt = _parse(row["last_attempt_at"])
63
+ out = {
64
+ "source": row["id"], "family": row["family"],
65
+ "last_success_at": row["last_success_at"], "latest_data_period": row["latest_data_period"],
66
+ "last_error": row["last_error"],
67
+ }
68
+
69
+ if last_success is None:
70
+ out["status"] = "failing" if last_attempt else "never"
71
+ out["reason"] = f"never succeeded: {row['last_error']}" if last_attempt else "never fetched"
72
+ return out
73
+
74
+ fetch_grade = _grade(now - last_success, cadence)
75
+ data_grade = "fresh"
76
+ period = _parse(row["latest_data_period"])
77
+ if period is not None:
78
+ data_grade = _grade(max(now - period - latency, timedelta(0)), cadence)
79
+
80
+ # A success clears last_error, so an error present means the latest attempt
81
+ # failed — the source is failing now, even if its last good data is still
82
+ # within cadence. (Comparing timestamps instead misses a failure in the same
83
+ # second as the success.)
84
+ failing = bool(row["last_error"])
85
+ grade = min(fetch_grade, data_grade, key=STATUS_ORDER.index)
86
+ if failing:
87
+ out["status"], out["reason"] = "failing", f"last attempt failed: {row['last_error']}"
88
+ else:
89
+ out["status"] = grade
90
+ if grade == "fresh":
91
+ out["reason"] = "on schedule"
92
+ elif STATUS_ORDER.index(data_grade) < STATUS_ORDER.index(fetch_grade):
93
+ out["reason"] = f"fetched fine but newest data is {row['latest_data_period']}"
94
+ else:
95
+ out["reason"] = f"last successful fetch {row['last_success_at']}"
96
+ return out
97
+
98
+
99
+ def report(con: sqlite3.Connection, now: datetime | None = None) -> dict:
100
+ now = now or datetime.now(timezone.utc)
101
+ sources = [source_status(r, now) for r in con.execute("select * from sources order by id")]
102
+
103
+ overdue, queued = [], []
104
+ for h in con.execute("select id, run_id, from_layer, to_layer, produced_at from handoffs where received_at is null"):
105
+ item = dict(h)
106
+ if h["to_layer"] not in ACTIVE_LAYERS:
107
+ queued.append(item)
108
+ elif now - _parse(h["produced_at"]) > HANDOFF_OVERDUE:
109
+ overdue.append(item)
110
+
111
+ healthy = all(s["status"] == "fresh" for s in sources) and not overdue
112
+ return {
113
+ "checked_at": now.isoformat(timespec="seconds"),
114
+ "healthy": healthy,
115
+ "sources": sources,
116
+ "handoffs_overdue": overdue,
117
+ "handoffs_queued_for_unbuilt_layers": len(queued),
118
+ }
intel/geo.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pure-Python geometry for the intel store — GeoJSON Polygon/MultiPolygon only.
3
+ Enough for Phase 1 (LGA centroids, which-LGA-is-this-point-in, distances) without
4
+ pulling a GIS stack into the backend; PostGIS replaces this when the store moves
5
+ to Postgres (ADR-0020).
6
+ """
7
+ import math
8
+
9
+ EARTH_RADIUS_KM = 6371.0088
10
+
11
+
12
+ def _rings(geometry: dict) -> list[list[list[list[float]]]]:
13
+ """Normalises Polygon and MultiPolygon to a list of polygons, each a list of
14
+ rings (outer ring first, then holes), each ring a list of [lon, lat]."""
15
+ if geometry["type"] == "Polygon":
16
+ return [geometry["coordinates"]]
17
+ if geometry["type"] == "MultiPolygon":
18
+ return geometry["coordinates"]
19
+ raise ValueError(f"Unsupported geometry type: {geometry['type']}")
20
+
21
+
22
+ def _ring_area_centroid(ring: list[list[float]]) -> tuple[float, float, float]:
23
+ """Signed area and area-weighted centroid of one ring (shoelace formula)."""
24
+ a = cx = cy = 0.0
25
+ for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]):
26
+ cross = x0 * y1 - x1 * y0
27
+ a += cross
28
+ cx += (x0 + x1) * cross
29
+ cy += (y0 + y1) * cross
30
+ a /= 2.0
31
+ if a == 0:
32
+ xs, ys = [p[0] for p in ring], [p[1] for p in ring]
33
+ return 0.0, sum(xs) / len(xs), sum(ys) / len(ys)
34
+ return a, cx / (6.0 * a), cy / (6.0 * a)
35
+
36
+
37
+ def centroid(geometry: dict) -> tuple[float, float]:
38
+ """Area-weighted centroid as (lon, lat). Holes subtract their area. Planar
39
+ maths on degrees is fine at LGA scale this close to the equator."""
40
+ total = sx = sy = 0.0
41
+ for polygon in _rings(geometry):
42
+ for i, ring in enumerate(polygon):
43
+ a, cx, cy = _ring_area_centroid([p[:2] for p in ring])
44
+ a = abs(a) if i == 0 else -abs(a)
45
+ total += a
46
+ sx += cx * a
47
+ sy += cy * a
48
+ if total == 0:
49
+ raise ValueError("Degenerate geometry")
50
+ return sx / total, sy / total
51
+
52
+
53
+ def bbox(geometry: dict) -> tuple[float, float, float, float]:
54
+ xs, ys = [], []
55
+ for polygon in _rings(geometry):
56
+ for p in polygon[0]:
57
+ xs.append(p[0])
58
+ ys.append(p[1])
59
+ return min(xs), min(ys), max(xs), max(ys)
60
+
61
+
62
+ def _in_ring(lon: float, lat: float, ring: list[list[float]]) -> bool:
63
+ inside = False
64
+ for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]):
65
+ if (y0 > lat) != (y1 > lat) and lon < (x1 - x0) * (lat - y0) / (y1 - y0) + x0:
66
+ inside = not inside
67
+ return inside
68
+
69
+
70
+ def contains(geometry: dict, lon: float, lat: float) -> bool:
71
+ for polygon in _rings(geometry):
72
+ outer, holes = polygon[0], polygon[1:]
73
+ if _in_ring(lon, lat, [p[:2] for p in outer]) and not any(_in_ring(lon, lat, [p[:2] for p in h]) for h in holes):
74
+ return True
75
+ return False
76
+
77
+
78
+ def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
79
+ p1, p2 = math.radians(lat1), math.radians(lat2)
80
+ dp, dl = p2 - p1, math.radians(lon2 - lon1)
81
+ h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
82
+ return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(h))
83
+
84
+
85
+ class PolygonIndex:
86
+ """Point-in-polygon lookup over many features, with a bbox prefilter so a
87
+ lookup only runs the ray-cast against the handful of polygons whose box
88
+ contains the point."""
89
+
90
+ def __init__(self, features: list[tuple[str, dict]]):
91
+ self._items = [(key, geom, bbox(geom)) for key, geom in features]
92
+
93
+ def lookup(self, lon: float, lat: float) -> str | None:
94
+ for key, geom, (x0, y0, x1, y1) in self._items:
95
+ if x0 <= lon <= x1 and y0 <= lat <= y1 and contains(geom, lon, lat):
96
+ return key
97
+ return None
intel/handoff.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Handoff receipts between layers. A layer's job ends with produce(); the next
3
+ layer's job starts with receive(). Neither layer knows anything else about the
4
+ other — and Layer 1 can see any report that was produced but never received.
5
+ """
6
+ import json
7
+ import sqlite3
8
+ import uuid
9
+
10
+ from intel.db import now_iso
11
+
12
+ STALE_AWARENESS = "stale_awareness"
13
+ DATA_STREAM = "data_stream"
14
+ INVESTIGATION = "investigation"
15
+ INTERFACE = "interface"
16
+ SEARCH = "search"
17
+
18
+ # Layers that exist and run. A report addressed to a layer that isn't built yet
19
+ # is queued, not overdue — Layer 1 lists it separately instead of raising it.
20
+ ACTIVE_LAYERS = {STALE_AWARENESS, DATA_STREAM}
21
+
22
+
23
+ def produce(con: sqlite3.Connection, run_id: str, from_layer: str, to_layer: str, report: dict) -> str:
24
+ hid = uuid.uuid4().hex
25
+ con.execute(
26
+ "insert into handoffs (id, run_id, from_layer, to_layer, report, produced_at) values (?, ?, ?, ?, ?, ?)",
27
+ (hid, run_id, from_layer, to_layer, json.dumps(report, default=str), now_iso()),
28
+ )
29
+ con.commit()
30
+ return hid
31
+
32
+
33
+ def receive(con: sqlite3.Connection, handoff_id: str, by: str) -> bool:
34
+ """Acknowledges a report. Returns False if it was already received (or
35
+ doesn't exist), so a receiver can tell a duplicate from a first delivery."""
36
+ cur = con.execute(
37
+ "update handoffs set received_at = ?, received_by = ? where id = ? and received_at is null",
38
+ (now_iso(), by, handoff_id),
39
+ )
40
+ con.commit()
41
+ return cur.rowcount == 1
42
+
43
+
44
+ def pending(con: sqlite3.Connection, to_layer: str) -> list[dict]:
45
+ rows = con.execute(
46
+ "select * from handoffs where to_layer = ? and received_at is null order by produced_at", (to_layer,)
47
+ ).fetchall()
48
+ return [{**dict(r), "report": json.loads(r["report"])} for r in rows]
intel/ingest/__init__.py ADDED
File without changes
intel/ingest/calendar.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nigeria's national calendar as events: federal public holidays, the Islamic
3
+ holidays and Ramadan (computed, so marked is_estimate), and the December festive
4
+ trading season. Generated rather than scraped, so history back to 2000 costs
5
+ nothing — which matters, because the correlation step learns from history.
6
+
7
+ Known limits, kept honest by is_estimate and the design doc:
8
+ - Islamic dates use the Umm al-Qura calendar; Nigeria's dates follow the
9
+ Sultan of Sokoto's moon-sighting announcement and can differ by a day.
10
+ - The Federal Government sometimes declares extra or substitute days (e.g. a
11
+ Monday when a holiday falls on Sunday, or a one-off holiday). Those aren't
12
+ predictable; the news pipeline is where declarations will be caught.
13
+ - School terms vary by state and aren't included yet.
14
+ """
15
+ import sqlite3
16
+ from datetime import date, timedelta
17
+
18
+ from hijridate import Gregorian, Hijri
19
+
20
+ from intel.db import now_iso
21
+ from intel.freshness import record_attempt
22
+ from intel.handoff import DATA_STREAM, INTERFACE, produce
23
+
24
+ SOURCE_ID = "calendar"
25
+ COUNTRY = "ng"
26
+ FIRST_YEAR = 2000
27
+ YEARS_AHEAD = 10
28
+
29
+
30
+ def easter(year: int) -> date:
31
+ """Western Easter Sunday (anonymous Gregorian algorithm)."""
32
+ a, b, c = year % 19, year // 100, year % 100
33
+ d, e = b // 4, b % 4
34
+ f = (b + 8) // 25
35
+ g = (b - f + 1) // 3
36
+ h = (19 * a + b - d - g + 15) % 30
37
+ i, k = c // 4, c % 4
38
+ l = (32 + 2 * e + 2 * i - h - k) % 7
39
+ m = (a + 11 * h + 22 * l) // 451
40
+ month = (h + l - 7 * m + 114) // 31
41
+ day = (h + l - 7 * m + 114) % 31 + 1
42
+ return date(year, month, day)
43
+
44
+
45
+ def _hijri_to_greg(hy: int, hm: int, hd: int) -> date:
46
+ return Hijri(hy, hm, hd).to_gregorian()
47
+
48
+
49
+ def _hijri_years_overlapping(year: int) -> range:
50
+ """The Hijri years whose dates can fall in this Gregorian year (a Hijri year
51
+ is ~11 days shorter, so it's always the one in progress on 1 January and the
52
+ next)."""
53
+ hy = Gregorian(year, 1, 1).to_hijri().year
54
+ return range(hy, hy + 2)
55
+
56
+
57
+ def events_for_year(year: int) -> list[dict]:
58
+ """[{key, type, title, starts_on, ends_on, is_estimate}] for one Gregorian year."""
59
+ out = []
60
+
61
+ def add(key, type_, title, start, end=None, estimate=False):
62
+ if start.year == year or (end and end.year == year):
63
+ out.append({"key": key, "type": type_, "title": title, "starts_on": start, "ends_on": end or start,
64
+ "is_estimate": estimate})
65
+
66
+ add("new_year", "public_holiday", "New Year's Day", date(year, 1, 1))
67
+ gf = easter(year) - timedelta(days=2)
68
+ add("good_friday", "public_holiday", "Good Friday", gf)
69
+ add("easter_monday", "public_holiday", "Easter Monday", gf + timedelta(days=3))
70
+ add("workers_day", "public_holiday", "Workers' Day", date(year, 5, 1))
71
+ # Democracy Day moved from 29 May to 12 June from 2019.
72
+ add("democracy_day", "public_holiday", "Democracy Day", date(year, 6, 12) if year >= 2019 else date(year, 5, 29))
73
+ add("independence_day", "public_holiday", "Independence Day", date(year, 10, 1))
74
+ add("christmas", "public_holiday", "Christmas Day", date(year, 12, 25))
75
+ add("boxing_day", "public_holiday", "Boxing Day", date(year, 12, 26))
76
+ # Festive trading season: VenThrive's own window for the December spending peak.
77
+ add("festive_season", "season", "Festive trading season", date(year, 12, 15), date(year + 1, 1, 5), estimate=True)
78
+
79
+ for hy in _hijri_years_overlapping(year):
80
+ ramadan_start = _hijri_to_greg(hy, 9, 1)
81
+ eid_fitr = _hijri_to_greg(hy, 10, 1)
82
+ add("ramadan", "season", "Ramadan", ramadan_start, eid_fitr - timedelta(days=1), estimate=True)
83
+ add("eid_al_fitr", "religious_holiday", "Eid al-Fitr", eid_fitr, eid_fitr + timedelta(days=1), estimate=True)
84
+ eid_adha = _hijri_to_greg(hy, 12, 10)
85
+ add("eid_al_adha", "religious_holiday", "Eid al-Adha", eid_adha, eid_adha + timedelta(days=1), estimate=True)
86
+ add("mawlid", "religious_holiday", "Eid-el-Maulud (Mawlid)", _hijri_to_greg(hy, 3, 12), estimate=True)
87
+ return out
88
+
89
+
90
+ def ingest(con: sqlite3.Connection, first_year: int = FIRST_YEAR, last_year: int | None = None,
91
+ run_id: str = "manual") -> dict:
92
+ last_year = last_year or date.today().year + YEARS_AHEAD
93
+ now = now_iso()
94
+ n = 0
95
+ try:
96
+ for year in range(first_year, last_year + 1):
97
+ for e in events_for_year(year):
98
+ ref = f"{e['key']}:{e['starts_on'].isoformat()}"
99
+ con.execute(
100
+ """insert into events (id, family, type, location_id, starts_on, ends_on, title, detail,
101
+ is_estimate, source_id, source_ref, ingested_at)
102
+ values (?, 'calendar', ?, ?, ?, ?, ?, null, ?, ?, ?, ?)
103
+ on conflict (source_id, source_ref) do update set title = excluded.title,
104
+ ends_on = excluded.ends_on, is_estimate = excluded.is_estimate""",
105
+ (f"{SOURCE_ID}:{ref}", e["type"], COUNTRY, e["starts_on"].isoformat(), e["ends_on"].isoformat(),
106
+ e["title"], int(e["is_estimate"]), SOURCE_ID, ref, now),
107
+ )
108
+ n += 1
109
+ con.commit()
110
+ except Exception as e:
111
+ record_attempt(con, SOURCE_ID, ok=False, error=str(e))
112
+ raise
113
+ record_attempt(con, SOURCE_ID, ok=True, latest_data_period=f"{last_year}-12-31")
114
+ summary = {"years": f"{first_year}-{last_year}", "events": n}
115
+ produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "calendar", **summary})
116
+ return summary
intel/ingest/overture_extract.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One-time (per Overture release) extract of Nigeria's places from Overture's public
3
+ S3 parquet into a local parquet file. ingest/places.py then loads that file into
4
+ the intel store — the slow network read happens once, not on every ingest.
5
+
6
+ py -m intel.ingest.overture_extract [--release 2026-09-23.1]
7
+
8
+ Filtered by Nigeria's bounding box only; the exact country boundary is applied at
9
+ ingest (point-in-LGA-polygon), which also drops the bits of Benin/Cameroon/Niger the
10
+ box catches.
11
+ """
12
+ import argparse
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import duckdb
17
+
18
+ DEFAULT_RELEASE = "2026-09-23.1"
19
+ # Nigeria's extent with a small margin (lon 2.67–14.68, lat 4.27–13.89).
20
+ BBOX = {"xmin": 2.6, "xmax": 14.7, "ymin": 4.2, "ymax": 13.95}
21
+ RAW_DIR = Path(__file__).resolve().parent.parent / "data" / "raw"
22
+
23
+
24
+ def extract_path(release: str) -> Path:
25
+ return RAW_DIR / f"overture_places_ng_{release}.parquet"
26
+
27
+
28
+ def extract(release: str = DEFAULT_RELEASE) -> Path:
29
+ out = extract_path(release)
30
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
31
+ con = duckdb.connect()
32
+ con.sql("INSTALL spatial; LOAD spatial; INSTALL httpfs; LOAD httpfs; SET s3_region='us-west-2';")
33
+ src = f"s3://overturemaps-us-west-2/release/{release}/theme=places/type=place/*"
34
+ started = time.time()
35
+ con.sql(f"""
36
+ COPY (
37
+ SELECT
38
+ id,
39
+ names.primary AS name,
40
+ basic_category,
41
+ taxonomy.primary AS taxonomy_primary,
42
+ array_to_string(taxonomy.hierarchy, '>') AS taxonomy_hierarchy,
43
+ confidence,
44
+ operating_status,
45
+ ST_X(geometry) AS lon,
46
+ ST_Y(geometry) AS lat,
47
+ addresses[1].locality AS locality,
48
+ addresses[1].region AS region,
49
+ addresses[1].country AS country
50
+ FROM read_parquet('{src}', hive_partitioning=1)
51
+ WHERE bbox.xmin BETWEEN {BBOX['xmin']} AND {BBOX['xmax']}
52
+ AND bbox.ymin BETWEEN {BBOX['ymin']} AND {BBOX['ymax']}
53
+ ) TO '{out.as_posix()}' (FORMAT PARQUET)
54
+ """)
55
+ n = con.sql(f"SELECT count(*) FROM read_parquet('{out.as_posix()}')").fetchone()[0]
56
+ print(f"Extracted {n} places to {out} in {time.time() - started:.0f}s")
57
+ return out
58
+
59
+
60
+ if __name__ == "__main__":
61
+ p = argparse.ArgumentParser()
62
+ p.add_argument("--release", default=DEFAULT_RELEASE)
63
+ extract(p.parse_args().release)
intel/ingest/places.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Loads the local Overture extract (overture_extract.py) into `places`: assigns
3
+ each place to the LGA it falls in (which also drops the Benin/Cameroon/Niger
4
+ points the bounding box caught), maps its category to a subsector, then derives
5
+ locality aliases for the gazetteer.
6
+
7
+ A release replaces the previous one wholesale — places close and reopen, and
8
+ Overture ids are stable across releases, so a full refresh is simpler and more
9
+ honest than trying to diff.
10
+ """
11
+ import sqlite3
12
+
13
+ import duckdb
14
+
15
+ from intel.freshness import record_attempt
16
+ from intel.geo import PolygonIndex
17
+ from intel.handoff import DATA_STREAM, INTERFACE, produce
18
+ from intel.ingest.overture_extract import DEFAULT_RELEASE, extract_path
19
+ from intel.locations import add_place_localities, lga_polygons
20
+ from intel.subsector_map import subsector_for
21
+
22
+ SOURCE_ID = "overture_places"
23
+
24
+
25
+ def ingest(con: sqlite3.Connection, release: str = DEFAULT_RELEASE, run_id: str = "manual") -> dict:
26
+ path = extract_path(release)
27
+ try:
28
+ if not path.exists():
29
+ raise FileNotFoundError(f"{path} missing — run `py -m intel.ingest.overture_extract --release {release}` first")
30
+ rows = duckdb.connect().sql(
31
+ f"""select id, name, basic_category, taxonomy_primary, taxonomy_hierarchy, confidence,
32
+ operating_status, lon, lat, locality
33
+ from read_parquet('{path.as_posix()}') where lon is not null and lat is not null"""
34
+ ).fetchall()
35
+
36
+ index = PolygonIndex(lga_polygons())
37
+ con.execute("delete from places where source_id = ?", (SOURCE_ID,))
38
+ kept = outside = mapped = 0
39
+ batch = []
40
+ for pid, name, basic, tax, hier, conf, status, lon, lat, locality in rows:
41
+ lga = index.lookup(lon, lat)
42
+ if lga is None:
43
+ outside += 1
44
+ continue
45
+ sub = subsector_for(basic, tax)
46
+ mapped += sub is not None
47
+ batch.append((pid, name, basic, hier, sub, conf, status, lat, lon, lga, locality, SOURCE_ID, release))
48
+ kept += 1
49
+ con.executemany(
50
+ """insert into places (id, name, category, taxonomy, subsector, confidence, operating_status,
51
+ lat, lon, location_id, locality, source_id, release)
52
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
53
+ batch,
54
+ )
55
+ con.commit()
56
+ aliases = add_place_localities(con)
57
+ except Exception as e:
58
+ record_attempt(con, SOURCE_ID, ok=False, error=str(e))
59
+ raise
60
+
61
+ record_attempt(con, SOURCE_ID, ok=True, latest_data_period=release.split(".")[0])
62
+ summary = {"release": release, "places": kept, "outside_nigeria": outside,
63
+ "with_subsector": mapped, "locality_aliases_added": aliases}
64
+ produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "places", **summary})
65
+ return summary
intel/ingest/weather.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Weather history and forecasts from Open-Meteo, at state centres (Phase 1's
3
+ native level — LGA-level history is the same code with level='lga', paced over
4
+ more days of quota).
5
+
6
+ Quota is the real constraint, not code: Open-Meteo counts a request covering
7
+ more than 2 weeks or more than 10 variables as several calls (weight ≈ days/14 ×
8
+ max(1, vars/10)), and the free tier allows 600/min, 5,000/hour, 10,000/day. 20
9
+ years × 37 states is ~19k weighted calls, so history is a *resumable backfill*:
10
+ each run fetches whole missing years, oldest first, until it hits its budget,
11
+ and the next run carries on where it stopped. A 429 ends the run cleanly as a
12
+ partial success rather than an error.
13
+
14
+ The free tier is for non-commercial use — fine for local Phase 1 development; a
15
+ paid plan (or ECMWF open data direct) is needed before this runs for a live
16
+ product (design doc, "Conflicts").
17
+ """
18
+ import math
19
+ import sqlite3
20
+ import time
21
+ from datetime import date, timedelta
22
+ from typing import Callable
23
+
24
+ import httpx
25
+
26
+ from intel.db import now_iso
27
+ from intel.freshness import record_attempt
28
+ from intel.handoff import DATA_STREAM, INTERFACE, produce
29
+ from intel.registry import WEATHER_VARIABLES
30
+
31
+ ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
32
+ FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
33
+ VARIABLES = [v for v, _ in WEATHER_VARIABLES]
34
+ DEFAULT_HISTORY_START = date(2006, 1, 1)
35
+ # The reanalysis archive trails real time by about 5 days.
36
+ ARCHIVE_LAG_DAYS = 6
37
+ PER_MINUTE_BUDGET = 500 # below Open-Meteo's 600/min, leaving headroom
38
+ DEFAULT_RUN_BUDGET = 4500 # below the 5,000/hour limit
39
+
40
+ Fetch = Callable[[str, dict], dict]
41
+
42
+
43
+ class QuotaExhausted(Exception):
44
+ pass
45
+
46
+
47
+ def call_weight(days: int, n_vars: int = len(VARIABLES)) -> float:
48
+ return max(1.0, days / 14) * max(1.0, n_vars / 10)
49
+
50
+
51
+ def http_fetch(url: str, params: dict) -> dict:
52
+ r = httpx.get(url, params=params, timeout=60.0)
53
+ if r.status_code == 429:
54
+ raise QuotaExhausted(r.text[:200])
55
+ r.raise_for_status()
56
+ return r.json()
57
+
58
+
59
+ def _points(con: sqlite3.Connection, level: str) -> list[sqlite3.Row]:
60
+ return con.execute("select id, lat, lon from locations where level = ? order by id", (level,)).fetchall()
61
+
62
+
63
+ def _store(con: sqlite3.Connection, location_id: str, payload: dict, is_forecast: bool, issued_at: str) -> int:
64
+ daily = payload["daily"]
65
+ now = now_iso()
66
+ rows = []
67
+ for var in VARIABLES:
68
+ for day, value in zip(daily["time"], daily.get(var, [])):
69
+ if value is not None:
70
+ rows.append((f"weather.{var}", location_id, day, int(is_forecast), issued_at, float(value), now))
71
+ con.executemany(
72
+ """insert into observations (stream_id, location_id, period_start, is_forecast, issued_at, value, ingested_at)
73
+ values (?, ?, ?, ?, ?, ?, ?)
74
+ on conflict (stream_id, location_id, period_start, is_forecast, issued_at)
75
+ do update set value = excluded.value, ingested_at = excluded.ingested_at""",
76
+ rows,
77
+ )
78
+ con.commit()
79
+ return len(rows)
80
+
81
+
82
+ def _missing_years(con: sqlite3.Connection, location_id: str, start: date, end: date) -> list[tuple[date, date]]:
83
+ """Calendar-year spans (clipped to [start, end]) still missing rows for the
84
+ first variable. A year that's complete from its first day up to some date
85
+ (the current year, typically) only resumes from the day after; any other
86
+ gap refetches the whole year — simpler than tracking holes, and rare."""
87
+ have = {r[0]: (r[1], r[2]) for r in con.execute(
88
+ """select substr(period_start, 1, 4) y, count(*), max(period_start) from observations
89
+ where stream_id = ? and location_id = ? and is_forecast = 0 group by y""",
90
+ (f"weather.{VARIABLES[0]}", location_id),
91
+ )}
92
+ spans = []
93
+ for y in range(start.year, end.year + 1):
94
+ a, b = max(start, date(y, 1, 1)), min(end, date(y, 12, 31))
95
+ count, last = have.get(str(y), (0, None))
96
+ if count >= (b - a).days + 1:
97
+ continue
98
+ if last and count == (date.fromisoformat(last) - a).days + 1:
99
+ a = date.fromisoformat(last) + timedelta(days=1)
100
+ spans.append((a, b))
101
+ return spans
102
+
103
+
104
+ def backfill_history(con: sqlite3.Connection, level: str = "state", start: date = DEFAULT_HISTORY_START,
105
+ budget: float = DEFAULT_RUN_BUDGET, fetch: Fetch = http_fetch,
106
+ sleep: Callable[[float], None] = time.sleep, run_id: str = "manual") -> dict:
107
+ end = date.today() - timedelta(days=ARCHIVE_LAG_DAYS)
108
+ todo = [(p, a, b) for p in _points(con, level) for a, b in _missing_years(con, p["id"], start, end)]
109
+ todo.sort(key=lambda t: (t[1], t[0]["id"])) # oldest years first, across all points
110
+
111
+ spent = minute_spent = 0.0
112
+ minute_started = time.monotonic()
113
+ fetched = rows = 0
114
+ stopped = None
115
+ try:
116
+ for p, a, b in todo:
117
+ w = call_weight((b - a).days + 1)
118
+ if spent + w > budget:
119
+ stopped = "run budget reached"
120
+ break
121
+ if minute_spent + w > PER_MINUTE_BUDGET:
122
+ sleep(max(0.0, 60 - (time.monotonic() - minute_started)))
123
+ minute_spent, minute_started = 0.0, time.monotonic()
124
+ payload = fetch(ARCHIVE_URL, {
125
+ "latitude": p["lat"], "longitude": p["lon"], "start_date": a.isoformat(), "end_date": b.isoformat(),
126
+ "daily": ",".join(VARIABLES), "timezone": "Africa/Lagos",
127
+ })
128
+ rows += _store(con, p["id"], payload, is_forecast=False, issued_at="")
129
+ spent += w
130
+ minute_spent += w
131
+ fetched += 1
132
+ except QuotaExhausted as e:
133
+ stopped = f"Open-Meteo quota: {e}"
134
+ except Exception as e:
135
+ record_attempt(con, "open_meteo_archive", ok=False, error=str(e))
136
+ raise
137
+
138
+ newest = con.execute(
139
+ "select max(period_start) from observations where stream_id like 'weather.%' and is_forecast = 0"
140
+ ).fetchone()[0]
141
+ remaining = len(todo) - fetched
142
+ if fetched or not todo:
143
+ record_attempt(con, "open_meteo_archive", ok=True, latest_data_period=newest)
144
+ else:
145
+ record_attempt(con, "open_meteo_archive", ok=False, error=stopped)
146
+ summary = {"level": level, "requests": fetched, "weighted_calls": round(spent, 1), "rows": rows,
147
+ "year_chunks_remaining": remaining, "stopped": stopped, "newest_day": newest}
148
+ if fetched:
149
+ produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "weather_history", **summary})
150
+ return summary
151
+
152
+
153
+ def fetch_forecast(con: sqlite3.Connection, level: str = "state", days: int = 16,
154
+ fetch: Fetch = http_fetch, run_id: str = "manual") -> dict:
155
+ issued = date.today().isoformat()
156
+ rows = n = 0
157
+ try:
158
+ for p in _points(con, level):
159
+ payload = fetch(FORECAST_URL, {
160
+ "latitude": p["lat"], "longitude": p["lon"], "forecast_days": days,
161
+ "daily": ",".join(VARIABLES), "timezone": "Africa/Lagos",
162
+ })
163
+ rows += _store(con, p["id"], payload, is_forecast=True, issued_at=issued)
164
+ n += 1
165
+ except Exception as e:
166
+ record_attempt(con, "open_meteo_forecast", ok=False, error=str(e))
167
+ raise
168
+ record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=issued)
169
+ summary = {"level": level, "points": n, "rows": rows, "issued_at": issued,
170
+ "weighted_calls": round(n * call_weight(days), 1)}
171
+ produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "weather_forecast", **summary})
172
+ return summary
173
+
174
+
175
+ def estimate_backfill_calls(con: sqlite3.Connection, level: str = "state", start: date = DEFAULT_HISTORY_START) -> float:
176
+ end = date.today() - timedelta(days=ARCHIVE_LAG_DAYS)
177
+ return math.fsum(call_weight((b - a).days + 1)
178
+ for p in _points(con, level) for a, b in _missing_years(con, p["id"], start, end))
intel/locations.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The gazetteer: country → state → LGA (→ town, later), each with a centre point,
3
+ plus an alias table that turns free text ("flooding in Ajah", "Port Harcourt
4
+ traders") into a location id.
5
+
6
+ State and LGA *names* come from the app's own list (nigeria_locations.json,
7
+ exported from data/nigeria-locations.ts) so they're exactly what users pick in
8
+ onboarding. *Shapes* come from geoBoundaries, whose LGA spellings differ ("Ado
9
+ Odo/Ota" vs "Ado-Odo/Ota") and whose ADM2 file carries no state, so each
10
+ boundary LGA is first placed in a state by point-in-polygon, then matched to the
11
+ app's name within that state.
12
+
13
+ resolve() is deliberately simple — exact alias matching on word boundaries,
14
+ with state context used to disambiguate LGA names that exist in several states
15
+ (Surulere, Obi, Bassa, Ifelodun, ...). It's the seed of the Layer 5 search
16
+ service, not a replacement for it.
17
+ """
18
+ import difflib
19
+ import json
20
+ import re
21
+ import sqlite3
22
+ from pathlib import Path
23
+
24
+ from intel.freshness import record_attempt
25
+ from intel.geo import PolygonIndex, centroid
26
+
27
+ HERE = Path(__file__).resolve().parent
28
+ STATES_JSON = HERE / "nigeria_locations.json"
29
+ RAW_DIR = HERE / "data" / "raw"
30
+ ADM1_PATH = RAW_DIR / "geoBoundaries-NGA-ADM1_simplified.geojson"
31
+ ADM2_PATH = RAW_DIR / "geoBoundaries-NGA-ADM2_simplified.geojson"
32
+
33
+ COUNTRY_ID = "ng"
34
+ FCT_APP_NAME = "FCT (Abuja)"
35
+ ADM1_NAME_TO_APP = {"Abuja Federal Capital Territory": FCT_APP_NAME}
36
+
37
+ # Boundary-file LGA spellings that normalise differently from the app's name and
38
+ # are too far apart for fuzzy matching to pair safely. Keyed by
39
+ # (app state, normalised boundary name) → app LGA name.
40
+ BOUNDARY_LGA_TO_APP: dict[tuple[str, str], str] = {
41
+ (FCT_APP_NAME, "municipal area council"): "Abuja Municipal (AMAC)",
42
+ }
43
+
44
+ # Common place names in news text that aren't state or LGA names. Kept short and
45
+ # only for unambiguous cases; place-data localities add many more automatically.
46
+ CURATED_ALIASES = {
47
+ "abuja": "ng.fct",
48
+ "lekki": "ng.lagos.eti-osa",
49
+ "ajah": "ng.lagos.eti-osa",
50
+ "victoria island": "ng.lagos.eti-osa",
51
+ "yaba": "ng.lagos.lagos-mainland",
52
+ "ibadan": "ng.oyo",
53
+ "benin city": "ng.edo.oredo",
54
+ "onitsha": "ng.anambra",
55
+ "aba": "ng.abia",
56
+ "warri": "ng.delta",
57
+ "jos": "ng.plateau",
58
+ "owerri": "ng.imo",
59
+ "calabar": "ng.cross-river",
60
+ "ilorin": "ng.kwara",
61
+ "enugu": "ng.enugu",
62
+ "kano": "ng.kano",
63
+ # National bodies: a policy from these applies to the whole country.
64
+ "federal government": "ng",
65
+ "fg": "ng",
66
+ "cbn": "ng",
67
+ "central bank of nigeria": "ng",
68
+ "firs": "ng",
69
+ "national assembly": "ng",
70
+ "nigerian": "ng",
71
+ }
72
+
73
+
74
+ def normalise(name: str) -> str:
75
+ s = name.lower().replace("&", " and ")
76
+ s = re.sub(r"\(.*?\)", " ", s)
77
+ s = re.sub(r"[^a-z0-9]+", " ", s)
78
+ return re.sub(r"\s+", " ", s).strip()
79
+
80
+
81
+ def _key(name: str) -> str:
82
+ return normalise(name).replace(" ", "")
83
+
84
+
85
+ def slug(name: str) -> str:
86
+ return normalise(name).replace(" ", "-")
87
+
88
+
89
+ def state_id(app_state: str) -> str:
90
+ return f"{COUNTRY_ID}.fct" if app_state == FCT_APP_NAME else f"{COUNTRY_ID}.{slug(app_state)}"
91
+
92
+
93
+ def lga_id(app_state: str, lga: str) -> str:
94
+ return f"{state_id(app_state)}.{slug(lga)}"
95
+
96
+
97
+ def _load_geojson(path: Path) -> list[dict]:
98
+ return json.loads(path.read_text(encoding="utf8"))["features"]
99
+
100
+
101
+ def match_boundaries(states: list[dict], adm1: list[dict], adm2: list[dict]) -> tuple[dict, list[str]]:
102
+ """Returns ({(app_state, app_lga): adm2 feature}, problems). Every app LGA
103
+ must end up with exactly one boundary — anything else is reported, not
104
+ guessed."""
105
+ app_states = {s["name"] for s in states}
106
+ state_index = PolygonIndex([
107
+ (ADM1_NAME_TO_APP.get(f["properties"]["shapeName"], f["properties"]["shapeName"]), f["geometry"]) for f in adm1
108
+ ])
109
+ unknown = {k for k, _, _ in state_index._items} - app_states
110
+ if unknown:
111
+ raise ValueError(f"Boundary states not in the app list: {sorted(unknown)}")
112
+
113
+ by_state: dict[str, list[dict]] = {}
114
+ problems = []
115
+ for f in adm2:
116
+ lon, lat = centroid(f["geometry"])
117
+ st = state_index.lookup(lon, lat)
118
+ if st is None:
119
+ problems.append(f"boundary LGA {f['properties']['shapeName']!r}: centre falls in no state")
120
+ continue
121
+ by_state.setdefault(st, []).append(f)
122
+
123
+ matched: dict[tuple[str, str], dict] = {}
124
+ for s in states:
125
+ st, wanted = s["name"], list(s["lgas"])
126
+ pool = list(by_state.get(st, []))
127
+
128
+ def take(app_lga: str, feature: dict) -> None:
129
+ matched[(st, app_lga)] = feature
130
+ wanted.remove(app_lga)
131
+ pool.remove(feature)
132
+
133
+ for f in list(pool):
134
+ k = _key(f["properties"]["shapeName"])
135
+ curated = BOUNDARY_LGA_TO_APP.get((st, normalise(f["properties"]["shapeName"])))
136
+ exact = [w for w in wanted if _key(w) == k]
137
+ if curated and curated in wanted:
138
+ take(curated, f)
139
+ elif len(exact) == 1:
140
+ take(exact[0], f)
141
+
142
+ # Fuzzy pass for what's left, one-to-one, best pairs first.
143
+ pairs = sorted(
144
+ ((difflib.SequenceMatcher(None, _key(w), _key(f["properties"]["shapeName"])).ratio(), w, f)
145
+ for w in wanted for f in pool),
146
+ key=lambda p: -p[0],
147
+ )
148
+ for ratio, w, f in pairs:
149
+ if ratio < 0.6:
150
+ break
151
+ if w in wanted and f in pool:
152
+ take(w, f)
153
+
154
+ for w in wanted:
155
+ problems.append(f"{st}: app LGA {w!r} has no boundary")
156
+ for f in pool:
157
+ problems.append(f"{st}: boundary LGA {f['properties']['shapeName']!r} matched no app LGA")
158
+ return matched, problems
159
+
160
+
161
+ def _add_alias(con: sqlite3.Connection, alias: str, location_id: str, origin: str) -> None:
162
+ alias = normalise(alias)
163
+ if alias:
164
+ con.execute("insert or ignore into location_aliases (alias, location_id, origin) values (?, ?, ?)",
165
+ (alias, location_id, origin))
166
+
167
+
168
+ def build(con: sqlite3.Connection) -> dict:
169
+ """(Re)builds country/state/LGA rows and their aliases. Idempotent."""
170
+ states = json.loads(STATES_JSON.read_text(encoding="utf8"))
171
+ adm1, adm2 = _load_geojson(ADM1_PATH), _load_geojson(ADM2_PATH)
172
+ matched, problems = match_boundaries(states, adm1, adm2)
173
+ if problems:
174
+ raise ValueError("Boundary matching failed:\n" + "\n".join(problems))
175
+
176
+ def upsert(lid, level, name, parent, lon, lat):
177
+ con.execute(
178
+ """insert into locations (id, level, name, parent_id, lat, lon, source) values (?, ?, ?, ?, ?, ?, 'geoboundaries')
179
+ on conflict (id) do update set level = excluded.level, name = excluded.name,
180
+ parent_id = excluded.parent_id, lat = excluded.lat, lon = excluded.lon""",
181
+ (lid, level, name, parent, lat, lon),
182
+ )
183
+
184
+ country = {"type": "MultiPolygon", "coordinates": []}
185
+ for f in adm1:
186
+ g = f["geometry"]
187
+ country["coordinates"] += [g["coordinates"]] if g["type"] == "Polygon" else g["coordinates"]
188
+ upsert(COUNTRY_ID, "country", "Nigeria", None, *centroid(country))
189
+ _add_alias(con, "Nigeria", COUNTRY_ID, "gazetteer")
190
+
191
+ adm1_by_app = {ADM1_NAME_TO_APP.get(f["properties"]["shapeName"], f["properties"]["shapeName"]): f for f in adm1}
192
+ for s in states:
193
+ sid = state_id(s["name"])
194
+ upsert(sid, "state", s["name"], COUNTRY_ID, *centroid(adm1_by_app[s["name"]]["geometry"]))
195
+ _add_alias(con, s["name"], sid, "gazetteer")
196
+ _add_alias(con, f"{s['name']} state", sid, "gazetteer")
197
+ _add_alias(con, adm1_by_app[s["name"]]["properties"]["shapeName"], sid, "boundary")
198
+ for lga in s["lgas"]:
199
+ lid = lga_id(s["name"], lga)
200
+ f = matched[(s["name"], lga)]
201
+ upsert(lid, "lga", lga, sid, *centroid(f["geometry"]))
202
+ _add_alias(con, lga, lid, "gazetteer")
203
+ _add_alias(con, f["properties"]["shapeName"], lid, "boundary")
204
+ # "Abuja Municipal (AMAC)" → also findable as "AMAC".
205
+ for inner in re.findall(r"\((.*?)\)", lga):
206
+ _add_alias(con, inner, lid, "gazetteer")
207
+
208
+ _add_alias(con, "FCT", state_id(FCT_APP_NAME), "gazetteer")
209
+ for alias, lid in CURATED_ALIASES.items():
210
+ _add_alias(con, alias, lid, "curated")
211
+ con.commit()
212
+ # The boundary files are downloaded by hand (backend/intel/README.md); the
213
+ # build is what proves they're present and usable, so it reports to Layer 1.
214
+ # No data period: boundaries have no "newest day", only the fetch clock.
215
+ record_attempt(con, "geoboundaries", ok=True)
216
+ n = con.execute("select level, count(*) from locations group by level").fetchall()
217
+ return {r[0]: r[1] for r in n}
218
+
219
+
220
+ def lga_polygons() -> list[tuple[str, dict]]:
221
+ """(lga id, geometry) for point-in-LGA lookups (places ingest)."""
222
+ states = json.loads(STATES_JSON.read_text(encoding="utf8"))
223
+ matched, problems = match_boundaries(states, _load_geojson(ADM1_PATH), _load_geojson(ADM2_PATH))
224
+ if problems:
225
+ raise ValueError("Boundary matching failed:\n" + "\n".join(problems))
226
+ return [(lga_id(st, lga), f["geometry"]) for (st, lga), f in matched.items()]
227
+
228
+
229
+ def _alias_rows(con: sqlite3.Connection) -> list[sqlite3.Row]:
230
+ """alias, location id, its level, and the state it belongs to (itself for a state)."""
231
+ return con.execute(
232
+ """select a.alias, l.id, l.level,
233
+ case when l.level = 'state' then l.id when p.level = 'state' then p.id end as state
234
+ from location_aliases a join locations l on l.id = a.location_id
235
+ left join locations p on p.id = l.parent_id"""
236
+ ).fetchall()
237
+
238
+
239
+ def resolve(con: sqlite3.Connection, text: str | None) -> str | None:
240
+ """Best location id mentioned in text, or None when there's no mention or
241
+ the mention is genuinely ambiguous. Prefers the most specific level, with
242
+ two rules:
243
+ - a name that is both a state and an LGA ("Bauchi", "Ekiti") means the
244
+ state — that's how people write;
245
+ - an LGA name shared by several states ("Surulere") only resolves when the
246
+ text also names one of those states; otherwise it falls back to their
247
+ common state if they share one, else None.
248
+ """
249
+ if not text:
250
+ return None
251
+ hay = f" {normalise(text)} "
252
+ hits = [r for r in _alias_rows(con) if f" {r['alias']} " in hay]
253
+ state_aliases = {r["alias"] for r in hits if r["level"] == "state"}
254
+ hits = [r for r in hits if not (r["level"] in ("lga", "town") and r["alias"] in state_aliases)]
255
+ named_states = {r["id"] for r in hits if r["level"] == "state"}
256
+
257
+ for level in ("town", "lga"):
258
+ cands = {(r["id"], r["state"]) for r in hits if r["level"] == level}
259
+ if not cands:
260
+ continue
261
+ narrowed = {c for c in cands if c[1] in named_states}
262
+ cands = narrowed or cands
263
+ if len(cands) == 1:
264
+ return next(iter(cands))[0]
265
+ states = {st for _, st in cands}
266
+ return next(iter(states)) if len(states) == 1 else None
267
+
268
+ if len(named_states) == 1:
269
+ return next(iter(named_states))
270
+ if named_states:
271
+ return None
272
+ return COUNTRY_ID if any(r["level"] == "country" for r in hits) else None
273
+
274
+
275
+ def add_place_localities(con: sqlite3.Connection, min_places: int = 20, share: float = 0.8) -> int:
276
+ """Turns place-data localities into aliases, by majority vote: a locality
277
+ string with enough places, most of them inside one LGA, becomes an alias of
278
+ that LGA; failing that, of their common state. Data-derived, so "Ikorodu" or
279
+ "Agbara" become resolvable without anyone typing them in."""
280
+ rows = con.execute(
281
+ """select lower(trim(locality)) loc, location_id, count(*) n from places
282
+ where locality is not null and location_id is not null group by 1, 2"""
283
+ ).fetchall()
284
+ by_loc: dict[str, list[tuple[str, int]]] = {}
285
+ for r in rows:
286
+ by_loc.setdefault(r["loc"], []).append((r["location_id"], r["n"]))
287
+ added = 0
288
+ for loc, counts in by_loc.items():
289
+ total = sum(n for _, n in counts)
290
+ if total < min_places or not normalise(loc):
291
+ continue
292
+ best, n = max(counts, key=lambda c: c[1])
293
+ if n / total >= share:
294
+ target = best
295
+ else:
296
+ state_counts: dict[str, int] = {}
297
+ for lid, c in counts:
298
+ state_counts[lid.rsplit(".", 1)[0]] = state_counts.get(lid.rsplit(".", 1)[0], 0) + c
299
+ st, sn = max(state_counts.items(), key=lambda c: c[1])
300
+ if sn / total < share:
301
+ continue
302
+ target = st
303
+ # Never let a derived alias shadow a gazetteer name (e.g. "Lagos" the
304
+ # locality must keep meaning Lagos State, not whichever LGA dominates).
305
+ if con.execute("select 1 from location_aliases where alias = ? and origin != 'places'", (normalise(loc),)).fetchone():
306
+ continue
307
+ _add_alias(con, loc, target, "places")
308
+ added += 1
309
+ con.commit()
310
+ return added
intel/nigeria_locations.json ADDED
@@ -0,0 +1,961 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "name": "Abia",
4
+ "lgas": [
5
+ "Aba North",
6
+ "Aba South",
7
+ "Arochukwu",
8
+ "Bende",
9
+ "Ikwuano",
10
+ "Isiala Ngwa North",
11
+ "Isiala Ngwa South",
12
+ "Isuikwuato",
13
+ "Obi Ngwa",
14
+ "Ohafia",
15
+ "Osisioma",
16
+ "Ugwunagbo",
17
+ "Ukwa East",
18
+ "Ukwa West",
19
+ "Umuahia North",
20
+ "Umuahia South",
21
+ "Umu Nneochi"
22
+ ]
23
+ },
24
+ {
25
+ "name": "Adamawa",
26
+ "lgas": [
27
+ "Demsa",
28
+ "Fufore",
29
+ "Ganye",
30
+ "Girei",
31
+ "Gombi",
32
+ "Guyuk",
33
+ "Hong",
34
+ "Jada",
35
+ "Lamurde",
36
+ "Madagali",
37
+ "Maiha",
38
+ "Mayo-Belwa",
39
+ "Michika",
40
+ "Mubi North",
41
+ "Mubi South",
42
+ "Numan",
43
+ "Shelleng",
44
+ "Song",
45
+ "Toungo",
46
+ "Yola North",
47
+ "Yola South"
48
+ ]
49
+ },
50
+ {
51
+ "name": "Akwa Ibom",
52
+ "lgas": [
53
+ "Abak",
54
+ "Eastern Obolo",
55
+ "Eket",
56
+ "Esit Eket",
57
+ "Essien Udim",
58
+ "Etim Ekpo",
59
+ "Etinan",
60
+ "Ibeno",
61
+ "Ibesikpo Asutan",
62
+ "Ibiono-Ibom",
63
+ "Ika",
64
+ "Ikono",
65
+ "Ikot Abasi",
66
+ "Ikot Ekpene",
67
+ "Ini",
68
+ "Itu",
69
+ "Mbo",
70
+ "Mkpat-Enin",
71
+ "Nsit-Atai",
72
+ "Nsit-Ibom",
73
+ "Nsit-Ubium",
74
+ "Obot Akara",
75
+ "Okobo",
76
+ "Onna",
77
+ "Oron",
78
+ "Oruk Anam",
79
+ "Ukanafun",
80
+ "Udung-Uko",
81
+ "Uruan",
82
+ "Urue-Offong/Oruko",
83
+ "Uyo"
84
+ ]
85
+ },
86
+ {
87
+ "name": "Anambra",
88
+ "lgas": [
89
+ "Aguata",
90
+ "Anambra East",
91
+ "Anambra West",
92
+ "Anaocha",
93
+ "Awka North",
94
+ "Awka South",
95
+ "Ayamelum",
96
+ "Dunukofia",
97
+ "Ekwusigo",
98
+ "Idemili North",
99
+ "Idemili South",
100
+ "Ihiala",
101
+ "Njikoka",
102
+ "Nnewi North",
103
+ "Nnewi South",
104
+ "Ogbaru",
105
+ "Onitsha North",
106
+ "Onitsha South",
107
+ "Orumba North",
108
+ "Orumba South",
109
+ "Oyi"
110
+ ]
111
+ },
112
+ {
113
+ "name": "Bauchi",
114
+ "lgas": [
115
+ "Alkaleri",
116
+ "Bauchi",
117
+ "Bogoro",
118
+ "Dambam",
119
+ "Darazo",
120
+ "Dass",
121
+ "Gamawa",
122
+ "Ganjuwa",
123
+ "Giade",
124
+ "Itas/Gadau",
125
+ "Jama'are",
126
+ "Katagum",
127
+ "Kirfi",
128
+ "Misau",
129
+ "Ningi",
130
+ "Shira",
131
+ "Tafawa Balewa",
132
+ "Toro",
133
+ "Warji",
134
+ "Zaki"
135
+ ]
136
+ },
137
+ {
138
+ "name": "Bayelsa",
139
+ "lgas": [
140
+ "Brass",
141
+ "Ekeremor",
142
+ "Kolokuma/Opokuma",
143
+ "Nembe",
144
+ "Ogbia",
145
+ "Sagbama",
146
+ "Southern Ijaw",
147
+ "Yenagoa"
148
+ ]
149
+ },
150
+ {
151
+ "name": "Benue",
152
+ "lgas": [
153
+ "Ado",
154
+ "Agatu",
155
+ "Apa",
156
+ "Buruku",
157
+ "Gboko",
158
+ "Guma",
159
+ "Gwer East",
160
+ "Gwer West",
161
+ "Katsina-Ala",
162
+ "Konshisha",
163
+ "Kwande",
164
+ "Logo",
165
+ "Makurdi",
166
+ "Obi",
167
+ "Ogbadibo",
168
+ "Ohimini",
169
+ "Oju",
170
+ "Okpokwu",
171
+ "Otukpo",
172
+ "Tarka",
173
+ "Ukum",
174
+ "Ushongo",
175
+ "Vandeikya"
176
+ ]
177
+ },
178
+ {
179
+ "name": "Borno",
180
+ "lgas": [
181
+ "Abadam",
182
+ "Askira/Uba",
183
+ "Bama",
184
+ "Bayo",
185
+ "Biu",
186
+ "Chibok",
187
+ "Damboa",
188
+ "Dikwa",
189
+ "Gubio",
190
+ "Guzamala",
191
+ "Gwoza",
192
+ "Hawul",
193
+ "Jere",
194
+ "Kaga",
195
+ "Kala/Balge",
196
+ "Konduga",
197
+ "Kukawa",
198
+ "Kwaya Kusar",
199
+ "Mafa",
200
+ "Magumeri",
201
+ "Maiduguri",
202
+ "Marte",
203
+ "Mobbar",
204
+ "Monguno",
205
+ "Ngala",
206
+ "Nganzai",
207
+ "Shani"
208
+ ]
209
+ },
210
+ {
211
+ "name": "Cross River",
212
+ "lgas": [
213
+ "Abi",
214
+ "Akamkpa",
215
+ "Akpabuyo",
216
+ "Bakassi",
217
+ "Bekwarra",
218
+ "Biase",
219
+ "Boki",
220
+ "Calabar Municipal",
221
+ "Calabar South",
222
+ "Etung",
223
+ "Ikom",
224
+ "Obanliku",
225
+ "Obubra",
226
+ "Obudu",
227
+ "Odukpani",
228
+ "Ogoja",
229
+ "Yakurr",
230
+ "Yala"
231
+ ]
232
+ },
233
+ {
234
+ "name": "Delta",
235
+ "lgas": [
236
+ "Aniocha North",
237
+ "Aniocha South",
238
+ "Bomadi",
239
+ "Burutu",
240
+ "Ethiope East",
241
+ "Ethiope West",
242
+ "Ika North East",
243
+ "Ika South",
244
+ "Isoko North",
245
+ "Isoko South",
246
+ "Ndokwa East",
247
+ "Ndokwa West",
248
+ "Okpe",
249
+ "Oshimili North",
250
+ "Oshimili South",
251
+ "Patani",
252
+ "Sapele",
253
+ "Udu",
254
+ "Ughelli North",
255
+ "Ughelli South",
256
+ "Ukwuani",
257
+ "Uvwie",
258
+ "Warri North",
259
+ "Warri South",
260
+ "Warri South West"
261
+ ]
262
+ },
263
+ {
264
+ "name": "Ebonyi",
265
+ "lgas": [
266
+ "Abakaliki",
267
+ "Afikpo North",
268
+ "Afikpo South",
269
+ "Ebonyi",
270
+ "Ezza North",
271
+ "Ezza South",
272
+ "Ikwo",
273
+ "Ishielu",
274
+ "Ivo",
275
+ "Izzi",
276
+ "Ohaozara",
277
+ "Ohaukwu",
278
+ "Onicha"
279
+ ]
280
+ },
281
+ {
282
+ "name": "Edo",
283
+ "lgas": [
284
+ "Akoko-Edo",
285
+ "Egor",
286
+ "Esan Central",
287
+ "Esan North-East",
288
+ "Esan South-East",
289
+ "Esan West",
290
+ "Etsako Central",
291
+ "Etsako East",
292
+ "Etsako West",
293
+ "Igueben",
294
+ "Ikpoba-Okha",
295
+ "Orhionmwon",
296
+ "Oredo",
297
+ "Ovia North-East",
298
+ "Ovia South-West",
299
+ "Owan East",
300
+ "Owan West",
301
+ "Uhunmwonde"
302
+ ]
303
+ },
304
+ {
305
+ "name": "Ekiti",
306
+ "lgas": [
307
+ "Ado Ekiti",
308
+ "Efon",
309
+ "Ekiti East",
310
+ "Ekiti South-West",
311
+ "Ekiti West",
312
+ "Emure",
313
+ "Gbonyin",
314
+ "Ido/Osi",
315
+ "Ijero",
316
+ "Ikere",
317
+ "Ikole",
318
+ "Ilejemeje",
319
+ "Irepodun/Ifelodun",
320
+ "Ise/Orun",
321
+ "Moba",
322
+ "Oye"
323
+ ]
324
+ },
325
+ {
326
+ "name": "Enugu",
327
+ "lgas": [
328
+ "Aninri",
329
+ "Awgu",
330
+ "Enugu East",
331
+ "Enugu North",
332
+ "Enugu South",
333
+ "Ezeagu",
334
+ "Igbo Etiti",
335
+ "Igbo Eze North",
336
+ "Igbo Eze South",
337
+ "Isi Uzo",
338
+ "Nkanu East",
339
+ "Nkanu West",
340
+ "Nsukka",
341
+ "Oji River",
342
+ "Udenu",
343
+ "Udi",
344
+ "Uzo Uwani"
345
+ ]
346
+ },
347
+ {
348
+ "name": "FCT (Abuja)",
349
+ "lgas": [
350
+ "Abaji",
351
+ "Abuja Municipal (AMAC)",
352
+ "Bwari",
353
+ "Gwagwalada",
354
+ "Kuje",
355
+ "Kwali"
356
+ ]
357
+ },
358
+ {
359
+ "name": "Gombe",
360
+ "lgas": [
361
+ "Akko",
362
+ "Balanga",
363
+ "Billiri",
364
+ "Dukku",
365
+ "Funakaye",
366
+ "Gombe",
367
+ "Kaltungo",
368
+ "Kwami",
369
+ "Nafada",
370
+ "Shongom",
371
+ "Yamaltu/Deba"
372
+ ]
373
+ },
374
+ {
375
+ "name": "Imo",
376
+ "lgas": [
377
+ "Aboh Mbaise",
378
+ "Ahiazu Mbaise",
379
+ "Ehime Mbano",
380
+ "Ezinihitte",
381
+ "Ideato North",
382
+ "Ideato South",
383
+ "Ihitte/Uboma",
384
+ "Ikeduru",
385
+ "Isiala Mbano",
386
+ "Isu",
387
+ "Mbaitoli",
388
+ "Ngor Okpala",
389
+ "Njaba",
390
+ "Nkwerre",
391
+ "Nwangele",
392
+ "Obowo",
393
+ "Oguta",
394
+ "Ohaji/Egbema",
395
+ "Okigwe",
396
+ "Orlu",
397
+ "Orsu",
398
+ "Oru East",
399
+ "Oru West",
400
+ "Owerri Municipal",
401
+ "Owerri North",
402
+ "Owerri West",
403
+ "Unuimo"
404
+ ]
405
+ },
406
+ {
407
+ "name": "Jigawa",
408
+ "lgas": [
409
+ "Auyo",
410
+ "Babura",
411
+ "Birnin Kudu",
412
+ "Birniwa",
413
+ "Buji",
414
+ "Dutse",
415
+ "Gagarawa",
416
+ "Garki",
417
+ "Gumel",
418
+ "Guri",
419
+ "Gwaram",
420
+ "Gwiwa",
421
+ "Hadejia",
422
+ "Jahun",
423
+ "Kafin Hausa",
424
+ "Kaugama",
425
+ "Kazaure",
426
+ "Kiri Kasama",
427
+ "Kiyawa",
428
+ "Maigatari",
429
+ "Malam Madori",
430
+ "Miga",
431
+ "Ringim",
432
+ "Roni",
433
+ "Sule Tankarkar",
434
+ "Taura",
435
+ "Yankwashi"
436
+ ]
437
+ },
438
+ {
439
+ "name": "Kaduna",
440
+ "lgas": [
441
+ "Birnin Gwari",
442
+ "Chikun",
443
+ "Giwa",
444
+ "Igabi",
445
+ "Ikara",
446
+ "Jaba",
447
+ "Jema'a",
448
+ "Kachia",
449
+ "Kaduna North",
450
+ "Kaduna South",
451
+ "Kagarko",
452
+ "Kajuru",
453
+ "Kaura",
454
+ "Kauru",
455
+ "Kubau",
456
+ "Kudan",
457
+ "Lere",
458
+ "Makarfi",
459
+ "Sabon Gari",
460
+ "Sanga",
461
+ "Soba",
462
+ "Zangon Kataf",
463
+ "Zaria"
464
+ ]
465
+ },
466
+ {
467
+ "name": "Kano",
468
+ "lgas": [
469
+ "Ajingi",
470
+ "Albasu",
471
+ "Bagwai",
472
+ "Bebeji",
473
+ "Bichi",
474
+ "Bunkure",
475
+ "Dala",
476
+ "Dambatta",
477
+ "Dawakin Kudu",
478
+ "Dawakin Tofa",
479
+ "Doguwa",
480
+ "Fagge",
481
+ "Gabasawa",
482
+ "Garko",
483
+ "Garun Mallam",
484
+ "Gaya",
485
+ "Gezawa",
486
+ "Gwale",
487
+ "Gwarzo",
488
+ "Kabo",
489
+ "Kano Municipal",
490
+ "Karaye",
491
+ "Kibiya",
492
+ "Kiru",
493
+ "Kumbotso",
494
+ "Kunchi",
495
+ "Kura",
496
+ "Madobi",
497
+ "Makoda",
498
+ "Minjibir",
499
+ "Nasarawa",
500
+ "Rano",
501
+ "Rimin Gado",
502
+ "Rogo",
503
+ "Shanono",
504
+ "Sumaila",
505
+ "Takai",
506
+ "Tarauni",
507
+ "Tofa",
508
+ "Tsanyawa",
509
+ "Tudun Wada",
510
+ "Ungogo",
511
+ "Warawa",
512
+ "Wudil"
513
+ ]
514
+ },
515
+ {
516
+ "name": "Katsina",
517
+ "lgas": [
518
+ "Bakori",
519
+ "Batagarawa",
520
+ "Batsari",
521
+ "Baure",
522
+ "Bindawa",
523
+ "Charanchi",
524
+ "Dan Musa",
525
+ "Dandume",
526
+ "Danja",
527
+ "Daura",
528
+ "Dutsi",
529
+ "Dutsin-Ma",
530
+ "Faskari",
531
+ "Funtua",
532
+ "Ingawa",
533
+ "Jibia",
534
+ "Kafur",
535
+ "Kaita",
536
+ "Kankara",
537
+ "Kankia",
538
+ "Katsina",
539
+ "Kurfi",
540
+ "Kusada",
541
+ "Mai'Adua",
542
+ "Malumfashi",
543
+ "Mani",
544
+ "Mashi",
545
+ "Matazu",
546
+ "Musawa",
547
+ "Rimi",
548
+ "Sabuwa",
549
+ "Safana",
550
+ "Sandamu",
551
+ "Zango"
552
+ ]
553
+ },
554
+ {
555
+ "name": "Kebbi",
556
+ "lgas": [
557
+ "Aleiro",
558
+ "Arewa Dandko",
559
+ "Argungu",
560
+ "Augie",
561
+ "Bagudo",
562
+ "Birnin Kebbi",
563
+ "Bunza",
564
+ "Dandi",
565
+ "Fakai",
566
+ "Gwandu",
567
+ "Jega",
568
+ "Kalgo",
569
+ "Koko/Besse",
570
+ "Maiyama",
571
+ "Ngaski",
572
+ "Sakaba",
573
+ "Shanga",
574
+ "Suru",
575
+ "Wasagu/Danko",
576
+ "Yauri",
577
+ "Zuru"
578
+ ]
579
+ },
580
+ {
581
+ "name": "Kogi",
582
+ "lgas": [
583
+ "Adavi",
584
+ "Ajaokuta",
585
+ "Ankpa",
586
+ "Bassa",
587
+ "Dekina",
588
+ "Ibaji",
589
+ "Idah",
590
+ "Igalamela-Odolu",
591
+ "Ijumu",
592
+ "Kabba/Bunu",
593
+ "Kogi",
594
+ "Lokoja",
595
+ "Mopa-Muro",
596
+ "Ofu",
597
+ "Ogori/Magongo",
598
+ "Okehi",
599
+ "Okene",
600
+ "Olamaboro",
601
+ "Omala",
602
+ "Yagba East",
603
+ "Yagba West"
604
+ ]
605
+ },
606
+ {
607
+ "name": "Kwara",
608
+ "lgas": [
609
+ "Asa",
610
+ "Baruten",
611
+ "Edu",
612
+ "Ekiti",
613
+ "Ifelodun",
614
+ "Ilorin East",
615
+ "Ilorin South",
616
+ "Ilorin West",
617
+ "Irepodun",
618
+ "Isin",
619
+ "Kaiama",
620
+ "Moro",
621
+ "Offa",
622
+ "Oke Ero",
623
+ "Oyun",
624
+ "Pategi"
625
+ ]
626
+ },
627
+ {
628
+ "name": "Lagos",
629
+ "lgas": [
630
+ "Agege",
631
+ "Ajeromi-Ifelodun",
632
+ "Alimosho",
633
+ "Amuwo-Odofin",
634
+ "Apapa",
635
+ "Badagry",
636
+ "Epe",
637
+ "Eti-Osa",
638
+ "Ibeju-Lekki",
639
+ "Ifako-Ijaiye",
640
+ "Ikeja",
641
+ "Ikorodu",
642
+ "Kosofe",
643
+ "Lagos Island",
644
+ "Lagos Mainland",
645
+ "Mushin",
646
+ "Ojo",
647
+ "Oshodi-Isolo",
648
+ "Shomolu",
649
+ "Surulere"
650
+ ]
651
+ },
652
+ {
653
+ "name": "Nasarawa",
654
+ "lgas": [
655
+ "Akwanga",
656
+ "Awe",
657
+ "Doma",
658
+ "Karu",
659
+ "Keana",
660
+ "Keffi",
661
+ "Kokona",
662
+ "Lafia",
663
+ "Nasarawa",
664
+ "Nasarawa Egon",
665
+ "Obi",
666
+ "Toto",
667
+ "Wamba"
668
+ ]
669
+ },
670
+ {
671
+ "name": "Niger",
672
+ "lgas": [
673
+ "Agaie",
674
+ "Agwara",
675
+ "Bida",
676
+ "Borgu",
677
+ "Bosso",
678
+ "Chanchaga",
679
+ "Edati",
680
+ "Gbako",
681
+ "Gurara",
682
+ "Katcha",
683
+ "Kontagora",
684
+ "Lapai",
685
+ "Lavun",
686
+ "Magama",
687
+ "Mariga",
688
+ "Mashegu",
689
+ "Mokwa",
690
+ "Moya",
691
+ "Paikoro",
692
+ "Rafi",
693
+ "Rijau",
694
+ "Shiroro",
695
+ "Suleja",
696
+ "Tafa",
697
+ "Wushishi"
698
+ ]
699
+ },
700
+ {
701
+ "name": "Ogun",
702
+ "lgas": [
703
+ "Abeokuta North",
704
+ "Abeokuta South",
705
+ "Ado-Odo/Ota",
706
+ "Egbado North",
707
+ "Egbado South",
708
+ "Ewekoro",
709
+ "Ifo",
710
+ "Ijebu East",
711
+ "Ijebu North",
712
+ "Ijebu North East",
713
+ "Ijebu Ode",
714
+ "Ikenne",
715
+ "Imeko Afon",
716
+ "Ipokia",
717
+ "Obafemi Owode",
718
+ "Odeda",
719
+ "Odogbolu",
720
+ "Ogun Waterside",
721
+ "Remo North",
722
+ "Shagamu"
723
+ ]
724
+ },
725
+ {
726
+ "name": "Ondo",
727
+ "lgas": [
728
+ "Akoko North-East",
729
+ "Akoko North-West",
730
+ "Akoko South-East",
731
+ "Akoko South-West",
732
+ "Akure North",
733
+ "Akure South",
734
+ "Ese Odo",
735
+ "Idanre",
736
+ "Ifedore",
737
+ "Ilaje",
738
+ "Ile Oluji/Okeigbo",
739
+ "Irele",
740
+ "Odigbo",
741
+ "Okitipupa",
742
+ "Ondo East",
743
+ "Ondo West",
744
+ "Ose",
745
+ "Owo"
746
+ ]
747
+ },
748
+ {
749
+ "name": "Osun",
750
+ "lgas": [
751
+ "Aiyedaade",
752
+ "Aiyedire",
753
+ "Atakunmosa East",
754
+ "Atakunmosa West",
755
+ "Boluwaduro",
756
+ "Boripe",
757
+ "Ede North",
758
+ "Ede South",
759
+ "Egbedore",
760
+ "Ejigbo",
761
+ "Ife Central",
762
+ "Ife East",
763
+ "Ife North",
764
+ "Ife South",
765
+ "Ifedayo",
766
+ "Ifelodun",
767
+ "Ila",
768
+ "Ilesa East",
769
+ "Ilesa West",
770
+ "Irepodun",
771
+ "Irewole",
772
+ "Isokan",
773
+ "Iwo",
774
+ "Obokun",
775
+ "Odo Otin",
776
+ "Ola-Oluwa",
777
+ "Olorunda",
778
+ "Oriade",
779
+ "Orolu",
780
+ "Osogbo"
781
+ ]
782
+ },
783
+ {
784
+ "name": "Oyo",
785
+ "lgas": [
786
+ "Afijio",
787
+ "Akinyele",
788
+ "Atiba",
789
+ "Atisbo",
790
+ "Egbeda",
791
+ "Ibadan North",
792
+ "Ibadan North-East",
793
+ "Ibadan North-West",
794
+ "Ibadan South-East",
795
+ "Ibadan South-West",
796
+ "Ibarapa Central",
797
+ "Ibarapa East",
798
+ "Ibarapa North",
799
+ "Ido",
800
+ "Irepo",
801
+ "Iseyin",
802
+ "Itesiwaju",
803
+ "Iwajowa",
804
+ "Kajola",
805
+ "Lagelu",
806
+ "Ogbomosho North",
807
+ "Ogbomosho South",
808
+ "Ogo Oluwa",
809
+ "Olorunsogo",
810
+ "Oluyole",
811
+ "Ona Ara",
812
+ "Orelope",
813
+ "Orire",
814
+ "Oyo East",
815
+ "Oyo West",
816
+ "Saki East",
817
+ "Saki West",
818
+ "Surulere"
819
+ ]
820
+ },
821
+ {
822
+ "name": "Plateau",
823
+ "lgas": [
824
+ "Barkin Ladi",
825
+ "Bassa",
826
+ "Bokkos",
827
+ "Jos East",
828
+ "Jos North",
829
+ "Jos South",
830
+ "Kanam",
831
+ "Kanke",
832
+ "Langtang North",
833
+ "Langtang South",
834
+ "Mangu",
835
+ "Mikang",
836
+ "Pankshin",
837
+ "Qua'an Pan",
838
+ "Riyom",
839
+ "Shendam",
840
+ "Wase"
841
+ ]
842
+ },
843
+ {
844
+ "name": "Rivers",
845
+ "lgas": [
846
+ "Abua/Odual",
847
+ "Ahoada East",
848
+ "Ahoada West",
849
+ "Akuku-Toru",
850
+ "Andoni",
851
+ "Asari-Toru",
852
+ "Bonny",
853
+ "Degema",
854
+ "Eleme",
855
+ "Emohua",
856
+ "Etche",
857
+ "Gokana",
858
+ "Ikwerre",
859
+ "Khana",
860
+ "Obio/Akpor",
861
+ "Ogba/Egbema/Ndoni",
862
+ "Ogu/Bolo",
863
+ "Okrika",
864
+ "Omuma",
865
+ "Opobo/Nkoro",
866
+ "Oyigbo",
867
+ "Port Harcourt",
868
+ "Tai"
869
+ ]
870
+ },
871
+ {
872
+ "name": "Sokoto",
873
+ "lgas": [
874
+ "Binji",
875
+ "Bodinga",
876
+ "Dange Shuni",
877
+ "Gada",
878
+ "Goronyo",
879
+ "Gudu",
880
+ "Gwadabawa",
881
+ "Illela",
882
+ "Isa",
883
+ "Kebbe",
884
+ "Kware",
885
+ "Rabah",
886
+ "Sabon Birni",
887
+ "Shagari",
888
+ "Silame",
889
+ "Sokoto North",
890
+ "Sokoto South",
891
+ "Tambuwal",
892
+ "Tangaza",
893
+ "Tureta",
894
+ "Wamako",
895
+ "Wurno",
896
+ "Yabo"
897
+ ]
898
+ },
899
+ {
900
+ "name": "Taraba",
901
+ "lgas": [
902
+ "Ardo Kola",
903
+ "Bali",
904
+ "Donga",
905
+ "Gashaka",
906
+ "Gassol",
907
+ "Ibi",
908
+ "Jalingo",
909
+ "Karim Lamido",
910
+ "Kumi",
911
+ "Lau",
912
+ "Sardauna",
913
+ "Takum",
914
+ "Ussa",
915
+ "Wukari",
916
+ "Yorro",
917
+ "Zing"
918
+ ]
919
+ },
920
+ {
921
+ "name": "Yobe",
922
+ "lgas": [
923
+ "Bade",
924
+ "Bursari",
925
+ "Damaturu",
926
+ "Fika",
927
+ "Fune",
928
+ "Geidam",
929
+ "Gujba",
930
+ "Gulani",
931
+ "Jakusko",
932
+ "Karasuwa",
933
+ "Machina",
934
+ "Nangere",
935
+ "Nguru",
936
+ "Potiskum",
937
+ "Tarmuwa",
938
+ "Yunusari",
939
+ "Yusufari"
940
+ ]
941
+ },
942
+ {
943
+ "name": "Zamfara",
944
+ "lgas": [
945
+ "Anka",
946
+ "Bakura",
947
+ "Birnin Magaji/Kiyaw",
948
+ "Bukkuyum",
949
+ "Bungudu",
950
+ "Gummi",
951
+ "Gusau",
952
+ "Kaura Namoda",
953
+ "Maradun",
954
+ "Maru",
955
+ "Shinkafi",
956
+ "Talata Mafara",
957
+ "Tsafe",
958
+ "Zurmi"
959
+ ]
960
+ }
961
+ ]
intel/registry.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The sources and streams the store knows about, defined in code and upserted on
3
+ `init`. Adding a stream is adding an entry here plus an ingester — Layer 1 then
4
+ watches it automatically.
5
+
6
+ expected_cadence_hours is how often the source publishes, not how often we
7
+ happen to poll it; latency_hours is how far behind "now" its newest data
8
+ legitimately is (Open-Meteo's reanalysis archive trails real time by ~5 days).
9
+ Layer 1 judges freshness against both.
10
+ """
11
+ import sqlite3
12
+
13
+ DAY = 24
14
+ SOURCES = [
15
+ # id, family, provider, licence, expected_cadence_hours, latency_hours
16
+ ("geoboundaries", "location", "geoBoundaries (William & Mary geoLab)", "CC BY 4.0", 365 * DAY, 0),
17
+ ("open_meteo_archive", "weather", "Open-Meteo (ECMWF ERA5 reanalysis)", "CC BY 4.0 data; API free tier is non-commercial", DAY, 7 * DAY),
18
+ ("open_meteo_forecast", "weather", "Open-Meteo (ECMWF IFS and others)", "CC BY 4.0 data; API free tier is non-commercial", DAY, 0),
19
+ ("overture_places", "places", "Overture Maps Foundation", "CDLA Permissive 2.0", 31 * DAY, 0),
20
+ ("calendar", "calendar", "VenThrive (computed)", "n/a", 365 * DAY, 0),
21
+ ("news_pipeline", "policy", "VenThrive news pipeline (ADR-0015)", "per-source", 7 * DAY, 0),
22
+ ]
23
+
24
+ WEATHER_VARIABLES = [
25
+ # Open-Meteo daily variable, unit
26
+ ("temperature_2m_max", "°C"),
27
+ ("temperature_2m_min", "°C"),
28
+ ("precipitation_sum", "mm"),
29
+ ("wind_speed_10m_max", "km/h"),
30
+ # Low humidity with a dry north-easterly is the harmattan signature, so this
31
+ # is the stream harmattan onset/strength is derived from.
32
+ ("relative_humidity_2m_mean", "%"),
33
+ ]
34
+
35
+ STREAMS = [
36
+ # id, family, metric, unit, kind, native_level, cadence, source_id
37
+ *[(f"weather.{v}", "weather", v, unit, "driver", "state", "daily", "open_meteo_archive") for v, unit in WEATHER_VARIABLES],
38
+ ]
39
+
40
+
41
+ def seed(con: sqlite3.Connection) -> None:
42
+ for sid, family, provider, licence, cadence, latency in SOURCES:
43
+ con.execute(
44
+ """insert into sources (id, family, provider, licence, expected_cadence_hours, latency_hours)
45
+ values (?, ?, ?, ?, ?, ?)
46
+ on conflict (id) do update set family = excluded.family, provider = excluded.provider,
47
+ licence = excluded.licence, expected_cadence_hours = excluded.expected_cadence_hours,
48
+ latency_hours = excluded.latency_hours""",
49
+ (sid, family, provider, licence, cadence, latency),
50
+ )
51
+ for row in STREAMS:
52
+ con.execute(
53
+ """insert into streams (id, family, metric, unit, kind, native_level, cadence, source_id)
54
+ values (?, ?, ?, ?, ?, ?, ?, ?)
55
+ on conflict (id) do update set family = excluded.family, metric = excluded.metric,
56
+ unit = excluded.unit, kind = excluded.kind, native_level = excluded.native_level,
57
+ cadence = excluded.cadence, source_id = excluded.source_id""",
58
+ row,
59
+ )
intel/subsector_map.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Overture place categories → VenThrive subsector ids (backend/news/subsectors.json).
3
+
4
+ Two levels, most specific first: Overture's detailed `taxonomy.primary` when it
5
+ pins the business down (a 'barber' inside the broad 'personal_or_beauty_service'),
6
+ otherwise its `basic_category`. Anything unmapped gets subsector None — mostly
7
+ context places (worship, civic, parks, landmarks), which are kept because they
8
+ drive footfall even though they aren't competitors.
9
+
10
+ Built from the categories actually present in Nigeria's extract (release
11
+ 2026-09-23.1, 151k places). Known gap: hotels and lodging (~6.8k places) have no
12
+ subsector in data/sectors.ts, so they map to 'other_services' — flagged in the
13
+ design doc as a sector-taxonomy gap rather than forced into the wrong subsector.
14
+ """
15
+
16
+ TAXONOMY_TO_SUBSECTOR = {
17
+ # personal_or_beauty_service, split properly
18
+ "barber": "hair_services", "hair_salon": "hair_services", "hair_extensions": "hair_services",
19
+ "hair_replacement": "hair_services", "beauty_salon": "hair_services",
20
+ "makeup_artist": "skincare_cosmetics", "skin_care_and_makeup": "skincare_cosmetics",
21
+ "nail_salon": "skincare_cosmetics", "tanning_salon": "body_spa", "hair_removal": "body_spa",
22
+ "life_coach": "online_coaching", "image_consultant": "management_consulting",
23
+ "shoe_repair": "footwear", "tattoo_and_piercing": "other_services",
24
+ # professional_service, split properly
25
+ "marketing_agency": "digital_services", "advertising_agency": "digital_services",
26
+ "internet_marketing_service": "digital_services", "social_media_agency": "digital_services",
27
+ "e_commerce_service": "digital_services", "public_relations": "content_media",
28
+ "writing_service": "content_media", "employment_agency": "management_consulting",
29
+ "career_counseling": "management_consulting", "business_consulting": "management_consulting",
30
+ "janitorial_service": "cleaning_services", "merchandising_service": "retail_trade",
31
+ # manufacturer, split properly
32
+ "industrial_equipment_manufacturer": "industrial_manufacturing", "metal_fabricator": "industrial_manufacturing",
33
+ "auto_manufacturer": "industrial_manufacturing", "motorcycle_manufacturer": "industrial_manufacturing",
34
+ "plastic_manufacturer": "industrial_manufacturing", "glass_manufacturer": "industrial_manufacturing",
35
+ "appliance_manufacturer": "light_manufacturing", "furniture_manufacturer": "light_manufacturing",
36
+ "mattress_manufacturing": "light_manufacturing", "textile_manufacturer": "textiles_materials",
37
+ "apparel_manufacturer": "clothing_apparel", "shoe_factory_manufacturer": "footwear",
38
+ "jewelry_and_watches_manufacturer": "accessories_jewellery",
39
+ # corporate_or_business_office, split properly
40
+ "information_technology_company": "it_consulting", "telecommunications_company": "it_consulting",
41
+ "auto_company": "vehicle_trade", "travel_company": "passenger_transport",
42
+ "industrial_company": "industrial_manufacturing", "pharmaceutical_company": "industrial_manufacturing",
43
+ "clothing_company": "clothing_apparel", "bottled_water_company": "food_production_processing",
44
+ "biotechnology_company": "industrial_manufacturing", "ferry_boat_company": "passenger_transport",
45
+ "bags_luggage_company": "accessories_jewellery",
46
+ }
47
+
48
+ BASIC_TO_SUBSECTOR = {
49
+ # food
50
+ "restaurant": "catering_food_service", "casual_eatery": "catering_food_service",
51
+ "fast_food_restaurant": "catering_food_service", "cafe": "catering_food_service",
52
+ "coffee_shop": "catering_food_service", "food_service": "catering_food_service",
53
+ "food_and_drink": "catering_food_service", "smoothie_juice_bar": "catering_food_service",
54
+ "food_truck_stand": "catering_food_service", "non_alcoholic_beverage_venue": "catering_food_service",
55
+ "brewery": "food_production_processing", "winery": "food_production_processing",
56
+ "distillery": "food_production_processing",
57
+ "farm": "agricultural_production",
58
+ "agricultural_service": "agri_trade_distribution", "farmers_market": "agri_trade_distribution",
59
+ # fashion
60
+ "fashion_and_apparel_store": "clothing_apparel",
61
+ # tech
62
+ "design_service": "digital_services", "technical_service": "hardware_repairs",
63
+ "b2b_science_and_technology_service": "it_consulting", "telecommunications_service": "it_consulting",
64
+ # health & beauty
65
+ "hospital": "healthcare_services", "health_care": "healthcare_services",
66
+ "diagnostics_imaging_or_lab_service": "healthcare_services", "dental_clinic": "healthcare_services",
67
+ "outpatient_care_facility": "healthcare_services", "medical_service": "healthcare_services",
68
+ "vision_or_eye_care_clinic": "healthcare_services", "behavioral_or_mental_health_clinic": "healthcare_services",
69
+ "physical_medicine_and_rehabilitation": "healthcare_services", "specialized_medical_facility": "healthcare_services",
70
+ "specialized_health_care": "healthcare_services", "reproductive_perinatal_and_womens_care": "healthcare_services",
71
+ "primary_care_or_general_clinic": "healthcare_services", "surgery": "healthcare_services",
72
+ "emergency_department": "healthcare_services", "emergency_or_urgent_care_facility": "healthcare_services",
73
+ "pediatric_clinic": "healthcare_services", "complementary_and_alternative_medicine": "healthcare_services",
74
+ "pharmacy_and_drug_store": "healthcare_services", "senior_living_facility": "healthcare_services",
75
+ "wellness_service": "wellness_fitness", "gym": "wellness_fitness", "fitness_studio": "wellness_fitness",
76
+ "sport_or_fitness_facility": "wellness_fitness",
77
+ "personal_or_beauty_service": "hair_services",
78
+ "personal_care_and_beauty_store": "skincare_cosmetics",
79
+ # education
80
+ "place_of_learning": "formal_education", "education": "formal_education",
81
+ "college_university": "formal_education", "elementary_school": "formal_education",
82
+ "high_school": "formal_education", "middle_school": "formal_education", "preschool": "formal_education",
83
+ "educational_facility": "formal_education", "campus_building": "formal_education",
84
+ "specialty_school": "skills_vocational", "educational_service": "skills_vocational",
85
+ "tutoring_service": "online_coaching",
86
+ # money
87
+ "financial_service": "financial_services", "bank_or_credit_union": "financial_services", "atm": "financial_services",
88
+ # transport & logistics
89
+ "shipping_or_delivery_service": "delivery_services",
90
+ "b2b_transportation_and_storage_service": "haulage_freight", "storage_facility": "haulage_freight",
91
+ "travel_and_transportation": "passenger_transport", "public_transit_facility_or_service": "passenger_transport",
92
+ "taxi_or_ride_share_service": "passenger_transport", "travel_service": "passenger_transport",
93
+ # property & building
94
+ "real_estate_service": "real_estate", "apartment": "real_estate",
95
+ "building_or_construction_service": "construction_renovation",
96
+ "hardware_home_and_garden_store": "building_materials",
97
+ # creative & events
98
+ "art_gallery": "visual_arts", "arts_crafts_and_hobby_store": "visual_arts",
99
+ "media_service": "content_media", "radio_station": "content_media",
100
+ "theatre_venue": "performing_arts", "performing_arts_venue": "performing_arts",
101
+ "event_or_party_service": "event_planning", "event_venue": "event_support",
102
+ "bar": "entertainment_venues", "lounge": "entertainment_venues", "dance_club": "entertainment_venues",
103
+ "nightlife_venue": "entertainment_venues", "alcoholic_beverage_venue": "entertainment_venues",
104
+ "music_venue": "entertainment_venues", "movie_theater": "entertainment_venues",
105
+ "comedy_club": "entertainment_venues", "gaming_venue": "entertainment_venues", "casino": "entertainment_venues",
106
+ "arcade": "entertainment_venues", "amusement_park": "entertainment_venues", "stadium_arena": "entertainment_venues",
107
+ # making things
108
+ "manufacturer": "light_manufacturing", "printing_service": "light_manufacturing",
109
+ "b2b_industrial_and_machine_service": "industrial_manufacturing",
110
+ "industrial_facility_or_service": "industrial_manufacturing",
111
+ # trade
112
+ "shopping": "retail_trade", "electronics_store": "retail_trade", "food_and_beverage_store": "retail_trade",
113
+ "convenience_store": "retail_trade", "books_music_and_video_store": "retail_trade",
114
+ "flowers_and_gifts_store": "retail_trade", "specialty_store": "retail_trade",
115
+ "sporting_goods_store": "retail_trade", "musical_instrument_and_pro_audio_store": "retail_trade",
116
+ "office_supply_store": "retail_trade", "second_hand_store": "retail_trade", "department_store": "retail_trade",
117
+ "discount_store": "retail_trade", "toys_and_games_store": "retail_trade", "superstore": "retail_trade",
118
+ "shopping_mall": "retail_trade", "warehouse_club_store": "retail_trade", "animal_and_pet_store": "retail_trade",
119
+ "market": "retail_trade", "gas_station": "retail_trade",
120
+ "supplier_or_distributor": "wholesale_distribution", "wholesaler": "wholesale_distribution",
121
+ # professional
122
+ "attorney_or_law_firm": "legal_compliance", "legal_service": "legal_compliance",
123
+ # vehicles
124
+ "automotive_service": "vehicle_maintenance", "vehicle_service": "vehicle_maintenance",
125
+ "auto_dealer": "vehicle_trade", "vehicle_dealer": "vehicle_trade", "vehicle_parts_store": "vehicle_trade",
126
+ # services
127
+ "laundry_service": "cleaning_services", "home_service": "home_services",
128
+ "hotel": "other_services", "lodging": "other_services", "bed_and_breakfast": "other_services",
129
+ "private_lodging": "other_services", "inn": "other_services", "resort": "other_services",
130
+ "professional_service": "other_services", "corporate_or_business_office": "other_services",
131
+ "b2b_office_and_professional_service": "other_services", "b2b_service": "other_services",
132
+ "b2b_energy_and_utility_service": "other_services", "security_service": "other_services",
133
+ "animal_or_pet_service": "other_services", "rental_service": "other_services",
134
+ "recreational_equipment_rental": "other_services", "environmental_or_ecological_service": "other_services",
135
+ }
136
+
137
+
138
+ def subsector_for(basic_category: str | None, taxonomy_primary: str | None) -> str | None:
139
+ if taxonomy_primary and taxonomy_primary in TAXONOMY_TO_SUBSECTOR:
140
+ return TAXONOMY_TO_SUBSECTOR[taxonomy_primary]
141
+ if basic_category:
142
+ return BASIC_TO_SUBSECTOR.get(basic_category)
143
+ return None
news/pipeline.py CHANGED
@@ -22,6 +22,7 @@ from news.classify import classify_item
22
  from news.relevance import score as relevance_score
23
  from news.windows import compute_expiry
24
  from utils.supabase_client import get_supabase
 
25
 
26
  # Three-way routing by confidence (replaces the old complete/incomplete split).
27
  # A complete, high-confidence item is business-relevant enough to publish with
@@ -121,7 +122,7 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
121
  expires_at = compute_expiry(result.category, scraped_at, result.deadline)
122
  status = _route(result)
123
 
124
- db.table("news_items").insert({
125
  "source_id": source["id"],
126
  "url": item.url,
127
  "url_hash": h,
@@ -139,10 +140,15 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
139
  "confidence": result.confidence,
140
  "review_reason": result.review_reason(),
141
  "classified_at": scraped_at.isoformat(),
142
- }).execute()
 
143
 
144
  if status == "classified":
145
  counts["items_classified"] += 1
 
 
 
 
146
  elif status == "rejected":
147
  counts["items_rejected"] += 1
148
  else:
 
22
  from news.relevance import score as relevance_score
23
  from news.windows import compute_expiry
24
  from utils.supabase_client import get_supabase
25
+ from intel.events import record_news_item
26
 
27
  # Three-way routing by confidence (replaces the old complete/incomplete split).
28
  # A complete, high-confidence item is business-relevant enough to publish with
 
122
  expires_at = compute_expiry(result.category, scraped_at, result.deadline)
123
  status = _route(result)
124
 
125
+ row = {
126
  "source_id": source["id"],
127
  "url": item.url,
128
  "url_hash": h,
 
140
  "confidence": result.confidence,
141
  "review_reason": result.review_reason(),
142
  "classified_at": scraped_at.isoformat(),
143
+ }
144
+ db.table("news_items").insert(row).execute()
145
 
146
  if status == "classified":
147
  counts["items_classified"] += 1
148
+ # Published items also become permanent event history for the
149
+ # intel store (no-op when it's off) — the feed expires them, the
150
+ # correlation step must not lose them.
151
+ record_news_item(row)
152
  elif status == "rejected":
153
  counts["items_rejected"] += 1
154
  else:
requirements-intel.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Intel store (backend/intel/) — install alongside requirements.txt to run it locally.
2
+ # Not needed by the deployed API: the news hook is a no-op without INTEL_DB_PATH.
3
+ duckdb==1.5.6
4
+ hijridate==2.3.0
routes/news.py CHANGED
@@ -25,6 +25,7 @@ from news.classify import IMPACTS, CATEGORIES
25
  from news.business_types import valid_subset
26
  from utils.supabase_client import get_supabase
27
  from utils.audit import log_action
 
28
 
29
  router = APIRouter()
30
 
@@ -213,4 +214,6 @@ def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_f
213
 
214
  title = updated[0]["title"] if updated else item_id
215
  log_action(admin_uid, "review", "published", title, target=item_id)
 
 
216
  return {"success": True, "status": "classified"}
 
25
  from news.business_types import valid_subset
26
  from utils.supabase_client import get_supabase
27
  from utils.audit import log_action
28
+ from intel.events import record_news_item
29
 
30
  router = APIRouter()
31
 
 
214
 
215
  title = updated[0]["title"] if updated else item_id
216
  log_action(admin_uid, "review", "published", title, target=item_id)
217
+ if updated:
218
+ record_news_item(updated[0])
219
  return {"success": True, "status": "classified"}
sql/intel_schema.sql ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Intel store: Phase 0 + Phase 1 of the predictive business-intelligence system
2
+ -- (docs/blueprint/intelligence-system.md, ADR-0017..0021).
3
+ --
4
+ -- Portable SQL: runs unchanged on SQLite (Phase 1, local — ADR-0020) and on
5
+ -- Postgres/Supabase when the store moves. The price of that portability:
6
+ -- timestamps are ISO-8601 UTC text, booleans are 0/1 integers, JSON is text, ids
7
+ -- are generated in Python (no serial/uuid defaults). Revisit all three when the
8
+ -- store moves to Postgres for good.
9
+ --
10
+ -- Applied by `py -m intel.cli init` (idempotent — every statement is IF NOT EXISTS).
11
+
12
+ -- ─── Gazetteer ──────────────────────────────────────────────────────────────
13
+ -- Everything in the store is keyed by location, so this comes first. Ids are
14
+ -- dotted paths: 'ng', 'ng.lagos', 'ng.lagos.ikeja'. State/LGA names are exactly
15
+ -- the ones users pick in onboarding (data/nigeria-locations.ts → nigeria_locations.json).
16
+ create table if not exists locations (
17
+ id text primary key,
18
+ level text not null check (level in ('country', 'state', 'lga', 'town')),
19
+ name text not null,
20
+ parent_id text references locations(id),
21
+ lat real,
22
+ lon real,
23
+ source text not null
24
+ );
25
+
26
+ -- Normalised alternative names → location. Seeded from the gazetteer itself,
27
+ -- boundary-file spellings, a short curated city list, and place-data localities
28
+ -- (intel/locations.py). The seed of the Layer 5 search/geocoding service.
29
+ create table if not exists location_aliases (
30
+ alias text not null,
31
+ location_id text not null references locations(id),
32
+ origin text not null, -- 'gazetteer' | 'boundary' | 'curated' | 'places'
33
+ primary key (alias, location_id)
34
+ );
35
+
36
+ -- ─── Layer 1: stale awareness ───────────────────────────────────────────────
37
+ -- One row per external source. "Stale" means late against the source's OWN
38
+ -- cadence, and two dates are tracked because a fetch can succeed today and still
39
+ -- return last quarter's numbers.
40
+ create table if not exists sources (
41
+ id text primary key,
42
+ family text not null,
43
+ provider text not null,
44
+ licence text not null,
45
+ expected_cadence_hours integer not null,
46
+ latency_hours integer not null default 0, -- how far behind "now" the newest data legitimately is
47
+ last_attempt_at text,
48
+ last_success_at text,
49
+ latest_data_period text, -- newest period the source has given us
50
+ last_error text
51
+ );
52
+
53
+ -- Every layer reports "done" and the receiving layer acknowledges it. Layer 1
54
+ -- flags reports nobody received.
55
+ create table if not exists handoffs (
56
+ id text primary key,
57
+ run_id text not null,
58
+ from_layer text not null,
59
+ to_layer text not null,
60
+ report text not null, -- JSON
61
+ produced_at text not null,
62
+ received_at text,
63
+ received_by text
64
+ );
65
+ create index if not exists handoffs_pending on handoffs (to_layer, received_at);
66
+
67
+ -- ─── Layer 2: data stream ───────────────────────────────────────────────────
68
+ -- A stream is one metric from one source, stored at its NATIVE resolution
69
+ -- (never copied down to finer locations — that would fake precision the models
70
+ -- would then learn from).
71
+ create table if not exists streams (
72
+ id text primary key, -- 'weather.precipitation_sum'
73
+ family text not null,
74
+ metric text not null,
75
+ unit text not null,
76
+ kind text not null check (kind in ('driver', 'indicator')),
77
+ native_level text not null check (native_level in ('country', 'state', 'lga', 'town', 'point')),
78
+ cadence text not null check (cadence in ('daily', 'weekly', 'monthly', 'quarterly', 'yearly', 'event')),
79
+ source_id text not null references sources(id)
80
+ );
81
+
82
+ -- Numeric time series. Observed history and forecasts share one table:
83
+ -- history has is_forecast=0 and issued_at=''; each forecast run keeps its own
84
+ -- rows (issued_at = run date) so forecast skill can be measured later.
85
+ create table if not exists observations (
86
+ stream_id text not null references streams(id),
87
+ location_id text not null references locations(id),
88
+ period_start text not null,
89
+ is_forecast integer not null default 0 check (is_forecast in (0, 1)),
90
+ issued_at text not null default '',
91
+ value real not null,
92
+ ingested_at text not null,
93
+ primary key (stream_id, location_id, period_start, is_forecast, issued_at)
94
+ );
95
+ create index if not exists observations_loc on observations (location_id, stream_id, period_start);
96
+
97
+ -- Things that happen rather than values that move: a policy enacted, a holiday,
98
+ -- an incident, a project gazetted. Kept permanently — this is the history the
99
+ -- correlation step and models learn from (unlike news_items, which expire from
100
+ -- the user-facing feed).
101
+ create table if not exists events (
102
+ id text primary key,
103
+ family text not null, -- policy | weather | infrastructure | funding | security | calendar | other
104
+ type text not null, -- e.g. 'public_holiday', 'religious_holiday', 'season', 'news'
105
+ location_id text references locations(id),
106
+ starts_on text not null,
107
+ ends_on text,
108
+ title text not null,
109
+ detail text,
110
+ is_estimate integer not null default 0 check (is_estimate in (0, 1)),
111
+ source_id text not null references sources(id),
112
+ source_ref text not null, -- natural key at the source, so re-ingest is idempotent
113
+ ingested_at text not null,
114
+ unique (source_id, source_ref)
115
+ );
116
+ create index if not exists events_loc on events (location_id, starts_on);
117
+
118
+ -- Points of interest. subsector is one of backend/news/subsectors.json, or null
119
+ -- for a context place (worship, civic, landmark) — those still matter as
120
+ -- footfall generators even though they aren't competitors.
121
+ create table if not exists places (
122
+ id text primary key, -- Overture GERS id
123
+ name text,
124
+ category text,
125
+ taxonomy text,
126
+ subsector text,
127
+ confidence real,
128
+ operating_status text,
129
+ lat real not null,
130
+ lon real not null,
131
+ location_id text references locations(id), -- the LGA it falls in
132
+ locality text,
133
+ source_id text not null references sources(id),
134
+ release text not null
135
+ );
136
+ create index if not exists places_loc on places (location_id, subsector);
tests/test_intel.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the intel store (Phase 0 + 1, docs/blueprint/intelligence-system.md):
3
+ geometry, gazetteer matching and text resolution, Layer 1 freshness, handoff
4
+ receipts, the calendar, resumable weather backfill, category mapping, and the
5
+ news → events hook. Everything runs on in-memory SQLite with synthetic data —
6
+ no network, and no dependency on the gitignored raw downloads.
7
+ """
8
+ from datetime import date, datetime, timedelta, timezone
9
+
10
+ import pytest
11
+
12
+ from intel import db, handoff
13
+ from intel.freshness import record_attempt, report, source_status
14
+ from intel.geo import PolygonIndex, centroid, contains, haversine_km
15
+ from intel.ingest import calendar as cal
16
+ from intel.ingest import weather
17
+ from intel.locations import match_boundaries, normalise, resolve
18
+ from intel.subsector_map import subsector_for
19
+
20
+
21
+ def square(x0, y0, x1, y1):
22
+ return {"type": "Polygon", "coordinates": [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]}
23
+
24
+
25
+ @pytest.fixture
26
+ def con():
27
+ c = db.connect(":memory:")
28
+ db.init(c)
29
+ yield c
30
+ c.close()
31
+
32
+
33
+ def add_location(con, lid, level, name, parent=None, lat=0.0, lon=0.0, aliases=()):
34
+ con.execute("insert into locations (id, level, name, parent_id, lat, lon, source) values (?, ?, ?, ?, ?, ?, 'test')",
35
+ (lid, level, name, parent, lat, lon))
36
+ for a in (name, *aliases):
37
+ con.execute("insert into location_aliases (alias, location_id, origin) values (?, ?, 'test')", (normalise(a), lid))
38
+
39
+
40
+ @pytest.fixture
41
+ def gazetteer(con):
42
+ add_location(con, "ng", "country", "Nigeria")
43
+ add_location(con, "ng.lagos", "state", "Lagos", "ng")
44
+ add_location(con, "ng.oyo", "state", "Oyo", "ng")
45
+ add_location(con, "ng.bauchi", "state", "Bauchi", "ng")
46
+ add_location(con, "ng.lagos.surulere", "lga", "Surulere", "ng.lagos")
47
+ add_location(con, "ng.oyo.surulere", "lga", "Surulere", "ng.oyo")
48
+ add_location(con, "ng.lagos.eti-osa", "lga", "Eti-Osa", "ng.lagos", aliases=("Ajah", "Lekki"))
49
+ add_location(con, "ng.bauchi.bauchi", "lga", "Bauchi", "ng.bauchi")
50
+ con.commit()
51
+ return con
52
+
53
+
54
+ # ─── geometry ───────────────────────────────────────────────────────────────
55
+
56
+ def test_centroid_of_square():
57
+ assert centroid(square(0, 0, 2, 2)) == pytest.approx((1.0, 1.0))
58
+
59
+
60
+ def test_centroid_respects_holes():
61
+ g = {"type": "Polygon", "coordinates": [
62
+ [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],
63
+ [[2, 0], [4, 0], [4, 4], [2, 4], [2, 0]], # right half removed
64
+ ]}
65
+ assert centroid(g)[0] == pytest.approx(1.0)
66
+
67
+
68
+ def test_contains_and_index():
69
+ idx = PolygonIndex([("a", square(0, 0, 1, 1)), ("b", square(1, 0, 2, 1))])
70
+ assert idx.lookup(0.5, 0.5) == "a"
71
+ assert idx.lookup(1.5, 0.5) == "b"
72
+ assert idx.lookup(5, 5) is None
73
+ assert not contains(square(0, 0, 1, 1), 1.5, 0.5)
74
+
75
+
76
+ def test_haversine_one_degree_at_equator():
77
+ assert haversine_km(0, 0, 1, 0) == pytest.approx(111.2, abs=0.2)
78
+
79
+
80
+ # ─── gazetteer ──────────────────────────────────────────────────────────────
81
+
82
+ def _feature(name, geom):
83
+ return {"properties": {"shapeName": name}, "geometry": geom}
84
+
85
+
86
+ def test_match_boundaries_exact_fuzzy_and_state_placement():
87
+ states = [{"name": "Lagos", "lgas": ["Ifako-Ijaiye", "Surulere"]}, {"name": "Oyo", "lgas": ["Surulere"]}]
88
+ adm1 = [_feature("Lagos", square(0, 0, 2, 1)), _feature("Oyo", square(0, 1, 2, 2))]
89
+ adm2 = [
90
+ _feature("Ifako/Ijaye", square(0, 0, 1, 1)), # fuzzy
91
+ _feature("Surulere", square(1, 0, 2, 1)), # exact, in Lagos by position
92
+ _feature("Surulere", square(0, 1, 2, 2)), # same name, in Oyo by position
93
+ ]
94
+ matched, problems = match_boundaries(states, adm1, adm2)
95
+ assert problems == []
96
+ assert matched[("Lagos", "Ifako-Ijaiye")]["properties"]["shapeName"] == "Ifako/Ijaye"
97
+ assert centroid(matched[("Oyo", "Surulere")]["geometry"])[1] == pytest.approx(1.5)
98
+
99
+
100
+ def test_match_boundaries_reports_rather_than_guesses():
101
+ states = [{"name": "Lagos", "lgas": ["Agege"]}]
102
+ adm1 = [_feature("Lagos", square(0, 0, 1, 1))]
103
+ adm2 = [_feature("Completely Different", square(0, 0, 1, 1))]
104
+ _, problems = match_boundaries(states, adm1, adm2)
105
+ assert any("Agege" in p for p in problems)
106
+
107
+
108
+ def test_resolve_alias_to_lga(gazetteer):
109
+ assert resolve(gazetteer, "Flooding in Ajah after heavy rain") == "ng.lagos.eti-osa"
110
+
111
+
112
+ def test_resolve_shared_lga_name_needs_state_context(gazetteer):
113
+ assert resolve(gazetteer, "Traders in Surulere protest") is None
114
+ assert resolve(gazetteer, "Surulere, Lagos market shut") == "ng.lagos.surulere"
115
+
116
+
117
+ def test_resolve_state_beats_same_named_lga(gazetteer):
118
+ assert resolve(gazetteer, "Bauchi governor signs levy") == "ng.bauchi"
119
+
120
+
121
+ def test_resolve_no_mention(gazetteer):
122
+ assert resolve(gazetteer, "Celebrity wedding photos") is None
123
+ assert resolve(gazetteer, None) is None
124
+
125
+
126
+ def test_resolve_word_boundaries(gazetteer):
127
+ # "Oyo" must not match inside another word.
128
+ assert resolve(gazetteer, "Coyotes spotted") is None
129
+
130
+
131
+ # ─── Layer 1: freshness ─────────────────────────────────────────────────────
132
+
133
+ def _source(con, sid):
134
+ return con.execute("select * from sources where id = ?", (sid,)).fetchone()
135
+
136
+
137
+ def test_never_fetched_source_is_never(con):
138
+ assert source_status(_source(con, "calendar"), datetime.now(timezone.utc))["status"] == "never"
139
+
140
+
141
+ def test_fresh_then_stale_on_fetch_clock(con):
142
+ record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=date.today().isoformat())
143
+ now = datetime.now(timezone.utc)
144
+ assert source_status(_source(con, "open_meteo_forecast"), now)["status"] == "fresh"
145
+ assert source_status(_source(con, "open_meteo_forecast"), now + timedelta(days=2))["status"] == "late"
146
+ assert source_status(_source(con, "open_meteo_forecast"), now + timedelta(days=4))["status"] == "stale"
147
+
148
+
149
+ def test_data_clock_catches_old_data_behind_a_fresh_fetch(con):
150
+ # Fetched just now, but the newest data is a month old on a daily source.
151
+ old = (date.today() - timedelta(days=30)).isoformat()
152
+ record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=old)
153
+ s = source_status(_source(con, "open_meteo_forecast"), datetime.now(timezone.utc))
154
+ assert s["status"] == "stale"
155
+ assert "newest data" in s["reason"]
156
+
157
+
158
+ def test_archive_latency_is_allowed(con):
159
+ # The archive legitimately trails by ~a week; 6-day-old data is on time.
160
+ record_attempt(con, "open_meteo_archive", ok=True, latest_data_period=(date.today() - timedelta(days=6)).isoformat())
161
+ assert source_status(_source(con, "open_meteo_archive"), datetime.now(timezone.utc))["status"] == "fresh"
162
+
163
+
164
+ def test_failure_after_success_is_failing(con):
165
+ record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=date.today().isoformat())
166
+ record_attempt(con, "open_meteo_forecast", ok=False, error="timeout")
167
+ s = source_status(_source(con, "open_meteo_forecast"), datetime.now(timezone.utc))
168
+ assert s["status"] == "failing" and "timeout" in s["reason"]
169
+
170
+
171
+ # ─── handoffs ───────────────────────────────────────────────────────────────
172
+
173
+ def test_handoff_receipt_once(con):
174
+ hid = handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.STALE_AWARENESS, {"ok": True})
175
+ assert [h["id"] for h in handoff.pending(con, handoff.STALE_AWARENESS)] == [hid]
176
+ assert handoff.receive(con, hid, "test") is True
177
+ assert handoff.receive(con, hid, "test") is False
178
+ assert handoff.pending(con, handoff.STALE_AWARENESS) == []
179
+
180
+
181
+ def test_unreceived_handoff_to_active_layer_goes_overdue(con):
182
+ handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.STALE_AWARENESS, {})
183
+ later = datetime.now(timezone.utc) + timedelta(days=2)
184
+ assert len(report(con, later)["handoffs_overdue"]) == 1
185
+
186
+
187
+ def test_handoff_to_unbuilt_layer_is_queued_not_overdue(con):
188
+ handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.INTERFACE, {})
189
+ r = report(con, datetime.now(timezone.utc) + timedelta(days=30))
190
+ assert r["handoffs_overdue"] == [] and r["handoffs_queued_for_unbuilt_layers"] == 1
191
+
192
+
193
+ # ─── calendar ───────────────────────────────────────────────────────────────
194
+
195
+ @pytest.mark.parametrize("year,expected", [(2024, date(2024, 3, 31)), (2025, date(2025, 4, 20)), (2026, date(2026, 4, 5))])
196
+ def test_easter(year, expected):
197
+ assert cal.easter(year) == expected
198
+
199
+
200
+ def test_democracy_day_moved_in_2019():
201
+ dd = lambda y: next(e["starts_on"] for e in cal.events_for_year(y) if e["key"] == "democracy_day") # noqa: E731
202
+ assert dd(2018) == date(2018, 5, 29)
203
+ assert dd(2019) == date(2019, 6, 12)
204
+
205
+
206
+ def test_islamic_holidays_are_estimates():
207
+ events = {e["key"]: e for e in cal.events_for_year(2025)}
208
+ assert events["eid_al_fitr"]["starts_on"] == date(2025, 3, 30)
209
+ assert events["eid_al_fitr"]["is_estimate"] and not events["christmas"]["is_estimate"]
210
+
211
+
212
+ def test_calendar_ingest_is_idempotent(gazetteer):
213
+ first = cal.ingest(gazetteer, 2024, 2025)["events"]
214
+ cal.ingest(gazetteer, 2024, 2025)
215
+ assert gazetteer.execute("select count(*) from events where source_id = 'calendar'").fetchone()[0] <= first
216
+
217
+
218
+ # ─── weather backfill ───────────────────────────────────────────────────────
219
+
220
+ def fake_fetch(calls):
221
+ def fetch(url, params):
222
+ calls.append(params)
223
+ a, b = date.fromisoformat(params.get("start_date") or date.today().isoformat()), \
224
+ date.fromisoformat(params.get("end_date") or (date.today() + timedelta(days=params.get("forecast_days", 1) - 1)).isoformat())
225
+ days = [(a + timedelta(days=i)).isoformat() for i in range((b - a).days + 1)]
226
+ return {"daily": {"time": days, **{v: [1.0] * len(days) for v in weather.VARIABLES}}}
227
+ return fetch
228
+
229
+
230
+ def test_call_weight():
231
+ assert weather.call_weight(14) == 1
232
+ assert weather.call_weight(365) == pytest.approx(365 / 14)
233
+
234
+
235
+ def test_backfill_stops_at_budget_and_resumes(gazetteer):
236
+ start = date(date.today().year - 3, 1, 1)
237
+ calls = []
238
+ first = weather.backfill_history(gazetteer, start=start, budget=60, fetch=fake_fetch(calls), sleep=lambda s: None)
239
+ assert first["stopped"] == "run budget reached" and first["year_chunks_remaining"] > 0
240
+ weather.backfill_history(gazetteer, start=start, budget=10_000, fetch=fake_fetch(calls), sleep=lambda s: None)
241
+ assert weather._missing_years(gazetteer, "ng.lagos", start, date.today() - timedelta(days=weather.ARCHIVE_LAG_DAYS)) == []
242
+ # A third run has nothing to fetch.
243
+ n = len(calls)
244
+ weather.backfill_history(gazetteer, start=start, budget=10_000, fetch=fake_fetch(calls), sleep=lambda s: None)
245
+ assert len(calls) == n
246
+
247
+
248
+ def test_backfill_resumes_current_year_from_last_day(gazetteer):
249
+ end = date.today() - timedelta(days=weather.ARCHIVE_LAG_DAYS)
250
+ start = date(end.year, 1, 1)
251
+ weather.backfill_history(gazetteer, start=start, fetch=fake_fetch([]), sleep=lambda s: None)
252
+ gazetteer.execute("delete from observations where period_start > ?", ((end - timedelta(days=3)).isoformat(),))
253
+ spans = weather._missing_years(gazetteer, "ng.lagos", start, end)
254
+ assert spans == [(end - timedelta(days=2), end)]
255
+
256
+
257
+ def test_quota_429_is_partial_success_not_error(gazetteer):
258
+ def fetch(url, params):
259
+ raise weather.QuotaExhausted("daily limit")
260
+ out = weather.backfill_history(gazetteer, start=date(date.today().year, 1, 1), fetch=fetch, sleep=lambda s: None)
261
+ assert "quota" in out["stopped"]
262
+
263
+
264
+ def test_forecast_rows_are_marked_and_versioned(gazetteer):
265
+ weather.fetch_forecast(gazetteer, days=3, fetch=fake_fetch([]))
266
+ rows = gazetteer.execute("select distinct is_forecast, issued_at from observations").fetchall()
267
+ assert [tuple(r) for r in rows] == [(1, date.today().isoformat())]
268
+
269
+
270
+ # ─── places category mapping ────────────────────────────────────────────────
271
+
272
+ def test_subsector_prefers_detailed_taxonomy():
273
+ assert subsector_for("personal_or_beauty_service", "barber") == "hair_services"
274
+ assert subsector_for("personal_or_beauty_service", "makeup_artist") == "skincare_cosmetics"
275
+ assert subsector_for("restaurant", None) == "catering_food_service"
276
+
277
+
278
+ def test_context_places_have_no_subsector():
279
+ assert subsector_for("christian_place_of_worship", None) is None
280
+ assert subsector_for(None, None) is None
281
+
282
+
283
+ def test_mapped_subsectors_are_real_ids():
284
+ from news.business_types import VALID_IDS
285
+ from intel.subsector_map import BASIC_TO_SUBSECTOR, TAXONOMY_TO_SUBSECTOR
286
+ assert set(BASIC_TO_SUBSECTOR.values()) | set(TAXONOMY_TO_SUBSECTOR.values()) <= set(VALID_IDS)
287
+
288
+
289
+ # ─── news → events ──────────────────────────────────────────────────────────
290
+
291
+ def test_record_news_item_is_noop_when_store_off(monkeypatch):
292
+ from intel.events import record_news_item
293
+ monkeypatch.delenv("INTEL_DB_PATH", raising=False)
294
+ assert record_news_item({"url_hash": "x", "title": "t"}) is False
295
+
296
+
297
+ def test_record_news_item_writes_located_event(monkeypatch, tmp_path):
298
+ from intel.events import record_news_item
299
+ path = tmp_path / "intel.db"
300
+ monkeypatch.setenv("INTEL_DB_PATH", str(path))
301
+ c = db.connect()
302
+ db.init(c)
303
+ add_location(c, "ng", "country", "Nigeria")
304
+ add_location(c, "ng.lagos", "state", "Lagos", "ng")
305
+ add_location(c, "ng.lagos.eti-osa", "lga", "Eti-Osa", "ng.lagos", aliases=("Lekki",))
306
+ c.commit()
307
+ item = {"url_hash": "abc", "title": "Lekki traders face new levy", "category": "policy", "location": "Lekki",
308
+ "published_at": "2026-10-01T09:00:00+00:00", "raw_text": "..."}
309
+ assert record_news_item(item) is True
310
+ assert record_news_item(item) is True # idempotent upsert
311
+ rows = c.execute("select family, location_id, starts_on from events where source_id = 'news_pipeline'").fetchall()
312
+ assert [tuple(r) for r in rows] == [("policy", "ng.lagos.eti-osa", "2026-10-01")]
313
+ c.close()
314
+
315
+
316
+ def test_record_news_item_never_raises(monkeypatch, tmp_path):
317
+ from intel.events import record_news_item
318
+ monkeypatch.setenv("INTEL_DB_PATH", str(tmp_path / "missing-schema.db"))
319
+ assert record_news_item({"url_hash": "x", "title": "t"}) is False