123 lines
4.3 KiB
Rust
123 lines
4.3 KiB
Rust
|
|
use anyhow::Result;
|
||
|
|
use feedsignal_core::{Article, ReadingEvent, ReadingOutcome, TopicAffinities};
|
||
|
|
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||
|
|
use uuid::Uuid;
|
||
|
|
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct Db {
|
||
|
|
pool: SqlitePool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Db {
|
||
|
|
/// `path` e.g. "sqlite://feedsignal.db?mode=rwc" — everything lives in
|
||
|
|
/// one file, no separate database server to run.
|
||
|
|
pub async fn connect(url: &str) -> Result<Self> {
|
||
|
|
let pool = SqlitePoolOptions::new().max_connections(5).connect(url).await?;
|
||
|
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||
|
|
Ok(Self { pool })
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn upsert_feed(&self, url: &str, title: &str) -> Result<Uuid> {
|
||
|
|
let id = Uuid::new_v4();
|
||
|
|
sqlx::query(
|
||
|
|
"INSERT INTO feeds (id, url, title) VALUES (?, ?, ?)
|
||
|
|
ON CONFLICT(url) DO UPDATE SET title = excluded.title",
|
||
|
|
)
|
||
|
|
.bind(id.to_string())
|
||
|
|
.bind(url)
|
||
|
|
.bind(title)
|
||
|
|
.execute(&self.pool)
|
||
|
|
.await?;
|
||
|
|
let row: (String,) = sqlx::query_as("SELECT id FROM feeds WHERE url = ?")
|
||
|
|
.bind(url)
|
||
|
|
.fetch_one(&self.pool)
|
||
|
|
.await?;
|
||
|
|
Ok(Uuid::parse_str(&row.0)?)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn insert_article(&self, article: &Article) -> Result<()> {
|
||
|
|
let topics = serde_json::to_string(&article.topics)?;
|
||
|
|
sqlx::query(
|
||
|
|
"INSERT OR IGNORE INTO articles
|
||
|
|
(id, feed_id, url, title, summary, fetched_at, topics, embedding_score, llm_score, final_score, estimated_read_seconds)
|
||
|
|
VALUES (?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)",
|
||
|
|
)
|
||
|
|
.bind(article.id.to_string())
|
||
|
|
.bind(article.feed_id.to_string())
|
||
|
|
.bind(&article.url)
|
||
|
|
.bind(&article.title)
|
||
|
|
.bind(&article.summary)
|
||
|
|
.bind(topics)
|
||
|
|
.bind(article.embedding_score)
|
||
|
|
.bind(article.llm_score)
|
||
|
|
.bind(article.final_score)
|
||
|
|
.bind(article.estimated_read_seconds)
|
||
|
|
.execute(&self.pool)
|
||
|
|
.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<Vec<Uuid>> {
|
||
|
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||
|
|
"SELECT id FROM articles
|
||
|
|
WHERE embedding_score >= ? AND llm_score IS NULL
|
||
|
|
ORDER BY embedding_score DESC
|
||
|
|
LIMIT ?",
|
||
|
|
)
|
||
|
|
.bind(threshold)
|
||
|
|
.bind(limit)
|
||
|
|
.fetch_all(&self.pool)
|
||
|
|
.await?;
|
||
|
|
rows.into_iter().map(|(s,)| Ok(Uuid::parse_str(&s)?)).collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn record_event(&self, event: &ReadingEvent) -> Result<()> {
|
||
|
|
let (outcome, dwell) = match &event.outcome {
|
||
|
|
ReadingOutcome::Impression => ("impression", None),
|
||
|
|
ReadingOutcome::Opened { dwell_seconds } => ("opened", *dwell_seconds),
|
||
|
|
ReadingOutcome::Starred => ("starred", None),
|
||
|
|
ReadingOutcome::Dismissed => ("dismissed", None),
|
||
|
|
};
|
||
|
|
sqlx::query(
|
||
|
|
"INSERT INTO reading_events (id, article_id, occurred_at, outcome, dwell_seconds)
|
||
|
|
VALUES (?, ?, ?, ?, ?)",
|
||
|
|
)
|
||
|
|
.bind(event.id.to_string())
|
||
|
|
.bind(event.article_id.to_string())
|
||
|
|
.bind(event.occurred_at.to_rfc3339())
|
||
|
|
.bind(outcome)
|
||
|
|
.bind(dwell)
|
||
|
|
.execute(&self.pool)
|
||
|
|
.await?;
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn load_affinities(&self) -> Result<TopicAffinities> {
|
||
|
|
let row: Option<(String,)> = sqlx::query_as("SELECT affinities FROM topic_affinities WHERE user_id = 'default'")
|
||
|
|
.fetch_optional(&self.pool)
|
||
|
|
.await?;
|
||
|
|
Ok(match row {
|
||
|
|
Some((json,)) => serde_json::from_str(&json)?,
|
||
|
|
None => TopicAffinities::default(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn save_affinities(&self, affinities: &TopicAffinities) -> Result<()> {
|
||
|
|
let json = serde_json::to_string(affinities)?;
|
||
|
|
sqlx::query(
|
||
|
|
"INSERT INTO topic_affinities (user_id, affinities, updated_at) VALUES ('default', ?, datetime('now'))
|
||
|
|
ON CONFLICT(user_id) DO UPDATE SET affinities = excluded.affinities, updated_at = excluded.updated_at",
|
||
|
|
)
|
||
|
|
.bind(json)
|
||
|
|
.execute(&self.pool)
|
||
|
|
.await?;
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn pool(&self) -> &SqlitePool {
|
||
|
|
&self.pool
|
||
|
|
}
|
||
|
|
}
|