Files
upbeatBytes/goodnews/dedup.py
T
thejayman77 2ef0efd909 Perf: skip needless dedup re-cluster + interlock word-search grids
Two things found while chasing the recurring ~15min slowness:

- dedup.py: cluster_duplicates re-ran an O(n²) cosine pass over ALL ~3.7k
  articles and rewrote duplicate_of for every one of them EVERY cycle — even
  when nothing new arrived (embedded=0) — ~53s CPU + a large WAL commit that
  starved live API reads (/api/brief 2-7s). Now skip the re-cluster entirely
  when nothing new was embedded (clusters can't have changed). Verified: cycle
  drops from ~53s to ~1s and /api/brief stays at 20ms through a cycle, vs 2-7s
  before. (A real new article still triggers a full re-cluster.)

- games.py _build_grid: word placement took the first random valid spot, so
  words rarely crossed. Now gather valid placements and PREFER ones that cross
  an already-placed word (shared matching letter), falling back to any valid
  spot — so the grid interlocks like a real word search. Every word still
  placed (tests green). NOTE: changes today's grid layouts, so an in-progress
  word search resets once.

Also added a systemd drop-in (Nice=19/CPUWeight=20/IOWeight=10/ionice-idle) to
deprioritize the batch cycle — minor, the dedup skip is the real fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:35:01 -04:00

182 lines
6.4 KiB
Python

"""Cross-source near-duplicate detection via local embeddings.
The exact-URL dedupe in feeds.py only catches the literal same link. The same
story carried by several outlets slips through as separate articles. Here we
embed each article's title+snippet with the local embedding model, cluster
near-identical ones within a short time window, and mark all but the best in
each cluster as duplicates (articles.duplicate_of). Feed and brief queries then
hide duplicates, keeping the single strongest version.
Pure-stdlib math: vectors are normalised once so cosine similarity is a dot
product, and comparisons are restricted to a date window, so no numpy is needed.
"""
from __future__ import annotations
import math
import sqlite3
from array import array
from datetime import date
from .llm import LocalModelClient
DEFAULT_THRESHOLD = 0.86
DEFAULT_WINDOW_DAYS = 3
_EMBED_BATCH = 16
def _embed_text(title: str, description: str | None) -> str:
text = title.strip()
if description:
text += ". " + description.strip()
return text[:2000]
def ensure_embeddings(
conn: sqlite3.Connection, client: LocalModelClient, limit: int | None = None
) -> int:
"""Embed and store any articles that lack an embedding. Returns count added."""
rows = conn.execute(
"""
SELECT a.id, a.title, a.description
FROM articles a
LEFT JOIN article_embeddings e ON e.article_id = a.id
WHERE e.article_id IS NULL
ORDER BY a.id
"""
).fetchall()
if limit is not None:
rows = rows[:limit]
if not rows:
return 0
added = 0
for start in range(0, len(rows), _EMBED_BATCH):
batch = rows[start : start + _EMBED_BATCH]
vectors = client.embed([_embed_text(r["title"], r["description"]) for r in batch])
for row, vector in zip(batch, vectors):
conn.execute(
"INSERT OR REPLACE INTO article_embeddings (article_id, vector, dim, model) "
"VALUES (?, ?, ?, ?)",
(row["id"], array("f", vector).tobytes(), len(vector), client.embed_model),
)
added += 1
conn.commit()
return added
def _unit(vector: list[float]) -> list[float]:
norm = math.sqrt(sum(x * x for x in vector))
if norm == 0:
return vector
return [x / norm for x in vector]
def _day_ordinal(value: str | None) -> int:
if not value:
return 0
try:
return date.fromisoformat(value[:10]).toordinal()
except ValueError:
return 0
def cluster_duplicates(
conn: sqlite3.Connection,
threshold: float = DEFAULT_THRESHOLD,
window_days: int = DEFAULT_WINDOW_DAYS,
) -> dict:
"""Group near-identical articles and record duplicate_of links.
Greedy single-link clustering: each article joins the first existing cluster
whose anchor it matches (cosine >= threshold, within window_days); otherwise
it starts a new cluster. The highest-ranked member of each cluster becomes
the representative; the rest point at it.
"""
rows = conn.execute(
"""
SELECT
a.id,
COALESCE(a.published_at, a.discovered_at) AS dt,
e.vector,
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
+ COALESCE(s.human_benefit_score,0) + src.trust_score
- COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0)
- COALESCE(s.pr_risk_score,0)) AS rank_score
FROM articles a
JOIN article_embeddings e ON e.article_id = a.id
JOIN sources src ON src.id = a.source_id
LEFT JOIN article_scores s ON s.article_id = a.id
ORDER BY dt
"""
).fetchall()
items = []
for r in rows:
vec = _unit(array("f", r["vector"]).tolist())
items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec, "score": r["rank_score"]})
clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]}
for it in items:
placed = False
for cl in clusters:
if abs(it["ord"] - cl["anchor_ord"]) > window_days:
continue
dot = sum(x * y for x, y in zip(it["vec"], cl["anchor_vec"]))
if dot >= threshold:
cl["members"].append(it)
placed = True
break
if not placed:
clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]})
# Reset prior decisions for everything we considered, then re-apply.
considered = [it["id"] for it in items]
conn.executemany(
"UPDATE articles SET duplicate_of = NULL WHERE id = ?", [(i,) for i in considered]
)
dup_clusters = 0
duplicates = 0
for cl in clusters:
if len(cl["members"]) < 2:
continue
dup_clusters += 1
rep = max(cl["members"], key=lambda m: (m["score"], -m["id"]))
for m in cl["members"]:
if m["id"] != rep["id"]:
conn.execute(
"UPDATE articles SET duplicate_of = ? WHERE id = ?", (rep["id"], m["id"])
)
duplicates += 1
conn.commit()
return {
"articles": len(items),
"clusters": len(clusters),
"duplicate_clusters": dup_clusters,
"duplicates": duplicates,
}
def dedup(
conn: sqlite3.Connection,
client: LocalModelClient,
threshold: float = DEFAULT_THRESHOLD,
window_days: int = DEFAULT_WINDOW_DAYS,
embed_limit: int | None = None,
) -> dict:
embedded = ensure_embeddings(conn, client, limit=embed_limit)
if embedded == 0:
# Nothing new entered the corpus → the clusters and duplicate_of links are
# unchanged, so skip the full re-cluster. It was re-running an O(n²) cosine
# pass over EVERY article and rewriting duplicate_of for all ~3.7k of them
# every cycle (~53s + a large WAL commit), which starved live API reads
# (/api/brief 2-7s). Most cycles find no new articles, so this makes the
# cycle near-instant and keeps reads fast. A real new article re-runs it.
dups = conn.execute("SELECT COUNT(*) FROM articles WHERE duplicate_of IS NOT NULL").fetchone()[0]
return {"embedded": 0, "articles": 0, "clusters": 0, "duplicate_clusters": 0,
"duplicates": dups, "skipped": True}
stats = cluster_duplicates(conn, threshold=threshold, window_days=window_days)
stats["embedded"] = embedded
return stats