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:
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user