Phase 2: ingest, embed, cluster

Pipeline: fetch -> normalise -> embed (CPU) -> cluster. `news poll` runs a
cycle; `news sources` and `news clusters` inspect the result. Replay and the
live service share run_cycle so tuning and production cannot diverge -- the old
system's replay harness hardcoded default params and could never sweep the
thresholds it existed to tune.

Calibrated, not guessed:

- Cluster threshold 0.82, from reading real output. 0.70 merged "Solheim Cup
  capsules" with "FIBA Women's World Cup Glance"; 0.82 gives clean groups (76
  items on the AfD Saxony-Anhalt election, 27 on the Nepal tunnel rescue). The
  old system produced 515 clusters of which 514 were singletons.
- Corroboration counts DISTINCT buckets, not feeds, so ten outlets reprinting
  one wire story is one confirmation rather than ten.

Removed after measurement: a SimHash near-duplicate stage. It gave +1.2 bits of
separation on titles and -0.2 with summaries -- news duplicates are paraphrases,
which is the same lexical trap that sank the old Jaccard clustering.

Sources: all 39 fetched against the live web, not assumed. Dropped CBC (every
feed URL times out from this host) and Tech Policy Press (serves HTML at every
documented feed path). Fixed WVXU (index.rss is an empty stub; local-news.rss
works). Reddit moved to .rss because .json now 403s without OAuth, and its
intervals raised because it 429s after three quick requests. Deutsche Welle is
back: it ships RSS 1.0/RDF, which feedparser reads and the old hand-rolled
quick-xml parser could not.
This commit is contained in:
connor committed 2026-09-08 14:59:06 -04:00
1 parent c89a723603
commit 64690ecfe0
18 files changed
+1338

No files matched your search

+3
View File
@@ -30,3 +30,6 @@ tests/**/*.pyc
**/*.db-shm
**/*.npy
.venv/**
# Local runtime data
data/**
+3
View File
@@ -0,0 +1,3 @@
"""Personal news aggregator: many sources in, a few well-chosen notifications out."""
__version__ = "0.1.0"
+82
View File
@@ -0,0 +1,82 @@
"""Command line entry point."""
from __future__ import annotations
import argparse
import sys
from . import cluster as cluster_mod
from . import store
from .config import Config
from .embed import Embedder
from .pipeline import run_cycle
def cmd_poll(args) -> int:
cfg = Config()
con = store.connect(cfg.db_path)
report = run_cycle(con, cfg, Embedder())
print(report)
for err in report.errors or []:
print(f" ! {err}", file=sys.stderr)
return 0
def cmd_sources(args) -> int:
cfg = Config()
con = store.connect(cfg.db_path)
rows = con.execute(
"""SELECT s.name, s.kind, s.bucket, s.enabled, s.last_status,
COUNT(i.id) AS items
FROM sources s LEFT JOIN items i ON i.source_id = s.id
GROUP BY s.id ORDER BY items DESC"""
).fetchall()
print(f"{'source':26} {'kind':7} {'bucket':22} {'items':>6} status")
for r in rows:
flag = " " if r["enabled"] else "-"
print(f"{flag}{r['name'][:25]:25} {r['kind']:7} {r['bucket'][:21]:22} "
f"{r['items']:>6} {(r['last_status'] or '')[:40]}")
return 0
def cmd_clusters(args) -> int:
cfg = Config()
con = store.connect(cfg.db_path)
rows = con.execute(
"""SELECT c.id, c.size, c.first_seen_at,
(SELECT title FROM items WHERE id = c.lead_item_id) AS lead
FROM clusters c WHERE c.size >= ?
ORDER BY c.size DESC, c.last_seen_at DESC LIMIT ?""",
(args.min_size, args.limit),
).fetchall()
for r in rows:
buckets = cluster_mod.bucket_count(con, r["id"])
print(f"\n[{r['size']:>3} items, {buckets} independent bucket(s)] {r['lead']}")
if args.verbose:
for m in con.execute(
"""SELECT i.title, s.name FROM cluster_members cm
JOIN items i ON i.id=cm.item_id JOIN sources s ON s.id=i.source_id
WHERE cm.cluster_id=? LIMIT 8""", (r["id"],)):
print(f" [{m['name'][:16]:16}] {m['title'][:70]}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="news")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("poll", help="run one fetch/embed/cluster cycle").set_defaults(fn=cmd_poll)
sub.add_parser("sources", help="list sources and item counts").set_defaults(fn=cmd_sources)
p = sub.add_parser("clusters", help="inspect clusters")
p.add_argument("--min-size", type=int, default=2)
p.add_argument("--limit", type=int, default=20)
p.add_argument("-v", "--verbose", action="store_true")
p.set_defaults(fn=cmd_clusters)
args = parser.parse_args(argv)
return args.fn(args)
if __name__ == "__main__":
raise SystemExit(main())
+149
View File
@@ -0,0 +1,149 @@
"""Incremental story clustering by embedding similarity.
Replaces the old system's Jaccard over title tokens, which could not see
paraphrase: "Gloria Steinem" coverage scored 0.33 against a 0.5 threshold and
every story stayed a singleton, so corroboration -- and therefore the whole
importance axis -- read 1 for everything.
THRESHOLD is calibrated, not guessed. On 3,885 real items:
0.70 over-merges badly ("Solheim Cup capsules" joins "FIBA Women's World Cup
Glance"; two different baseball games merge)
0.74 still mixes distinct stories that share a topic
0.82 chosen. 35% of items land in multi-item clusters and the large ones are
clean: 76 items on the AfD Saxony-Anhalt election, 28 on the Trump
envoys to Moscow and Kyiv, 27 on the Nepal tunnel rescue, 22 on the UK
West Bank settlement sanctions.
0.86 under-merges; only 22% of items cluster.
Over-merging is the worse failure: it inflates the corroboration signal that
feeds importance, and it merges unrelated stories into one notification.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta
import numpy as np
from . import store
from .util import stable_id
THRESHOLD = 0.82
WINDOW_HOURS = 48
@dataclass(frozen=True)
class Assignment:
item_id: str
cluster_id: str
created: bool
similarity: float
def _parse(ts: str) -> datetime:
return datetime.fromisoformat(ts)
def assign(
con: sqlite3.Connection,
items: list[sqlite3.Row],
vectors: dict[str, np.ndarray],
threshold: float = THRESHOLD,
window_hours: int = WINDOW_HOURS,
) -> list[Assignment]:
"""Assign items to clusters, oldest first.
Order matters: processing in publication order makes the result stable and
reproducible in replay, which is what makes threshold tuning meaningful.
"""
out: list[Assignment] = []
ordered = sorted(items, key=lambda r: r["published_at"])
for item in ordered:
vec = vectors.get(item["content_hash"])
if vec is None:
continue
# Guard against double-counting: re-assigning an item that already has a
# cluster would increment that cluster's size again and corrupt the
# corroboration signal. The pipeline only passes unclustered items, but
# replay and manual invocation must be safe too.
existing = con.execute(
"SELECT cluster_id FROM cluster_members WHERE item_id=?", (item["id"],)
).fetchone()
if existing is not None:
out.append(Assignment(item["id"], existing["cluster_id"], False, 1.0))
continue
published = _parse(item["published_at"])
cutoff = (published - timedelta(hours=window_hours)).isoformat()
# Only clusters still warm can absorb the item. A 2h grace on the upper
# bound lets a late-arriving report join a story it predates.
rows = con.execute(
"""SELECT id, centroid, size FROM clusters
WHERE last_seen_at >= ? AND first_seen_at <= ?""",
(cutoff, (published + timedelta(hours=2)).isoformat()),
).fetchall()
best_id, best_sim = None, -1.0
if rows:
mat = np.stack([store.from_blob(r["centroid"]) for r in rows])
mat /= np.maximum(np.linalg.norm(mat, axis=1, keepdims=True), 1e-12)
sims = mat @ vec
k = int(np.argmax(sims))
best_id, best_sim = rows[k]["id"], float(sims[k])
if best_id is not None and best_sim >= threshold:
row = next(r for r in rows if r["id"] == best_id)
size = row["size"]
centroid = (store.from_blob(row["centroid"]) * size + vec) / (size + 1)
con.execute(
"""UPDATE clusters SET centroid=?, size=?, last_seen_at=max(last_seen_at,?)
WHERE id=?""",
(store.to_blob(centroid), size + 1, item["published_at"], best_id),
)
cluster_id, created = best_id, False
else:
cluster_id = stable_id("cluster", item["id"])
con.execute(
"""INSERT OR IGNORE INTO clusters
(id,centroid,size,first_seen_at,last_seen_at,lead_item_id)
VALUES (?,?,?,?,?,?)""",
(
cluster_id,
store.to_blob(vec),
1,
item["published_at"],
item["published_at"],
item["id"],
),
)
created = True
con.execute(
"""INSERT OR IGNORE INTO cluster_members (cluster_id,item_id,added_at)
VALUES (?,?,datetime('now'))""",
(cluster_id, item["id"]),
)
out.append(Assignment(item["id"], cluster_id, created, best_sim))
return out
def bucket_count(con: sqlite3.Connection, cluster_id: str) -> int:
"""Distinct editorial-independence buckets in a cluster.
This, not raw member count, is the corroboration signal. Ten outlets
reprinting one wire story is one bucket; the old system counted it as ten
and then fired a priority-5 push on it.
"""
row = con.execute(
"""SELECT COUNT(DISTINCT s.bucket) AS n
FROM cluster_members cm
JOIN items i ON i.id = cm.item_id
JOIN sources s ON s.id = i.source_id
WHERE cm.cluster_id = ?""",
(cluster_id,),
).fetchone()
return int(row["n"] or 0)
+46
View File
@@ -0,0 +1,46 @@
"""Configuration loading: sources, profile, and runtime paths."""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def _path(env: str, default: Path) -> Path:
return Path(os.environ.get(env, default))
@dataclass
class Config:
db_path: Path = field(default_factory=lambda: _path("NEWS_DB", REPO / "data" / "news.db"))
sources_path: Path = field(
default_factory=lambda: _path("NEWS_SOURCES", REPO / "profile" / "sources.toml")
)
profile_path: Path = field(
default_factory=lambda: _path("NEWS_PROFILE", REPO / "profile" / "profile.toml")
)
user_agent: str = (
"news/0.1 (+https://news.rcjohnstone.com; personal feed reader; one user)"
)
def sources(self) -> list[dict]:
raw = tomllib.loads(self.sources_path.read_text())
defaults = raw.get("defaults", {})
out = []
for entry in raw.get("source", []):
merged = {**defaults, **entry}
merged.setdefault("kind", "rss")
merged.setdefault("bucket", merged["name"].lower().replace(" ", "-"))
merged.setdefault("weight", 1.0)
merged.setdefault("prominence", 0)
merged.setdefault("enabled", True)
merged.setdefault("poll_interval_secs", 900)
out.append(merged)
return out
def profile(self) -> dict:
return tomllib.loads(self.profile_path.read_text())
+64
View File
@@ -0,0 +1,64 @@
"""Embedding, on CPU, with a content-hash cache.
Deliberately fastembed (quantized ONNX) rather than sentence-transformers: no
torch, no CUDA, ~130 MB of model. It never touches the GPU, so it cannot
contend with the llama-swap inference stack.
Measured on this box: ~11 items/sec with default settings. That is far slower
than the "thousands/sec" the design originally assumed, but irrelevant in
practice -- a ~2,000-item day embeds in about three minutes, and the cache means
each distinct text is embedded exactly once.
"""
from __future__ import annotations
import sqlite3
from collections.abc import Sequence
import numpy as np
from . import store
MODEL = "BAAI/bge-small-en-v1.5"
DIM = 384
class Embedder:
def __init__(self, model_name: str = MODEL) -> None:
self._model_name = model_name
self._model = None # loaded lazily: importing fastembed is slow
def _load(self):
if self._model is None:
from fastembed import TextEmbedding
self._model = TextEmbedding(self._model_name)
return self._model
def encode(self, texts: Sequence[str]) -> np.ndarray:
"""L2-normalised embeddings, so a dot product is cosine similarity."""
if not texts:
return np.zeros((0, DIM), dtype=np.float32)
vecs = np.array(list(self._load().embed(list(texts))), dtype=np.float32)
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
return vecs / np.maximum(norms, 1e-12)
def encode_cached(
self, con: sqlite3.Connection, pairs: Sequence[tuple[str, str]]
) -> dict[str, np.ndarray]:
"""`pairs` is (content_hash, text). Only cache misses are embedded."""
hashes = [h for h, _ in pairs]
have = store.cached_vectors(con, hashes)
todo = {h: t for h, t in pairs if h not in have}
if todo:
keys = list(todo)
fresh = self.encode([todo[k] for k in keys])
store.store_vectors(con, zip(keys, fresh))
have.update(dict(zip(keys, fresh)))
return have
def embed_text(title: str, summary: str = "", limit: int = 400) -> str:
"""The canonical text form. Must match everywhere or the cache is useless."""
summary = " ".join((summary or "").split())[:limit]
return f"{title.strip()}. {summary}".strip()
+129
View File
@@ -0,0 +1,129 @@
"""One poll cycle: fetch -> store -> embed -> cluster.
Kept separate from the CLI and the (future) scheduler so replay and the live
service run exactly the same code. Divergence between the tuning path and the
production path is what made the old system's replay harness useless.
"""
from __future__ import annotations
import asyncio
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC, datetime
import httpx
from . import cluster, store
from .config import Config
from .embed import Embedder, embed_text
from .sources import fetch
from .util import stable_id
@dataclass
class CycleReport:
fetched: int = 0
new_items: int = 0
embedded: int = 0
clustered: int = 0
new_clusters: int = 0
errors: list[str] = field(default_factory=list)
rate_limited: list[str] = field(default_factory=list)
def __str__(self) -> str:
errs = f", {len(self.errors)} errors" if self.errors else ""
if self.rate_limited:
errs += f", rate-limited: {', '.join(self.rate_limited)}"
return (
f"fetched {self.fetched}, new {self.new_items}, embedded {self.embedded}, "
f"clustered {self.clustered} ({self.new_clusters} new){errs}"
)
def sync_sources(con: sqlite3.Connection, cfg: Config) -> list[dict]:
"""Config is authoritative. Returns the enabled sources with their ids."""
out = []
for src in cfg.sources():
src = dict(src)
src["id"] = stable_id("source", src["url"])
src["enabled"] = 1 if src.get("enabled", True) else 0
store.upsert_source(con, **{k: src[k] for k in (
"id", "name", "url", "kind", "bucket", "weight",
"prominence", "enabled", "poll_interval_secs")})
if src["enabled"]:
row = con.execute(
"SELECT etag, last_modified FROM sources WHERE id=?", (src["id"],)
).fetchone()
src["etag"], src["last_modified"] = row["etag"], row["last_modified"]
out.append(src)
return out
async def fetch_all(cfg: Config, sources: list[dict], concurrency: int = 8):
"""Fetch every source concurrently. A failing source never blocks the others."""
results: list[tuple[dict, list[dict], dict, str | None]] = []
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(30.0, connect=10.0),
headers={"User-Agent": cfg.user_agent, "Accept": "*/*"},
) as client:
async def one(src):
async with sem:
try:
items, state = await fetch(client, src)
return src, items, state, None
except Exception as exc: # noqa: BLE001 - one bad feed must not stop a cycle
return src, [], {}, f"{src['name']}: {type(exc).__name__}: {exc}"
results = await asyncio.gather(*(one(s) for s in sources))
return results
def run_cycle(con: sqlite3.Connection, cfg: Config, embedder: Embedder) -> CycleReport:
report = CycleReport()
sources = sync_sources(con, cfg)
results = asyncio.run(fetch_all(cfg, sources))
for src, items, state, err in results:
if err:
report.errors.append(err)
con.execute(
"UPDATE sources SET last_polled_at=?, last_status=? WHERE id=?",
(datetime.now(UTC).isoformat(), err[:200], src["id"]),
)
continue
if state.get("rate_limited"):
report.rate_limited.append(src["name"])
report.fetched += len(items)
if items:
report.new_items += store.insert_items(con, items)
con.execute(
"""UPDATE sources SET etag=?, last_modified=?, last_polled_at=?, last_status=?
WHERE id=?""",
(state.get("etag"), state.get("last_modified"),
datetime.now(UTC).isoformat(), state.get("status", "ok"), src["id"]),
)
# Embed and cluster anything not yet in a cluster, oldest first.
pending = con.execute(
"""SELECT i.* FROM items i
LEFT JOIN cluster_members cm ON cm.item_id = i.id
WHERE cm.item_id IS NULL
ORDER BY i.published_at"""
).fetchall()
if pending:
pairs = [(r["content_hash"], embed_text(r["title"], r["summary"])) for r in pending]
before = con.execute("SELECT COUNT(*) AS n FROM embeddings").fetchone()["n"]
vectors = embedder.encode_cached(con, pairs)
report.embedded = con.execute(
"SELECT COUNT(*) AS n FROM embeddings"
).fetchone()["n"] - before
assignments = cluster.assign(con, list(pending), vectors)
report.clustered = len(assignments)
report.new_clusters = sum(1 for a in assignments if a.created)
return report
+21
View File
@@ -0,0 +1,21 @@
"""Source adapters. Each returns normalised item dicts; the store does the rest."""
from __future__ import annotations
from . import hn, rss
# No Reddit adapter: the .json endpoints 403 without OAuth as of 2026-09, and
# the .rss views parse fine as ordinary Atom. Subreddits are configured as
# `kind = "rss"` instead.
ADAPTERS = {"rss": rss.fetch, "hn": hn.fetch}
async def fetch(client, source) -> tuple[list[dict], dict]:
"""Dispatch to the adapter for `source['kind']`.
Returns (items, state) where state carries etag/last_modified to persist.
"""
adapter = ADAPTERS.get(source["kind"])
if adapter is None:
raise ValueError(f"unknown source kind: {source['kind']!r}")
return await adapter(client, source)
+52
View File
@@ -0,0 +1,52 @@
"""Hacker News via the Algolia search API.
Uses `search_by_date` rather than the front page so items are seen early, and
carries points and comment counts as `ext_*`. Those are re-polled over an item's
life: an HN post at 3 points is noise and the same post at 300 is news, so the
routing layer waits for a maturation window instead of firing on first sight.
"""
from __future__ import annotations
from datetime import UTC, datetime
from ..embed import embed_text
from ..util import canonical_url, content_hash, stable_id
API = "https://hn.algolia.com/api/v1/search_by_date"
async def fetch(client, source) -> tuple[list[dict], dict]:
params = {
"tags": source.get("query", "story"),
"hitsPerPage": str(source.get("page_size", 100)),
}
resp = await client.get(API, params=params)
resp.raise_for_status()
now = datetime.now(UTC).isoformat()
items = []
for hit in resp.json().get("hits", []):
title = " ".join((hit.get("title") or "").split())
# Ask HN and text posts have no URL; point at the discussion instead.
link = hit.get("url") or f"https://news.ycombinator.com/item?id={hit['objectID']}"
if not title:
continue
canon = canonical_url(link)
items.append(
{
"id": stable_id(source["id"], canon),
"source_id": source["id"],
"title": title,
"summary": (hit.get("story_text") or "")[:600],
"url": link,
"canonical_url": canon,
"published_at": datetime.fromtimestamp(hit["created_at_i"], UTC).isoformat(),
"first_seen_at": now,
"content_hash": content_hash(embed_text(title, "")),
"ext_score": hit.get("points"),
"ext_comments": hit.get("num_comments"),
"ext_updated_at": now,
}
)
return items, {"etag": None, "last_modified": None, "status": str(resp.status_code)}
+100
View File
@@ -0,0 +1,100 @@
"""RSS/Atom via feedparser.
feedparser is used instead of hand-rolled XML parsing on purpose. The old
system's 700 lines of quick-xml could not read RSS 1.0/RDF at all, which is why
Deutsche Welle was excluded with the note that it "would yield zero items
silently". feedparser reads RDF, RSS and Atom, and arXiv and GitHub release
feeds are just Atom, so they need no adapter of their own.
"""
from __future__ import annotations
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import feedparser
from ..embed import embed_text
from ..util import canonical_url, content_hash, stable_id
MAX_BYTES = 10 * 1024 * 1024
def _published(entry) -> datetime:
for key in ("published", "updated", "created"):
raw = entry.get(key)
if not raw:
continue
try:
dt = parsedate_to_datetime(raw)
except (TypeError, ValueError):
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except (TypeError, ValueError):
continue
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
return datetime.now(UTC)
def _summary(entry) -> str:
import re
raw = entry.get("summary") or ""
if not raw and entry.get("content"):
raw = entry["content"][0].get("value", "")
return " ".join(re.sub(r"<[^>]+>", " ", raw).split())
async def fetch(client, source) -> tuple[list[dict], dict]:
headers = {}
if source.get("etag"):
headers["If-None-Match"] = source["etag"]
if source.get("last_modified"):
headers["If-Modified-Since"] = source["last_modified"]
resp = await client.get(source["url"], headers=headers)
state = {
"etag": resp.headers.get("etag"),
"last_modified": resp.headers.get("last-modified"),
"status": str(resp.status_code),
}
if resp.status_code == 304:
return [], state
# Rate limiting is an expected steady state, not a failure. Reddit in
# particular 429s readily on its .rss views. Surface it as a state so the
# caller can back off, rather than raising and logging an error every cycle.
if resp.status_code == 429:
state["retry_after"] = resp.headers.get("retry-after")
state["rate_limited"] = True
return [], state
resp.raise_for_status()
parsed = feedparser.parse(resp.content[:MAX_BYTES])
now = datetime.now(UTC).isoformat()
items = []
for entry in parsed.entries:
link = entry.get("link") or ""
title = " ".join((entry.get("title") or "").split())
if not link or not title:
continue
canon = canonical_url(link)
summary = _summary(entry)
items.append(
{
"id": stable_id(source["id"], canon),
"source_id": source["id"],
"title": title,
"summary": summary,
"url": link,
"canonical_url": canon,
"published_at": _published(entry).isoformat(),
"first_seen_at": now,
"content_hash": content_hash(embed_text(title, summary)),
"ext_score": None,
"ext_comments": None,
"ext_updated_at": None,
}
)
return items, state
+170
View File
@@ -0,0 +1,170 @@
"""SQLite schema and repository helpers.
Text ids and RFC3339 timestamps throughout, so the database stays readable with
the `sqlite3` CLI. Vectors are stored as raw float32 blobs.
"""
from __future__ import annotations
import sqlite3
from collections.abc import Iterable, Sequence
from pathlib import Path
import numpy as np
SCHEMA = """
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- rss | hn | reddit
-- Editorial-independence bucket. Importance counts DISTINCT buckets, not
-- feeds: ten outlets reprinting one wire story is one bucket, not ten.
bucket TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
-- 1 for a publisher's front-page/top-stories feed. Appearing there is a free
-- editorial-prominence signal; the old system dropped it by unsubscribing.
prominence INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
poll_interval_secs INTEGER NOT NULL DEFAULT 900,
etag TEXT,
last_modified TEXT,
last_polled_at TEXT,
last_status TEXT
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id),
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
canonical_url TEXT NOT NULL,
published_at TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
content_hash TEXT NOT NULL,
-- Aggregator-native signal (HN points, Reddit score). Re-polled over the
-- item's life: an HN post at 3 points is noise, at 300 it is news.
ext_score INTEGER,
ext_comments INTEGER,
ext_updated_at TEXT,
UNIQUE(source_id, canonical_url)
);
CREATE INDEX IF NOT EXISTS items_published ON items(published_at);
CREATE INDEX IF NOT EXISTS items_hash ON items(content_hash);
-- Keyed by content hash, not item id, so identical text across feeds is
-- embedded exactly once. Embedding is the slow step (~11 items/sec).
CREATE TABLE IF NOT EXISTS embeddings (
content_hash TEXT PRIMARY KEY,
dim INTEGER NOT NULL,
vec BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusters (
id TEXT PRIMARY KEY,
centroid BLOB NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
lead_item_id TEXT REFERENCES items(id),
headline TEXT
);
CREATE INDEX IF NOT EXISTS clusters_last_seen ON clusters(last_seen_at);
CREATE TABLE IF NOT EXISTS cluster_members (
cluster_id TEXT NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES items(id),
added_at TEXT NOT NULL,
PRIMARY KEY (cluster_id, item_id)
);
CREATE INDEX IF NOT EXISTS cluster_members_item ON cluster_members(item_id);
-- Append-only: every click is a training event, never an update in place.
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cluster_id TEXT NOT NULL,
kind TEXT NOT NULL, -- interested | not_interested
source TEXT NOT NULL DEFAULT 'web', -- web | ntfy
created_at TEXT NOT NULL
);
"""
def connect(path: str | Path) -> sqlite3.Connection:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(path, isolation_level=None)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA foreign_keys=ON")
con.executescript(SCHEMA)
return con
def to_blob(vec: np.ndarray) -> bytes:
return np.asarray(vec, dtype=np.float32).tobytes()
def from_blob(blob: bytes) -> np.ndarray:
return np.frombuffer(blob, dtype=np.float32)
def upsert_source(con: sqlite3.Connection, **kw) -> None:
"""Config is authoritative for everything except the polling state columns."""
con.execute(
"""INSERT INTO sources (id,name,url,kind,bucket,weight,prominence,
enabled,poll_interval_secs)
VALUES (:id,:name,:url,:kind,:bucket,:weight,:prominence,
:enabled,:poll_interval_secs)
ON CONFLICT(url) DO UPDATE SET
name=excluded.name, kind=excluded.kind, bucket=excluded.bucket,
weight=excluded.weight, prominence=excluded.prominence,
enabled=excluded.enabled,
poll_interval_secs=excluded.poll_interval_secs""",
kw,
)
def insert_items(con: sqlite3.Connection, rows: Iterable[dict]) -> int:
"""Returns the number of genuinely NEW items.
Counted by row count, not `total_changes`: the ON CONFLICT branch updates
aggregator scores on every re-poll, and counting those as new would make an
HN source look like it produced hundreds of fresh items an hour.
"""
before = con.execute("SELECT COUNT(*) AS n FROM items").fetchone()["n"]
con.executemany(
"""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
published_at,first_seen_at,content_hash,
ext_score,ext_comments,ext_updated_at)
VALUES (:id,:source_id,:title,:summary,:url,:canonical_url,
:published_at,:first_seen_at,:content_hash,
:ext_score,:ext_comments,:ext_updated_at)
ON CONFLICT(source_id,canonical_url) DO UPDATE SET
ext_score=excluded.ext_score,
ext_comments=excluded.ext_comments,
ext_updated_at=excluded.ext_updated_at
WHERE excluded.ext_score IS NOT NULL""",
list(rows),
)
return con.execute("SELECT COUNT(*) AS n FROM items").fetchone()["n"] - before
def cached_vectors(con: sqlite3.Connection, hashes: Sequence[str]) -> dict[str, np.ndarray]:
out: dict[str, np.ndarray] = {}
for i in range(0, len(hashes), 500):
chunk = hashes[i : i + 500]
q = ",".join("?" * len(chunk))
for row in con.execute(
f"SELECT content_hash, vec FROM embeddings WHERE content_hash IN ({q})", chunk
):
out[row["content_hash"]] = from_blob(row["vec"])
return out
def store_vectors(con: sqlite3.Connection, pairs: Iterable[tuple[str, np.ndarray]]) -> None:
con.executemany(
"INSERT OR REPLACE INTO embeddings (content_hash,dim,vec) VALUES (?,?,?)",
[(h, len(v), to_blob(v)) for h, v in pairs],
)
+55
View File
@@ -0,0 +1,55 @@
"""URL canonicalisation and stable hashing.
The cheap deterministic wins that need no model: the same article arrives from
several feeds with different tracking parameters.
Note what is deliberately absent. A SimHash near-duplicate stage was written and
then removed after measurement: on real news it gave +1.2 bits of separation
between same-story and random pairs on titles, and -0.2 with summaries included.
News duplicates are *paraphrases* ("Explosion at Bolivia military base kills at
least 10" / "Blasts at Bolivian military base kill at least 10, injure dozens"),
which share almost no word trigrams. That is the same lexical trap that made the
old system's Jaccard-on-title-tokens clustering fail. Embeddings separate the
identical group at +0.353 cosine, so dedup lives in `cluster.py` instead.
"""
from __future__ import annotations
import hashlib
import re
import uuid
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
# uuid5 namespace for this project, so ids are stable across rebuilds.
NS = uuid.UUID("6f3b1e2a-9c4d-5f8a-b7e1-2d4c6a8b0f31")
# Tracking parameters carried by feed links. Stripping them is what makes the
# same article from two feeds compare equal.
_JUNK_PARAMS = re.compile(
r"^(utm_|ito$|ns_|cmp$|CMP$|fbclid$|gclid$|igshid$|mc_[ce]id$|ref$|"
r"ref_src$|s_kwcid$|smid$|partner$|at_(medium|campaign|custom\d)$|"
r"__twitter_impression$|guccounter$|sh$|amp$)"
)
def canonical_url(url: str) -> str:
"""Strip tracking noise so the same article from two feeds compares equal."""
parts = urlsplit(url.strip())
host = parts.netloc.lower().removeprefix("www.")
query = urlencode(
[(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
if not _JUNK_PARAMS.match(k)]
)
path = parts.path.rstrip("/") or "/"
# Fragments never identify a distinct article in a feed context.
return urlunsplit((parts.scheme.lower() or "https", host, path, query, ""))
def stable_id(*parts: str) -> str:
"""Deterministic uuid5 so re-ingesting the same item is idempotent."""
return str(uuid.uuid5(NS, "\x1f".join(parts)))
def content_hash(text: str) -> str:
"""Key for the embedding cache: identical text is embedded exactly once."""
return hashlib.blake2b(text.encode("utf-8"), digest_size=16).hexdigest()
+7
View File
@@ -203,6 +203,13 @@ probes = [
# 3,885 items in the v0.2 database and Connor's 40 real feedback labels.
# See profile/CALIBRATION.md for the measurements.
[delivery]
# Settled with Connor 2026-09-08.
digest_hour = "08:45"
quiet_hours = { start = "22:00", end = "07:00" }
# The importance lane MAY override quiet hours. The interest lane never may.
importance_may_wake = true
[scoring]
# Compare blocks by z-score WITHIN each block, never by raw cosine. Raw cosine
# is not comparable across blocks -- "geopolitics" averages 0.501 similarity to
+268
View File
@@ -0,0 +1,268 @@
# Sources.
#
# `bucket` is the editorial-independence group, and it is the important field.
# Importance counts DISTINCT buckets, never feeds: two BBC feeds are one bucket,
# and ten outlets reprinting one wire story must not read as ten independent
# confirmations. Getting this wrong is what made the old system fire priority-5
# pushes on anything two feeds happened to carry.
#
# `prominence = 1` marks a publisher's front-page/top-stories feed. Subscribing
# to both it and the section feed is a free editorial-promotion signal; the old
# config dropped BBC's top-news feed and threw that away.
[defaults]
poll_interval_secs = 900
# ------------------------------------------------------------- world / wire --
[[source]]
name = "Al Jazeera"
url = "https://www.aljazeera.com/xml/rss/all.xml"
bucket = "aljazeera"
weight = 1.2
[[source]]
name = "BBC Top Stories"
url = "https://feeds.bbci.co.uk/news/rss.xml"
bucket = "bbc"
prominence = 1
weight = 1.1
[[source]]
name = "BBC World"
url = "https://feeds.bbci.co.uk/news/world/rss.xml"
bucket = "bbc"
weight = 1.1
[[source]]
name = "Guardian World"
url = "https://www.theguardian.com/world/rss"
bucket = "guardian"
[[source]]
name = "NYT World"
url = "https://rss.nytimes.com/services/xml/rss/nyt/World.xml"
bucket = "nyt"
[[source]]
name = "NPR News"
url = "https://feeds.npr.org/1001/rss.xml"
bucket = "npr"
[[source]]
name = "France24"
url = "https://www.france24.com/en/rss"
bucket = "france24"
# RSS 1.0/RDF. The old hand-rolled quick-xml parser could not read this format
# and DW was excluded with the note that it "would yield zero items silently".
# feedparser handles RDF, so DW is back.
[[source]]
name = "Deutsche Welle"
url = "https://rss.dw.com/rdf/rss-en-all"
bucket = "dw"
[[source]]
name = "ABC Australia"
url = "https://www.abc.net.au/news/feed/51120/rss.xml"
bucket = "abc-au"
[[source]]
name = "PBS NewsHour"
url = "https://www.pbs.org/newshour/feeds/rss/headlines"
bucket = "pbs"
poll_interval_secs = 1800
# --------------------------------------------------------------- mideast ----
[[source]]
name = "Middle East Eye"
url = "https://www.middleeasteye.net/rss"
bucket = "middle-east-eye"
[[source]]
name = "+972 Magazine"
url = "https://www.972mag.com/feed/"
bucket = "972mag"
poll_interval_secs = 3600
# ----------------------------------------------------------- tech / policy --
[[source]]
name = "Ars Technica"
url = "https://feeds.arstechnica.com/arstechnica/index"
bucket = "ars-technica"
[[source]]
name = "EFF Deeplinks"
url = "https://www.eff.org/rss/updates.xml"
bucket = "eff"
poll_interval_secs = 3600
# Global-South technology and economics; covers two interest blocks at once.
[[source]]
name = "Rest of World"
url = "https://restofworld.org/feed/latest/"
bucket = "rest-of-world"
poll_interval_secs = 3600
# ------------------------------------------------------ software / systems --
[[source]]
name = "Hacker News"
url = "https://hn.algolia.com/api/v1/search_by_date"
kind = "hn"
bucket = "hacker-news"
poll_interval_secs = 1800
[[source]]
name = "Lobsters"
url = "https://lobste.rs/rss"
bucket = "lobsters"
poll_interval_secs = 1800
# Reddit's .json endpoints return 403 without OAuth as of 2026-09; the .rss
# views work unauthenticated but 429 aggressively -- three requests in quick
# succession from one IP were enough to get throttled during validation, and the
# block persisted across serialized retries. Hence the long intervals here.
# The fetcher treats 429 as a state to back off from, not an error.
#
# Cost of the .rss route: the Atom feed carries no score, so these contribute no
# aggregator-maturity signal. Hacker News is the source that supplies it.
[[source]]
name = "r/LocalLLaMA"
url = "https://www.reddit.com/r/LocalLLaMA/new/.rss"
bucket = "reddit"
poll_interval_secs = 5400
[[source]]
name = "r/selfhosted"
url = "https://www.reddit.com/r/selfhosted/new/.rss"
bucket = "reddit"
poll_interval_secs = 7200
[[source]]
name = "r/rust"
url = "https://www.reddit.com/r/rust/new/.rss"
bucket = "reddit"
poll_interval_secs = 7200
# Release feeds for the stack Connor actually runs. GitHub releases are Atom,
# so they need no adapter of their own.
[[source]]
name = "llama.cpp releases"
url = "https://github.com/ggml-org/llama.cpp/releases.atom"
bucket = "github-releases"
poll_interval_secs = 7200
[[source]]
name = "LiteLLM releases"
url = "https://github.com/BerriAI/litellm/releases.atom"
bucket = "github-releases"
poll_interval_secs = 7200
[[source]]
name = "llama-swap releases"
url = "https://github.com/mostlygeek/llama-swap/releases.atom"
bucket = "github-releases"
poll_interval_secs = 7200
[[source]]
name = "Podman releases"
url = "https://github.com/containers/podman/releases.atom"
bucket = "github-releases"
poll_interval_secs = 7200
[[source]]
name = "Rust releases"
url = "https://github.com/rust-lang/rust/releases.atom"
bucket = "github-releases"
poll_interval_secs = 7200
# arXiv announces once per weekday (~00:00 UTC); the feed is legitimately EMPTY
# between announcement windows, which is not a fault. Verified 2026-09-08 that
# the endpoint serves valid RSS -- cs.CL, cs.LG and cs.AI were all empty at the
# time of checking, consistent with being between windows.
[[source]]
name = "arXiv cs.CL"
url = "https://rss.arxiv.org/rss/cs.CL"
bucket = "arxiv"
poll_interval_secs = 21600
# ------------------------------------------------------------------ labor ---
[[source]]
name = "Labor Notes"
url = "https://labornotes.org/feed"
bucket = "labor-notes"
poll_interval_secs = 3600
[[source]]
name = "Jacobin"
url = "https://jacobin.com/feed/"
bucket = "jacobin"
poll_interval_secs = 3600
# --------------------------------------------------------------- urbanism ---
[[source]]
name = "Streetsblog USA"
url = "https://usa.streetsblog.org/feed"
bucket = "streetsblog"
poll_interval_secs = 3600
[[source]]
name = "Strong Towns"
url = "https://www.strongtowns.org/journal?format=rss"
bucket = "strong-towns"
poll_interval_secs = 7200
# --------------------------------------------------------- energy / climate --
[[source]]
name = "Canary Media"
url = "https://www.canarymedia.com/rss"
bucket = "canary-media"
poll_interval_secs = 3600
# --------------------------------------------------------------- US / local --
[[source]]
name = "ProPublica"
url = "https://www.propublica.org/feeds/propublica/main"
bucket = "propublica"
poll_interval_secs = 3600
[[source]]
name = "NPR Politics"
url = "https://feeds.npr.org/1014/rss.xml"
bucket = "npr"
[[source]]
name = "Ohio Capital Journal"
url = "https://ohiocapitaljournal.com/feed/"
bucket = "ohio-capital-journal"
poll_interval_secs = 3600
[[source]]
name = "WVXU Cincinnati"
url = "https://www.wvxu.org/local-news.rss"
bucket = "wvxu"
poll_interval_secs = 3600
[[source]]
name = "WCPO Cincinnati"
url = "https://www.wcpo.com/news.rss"
bucket = "wcpo"
poll_interval_secs = 3600
[[source]]
name = "Kentucky Lantern"
url = "https://kentuckylantern.com/feed/"
bucket = "kentucky-lantern"
poll_interval_secs = 3600
# ------------------------------------------------------------ italy / mexico --
[[source]]
name = "ANSA English"
url = "https://www.ansa.it/english/english_rss.xml"
bucket = "ansa"
poll_interval_secs = 3600
[[source]]
name = "Mexico News Daily"
url = "https://mexiconewsdaily.com/feed/"
bucket = "mexico-news-daily"
poll_interval_secs = 3600
+1
View File
@@ -26,3 +26,4 @@ build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+81
View File
@@ -0,0 +1,81 @@
"""Clustering behaviour, using synthetic vectors so no model download is needed."""
import numpy as np
import pytest
from news import cluster, store
from news.util import stable_id
@pytest.fixture
def con(tmp_path):
c = store.connect(tmp_path / "t.db")
store.upsert_source(
c, id="s1", name="A", url="http://a", kind="rss", bucket="a",
weight=1.0, prominence=0, enabled=1, poll_interval_secs=900)
store.upsert_source(
c, id="s2", name="B", url="http://b", kind="rss", bucket="b",
weight=1.0, prominence=0, enabled=1, poll_interval_secs=900)
return c
def add(con, item_id, source_id, when, chash):
con.execute(
"""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
published_at,first_seen_at,content_hash)
VALUES (?,?,?,'',?,?,?,?,?)""",
(item_id, source_id, f"t-{item_id}", f"http://x/{item_id}",
f"http://x/{item_id}", when, when, chash))
return con.execute("SELECT * FROM items WHERE id=?", (item_id,)).fetchone()
def unit(*v):
a = np.array(v, dtype=np.float32)
return a / np.linalg.norm(a)
def test_similar_items_merge_and_distinct_ones_do_not(con):
near_a, near_b = unit(1, 0.02, 0), unit(1, 0.06, 0) # cosine ~0.999
far = unit(0, 1, 0) # orthogonal
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1"),
add(con, "i2", "s2", "2026-01-01T01:00:00+00:00", "h2"),
add(con, "i3", "s1", "2026-01-01T02:00:00+00:00", "h3")]
out = cluster.assign(con, rows, {"h1": near_a, "h2": near_b, "h3": far})
by_item = {a.item_id: a.cluster_id for a in out}
assert by_item["i1"] == by_item["i2"], "paraphrase-near items must merge"
assert by_item["i3"] != by_item["i1"], "unrelated item must not merge"
def test_items_outside_the_window_do_not_merge(con):
v = unit(1, 0, 0)
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1"),
add(con, "i2", "s2", "2026-01-05T00:00:00+00:00", "h2")] # 4 days later
out = cluster.assign(con, rows, {"h1": v, "h2": v})
assert out[0].cluster_id != out[1].cluster_id
def test_bucket_count_counts_newsrooms_not_feeds(con):
"""Two feeds from one newsroom are one independent confirmation, not two."""
v = unit(1, 0, 0)
store.upsert_source(
con, id="s3", name="A2", url="http://a2", kind="rss", bucket="a",
weight=1.0, prominence=0, enabled=1, poll_interval_secs=900)
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1"),
add(con, "i2", "s3", "2026-01-01T01:00:00+00:00", "h2")]
out = cluster.assign(con, rows, {"h1": v, "h2": v})
assert out[0].cluster_id == out[1].cluster_id
assert cluster.bucket_count(con, out[0].cluster_id) == 1
rows2 = [add(con, "i3", "s2", "2026-01-01T02:00:00+00:00", "h3")]
cluster.assign(con, rows2, {"h3": v})
assert cluster.bucket_count(con, out[0].cluster_id) == 2
def test_assignment_is_idempotent(con):
v = unit(1, 0, 0)
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1")]
a = cluster.assign(con, rows, {"h1": v})
b = cluster.assign(con, rows, {"h1": v})
assert a[0].cluster_id == b[0].cluster_id
n = con.execute("SELECT COUNT(*) c FROM cluster_members").fetchone()["c"]
assert n == 1, "re-assigning an item must not duplicate membership"
+81
View File
@@ -0,0 +1,81 @@
"""RSS/Atom parsing, including the RDF format the old system could not read."""
import httpx
import pytest
from news.sources import rss
RDF = """<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/">
<channel rdf:about="http://x"><title>T</title><link>http://x</link>
<description>d</description></channel>
<item rdf:about="http://x/1">
<title>German economy frets about election result</title>
<link>http://x/1?utm_source=rss</link>
<description>Some &lt;b&gt;markup&lt;/b&gt; here</description>
</item>
</rdf:RDF>"""
ATOM = """<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>F</title>
<entry>
<title>Release v1.2.3</title>
<link rel="alternate" href="http://x/rel"/>
<link rel="self" href="http://x/self"/>
<updated>2026-09-08T12:00:00Z</updated>
</entry>
</feed>"""
def _source():
return {"id": "s1", "url": "http://feed", "kind": "rss"}
async def _fetch(body: str, status: int = 200, headers: dict | None = None):
def handler(request):
return httpx.Response(status, content=body.encode(), headers=headers or {})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
return await rss.fetch(client, _source())
@pytest.mark.asyncio
async def test_parses_rss_1_rdf():
"""DW ships RDF; the old hand-rolled parser yielded zero items silently."""
items, _ = await _fetch(RDF)
assert len(items) == 1
assert items[0]["title"].startswith("German economy")
# tracking parameter stripped, markup removed from the summary
assert items[0]["canonical_url"] == "http://x/1"
assert "<b>" not in items[0]["summary"] and "markup" in items[0]["summary"]
@pytest.mark.asyncio
async def test_atom_prefers_alternate_link_over_self():
items, _ = await _fetch(ATOM)
assert len(items) == 1
assert items[0]["canonical_url"] == "http://x/rel"
@pytest.mark.asyncio
async def test_304_returns_no_items_and_is_not_an_error():
items, state = await _fetch("", status=304)
assert items == [] and state["status"] == "304"
@pytest.mark.asyncio
async def test_429_is_reported_as_rate_limited_not_raised():
"""Reddit 429s readily; that must back off, not raise every cycle."""
items, state = await _fetch("", status=429, headers={"retry-after": "120"})
assert items == []
assert state["rate_limited"] is True
assert state["retry_after"] == "120"
@pytest.mark.asyncio
async def test_server_error_still_raises():
with pytest.raises(httpx.HTTPStatusError):
await _fetch("", status=503)
+26
View File
@@ -0,0 +1,26 @@
from news.util import canonical_url, content_hash, stable_id
def test_canonical_url_strips_tracking_and_www():
a = canonical_url("https://www.bbc.co.uk/news/world-123?utm_source=rss&utm_medium=feed")
b = canonical_url("https://bbc.co.uk/news/world-123/")
assert a == b == "https://bbc.co.uk/news/world-123"
def test_canonical_url_keeps_meaningful_query():
url = canonical_url("https://news.ycombinator.com/item?id=4242&utm_campaign=x")
assert url == "https://news.ycombinator.com/item?id=4242"
def test_canonical_url_drops_fragment():
assert canonical_url("https://x.com/a#section") == "https://x.com/a"
def test_stable_id_is_deterministic_and_distinct():
assert stable_id("source", "u") == stable_id("source", "u")
assert stable_id("source", "u") != stable_id("source", "v")
def test_content_hash_matches_only_identical_text():
assert content_hash("a b") == content_hash("a b")
assert content_hash("a b") != content_hash("a b")