Agentically evaluated local RSS application
Find a file
Austin Schaefer 6c1020c3ef
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
Split CI clippy checks into parallel jobs; move desktop build deps to a dedicated runner image
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>
2026-09-15 13:30:36 +02:00
.forgejo/workflows Split CI clippy checks into parallel jobs; move desktop build deps to a dedicated runner image 2026-09-15 13:30:36 +02:00
crates Add desktop platform support via a new feedsignal-web desktop feature 2026-09-15 12:36:22 +02:00
.gitignore chore: Change license, re-position test comments, add gitignore entries. 2026-08-21 13:53:49 +02:00
Cargo.lock Add desktop platform support via a new feedsignal-web desktop feature 2026-09-15 12:36:22 +02:00
Cargo.toml Extract vendored dioxus-primitives components into their own crate 2026-09-15 11:31:27 +02:00
DEFINITION_OF_DONE.md Add and apply a models.rs convention for data-model structs 2026-09-03 21:23:34 +02:00
README.md Add desktop platform support via a new feedsignal-web desktop feature 2026-09-15 12:36:22 +02:00

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:

  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) 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.
  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.01.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 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.