Agentically evaluated local RSS application
Find a file
Austin Schaefer 893e0ed251
All checks were successful
CI / check (pull_request) Successful in 2m5s
CI / test (pull_request) Successful in 2m43s
CI / audit (pull_request) Successful in 10s
Move RelevanceInputs into core's models.rs, wire up api/models.rs rename
The previous commit only carried the dto.rs -> models.rs file rename;
this carries the rest: mod.rs's import/re-export update, and
RelevanceInputs moving out of scoring.rs into models.rs (built by
score_article's callers, not score_article itself), plus an unrelated
import-order fmt fix in db/src/articles.rs.
2026-09-03 23:24:48 +02:00
.forgejo/workflows Drop redundant cargo check steps from the check job 2026-08-21 13:20:27 +02:00
crates Move RelevanceInputs into core's models.rs, wire up api/models.rs rename 2026-09-03 23:24:48 +02:00
.gitignore chore: Change license, re-position test comments, add gitignore entries. 2026-08-21 13:53:49 +02:00
Cargo.lock Add feed sidebar navigation, built from Dioxus's component library 2026-09-03 16:28:22 +02:00
Cargo.toml chore: Use valid license SPDX id. Revert logic refactor. 2026-08-21 14:03:10 +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 Default LLM scoring stage to a model already pulled locally 2026-08-20 17:11:33 +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. 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

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.