Spaces:
Running
Running
Download routes/news.py from Success-dev/venthrive-api: direct link, hf CLI and curl.
- Browser
- Download file 8.53 kB
-
https://huggingface.co/spaces/Success-dev/venthrive-api/resolve/main/routes/news.py
- Command line
-
hf download hf://spaces/Success-dev/venthrive-api/routes/news.py
-
curl -L -o news.py https://huggingface.co/spaces/Success-dev/venthrive-api/resolve/main/routes/news.py
8.53 kB
| """ | |
| Business News Routes (ADR-0015) | |
| ────────────────────────────────────────────────────────────────────────────── | |
| Routes: | |
| POST /api/news/poll → admin-triggered scrape+classify run (max 30 days back) | |
| GET /api/news/admin/polls → admin: recent poll runs and their outcome | |
| GET /api/news/items → classified, unexpired items (optionally filtered by business type) | |
| GET /api/news/admin/review → admin: items flagged incomplete, awaiting a human fix | |
| PATCH /api/news/admin/review/{id} → admin: manually complete or reject a flagged item | |
| Polling runs as a background task (see news/pipeline.py) because scraping every | |
| source and classifying each new item through Groq takes minutes — far past any | |
| gateway's request timeout. POST /poll returns as soon as the run is recorded; | |
| the frontend watches GET /admin/polls for it to finish. | |
| """ | |
| from datetime import datetime, timezone | |
| from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks | |
| from pydantic import BaseModel, Field | |
| from firebase_admin import firestore | |
| from auth import verify_firebase_token | |
| from news.pipeline import start_poll, execute_poll | |
| from news.classify import IMPACTS, CATEGORIES | |
| from news.business_types import valid_subset | |
| from utils.supabase_client import get_supabase | |
| from utils.audit import log_action | |
| from intel.events import record_news_item | |
| router = APIRouter() | |
| def _verify_admin(user: dict) -> str: | |
| uid = user.get("uid") | |
| if not uid: | |
| raise HTTPException(status_code=401, detail="Unauthorized.") | |
| db = firestore.client() | |
| doc = db.collection("user_data").document(uid).get() | |
| role = doc.to_dict().get("role", "user") if doc.exists else "user" | |
| if role != "admin": | |
| raise HTTPException(status_code=403, detail="Admin access required.") | |
| return uid | |
| class PollRequest(BaseModel): | |
| since_days: int = Field(default=7, ge=1, le=30) | |
| def poll_news(body: PollRequest, background_tasks: BackgroundTasks, user: dict = Depends(verify_firebase_token)): | |
| admin_uid = _verify_admin(user) | |
| poll_id = start_poll(since_days=body.since_days, triggered_by=admin_uid) | |
| background_tasks.add_task(execute_poll, poll_id, body.since_days) | |
| log_action(admin_uid, "run", "started", f"Looking back {body.since_days} day(s)", target=poll_id) | |
| return {"poll_id": poll_id, "started": True} | |
| def admin_overview(user: dict = Depends(verify_firebase_token)): | |
| _verify_admin(user) | |
| db = get_supabase() | |
| def count(status: str) -> int: | |
| res = db.table("news_items").select("id", count="exact").eq("status", status).execute() | |
| return res.count or 0 | |
| sources = db.table("news_sources").select("active").execute().data | |
| last_poll = ( | |
| db.table("news_polls") | |
| .select("id, started_at, finished_at, error") | |
| .order("started_at", desc=True) | |
| .limit(1) | |
| .execute() | |
| .data | |
| ) | |
| return { | |
| "published": count("classified"), | |
| "needs_review": count("needs_review"), | |
| "rejected": count("rejected"), | |
| "sources_active": sum(1 for s in sources if s["active"]), | |
| "sources_total": len(sources), | |
| "last_poll": last_poll[0] if last_poll else None, | |
| } | |
| def list_sources(user: dict = Depends(verify_firebase_token)): | |
| _verify_admin(user) | |
| db = get_supabase() | |
| sources = db.table("news_sources").select("*").order("name").execute().data | |
| return {"sources": sources} | |
| class SourceUpdate(BaseModel): | |
| active: bool | |
| def update_source(source_id: str, body: SourceUpdate, user: dict = Depends(verify_firebase_token)): | |
| admin_uid = _verify_admin(user) | |
| db = get_supabase() | |
| updated = db.table("news_sources").update({"active": body.active}).eq("id", source_id).execute().data | |
| name = updated[0]["name"] if updated else source_id | |
| log_action(admin_uid, "source", "enabled" if body.active else "disabled", name, target=source_id) | |
| return {"success": True} | |
| def list_polls(user: dict = Depends(verify_firebase_token)): | |
| _verify_admin(user) | |
| db = get_supabase() | |
| polls = ( | |
| db.table("news_polls") | |
| .select("id, triggered_by, since_days, started_at, finished_at, items_scraped, items_deduped_out, items_classified, items_flagged, items_rejected, current_source, error") | |
| .order("started_at", desc=True) | |
| .limit(10) | |
| .execute() | |
| .data | |
| ) | |
| return {"polls": polls} | |
| def list_items(business_type: str | None = None, user: dict = Depends(verify_firebase_token)): | |
| db = get_supabase() | |
| now_iso = datetime.now(timezone.utc).isoformat() | |
| query = ( | |
| db.table("news_items") | |
| .select("id, title, url, impact, category, business_types, location, deadline, expires_at, published_at") | |
| .eq("status", "classified") | |
| .gt("expires_at", now_iso) | |
| .order("published_at", desc=True) | |
| .limit(100) | |
| ) | |
| if business_type: | |
| query = query.contains("business_types", [business_type]) | |
| return {"items": query.execute().data} | |
| def admin_list_items( | |
| status: str | None = None, | |
| limit: int = 50, | |
| offset: int = 0, | |
| user: dict = Depends(verify_firebase_token), | |
| ): | |
| _verify_admin(user) | |
| db = get_supabase() | |
| query = db.table("news_items").select( | |
| "id, title, url, impact, category, business_types, location, confidence, status, scraped_at, published_at, expires_at", | |
| count="exact", | |
| ).order("scraped_at", desc=True).range(offset, offset + limit - 1) | |
| if status: | |
| query = query.eq("status", status) | |
| res = query.execute() | |
| return {"items": res.data, "total": res.count or 0} | |
| def list_review_queue(user: dict = Depends(verify_firebase_token)): | |
| _verify_admin(user) | |
| db = get_supabase() | |
| items = ( | |
| db.table("news_items") | |
| .select("id, title, url, raw_text, impact, category, business_types, location, review_reason, scraped_at") | |
| .eq("status", "needs_review") | |
| .order("scraped_at", desc=True) | |
| .limit(100) | |
| .execute() | |
| .data | |
| ) | |
| return {"items": items} | |
| class ReviewFix(BaseModel): | |
| status: str = Field(pattern="^(classified|rejected)$") | |
| impact: str | None = None | |
| category: str | None = None | |
| business_types: list[str] | None = None | |
| location: str | None = None | |
| def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_firebase_token)): | |
| admin_uid = _verify_admin(user) | |
| db = get_supabase() | |
| if body.status == "rejected": | |
| updated = db.table("news_items").update({"status": "rejected"}).eq("id", item_id).execute().data | |
| title = updated[0]["title"] if updated else item_id | |
| log_action(admin_uid, "review", "discarded", title, target=item_id) | |
| return {"success": True, "status": "rejected"} | |
| # Completing by hand still has to satisfy the same bar automated classification does. | |
| if body.impact and body.impact not in IMPACTS: | |
| raise HTTPException(status_code=400, detail=f"impact must be one of {IMPACTS}") | |
| if body.category and body.category not in CATEGORIES: | |
| raise HTTPException(status_code=400, detail=f"category must be one of {CATEGORIES}") | |
| business_types = valid_subset(body.business_types or []) | |
| if not (body.category and body.location and business_types): | |
| raise HTTPException(status_code=400, detail="category, location and at least one valid business_type are required to mark this classified.") | |
| updated = db.table("news_items").update({ | |
| "status": "classified", | |
| "impact": body.impact, | |
| "category": body.category, | |
| "business_types": business_types, | |
| "location": body.location, | |
| "review_reason": None, | |
| "classified_at": datetime.now(timezone.utc).isoformat(), | |
| }).eq("id", item_id).execute().data | |
| title = updated[0]["title"] if updated else item_id | |
| log_action(admin_uid, "review", "published", title, target=item_id) | |
| if updated: | |
| record_news_item(updated[0]) | |
| return {"success": True, "status": "classified"} | |