#!/usr/bin/env python3 """PROTOTYPE geo audit (not production). Codex/Claude plan: before building any "Closer to Home" UI or touching the production classify schema, measure what subject-geography the LLM can actually extract from recent good-news articles, and whether it understands WHERE A STORY HAPPENED vs. merely spotting place names. Key taxonomy decision: "local" is relative to the VIEWER, so we do NOT store it. We store the article's intrinsic geographic BREADTH (locality/regional/national/ multinational/global/unknown) plus the actual place(s). The UI later decides "Near you" by comparing those places to the visitor's chosen home. This writes results to a scratch JSON file and prints a coverage report. It does not migrate the DB, change the classify pipeline, or backfill anything. Run (host can reach the LAN model): .venv/bin/python scripts/geo_audit.py --limit 400 --base-url http://127.0.0.1:8080/v1 Resumable: re-running skips article ids already in the out file. """ from __future__ import annotations import argparse import json import statistics from collections import Counter from pathlib import Path from goodnews.cli import _default_db from goodnews.db import connect from goodnews.llm import LocalModelClient, parse_classifier_json BREADTHS = {"locality", "regional", "national", "multinational", "global", "unknown"} SYSTEM = ( "You tag the real-world geography of a news story for a calm good-news site. " "Identify the place(s) the story is fundamentally ABOUT or where it HAPPENED — " "NOT places mentioned only in passing. Many good-news stories (general science, " "space, broad research) have no specific place; those are 'global'. If a location " "is only incidental or genuinely unclear, use 'unknown'. Do not guess. " "Reply with ONLY a JSON object, no prose." ) INSTRUCT = ( "Return JSON exactly like:\n" '{"breadth": "", ' '"places": [{"country": "", "state_province": "", ' '"locality": ""}], "confidence": "", ' '"rationale": ""}\n' "breadth guide: locality=a specific city/town/county; regional=a state/province/region; " "national=about a whole country; multinational=a few specific countries; " "global=worldwide or no specific country; unknown=incidental/unclear. " "places may list more than one when a story genuinely spans regions; use null for parts you can't support." ) def fetch(conn, limit): return conn.execute( """ SELECT a.id, a.title, a.description, a.published_at, a.discovered_at, sm.summary, sm.what_happened, sm.why_matters FROM articles a JOIN article_scores s ON s.article_id = a.id LEFT JOIN article_summaries sm ON sm.article_id = a.id WHERE s.accepted = 1 AND a.duplicate_of IS NULL ORDER BY a.discovered_at DESC LIMIT ? """, (limit,), ).fetchall() def article_text(r): parts = [f"TITLE: {r['title']}"] for label, key in (("SUMMARY", "summary"), ("WHAT HAPPENED", "what_happened"), ("WHY IT MATTERS", "why_matters"), ("PUBLISHER BLURB", "description")): v = r[key] if v: parts.append(f"{label}: {v}") return "\n".join(parts) def extract(client, r): messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": article_text(r) + "\n\n" + INSTRUCT}, ] raw = client.chat_text(messages) data = parse_classifier_json(raw) # raises on unparseable breadth = data.get("breadth") if breadth not in BREADTHS: breadth = "unknown" places = data.get("places") places = [p for p in places if isinstance(p, dict)] if isinstance(places, list) else [] conf = data.get("confidence") if data.get("confidence") in {"high", "medium", "low"} else "low" return { "breadth": breadth, "places": places, "confidence": conf, "rationale": (data.get("rationale") or "")[:300], } def report(rows, results): by_id = {r["id"]: r for r in rows} n = len(results) print(f"\n===== GEO AUDIT REPORT (n={n}) =====") if not n: return breadth = Counter(v["breadth"] for v in results.values()) conf = Counter(v["confidence"] for v in results.values()) countries = Counter() states = Counter() unknown = 0 for v in results.values(): if v["breadth"] == "unknown" or not v["places"]: unknown += 1 for p in v["places"]: if p.get("country"): countries[str(p["country"]).strip()] += 1 if p.get("state_province"): states[str(p["state_province"]).strip()] += 1 def pct(x): return f"{100*x/n:.0f}%" print("\nBreadth:") for k in ("locality", "regional", "national", "multinational", "global", "unknown"): print(f" {k:<13} {breadth.get(k,0):>4} {pct(breadth.get(k,0))}") print(f"\nUnknown/no-place rate: {unknown}/{n} {pct(unknown)}") print("Confidence:", dict(conf)) print("\nTop countries:") for name, c in countries.most_common(12): print(f" {name:<22} {c}") print("\nTop states/provinces:") for name, c in states.most_common(12): print(f" {name:<22} {c}") # US-local fuel check: how many map to a US state (the "Near you" payload for Americans) us_local = sum(1 for v in results.values() if any((p.get("country") or "").strip() in ("United States", "USA", "US") and p.get("state_province") for p in v["places"])) print(f"\nArticles with a US state attached (US 'Near you' fuel): {us_local} {pct(us_local)}") # freshness days = [by_id[int(i)]["discovered_at"][:10] for i in results if by_id.get(int(i)) and by_id[int(i)]["discovered_at"]] if days: print(f"\nFreshness: {min(days)} .. {max(days)} ({len(set(days))} distinct days)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--db", default=None) ap.add_argument("--limit", type=int, default=400) ap.add_argument("--inspect", type=int, default=8, help="print N samples for manual eyeballing") ap.add_argument("--out", default="data/geo_audit.json") ap.add_argument("--base-url", default=None) ap.add_argument("--model", default=None) args = ap.parse_args() conn = connect(args.db or str(_default_db())) client = LocalModelClient.from_env() if args.base_url: client.base_url = args.base_url.rstrip("/") if args.model: client.model = args.model out = Path(args.out) results = json.loads(out.read_text()) if out.exists() else {} rows = fetch(conn, args.limit) print(f"Fetched {len(rows)} accepted non-dup articles; {len(results)} already done.") done = 0 for r in rows: rid = str(r["id"]) if rid in results: continue try: results[rid] = extract(client, r) except Exception as exc: # noqa: BLE001 — prototype; record + continue results[rid] = {"breadth": "unknown", "places": [], "confidence": "low", "rationale": f"ERROR: {type(exc).__name__}: {exc}"[:300], "error": True} done += 1 if done % 25 == 0: out.write_text(json.dumps(results, indent=1)) print(f" ...{done} new, {len(results)} total") out.write_text(json.dumps(results, indent=1)) conn.close() # Manual-inspection sample: the step Codex flagged as essential — eyeball whether # the model captured WHERE IT HAPPENED, not just place-name recognition. print(f"\n----- SAMPLE FOR MANUAL INSPECTION (first {args.inspect}) -----") shown = 0 for r in rows: rid = str(r["id"]) if rid not in results: continue v = results[rid] print(f"\n[{rid}] {r['title']}") print(f" breadth={v['breadth']} conf={v['confidence']} places={v['places']}") print(f" why: {v['rationale']}") shown += 1 if shown >= args.inspect: break report(rows, results) if __name__ == "__main__": main()