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
|
||
|---|---|---|
| .forgejo/workflows | ||
| crates | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| 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. 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
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.