A fourth explicit verdict, "AI slop" (Vote::Slop, label `slop`), joins the rating widget on the web, the EPUB footer links, the /r/ confirmation page, the CLI, imports and the dashboard filters. It counts as a full negative (curation.feedback.slop_value, -1.0) in the neighbour and affinity signals. Beyond that, each run loads the authors whose current verdict is slop, with no lookback, and multiplies the preliminary blend and the utility of every candidate by that author by 1 - curation.ranking.slop_author_penalty (0.75), so they sink before triage. The flag is recorded in signals_json, shown by `explain` and the dashboard signals table, and the confirmation names the author (or says no author is known, in which case only the rating applies). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
555 lines
17 KiB
Rust
555 lines
17 KiB
Rust
//! Historical rating imports queued from the dashboard.
|
|
|
|
use anyhow::{Context, Result};
|
|
use jiff::Timestamp;
|
|
use sqlx::Row as _;
|
|
|
|
use crate::config::Config;
|
|
use crate::curate::embedding::EmbeddingService;
|
|
use crate::db::{Db, fmt_ts};
|
|
use crate::extract::{Extractor, Page};
|
|
use crate::types::{Article, ExtractMethod, Extracted, Vote};
|
|
|
|
const BATCH_SIZE: i64 = 20;
|
|
|
|
/// One row in the historical rating import queue.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ImportRow {
|
|
pub id: i64,
|
|
pub url: String,
|
|
pub label: String,
|
|
pub note: Option<String>,
|
|
pub status: String,
|
|
pub message: Option<String>,
|
|
pub article_id: Option<i64>,
|
|
pub requested_by: Option<i64>,
|
|
pub requested_at: String,
|
|
pub finished_at: Option<String>,
|
|
}
|
|
|
|
fn row_from(row: &sqlx::sqlite::SqliteRow) -> ImportRow {
|
|
ImportRow {
|
|
id: row.get("id"),
|
|
url: row.get("url"),
|
|
label: row.get("label"),
|
|
note: row.get("note"),
|
|
status: row.get("status"),
|
|
message: row.get("message"),
|
|
article_id: row.get("article_id"),
|
|
requested_by: row.get("requested_by"),
|
|
requested_at: row.get("requested_at"),
|
|
finished_at: row.get("finished_at"),
|
|
}
|
|
}
|
|
|
|
/// Insert one pending import row per URL and return how many were queued.
|
|
pub async fn queue(
|
|
db: &Db,
|
|
urls: &[String],
|
|
label: &str,
|
|
note: Option<&str>,
|
|
requested_by: Option<i64>,
|
|
now: Timestamp,
|
|
) -> Result<usize, sqlx::Error> {
|
|
let mut tx = db.pool().begin().await?;
|
|
let requested_at = fmt_ts(now);
|
|
for url in urls {
|
|
sqlx::query(
|
|
"INSERT INTO rating_imports
|
|
(url, label, note, status, requested_by, requested_at)
|
|
VALUES (?, ?, ?, 'pending', ?, ?)",
|
|
)
|
|
.bind(url)
|
|
.bind(label)
|
|
.bind(note)
|
|
.bind(requested_by)
|
|
.bind(&requested_at)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(urls.len())
|
|
}
|
|
|
|
/// Newest import rows for the ratings dashboard.
|
|
pub async fn recent(db: &Db, limit: i64) -> Result<Vec<ImportRow>, sqlx::Error> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, url, label, note, status, message, article_id, requested_by,
|
|
requested_at, finished_at
|
|
FROM rating_imports ORDER BY id DESC LIMIT ?",
|
|
)
|
|
.bind(limit)
|
|
.fetch_all(db.pool())
|
|
.await?;
|
|
Ok(rows.iter().map(row_from).collect())
|
|
}
|
|
|
|
async fn pending(db: &Db) -> Result<Vec<ImportRow>, sqlx::Error> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, url, label, note, status, message, article_id, requested_by,
|
|
requested_at, finished_at
|
|
FROM rating_imports WHERE status = 'pending' ORDER BY id LIMIT ?",
|
|
)
|
|
.bind(BATCH_SIZE)
|
|
.fetch_all(db.pool())
|
|
.await?;
|
|
Ok(rows.iter().map(row_from).collect())
|
|
}
|
|
|
|
async fn finish(
|
|
db: &Db,
|
|
id: i64,
|
|
status: &str,
|
|
message: &str,
|
|
article_id: Option<i64>,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE rating_imports
|
|
SET status = ?, message = ?, article_id = ?, finished_at = ? WHERE id = ?",
|
|
)
|
|
.bind(status)
|
|
.bind(short_message(message))
|
|
.bind(article_id)
|
|
.bind(fmt_ts(Timestamp::now()))
|
|
.bind(id)
|
|
.execute(db.pool())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn short_message(message: &str) -> String {
|
|
message
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.chars()
|
|
.take(500)
|
|
.collect()
|
|
}
|
|
|
|
fn vote(label: &str) -> Result<Vote> {
|
|
match label {
|
|
"loved" => Ok(Vote::Loved),
|
|
"good" => Ok(Vote::Good),
|
|
"not_for_me" => Ok(Vote::NotForMe),
|
|
"slop" => Ok(Vote::Slop),
|
|
other => anyhow::bail!("invalid rating label {other:?}"),
|
|
}
|
|
}
|
|
|
|
struct Processed {
|
|
article_id: i64,
|
|
created: bool,
|
|
embedded: bool,
|
|
embedding_note: Option<String>,
|
|
}
|
|
|
|
fn imported_article(
|
|
canonical_url: String,
|
|
page: Page,
|
|
extracted: Extracted,
|
|
first_seen: Timestamp,
|
|
) -> Article {
|
|
Article {
|
|
id: 0,
|
|
canonical_url,
|
|
title: if page.title.trim().is_empty() {
|
|
page.final_url.clone()
|
|
} else {
|
|
page.title
|
|
},
|
|
best_entry_id: 0,
|
|
content_html: extracted.content_html,
|
|
word_count: extracted.word_count,
|
|
excerpt_only: extracted.excerpt_only,
|
|
image_count: extracted.image_urls.len() as i64,
|
|
sources: Vec::new(),
|
|
first_seen,
|
|
url: page.final_url,
|
|
author: extracted.author,
|
|
feed_id: 0,
|
|
feed_title: "Imported".into(),
|
|
category: None,
|
|
published_at: None,
|
|
comments_url: None,
|
|
image_urls: extracted.image_urls,
|
|
social: Vec::new(),
|
|
extract_method: ExtractMethod::Readability,
|
|
}
|
|
}
|
|
|
|
async fn process(
|
|
config: &Config,
|
|
db: &Db,
|
|
extractor: &Extractor,
|
|
embedding: Option<&EmbeddingService>,
|
|
embedding_unavailable: Option<&str>,
|
|
row: &ImportRow,
|
|
) -> Result<Processed> {
|
|
let canonical = crate::dedupe::canonical_url(&row.url)
|
|
.with_context(|| format!("invalid URL {:?}", row.url))?;
|
|
let (article, created) = match db.article_id_for_url(&canonical).await? {
|
|
Some(id) => (
|
|
db.get_article(id)
|
|
.await?
|
|
.with_context(|| format!("article {id} disappeared"))?,
|
|
false,
|
|
),
|
|
None => {
|
|
let page = extractor
|
|
.fetch_readable(&canonical)
|
|
.await
|
|
.with_context(|| format!("fetching {canonical}"))?;
|
|
let extracted = extractor.finish_readable(&canonical, &page);
|
|
let mut article = imported_article(canonical, page, extracted, Timestamp::now());
|
|
article.id = db.upsert_article(&article).await?;
|
|
(article, true)
|
|
}
|
|
};
|
|
|
|
let mut embedded = false;
|
|
let mut embedding_note = embedding_unavailable.map(str::to_string);
|
|
if let Some(service) = embedding {
|
|
match service.articles(std::slice::from_ref(&article)).await {
|
|
Ok(vectors) if vectors.contains_key(&article.id) => embedded = true,
|
|
Ok(_) => {
|
|
tracing::warn!(url = %row.url, article_id = article.id, "Voyage returned no embedding for rating import");
|
|
embedding_note = Some("Voyage returned no embedding".into());
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(url = %row.url, article_id = article.id, %error, "could not embed rating import");
|
|
embedding_note = Some(error.to_string());
|
|
}
|
|
}
|
|
} else if !config.voyage.enabled {
|
|
embedding_note = Some("Voyage is disabled".into());
|
|
}
|
|
|
|
crate::rate::record_explicit(
|
|
config,
|
|
db,
|
|
article.id,
|
|
Some(vote(&row.label)?),
|
|
"import",
|
|
row.requested_by,
|
|
row.note.clone(),
|
|
)
|
|
.await?;
|
|
|
|
Ok(Processed {
|
|
article_id: article.id,
|
|
created,
|
|
embedded,
|
|
embedding_note,
|
|
})
|
|
}
|
|
|
|
/// Fetch, embed and rate all pending historical imports.
|
|
pub async fn run(config: &Config, db: &Db) -> Result<String> {
|
|
let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT)
|
|
.context("building article HTTP client")?;
|
|
let extractor = Extractor::new(http, config.curation.paywall_domains.clone());
|
|
let (embedding, embedding_unavailable) = if config.voyage.enabled {
|
|
match EmbeddingService::real(db.clone(), config.voyage.clone()) {
|
|
Ok(service) => (Some(service), None),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Voyage embeddings unavailable for rating imports");
|
|
(None, Some(error.to_string()))
|
|
}
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
run_with(
|
|
config,
|
|
db,
|
|
&extractor,
|
|
embedding.as_ref(),
|
|
embedding_unavailable.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn run_with(
|
|
config: &Config,
|
|
db: &Db,
|
|
extractor: &Extractor,
|
|
embedding: Option<&EmbeddingService>,
|
|
embedding_unavailable: Option<&str>,
|
|
) -> Result<String> {
|
|
let mut imported = 0usize;
|
|
let mut created = 0usize;
|
|
let mut existing = 0usize;
|
|
let mut failed = 0usize;
|
|
loop {
|
|
let rows = pending(db).await?;
|
|
if rows.is_empty() {
|
|
break;
|
|
}
|
|
for row in rows {
|
|
match process(
|
|
config,
|
|
db,
|
|
extractor,
|
|
embedding,
|
|
embedding_unavailable,
|
|
&row,
|
|
)
|
|
.await
|
|
{
|
|
Ok(done) => {
|
|
imported += 1;
|
|
if done.created {
|
|
created += 1;
|
|
} else {
|
|
existing += 1;
|
|
}
|
|
let mut message = if done.created {
|
|
"created".to_string()
|
|
} else {
|
|
"existing article".to_string()
|
|
};
|
|
if done.embedded {
|
|
message.push_str(" + embedded");
|
|
} else if let Some(note) = done.embedding_note {
|
|
message.push_str(&format!("; no embedding ({note})"));
|
|
}
|
|
finish(db, row.id, "ok", &message, Some(done.article_id)).await?;
|
|
tracing::info!(url = %row.url, article_id = done.article_id, %message, "rating import ok");
|
|
}
|
|
Err(error) => {
|
|
failed += 1;
|
|
let message = format!("{error:#}");
|
|
finish(db, row.id, "failed", &message, None).await?;
|
|
tracing::info!(url = %row.url, %message, "rating import failed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if imported == 0 && failed == 0 {
|
|
Ok("no pending rating imports".into())
|
|
} else {
|
|
Ok(format!(
|
|
"{imported} imported ({created} new, {existing} existing), {failed} failed"
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::Entry;
|
|
|
|
async fn test_db() -> (tempfile::TempDir, Db) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
|
.await
|
|
.unwrap();
|
|
(dir, db)
|
|
}
|
|
|
|
async fn existing_article(db: &Db, url: &str) -> i64 {
|
|
let now: Timestamp = "2026-09-01T00:00:00Z".parse().unwrap();
|
|
db.upsert_entry(&Entry {
|
|
id: 42,
|
|
feed_id: 7,
|
|
feed_title: Some("Feed".into()),
|
|
category: None,
|
|
title: "Existing".into(),
|
|
url: url.into(),
|
|
canonical_url: Some(url.into()),
|
|
author: None,
|
|
published_at: None,
|
|
comments_url: None,
|
|
raw_content: "<p>Existing body</p>".into(),
|
|
fetched_at: now,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
db.upsert_article(&Article {
|
|
id: 0,
|
|
canonical_url: url.into(),
|
|
title: "Existing".into(),
|
|
best_entry_id: 42,
|
|
content_html: "<p>Existing body</p>".into(),
|
|
word_count: 2,
|
|
excerpt_only: false,
|
|
image_count: 0,
|
|
sources: Vec::new(),
|
|
first_seen: now,
|
|
url: url.into(),
|
|
author: None,
|
|
feed_id: 7,
|
|
feed_title: "Feed".into(),
|
|
category: None,
|
|
published_at: None,
|
|
comments_url: None,
|
|
image_urls: Vec::new(),
|
|
social: Vec::new(),
|
|
extract_method: ExtractMethod::Miniflux,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
fn config_without_voyage() -> Config {
|
|
let mut config = Config::default();
|
|
config.voyage.enabled = false;
|
|
config
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn existing_article_is_reused_and_rated() {
|
|
let (_dir, db) = test_db().await;
|
|
let id = existing_article(&db, "https://example.com/post").await;
|
|
let user = crate::web::users::add(&db, "admin", "correct horse battery", true)
|
|
.await
|
|
.unwrap();
|
|
queue(
|
|
&db,
|
|
&["https://EXAMPLE.com/post/?utm_source=test".into()],
|
|
"good",
|
|
Some("still useful"),
|
|
Some(user.id),
|
|
Timestamp::now(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let summary = run_with(
|
|
&config_without_voyage(),
|
|
&db,
|
|
&Extractor::offline(Vec::new()),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(summary, "1 imported (0 new, 1 existing), 0 failed");
|
|
assert_eq!(db.count_entries().await.unwrap(), 1);
|
|
let current = db.current_ratings(36500).await.unwrap();
|
|
assert_eq!(current.len(), 1);
|
|
assert_eq!(current[0].article_id, id);
|
|
assert_eq!(current[0].user_id, Some(user.id));
|
|
assert_eq!(current[0].label, "good");
|
|
assert_eq!(current[0].note.as_deref(), Some("still useful"));
|
|
let source: String =
|
|
sqlx::query_scalar("SELECT source FROM rating_events ORDER BY id DESC LIMIT 1")
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(source, "import");
|
|
let row = recent(&db, 1).await.unwrap().remove(0);
|
|
assert_eq!(row.status, "ok");
|
|
assert_eq!(row.article_id, Some(id));
|
|
assert!(row.message.unwrap().contains("existing article"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_url_fails_and_empty_run_is_benign() {
|
|
let (_dir, db) = test_db().await;
|
|
queue(
|
|
&db,
|
|
&["not-a-url".into()],
|
|
"loved",
|
|
None,
|
|
None,
|
|
Timestamp::now(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let config = config_without_voyage();
|
|
let extractor = Extractor::offline(Vec::new());
|
|
assert_eq!(
|
|
run_with(&config, &db, &extractor, None, None)
|
|
.await
|
|
.unwrap(),
|
|
"0 imported (0 new, 0 existing), 1 failed"
|
|
);
|
|
let row = recent(&db, 1).await.unwrap().remove(0);
|
|
assert_eq!(row.status, "failed");
|
|
assert!(row.message.unwrap().contains("invalid URL"));
|
|
assert_eq!(
|
|
run_with(&config, &db, &extractor, None, None)
|
|
.await
|
|
.unwrap(),
|
|
"no pending rating imports"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fetched_page_becomes_an_entryless_sanitized_article() {
|
|
let (_dir, db) = test_db().await;
|
|
let extractor = Extractor::offline(Vec::new());
|
|
let page = Page {
|
|
title: "A historical essay".into(),
|
|
html: "<article><p>Useful old writing.</p><script>bad()</script><img src=\"/chart.png\"></article>".into(),
|
|
author: Some("Essay Writer".into()),
|
|
final_url: "https://example.com/essays/old".into(),
|
|
};
|
|
let extracted = extractor.finish_readable("https://example.com/old", &page);
|
|
let mut article = imported_article(
|
|
"https://example.com/old".into(),
|
|
page,
|
|
extracted,
|
|
"2026-09-06T00:00:00Z".parse().unwrap(),
|
|
);
|
|
assert_eq!(article.title, "A historical essay");
|
|
assert_eq!(article.best_entry_id, 0);
|
|
assert_eq!(article.feed_title, "Imported");
|
|
assert_eq!(article.author.as_deref(), Some("Essay Writer"));
|
|
assert!(article.sources.is_empty());
|
|
assert!(!article.content_html.contains("script"));
|
|
assert_eq!(article.image_urls, ["https://example.com/chart.png"]);
|
|
article.id = db.upsert_article(&article).await.unwrap();
|
|
let loaded = db.get_article(article.id).await.unwrap().unwrap();
|
|
assert_eq!(loaded.title, "A historical essay");
|
|
assert_eq!(loaded.best_entry_id, 0);
|
|
assert_eq!(loaded.feed_title, "Imported");
|
|
assert_eq!(loaded.author.as_deref(), Some("Essay Writer"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rows_queued_while_running_are_picked_up_in_the_next_batch() {
|
|
let (_dir, db) = test_db().await;
|
|
existing_article(&db, "https://example.com/post").await;
|
|
queue(
|
|
&db,
|
|
&["https://example.com/post".into()],
|
|
"loved",
|
|
None,
|
|
None,
|
|
Timestamp::now(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"CREATE TRIGGER queue_during_import AFTER UPDATE OF status ON rating_imports
|
|
WHEN OLD.status = 'pending' AND NEW.status = 'ok' AND NEW.id = 1
|
|
BEGIN
|
|
INSERT INTO rating_imports
|
|
(url, label, status, requested_at)
|
|
VALUES ('https://example.com/post', 'good', 'pending', '2026-09-01T00:00:00Z');
|
|
END",
|
|
)
|
|
.execute(db.pool())
|
|
.await
|
|
.unwrap();
|
|
let summary = run_with(
|
|
&config_without_voyage(),
|
|
&db,
|
|
&Extractor::offline(Vec::new()),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(summary, "2 imported (0 new, 2 existing), 0 failed");
|
|
assert_eq!(recent(&db, 10).await.unwrap().len(), 2);
|
|
let ratings: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rating_events")
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(ratings, 2);
|
|
}
|
|
}
|