"""T03 — News story clustering (G04 FIX). Per MINIMAX_M3_TASKS.md T03. MinHash + DBSCAN dedupes raw RSS items into single "stories" so AI agents don't see the same CoinDesk/The Block story counted 2-3x in their signal. Algorithm: 1. MinHash signature (128 permutations) on shingled title+body (first 500 chars) 2. DBSCAN clusters within 30-minute windows, Jaccard threshold 0.6, eps=0.15 3. Each cluster = one story with all source URLs, sentiment avg, item count 4. Persist clusters to Postgres `news_clusters` table; raw items unchanged Endpoints: GET /api/v1/news?clustered=true returns stories (clusters), not raw items GET /api/v1/news raw items (legacy) This module is pure logic — no I/O at import time. Router/background job call `cluster_items(items) -> list[StoryCluster]`. """ from __future__ import annotations import hashlib import logging import re import time from dataclasses import dataclass, field from datetime import UTC, datetime logger = logging.getLogger(__name__) # ── Tokenization ──────────────────────────────────────────────────── _TOKEN_RE = re.compile(r"[a-z0-9]{3,}", re.IGNORECASE) def _shingles(text: str, k: int = 3) -> set[str]: """k-shingle set of lowercased alphanumeric tokens. For Jaccard.""" if not text: return set() toks = _TOKEN_RE.findall(text.lower()) if len(toks) < k: return set(toks) return {" ".join(toks[i : i + k]) for i in range(len(toks) - k + 1)} # ── MinHash ───────────────────────────────────────────────────────── _NUM_PERM = 128 _MAX_HASH = (1 << 32) - 1 def _minhash_signature(shingles: set[str], seed: int = 42) -> list[int]: """128-permutation MinHash signature of a shingle set. Uses SHA-256 seeded permutations — fast, deterministic, no numpy. """ if not shingles: return [_MAX_HASH] * _NUM_PERM sig: list[int] = [] for i in range(_NUM_PERM): m = _MAX_HASH for s in shingles: h = int.from_bytes( hashlib.sha256(f"{i}:{seed}:{s}".encode()).digest()[:4], "big", ) if h < m: m = h sig.append(m) return sig def _jaccard_minhash(a: list[int], b: list[int]) -> float: """Estimate Jaccard similarity from two MinHash signatures.""" if not a or not b or len(a) != len(b): return 0.0 return sum(1 for x, y in zip(a, b, strict=False) if x == y) / len(a) # ── DBSCAN (pure-python, no sklearn dep) ──────────────────────────── def _dbscan( signatures: list[list[int]], eps: float = 0.4, min_samples: int = 2, ) -> list[int]: """Density-based clustering. Returns cluster id per item (-1 = noise). Similarity is Jaccard (estimated via MinHash). Neighbours are pairs with Jaccard distance <= eps (i.e. similarity >= 1 - eps). Default eps=0.4 means similarity >= 0.6 (per T03 spec). """ n = len(signatures) labels = [-1] * n cluster_id = 0 for i in range(n): if labels[i] != -1: continue neighbors = [ j for j in range(n) if i != j and (1.0 - _jaccard_minhash(signatures[i], signatures[j])) <= eps ] if len(neighbors) < min_samples - 1: # not enough neighbours — mark as noise (may become border later) continue labels[i] = cluster_id seed_set = list(neighbors) k = 0 while k < len(seed_set): q = seed_set[k] if labels[q] == -1: labels[q] = cluster_id q_neighbors = [ j for j in range(n) if j != q and (1.0 - _jaccard_minhash(signatures[q], signatures[j])) <= eps ] if len(q_neighbors) >= min_samples - 1: seed_set.extend(q_neighbors) elif labels[q] is None or labels[q] == -1: labels[q] = cluster_id k += 1 cluster_id += 1 return labels # ── Domain types ──────────────────────────────────────────────────── @dataclass class NewsItem: """Minimal news item for clustering. Adapts from DB rows or dicts.""" id: str title: str body: str = "" source: str = "" url: str = "" published_at: datetime = field(default_factory=lambda: datetime.now(UTC)) sentiment: float = 0.0 @classmethod def from_row(cls, row: dict) -> NewsItem: published = row.get("published_at") or row.get("created_at") if isinstance(published, str): try: published = datetime.fromisoformat(published.replace("Z", "+00:00")) except (ValueError, AttributeError): published = datetime.now(UTC) elif not isinstance(published, datetime): published = datetime.now(UTC) return cls( id=str(row.get("id", row.get("news_id", ""))), title=row.get("title", "") or "", body=(row.get("body") or row.get("summary") or "")[:500], source=row.get("source", "") or "", url=row.get("url", "") or "", published_at=published, sentiment=float(row.get("sentiment", 0.0) or 0.0), ) @dataclass class StoryCluster: """One deduplicated story spanning 1+ source items.""" cluster_id: str representative_title: str source_urls: list[str] sources: list[str] first_seen: datetime last_updated: datetime item_count: int sentiment_avg: float item_ids: list[str] def to_dict(self) -> dict: return { "cluster_id": self.cluster_id, "representative_title": self.representative_title, "source_urls": self.source_urls, "sources": self.sources, "first_seen": self.first_seen.isoformat(), "last_updated": self.last_updated.isoformat(), "item_count": self.item_count, "sentiment_avg": round(self.sentiment_avg, 3), "item_ids": self.item_ids, } # ── Main entry point ──────────────────────────────────────────────── def cluster_items( items: list[NewsItem], window_minutes: int = 30, eps: float = 0.4, min_samples: int = 2, ) -> list[StoryCluster]: """Cluster news items into stories. Items are first grouped by 30-minute time windows, then DBSCAN runs on MinHash signatures within each window. Single-item clusters are kept (they're "noise" in DBSCAN terms but valid singleton stories). `eps` is the Jaccard DISTANCE threshold (1 - similarity). Per the task spec, two items cluster together when Jaccard similarity >= 0.6, so distance <= 0.4, so eps=0.4. Tighten for stricter clusters. """ t0 = time.time() if not items: return [] # Group by time window windows: dict[datetime, list[NewsItem]] = {} for it in sorted(items, key=lambda x: x.published_at): bucket = it.published_at.replace( minute=(it.published_at.minute // window_minutes) * window_minutes, second=0, microsecond=0, ) windows.setdefault(bucket, []).append(it) stories: list[StoryCluster] = [] for _bucket, group in windows.items(): if len(group) == 1: # singleton — still a story it = group[0] stories.append( StoryCluster( cluster_id=hashlib.sha1( f"single:{it.id}:{it.published_at.isoformat()}".encode() ).hexdigest()[:16], representative_title=it.title, source_urls=[it.url] if it.url else [], sources=[it.source] if it.source else [], first_seen=it.published_at, last_updated=it.published_at, item_count=1, sentiment_avg=it.sentiment, item_ids=[it.id], ) ) continue sigs = [_minhash_signature(_shingles(f"{it.title} {it.body}")) for it in group] labels = _dbscan(sigs, eps=eps, min_samples=min_samples) # Singletons (label == -1) still become stories clusters: dict[int, list[int]] = {} for idx, lbl in enumerate(labels): clusters.setdefault(lbl if lbl != -1 else idx, []).append(idx) for _cid, indices in clusters.items(): members = [group[i] for i in indices] # Pick representative = longest title (usually the most descriptive) rep = max(members, key=lambda x: len(x.title)) sentiments = [m.sentiment for m in members if m.sentiment is not None] avg_sent = sum(sentiments) / len(sentiments) if sentiments else 0.0 cluster_id = hashlib.sha1( ":".join(sorted(m.id for m in members)).encode() ).hexdigest()[:16] stories.append( StoryCluster( cluster_id=cluster_id, representative_title=rep.title, source_urls=[m.url for m in members if m.url], sources=sorted({m.source for m in members if m.source}), first_seen=min(m.published_at for m in members), last_updated=max(m.published_at for m in members), item_count=len(members), sentiment_avg=avg_sent, item_ids=[m.id for m in members], ) ) logger.info( "news_clustered items=%d stories=%d windows=%d elapsed_ms=%.1f", len(items), len(stories), len(windows), (time.time() - t0) * 1000, ) return stories # ── DB persistence (optional, lazy import) ────────────────────────── _PG_SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS news_clusters ( cluster_id TEXT PRIMARY KEY, representative_title TEXT NOT NULL, first_seen TIMESTAMPTZ NOT NULL, last_updated TIMESTAMPTZ NOT NULL, item_count INTEGER NOT NULL DEFAULT 0, sentiment_avg DOUBLE PRECISION NOT NULL DEFAULT 0.0, source_urls JSONB NOT NULL DEFAULT '[]'::jsonb, sources JSONB NOT NULL DEFAULT '[]'::jsonb, item_ids JSONB NOT NULL DEFAULT '[]'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS news_clusters_last_updated_idx ON news_clusters (last_updated DESC); """ async def ensure_schema() -> bool: """Create news_clusters table if missing. Returns True on success.""" try: import asyncpg from app.core.db_pool import PG_URL conn = await asyncpg.connect(PG_URL) try: await conn.execute(_PG_SCHEMA_SQL) finally: await conn.close() logger.info("news_clusters_schema_ready") return True except Exception as exc: logger.warning("news_clusters_schema_failed err=%s", exc) return False async def persist_clusters(stories: list[StoryCluster]) -> int: """Upsert stories to Postgres. Returns rows affected.""" if not stories: return 0 try: import json import asyncpg from app.core.db_pool import PG_URL conn = await asyncpg.connect(PG_URL) try: rows = [ ( s.cluster_id, s.representative_title, s.first_seen, s.last_updated, s.item_count, s.sentiment_avg, json.dumps(s.source_urls), json.dumps(s.sources), json.dumps(s.item_ids), ) for s in stories ] await conn.executemany( """ INSERT INTO news_clusters (cluster_id, representative_title, first_seen, last_updated, item_count, sentiment_avg, source_urls, sources, item_ids) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb) ON CONFLICT (cluster_id) DO UPDATE SET representative_title = EXCLUDED.representative_title, first_seen = EXCLUDED.first_seen, last_updated = EXCLUDED.last_updated, item_count = EXCLUDED.item_count, sentiment_avg = EXCLUDED.sentiment_avg, source_urls = EXCLUDED.source_urls, sources = EXCLUDED.sources, item_ids = EXCLUDED.item_ids """, rows, ) return len(rows) finally: await conn.close() except Exception as exc: logger.warning("news_clusters_persist_failed err=%s", exc) return 0 async def load_recent_clusters(limit: int = 50) -> list[dict]: """Load recent clusters from Postgres.""" try: import json import asyncpg from app.core.db_pool import PG_URL conn = await asyncpg.connect(PG_URL) try: rows = await conn.fetch( "SELECT * FROM news_clusters ORDER BY last_updated DESC LIMIT $1", limit, ) return [ { "cluster_id": r["cluster_id"], "representative_title": r["representative_title"], "first_seen": r["first_seen"].isoformat(), "last_updated": r["last_updated"].isoformat(), "item_count": r["item_count"], "sentiment_avg": r["sentiment_avg"], "source_urls": json.loads(r["source_urls"]), "sources": json.loads(r["sources"]), "item_ids": json.loads(r["item_ids"]), } for r in rows ] finally: await conn.close() except Exception as exc: logger.warning("news_clusters_load_failed err=%s", exc) return [] __all__ = [ "NewsItem", "StoryCluster", "cluster_items", "ensure_schema", "load_recent_clusters", "persist_clusters", ]