feedsignal/README.md
Austin Schaefer 67d399ad05
All checks were successful
CI / check (pull_request) Successful in 7m49s
CI / test (pull_request) Successful in 3m37s
CI / audit (pull_request) Successful in 14s
Add desktop platform support via a new feedsignal-web desktop feature
Combines dioxus's desktop (webview) renderer with the existing `server`
feature so `#[server]` functions execute in-process instead of going over
HTTP — one self-contained native binary with DB, feed polling, LLM calls,
and the scheduler, no separate server to run. main.rs picks the desktop
launcher explicitly since dioxus::launch's automatic platform detection
would otherwise prefer the (also-enabled) server platform over desktop.

Verified: cargo check/clippy pass for the new feature (native crates and
existing server/web features unaffected), and running the built binary
opens a window and completes an in-process server-fn call (DB connects,
migrations run) with no HTTP round trip. CI gets a matching desktop
clippy job with the GTK/WebKit apt packages dioxus-desktop needs to
compile on Linux.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 12:36:22 +02:00

126 lines
5.9 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; `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](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
```
### 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:
```sh
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.