|
All checks were successful
CI / fmt (pull_request) Successful in 15s
CI / clippy-native (pull_request) Successful in 11m46s
CI / clippy-server (pull_request) Successful in 11m46s
CI / clippy-web (pull_request) Successful in 3m32s
CI / clippy-desktop (pull_request) Successful in 3m37s
CI / test (pull_request) Successful in 10m14s
CI / audit (pull_request) Successful in 21s
|
||
|---|---|---|
| .forgejo/workflows | ||
| crates | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| DEFINITION_OF_DONE.md | ||
| README.md | ||
feedsignal
A self-hosted RSS reader that uses a local Ollama LLM (via 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
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.
Two-stage relevance filtering
Running the LLM on every article from every feed doesn't scale locally. Instead:
- Every new article is embedded (
nomic-embed-textby default) and scored against your preference-profile embedding — cheap, runs on all of them. - Only articles above
EMBEDDING_SHORTLIST_THRESHOLD(crates/web/src/server/pipeline.rs) go to the LLM (gemma4-e4bby 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 incrates/web/src/server.rsfor a bigger/smaller model as needed) for an actual relevance judgment with a rationale. feedsignal_core::scoring::score_articleblends LLM score (dominant when present), embedding score (fallback / floor), and topic affinity (bounded nudge) intofinal_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_scoreat 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_feedexists but nothing calls it on a schedule yet. - The
preferencestable (your free-text "what I care about" description) isn't read by the pipeline yet —pipeline.rshas aTODOwhere 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_secondson theopenedevent). - 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 running locally with the models pulled:
ollama pull nomic-embed-text
ollama pull gemma4-e4b
And the Dioxus CLI (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:
cargo install dioxus-cli
cd crates/web
dx serve
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:
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.
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.