Compare commits

..

No commits in common. "4f70bfc9608ca4422f5d8929d7ddbf45b447e452" and "a8d4599a03ab941683a3c10d889a40288cc7c5ac" have entirely different histories.

8 changed files with 32 additions and 160 deletions

View file

@ -7,80 +7,30 @@ on:
jobs:
check:
runs-on: rust-ci
runs-on: docker
container: docker.io/library/rust:1-bookworm
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry and build artifacts
- name: Cache cargo registry and target dir
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
# 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 }}-
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Add wasm target
run: rustup target add wasm32-unknown-unknown
- name: Format check
run: cargo fmt --check
- name: Check native crates
run: cargo check --workspace --exclude feedsignal-web
- name: Clippy (native crates)
run: cargo clippy --workspace --exclude feedsignal-web --all-targets -- -D warnings
- name: Check web crate (server)
run: cargo check -p feedsignal-web --no-default-features --features server
- 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: Check web crate (wasm client)
run: cargo check -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown
- 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

View file

@ -1,7 +1,7 @@
pub mod affinity;
pub mod models;
pub mod affinity;
pub mod scoring;
pub use affinity::TopicAffinities;
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome};
pub use scoring::{score_article, RelevanceInputs};
pub use scoring::{RelevanceInputs, score_article};

View file

@ -51,9 +51,7 @@ 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<u32>,
},
Opened { dwell_seconds: Option<u32> },
Starred,
/// Explicitly marked not relevant, independent of whether it was opened.
Dismissed,

View file

@ -26,9 +26,7 @@ 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,
}

View file

@ -37,8 +37,7 @@ 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??;
@ -59,11 +58,7 @@ 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)?)
}
@ -109,10 +104,7 @@ 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<Option<(String, String, Vec<String>, f32)>> {
pub async fn article_scoring_fields(&self, article_id: Uuid) -> Result<Option<(String, String, Vec<String>, f32)>> {
use schema::articles::dsl;
let mut conn = self.pool.get().await?;
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
@ -130,31 +122,18 @@ 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<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
pub async fn list_ranked_articles(&self, limit: i64) -> Result<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
use schema::articles::dsl;
let mut conn = self.pool.get().await?;
// SQLite sorts NULL before any value, so `DESC` already puts NULL
@ -162,27 +141,13 @@ impl Db {
let rows: Vec<(String, String, String, String, String, Option<f32>)> = 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())
}
@ -230,11 +195,7 @@ 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)))

View file

@ -27,12 +27,7 @@ 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<String>,
embedding_dims: usize,
chat_model: impl Into<String>,
) -> Result<Self> {
pub fn new(base_url: &str, embedding_model: impl Into<String>, embedding_dims: usize, chat_model: impl Into<String>) -> Result<Self> {
let client = ollama::Client::builder()
.api_key(Nothing)
.base_url(base_url)
@ -47,13 +42,8 @@ impl Llm {
}
pub async fn embed(&self, text: &str) -> Result<Vec<f32>> {
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())
}
@ -89,10 +79,7 @@ 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()

View file

@ -24,15 +24,7 @@ async fn ensure_background_jobs_started(db: Arc<Db>) {
.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");
}
@ -55,16 +47,7 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
.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())
}

View file

@ -60,9 +60,7 @@ pub async fn start_scheduler(db: Arc<Db>, llm: Arc<Llm>) -> Result<JobScheduler>
/// 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");
@ -74,9 +72,7 @@ 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;
};
@ -96,8 +92,7 @@ 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(())