""" 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)