File size: 7,703 Bytes
1f5f31a
 
a32af27
 
 
 
 
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
a32af27
1f5f31a
 
b7c46f4
1f5f31a
e740385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f5f31a
e469aff
 
 
 
 
 
1f5f31a
 
 
 
e469aff
1f5f31a
e469aff
 
 
 
 
 
1f5f31a
e740385
1f5f31a
 
 
 
 
 
a32af27
 
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a32af27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f5f31a
 
 
 
 
 
 
 
e740385
1f5f31a
b7c46f4
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7c46f4
 
1f5f31a
 
 
b7c46f4
 
 
 
e740385
 
1f5f31a
 
 
 
 
 
 
 
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""
Orchestrates one admin-triggered poll (ADR-0015): scrape every active source,
dedup by URL hash, keyword-prefilter (news/relevance.py) what survives dedup,
classify the business-relevant remainder through Groq, apply the completeness
bar, and record a run summary in news_polls. The keyword prefilter exists
because Groq is the expensive step β€” an item with zero business/economy signal
is auto-rejected before it ever reaches classify_item().

A single source failing to fetch (network error, bad feed, site down) must not
fail the whole poll β€” it's logged and skipped, same as DataWiz's approach to
per-source scrape failures. A single item failing to classify (Groq error,
malformed response) is likewise skipped rather than aborting the run; it stays
unclassified and will be retried on the next poll since it's never marked
'rejected'.
"""

from datetime import datetime, timedelta, timezone

from news.scrape import fetch_source
from news.dedup import url_hash
from news.classify import classify_item
from news.relevance import score as relevance_score
from news.windows import compute_expiry
from utils.supabase_client import get_supabase
from intel.events import record_news_item

# Three-way routing by confidence (replaces the old complete/incomplete split).
# A complete, high-confidence item is business-relevant enough to publish with
# no human step. A low-confidence item β€” incomplete AND the model wasn't sure
# about it either β€” is almost always noise (celebrity gossip, a scraped nav
# menu) and gets discarded automatically rather than wasting a reviewer's time.
# Everything in between still needs a human: either it's complete but the
# model wasn't confident, or it's incomplete but confident enough that a
# person might be able to fill the gap.
AUTO_PUBLISH_CONFIDENCE = 0.8
AUTO_DISCARD_CONFIDENCE = 0.5


def _route(result) -> str:
    confidence = result.confidence if result.confidence is not None else 0.0
    if result.is_complete and confidence >= AUTO_PUBLISH_CONFIDENCE:
        return "classified"
    if not result.is_complete and confidence < AUTO_DISCARD_CONFIDENCE:
        return "rejected"
    return "needs_review"


def start_poll(since_days: int, triggered_by: str) -> str:
    """Records the poll row and hands back its id immediately β€” the scrape+classify
    work for even a handful of sources takes minutes (Groq calls per item), which
    is well past most gateway timeouts (HF Spaces included). The caller runs
    execute_poll(poll_id, since_days) as a background task rather than awaiting
    it inline, so the HTTP response never has to wait on it."""
    db = get_supabase()
    poll = db.table("news_polls").insert({
        "triggered_by": triggered_by,
        "since_days": since_days,
        "started_at": datetime.now(timezone.utc).isoformat(),
    }).execute()
    return poll.data[0]["id"]


def execute_poll(poll_id: str, since_days: int) -> dict:
    db = get_supabase()
    cutoff = datetime.now(timezone.utc) - timedelta(days=since_days)

    counts = {"items_scraped": 0, "items_deduped_out": 0, "items_classified": 0, "items_flagged": 0, "items_rejected": 0}
    error: str | None = None

    try:
        sources = db.table("news_sources").select("*").eq("active", True).execute().data

        for source in sources:
            db.table("news_polls").update({"current_source": source["name"], **counts}).eq("id", poll_id).execute()

            try:
                raw_items = fetch_source(source)
            except Exception as e:
                print(f"[news] source '{source['name']}' failed to fetch: {e}")
                continue

            for item in raw_items:
                if item.published_at and item.published_at < cutoff:
                    continue

                h = url_hash(item.url)
                existing = db.table("news_items").select("id").eq("url_hash", h).limit(1).execute().data
                if existing:
                    counts["items_deduped_out"] += 1
                    continue

                counts["items_scraped"] += 1
                scraped_at = datetime.now(timezone.utc)

                # Keyword pre-filter (DataWiz's relevance-gate pattern): a scraped item with
                # no business/economy/regulatory signal at all never reaches Groq β€” it's
                # auto-rejected for free instead of burning an AI call on sports, celebrity
                # or isolated-incident filler that a feed mixes in alongside real items.
                relevance = relevance_score(f"{item.title} {item.text}")
                if not relevance["is_candidate"]:
                    db.table("news_items").insert({
                        "source_id":     source["id"],
                        "url":           item.url,
                        "url_hash":      h,
                        "title":         item.title,
                        "raw_text":      item.text,
                        "published_at":  item.published_at.isoformat() if item.published_at else None,
                        "scraped_at":    scraped_at.isoformat(),
                        "status":        "rejected",
                        "review_reason": f"Pre-filter: {relevance['reason']}",
                        "expires_at":    scraped_at.isoformat(),
                    }).execute()
                    counts["items_rejected"] += 1
                    continue

                try:
                    result = classify_item(item.title, item.text)
                except Exception as e:
                    print(f"[news] classification failed for {item.url}: {e}")
                    continue

                expires_at = compute_expiry(result.category, scraped_at, result.deadline)
                status = _route(result)

                row = {
                    "source_id":     source["id"],
                    "url":           item.url,
                    "url_hash":      h,
                    "title":         item.title,
                    "raw_text":      item.text,
                    "published_at":  item.published_at.isoformat() if item.published_at else None,
                    "scraped_at":    scraped_at.isoformat(),
                    "status":        status,
                    "impact":        result.impact,
                    "category":      result.category,
                    "business_types": result.business_types,
                    "location":      result.location,
                    "deadline":      result.deadline.isoformat() if result.deadline else None,
                    "expires_at":    expires_at.isoformat(),
                    "confidence":    result.confidence,
                    "review_reason": result.review_reason(),
                    "classified_at": scraped_at.isoformat(),
                }
                db.table("news_items").insert(row).execute()

                if status == "classified":
                    counts["items_classified"] += 1
                    # Published items also become permanent event history for the
                    # intel store (no-op when it's off) β€” the feed expires them, the
                    # correlation step must not lose them.
                    record_news_item(row)
                elif status == "rejected":
                    counts["items_rejected"] += 1
                else:
                    counts["items_flagged"] += 1

    except Exception as e:
        error = str(e)

    db.table("news_polls").update({
        "finished_at": datetime.now(timezone.utc).isoformat(),
        "current_source": None,
        "error": error,
        **counts,
    }).eq("id", poll_id).execute()

    return {"poll_id": poll_id, "error": error, **counts}