Ground-up replacement for news_triage, whose signal quality failed because it had no representation of what its user likes -- three keywords fed to BM25 plus a two-term substring blocklist. profile/ holds the taste model as prose and TOML: 11 interest blocks, 3 veto blocks, 5 importance blocks, each a set of probe sentences to be embedded. The [scoring] constants are fitted, not guessed. CALIBRATION.md records the measurements against all 3,885 items in the v0.2 database and 40 real feedback labels, including two findings that changed the design: - compare blocks by z-score within block, never raw cosine; raw cosine let geopolitics win 78% of items and buried known-liked items at median rank 1246 of 3885 (293 after normalizing) - veto needs a margin over the interest signal, not a threshold; a plain 0.55 cutoff falsely killed "Belgian mayor blocks club from hosting Israeli opponent in Europa League", which the old substring blocklist also killed
54 lines
2.3 KiB
Markdown
54 lines
2.3 KiB
Markdown
# news
|
||
|
||
A personal news aggregator: many sources in, two to five well-chosen push
|
||
notifications and one readable daily digest out.
|
||
|
||
This is a ground-up rewrite of [`news_triage`](../news_triage), which is still
|
||
running. The rewrite exists because the old system had no representation of what
|
||
its user actually likes — its whole preference model was three keywords fed to
|
||
BM25 plus a two-term substring blocklist, and everything else was ranking
|
||
machinery with nothing meaningful to rank.
|
||
|
||
## The core idea
|
||
|
||
Taste is a set of **vectors**, not a bag of keywords.
|
||
|
||
Every interest, veto and importance category in [`profile/profile.toml`](profile/profile.toml)
|
||
is written as prose and embedded with a small CPU model (`bge-small-en-v1.5`,
|
||
~130 MB, ONNX, no torch, no GPU — so it never contends with the llama-swap stack).
|
||
An item's score is the max cosine similarity across a block's probes, never the
|
||
mean: averaging "Middle East geopolitics" with "grid-scale energy storage" gives
|
||
a vector near neither.
|
||
|
||
This is what lets a topic be expressed as a *region* rather than a string. The
|
||
old system could not represent "sports" as a concept, only as the literal
|
||
characters `s-p-o-r-t-s`, which is why 18 of its 32 feedback clicks were its user
|
||
hand-rejecting baseball recaps that `blocklist = ["sports"]` never matched.
|
||
|
||
A generative LLM is used only for prose — the daily digest write-up and cluster
|
||
headlines — at roughly 1–30 calls/day through LiteLLM, never for per-item scoring.
|
||
|
||
## Two routing lanes
|
||
|
||
Notifications are decided on two independent axes, because "something I might
|
||
like" and "something I need to know about" are different questions:
|
||
|
||
- **interest** — high similarity to the profile's interest vectors.
|
||
- **importance** — burst velocity, independent-source-bucket count, editorial
|
||
prominence, and aggregator-native scores. Deliberately *not* raw corroboration
|
||
count, which just measures how many wire-reprinting outlets you subscribed to.
|
||
|
||
Vetoes are absolute on **both** lanes. In the old system the bypass lane ignored
|
||
relevance entirely, so a blocklisted item could still fire at priority 5.
|
||
|
||
## Layout
|
||
|
||
profile/ the user's taste, as prose and as TOML. Start here.
|
||
news/ the pipeline: sources, store, embed, cluster, score, route, deliver
|
||
tests/
|
||
deploy/
|
||
|
||
## Status
|
||
|
||
Early. `profile/` is written; the pipeline is not yet built.
|