Spaces:
Running
Running
GitHub Actions commited on
Commit ·
1f5f31a
1
Parent(s): da1822e
Sync from GitHub · 2026-10-20 00:01 UTC
Browse files- .env.example +6 -0
- main.py +2 -1
- news/__init__.py +0 -0
- news/business_types.py +23 -0
- news/classify.py +86 -0
- news/dedup.py +12 -0
- news/pipeline.py +104 -0
- news/scrape.py +83 -0
- news/subsectors.json +198 -0
- news/windows.py +30 -0
- requirements.txt +5 -0
- routes/news.py +121 -0
- sql/news_schema.sql +89 -0
- tests/fixtures/groq/news_classify_complete.json +8 -0
- tests/fixtures/groq/news_classify_incomplete.json +8 -0
- tests/fixtures/groq/news_classify_opportunity.json +8 -0
- tests/test_groq_fixture_mode.py +35 -0
- tests/test_news.py +132 -0
- utils/groq_client.py +24 -2
- utils/supabase_client.py +32 -0
.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 |
-
|
|
|
|
| 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
|