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

Sync from GitHub · 2026-10-20 12:42 UTC

Browse files
Files changed (4) hide show
  1. news/pipeline.py +24 -2
  2. routes/news.py +69 -1
  3. sql/news_schema.sql +3 -0
  4. tests/test_news.py +37 -0
news/pipeline.py CHANGED
@@ -19,6 +19,26 @@ from news.classify import classify_item
19
  from news.windows import compute_expiry
20
  from utils.supabase_client import get_supabase
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  def start_poll(since_days: int, triggered_by: str) -> str:
24
  """Records the poll row and hands back its id immediately — the scrape+classify
@@ -39,7 +59,7 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
39
  db = get_supabase()
40
  cutoff = datetime.now(timezone.utc) - timedelta(days=since_days)
41
 
42
- counts = {"items_scraped": 0, "items_deduped_out": 0, "items_classified": 0, "items_flagged": 0}
43
  error: str | None = None
44
 
45
  try:
@@ -72,7 +92,7 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
72
 
73
  scraped_at = datetime.now(timezone.utc)
74
  expires_at = compute_expiry(result.category, scraped_at, result.deadline)
75
- status = "classified" if result.is_complete else "needs_review"
76
 
77
  db.table("news_items").insert({
78
  "source_id": source["id"],
@@ -96,6 +116,8 @@ def execute_poll(poll_id: str, since_days: int) -> dict:
96
 
97
  if status == "classified":
98
  counts["items_classified"] += 1
 
 
99
  else:
100
  counts["items_flagged"] += 1
101
 
 
19
  from news.windows import compute_expiry
20
  from utils.supabase_client import get_supabase
21
 
22
+ # Three-way routing by confidence (replaces the old complete/incomplete split).
23
+ # A complete, high-confidence item is business-relevant enough to publish with
24
+ # no human step. A low-confidence item — incomplete AND the model wasn't sure
25
+ # about it either — is almost always noise (celebrity gossip, a scraped nav
26
+ # menu) and gets discarded automatically rather than wasting a reviewer's time.
27
+ # Everything in between still needs a human: either it's complete but the
28
+ # model wasn't confident, or it's incomplete but confident enough that a
29
+ # person might be able to fill the gap.
30
+ AUTO_PUBLISH_CONFIDENCE = 0.8
31
+ AUTO_DISCARD_CONFIDENCE = 0.5
32
+
33
+
34
+ def _route(result) -> str:
35
+ confidence = result.confidence if result.confidence is not None else 0.0
36
+ if result.is_complete and confidence >= AUTO_PUBLISH_CONFIDENCE:
37
+ return "classified"
38
+ if not result.is_complete and confidence < AUTO_DISCARD_CONFIDENCE:
39
+ return "rejected"
40
+ return "needs_review"
41
+
42
 
43
  def start_poll(since_days: int, triggered_by: str) -> str:
44
  """Records the poll row and hands back its id immediately — the scrape+classify
 
59
  db = get_supabase()
60
  cutoff = datetime.now(timezone.utc) - timedelta(days=since_days)
61
 
62
+ counts = {"items_scraped": 0, "items_deduped_out": 0, "items_classified": 0, "items_flagged": 0, "items_rejected": 0}
63
  error: str | None = None
64
 
65
  try:
 
92
 
93
  scraped_at = datetime.now(timezone.utc)
94
  expires_at = compute_expiry(result.category, scraped_at, result.deadline)
95
+ status = _route(result)
96
 
97
  db.table("news_items").insert({
98
  "source_id": source["id"],
 
116
 
117
  if status == "classified":
118
  counts["items_classified"] += 1
119
+ elif status == "rejected":
120
+ counts["items_rejected"] += 1
121
  else:
122
  counts["items_flagged"] += 1
123
 
routes/news.py CHANGED
@@ -52,13 +52,62 @@ def poll_news(body: PollRequest, background_tasks: BackgroundTasks, user: dict =
52
  return {"poll_id": poll_id, "started": True}
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  @router.get("/admin/polls")
56
  def list_polls(user: dict = Depends(verify_firebase_token)):
57
  _verify_admin(user)
58
  db = get_supabase()
59
  polls = (
60
  db.table("news_polls")
61
- .select("id, triggered_by, since_days, started_at, finished_at, items_scraped, items_deduped_out, items_classified, items_flagged, error")
62
  .order("started_at", desc=True)
63
  .limit(10)
64
  .execute()
@@ -84,6 +133,25 @@ def list_items(business_type: str | None = None, user: dict = Depends(verify_fir
84
  return {"items": query.execute().data}
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  @router.get("/admin/review")
88
  def list_review_queue(user: dict = Depends(verify_firebase_token)):
89
  _verify_admin(user)
 
52
  return {"poll_id": poll_id, "started": True}
53
 
54
 
55
+ @router.get("/admin/overview")
56
+ def admin_overview(user: dict = Depends(verify_firebase_token)):
57
+ _verify_admin(user)
58
+ db = get_supabase()
59
+
60
+ def count(status: str) -> int:
61
+ res = db.table("news_items").select("id", count="exact").eq("status", status).execute()
62
+ return res.count or 0
63
+
64
+ sources = db.table("news_sources").select("active").execute().data
65
+ last_poll = (
66
+ db.table("news_polls")
67
+ .select("id, started_at, finished_at, error")
68
+ .order("started_at", desc=True)
69
+ .limit(1)
70
+ .execute()
71
+ .data
72
+ )
73
+
74
+ return {
75
+ "published": count("classified"),
76
+ "needs_review": count("needs_review"),
77
+ "rejected": count("rejected"),
78
+ "sources_active": sum(1 for s in sources if s["active"]),
79
+ "sources_total": len(sources),
80
+ "last_poll": last_poll[0] if last_poll else None,
81
+ }
82
+
83
+
84
+ @router.get("/admin/sources")
85
+ def list_sources(user: dict = Depends(verify_firebase_token)):
86
+ _verify_admin(user)
87
+ db = get_supabase()
88
+ sources = db.table("news_sources").select("*").order("name").execute().data
89
+ return {"sources": sources}
90
+
91
+
92
+ class SourceUpdate(BaseModel):
93
+ active: bool
94
+
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
+
104
  @router.get("/admin/polls")
105
  def list_polls(user: dict = Depends(verify_firebase_token)):
106
  _verify_admin(user)
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()
 
133
  return {"items": query.execute().data}
134
 
135
 
136
+ @router.get("/admin/items")
137
+ def admin_list_items(
138
+ status: str | None = None,
139
+ limit: int = 50,
140
+ offset: int = 0,
141
+ user: dict = Depends(verify_firebase_token),
142
+ ):
143
+ _verify_admin(user)
144
+ db = get_supabase()
145
+ query = db.table("news_items").select(
146
+ "id, title, url, impact, category, business_types, location, confidence, status, scraped_at, published_at, expires_at",
147
+ count="exact",
148
+ ).order("scraped_at", desc=True).range(offset, offset + limit - 1)
149
+ if status:
150
+ query = query.eq("status", status)
151
+ res = query.execute()
152
+ return {"items": res.data, "total": res.count or 0}
153
+
154
+
155
  @router.get("/admin/review")
156
  def list_review_queue(user: dict = Depends(verify_firebase_token)):
157
  _verify_admin(user)
sql/news_schema.sql CHANGED
@@ -85,5 +85,8 @@ create table if not exists news_polls (
85
  items_deduped_out int not null default 0,
86
  items_classified int not null default 0,
87
  items_flagged int not null default 0,
 
88
  error text
89
  );
 
 
 
85
  items_deduped_out int not null default 0,
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;
tests/test_news.py CHANGED
@@ -14,6 +14,8 @@ import pytest
14
  from news.dedup import url_hash
15
  from news.windows import compute_expiry, CATEGORY_WINDOW_DAYS, DEFAULT_WINDOW_DAYS
16
  from news.business_types import valid_subset, VALID_IDS
 
 
17
 
18
 
19
  # ─── dedup ──────────────────────────────────────────────────────────────────
@@ -130,3 +132,38 @@ def test_classification_result_malformed_deadline_is_ignored():
130
  "deadline": "not-a-date",
131
  })
132
  assert result.deadline is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from news.dedup import url_hash
15
  from news.windows import compute_expiry, CATEGORY_WINDOW_DAYS, DEFAULT_WINDOW_DAYS
16
  from news.business_types import valid_subset, VALID_IDS
17
+ from news.classify import ClassificationResult
18
+ from news.pipeline import _route
19
 
20
 
21
  # ─── dedup ──────────────────────────────────────────────────────────────────
 
132
  "deadline": "not-a-date",
133
  })
134
  assert result.deadline is None
135
+
136
+
137
+ # ─── confidence routing ─────────────────────────────────────────────────────
138
+
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,
146
+ "business_types": business_types, "location": location,
147
+ "confidence": confidence,
148
+ })
149
+
150
+
151
+ def test_route_auto_publishes_complete_high_confidence():
152
+ assert _route(_result(confidence=0.9)) == "classified"
153
+
154
+
155
+ def test_route_sends_complete_low_confidence_to_review():
156
+ assert _route(_result(confidence=0.6)) == "needs_review"
157
+
158
+
159
+ def test_route_auto_discards_incomplete_low_confidence():
160
+ # e.g. celebrity gossip the model correctly isn't confident about
161
+ assert _route(_result(category=None, business_types=(), location=None, confidence=0.2)) == "rejected"
162
+
163
+
164
+ def test_route_sends_incomplete_but_confident_to_review():
165
+ assert _route(_result(location=None, confidence=0.7)) == "needs_review"
166
+
167
+
168
+ def test_route_treats_missing_confidence_as_zero():
169
+ assert _route(_result(category=None, business_types=(), location=None, confidence=None)) == "rejected"