feedsignal/README.md
Austin Schaefer 2b61c80143
Some checks failed
CI / check (push) Failing after 11s
Default LLM scoring stage to a model already pulled locally
Was hardcoded to llama3.1, which isn't actually on this machine.
Switched to gemma4-e4b (5.0 GB, already pulled) as a speed/quality
balance for a call made once per shortlisted article on every
pipeline run.
2026-08-20 17:11:33 +02:00

107 lines
5.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# feedsignal
A self-hosted RSS reader that uses a local Ollama LLM (via [rig](https://github.com/0xPlaygrounds/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](https://ollama.com) running locally with the models pulled:
```sh
ollama pull nomic-embed-text
ollama pull gemma4-e4b
```
And the [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started/) (`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:
```sh
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.