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

Sync from GitHub · 2026-10-20 19:36 UTC

Browse files
news/classify.py CHANGED
@@ -12,7 +12,7 @@ from datetime import date
12
  from utils.groq_client import call_groq_json, LIGHT_MODEL
13
  from news.business_types import labels_for_prompt, valid_subset
14
 
15
- IMPACTS = ("threat", "opportunity", "neutral")
16
  CATEGORIES = ("policy", "weather", "infrastructure", "funding", "security", "other")
17
 
18
  _SYSTEM_PROMPT = f"""You classify a single news/bulletin item for VenThrive, a Nigerian small-business platform.
@@ -28,27 +28,28 @@ Read the title and text given, then output ONLY a JSON object with these exact f
28
  Business type taxonomy (use these ids exactly, do not invent new ones):
29
  {labels_for_prompt()}
30
 
31
- "threat" and "opportunity" are reserved for items with a CONCRETE, near-term consequence for how
32
- a small business actually operates — a cost that goes up, a new restriction or compliance
33
- requirement, a market or supply disruption, a scheme/grant/program it could apply to, a new
34
- customer segment or channel opening up. If the only effect is on government finances, a
35
- company's share price, politics, or the general public, and there's no specific operational
36
- change a small-business owner would need to act on, that is "neutral" even if the headline sounds
37
- dramatic. A single incident (an accident, a crime, an arrest) is "neutral" unless the text itself
38
- states a resulting regulatory or operational change businesses must respond to. An aspirational
39
- government target or long-term roadmap with no specific program, deadline, or eligibility a
40
- business could act on today is "neutral", not "opportunity" — reserve "opportunity" for something
41
- a business owner could actually apply to, access, or benefit from now.
 
42
 
43
  Examples:
44
- - A new CBN forex directive raising import costs -> impact=threat, category=policy, business_types=[relevant import/trade subsectors], location=Nationwide
45
- - A flood warning for Lagos -> impact=threat, category=weather, business_types=[subsectors with physical premises/stock in the area], location=Lagos
46
  - A state SME grant with an application deadline of 2026-12-01 -> impact=opportunity, category=funding, deadline=2026-12-01
47
  - A tax holiday for new small businesses with no stated end date -> impact=opportunity, category=policy, deadline=null
 
 
48
  - A plane makes an emergency landing with no stated regulatory fallout -> impact=neutral, category=other (an isolated incident, not a business-operational change)
49
  - A senator publicly criticising a regulator's pace, with no rule actually changed yet -> impact=neutral, category=policy (nothing has changed for a business yet)
50
  - A 10-year national capital-market target or roadmap with no program a business can apply to today -> impact=neutral, category=policy (aspirational, not actionable)
51
- - A stock exchange index falling on a single trading day -> impact=neutral, category=other unless the text names a specific operational consequence for non-listed small businesses
52
 
53
  If the item is not genuinely business-relevant, or you cannot determine a category, business
54
  type, or location with reasonable confidence, leave the relevant field null/empty rather than
 
12
  from utils.groq_client import call_groq_json, LIGHT_MODEL
13
  from news.business_types import labels_for_prompt, valid_subset
14
 
15
+ IMPACTS = ("opportunity", "neutral")
16
  CATEGORIES = ("policy", "weather", "infrastructure", "funding", "security", "other")
17
 
18
  _SYSTEM_PROMPT = f"""You classify a single news/bulletin item for VenThrive, a Nigerian small-business platform.
 
28
  Business type taxonomy (use these ids exactly, do not invent new ones):
29
  {labels_for_prompt()}
30
 
31
+ There is no "threat" value. Reporting that something bad already happened has no value to a
32
+ business owner who can no longer act on it — a cost increase, a flood, or a security incident is
33
+ only useful here if it is a live, ongoing condition the owner can still respond to today (e.g. a
34
+ flood warning still in effect, a tax change not yet in force). Forward-looking, predictive risk
35
+ ("what's likely to happen") is a separate system, not this classifier's job — don't try to infer
36
+ or imply it.
37
+
38
+ "opportunity" is reserved for something a business owner could actually apply to, access, or
39
+ benefit from now: a scheme/grant/program with open eligibility, a new customer segment or channel
40
+ opening up, a still-pending deadline. Everything else — including a cost going up, a disruption,
41
+ an incident, an aspirational government target, or a roadmap with no program to act on today — is
42
+ "neutral". Err toward "neutral" whenever in doubt; "opportunity" is the narrow, specific case.
43
 
44
  Examples:
 
 
45
  - A state SME grant with an application deadline of 2026-12-01 -> impact=opportunity, category=funding, deadline=2026-12-01
46
  - A tax holiday for new small businesses with no stated end date -> impact=opportunity, category=policy, deadline=null
47
+ - A new CBN forex directive raising import costs -> impact=neutral, category=policy, business_types=[relevant import/trade subsectors], location=Nationwide (nothing a business can apply to or act on, just a cost change)
48
+ - A flood warning for Lagos -> impact=neutral, category=weather, business_types=[subsectors with physical premises/stock in the area], location=Lagos
49
  - A plane makes an emergency landing with no stated regulatory fallout -> impact=neutral, category=other (an isolated incident, not a business-operational change)
50
  - A senator publicly criticising a regulator's pace, with no rule actually changed yet -> impact=neutral, category=policy (nothing has changed for a business yet)
51
  - A 10-year national capital-market target or roadmap with no program a business can apply to today -> impact=neutral, category=policy (aspirational, not actionable)
52
+ - A stock exchange index falling on a single trading day -> impact=neutral, category=other
53
 
54
  If the item is not genuinely business-relevant, or you cannot determine a category, business
55
  type, or location with reasonable confidence, leave the relevant field null/empty rather than
news/pipeline.py CHANGED
@@ -1,7 +1,10 @@
1
  """
2
  Orchestrates one admin-triggered poll (ADR-0015): scrape every active source,
3
- dedup by URL hash, classify everything that survives dedup, apply the
4
- completeness bar, and record a run summary in news_polls.
 
 
 
5
 
6
  A single source failing to fetch (network error, bad feed, site down) must not
7
  fail the whole poll — it's logged and skipped, same as DataWiz's approach to
@@ -16,6 +19,7 @@ from datetime import datetime, timedelta, timezone
16
  from news.scrape import fetch_source
17
  from news.dedup import url_hash
18
  from news.classify import classify_item
 
19
  from news.windows import compute_expiry
20
  from utils.supabase_client import get_supabase
21
 
@@ -66,6 +70,8 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
66
  sources = db.table("news_sources").select("*").eq("active", True).execute().data
67
 
68
  for source in sources:
 
 
69
  try:
70
  raw_items = fetch_source(source)
71
  except Exception as e:
@@ -83,6 +89,28 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
83
  continue
84
 
85
  counts["items_scraped"] += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  try:
88
  result = classify_item(item.title, item.text)
@@ -90,7 +118,6 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
90
  print(f"[news] classification failed for {item.url}: {e}")
91
  continue
92
 
93
- scraped_at = datetime.now(timezone.utc)
94
  expires_at = compute_expiry(result.category, scraped_at, result.deadline)
95
  status = _route(result)
96
 
@@ -126,6 +153,7 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
126
 
127
  db.table("news_polls").update({
128
  "finished_at": datetime.now(timezone.utc).isoformat(),
 
129
  "error": error,
130
  **counts,
131
  }).eq("id", poll_id).execute()
 
1
  """
2
  Orchestrates one admin-triggered poll (ADR-0015): scrape every active source,
3
+ dedup by URL hash, keyword-prefilter (news/relevance.py) what survives dedup,
4
+ classify the business-relevant remainder through Groq, apply the completeness
5
+ bar, and record a run summary in news_polls. The keyword prefilter exists
6
+ because Groq is the expensive step — an item with zero business/economy signal
7
+ is auto-rejected before it ever reaches classify_item().
8
 
9
  A single source failing to fetch (network error, bad feed, site down) must not
10
  fail the whole poll — it's logged and skipped, same as DataWiz's approach to
 
19
  from news.scrape import fetch_source
20
  from news.dedup import url_hash
21
  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
 
 
70
  sources = db.table("news_sources").select("*").eq("active", True).execute().data
71
 
72
  for source in sources:
73
+ db.table("news_polls").update({"current_source": source["name"], **counts}).eq("id", poll_id).execute()
74
+
75
  try:
76
  raw_items = fetch_source(source)
77
  except Exception as e:
 
89
  continue
90
 
91
  counts["items_scraped"] += 1
92
+ scraped_at = datetime.now(timezone.utc)
93
+
94
+ # Keyword pre-filter (DataWiz's relevance-gate pattern): a scraped item with
95
+ # no business/economy/regulatory signal at all never reaches Groq — it's
96
+ # auto-rejected for free instead of burning an AI call on sports, celebrity
97
+ # or isolated-incident filler that a feed mixes in alongside real items.
98
+ relevance = relevance_score(f"{item.title} {item.text}")
99
+ if not relevance["is_candidate"]:
100
+ db.table("news_items").insert({
101
+ "source_id": source["id"],
102
+ "url": item.url,
103
+ "url_hash": h,
104
+ "title": item.title,
105
+ "raw_text": item.text,
106
+ "published_at": item.published_at.isoformat() if item.published_at else None,
107
+ "scraped_at": scraped_at.isoformat(),
108
+ "status": "rejected",
109
+ "review_reason": f"Pre-filter: {relevance['reason']}",
110
+ "expires_at": scraped_at.isoformat(),
111
+ }).execute()
112
+ counts["items_rejected"] += 1
113
+ continue
114
 
115
  try:
116
  result = classify_item(item.title, item.text)
 
118
  print(f"[news] classification failed for {item.url}: {e}")
119
  continue
120
 
 
121
  expires_at = compute_expiry(result.category, scraped_at, result.deadline)
122
  status = _route(result)
123
 
 
153
 
154
  db.table("news_polls").update({
155
  "finished_at": datetime.now(timezone.utc).isoformat(),
156
+ "current_source": None,
157
  "error": error,
158
  **counts,
159
  }).eq("id", poll_id).execute()
news/relevance.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Business-relevance pre-filter (DataWiz-Project's `pipeline/relevance.py` pattern,
2
+ applied to MSME news instead of security incidents).
3
+
4
+ Groq is the expensive step of this pipeline, not scraping: every scraped item that
5
+ reaches classify_item() costs an API call, and a feed mixes in sports, entertainment,
6
+ school-life and aviation-incident filler alongside the genuinely business-relevant
7
+ items. This module is a cheap, zero-cost keyword check that runs first — an item
8
+ with no match to any business/economy/regulatory signal never reaches Groq at all
9
+ and is auto-rejected on the spot, the same way DataWiz discards a zero-match article
10
+ before it ever reaches the NLP extractor.
11
+
12
+ This is intentionally coarser than DataWiz's incident classifier: it only answers
13
+ "is this worth spending a Groq call on", not what category/impact it is — that fine
14
+ classification still happens in classify.py. A false positive here just costs one
15
+ wasted Groq call (same as today); a false negative would silently drop a relevant
16
+ item, so the keyword list stays broad on purpose.
17
+ """
18
+
19
+ import re
20
+
21
+ # Roots covering the five categories classify.py sorts into, plus general
22
+ # economic/business-operational signal. Stored as roots, matched as `\b<root>\w*\b`
23
+ # so one entry covers inflected forms (e.g. "levy" matches "levies").
24
+ BUSINESS_KEYWORDS: dict[str, str] = {
25
+ # policy / regulatory
26
+ "tax": "policy", "vat": "policy", "levy": "policy", "tariff": "policy",
27
+ "duty": "policy", "customs": "policy", "excise": "policy", "regulat": "policy",
28
+ "cbn": "policy", "naira": "policy", "forex": "policy", "fx rate": "policy",
29
+ "exchange rate": "policy", "interest rate": "policy", "monetary polic": "policy",
30
+ "minimum wage": "policy", "subsidy": "policy", "fuel price": "policy",
31
+ "petrol price": "policy", "firs": "policy", "son ": "policy", "nafdac": "policy",
32
+ "ban on": "policy", "import ban": "policy", "export ban": "policy",
33
+ "license": "policy", "licence": "policy", "compliance": "policy",
34
+ "inflation": "policy", "devalu": "policy",
35
+ # weather
36
+ "flood": "weather", "rainfall": "weather", "drought": "weather",
37
+ "heatwave": "weather", "harmattan": "weather", "storm": "weather",
38
+ "nimet": "weather", "weather alert": "weather", "weather warning": "weather",
39
+ "windstorm": "weather", "erosion": "weather",
40
+ # infrastructure
41
+ "power outage": "infrastructure", "blackout": "infrastructure",
42
+ "electricity tariff": "infrastructure", "grid collapse": "infrastructure",
43
+ "fuel scarcity": "infrastructure", "port congestion": "infrastructure",
44
+ "road closure": "infrastructure", "bridge collaps": "infrastructure",
45
+ "network outage": "infrastructure", "internet shutdown": "infrastructure",
46
+ "telecom": "infrastructure", "logistics": "infrastructure",
47
+ "supply chain": "infrastructure", "diesel price": "infrastructure",
48
+ # funding / programs
49
+ "grant": "funding", "loan scheme": "funding", "funding": "funding",
50
+ "boi": "funding", "nirsan": "funding", "smedan": "funding",
51
+ "empowerment program": "funding", "empowerment scheme": "funding",
52
+ "accelerator": "funding", "incubat": "funding", "disburse": "funding",
53
+ "credit facility": "funding", "intervention fund": "funding",
54
+ "msme": "funding", "sme ": "funding", "startup fund": "funding",
55
+ # security (business-operational, not generic crime)
56
+ "kidnap": "security", "bandit": "security", "robbery": "security",
57
+ "protest": "security", "strike action": "security", "shutdown order": "security",
58
+ "curfew": "security", "unrest": "security", "insecurity": "security",
59
+ "looting": "security", "market burnt": "security", "market fire": "security",
60
+ # general business/economy signal not tied to one category
61
+ "business": "other", "trade": "other", "commerce": "other", "market": "other",
62
+ "manufactur": "other", "agribusiness": "other", "investment": "other",
63
+ "economy": "other", "economic": "other", "entrepreneur": "other",
64
+ "export": "other", "import": "other", "stock exchange": "other",
65
+ "naira exchange": "other", "gdp": "other",
66
+ }
67
+
68
+ _PATTERN_OVERRIDES = {
69
+ "son ": r"\bSON\b",
70
+ "sme ": r"\bSMEs?\b",
71
+ }
72
+
73
+
74
+ def _compile(root: str) -> re.Pattern:
75
+ if root in _PATTERN_OVERRIDES:
76
+ return re.compile(_PATTERN_OVERRIDES[root], re.IGNORECASE)
77
+ if " " in root:
78
+ return re.compile(r"\b" + re.escape(root.strip()) + r"\w*\b", re.IGNORECASE)
79
+ return re.compile(r"\b" + re.escape(root) + r"\w*\b", re.IGNORECASE)
80
+
81
+
82
+ _PATTERNS = {root: _compile(root) for root in BUSINESS_KEYWORDS}
83
+
84
+
85
+ def score(text: str) -> dict:
86
+ """Cheap verdict on whether `text` is worth a Groq call.
87
+
88
+ Zero matches -> not a candidate; classify_item() is skipped entirely and the
89
+ item is auto-rejected. Any match -> candidate; Groq still does the real
90
+ classification (category/impact/business_types/location), since a keyword
91
+ hit only proves the text is business-adjacent, not which category it is.
92
+ """
93
+ matched = [root.strip() for root, pat in _PATTERNS.items() if pat.search(text)]
94
+ is_candidate = bool(matched)
95
+ reason = None if is_candidate else "no business-relevant keyword matched"
96
+ return {
97
+ "is_candidate": is_candidate,
98
+ "matched_keywords": matched,
99
+ "reason": reason,
100
+ }
routes/admin.py CHANGED
@@ -9,6 +9,7 @@ Routes:
9
  PUT /api/admin/users/{uid} → update locked fields (fullName, phone, role)
10
  DELETE /api/admin/users/{uid} → delete user account + Firestore data
11
  GET /api/admin/stats → platform overview stats
 
12
  """
13
 
14
  from fastapi import APIRouter, HTTPException, Depends
@@ -16,6 +17,7 @@ from pydantic import BaseModel
16
  from firebase_admin import auth as fb_auth, firestore
17
  from auth import verify_firebase_token
18
  from routes.account import delete_all_user_data
 
19
 
20
  router = APIRouter()
21
 
@@ -116,6 +118,18 @@ def delete_user(
116
  return {"success": True, "deleted": target_uid}
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  @router.get("/stats")
120
  def platform_stats(user: dict = Depends(verify_firebase_token)):
121
  _verify_admin(user)
 
9
  PUT /api/admin/users/{uid} → update locked fields (fullName, phone, role)
10
  DELETE /api/admin/users/{uid} → delete user account + Firestore data
11
  GET /api/admin/stats → platform overview stats
12
+ GET /api/admin/audit → audit trail of manual admin actions
13
  """
14
 
15
  from fastapi import APIRouter, HTTPException, Depends
 
17
  from firebase_admin import auth as fb_auth, firestore
18
  from auth import verify_firebase_token
19
  from routes.account import delete_all_user_data
20
+ from utils.supabase_client import get_supabase
21
 
22
  router = APIRouter()
23
 
 
118
  return {"success": True, "deleted": target_uid}
119
 
120
 
121
+ @router.get("/audit")
122
+ def audit_trail(kind: str | None = None, limit: int = 200, user: dict = Depends(verify_firebase_token)):
123
+ """Every manual admin action (review decisions, source toggles, run starts),
124
+ newest first — see backend/sql/admin_audit_schema.sql."""
125
+ _verify_admin(user)
126
+ db = get_supabase()
127
+ query = db.table("admin_audit_log").select("*").order("at", desc=True).limit(limit)
128
+ if kind:
129
+ query = query.eq("kind", kind)
130
+ return {"entries": query.execute().data}
131
+
132
+
133
  @router.get("/stats")
134
  def platform_stats(user: dict = Depends(verify_firebase_token)):
135
  _verify_admin(user)
routes/news.py CHANGED
@@ -24,6 +24,7 @@ from news.pipeline import start_poll, execute_poll
24
  from news.classify import IMPACTS, CATEGORIES
25
  from news.business_types import valid_subset
26
  from utils.supabase_client import get_supabase
 
27
 
28
  router = APIRouter()
29
 
@@ -49,6 +50,7 @@ def poll_news(body: PollRequest, background_tasks: BackgroundTasks, user: dict =
49
  admin_uid = _verify_admin(user)
50
  poll_id = start_poll(since_days=body.since_days, triggered_by=admin_uid)
51
  background_tasks.add_task(execute_poll, poll_id, body.since_days)
 
52
  return {"poll_id": poll_id, "started": True}
53
 
54
 
@@ -95,9 +97,11 @@ class SourceUpdate(BaseModel):
95
 
96
  @router.patch("/admin/sources/{source_id}")
97
  def update_source(source_id: str, body: SourceUpdate, user: dict = Depends(verify_firebase_token)):
98
- _verify_admin(user)
99
  db = get_supabase()
100
- db.table("news_sources").update({"active": body.active}).eq("id", source_id).execute()
 
 
101
  return {"success": True}
102
 
103
 
@@ -107,7 +111,7 @@ def list_polls(user: dict = Depends(verify_firebase_token)):
107
  db = get_supabase()
108
  polls = (
109
  db.table("news_polls")
110
- .select("id, triggered_by, since_days, started_at, finished_at, items_scraped, items_deduped_out, items_classified, items_flagged, items_rejected, error")
111
  .order("started_at", desc=True)
112
  .limit(10)
113
  .execute()
@@ -178,11 +182,13 @@ class ReviewFix(BaseModel):
178
 
179
  @router.patch("/admin/review/{item_id}")
180
  def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_firebase_token)):
181
- _verify_admin(user)
182
  db = get_supabase()
183
 
184
  if body.status == "rejected":
185
- db.table("news_items").update({"status": "rejected"}).eq("id", item_id).execute()
 
 
186
  return {"success": True, "status": "rejected"}
187
 
188
  # Completing by hand still has to satisfy the same bar automated classification does.
@@ -195,7 +201,7 @@ def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_f
195
  if not (body.category and body.location and business_types):
196
  raise HTTPException(status_code=400, detail="category, location and at least one valid business_type are required to mark this classified.")
197
 
198
- db.table("news_items").update({
199
  "status": "classified",
200
  "impact": body.impact,
201
  "category": body.category,
@@ -203,6 +209,8 @@ def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_f
203
  "location": body.location,
204
  "review_reason": None,
205
  "classified_at": datetime.now(timezone.utc).isoformat(),
206
- }).eq("id", item_id).execute()
207
 
 
 
208
  return {"success": True, "status": "classified"}
 
24
  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
 
 
50
  admin_uid = _verify_admin(user)
51
  poll_id = start_poll(since_days=body.since_days, triggered_by=admin_uid)
52
  background_tasks.add_task(execute_poll, poll_id, body.since_days)
53
+ log_action(admin_uid, "run", "started", f"Looking back {body.since_days} day(s)", target=poll_id)
54
  return {"poll_id": poll_id, "started": True}
55
 
56
 
 
97
 
98
  @router.patch("/admin/sources/{source_id}")
99
  def update_source(source_id: str, body: SourceUpdate, user: dict = Depends(verify_firebase_token)):
100
+ admin_uid = _verify_admin(user)
101
  db = get_supabase()
102
+ updated = db.table("news_sources").update({"active": body.active}).eq("id", source_id).execute().data
103
+ name = updated[0]["name"] if updated else source_id
104
+ log_action(admin_uid, "source", "enabled" if body.active else "disabled", name, target=source_id)
105
  return {"success": True}
106
 
107
 
 
111
  db = get_supabase()
112
  polls = (
113
  db.table("news_polls")
114
+ .select("id, triggered_by, since_days, started_at, finished_at, items_scraped, items_deduped_out, items_classified, items_flagged, items_rejected, current_source, error")
115
  .order("started_at", desc=True)
116
  .limit(10)
117
  .execute()
 
182
 
183
  @router.patch("/admin/review/{item_id}")
184
  def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_firebase_token)):
185
+ admin_uid = _verify_admin(user)
186
  db = get_supabase()
187
 
188
  if body.status == "rejected":
189
+ updated = db.table("news_items").update({"status": "rejected"}).eq("id", item_id).execute().data
190
+ title = updated[0]["title"] if updated else item_id
191
+ log_action(admin_uid, "review", "discarded", title, target=item_id)
192
  return {"success": True, "status": "rejected"}
193
 
194
  # Completing by hand still has to satisfy the same bar automated classification does.
 
201
  if not (body.category and body.location and business_types):
202
  raise HTTPException(status_code=400, detail="category, location and at least one valid business_type are required to mark this classified.")
203
 
204
+ updated = db.table("news_items").update({
205
  "status": "classified",
206
  "impact": body.impact,
207
  "category": body.category,
 
209
  "location": body.location,
210
  "review_reason": None,
211
  "classified_at": datetime.now(timezone.utc).isoformat(),
212
+ }).eq("id", item_id).execute().data
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"}
sql/admin_audit_schema.sql ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Admin audit trail — every manual admin action (review decisions, source
2
+ -- toggles, poll runs), newest first. Mirrors DataWiz-Project's /admin/audit.
3
+ -- Automatic pipeline decisions (auto-publish, auto-discard) are NOT logged
4
+ -- here — they're already visible per-item in news_items.status, and logging
5
+ -- every one of them would make this table useless as a human-action trail.
6
+
7
+ create extension if not exists pgcrypto;
8
+
9
+ create table if not exists admin_audit_log (
10
+ id uuid primary key default gen_random_uuid(),
11
+ at timestamptz not null default now(),
12
+ actor text not null, -- admin's Firebase UID
13
+ kind text not null check (kind in ('review', 'source', 'run')),
14
+ action text not null, -- e.g. 'published', 'discarded', 'enabled', 'disabled', 'started'
15
+ target text, -- the item/source/poll id this action was on
16
+ summary text not null -- short human-readable description, e.g. the item title
17
+ );
18
+
19
+ create index if not exists admin_audit_log_at_idx on admin_audit_log (at desc);
sql/news_schema.sql CHANGED
@@ -47,8 +47,10 @@ create table if not exists news_items (
47
  status text not null default 'pending'
48
  check (status in ('pending', 'classified', 'needs_review', 'rejected')),
49
 
50
- -- Classification output (ADR-0015) — all null until classified.
51
- impact text check (impact in ('threat', 'opportunity', 'neutral')),
 
 
52
  category text check (category in ('policy', 'weather', 'infrastructure', 'funding', 'security', 'other')),
53
  business_types text[], -- subsector ids from data/sectors.ts, see backend/news/subsectors.json
54
  location text,
@@ -86,7 +88,16 @@ create table if not exists news_polls (
86
  items_classified int not null default 0,
87
  items_flagged int not null default 0,
88
  items_rejected int not null default 0, -- auto-discarded: incomplete and the model wasn't confident either
 
89
  error text
90
  );
91
 
92
  alter table news_polls add column if not exists items_rejected int not null default 0;
 
 
 
 
 
 
 
 
 
47
  status text not null default 'pending'
48
  check (status in ('pending', 'classified', 'needs_review', 'rejected')),
49
 
50
+ -- Classification output (ADR-0015) — all null until classified. No 'threat' value:
51
+ -- reporting something bad that already happened has no value once it can't be acted
52
+ -- on; a predictive risk system is a separate, forward-looking feature.
53
+ impact text check (impact in ('opportunity', 'neutral')),
54
  category text check (category in ('policy', 'weather', 'infrastructure', 'funding', 'security', 'other')),
55
  business_types text[], -- subsector ids from data/sectors.ts, see backend/news/subsectors.json
56
  location text,
 
88
  items_classified int not null default 0,
89
  items_flagged int not null default 0,
90
  items_rejected int not null default 0, -- auto-discarded: incomplete and the model wasn't confident either
91
+ current_source text, -- name of the source being scraped right now, for a live run console
92
  error text
93
  );
94
 
95
  alter table news_polls add column if not exists items_rejected int not null default 0;
96
+ alter table news_polls add column if not exists current_source text;
97
+
98
+ -- 'threat' removed from impact (ADR-0015 addendum): a reactive "this already happened"
99
+ -- label has no value to a business owner who can't act on it anymore. Existing rows
100
+ -- are reclassified neutral; a predictive risk/forecast system is planned separately.
101
+ update news_items set impact = 'neutral' where impact = 'threat';
102
+ alter table news_items drop constraint if exists news_items_impact_check;
103
+ alter table news_items add constraint news_items_impact_check check (impact in ('opportunity', 'neutral'));
tests/fixtures/groq/news_classify_complete.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "impact": "threat",
3
  "category": "weather",
4
  "business_types": ["catering_food_service", "food_production_processing"],
5
  "location": "Lagos",
 
1
  {
2
+ "impact": "neutral",
3
  "category": "weather",
4
  "business_types": ["catering_food_service", "food_production_processing"],
5
  "location": "Lagos",
tests/test_news.py CHANGED
@@ -87,7 +87,7 @@ def test_classify_item_complete_result_is_complete(classify_module):
87
  result = classify_module.classify_item("Lagos flood warning", "text", fixture="news_classify_complete")
88
  assert result.is_complete
89
  assert result.review_reason() is None
90
- assert result.impact == "threat"
91
  assert result.category == "weather"
92
 
93
 
@@ -139,7 +139,7 @@ def test_classification_result_malformed_deadline_is_ignored():
139
  _SOME_VALID_ID = next(iter(VALID_IDS))
140
 
141
 
142
- def _result(impact="threat", category="weather", business_types=None, location="Lagos", confidence=None):
143
  business_types = [_SOME_VALID_ID] if business_types is None else business_types
144
  return ClassificationResult({
145
  "impact": impact, "category": category,
 
87
  result = classify_module.classify_item("Lagos flood warning", "text", fixture="news_classify_complete")
88
  assert result.is_complete
89
  assert result.review_reason() is None
90
+ assert result.impact == "neutral"
91
  assert result.category == "weather"
92
 
93
 
 
139
  _SOME_VALID_ID = next(iter(VALID_IDS))
140
 
141
 
142
+ def _result(impact="neutral", category="weather", business_types=None, location="Lagos", confidence=None):
143
  business_types = [_SOME_VALID_ID] if business_types is None else business_types
144
  return ClassificationResult({
145
  "impact": impact, "category": category,
tests/test_relevance.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the keyword pre-filter (news/relevance.py) that gates Groq calls."""
2
+
3
+ from news.relevance import score
4
+
5
+
6
+ def test_rejects_text_with_no_business_signal():
7
+ result = score("Bunk mates fall out over missing snacks at boarding school dormitory")
8
+ assert result["is_candidate"] is False
9
+ assert result["matched_keywords"] == []
10
+
11
+
12
+ def test_rejects_isolated_incident_with_no_business_angle():
13
+ result = score("Aircraft loses contact mid-flight before making emergency landing")
14
+ assert result["is_candidate"] is False
15
+
16
+
17
+ def test_accepts_policy_item():
18
+ result = score("CBN raises interest rate, forex dealers react")
19
+ assert result["is_candidate"] is True
20
+ assert "cbn" in result["matched_keywords"]
21
+
22
+
23
+ def test_accepts_funding_item():
24
+ result = score("BOI opens new loan scheme for small manufacturers")
25
+ assert result["is_candidate"] is True
26
+
27
+
28
+ def test_accepts_weather_item():
29
+ result = score("NiMet issues flood warning for Lagos ahead of heavy rainfall")
30
+ assert result["is_candidate"] is True
31
+
32
+
33
+ def test_accepts_security_item_with_business_operational_angle():
34
+ result = score("Bandits attack causes market shutdown order in Zamfara")
35
+ assert result["is_candidate"] is True
utils/audit.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Writes to the admin audit trail (backend/sql/admin_audit_schema.sql). Never
3
+ raises — a failed audit write must not break the action it's recording.
4
+ """
5
+
6
+ from utils.supabase_client import get_supabase
7
+
8
+
9
+ def log_action(actor: str, kind: str, action: str, summary: str, target: str | None = None) -> None:
10
+ try:
11
+ get_supabase().table("admin_audit_log").insert({
12
+ "actor": actor, "kind": kind, "action": action, "target": target, "summary": summary,
13
+ }).execute()
14
+ except Exception as e:
15
+ print(f"[audit] failed to log {kind}/{action}: {e}")