Spaces:
Running
Running
GitHub Actions commited on
Commit ·
b7c46f4
1
Parent(s): a32af27
Sync from GitHub · 2026-10-21 11:53 UTC
Browse files- intel/README.md +88 -0
- intel/__init__.py +0 -0
- intel/cli.py +108 -0
- intel/db.py +49 -0
- intel/events.py +70 -0
- intel/export.py +45 -0
- intel/freshness.py +118 -0
- intel/geo.py +97 -0
- intel/handoff.py +48 -0
- intel/ingest/__init__.py +0 -0
- intel/ingest/calendar.py +116 -0
- intel/ingest/overture_extract.py +63 -0
- intel/ingest/places.py +65 -0
- intel/ingest/weather.py +178 -0
- intel/locations.py +310 -0
- intel/nigeria_locations.json +961 -0
- intel/registry.py +59 -0
- intel/subsector_map.py +143 -0
- news/pipeline.py +8 -2
- requirements-intel.txt +4 -0
- routes/news.py +3 -0
- sql/intel_schema.sql +136 -0
- tests/test_intel.py +319 -0
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 |
-
|
| 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 |
-
}
|
|
|
|
| 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
|