2026-08-20 15:04:14 +00:00
|
|
|
|
# feedsignal
|
|
|
|
|
|
|
|
|
|
|
|
A self-hosted RSS reader that uses a local Ollama LLM (via [rig](https://github.com/0xPlaygrounds/rig))
|
|
|
|
|
|
to surface the articles actually worth reading out of a large set of subscribed feeds, and learns
|
|
|
|
|
|
from what you read (and how long, and what you dismiss) to get better at that over time.
|
|
|
|
|
|
|
|
|
|
|
|
## Architecture
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
crates/
|
|
|
|
|
|
core/ domain models (Article, Feed, ReadingEvent) + the topic-affinity
|
|
|
|
|
|
learning engine + the final relevance-scoring formula. No I/O.
|
|
|
|
|
|
db/ SQLite (sqlx) persistence: feeds, articles, the append-only
|
|
|
|
|
|
reading_events log, topic_affinities, preferences.
|
|
|
|
|
|
feeds/ RSS/Atom fetching + parsing (feed-rs).
|
|
|
|
|
|
llm/ rig + local Ollama: embeddings for the cheap first-pass filter,
|
|
|
|
|
|
chat completion for the relevance judgment on the shortlist.
|
|
|
|
|
|
web/ axum + Dioxus fullstack UI. `server` feature = native binary
|
|
|
|
|
|
(DB, feed polling, LLM calls, scheduler); `web` feature = the
|
2026-09-15 10:36:22 +00:00
|
|
|
|
WASM client shipped to the browser; `desktop` feature = a
|
|
|
|
|
|
self-contained native webview app that embeds the `server`
|
|
|
|
|
|
feature's DB/polling/LLM stack in the same process (no HTTP
|
|
|
|
|
|
hop, no separate server to run). No separate JS/npm stack.
|
2026-08-20 15:04:14 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Two-stage relevance filtering
|
|
|
|
|
|
|
|
|
|
|
|
Running the LLM on every article from every feed doesn't scale locally. Instead:
|
|
|
|
|
|
|
|
|
|
|
|
1. Every new article is embedded (`nomic-embed-text` by default) and scored against your
|
|
|
|
|
|
preference-profile embedding — cheap, runs on all of them.
|
|
|
|
|
|
2. Only articles above `EMBEDDING_SHORTLIST_THRESHOLD` (`crates/web/src/server/pipeline.rs`)
|
2026-08-20 15:11:33 +00:00
|
|
|
|
go to the LLM (`gemma4-e4b` by default — chosen because it's already pulled locally and a
|
|
|
|
|
|
reasonable size/speed tradeoff for a call made once per shortlisted article on every
|
|
|
|
|
|
pipeline run; swap it in `crates/web/src/server.rs` for a bigger/smaller model as needed)
|
|
|
|
|
|
for an actual relevance judgment with a rationale.
|
2026-08-20 15:04:14 +00:00
|
|
|
|
3. `feedsignal_core::scoring::score_article` blends LLM score (dominant when present),
|
|
|
|
|
|
embedding score (fallback / floor), and topic affinity (bounded nudge) into `final_score`,
|
|
|
|
|
|
which is what the UI ranks by.
|
|
|
|
|
|
|
|
|
|
|
|
### Learning from reading behavior
|
|
|
|
|
|
|
|
|
|
|
|
Every interaction (impression, open, dwell time, star, dismiss) is appended to an immutable
|
|
|
|
|
|
`reading_events` log — never mutated, so the model can be recomputed or retuned later without
|
|
|
|
|
|
losing data (`crates/db/migrations/0001_init.sql`).
|
|
|
|
|
|
|
|
|
|
|
|
From that log, `feedsignal_core::affinity`:
|
|
|
|
|
|
|
|
|
|
|
|
- Computes an **engagement score** per article (0.0–1.0) from whether it was opened, how long
|
|
|
|
|
|
you spent relative to its estimated read time, and explicit star/dismiss signals.
|
|
|
|
|
|
- Compares engagement to what the pipeline *predicted* (`final_score` at ingest time) to get a
|
|
|
|
|
|
**surprise** signal — did you engage more or less than expected?
|
|
|
|
|
|
- Nudges the affinity of that article's topics by `learning_rate * surprise`, clamped to
|
|
|
|
|
|
`[-1, 1]`, so "the model rated this low but I read the whole thing" measurably shifts future
|
|
|
|
|
|
scoring, and "the model rated this high but I dismissed it unread" pulls the other way.
|
|
|
|
|
|
- Decays all affinities toward zero once a day so stale interests fade instead of anchoring
|
|
|
|
|
|
the model forever.
|
|
|
|
|
|
|
|
|
|
|
|
This is a from-scratch numeric signal engine, deliberately not an LLM-rewritten prose "taste
|
|
|
|
|
|
profile" — the topic scores are inspectable, and the update rule is simple enough to reason
|
|
|
|
|
|
about and tune. See `crates/core/src/affinity.rs` for the full implementation and unit tests.
|
|
|
|
|
|
|
|
|
|
|
|
## Status
|
|
|
|
|
|
|
|
|
|
|
|
This is a scaffold, not a working app yet. What's real and compiles (`cargo check`/`cargo test`
|
|
|
|
|
|
pass for every crate, both the `server` and `wasm32-unknown-unknown` targets of `feedsignal-web`):
|
|
|
|
|
|
|
|
|
|
|
|
- The domain model, DB schema/migrations, and the topic-affinity engine (with tests).
|
|
|
|
|
|
- Feed fetching/parsing.
|
|
|
|
|
|
- Ollama embedding + chat-completion calls via rig (against real rig-core 0.42 APIs).
|
|
|
|
|
|
- The scoring pipeline shape and a cron-scheduled job wiring (`tokio-cron-scheduler`).
|
|
|
|
|
|
- A minimal Dioxus UI that lists ranked articles and lets you dismiss one.
|
|
|
|
|
|
|
|
|
|
|
|
What's *not* wired up yet (left as the natural next steps):
|
|
|
|
|
|
|
|
|
|
|
|
- Feed subscription management (add/remove feed URLs) — `feedsignal_feeds::fetch_feed` exists
|
|
|
|
|
|
but nothing calls it on a schedule yet.
|
|
|
|
|
|
- The `preferences` table (your free-text "what I care about" description) isn't read by the
|
|
|
|
|
|
pipeline yet — `pipeline.rs` has a `TODO` where it belongs.
|
|
|
|
|
|
- Dwell-time capture from the browser (needs a small bit of JS/visibility-API glue on the
|
|
|
|
|
|
article view, or a "mark read" action, to backfill `dwell_seconds` on the `opened` event).
|
|
|
|
|
|
- Full article content extraction (currently only the feed's summary/content field is used;
|
|
|
|
|
|
no readability-style scraping of the linked page).
|
|
|
|
|
|
|
|
|
|
|
|
## Running it
|
|
|
|
|
|
|
|
|
|
|
|
Requires [Ollama](https://ollama.com) running locally with the models pulled:
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
ollama pull nomic-embed-text
|
2026-08-20 15:11:33 +00:00
|
|
|
|
ollama pull gemma4-e4b
|
2026-08-20 15:04:14 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
And the [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started/) (`dx`) to build/serve
|
|
|
|
|
|
the fullstack app — `dx` also has an `dx add`/component-scaffolding workflow worth using instead
|
|
|
|
|
|
of hand-rolling UI pieces as this grows:
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
cargo install dioxus-cli
|
|
|
|
|
|
cd crates/web
|
|
|
|
|
|
dx serve
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-15 10:36:22 +00:00
|
|
|
|
### Desktop
|
|
|
|
|
|
|
|
|
|
|
|
`feedsignal-web`'s `desktop` feature builds a single native webview binary — DB, feed
|
|
|
|
|
|
polling, LLM calls, and the scheduler all run in-process, so there's no separate server to
|
|
|
|
|
|
start:
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
cd crates/web
|
|
|
|
|
|
dx serve --platform desktop
|
|
|
|
|
|
# or: cargo run -p feedsignal-web --no-default-features --features desktop
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
On Linux this needs GTK/WebKit dev headers (`libgtk-3-dev`, `libwebkit2gtk-4.1-dev`,
|
|
|
|
|
|
`libsoup-3.0-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev` on
|
|
|
|
|
|
Debian/Ubuntu) — the same prerequisites as Tauri, since both build on `tao`/`wry`.
|
|
|
|
|
|
|
2026-08-20 15:04:14 +00:00
|
|
|
|
The native crates (`core`, `db`, `feeds`, `llm`) build with plain `cargo check`/`cargo test` and
|
|
|
|
|
|
don't need `dx` at all.
|
|
|
|
|
|
|
|
|
|
|
|
## CI
|
|
|
|
|
|
|
|
|
|
|
|
`.forgejo/workflows/ci.yml` runs on the self-hosted Forgejo runner: checks the native crates,
|
|
|
|
|
|
both feature-sets of `feedsignal-web` (server + wasm client), and runs tests.
|