venthrive-api / news /classify.py
GitHub Actions
Sync from GitHub · 2026-10-20 19:36 UTC
a32af27
Raw History Blame Contribute Delete
5.88 kB
"""
Classifies one scraped item's own text directly — no scraping of other
platforms that already classify this kind of news (see the news-system design
discussion referenced in ADR-0015). A constrained-enum + few-shot prompt sent
to the light (20B) Groq model, modeled on DataWiz-Project's bounded,
admin-validated "autofill" step.
"""
import json
from datetime import date
from utils.groq_client import call_groq_json, LIGHT_MODEL
from news.business_types import labels_for_prompt, valid_subset
IMPACTS = ("opportunity", "neutral")
CATEGORIES = ("policy", "weather", "infrastructure", "funding", "security", "other")
_SYSTEM_PROMPT = f"""You classify a single news/bulletin item for VenThrive, a Nigerian small-business platform.
Read the title and text given, then output ONLY a JSON object with these exact fields:
- "impact": one of {list(IMPACTS)}
- "category": one of {list(CATEGORIES)}
- "business_types": a list of 1-4 ids from the taxonomy below that this item affects. Empty list if none clearly apply.
- "location": the Nigerian state, region, or "Nationwide" this affects. Null if genuinely unclear.
- "deadline": an ISO date (YYYY-MM-DD) ONLY if impact is "opportunity" AND the text states an explicit application/closing deadline. Otherwise null.
- "confidence": your confidence in this classification, 0.0 to 1.0.
Business type taxonomy (use these ids exactly, do not invent new ones):
{labels_for_prompt()}
There is no "threat" value. Reporting that something bad already happened has no value to a
business owner who can no longer act on it — a cost increase, a flood, or a security incident is
only useful here if it is a live, ongoing condition the owner can still respond to today (e.g. a
flood warning still in effect, a tax change not yet in force). Forward-looking, predictive risk
("what's likely to happen") is a separate system, not this classifier's job — don't try to infer
or imply it.
"opportunity" is reserved for something a business owner could actually apply to, access, or
benefit from now: a scheme/grant/program with open eligibility, a new customer segment or channel
opening up, a still-pending deadline. Everything else — including a cost going up, a disruption,
an incident, an aspirational government target, or a roadmap with no program to act on today — is
"neutral". Err toward "neutral" whenever in doubt; "opportunity" is the narrow, specific case.
Examples:
- A state SME grant with an application deadline of 2026-12-01 -> impact=opportunity, category=funding, deadline=2026-12-01
- A tax holiday for new small businesses with no stated end date -> impact=opportunity, category=policy, deadline=null
- A new CBN forex directive raising import costs -> impact=neutral, category=policy, business_types=[relevant import/trade subsectors], location=Nationwide (nothing a business can apply to or act on, just a cost change)
- A flood warning for Lagos -> impact=neutral, category=weather, business_types=[subsectors with physical premises/stock in the area], location=Lagos
- A plane makes an emergency landing with no stated regulatory fallout -> impact=neutral, category=other (an isolated incident, not a business-operational change)
- A senator publicly criticising a regulator's pace, with no rule actually changed yet -> impact=neutral, category=policy (nothing has changed for a business yet)
- A 10-year national capital-market target or roadmap with no program a business can apply to today -> impact=neutral, category=policy (aspirational, not actionable)
- A stock exchange index falling on a single trading day -> impact=neutral, category=other
If the item is not genuinely business-relevant, or you cannot determine a category, business
type, or location with reasonable confidence, leave the relevant field null/empty rather than
guessing — an incomplete classification is flagged for human review instead of shown to users,
which is the correct outcome for a low-confidence item."""
class ClassificationResult:
def __init__(self, raw: dict):
impact = raw.get("impact")
category = raw.get("category")
business_types = valid_subset(raw.get("business_types") or [])
location = (raw.get("location") or "").strip() or None
confidence = raw.get("confidence")
deadline_str = raw.get("deadline")
deadline: date | None = None
if deadline_str:
try:
deadline = date.fromisoformat(deadline_str)
except ValueError:
deadline = None # malformed date from the model — treat as not given
self.impact = impact if impact in IMPACTS else None
self.category = category if category in CATEGORIES else None
self.business_types = business_types
self.location = location
self.deadline = deadline if self.impact == "opportunity" else None
self.confidence = float(confidence) if isinstance(confidence, (int, float)) else None
@property
def is_complete(self) -> bool:
"""The ADR-0015 completeness bar: category + >=1 business type + location."""
return bool(self.category and self.business_types and self.location)
def review_reason(self) -> str | None:
if self.is_complete:
return None
missing = []
if not self.category: missing.append("category")
if not self.business_types: missing.append("business_types")
if not self.location: missing.append("location")
return f"Missing: {', '.join(missing)}"
def classify_item(title: str, text: str, fixture: str | None = None) -> ClassificationResult:
user_prompt = f"Title: {title}\n\nText: {text[:4000]}"
raw = call_groq_json(_SYSTEM_PROMPT, user_prompt, model=LIGHT_MODEL, max_tokens=500, fixture=fixture)
if isinstance(raw, str):
raw = json.loads(raw)
return ClassificationResult(raw)