GitHub Actions commited on
Commit
1f5f31a
·
1 Parent(s): da1822e

Sync from GitHub · 2026-10-20 00:01 UTC

Browse files
.env.example CHANGED
@@ -10,3 +10,9 @@ ALLOWED_ORIGINS=http://localhost:3000,https://venthrive.com
10
  # Optional: Pinecone (used in Stage 6 when AI Chat is built)
11
  PINECONE_API_KEY=
12
  PINECONE_INDEX_NAME=venthrive-bff
 
 
 
 
 
 
 
10
  # Optional: Pinecone (used in Stage 6 when AI Chat is built)
11
  PINECONE_API_KEY=
12
  PINECONE_INDEX_NAME=venthrive-bff
13
+
14
+ # Business news system (ADR-0015) — run backend/sql/news_schema.sql against
15
+ # this Supabase project first. Service-role key: server-side only, never send
16
+ # it to the frontend.
17
+ SUPABASE_URL=
18
+ SUPABASE_SERVICE_ROLE_KEY=
main.py CHANGED
@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse
14
  import firebase_admin
15
  from firebase_admin import credentials
16
 
17
- from routes import bff, chat, transcribe, documents, consultation, planning, admin, account, help as help_routes
18
 
19
 
20
  # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
@@ -92,6 +92,7 @@ app.include_router(planning.router, prefix="/api/planning", tags=["Plann
92
  app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
93
  app.include_router(account.router, prefix="/api/account", tags=["Account"])
94
  app.include_router(help_routes.router, prefix="/api/help", tags=["Help"])
 
95
 
96
 
97
  # ─── Health check (unauthenticated) ──────────────────────────────────────────
 
14
  import firebase_admin
15
  from firebase_admin import credentials
16
 
17
+ from routes import bff, chat, transcribe, documents, consultation, planning, admin, account, help as help_routes, news
18
 
19
 
20
  # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
 
92
  app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
93
  app.include_router(account.router, prefix="/api/account", tags=["Account"])
94
  app.include_router(help_routes.router, prefix="/api/help", tags=["Help"])
95
+ app.include_router(news.router, prefix="/api/news", tags=["News"])
96
 
97
 
98
  # ─── Health check (unauthenticated) ──────────────────────────────────────────
news/__init__.py ADDED
File without changes
news/business_types.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Subsector ids the news classifier is allowed to use for `business_types`.
3
+ Sourced from data/sectors.ts via scripts/export-subsectors.mjs — re-run that
4
+ script and this file picks up the change automatically, no code edit needed.
5
+ See ADR-0015 for why subsector-level (not sector or niche) was chosen.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ _PATH = Path(__file__).resolve().parent / "subsectors.json"
12
+ SUBSECTORS: list[dict] = json.loads(_PATH.read_text(encoding="utf-8"))
13
+ VALID_IDS: set[str] = {s["id"] for s in SUBSECTORS}
14
+
15
+
16
+ def labels_for_prompt() -> str:
17
+ """'id — label' lines, for embedding the allowed list in the classifier prompt."""
18
+ return "\n".join(f"- {s['id']} — {s['label']}" for s in SUBSECTORS)
19
+
20
+
21
+ def valid_subset(ids: list[str]) -> list[str]:
22
+ """Drops any id the model hallucinated that isn't in our taxonomy."""
23
+ return [i for i in ids if i in VALID_IDS]
news/classify.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Classifies one scraped item's own text directly — no scraping of other
3
+ platforms that already classify this kind of news (see the news-system design
4
+ discussion referenced in ADR-0015). A constrained-enum + few-shot prompt sent
5
+ to the light (20B) Groq model, modeled on DataWiz-Project's bounded,
6
+ admin-validated "autofill" step.
7
+ """
8
+
9
+ import json
10
+ from datetime import date
11
+
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.
19
+
20
+ Read the title and text given, then output ONLY a JSON object with these exact fields:
21
+ - "impact": one of {list(IMPACTS)}
22
+ - "category": one of {list(CATEGORIES)}
23
+ - "business_types": a list of 1-4 ids from the taxonomy below that this item affects. Empty list if none clearly apply.
24
+ - "location": the Nigerian state, region, or "Nationwide" this affects. Null if genuinely unclear.
25
+ - "deadline": an ISO date (YYYY-MM-DD) ONLY if impact is "opportunity" AND the text states an explicit application/closing deadline. Otherwise null.
26
+ - "confidence": your confidence in this classification, 0.0 to 1.0.
27
+
28
+ Business type taxonomy (use these ids exactly, do not invent new ones):
29
+ {labels_for_prompt()}
30
+
31
+ Examples:
32
+ - A new CBN forex directive raising import costs -> impact=threat, category=policy, business_types=[relevant import/trade subsectors], location=Nationwide
33
+ - A flood warning for Lagos -> impact=threat, category=weather, business_types=[subsectors with physical premises/stock in the area], location=Lagos
34
+ - A state SME grant with an application deadline of 2026-12-01 -> impact=opportunity, category=funding, deadline=2026-12-01
35
+ - A tax holiday for new small businesses with no stated end date -> impact=opportunity, category=policy, deadline=null
36
+
37
+ If the item is not genuinely business-relevant, or you cannot determine a category, business
38
+ type, or location with reasonable confidence, leave the relevant field null/empty rather than
39
+ guessing — an incomplete classification is flagged for human review instead of shown to users,
40
+ which is the correct outcome for a low-confidence item."""
41
+
42
+
43
+ class ClassificationResult:
44
+ def __init__(self, raw: dict):
45
+ impact = raw.get("impact")
46
+ category = raw.get("category")
47
+ business_types = valid_subset(raw.get("business_types") or [])
48
+ location = (raw.get("location") or "").strip() or None
49
+ confidence = raw.get("confidence")
50
+
51
+ deadline_str = raw.get("deadline")
52
+ deadline: date | None = None
53
+ if deadline_str:
54
+ try:
55
+ deadline = date.fromisoformat(deadline_str)
56
+ except ValueError:
57
+ deadline = None # malformed date from the model — treat as not given
58
+
59
+ self.impact = impact if impact in IMPACTS else None
60
+ self.category = category if category in CATEGORIES else None
61
+ self.business_types = business_types
62
+ self.location = location
63
+ self.deadline = deadline if self.impact == "opportunity" else None
64
+ self.confidence = float(confidence) if isinstance(confidence, (int, float)) else None
65
+
66
+ @property
67
+ def is_complete(self) -> bool:
68
+ """The ADR-0015 completeness bar: category + >=1 business type + location."""
69
+ return bool(self.category and self.business_types and self.location)
70
+
71
+ def review_reason(self) -> str | None:
72
+ if self.is_complete:
73
+ return None
74
+ missing = []
75
+ if not self.category: missing.append("category")
76
+ if not self.business_types: missing.append("business_types")
77
+ if not self.location: missing.append("location")
78
+ return f"Missing: {', '.join(missing)}"
79
+
80
+
81
+ def classify_item(title: str, text: str, fixture: str | None = None) -> ClassificationResult:
82
+ user_prompt = f"Title: {title}\n\nText: {text[:4000]}"
83
+ raw = call_groq_json(_SYSTEM_PROMPT, user_prompt, model=LIGHT_MODEL, max_tokens=500, fixture=fixture)
84
+ if isinstance(raw, str):
85
+ raw = json.loads(raw)
86
+ return ClassificationResult(raw)
news/dedup.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """URL-hash dedup (ADR-0015: dedup runs before classification, so we never
2
+ spend a Groq call on something we're about to discard as already-seen)."""
3
+
4
+ import hashlib
5
+
6
+
7
+ def url_hash(url: str) -> str:
8
+ # Trailing slashes and query strings are common no-op variants of the same
9
+ # article URL — strip them so the same piece of content doesn't get
10
+ # rescraped as "new" on a second poll just because of a tracking param.
11
+ normalized = url.strip().rstrip("/").split("?", 1)[0].split("#", 1)[0]
12
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
news/pipeline.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
+ per-source scrape failures. A single item failing to classify (Groq error,
9
+ malformed response) is likewise skipped rather than aborting the run; it stays
10
+ unclassified and will be retried on the next poll since it's never marked
11
+ 'rejected'.
12
+ """
13
+
14
+ from datetime import datetime, timedelta, timezone
15
+
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
+
22
+
23
+ def run_poll(since_days: int, triggered_by: str) -> dict:
24
+ db = get_supabase()
25
+ now = datetime.now(timezone.utc)
26
+ cutoff = now - timedelta(days=since_days)
27
+
28
+ poll = db.table("news_polls").insert({
29
+ "triggered_by": triggered_by,
30
+ "since_days": since_days,
31
+ "started_at": now.isoformat(),
32
+ }).execute()
33
+ poll_id = poll.data[0]["id"]
34
+
35
+ counts = {"items_scraped": 0, "items_deduped_out": 0, "items_classified": 0, "items_flagged": 0}
36
+ error: str | None = None
37
+
38
+ try:
39
+ sources = db.table("news_sources").select("*").eq("active", True).execute().data
40
+
41
+ for source in sources:
42
+ try:
43
+ raw_items = fetch_source(source)
44
+ except Exception as e:
45
+ print(f"[news] source '{source['name']}' failed to fetch: {e}")
46
+ continue
47
+
48
+ for item in raw_items:
49
+ if item.published_at and item.published_at < cutoff:
50
+ continue
51
+
52
+ h = url_hash(item.url)
53
+ existing = db.table("news_items").select("id").eq("url_hash", h).limit(1).execute().data
54
+ if existing:
55
+ counts["items_deduped_out"] += 1
56
+ continue
57
+
58
+ counts["items_scraped"] += 1
59
+
60
+ try:
61
+ result = classify_item(item.title, item.text)
62
+ except Exception as e:
63
+ print(f"[news] classification failed for {item.url}: {e}")
64
+ continue
65
+
66
+ scraped_at = datetime.now(timezone.utc)
67
+ expires_at = compute_expiry(result.category, scraped_at, result.deadline)
68
+ status = "classified" if result.is_complete else "needs_review"
69
+
70
+ db.table("news_items").insert({
71
+ "source_id": source["id"],
72
+ "url": item.url,
73
+ "url_hash": h,
74
+ "title": item.title,
75
+ "raw_text": item.text,
76
+ "published_at": item.published_at.isoformat() if item.published_at else None,
77
+ "scraped_at": scraped_at.isoformat(),
78
+ "status": status,
79
+ "impact": result.impact,
80
+ "category": result.category,
81
+ "business_types": result.business_types,
82
+ "location": result.location,
83
+ "deadline": result.deadline.isoformat() if result.deadline else None,
84
+ "expires_at": expires_at.isoformat(),
85
+ "confidence": result.confidence,
86
+ "review_reason": result.review_reason(),
87
+ "classified_at": scraped_at.isoformat(),
88
+ }).execute()
89
+
90
+ if status == "classified":
91
+ counts["items_classified"] += 1
92
+ else:
93
+ counts["items_flagged"] += 1
94
+
95
+ except Exception as e:
96
+ error = str(e)
97
+
98
+ db.table("news_polls").update({
99
+ "finished_at": datetime.now(timezone.utc).isoformat(),
100
+ "error": error,
101
+ **counts,
102
+ }).eq("id", poll_id).execute()
103
+
104
+ return {"poll_id": poll_id, "error": error, **counts}
news/scrape.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fetches raw items from a single source. Two strategies (ADR-0015):
3
+ - 'rss': feedparser, for the national/economic news sources that actually
4
+ publish a feed.
5
+ - 'html': a generic title + first-substantial-paragraph extractor, for
6
+ government bulletins and funder/program pages that don't publish
7
+ RSS. Expect a higher needs_review rate from these by design — the
8
+ completeness bar in classify.py catches weak extractions rather
9
+ than showing them to users.
10
+
11
+ Network errors for one source must never take down a whole poll run — the
12
+ caller (pipeline.py) treats a per-source failure as "zero items from this
13
+ source this run", not a fatal error.
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from time import mktime
19
+
20
+ import feedparser
21
+ import httpx
22
+ from bs4 import BeautifulSoup
23
+
24
+ REQUEST_TIMEOUT = 15.0
25
+ USER_AGENT = "VenThriveNewsBot/1.0 (+https://venthrive.com; business-news aggregator)"
26
+
27
+
28
+ @dataclass
29
+ class RawItem:
30
+ url: str
31
+ title: str
32
+ text: str
33
+ published_at: datetime | None
34
+
35
+
36
+ def fetch_rss(feed_url: str) -> list[RawItem]:
37
+ parsed = feedparser.parse(
38
+ feed_url,
39
+ agent=USER_AGENT,
40
+ request_headers={"User-Agent": USER_AGENT},
41
+ )
42
+ items: list[RawItem] = []
43
+ for entry in parsed.entries:
44
+ url = entry.get("link", "").strip()
45
+ title = entry.get("title", "").strip()
46
+ if not url or not title:
47
+ continue
48
+ text = entry.get("summary", "") or entry.get("description", "")
49
+ text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
50
+ published_at = None
51
+ if entry.get("published_parsed"):
52
+ published_at = datetime.fromtimestamp(mktime(entry.published_parsed), tz=timezone.utc)
53
+ items.append(RawItem(url=url, title=title, text=text, published_at=published_at))
54
+ return items
55
+
56
+
57
+ def fetch_html(page_url: str) -> list[RawItem]:
58
+ """One item: the page itself. Government/funder pages are usually a single
59
+ announcement or a landing page we read as-is — there's no reliable, generic
60
+ way to discover "articles" on an arbitrary site's markup, so this reads the
61
+ page's own headline + body rather than trying to crawl links from it."""
62
+ resp = httpx.get(page_url, headers={"User-Agent": USER_AGENT}, timeout=REQUEST_TIMEOUT, follow_redirects=True)
63
+ resp.raise_for_status()
64
+ soup = BeautifulSoup(resp.text, "html.parser")
65
+
66
+ title_tag = soup.find("h1") or soup.find("title")
67
+ title = title_tag.get_text(strip=True) if title_tag else ""
68
+
69
+ paragraphs = [p.get_text(" ", strip=True) for p in soup.find_all("p")]
70
+ substantial = [p for p in paragraphs if len(p) > 60]
71
+ text = " ".join(substantial[:5])
72
+
73
+ if not title or not text:
74
+ return []
75
+ return [RawItem(url=page_url, title=title, text=text, published_at=None)]
76
+
77
+
78
+ def fetch_source(source: dict) -> list[RawItem]:
79
+ if source["source_type"] == "rss":
80
+ return fetch_rss(source["url"])
81
+ if source["source_type"] == "html":
82
+ return fetch_html(source["url"])
83
+ raise ValueError(f"Unknown source_type: {source['source_type']!r}")
news/subsectors.json ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "catering_food_service",
4
+ "label": "Catering & Food Service"
5
+ },
6
+ {
7
+ "id": "food_production_processing",
8
+ "label": "Food Production & Processing"
9
+ },
10
+ {
11
+ "id": "agricultural_production",
12
+ "label": "Agricultural Production"
13
+ },
14
+ {
15
+ "id": "agri_trade_distribution",
16
+ "label": "Agricultural Trade & Distribution"
17
+ },
18
+ {
19
+ "id": "clothing_apparel",
20
+ "label": "Clothing & Apparel"
21
+ },
22
+ {
23
+ "id": "tailoring_bespoke",
24
+ "label": "Tailoring & Bespoke"
25
+ },
26
+ {
27
+ "id": "footwear",
28
+ "label": "Footwear"
29
+ },
30
+ {
31
+ "id": "accessories_jewellery",
32
+ "label": "Accessories & Jewellery"
33
+ },
34
+ {
35
+ "id": "software_apps",
36
+ "label": "Software & Apps"
37
+ },
38
+ {
39
+ "id": "digital_services",
40
+ "label": "Digital Services"
41
+ },
42
+ {
43
+ "id": "hardware_repairs",
44
+ "label": "Hardware & Repairs"
45
+ },
46
+ {
47
+ "id": "it_consulting",
48
+ "label": "IT Consulting & Support"
49
+ },
50
+ {
51
+ "id": "healthcare_services",
52
+ "label": "Healthcare Services"
53
+ },
54
+ {
55
+ "id": "wellness_fitness",
56
+ "label": "Wellness & Fitness"
57
+ },
58
+ {
59
+ "id": "hair_services",
60
+ "label": "Hair Services"
61
+ },
62
+ {
63
+ "id": "skincare_cosmetics",
64
+ "label": "Skincare & Cosmetics"
65
+ },
66
+ {
67
+ "id": "body_spa",
68
+ "label": "Body Care & Spa"
69
+ },
70
+ {
71
+ "id": "formal_education",
72
+ "label": "Formal Education"
73
+ },
74
+ {
75
+ "id": "skills_vocational",
76
+ "label": "Skills & Vocational Training"
77
+ },
78
+ {
79
+ "id": "online_coaching",
80
+ "label": "Online Learning & Coaching"
81
+ },
82
+ {
83
+ "id": "financial_services",
84
+ "label": "Financial Services"
85
+ },
86
+ {
87
+ "id": "insurance",
88
+ "label": "Insurance"
89
+ },
90
+ {
91
+ "id": "fintech_payments",
92
+ "label": "Fintech & Payments"
93
+ },
94
+ {
95
+ "id": "delivery_services",
96
+ "label": "Delivery Services"
97
+ },
98
+ {
99
+ "id": "haulage_freight",
100
+ "label": "Haulage & Freight"
101
+ },
102
+ {
103
+ "id": "passenger_transport",
104
+ "label": "Passenger Transport"
105
+ },
106
+ {
107
+ "id": "real_estate",
108
+ "label": "Real Estate"
109
+ },
110
+ {
111
+ "id": "construction_renovation",
112
+ "label": "Construction & Renovation"
113
+ },
114
+ {
115
+ "id": "building_materials",
116
+ "label": "Building Materials"
117
+ },
118
+ {
119
+ "id": "visual_arts",
120
+ "label": "Visual Arts"
121
+ },
122
+ {
123
+ "id": "content_media",
124
+ "label": "Content & Media"
125
+ },
126
+ {
127
+ "id": "performing_arts",
128
+ "label": "Performing Arts"
129
+ },
130
+ {
131
+ "id": "event_planning",
132
+ "label": "Event Planning"
133
+ },
134
+ {
135
+ "id": "event_support",
136
+ "label": "Event Support Services"
137
+ },
138
+ {
139
+ "id": "entertainment_venues",
140
+ "label": "Entertainment Venues"
141
+ },
142
+ {
143
+ "id": "light_manufacturing",
144
+ "label": "Light Manufacturing"
145
+ },
146
+ {
147
+ "id": "industrial_manufacturing",
148
+ "label": "Industrial Manufacturing"
149
+ },
150
+ {
151
+ "id": "textiles_materials",
152
+ "label": "Textiles & Materials"
153
+ },
154
+ {
155
+ "id": "retail_trade",
156
+ "label": "Retail Trade"
157
+ },
158
+ {
159
+ "id": "wholesale_distribution",
160
+ "label": "Wholesale & Distribution"
161
+ },
162
+ {
163
+ "id": "import_export",
164
+ "label": "Import & Export"
165
+ },
166
+ {
167
+ "id": "legal_compliance",
168
+ "label": "Legal & Compliance"
169
+ },
170
+ {
171
+ "id": "accounting_finance_advisory",
172
+ "label": "Accounting & Finance Advisory"
173
+ },
174
+ {
175
+ "id": "management_consulting",
176
+ "label": "Management & Business Consulting"
177
+ },
178
+ {
179
+ "id": "vehicle_maintenance",
180
+ "label": "Vehicle Maintenance & Repair"
181
+ },
182
+ {
183
+ "id": "vehicle_trade",
184
+ "label": "Vehicle Trade & Rentals"
185
+ },
186
+ {
187
+ "id": "cleaning_services",
188
+ "label": "Cleaning Services"
189
+ },
190
+ {
191
+ "id": "home_services",
192
+ "label": "Home & Household Services"
193
+ },
194
+ {
195
+ "id": "other_services",
196
+ "label": "Other Services"
197
+ }
198
+ ]
news/windows.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Default relevance windows per category (ADR-0015). These are starting points,
3
+ not final — day counts picked before any real classified history existed, to
4
+ be revisited once there's enough data to see how fast each category actually
5
+ goes stale.
6
+
7
+ An opportunity with an explicit deadline in the source text uses that deadline
8
+ instead of its category's default window — see classify.py.
9
+ """
10
+
11
+ from datetime import datetime, timedelta, timezone, date
12
+
13
+ CATEGORY_WINDOW_DAYS = {
14
+ "weather": 7,
15
+ "infrastructure": 14,
16
+ "security": 14,
17
+ "policy": 90,
18
+ "funding": 30,
19
+ "other": 21,
20
+ }
21
+
22
+ DEFAULT_WINDOW_DAYS = 21
23
+
24
+
25
+ def compute_expiry(category: str | None, scraped_at: datetime, deadline: date | None) -> datetime:
26
+ """The point at which an item stops being shown to users."""
27
+ if deadline is not None:
28
+ return datetime.combine(deadline, datetime.min.time(), tzinfo=timezone.utc)
29
+ days = CATEGORY_WINDOW_DAYS.get(category or "", DEFAULT_WINDOW_DAYS)
30
+ return scraped_at + timedelta(days=days)
requirements.txt CHANGED
@@ -10,3 +10,8 @@ httpx==0.27.2
10
  pinecone>=9.0.0
11
  # PDF text extraction for book ingestion script (pure Python, Windows-compatible)
12
  pypdf>=4.0.0
 
 
 
 
 
 
10
  pinecone>=9.0.0
11
  # PDF text extraction for book ingestion script (pure Python, Windows-compatible)
12
  pypdf>=4.0.0
13
+ # Business news system (ADR-0015): Supabase storage, RSS/HTML scraping
14
+ supabase>=2.9.0,<3.0.0
15
+ feedparser>=6.0.11,<7.0.0
16
+ beautifulsoup4>=4.12.3,<5.0.0
17
+
routes/news.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Business News Routes (ADR-0015)
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Routes:
5
+ POST /api/news/poll → admin-triggered scrape+classify run (max 30 days back)
6
+ GET /api/news/items → classified, unexpired items (optionally filtered by business type)
7
+ GET /api/news/admin/review → admin: items flagged incomplete, awaiting a human fix
8
+ PATCH /api/news/admin/review/{id} → admin: manually complete or reject a flagged item
9
+ """
10
+
11
+ from datetime import datetime, timezone
12
+
13
+ from fastapi import APIRouter, HTTPException, Depends
14
+ from pydantic import BaseModel, Field
15
+ from firebase_admin import firestore
16
+ from auth import verify_firebase_token
17
+ from news.pipeline import run_poll
18
+ from news.classify import IMPACTS, CATEGORIES
19
+ from news.business_types import valid_subset
20
+ from utils.supabase_client import get_supabase
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ def _verify_admin(user: dict) -> str:
26
+ uid = user.get("uid")
27
+ if not uid:
28
+ raise HTTPException(status_code=401, detail="Unauthorized.")
29
+ db = firestore.client()
30
+ doc = db.collection("user_data").document(uid).get()
31
+ role = doc.to_dict().get("role", "user") if doc.exists else "user"
32
+ if role != "admin":
33
+ raise HTTPException(status_code=403, detail="Admin access required.")
34
+ return uid
35
+
36
+
37
+ class PollRequest(BaseModel):
38
+ since_days: int = Field(default=7, ge=1, le=30)
39
+
40
+
41
+ @router.post("/poll")
42
+ def poll_news(body: PollRequest, user: dict = Depends(verify_firebase_token)):
43
+ admin_uid = _verify_admin(user)
44
+ try:
45
+ result = run_poll(since_days=body.since_days, triggered_by=admin_uid)
46
+ except Exception as e:
47
+ raise HTTPException(status_code=502, detail=f"Poll failed: {e}")
48
+ return result
49
+
50
+
51
+ @router.get("/items")
52
+ def list_items(business_type: str | None = None, user: dict = Depends(verify_firebase_token)):
53
+ db = get_supabase()
54
+ now_iso = datetime.now(timezone.utc).isoformat()
55
+ query = (
56
+ db.table("news_items")
57
+ .select("id, title, url, impact, category, business_types, location, deadline, expires_at, published_at")
58
+ .eq("status", "classified")
59
+ .gt("expires_at", now_iso)
60
+ .order("published_at", desc=True)
61
+ .limit(100)
62
+ )
63
+ if business_type:
64
+ query = query.contains("business_types", [business_type])
65
+ return {"items": query.execute().data}
66
+
67
+
68
+ @router.get("/admin/review")
69
+ def list_review_queue(user: dict = Depends(verify_firebase_token)):
70
+ _verify_admin(user)
71
+ db = get_supabase()
72
+ items = (
73
+ db.table("news_items")
74
+ .select("id, title, url, raw_text, impact, category, business_types, location, review_reason, scraped_at")
75
+ .eq("status", "needs_review")
76
+ .order("scraped_at", desc=True)
77
+ .limit(100)
78
+ .execute()
79
+ .data
80
+ )
81
+ return {"items": items}
82
+
83
+
84
+ class ReviewFix(BaseModel):
85
+ status: str = Field(pattern="^(classified|rejected)$")
86
+ impact: str | None = None
87
+ category: str | None = None
88
+ business_types: list[str] | None = None
89
+ location: str | None = None
90
+
91
+
92
+ @router.patch("/admin/review/{item_id}")
93
+ def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_firebase_token)):
94
+ _verify_admin(user)
95
+ db = get_supabase()
96
+
97
+ if body.status == "rejected":
98
+ db.table("news_items").update({"status": "rejected"}).eq("id", item_id).execute()
99
+ return {"success": True, "status": "rejected"}
100
+
101
+ # Completing by hand still has to satisfy the same bar automated classification does.
102
+ if body.impact and body.impact not in IMPACTS:
103
+ raise HTTPException(status_code=400, detail=f"impact must be one of {IMPACTS}")
104
+ if body.category and body.category not in CATEGORIES:
105
+ raise HTTPException(status_code=400, detail=f"category must be one of {CATEGORIES}")
106
+
107
+ business_types = valid_subset(body.business_types or [])
108
+ if not (body.category and body.location and business_types):
109
+ raise HTTPException(status_code=400, detail="category, location and at least one valid business_type are required to mark this classified.")
110
+
111
+ db.table("news_items").update({
112
+ "status": "classified",
113
+ "impact": body.impact,
114
+ "category": body.category,
115
+ "business_types": business_types,
116
+ "location": body.location,
117
+ "review_reason": None,
118
+ "classified_at": datetime.now(timezone.utc).isoformat(),
119
+ }).eq("id", item_id).execute()
120
+
121
+ return {"success": True, "status": "classified"}
sql/news_schema.sql ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- VenThrive business-news system — run this once against your Supabase project's
2
+ -- SQL editor (or `psql`). See docs/blueprint/adr/0015-business-news-system-design.md
3
+ -- for the reasoning behind every choice below.
4
+
5
+ create extension if not exists pgcrypto;
6
+
7
+ -- ─── Sources ────────────────────────────────────────────────────────────────
8
+ -- Sources are data, not code, so an admin can add/disable one without a deploy.
9
+ -- 'rss' sources are polled with feedparser; 'html' sources with a generic
10
+ -- title + first-paragraph extractor (see backend/news/scrape.py).
11
+ create table if not exists news_sources (
12
+ id uuid primary key default gen_random_uuid(),
13
+ name text not null,
14
+ url text not null unique,
15
+ source_type text not null check (source_type in ('rss', 'html')),
16
+ -- Which of the three sourcing strategies this feeds (ADR-0015) — purely
17
+ -- informational, doesn't change scrape behavior.
18
+ source_group text not null check (source_group in ('national_news', 'agency_bulletin', 'funder_program')),
19
+ active boolean not null default true,
20
+ created_at timestamptz not null default now()
21
+ );
22
+
23
+ -- A handful of real, well-known starter sources per ADR-0015's three source
24
+ -- groups. Add more through this table directly — no code change needed.
25
+ insert into news_sources (name, url, source_type, source_group) values
26
+ ('BusinessDay', 'https://businessday.ng/feed/', 'rss', 'national_news'),
27
+ ('Nairametrics', 'https://nairametrics.com/feed/', 'rss', 'national_news'),
28
+ ('Premium Times — Business', 'https://www.premiumtimesng.com/category/business/feed', 'rss', 'national_news'),
29
+ ('NiMet — Weather Alerts', 'https://www.nimet.gov.ng', 'html', 'agency_bulletin'),
30
+ ('NEMA — Advisories', 'https://nema.gov.ng', 'html', 'agency_bulletin'),
31
+ ('SMEDAN', 'https://smedan.gov.ng', 'html', 'funder_program'),
32
+ ('Bank of Industry — News', 'https://www.boi.ng/category/news/', 'html', 'funder_program'),
33
+ ('Tony Elumelu Foundation', 'https://www.tonyelumelufoundation.org/news', 'html', 'funder_program')
34
+ on conflict (url) do nothing;
35
+
36
+ -- ─── Items ──────────────────────────────────────────────────────────────────
37
+ create table if not exists news_items (
38
+ id uuid primary key default gen_random_uuid(),
39
+ source_id uuid references news_sources(id) on delete set null,
40
+ url text not null,
41
+ url_hash text not null unique,
42
+ title text not null,
43
+ raw_text text not null default '',
44
+ published_at timestamptz,
45
+ scraped_at timestamptz not null default now(),
46
+
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,
55
+ deadline date, -- only set for an opportunity with an explicit deadline in the source text
56
+ expires_at timestamptz, -- deadline if set, else scraped_at + the category's default window
57
+ confidence numeric,
58
+ review_reason text, -- why this needs_review, for the admin queue
59
+ classified_at timestamptz,
60
+
61
+ -- The completeness bar from ADR-0015, enforced here, not just in application
62
+ -- code: a classified item must have a category, at least one business type,
63
+ -- and a location.
64
+ constraint classified_items_are_complete check (
65
+ status <> 'classified'
66
+ or (category is not null and location is not null
67
+ and business_types is not null and array_length(business_types, 1) > 0)
68
+ )
69
+ );
70
+
71
+ create index if not exists news_items_status_idx on news_items (status);
72
+ create index if not exists news_items_expires_at_idx on news_items (expires_at);
73
+ create index if not exists news_items_business_types_idx on news_items using gin (business_types);
74
+
75
+ -- ─── Poll run log ───────────────────────────────────────────────────────────
76
+ -- One row per admin-triggered poll (ADR-0015: admin-triggered only, daily
77
+ -- auto-poll deferred) — an audit trail and a place to surface run-level errors.
78
+ create table if not exists news_polls (
79
+ id uuid primary key default gen_random_uuid(),
80
+ triggered_by text not null, -- admin's Firebase UID
81
+ since_days int not null check (since_days between 1 and 30),
82
+ started_at timestamptz not null default now(),
83
+ finished_at timestamptz,
84
+ items_scraped int not null default 0,
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
+ );
tests/fixtures/groq/news_classify_complete.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "impact": "threat",
3
+ "category": "weather",
4
+ "business_types": ["catering_food_service", "food_production_processing"],
5
+ "location": "Lagos",
6
+ "deadline": null,
7
+ "confidence": 0.9
8
+ }
tests/fixtures/groq/news_classify_incomplete.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "impact": "neutral",
3
+ "category": null,
4
+ "business_types": [],
5
+ "location": null,
6
+ "deadline": null,
7
+ "confidence": 0.3
8
+ }
tests/fixtures/groq/news_classify_opportunity.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "impact": "opportunity",
3
+ "category": "funding",
4
+ "business_types": ["catering_food_service"],
5
+ "location": "Nationwide",
6
+ "deadline": "2026-12-01",
7
+ "confidence": 0.85
8
+ }
tests/test_groq_fixture_mode.py CHANGED
@@ -52,6 +52,41 @@ def test_call_groq_transcribe_returns_object_with_text(fixture_mode):
52
  assert "meals a day" in result.text
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def test_fixture_mode_is_off_by_default():
56
  import utils.groq_client as gc
57
  importlib.reload(gc)
 
52
  assert "meals a day" in result.text
53
 
54
 
55
+ @pytest.fixture
56
+ def demo_mode(monkeypatch):
57
+ monkeypatch.setenv("DEMO_MODE", "1")
58
+ monkeypatch.setenv("E2E_FIXTURE_MODE", "1")
59
+ import utils.groq_client as gc
60
+ importlib.reload(gc)
61
+ yield gc
62
+ monkeypatch.delenv("E2E_FIXTURE_MODE", raising=False)
63
+ monkeypatch.delenv("DEMO_MODE", raising=False)
64
+ importlib.reload(gc)
65
+
66
+
67
+ def test_demo_mode_turns_json_text_into_test_environment_but_keeps_structure(demo_mode):
68
+ data = demo_mode.call_groq_json("system", "user", fixture="planning_tasks")
69
+ tasks = data["suggestions"]
70
+ assert tasks and all(t["title"] == "Test Environment" for t in tasks)
71
+ assert all(t["column"] in {"todo", "in_progress", "done"} for t in tasks)
72
+
73
+
74
+ def test_demo_mode_stream_says_test_environment(demo_mode):
75
+ chunks = list(demo_mode.call_groq_stream("system", "user", fixture="chat_response"))
76
+ assert "".join(c.choices[0].delta.content for c in chunks).strip() == "Test Environment"
77
+
78
+
79
+ def test_demo_mode_needs_fixture_mode():
80
+ os.environ["DEMO_MODE"] = "1"
81
+ try:
82
+ import utils.groq_client as gc
83
+ importlib.reload(gc)
84
+ assert gc.DEMO_MODE is False
85
+ finally:
86
+ os.environ.pop("DEMO_MODE", None)
87
+ importlib.reload(gc)
88
+
89
+
90
  def test_fixture_mode_is_off_by_default():
91
  import utils.groq_client as gc
92
  importlib.reload(gc)
tests/test_news.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the business news system (ADR-0015): dedup hashing, relevance
3
+ windows, business-type validation, and classification completeness logic.
4
+
5
+ classify_item goes through real Groq in production; here it's exercised only
6
+ in E2E_FIXTURE_MODE against canned fixtures, same pattern as
7
+ test_groq_fixture_mode.py — no network calls.
8
+ """
9
+ import importlib
10
+ from datetime import date, datetime, timezone
11
+
12
+ import pytest
13
+
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 ──────────────────────────────────────────────────────────────────
20
+
21
+ def test_url_hash_ignores_trailing_slash():
22
+ assert url_hash("https://example.com/a") == url_hash("https://example.com/a/")
23
+
24
+
25
+ def test_url_hash_ignores_query_string():
26
+ assert url_hash("https://example.com/a") == url_hash("https://example.com/a?utm_source=x")
27
+
28
+
29
+ def test_url_hash_ignores_fragment():
30
+ assert url_hash("https://example.com/a") == url_hash("https://example.com/a#section")
31
+
32
+
33
+ def test_url_hash_distinguishes_different_urls():
34
+ assert url_hash("https://example.com/a") != url_hash("https://example.com/b")
35
+
36
+
37
+ # ─── windows ────────────────────────────────────────────────────────────────
38
+
39
+ def test_compute_expiry_uses_category_window():
40
+ scraped_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
41
+ expiry = compute_expiry("weather", scraped_at, None)
42
+ assert expiry == scraped_at + __import__("datetime").timedelta(days=CATEGORY_WINDOW_DAYS["weather"])
43
+
44
+
45
+ def test_compute_expiry_falls_back_to_default_for_unknown_category():
46
+ scraped_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
47
+ expiry = compute_expiry(None, scraped_at, None)
48
+ assert expiry == scraped_at + __import__("datetime").timedelta(days=DEFAULT_WINDOW_DAYS)
49
+
50
+
51
+ def test_compute_expiry_deadline_overrides_category_window():
52
+ scraped_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
53
+ deadline = date(2026, 12, 1)
54
+ expiry = compute_expiry("funding", scraped_at, deadline)
55
+ assert expiry == datetime(2026, 12, 1, tzinfo=timezone.utc)
56
+
57
+
58
+ # ─── business_types ───────────────────────────────────────────────────────
59
+
60
+ def test_valid_subset_drops_hallucinated_ids():
61
+ real_id = next(iter(VALID_IDS))
62
+ assert valid_subset([real_id, "not-a-real-subsector"]) == [real_id]
63
+
64
+
65
+ def test_valid_subset_empty_input():
66
+ assert valid_subset([]) == []
67
+
68
+
69
+ # ─── classify (fixture mode) ───────────────────────────────────────────────
70
+
71
+ @pytest.fixture
72
+ def classify_module(monkeypatch):
73
+ monkeypatch.setenv("E2E_FIXTURE_MODE", "1")
74
+ import utils.groq_client as gc
75
+ importlib.reload(gc)
76
+ import news.classify as classify
77
+ importlib.reload(classify)
78
+ yield classify
79
+ monkeypatch.delenv("E2E_FIXTURE_MODE", raising=False)
80
+ importlib.reload(gc)
81
+ importlib.reload(classify)
82
+
83
+
84
+ def test_classify_item_complete_result_is_complete(classify_module):
85
+ result = classify_module.classify_item("Lagos flood warning", "text", fixture="news_classify_complete")
86
+ assert result.is_complete
87
+ assert result.review_reason() is None
88
+ assert result.impact == "threat"
89
+ assert result.category == "weather"
90
+
91
+
92
+ def test_classify_item_incomplete_result_flags_missing_fields(classify_module):
93
+ result = classify_module.classify_item("Unclear item", "text", fixture="news_classify_incomplete")
94
+ assert not result.is_complete
95
+ reason = result.review_reason()
96
+ assert "category" in reason
97
+ assert "business_types" in reason
98
+ assert "location" in reason
99
+
100
+
101
+ def test_classify_item_opportunity_keeps_deadline(classify_module):
102
+ result = classify_module.classify_item("State SME grant", "text", fixture="news_classify_opportunity")
103
+ assert result.impact == "opportunity"
104
+ assert result.deadline == date(2026, 12, 1)
105
+
106
+
107
+ def test_classify_item_deadline_dropped_when_not_opportunity(classify_module):
108
+ result = classify_module.classify_item("Lagos flood warning", "text", fixture="news_classify_complete")
109
+ assert result.deadline is None
110
+
111
+
112
+ def test_classification_result_drops_invalid_enum_values():
113
+ result = __import__("news.classify", fromlist=["ClassificationResult"]).ClassificationResult({
114
+ "impact": "not-a-real-impact",
115
+ "category": "not-a-real-category",
116
+ "business_types": [],
117
+ "location": "Lagos",
118
+ })
119
+ assert result.impact is None
120
+ assert result.category is None
121
+
122
+
123
+ def test_classification_result_malformed_deadline_is_ignored():
124
+ from news.classify import ClassificationResult
125
+ result = ClassificationResult({
126
+ "impact": "opportunity",
127
+ "category": "funding",
128
+ "business_types": [],
129
+ "location": "Lagos",
130
+ "deadline": "not-a-date",
131
+ })
132
+ assert result.deadline is None
utils/groq_client.py CHANGED
@@ -36,6 +36,27 @@ FIXTURE_MODE = os.environ.get("E2E_FIXTURE_MODE") == "1"
36
  _FIXTURES_DIR = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "groq"
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def _load_json_fixture(name: str) -> dict:
40
  path = _FIXTURES_DIR / f"{name}.json"
41
  if not path.exists():
@@ -43,7 +64,8 @@ def _load_json_fixture(name: str) -> dict:
43
  f"[E2E_FIXTURE_MODE] no fixture file for '{name}' at {path} — "
44
  "add one under backend/tests/fixtures/groq/"
45
  )
46
- return json.loads(path.read_text(encoding="utf-8"))
 
47
 
48
 
49
  def _load_text_fixture(name: str) -> str:
@@ -53,7 +75,7 @@ def _load_text_fixture(name: str) -> str:
53
  f"[E2E_FIXTURE_MODE] no fixture file for '{name}' at {path} — "
54
  "add one under backend/tests/fixtures/groq/"
55
  )
56
- return path.read_text(encoding="utf-8")
57
 
58
  # ─── Load all keys ────────────────────────────────────────────────────────────
59
  # Accepts: GROQ_API_KEY, GROQ_API_KEY_1 ... GROQ_API_KEY_N (any suffix)
 
36
  _FIXTURES_DIR = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "groq"
37
 
38
 
39
+ # DEMO_MODE=1 (with fixture mode, used by `npm run demo`): every AI answer reads
40
+ # "Test Environment". Only fields the app needs to keep working are left alone.
41
+ DEMO_MODE = FIXTURE_MODE and os.environ.get("DEMO_MODE") == "1"
42
+ DEMO_TEXT = "Test Environment"
43
+ _DEMO_KEEP_KEYS = {"id", "type", "column", "targetDate", "date", "status"}
44
+
45
+
46
+ def _demo_value(value, key=None):
47
+ if isinstance(value, dict):
48
+ return {k: _demo_value(v, k) for k, v in value.items()}
49
+ if isinstance(value, list):
50
+ strings = [v for v in value if isinstance(v, str)]
51
+ if strings and len(strings) == len(value):
52
+ # Numbered so choice lists and React keys stay distinct
53
+ return [f"{DEMO_TEXT} {i + 1}" for i in range(len(value))] if len(value) > 1 else [DEMO_TEXT]
54
+ return [_demo_value(v, key) for v in value]
55
+ if isinstance(value, str) and key not in _DEMO_KEEP_KEYS:
56
+ return DEMO_TEXT
57
+ return value
58
+
59
+
60
  def _load_json_fixture(name: str) -> dict:
61
  path = _FIXTURES_DIR / f"{name}.json"
62
  if not path.exists():
 
64
  f"[E2E_FIXTURE_MODE] no fixture file for '{name}' at {path} — "
65
  "add one under backend/tests/fixtures/groq/"
66
  )
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ return _demo_value(data) if DEMO_MODE else data
69
 
70
 
71
  def _load_text_fixture(name: str) -> str:
 
75
  f"[E2E_FIXTURE_MODE] no fixture file for '{name}' at {path} — "
76
  "add one under backend/tests/fixtures/groq/"
77
  )
78
+ return DEMO_TEXT if DEMO_MODE else path.read_text(encoding="utf-8")
79
 
80
  # ─── Load all keys ────────────────────────────────────────────────────────────
81
  # Accepts: GROQ_API_KEY, GROQ_API_KEY_1 ... GROQ_API_KEY_N (any suffix)
utils/supabase_client.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supabase client for the business-news system (ADR-0015). Lazily initialized —
3
+ unlike groq_client.py's eager key check, this only raises if something
4
+ actually tries to use Supabase without it being configured, so importing this
5
+ module (or any module that imports it) never breaks the test suite or routes
6
+ that don't touch news.
7
+
8
+ Uses the service-role key: this client is server-side only (backend/news/*,
9
+ backend/routes/news.py) and must never be sent to or used from the frontend.
10
+ """
11
+
12
+ import os
13
+ from supabase import create_client, Client
14
+
15
+ _client: Client | None = None
16
+
17
+
18
+ def get_supabase() -> Client:
19
+ global _client
20
+ if _client is not None:
21
+ return _client
22
+
23
+ url = os.environ.get("SUPABASE_URL")
24
+ key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
25
+ if not url or not key:
26
+ raise RuntimeError(
27
+ "SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY not set — the news system needs a "
28
+ "Supabase project (see backend/sql/news_schema.sql and ADR-0015) before "
29
+ "/api/news/* routes can do anything."
30
+ )
31
+ _client = create_client(url, key)
32
+ return _client