use crate::app::ArticleView; use anyhow::Result; use feedsignal_core::{ReadingEvent, ReadingOutcome}; use feedsignal_db::Db; use feedsignal_llm::Llm; use std::sync::Arc; use tokio::sync::OnceCell; use uuid::Uuid; pub mod pipeline; pub fn init_tracing() { tracing_subscriber::fmt::init(); } static BACKGROUND_JOBS: OnceCell<()> = OnceCell::const_new(); /// Lazily starts the feed-polling/scoring scheduler on first use. Deferred /// rather than started at process boot because `dioxus_server::launch_cfg` /// owns the tokio runtime construction — this runs the first time a server /// function executes, which is guaranteed to already be inside that runtime. async fn ensure_background_jobs_started(db: Arc) { BACKGROUND_JOBS .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"), ); if let Err(err) = pipeline::start_scheduler(db, llm).await { tracing::error!(?err, "failed to start background job scheduler"); } }) .await; } pub async fn db() -> Result { // TODO: hold this in a `OnceCell`/app-wide state instead of reconnecting // per request once the server-state story is wired up. let db = Db::connect("feedsignal.db") .await .map_err(|e| dioxus::prelude::ServerFnError::new(e.to_string()))?; ensure_background_jobs_started(Arc::new(db.clone())).await; Ok(db) } pub async fn list_ranked_articles_impl(db: Db) -> Result> { Ok(db .list_ranked_articles(100) .await? .into_iter() .map( |(id, title, url, summary, topics, final_score)| ArticleView { id, title, url, summary, topics, final_score, }, ) .collect()) } /// Subscribes to a feed by URL: registers it (or updates its title if /// already subscribed) and does an immediate first fetch so the reader /// isn't empty until the next scheduled poll. Returns the number of /// articles pulled in on this fetch. pub async fn subscribe_feed_impl(db: Db, url: String) -> Result { let url = url.trim(); anyhow::ensure!(!url.is_empty(), "feed URL is required"); // Register under a placeholder title first so the feed exists even if // the fetch below fails (e.g. transient network error) — it'll be // picked up by a later poll once feed polling is wired in. let feed_id = db.upsert_feed(url, url).await?; let (title, articles) = feedsignal_feeds::fetch_feed(url, feed_id).await?; db.upsert_feed(url, &title).await?; let count = articles.len(); for article in &articles { db.insert_article(article).await?; } Ok(count) } pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> { let event = ReadingEvent { id: Uuid::new_v4(), article_id: Uuid::parse_str(&article_id)?, occurred_at: chrono::Utc::now(), outcome: ReadingOutcome::Dismissed, }; db.record_event(&event).await?; // Affinity re-weighting from this event happens in the batched pipeline // job (see `pipeline::apply_pending_feedback`) rather than inline here, // so a burst of dismissals doesn't serialize on read-modify-write of // the single affinities row. Ok(()) }