Fix CI to use rust-ci runner, matching sporah's workflow pattern #1
7 changed files with 100 additions and 22 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<u32> },
|
||||
Opened {
|
||||
dwell_seconds: Option<u32>,
|
||||
},
|
||||
Starred,
|
||||
/// Explicitly marked not relevant, independent of whether it was opened.
|
||||
Dismissed,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -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<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
|
||||
|
|
@ -141,13 +162,27 @@ 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())
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
|
|
@ -42,8 +47,13 @@ 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())
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,15 @@ 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");
|
||||
}
|
||||
|
|
@ -47,7 +55,16 @@ 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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ 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");
|
||||
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
Loading…
Reference in a new issue