|
Some checks failed
CI / fmt (pull_request) Successful in 9s
CI / clippy-native (pull_request) Successful in 2m47s
CI / clippy-server (pull_request) Successful in 2m41s
CI / clippy-web (pull_request) Successful in 1m38s
CI / clippy-desktop (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / audit (pull_request) Has been cancelled
The single "check" job ran fmt + four clippy variants (native, server, web, desktop) as sequential steps, so the desktop feature's clippy check always waited on the other three even though none of them share dependencies. Splitting each into its own job lets the runner (capacity 2) run them concurrently instead. Also drops the per-run `apt-get install` for the GTK/WebKit headers dioxus-desktop needs — those are now baked into a dedicated `rust-ci-desktop` runner image (schaefera/infrastructure#TBD) so only the desktop job's image is bigger, not every job's. Depends on the `rust-ci-desktop` label being registered on the ci-runner box before this workflow's clippy-desktop job can pick up work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| .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.