File size: 5,880 Bytes
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
a32af27
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a32af27
 
 
 
 
 
 
 
 
 
 
 
b44e8fd
1f5f31a
 
 
a32af27
 
b44e8fd
 
 
a32af27
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""
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)