82 lines
2.9 KiB
Rust
82 lines
2.9 KiB
Rust
|
|
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 the common local Ollama defaults.
|
||
|
|
let llm = Arc::new(Llm::new("http://localhost:11434", "nomic-embed-text", 768, "llama3.1").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("sqlite://feedsignal.db?mode=rwc")
|
||
|
|
.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>> {
|
||
|
|
let rows: Vec<(String, String, String, String, String, Option<f32>)> = sqlx::query_as(
|
||
|
|
"SELECT id, title, url, summary, topics, final_score
|
||
|
|
FROM articles
|
||
|
|
ORDER BY final_score DESC NULLS LAST
|
||
|
|
LIMIT 100",
|
||
|
|
)
|
||
|
|
.fetch_all(db.pool())
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
Ok(rows
|
||
|
|
.into_iter()
|
||
|
|
.map(|(id, title, url, summary, topics_json, final_score)| ArticleView {
|
||
|
|
id,
|
||
|
|
title,
|
||
|
|
url,
|
||
|
|
summary,
|
||
|
|
topics: serde_json::from_str(&topics_json).unwrap_or_default(),
|
||
|
|
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(())
|
||
|
|
}
|