Add semantic cross-source dedup via local embeddings

- LocalModelClient.embed() calls the OpenAI-compatible /embeddings endpoint
  (local nomic model); base_url shared with chat, model via GOODNEWS_EMBED_MODEL.
- New article_embeddings table and articles.duplicate_of column (+ migration).
- dedup module: embeds missing articles, clusters near-identical stories within
  a date window by cosine similarity (pure-stdlib, vectors normalised once), and
  marks all but the highest-ranked member of each cluster as a duplicate.
- 'dedup' CLI command; cycle now runs poll -> classify -> dedup -> brief.
- Feed and brief queries hide duplicates, so a story carried by multiple
  outlets shows once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 15:40:55 +00:00
parent 2a9c49e2a9
commit 5d44072fca
7 changed files with 259 additions and 4 deletions
+25
View File
@@ -19,6 +19,7 @@ from .taxonomy import (
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
DEFAULT_MODEL = "gpt-oss"
DEFAULT_EMBED_MODEL = "text-embedding-nomic-embed-text-v1.5"
DEFAULT_TIMEOUT = 180
@@ -106,6 +107,7 @@ class LocalModelClient:
model: str
api_key: str | None = None
timeout: int = DEFAULT_TIMEOUT
embed_model: str = DEFAULT_EMBED_MODEL
# Index into _RESPONSE_FORMATS that the server accepts; discovered lazily.
_response_format_idx: int | None = None
@@ -116,8 +118,31 @@ class LocalModelClient:
model=os.environ.get("GOODNEWS_LLM_MODEL", DEFAULT_MODEL),
api_key=os.environ.get("GOODNEWS_LLM_API_KEY"),
timeout=int(os.environ.get("GOODNEWS_LLM_TIMEOUT", DEFAULT_TIMEOUT)),
embed_model=os.environ.get("GOODNEWS_EMBED_MODEL", DEFAULT_EMBED_MODEL),
)
def embed(self, texts: list[str]) -> list[list[float]]:
"""Return embedding vectors for a batch of texts via /embeddings."""
body = json.dumps({"model": self.embed_model, "input": texts}).encode("utf-8")
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
request = urllib.request.Request(
f"{self.base_url}/embeddings", data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} from embeddings: {detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"could not reach embeddings at {self.base_url}: {exc.reason}") from exc
try:
return [item["embedding"] for item in data["data"]]
except (KeyError, TypeError) as exc:
raise RuntimeError(f"unexpected embeddings response: {data}") from exc
def classify(self, article: sqlite3.Row) -> dict:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},