use crate::schema; use crate::Db; use anyhow::Result; use chrono::Utc; use diesel::prelude::*; use diesel_async::RunQueryDsl; use feedsignal_core::Article; use uuid::Uuid; impl Db { pub async fn insert_article(&self, article: &Article) -> Result<()> { use schema::articles::dsl; let mut conn = self.pool.get().await?; let topics = serde_json::to_string(&article.topics)?; diesel::insert_into(dsl::articles) .values(( dsl::id.eq(article.id.to_string()), dsl::feed_id.eq(article.feed_id.to_string()), dsl::url.eq(&article.url), dsl::title.eq(&article.title), dsl::summary.eq(&article.summary), dsl::fetched_at.eq(Utc::now().to_rfc3339()), dsl::topics.eq(topics), dsl::embedding_score.eq(article.embedding_score), dsl::llm_score.eq(article.llm_score), dsl::final_score.eq(article.final_score), dsl::estimated_read_seconds.eq(article.estimated_read_seconds.map(|v| v as i32)), )) .on_conflict_do_nothing() .execute(&mut conn) .await?; Ok(()) } /// Articles above the embedding-similarity threshold that haven't been /// through the (slower) LLM scoring stage yet. pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result> { use schema::articles::dsl; let mut conn = self.pool.get().await?; let ids: Vec = dsl::articles .filter(dsl::embedding_score.ge(threshold)) .filter(dsl::llm_score.is_null()) .order(dsl::embedding_score.desc()) .limit(limit) .select(dsl::id) .load(&mut conn) .await?; ids.into_iter().map(|s| Ok(Uuid::parse_str(&s)?)).collect() } /// 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)>> { use schema::articles::dsl; let mut conn = self.pool.get().await?; let row: Option<(String, String, String, Option)> = dsl::articles .filter(dsl::id.eq(article_id.to_string())) .select((dsl::title, dsl::summary, dsl::topics, dsl::embedding_score)) .first(&mut conn) .await .optional()?; Ok(match row { Some((title, summary, topics_json, score)) => { let topics: Vec = serde_json::from_str(&topics_json).unwrap_or_default(); Some((title, summary, topics, score.unwrap_or(0.0))) } None => None, }) } 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), )) .execute(&mut conn) .await?; Ok(()) } /// Highest-ranked articles for display, most relevant first. 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 // `final_score`s last — no separate NULLS LAST clause needed here. 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, )) .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, ) }) .collect()) } }