Agentically evaluated local RSS application
Find a file
Austin Schaefer f0e42e2ad5
All checks were successful
CI / check (pull_request) Successful in 4m55s
CI / test (pull_request) Successful in 4m2s
CI / audit (pull_request) Successful in 14s
Fix article list not scrolling: duplicate <main> broke the scroll area
Articles past the first screenful were unreachable — not clipped by
accident, genuinely inaccessible, since the page itself couldn't
scroll either (the sidebar wrapper is overflow: hidden by design, so
the intended scroll boundary is internal to the main content pane).

Root cause: SidebarInset already renders a <main> (class
dx-sidebar-inset, a properly height-bound flex column — main{
height:900px in a 900px viewport, flex-direction:column}), but I'd
also written an explicit `main { ... }` as ITS child, producing a
`<main><main>...</main></main>` (confirmed via a headless Playwright
probe against the live dev server, not just DevTools guesswork). The
inner <main> is just a plain flex item with the default flex: 0 1 auto,
so it sized to its own content (6500+px) instead of being constrained
by the outer one's box, and the ScrollArea inside it had nothing
bounded to scroll within.

Removed the redundant inner <main> — SidebarInset's children (the
content-header div and ScrollArea) now sit directly in its own <main>,
which is the actual flex column that needs to size them. Also pinned
ScrollArea's direction to Vertical (it defaults to Both, which was
adding an unnecessary horizontal scrollbar) and moved the sizing rule
in app.css off a class ScrollArea silently drops (confirmed via the
same probe — a caller-supplied `class` never reaches ScrollArea's
rendered DOM, only its own internal one does) onto its stable
data-scroll-direction attribute instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 19:17:57 +02:00
.forgejo/workflows Drop redundant cargo check steps from the check job 2026-08-21 13:20:27 +02:00
crates Fix article list not scrolling: duplicate <main> broke the scroll area 2026-09-03 19:17:57 +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
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.