feedsignal/crates/web/src/server.rs

68 lines
2.5 KiB
Rust
Raw Normal View History

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<Db>) {
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<Db, dioxus::prelude::ServerFnError> {
// 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<Vec<ArticleView>> {
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())
}
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(())
}