{html.escape(s.title)}
- {mems}
diff --git a/prototype/cluster.py b/prototype/cluster.py new file mode 100644 index 0000000..0bc7517 --- /dev/null +++ b/prototype/cluster.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Story layer for the news aggregator. + +fetch -> dedup -> cluster -> rank -> [LLM top-k] -> out/index.html + +Reads from one of: + * a FreshRSS export (JSON snapshot on disk), or + * the starter OPML directly over HTTP (no Docker needed for dev). + +Usage: + python cluster.py # ingest -> out/index.html + python cluster.py --opml feeds.opml # fetch stub feeds directly (no FreshRSS) + python cluster.py --llm # LLM-summarize top-k via Hermes at :8080 + python cluster.py --serve :8765 # serve out/index.html on port 8765 + python cluster.py --topk 10 # cap LLM work to top-k stories + +Env (--llm only): + LLM_BASE_URL default http://127.0.0.1:8080/v1 (host llama.cpp / Hermes) + LLM_MODEL default hermes +""" +from __future__ import annotations + +import argparse +import hashlib +import html +import http.server +import json +import os +import random +import re +import sys +import time +import xml.etree.ElementTree as ET +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +try: + import httpx +except ImportError: # pragma: no cover + httpx = None + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36") + + +@dataclass +class Item: + guid: str + url: str + title: str + summary: str + source: str + published: float # epoch seconds + + @property + def normalized_title(self) -> str: + return re.sub(r"[^a-z0-9 ]+", " ", self.title.lower()).strip() + + +@dataclass +class Story: + id: int + title: str # canonical (longest) title among members + coverage_count: int + first_published: float + last_published: float + members: list = field(default_factory=list) + summary: str = "" + + +# --------------------------------------------------------------------------- +# Ingest +# --------------------------------------------------------------------------- + +def parse_opml(path: str) -> list[str]: + root = ET.parse(path).getroot() + urls = [] + for el in root.iter("outline"): + if el.attrib.get("type") == "rss" and el.attrib.get("xmlUrl"): + urls.append(el.attrib["xmlUrl"]) + return urls + + +ATOM = "{http://www.w3.org/2005/Atom}" + + +def _atom(e, tag): + """Fetch a namespaced Atom child by local name.""" + return e.find(f".//{ATOM}{tag}") + + +def parse_rss_text(text: str, source: str) -> list[Item]: + items: list[Item] = [] + root = ET.fromstring(text) + is_atom = root.tag == f"{ATOM}feed" + + def pick(entry, rss_tag, atom_tag, get_text=True): + if is_atom: + el = _atom(entry, atom_tag) + if el is not None: + if el.tag == f"{ATOM}link": + return el.attrib.get("href") + return el.text + else: + el = entry.find(rss_tag) + if el is not None: + return el.text + return None + + for entry in root.iter(): + local = entry.tag.rsplit("}", 1)[-1] + if not is_atom and local != "item": + continue + if is_atom and local != "entry": + continue + + title = pick(entry, "title", "title") + title = title or "(untitled)" + url = pick(entry, "link", "link") + guid = pick(entry, "guid", "id") or url or "" + summary = pick(entry, "description", "summary") or "" + if is_atom and str(summary).startswith("<"): + summary = "".join(_atom(entry, "summary").itertext()) if _atom(entry, "summary") is not None else "" + if not str(title).strip(): + continue + published = _pub_to_ts(pick(entry, "pubDate", "updated") or "") + items.append(Item(guid=str(guid), url=str(url or guid), title=html.unescape(str(title)).strip(), + summary=html.unescape(str(summary)).strip(), source=source, + published=published)) + return items + + +def _pub_to_ts(s: str) -> float: + s = s.strip() + for fmt in ("%a, %d %b %Y %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S %Z", + "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ"): + try: + return datetime.strptime(s, fmt).timestamp() + except ValueError: + continue + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() + except ValueError: + return time.time() + + +def fetch_opml(opml: str) -> list[Item]: + """Fetch every feed in the OPML directly. Tolerant of individual failures.""" + if httpx is None: + raise SystemExit("httpx not installed โ run: pip install -r requirements.txt") + all_items: list[Item] = [] + with httpx.Client(timeout=25.0, follow_redirects=True, headers={"User-Agent": UA}) as c: + for url in parse_opml(opml): + try: + r = c.get(url) + r.raise_for_status() + got = parse_rss_text(r.text, url) + print(f" {len(got):4d} {url}") + all_items.extend(got) + except Exception as e: # noqa: BLE001 + print(f" FAIL {url}: {e}") + return all_items + + +def load_snapshot(path: str) -> list[Item]: + """Load a FreshRSS export JSON: [{"url","title","summary","source","published"}, ...]""" + raw = json.loads(Path(path).read_text(encoding="utf-8")) + items = [] + for i, r in enumerate(raw): + items.append(Item(guid=str(r.get("guid", i)), url=r.get("url", ""), + title=r.get("title", ""), summary=r.get("summary", ""), + source=r.get("source", ""), published=float(r.get("published", time.time())))) + return items + + +# --------------------------------------------------------------------------- +# Dedup + clustering (MinHash + LSH -> connected components) +# --------------------------------------------------------------------------- + +def _word_shingles(text: str, k: int = 5): + words = text.split() + if len(words) < k: + return set(words) if words else set() + return set(" ".join(words[i:i + k]) for i in range(len(words) - k + 1)) + + +def _minhash_signature(shingles: set, perms): + sig = [] + for a, b in perms: + m = None + for s in shingles: + h = (a * hash(s) + b) % 2**31 + m = h if m is None else min(m, h) + sig.append(m if m is not None else 0) + return sig + + +class Clusterer: + def __init__(self, num_perm: int = 128, rows: int = 8, k: int = 4, + sim_threshold: float = 0.40): + # LSH bands: num_perm rows per band -> number of bands = num_perm // rows + # k = word-shingle width over title + body (bodies carry the signal in + # syndicated rewrites; titles routinely diverge). + self.num_perm = num_perm + self.rows = rows + self.k = k + self.sim_threshold = sim_threshold + random.seed(7) + self.perms = [(random.randint(1, 2**31 - 1), random.randint(1, 2**31 - 1)) + for _ in range(num_perm)] + + def signature(self, item: Item) -> list: + text = f"{item.normalized_title} {re.sub(r'[^a-z0-9 ]+', ' ', item.summary.lower())}" + return _minhash_signature(_word_shingles(text, self.k), self.perms) or [0] * self.num_perm + + @staticmethod + def jaccard(s1: list, s2: list) -> float: + eq = sum(1 for a, b in zip(s1, s2) if a == b) + return eq / len(s1) + + def _bands(self, sig: list): + bands = [] + for i in range(0, self.num_perm, self.rows): + bands.append(tuple(sig[i:i + self.rows])) + return bands + + def cluster(self, items: list[Item]) -> list[Story]: + # --- dedup fast path: exact normalized title OR same canonical url --- + seen_title: dict[str, int] = {} + seen_url: dict[str, int] = {} + groups: list[list[Item]] = [] + for it in items: + key = it.normalized_title + matched = seen_title.get(key) or seen_url.get(it.url.strip().rstrip("/")) + if matched is not None: + groups[matched].append(it) + continue + groups.append([it]) + seen_title[key] = len(groups) - 1 + seen_url.setdefault(it.url.strip().rstrip("/"), len(groups) - 1) + + # --- MinHash signatures: representative = longest member of each group --- + sigs = [self.signature(max(g, key=lambda it: len(it.title))) for g in groups] + + union = [-1] * len(groups) + + def find(x): + while union[x] != -1: + x = union[x] + return x + + def merge(a, b): + ra, rb = find(a), find(b) + if ra != rb: + union[ra] = rb + + n = len(groups) + if n <= 2000: + # Prototype scale: exact pairwise over MinHash signatures. Guarantees + # the merge for near-dups that LSH banding would miss (band all-equal + # probability collapses as jaccard drops toward the threshold). + for i in range(n): + for j in range(i + 1, n): + if self.jaccard(sigs[i], sigs[j]) >= self.sim_threshold: + merge(i, j) + else: + # Large corpus: LSH bands prune candidates, then confirm by jaccard. + buckets: dict[tuple, list[int]] = {} + for idx, sig in enumerate(sigs): + for band in self._bands(sig): + buckets.setdefault(band, []).append(idx) + for band, idxs in buckets.items(): + if len(idxs) < 2: + continue + for i in range(len(idxs)): + for j in range(i + 1, len(idxs)): + a, b = idxs[i], idxs[j] + if self.jaccard(sigs[a], sigs[b]) >= self.sim_threshold: + merge(a, b) + + clusters: dict[int, list] = {} + for gi in range(len(groups)): + clusters.setdefault(find(gi), []).extend(groups[gi]) + return [self._make_story(i, members) for i, members in + enumerate(clusters.values())] + + @staticmethod + def _make_story(i, members: list[Item]) -> Story: + members.sort(key=lambda it: it.published) + longest = max(members, key=lambda it: len(it.title)) + return Story( + id=i, + title=longest.title, + coverage_count=len(members), + first_published=members[0].published, + last_published=members[-1].published, + members=members, + summary="", + ) + + +# --------------------------------------------------------------------------- +# Rank +# --------------------------------------------------------------------------- + +def compute_scores(stories: list[Story], now: float, alpha: float = 1.8) -> list: + """score = authority * (coverage_count + 1) / (age_h + 2)^1.8 (HN-style decay)""" + scored = [] + for s in stories: + age_h = max(0.0, (now - s.first_published) / 3600.0) + rank = 1.0 * (s.coverage_count + 1) / (age_h + 2.0) ** alpha + scored.append((rank, s)) + scored.sort(key=lambda x: -x[0]) + return scored + + +# --------------------------------------------------------------------------- +# LLM summary of top-k only (capped; host llama.cpp/Hermes, never DeepSeek) +# --------------------------------------------------------------------------- + +def llm_summarize_topk(scored, topk: int) -> None: + base = os.environ.get("LLM_BASE_URL", "http://127.0.0.1:8080/v1").rstrip("/") + model = os.environ.get("LLM_MODEL", "hermes") + if httpx is None: + print(" (httpx missing; skipping LLM)") + return + for rank, story in scored[:topk]: + lead = story.members[0].url or story.members[0].summary + text = f"{story.title}\n\n{story.members[0].summary[:300]}" + prompt = ("Summarize this news story cluster in 2 punchy sentences. " + f"({story.coverage_count} sources). Story:\n{text}") + try: + with httpx.Client(timeout=120.0) as c: + r = c.post(f"{base}/chat/completions", json={ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 120, + }) + r.raise_for_status() + story.summary = r.json()["choices"][0]["message"]["content"].strip() + print(f" [llm] {story.title[:50]}...") + except Exception as e: # noqa: BLE001 + print(f" [llm FAIL] {story.title[:40]}: {e}") + + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + +def _fmt(ts: float) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%b %d %H:%M") + + +def render_html(stories: list, out_dir: Path) -> None: + rows = [] + for rank, s in enumerate(stories, 1): + mems = "".join( + f'
{html.escape(s.summary)}
" if s.summary else "" + rows.append(f""" +No stories.
" + html_doc = f""" +