diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 72d4cab..ba0f474 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -7,30 +7,80 @@ on: jobs: check: - runs-on: docker - container: docker.io/library/rust:1-bookworm + runs-on: rust-ci steps: - uses: actions/checkout@v4 - - name: Cache cargo registry and target dir + - name: Cache cargo registry and build artifacts uses: actions/cache@v4 with: path: | ~/.cargo/registry + ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Cache sccache compilation objects + uses: actions/cache@v4 + with: + path: /root/.cache/sccache + # Not keyed to Cargo.lock: sccache caches individual compiler + # invocations by content hash, so it should accumulate across + # dependency bumps rather than reset like the target/ cache above. + key: sccache-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + sccache-${{ runner.os }}- - name: Add wasm target run: rustup target add wasm32-unknown-unknown - - name: Check native crates - run: cargo check --workspace --exclude feedsignal-web + - name: Format check + run: cargo fmt --check - - name: Check web crate (server) - run: cargo check -p feedsignal-web --no-default-features --features server + - name: Clippy (native crates) + run: cargo clippy --workspace --exclude feedsignal-web --all-targets -- -D warnings - - name: Check web crate (wasm client) - run: cargo check -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown + - name: Clippy (web crate, server) + run: cargo clippy -p feedsignal-web --no-default-features --features server --all-targets -- -D warnings + + - name: Clippy (web crate, wasm client) + run: cargo clippy -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown -- -D warnings + + test: + needs: check + runs-on: rust-ci + steps: + - uses: actions/checkout@v4 + + - name: Cache cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Cache sccache compilation objects + uses: actions/cache@v4 + with: + path: /root/.cache/sccache + key: sccache-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + sccache-${{ runner.os }}- - name: Test run: cargo test --workspace --exclude feedsignal-web + + audit: + needs: test + runs-on: rust-ci + steps: + - uses: actions/checkout@v4 + + - name: cargo audit + run: cargo audit diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ca08e3f..1ef9e6a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,7 +1,7 @@ -pub mod models; pub mod affinity; +pub mod models; pub mod scoring; pub use affinity::TopicAffinities; pub use models::{Article, Feed, ReadingEvent, ReadingOutcome}; -pub use scoring::{RelevanceInputs, score_article}; +pub use scoring::{score_article, RelevanceInputs}; diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index f203a41..e79bf68 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -51,7 +51,9 @@ pub enum ReadingOutcome { Impression, /// User opened the article. `dwell_seconds` is filled in later via an /// update event (e.g. on tab close / navigation away) once known. - Opened { dwell_seconds: Option }, + Opened { + dwell_seconds: Option, + }, Starred, /// Explicitly marked not relevant, independent of whether it was opened. Dismissed, diff --git a/crates/core/src/scoring.rs b/crates/core/src/scoring.rs index 38e091a..fba8353 100644 --- a/crates/core/src/scoring.rs +++ b/crates/core/src/scoring.rs @@ -26,7 +26,9 @@ pub fn score_article(inputs: RelevanceInputs) -> f32 { let affinity_component = (affinity + 1.0) / 2.0; // renormalize to [0, 1] match inputs.llm_score { - Some(llm) => W_LLM * llm + W_EMBEDDING * inputs.embedding_score + W_AFFINITY * affinity_component, + Some(llm) => { + W_LLM * llm + W_EMBEDDING * inputs.embedding_score + W_AFFINITY * affinity_component + } // No LLM score yet: redistribute its weight onto the embedding score. None => (W_LLM + W_EMBEDDING) * inputs.embedding_score + W_AFFINITY * affinity_component, } diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 84dcc11..751d0b6 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -37,7 +37,8 @@ impl Db { let url = database_url.to_string(); tokio::task::spawn_blocking(move || -> Result<()> { let mut conn = SqliteConnection::establish(&url)?; - conn.run_pending_migrations(MIGRATIONS).map_err(|e| anyhow::anyhow!(e))?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|e| anyhow::anyhow!(e))?; Ok(()) }) .await??; @@ -58,7 +59,11 @@ impl Db { .set(dsl::title.eq(title)) .execute(&mut conn) .await?; - let id: String = dsl::feeds.filter(dsl::url.eq(url)).select(dsl::id).first(&mut conn).await?; + let id: String = dsl::feeds + .filter(dsl::url.eq(url)) + .select(dsl::id) + .first(&mut conn) + .await?; Ok(Uuid::parse_str(&id)?) } @@ -104,7 +109,10 @@ impl Db { /// Title/summary/topics/embedding_score for one article, used to build /// the LLM-judging prompt for it. - pub async fn article_scoring_fields(&self, article_id: Uuid) -> Result, f32)>> { + pub async fn article_scoring_fields( + &self, + article_id: Uuid, + ) -> Result, f32)>> { use schema::articles::dsl; let mut conn = self.pool.get().await?; let row: Option<(String, String, String, Option)> = dsl::articles @@ -122,18 +130,31 @@ impl Db { }) } - pub async fn store_llm_result(&self, article_id: Uuid, llm_score: f32, rationale: &str, final_score: f32) -> Result<()> { + pub async fn store_llm_result( + &self, + article_id: Uuid, + llm_score: f32, + rationale: &str, + final_score: f32, + ) -> Result<()> { use schema::articles::dsl; let mut conn = self.pool.get().await?; diesel::update(dsl::articles.filter(dsl::id.eq(article_id.to_string()))) - .set((dsl::llm_score.eq(llm_score), dsl::llm_rationale.eq(rationale), dsl::final_score.eq(final_score))) + .set(( + dsl::llm_score.eq(llm_score), + dsl::llm_rationale.eq(rationale), + dsl::final_score.eq(final_score), + )) .execute(&mut conn) .await?; Ok(()) } /// Highest-ranked articles for display, most relevant first. - pub async fn list_ranked_articles(&self, limit: i64) -> Result, Option)>> { + pub async fn list_ranked_articles( + &self, + limit: i64, + ) -> Result, Option)>> { use schema::articles::dsl; let mut conn = self.pool.get().await?; // SQLite sorts NULL before any value, so `DESC` already puts NULL @@ -141,13 +162,27 @@ impl Db { let rows: Vec<(String, String, String, String, String, Option)> = dsl::articles .order(dsl::final_score.desc()) .limit(limit) - .select((dsl::id, dsl::title, dsl::url, dsl::summary, dsl::topics, dsl::final_score)) + .select(( + dsl::id, + dsl::title, + dsl::url, + dsl::summary, + dsl::topics, + dsl::final_score, + )) .load(&mut conn) .await?; Ok(rows .into_iter() .map(|(id, title, url, summary, topics_json, final_score)| { - (id, title, url, summary, serde_json::from_str(&topics_json).unwrap_or_default(), final_score) + ( + id, + title, + url, + summary, + serde_json::from_str(&topics_json).unwrap_or_default(), + final_score, + ) }) .collect()) } @@ -195,7 +230,11 @@ impl Db { let json = serde_json::to_string(affinities)?; let updated_at = Utc::now().to_rfc3339(); diesel::insert_into(dsl::topic_affinities) - .values((dsl::user_id.eq("default"), dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at))) + .values(( + dsl::user_id.eq("default"), + dsl::affinities.eq(&json), + dsl::updated_at.eq(&updated_at), + )) .on_conflict(dsl::user_id) .do_update() .set((dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at))) diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index 3191a3a..02dcfd0 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -27,7 +27,12 @@ impl Llm { /// be pulled locally, e.g. `ollama pull nomic-embed-text` and /// `ollama pull llama3.1`. `embedding_dims` must match the pulled /// embedding model (768 for nomic-embed-text, 384 for all-minilm). - pub fn new(base_url: &str, embedding_model: impl Into, embedding_dims: usize, chat_model: impl Into) -> Result { + pub fn new( + base_url: &str, + embedding_model: impl Into, + embedding_dims: usize, + chat_model: impl Into, + ) -> Result { let client = ollama::Client::builder() .api_key(Nothing) .base_url(base_url) @@ -42,8 +47,13 @@ impl Llm { } pub async fn embed(&self, text: &str) -> Result> { - let model = self.client.embedding_model_with_ndims(&self.embedding_model, self.embedding_dims); - let embedding = model.embed_text(text).await.context("ollama embedding request failed")?; + let model = self + .client + .embedding_model_with_ndims(&self.embedding_model, self.embedding_dims); + let embedding = model + .embed_text(text) + .await + .context("ollama embedding request failed")?; Ok(embedding.vec.into_iter().map(|v| v as f32).collect()) } @@ -79,7 +89,10 @@ impl Llm { ) .build(); - let response = model.completion(request).await.context("ollama chat request failed")?; + let response = model + .completion(request) + .await + .context("ollama chat request failed")?; let text: String = response .choice .into_iter() diff --git a/crates/web/src/server.rs b/crates/web/src/server.rs index dd83db1..86dcf89 100644 --- a/crates/web/src/server.rs +++ b/crates/web/src/server.rs @@ -24,7 +24,15 @@ async fn ensure_background_jobs_started(db: Arc) { .get_or_init(|| async { // TODO: move base_url/model names to config/env once there's a // settings story; hardcoded to models already pulled locally. - let llm = Arc::new(Llm::new("http://localhost:11434", "nomic-embed-text", 768, "gemma4-e4b").expect("failed to construct ollama client")); + let llm = Arc::new( + Llm::new( + "http://localhost:11434", + "nomic-embed-text", + 768, + "gemma4-e4b", + ) + .expect("failed to construct ollama client"), + ); if let Err(err) = pipeline::start_scheduler(db, llm).await { tracing::error!(?err, "failed to start background job scheduler"); } @@ -47,7 +55,16 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result> { .list_ranked_articles(100) .await? .into_iter() - .map(|(id, title, url, summary, topics, final_score)| ArticleView { id, title, url, summary, topics, final_score }) + .map( + |(id, title, url, summary, topics, final_score)| ArticleView { + id, + title, + url, + summary, + topics, + final_score, + }, + ) .collect()) } diff --git a/crates/web/src/server/pipeline.rs b/crates/web/src/server/pipeline.rs index 4abfe27..51ba527 100644 --- a/crates/web/src/server/pipeline.rs +++ b/crates/web/src/server/pipeline.rs @@ -60,7 +60,9 @@ pub async fn start_scheduler(db: Arc, llm: Arc) -> Result /// a TODO here — wire it in once feed subscription management exists. async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> { let affinities = db.load_affinities().await?; - let shortlist = db.shortlist_for_llm_scoring(EMBEDDING_SHORTLIST_THRESHOLD, LLM_BATCH_SIZE).await?; + let shortlist = db + .shortlist_for_llm_scoring(EMBEDDING_SHORTLIST_THRESHOLD, LLM_BATCH_SIZE) + .await?; tracing::info!(count = shortlist.len(), "scoring shortlist with LLM"); @@ -72,7 +74,9 @@ async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> { for article_id in shortlist { // Fetch just the fields needed for the prompt; a real implementation // would batch this rather than one query per article. - let Some((title, summary, topics, embedding_score)) = db.article_scoring_fields(article_id).await? else { + let Some((title, summary, topics, embedding_score)) = + db.article_scoring_fields(article_id).await? + else { continue; }; @@ -92,7 +96,8 @@ async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> { affinities: &affinities, }); - db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score).await?; + db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score) + .await?; } Ok(())