The Current tab ran three statements per rated article: two for `get_article` (the article and its social rows) and one for the newest explicit `rating_events` source. That is fine on the dev seed and linear in a production ratings table, and per-statement overhead, not SQLite's work, was most of the page's origin time. Collect the article ids up front, load them with the batched `Db::get_articles` added for the issue page, and resolve every source in one windowed query per chunk of 500 ids. Semantics are unchanged: ties still break on `id DESC`, and an article with no explicit event still renders an empty source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
1405 lines
47 KiB
Rust
1405 lines
47 KiB
Rust
//! Dashboard: the ratings page (`/dashboard/ratings`, web plan §10).
|
||
//!
|
||
//! The **Current** tab shows one row per rated article with every way the
|
||
//! verdict enters the ranker: its decayed neighbour weight (curation plan
|
||
//! §9.2), the feed credit (§9.3), whether it sits in the prompt's verdict block
|
||
//! (§8.4) and in the weekly rebuild set (§8.3), and how many candidates of the
|
||
//! last run listed it among their nearest rated neighbours. The **Events** tab
|
||
//! is the append-only `rating_events` history.
|
||
|
||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||
|
||
use askama::Template;
|
||
use axum::Router;
|
||
use axum::extract::{Extension, Query, State};
|
||
use axum::response::{IntoResponse, Response};
|
||
use axum::routing::get;
|
||
use axum_login::tower_sessions::Session;
|
||
use jiff::Timestamp;
|
||
use serde::Deserialize;
|
||
use sqlx::Row;
|
||
|
||
use crate::config::Config;
|
||
use crate::curate::profile::{MAX_RATINGS_IN_REBUILD, REBUILD_INTERVAL_DAYS};
|
||
use crate::curate::signals::{self, PreferenceState};
|
||
use crate::curate::telemetry::SignalsJson;
|
||
use crate::db::{Db, DbError};
|
||
use crate::server::AppState;
|
||
use crate::types::{Article, ArticleId, FeedId, RatedArticle};
|
||
use crate::web::rate::RatingWidget;
|
||
use crate::web::session::{AuthSession, Viewer};
|
||
use crate::web::{Html, Page, Pagination, WebError, encode_component, format_time, take_flash};
|
||
|
||
/// The verdict block and the rebuild set are bounded by count, not age, so the
|
||
/// page lists every current verdict (curation plan §8.3, §8.4).
|
||
const CURRENT_LOOKBACK_DAYS: i64 = 36_500;
|
||
const EVENTS_PER_PAGE: u32 = 100;
|
||
|
||
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new().route("/dashboard/ratings", get(index))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Contributions (pure; §10 table columns)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// One direct feed's share of a rating (curation plan §9.3).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct FeedCredit {
|
||
pub feed_id: FeedId,
|
||
pub feed_title: String,
|
||
/// `value × decay / n` over the article's `n` direct feeds.
|
||
pub credit: f64,
|
||
}
|
||
|
||
/// How one current verdict enters the algorithm (§10).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Contribution {
|
||
pub age_days: f64,
|
||
/// `0.5 ^ (age / half_life)` (`signals::decay`).
|
||
pub decay: f64,
|
||
/// `value × decay`, the `w_i` of §9.2; `None` without an embedding.
|
||
pub neighbour_weight: Option<f64>,
|
||
/// Older than `rating_lookback_days`: the preference state skips it.
|
||
pub beyond_lookback: bool,
|
||
pub feed_credits: Vec<FeedCredit>,
|
||
/// Rank among current non-cleared verdicts, newest first; `None` for `cleared`.
|
||
pub rank: Option<usize>,
|
||
pub in_prompt: bool,
|
||
pub in_rebuild: bool,
|
||
}
|
||
|
||
/// Compute the §10 columns for one verdict. `rank` is the article's position
|
||
/// among the current non-cleared verdicts ordered newest first.
|
||
pub fn contribution(
|
||
rating: &RatedArticle,
|
||
article: Option<&Article>,
|
||
has_embedding: bool,
|
||
rank: Option<usize>,
|
||
now: Timestamp,
|
||
config: &Config,
|
||
) -> Contribution {
|
||
let ranking = &config.curation.ranking;
|
||
let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0;
|
||
let decay = signals::decay(age_days, ranking.rating_half_life_days);
|
||
let weight = rating.value * decay;
|
||
let feeds = article.map(signals::direct_feeds).unwrap_or_default();
|
||
let feed_credits = if rating.label == "cleared" || feeds.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
let credit = weight / feeds.len() as f64;
|
||
feeds
|
||
.iter()
|
||
.map(|feed_id| FeedCredit {
|
||
feed_id: *feed_id,
|
||
feed_title: feed_title_for(article, *feed_id),
|
||
credit,
|
||
})
|
||
.collect()
|
||
};
|
||
let active = rating.label != "cleared";
|
||
Contribution {
|
||
age_days,
|
||
decay,
|
||
neighbour_weight: (active && has_embedding).then_some(weight),
|
||
beyond_lookback: age_days > ranking.rating_lookback_days as f64,
|
||
feed_credits,
|
||
rank,
|
||
in_prompt: rank.is_some_and(|rank| rank < config.curation.feedback.verdicts_in_prompt),
|
||
in_rebuild: rank.is_some_and(|rank| rank < MAX_RATINGS_IN_REBUILD),
|
||
}
|
||
}
|
||
|
||
fn feed_title_for(article: Option<&Article>, feed_id: FeedId) -> String {
|
||
let Some(article) = article else {
|
||
return format!("feed {feed_id}");
|
||
};
|
||
article
|
||
.sources
|
||
.iter()
|
||
.find(|source| source.feed_id == feed_id)
|
||
.map(|source| source.feed_title.clone())
|
||
.or_else(|| (article.feed_id == feed_id).then(|| article.feed_title.clone()))
|
||
.filter(|title| !title.trim().is_empty())
|
||
.unwrap_or_else(|| format!("feed {feed_id}"))
|
||
}
|
||
|
||
/// How often a rated article appeared among candidates' nearest neighbours in
|
||
/// one run (§10 "Used last run").
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct NeighbourUse {
|
||
pub total: usize,
|
||
pub selected: usize,
|
||
}
|
||
|
||
/// The latest run that was not a dry run.
|
||
#[derive(Debug, Clone)]
|
||
pub struct LastRun {
|
||
pub id: i64,
|
||
pub date: String,
|
||
pub status: String,
|
||
}
|
||
|
||
pub async fn last_real_run(db: &Db) -> Result<Option<LastRun>, DbError> {
|
||
let row = sqlx::query(
|
||
"SELECT id, date, status FROM runs WHERE status != 'dry_run' ORDER BY id DESC LIMIT 1",
|
||
)
|
||
.fetch_optional(db.pool())
|
||
.await?;
|
||
Ok(row.map(|row| LastRun {
|
||
id: row.get("id"),
|
||
date: row.get("date"),
|
||
status: row.get("status"),
|
||
}))
|
||
}
|
||
|
||
/// Count, per rated article, the candidates of `run_id` whose
|
||
/// `signals_json.neighbours` list it, and how many of those were selected.
|
||
pub async fn neighbour_usage(
|
||
db: &Db,
|
||
run_id: i64,
|
||
) -> Result<HashMap<ArticleId, NeighbourUse>, DbError> {
|
||
let rows = sqlx::query(
|
||
"SELECT stage, signals_json FROM candidate_runs
|
||
WHERE run_id = ? AND signals_json LIKE '%\"neighbours\":[{%'",
|
||
)
|
||
.bind(run_id)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
let mut usage: HashMap<ArticleId, NeighbourUse> = HashMap::new();
|
||
for row in rows {
|
||
let stage: String = row.get("stage");
|
||
let raw: String = row.get("signals_json");
|
||
let Ok(signals) = serde_json::from_str::<SignalsJson>(&raw) else {
|
||
continue;
|
||
};
|
||
let mut seen = HashSet::new();
|
||
for neighbour in signals.neighbours {
|
||
if !seen.insert(neighbour.article_id) {
|
||
continue;
|
||
}
|
||
let entry = usage.entry(neighbour.article_id).or_default();
|
||
entry.total += 1;
|
||
if stage == "selected" {
|
||
entry.selected += 1;
|
||
}
|
||
}
|
||
}
|
||
Ok(usage)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Query parameters
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[derive(Debug, Clone, Default, Deserialize)]
|
||
pub struct RatingsQuery {
|
||
#[serde(default)]
|
||
tab: Option<String>,
|
||
#[serde(default)]
|
||
label: Option<String>,
|
||
#[serde(default)]
|
||
source: Option<String>,
|
||
#[serde(default)]
|
||
feed: Option<String>,
|
||
#[serde(default)]
|
||
q: Option<String>,
|
||
#[serde(default)]
|
||
user: Option<String>,
|
||
#[serde(default)]
|
||
from: Option<String>,
|
||
#[serde(default)]
|
||
to: Option<String>,
|
||
#[serde(default)]
|
||
page: Option<String>,
|
||
}
|
||
|
||
fn clean(value: &Option<String>) -> Option<String> {
|
||
value
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string)
|
||
}
|
||
|
||
/// Widget labels (`loved|good|down|cleared`) → the stored event label.
|
||
fn event_label(widget: &str) -> Option<&'static str> {
|
||
match widget {
|
||
"loved" => Some("loved"),
|
||
"good" => Some("good"),
|
||
"down" => Some("not_for_me"),
|
||
"cleared" => Some("cleared"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Stored event labels → the widget/badge label and its display text.
|
||
fn widget_label(label: &str) -> (&'static str, &'static str) {
|
||
match label {
|
||
"loved" => ("loved", "Loved it"),
|
||
"good" => ("good", "Good"),
|
||
"not_for_me" | "down" => ("down", "Not for me"),
|
||
"cleared" => ("cleared", "Cleared"),
|
||
_ => ("", "Unknown"),
|
||
}
|
||
}
|
||
|
||
fn valid_date(value: Option<String>) -> Option<String> {
|
||
value.filter(|value| value.parse::<jiff::civil::Date>().is_ok())
|
||
}
|
||
|
||
fn source_is_valid(source: &str) -> bool {
|
||
!source.is_empty()
|
||
&& source.len() <= 32
|
||
&& source
|
||
.chars()
|
||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
struct Filters {
|
||
tab: String,
|
||
label: Option<String>,
|
||
source: Option<String>,
|
||
feed: Option<FeedId>,
|
||
q: Option<String>,
|
||
user: Option<String>,
|
||
from: Option<String>,
|
||
to: Option<String>,
|
||
page: u32,
|
||
}
|
||
|
||
impl Filters {
|
||
fn parse(query: RatingsQuery) -> Self {
|
||
let tab = match query.tab.as_deref() {
|
||
Some("events") => "events",
|
||
_ => "current",
|
||
}
|
||
.to_string();
|
||
Self {
|
||
tab,
|
||
label: clean(&query.label).filter(|label| event_label(label).is_some()),
|
||
source: clean(&query.source).filter(|source| source_is_valid(source)),
|
||
feed: clean(&query.feed).and_then(|feed| feed.parse::<FeedId>().ok()),
|
||
q: clean(&query.q).map(|q| q.chars().take(200).collect()),
|
||
user: clean(&query.user).filter(|user| user.len() <= 32),
|
||
from: valid_date(clean(&query.from)),
|
||
to: valid_date(clean(&query.to)),
|
||
page: clean(&query.page)
|
||
.and_then(|page| page.parse::<u32>().ok())
|
||
.unwrap_or(1)
|
||
.max(1),
|
||
}
|
||
}
|
||
|
||
/// The page's own URL with every active filter, used as the widget's `next`.
|
||
fn href(&self, page: Option<u32>) -> String {
|
||
let mut params = vec![("tab", self.tab.clone())];
|
||
if let Some(label) = &self.label {
|
||
params.push(("label", label.clone()));
|
||
}
|
||
if let Some(source) = &self.source {
|
||
params.push(("source", source.clone()));
|
||
}
|
||
if let Some(feed) = self.feed {
|
||
params.push(("feed", feed.to_string()));
|
||
}
|
||
if let Some(q) = &self.q {
|
||
params.push(("q", q.clone()));
|
||
}
|
||
if let Some(user) = &self.user {
|
||
params.push(("user", user.clone()));
|
||
}
|
||
if let Some(from) = &self.from {
|
||
params.push(("from", from.clone()));
|
||
}
|
||
if let Some(to) = &self.to {
|
||
params.push(("to", to.clone()));
|
||
}
|
||
if let Some(page) = page.filter(|page| *page > 1) {
|
||
params.push(("page", page.to_string()));
|
||
}
|
||
let query = params
|
||
.iter()
|
||
.map(|(key, value)| format!("{key}={}", encode_component(value)))
|
||
.collect::<Vec<_>>()
|
||
.join("&");
|
||
format!("/dashboard/ratings?{query}")
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// View models
|
||
// ---------------------------------------------------------------------------
|
||
|
||
struct FeedCreditView {
|
||
title: String,
|
||
credit: String,
|
||
}
|
||
|
||
struct CurrentRow {
|
||
article_id: ArticleId,
|
||
title: String,
|
||
feed_title: String,
|
||
issue_date: String,
|
||
badge: String,
|
||
widget: RatingWidget,
|
||
when: String,
|
||
source: String,
|
||
username: String,
|
||
note: String,
|
||
age_days: String,
|
||
decay: String,
|
||
has_embedding: bool,
|
||
neighbour_weight: String,
|
||
beyond_lookback: bool,
|
||
feed_credits: Vec<FeedCreditView>,
|
||
in_prompt: bool,
|
||
in_rebuild: bool,
|
||
used_total: usize,
|
||
used_selected: usize,
|
||
}
|
||
|
||
struct EventRow {
|
||
id: i64,
|
||
article_id: ArticleId,
|
||
title: String,
|
||
issue_date: String,
|
||
kind: String,
|
||
badge: String,
|
||
verdict: String,
|
||
value: String,
|
||
when: String,
|
||
source: String,
|
||
username: String,
|
||
note: String,
|
||
superseded: bool,
|
||
}
|
||
|
||
struct FeedOption {
|
||
id: FeedId,
|
||
title: String,
|
||
}
|
||
|
||
/// Configuration values inlined into the "How ratings enter the algorithm" block.
|
||
struct HowValues {
|
||
loved: String,
|
||
good: String,
|
||
not_for_me: String,
|
||
verdicts_in_prompt: usize,
|
||
rebuild_interval_days: i64,
|
||
max_ratings_in_rebuild: usize,
|
||
half_life_days: String,
|
||
lookback_days: i64,
|
||
neighbour_k: usize,
|
||
negative_coefficient: String,
|
||
knn_floor: usize,
|
||
knn_full: usize,
|
||
knn_weight: String,
|
||
feed_floor: usize,
|
||
feed_full: usize,
|
||
feed_weight: String,
|
||
}
|
||
|
||
impl HowValues {
|
||
fn from_config(config: &Config) -> Self {
|
||
let feedback = &config.curation.feedback;
|
||
let ranking = &config.curation.ranking;
|
||
Self {
|
||
loved: format!("{:+.2}", feedback.loved_value),
|
||
good: format!("{:+.2}", feedback.good_value),
|
||
not_for_me: format!("{:+.2}", feedback.not_for_me_value),
|
||
verdicts_in_prompt: feedback.verdicts_in_prompt,
|
||
rebuild_interval_days: REBUILD_INTERVAL_DAYS,
|
||
max_ratings_in_rebuild: MAX_RATINGS_IN_REBUILD,
|
||
half_life_days: format!("{}", ranking.rating_half_life_days),
|
||
lookback_days: ranking.rating_lookback_days,
|
||
neighbour_k: ranking.neighbour_k,
|
||
negative_coefficient: format!("{}", ranking.negative_coefficient),
|
||
knn_floor: ranking.knn_floor,
|
||
knn_full: ranking.knn_full,
|
||
knn_weight: format!("{}", ranking.weights.preliminary.knn),
|
||
feed_floor: ranking.feed_floor,
|
||
feed_full: ranking.feed_full,
|
||
feed_weight: format!("{}", ranking.weights.preliminary.feed),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Template)]
|
||
#[template(path = "dashboard/ratings.html")]
|
||
struct RatingsTemplate {
|
||
page: Page,
|
||
tab: String,
|
||
summary_line: String,
|
||
no_embedding_count: usize,
|
||
how: HowValues,
|
||
filter_label: String,
|
||
filter_source: String,
|
||
filter_feed: String,
|
||
filter_q: String,
|
||
filter_user: String,
|
||
filter_from: String,
|
||
filter_to: String,
|
||
sources: Vec<String>,
|
||
feeds: Vec<FeedOption>,
|
||
usernames: Vec<String>,
|
||
current_href: String,
|
||
events_href: String,
|
||
current: Vec<CurrentRow>,
|
||
current_total: usize,
|
||
last_run: Option<LastRun>,
|
||
events: Vec<EventRow>,
|
||
pagination: Pagination,
|
||
prev_href: String,
|
||
next_href: String,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Handler
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async fn index(
|
||
State(state): State<AppState>,
|
||
auth: AuthSession,
|
||
Extension(session): Extension<Session>,
|
||
Query(query): Query<RatingsQuery>,
|
||
) -> Result<Response, WebError> {
|
||
let viewer = auth
|
||
.user()
|
||
.await
|
||
.map(Viewer::from)
|
||
.ok_or_else(|| WebError::Unauthenticated {
|
||
next: "/dashboard/ratings".into(),
|
||
})?;
|
||
let config = state.config();
|
||
let filters = Filters::parse(query);
|
||
let now = Timestamp::now();
|
||
let db = &state.db;
|
||
|
||
let preference = PreferenceState::load(db, &config.voyage, &config.curation.ranking, now)
|
||
.await
|
||
.map_err(WebError::Internal)?
|
||
.summary();
|
||
let ratings = db
|
||
.current_ratings_including_cleared(CURRENT_LOOKBACK_DAYS)
|
||
.await?;
|
||
let active_count = ratings
|
||
.iter()
|
||
.filter(|rating| rating.label != "cleared")
|
||
.count();
|
||
let embedded = embedded_article_ids(db, &config).await?;
|
||
let no_embedding_count = ratings
|
||
.iter()
|
||
.filter(|rating| rating.label != "cleared" && !embedded.contains(&rating.article_id))
|
||
.count();
|
||
let summary_line = format!(
|
||
"{} rated articles with embeddings → neighbour signal at {:.0}% (floor {}, full {}) · {} attributable feed ratings → feed affinity at {:.0}% · {} verdicts in the prompt · {} in the weekly rebuild set",
|
||
preference.rated_with_embeddings,
|
||
preference.knn_gate * 100.0,
|
||
config.curation.ranking.knn_floor,
|
||
config.curation.ranking.knn_full,
|
||
preference.attributable_feed_ratings,
|
||
preference.feed_gate * 100.0,
|
||
active_count.min(config.curation.feedback.verdicts_in_prompt),
|
||
active_count.min(MAX_RATINGS_IN_REBUILD),
|
||
);
|
||
|
||
let usernames_by_id = usernames(db).await?;
|
||
let sources = event_sources(db).await?;
|
||
let last_run = last_real_run(db).await?;
|
||
|
||
let mut page = Page::new("Ratings", Some(viewer), "ratings");
|
||
page.flash = take_flash(&session).await?;
|
||
let mut template = RatingsTemplate {
|
||
page,
|
||
tab: filters.tab.clone(),
|
||
summary_line,
|
||
no_embedding_count,
|
||
how: HowValues::from_config(&config),
|
||
filter_label: filters.label.clone().unwrap_or_default(),
|
||
filter_source: filters.source.clone().unwrap_or_default(),
|
||
filter_feed: filters
|
||
.feed
|
||
.map(|feed| feed.to_string())
|
||
.unwrap_or_default(),
|
||
filter_q: filters.q.clone().unwrap_or_default(),
|
||
filter_user: filters.user.clone().unwrap_or_default(),
|
||
filter_from: filters.from.clone().unwrap_or_default(),
|
||
filter_to: filters.to.clone().unwrap_or_default(),
|
||
sources,
|
||
feeds: Vec::new(),
|
||
usernames: usernames_by_id.values().cloned().collect(),
|
||
current_href: Filters {
|
||
tab: "current".into(),
|
||
..filters.clone()
|
||
}
|
||
.href(None),
|
||
events_href: Filters {
|
||
tab: "events".into(),
|
||
..filters.clone()
|
||
}
|
||
.href(None),
|
||
current: Vec::new(),
|
||
current_total: ratings.len(),
|
||
last_run,
|
||
events: Vec::new(),
|
||
pagination: Pagination {
|
||
page: 1,
|
||
per_page: EVENTS_PER_PAGE,
|
||
total: 0,
|
||
},
|
||
prev_href: String::new(),
|
||
next_href: String::new(),
|
||
};
|
||
|
||
if filters.tab == "events" {
|
||
let (events, total) = load_events(db, &config, &filters, &usernames_by_id).await?;
|
||
template.pagination = Pagination {
|
||
page: filters.page,
|
||
per_page: EVENTS_PER_PAGE,
|
||
total,
|
||
};
|
||
if filters.page > 1 {
|
||
template.prev_href = filters.href(Some(filters.page - 1));
|
||
}
|
||
if filters.page < template.pagination.pages() {
|
||
template.next_href = filters.href(Some(filters.page + 1));
|
||
}
|
||
template.events = events;
|
||
} else {
|
||
let usage = match &template.last_run {
|
||
Some(run) => neighbour_usage(db, run.id).await?,
|
||
None => HashMap::new(),
|
||
};
|
||
let (rows, feeds) = build_current_rows(
|
||
db,
|
||
&config,
|
||
&filters,
|
||
&ratings,
|
||
&embedded,
|
||
&usernames_by_id,
|
||
&usage,
|
||
now,
|
||
)
|
||
.await?;
|
||
template.current = rows;
|
||
template.feeds = feeds;
|
||
}
|
||
Ok(Html(template).into_response())
|
||
}
|
||
|
||
async fn embedded_article_ids(db: &Db, config: &Config) -> Result<HashSet<ArticleId>, WebError> {
|
||
let rows =
|
||
sqlx::query("SELECT article_id FROM article_embeddings WHERE model = ? AND dimension = ?")
|
||
.bind(&config.voyage.model)
|
||
.bind(config.voyage.output_dimension as i64)
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.map_err(DbError::from)?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|row| row.get::<ArticleId, _>("article_id"))
|
||
.collect())
|
||
}
|
||
|
||
async fn usernames(db: &Db) -> Result<BTreeMap<i64, String>, WebError> {
|
||
let rows = sqlx::query("SELECT id, username FROM users ORDER BY username")
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.map_err(DbError::from)?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|row| (row.get::<i64, _>("id"), row.get::<String, _>("username")))
|
||
.collect())
|
||
}
|
||
|
||
async fn event_sources(db: &Db) -> Result<Vec<String>, WebError> {
|
||
let rows = sqlx::query("SELECT DISTINCT source FROM rating_events ORDER BY source")
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.map_err(DbError::from)?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|row| row.get::<String, _>("source"))
|
||
.collect())
|
||
}
|
||
|
||
fn username_for(usernames: &BTreeMap<i64, String>, user_id: Option<i64>, source: &str) -> String {
|
||
match user_id.and_then(|id| usernames.get(&id)) {
|
||
Some(username) => username.clone(),
|
||
None if source == "epub" => "e-reader link".into(),
|
||
None => "—".into(),
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn build_current_rows(
|
||
db: &Db,
|
||
config: &Config,
|
||
filters: &Filters,
|
||
ratings: &[RatedArticle],
|
||
embedded: &HashSet<ArticleId>,
|
||
usernames: &BTreeMap<i64, String>,
|
||
usage: &HashMap<ArticleId, NeighbourUse>,
|
||
now: Timestamp,
|
||
) -> Result<(Vec<CurrentRow>, Vec<FeedOption>), WebError> {
|
||
let next = filters.href(None);
|
||
let mut rows = Vec::new();
|
||
let mut feeds: BTreeMap<FeedId, String> = BTreeMap::new();
|
||
let mut rank = 0usize;
|
||
let ids: Vec<ArticleId> = ratings.iter().map(|rating| rating.article_id).collect();
|
||
let articles = db.get_articles(&ids).await?;
|
||
let sources = event_sources_for(db, &ids).await?;
|
||
for rating in ratings {
|
||
let article = articles.get(&rating.article_id);
|
||
let this_rank = if rating.label == "cleared" {
|
||
None
|
||
} else {
|
||
let current = rank;
|
||
rank += 1;
|
||
Some(current)
|
||
};
|
||
let has_embedding = embedded.contains(&rating.article_id);
|
||
let contribution = contribution(rating, article, has_embedding, this_rank, now, config);
|
||
let direct = article.map(signals::direct_feeds).unwrap_or_default();
|
||
for feed_id in &direct {
|
||
feeds
|
||
.entry(*feed_id)
|
||
.or_insert_with(|| feed_title_for(article, *feed_id));
|
||
}
|
||
|
||
let (badge, _) = widget_label(&rating.label);
|
||
if filters.label.as_deref().is_some_and(|label| label != badge) {
|
||
continue;
|
||
}
|
||
if let Some(feed) = filters.feed
|
||
&& !direct.contains(&feed)
|
||
{
|
||
continue;
|
||
}
|
||
if let Some(q) = &filters.q
|
||
&& !rating.title.to_lowercase().contains(&q.to_lowercase())
|
||
{
|
||
continue;
|
||
}
|
||
let source = sources.get(&rating.article_id).cloned().unwrap_or_default();
|
||
if filters
|
||
.source
|
||
.as_deref()
|
||
.is_some_and(|wanted| wanted != source)
|
||
{
|
||
continue;
|
||
}
|
||
let used = usage.get(&rating.article_id).copied().unwrap_or_default();
|
||
let issue_date = rating
|
||
.issue_date
|
||
.map(|date| date.to_string())
|
||
.unwrap_or_default();
|
||
rows.push(CurrentRow {
|
||
article_id: rating.article_id,
|
||
title: if rating.title.trim().is_empty() {
|
||
format!("article {}", rating.article_id)
|
||
} else {
|
||
rating.title.clone()
|
||
},
|
||
feed_title: rating.feed_title.clone(),
|
||
issue_date: issue_date.clone(),
|
||
badge: badge.to_string(),
|
||
widget: RatingWidget {
|
||
article_id: rating.article_id,
|
||
issue_date,
|
||
next: next.clone(),
|
||
current: badge.to_string(),
|
||
show_note: true,
|
||
},
|
||
when: format_time(rating.event_at, config),
|
||
username: username_for(usernames, rating.user_id, &source),
|
||
source,
|
||
note: rating.note.clone().unwrap_or_default(),
|
||
age_days: format!("{:.0}", contribution.age_days),
|
||
decay: format!("{:.3}", contribution.decay),
|
||
has_embedding,
|
||
neighbour_weight: contribution
|
||
.neighbour_weight
|
||
.map(|weight| format!("{weight:+.3}"))
|
||
.unwrap_or_default(),
|
||
beyond_lookback: contribution.beyond_lookback,
|
||
feed_credits: contribution
|
||
.feed_credits
|
||
.iter()
|
||
.map(|credit| FeedCreditView {
|
||
title: credit.feed_title.clone(),
|
||
credit: format!("{:+.3}", credit.credit),
|
||
})
|
||
.collect(),
|
||
in_prompt: contribution.in_prompt,
|
||
in_rebuild: contribution.in_rebuild,
|
||
used_total: used.total,
|
||
used_selected: used.selected,
|
||
});
|
||
}
|
||
let feeds = feeds
|
||
.into_iter()
|
||
.map(|(id, title)| FeedOption { id, title })
|
||
.collect();
|
||
Ok((rows, feeds))
|
||
}
|
||
|
||
/// The `source` of the event behind each current verdict (`RatedArticle` does
|
||
/// not carry it), for every article of the page in one statement per chunk.
|
||
/// An article with no explicit event is absent from the map; the caller shows
|
||
/// an empty source for it.
|
||
async fn event_sources_for(
|
||
db: &Db,
|
||
ids: &[ArticleId],
|
||
) -> Result<HashMap<ArticleId, String>, WebError> {
|
||
let mut sources = HashMap::with_capacity(ids.len());
|
||
// SQLite's default bound-parameter ceiling is 32 766; stay well under.
|
||
for chunk in ids.chunks(500) {
|
||
// Only the placeholder count is interpolated; every value is bound,
|
||
// which is what `AssertSqlSafe` asserts.
|
||
let placeholders = vec!["?"; chunk.len()].join(", ");
|
||
let mut query = sqlx::query(sqlx::AssertSqlSafe(format!(
|
||
"SELECT article_id, source FROM (
|
||
SELECT article_id, source,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY article_id
|
||
ORDER BY event_at DESC, id DESC
|
||
) AS event_rank
|
||
FROM rating_events
|
||
WHERE kind = 'explicit' AND article_id IN ({placeholders})
|
||
)
|
||
WHERE event_rank = 1"
|
||
)));
|
||
for id in chunk {
|
||
query = query.bind(*id);
|
||
}
|
||
for row in query.fetch_all(db.pool()).await.map_err(DbError::from)? {
|
||
sources.insert(
|
||
row.get::<ArticleId, _>("article_id"),
|
||
row.get::<String, _>("source"),
|
||
);
|
||
}
|
||
}
|
||
Ok(sources)
|
||
}
|
||
|
||
async fn load_events(
|
||
db: &Db,
|
||
config: &Config,
|
||
filters: &Filters,
|
||
usernames: &BTreeMap<i64, String>,
|
||
) -> Result<(Vec<EventRow>, i64), WebError> {
|
||
// Every clause below is fixed text chosen from the allow-listed filters;
|
||
// only values are bound, which is what `AssertSqlSafe` asserts.
|
||
let mut clauses: Vec<&'static str> = Vec::new();
|
||
let mut binds: Vec<String> = Vec::new();
|
||
if let Some(label) = &filters.label
|
||
&& let Some(stored) = event_label(label)
|
||
{
|
||
clauses.push("re.label = ?");
|
||
binds.push(stored.to_string());
|
||
}
|
||
if let Some(source) = &filters.source {
|
||
clauses.push("re.source = ?");
|
||
binds.push(source.clone());
|
||
}
|
||
if let Some(user) = &filters.user {
|
||
clauses.push("u.username = ? COLLATE NOCASE");
|
||
binds.push(user.clone());
|
||
}
|
||
if let Some(from) = &filters.from {
|
||
clauses.push("substr(re.event_at, 1, 10) >= ?");
|
||
binds.push(from.clone());
|
||
}
|
||
if let Some(to) = &filters.to {
|
||
clauses.push("substr(re.event_at, 1, 10) <= ?");
|
||
binds.push(to.clone());
|
||
}
|
||
let where_sql = if clauses.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("WHERE {}", clauses.join(" AND "))
|
||
};
|
||
let base = format!(
|
||
"FROM rating_events re
|
||
LEFT JOIN articles a ON a.id = re.article_id
|
||
LEFT JOIN users u ON u.id = re.user_id
|
||
{where_sql}"
|
||
);
|
||
|
||
let count_sql = format!("SELECT COUNT(*) AS n {base}");
|
||
let mut count = sqlx::query(sqlx::AssertSqlSafe(count_sql));
|
||
for value in &binds {
|
||
count = count.bind(value);
|
||
}
|
||
let total: i64 = count
|
||
.fetch_one(db.pool())
|
||
.await
|
||
.map_err(DbError::from)?
|
||
.get("n");
|
||
|
||
let pagination = Pagination {
|
||
page: filters.page,
|
||
per_page: EVENTS_PER_PAGE,
|
||
total,
|
||
};
|
||
let sql = format!(
|
||
"SELECT re.id, re.article_id, re.issue_date, re.kind, re.source, re.label, re.value,
|
||
re.note, re.event_at, re.user_id, COALESCE(a.title, '') AS title,
|
||
EXISTS (
|
||
SELECT 1 FROM rating_events later
|
||
WHERE later.article_id = re.article_id AND later.kind = 'explicit'
|
||
AND (later.event_at > re.event_at
|
||
OR (later.event_at = re.event_at AND later.id > re.id))
|
||
) AS superseded
|
||
{base}
|
||
ORDER BY re.event_at DESC, re.id DESC
|
||
LIMIT ? OFFSET ?"
|
||
);
|
||
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql));
|
||
for value in &binds {
|
||
query = query.bind(value);
|
||
}
|
||
let rows = query
|
||
.bind(i64::from(EVENTS_PER_PAGE))
|
||
.bind(pagination.offset())
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.map_err(DbError::from)?;
|
||
let mut events = Vec::with_capacity(rows.len());
|
||
for row in rows {
|
||
let label: String = row.get("label");
|
||
let (badge, verdict) = widget_label(&label);
|
||
let source: String = row.get("source");
|
||
let event_at =
|
||
crate::db::parse_ts("rating_events.event_at", &row.get::<String, _>("event_at"))?;
|
||
let article_id: ArticleId = row.get("article_id");
|
||
let title: String = row.get("title");
|
||
events.push(EventRow {
|
||
id: row.get("id"),
|
||
article_id,
|
||
title: if title.trim().is_empty() {
|
||
format!("article {article_id}")
|
||
} else {
|
||
title
|
||
},
|
||
issue_date: row
|
||
.get::<Option<String>, _>("issue_date")
|
||
.unwrap_or_default(),
|
||
kind: row.get("kind"),
|
||
badge: badge.to_string(),
|
||
verdict: if badge.is_empty() {
|
||
label
|
||
} else {
|
||
verdict.to_string()
|
||
},
|
||
value: format!("{:+.2}", row.get::<f64, _>("value")),
|
||
when: format_time(event_at, config),
|
||
username: username_for(usernames, row.get("user_id"), &source),
|
||
source,
|
||
note: row.get::<Option<String>, _>("note").unwrap_or_default(),
|
||
superseded: row.get::<bool, _>("superseded"),
|
||
});
|
||
}
|
||
Ok((events, total))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use axum::body::{Body, to_bytes};
|
||
use axum::http::{Method, Request, StatusCode, header};
|
||
use tower::ServiceExt;
|
||
|
||
use super::*;
|
||
use crate::types::{Entry, RatingEvent, SourceKind, SourceRef};
|
||
use crate::web::users;
|
||
|
||
fn rated(article_id: ArticleId, label: &str, value: f64, event_at: &str) -> RatedArticle {
|
||
RatedArticle {
|
||
article_id,
|
||
user_id: None,
|
||
issue_date: None,
|
||
title: format!("Article {article_id}"),
|
||
feed_title: "Example Feed".into(),
|
||
summary: None,
|
||
facets: None,
|
||
note: None,
|
||
value,
|
||
label: label.into(),
|
||
event_at: event_at.parse().unwrap(),
|
||
}
|
||
}
|
||
|
||
fn two_feed_article() -> Article {
|
||
let mut article = crate::epub::fixtures::article(1, 1001, "Two feeds");
|
||
article.sources.push(SourceRef {
|
||
entry_id: 1002,
|
||
feed_id: 9,
|
||
feed_title: "Second Feed".into(),
|
||
category: None,
|
||
kind: SourceKind::Feed,
|
||
});
|
||
article.sources.push(SourceRef {
|
||
entry_id: 1003,
|
||
feed_id: 11,
|
||
feed_title: "Scour".into(),
|
||
category: None,
|
||
kind: SourceKind::Scour,
|
||
});
|
||
article
|
||
}
|
||
|
||
#[test]
|
||
fn contribution_matches_hand_checked_values() {
|
||
let config = Config::default();
|
||
let now: Timestamp = "2026-09-03T00:00:00Z".parse().unwrap();
|
||
// Loved 60 days ago with the 60-day half-life: decay 0.5, weight 0.5,
|
||
// split over the two direct feeds (the Scour source is not direct).
|
||
let rating = rated(1, "loved", 1.0, "2026-07-05T00:00:00Z");
|
||
let article = two_feed_article();
|
||
let c = contribution(&rating, Some(&article), true, Some(59), now, &config);
|
||
assert!((c.age_days - 60.0).abs() < 1e-9);
|
||
assert!((c.decay - 0.5).abs() < 1e-9);
|
||
assert_eq!(c.neighbour_weight, Some(0.5));
|
||
assert!(!c.beyond_lookback);
|
||
let credits = c
|
||
.feed_credits
|
||
.iter()
|
||
.map(|credit| (credit.feed_id, credit.feed_title.as_str(), credit.credit))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
credits,
|
||
[(7, "Example Feed", 0.25), (9, "Second Feed", 0.25)]
|
||
);
|
||
assert!(c.in_prompt && c.in_rebuild);
|
||
|
||
// Rank 60 falls out of the 60-line prompt block but stays in the
|
||
// 200-item rebuild set; rank 200 is in neither.
|
||
let c = contribution(&rating, Some(&article), true, Some(60), now, &config);
|
||
assert!(!c.in_prompt && c.in_rebuild);
|
||
let c = contribution(&rating, Some(&article), true, Some(200), now, &config);
|
||
assert!(!c.in_prompt && !c.in_rebuild);
|
||
|
||
// Not-for-me 120 days ago: decay 0.25, weight −0.25, one direct feed
|
||
// gets the whole (negative) credit; no embedding → no neighbour weight.
|
||
let rating = rated(2, "not_for_me", -1.0, "2026-05-06T00:00:00Z");
|
||
let article = crate::epub::fixtures::article(2, 1002, "One feed");
|
||
let c = contribution(&rating, Some(&article), false, Some(0), now, &config);
|
||
assert!((c.decay - 0.25).abs() < 1e-9);
|
||
assert_eq!(c.neighbour_weight, None);
|
||
assert_eq!(c.feed_credits.len(), 1);
|
||
assert!((c.feed_credits[0].credit + 0.25).abs() < 1e-9);
|
||
|
||
// Cleared verdicts contribute nothing anywhere.
|
||
let rating = rated(3, "cleared", 0.0, "2026-09-02T00:00:00Z");
|
||
let c = contribution(&rating, Some(&article), true, None, now, &config);
|
||
assert_eq!(c.neighbour_weight, None);
|
||
assert!(c.feed_credits.is_empty());
|
||
assert!(!c.in_prompt && !c.in_rebuild);
|
||
|
||
// Older than the 180-day lookback: flagged, weight still shown.
|
||
let rating = rated(4, "good", 0.35, "2026-01-01T00:00:00Z");
|
||
let c = contribution(&rating, Some(&article), true, Some(1), now, &config);
|
||
assert!(c.beyond_lookback);
|
||
assert!(c.neighbour_weight.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn filters_parse_leniently_and_round_trip_into_hrefs() {
|
||
let filters = Filters::parse(RatingsQuery {
|
||
tab: Some("events".into()),
|
||
label: Some("bogus".into()),
|
||
source: Some("cli".into()),
|
||
feed: Some("x".into()),
|
||
q: Some(" Postgres ".into()),
|
||
user: None,
|
||
from: Some("2026-01-01".into()),
|
||
to: Some("not a date".into()),
|
||
page: Some("3".into()),
|
||
});
|
||
assert_eq!(filters.tab, "events");
|
||
assert_eq!(filters.label, None);
|
||
assert_eq!(filters.source.as_deref(), Some("cli"));
|
||
assert_eq!(filters.feed, None);
|
||
assert_eq!(filters.q.as_deref(), Some("Postgres"));
|
||
assert_eq!(filters.from.as_deref(), Some("2026-01-01"));
|
||
assert_eq!(filters.to, None);
|
||
assert_eq!(filters.page, 3);
|
||
assert_eq!(
|
||
filters.href(Some(2)),
|
||
"/dashboard/ratings?tab=events&source=cli&q=Postgres&from=2026-01-01&page=2"
|
||
);
|
||
assert_eq!(Filters::parse(RatingsQuery::default()).tab, "current");
|
||
}
|
||
|
||
async fn seed_article(db: &Db, id: ArticleId, entry_id: i64, title: &str) -> ArticleId {
|
||
let article = crate::epub::fixtures::article(id, entry_id, title);
|
||
db.upsert_entry(&Entry {
|
||
id: article.best_entry_id,
|
||
feed_id: article.feed_id,
|
||
feed_title: Some(article.feed_title.clone()),
|
||
category: article.category.clone(),
|
||
title: article.title.clone(),
|
||
url: article.url.clone(),
|
||
canonical_url: Some(article.canonical_url.clone()),
|
||
author: article.author.clone(),
|
||
published_at: article.published_at,
|
||
comments_url: article.comments_url.clone(),
|
||
raw_content: article.content_html.clone(),
|
||
fetched_at: article.first_seen,
|
||
})
|
||
.await
|
||
.unwrap();
|
||
db.upsert_article(&article).await.unwrap()
|
||
}
|
||
|
||
async fn seed_event(
|
||
db: &Db,
|
||
article_id: ArticleId,
|
||
label: &str,
|
||
value: f64,
|
||
source: &str,
|
||
user_id: Option<i64>,
|
||
event_at: &str,
|
||
) -> i64 {
|
||
db.append_rating_event(&RatingEvent {
|
||
id: 0,
|
||
user_id,
|
||
article_id,
|
||
issue_date: None,
|
||
kind: "explicit".into(),
|
||
source: source.into(),
|
||
label: label.into(),
|
||
value,
|
||
note: Some(format!("note for {article_id}")),
|
||
event_at: event_at.parse().unwrap(),
|
||
})
|
||
.await
|
||
.unwrap()
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn neighbour_usage_counts_candidates_and_selected_ones() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||
.await
|
||
.unwrap();
|
||
for id in 1..=5 {
|
||
seed_article(&db, id, 1000 + id, &format!("Article {id}")).await;
|
||
}
|
||
let run_id = db
|
||
.start_run("2026-09-03".parse().unwrap(), Timestamp::now())
|
||
.await
|
||
.unwrap();
|
||
sqlx::query("UPDATE runs SET status = 'ok' WHERE id = ?")
|
||
.bind(run_id)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let neighbours = |ids: &[ArticleId]| {
|
||
let list = ids
|
||
.iter()
|
||
.map(|id| {
|
||
format!(
|
||
r#"{{"article_id":{id},"label":"loved","cos":0.8,"title":"Article {id}"}}"#
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
format!(r#"{{"v":1,"neighbours":[{list}]}}"#)
|
||
};
|
||
for (article_id, stage, json) in [
|
||
(3, "selected", neighbours(&[1, 2])),
|
||
(4, "assessed", neighbours(&[1])),
|
||
(5, "eligible", r#"{"v":1,"neighbours":[]}"#.to_string()),
|
||
] {
|
||
sqlx::query(
|
||
"INSERT INTO candidate_runs (run_id, article_id, stage, signals_json)
|
||
VALUES (?, ?, ?, ?)",
|
||
)
|
||
.bind(run_id)
|
||
.bind(article_id)
|
||
.bind(stage)
|
||
.bind(json)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
}
|
||
let usage = neighbour_usage(&db, run_id).await.unwrap();
|
||
assert_eq!(
|
||
usage.get(&1),
|
||
Some(&NeighbourUse {
|
||
total: 2,
|
||
selected: 1
|
||
})
|
||
);
|
||
assert_eq!(
|
||
usage.get(&2),
|
||
Some(&NeighbourUse {
|
||
total: 1,
|
||
selected: 1
|
||
})
|
||
);
|
||
assert_eq!(usage.get(&3), None);
|
||
let last = last_real_run(&db).await.unwrap().unwrap();
|
||
assert_eq!(last.id, run_id);
|
||
assert_eq!(last.date, "2026-09-03");
|
||
}
|
||
|
||
async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String {
|
||
let response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method(Method::POST)
|
||
.uri("/login")
|
||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||
.header("sec-fetch-site", "same-origin")
|
||
.header("x-forwarded-for", "192.0.2.44")
|
||
.body(Body::from(format!(
|
||
"username={username}&password={password}&next=%2F"
|
||
)))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||
response
|
||
.headers()
|
||
.get(header::SET_COOKIE)
|
||
.unwrap()
|
||
.to_str()
|
||
.unwrap()
|
||
.split(';')
|
||
.next()
|
||
.unwrap()
|
||
.to_string()
|
||
}
|
||
|
||
async fn get(app: &axum::Router, uri: &str, cookie: Option<&str>) -> Response {
|
||
let mut request = Request::builder().uri(uri);
|
||
if let Some(cookie) = cookie {
|
||
request = request.header(header::COOKIE, cookie);
|
||
}
|
||
app.clone()
|
||
.oneshot(request.body(Body::empty()).unwrap())
|
||
.await
|
||
.unwrap()
|
||
}
|
||
|
||
async fn text(response: Response) -> String {
|
||
String::from_utf8(
|
||
to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||
.await
|
||
.unwrap()
|
||
.to_vec(),
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn ratings_page_renders_current_and_events_tabs() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||
.await
|
||
.unwrap();
|
||
let admin = users::add(&db, "tyler", "correct horse battery", true)
|
||
.await
|
||
.unwrap();
|
||
let first = seed_article(&db, 1, 1001, "Postgres failover story").await;
|
||
let second = seed_article(&db, 2, 1002, "A listicle").await;
|
||
let third = seed_article(&db, 3, 1003, "Cleared later").await;
|
||
seed_event(
|
||
&db,
|
||
first,
|
||
"good",
|
||
0.35,
|
||
"cli",
|
||
None,
|
||
"2026-08-01T00:00:00Z",
|
||
)
|
||
.await;
|
||
seed_event(
|
||
&db,
|
||
first,
|
||
"loved",
|
||
1.0,
|
||
"dashboard",
|
||
Some(admin.id),
|
||
"2026-08-20T00:00:00Z",
|
||
)
|
||
.await;
|
||
seed_event(
|
||
&db,
|
||
second,
|
||
"not_for_me",
|
||
-1.0,
|
||
"epub",
|
||
None,
|
||
"2026-08-21T00:00:00Z",
|
||
)
|
||
.await;
|
||
seed_event(
|
||
&db,
|
||
third,
|
||
"loved",
|
||
1.0,
|
||
"cli",
|
||
None,
|
||
"2026-08-22T00:00:00Z",
|
||
)
|
||
.await;
|
||
seed_event(
|
||
&db,
|
||
third,
|
||
"cleared",
|
||
0.0,
|
||
"cli",
|
||
None,
|
||
"2026-08-23T00:00:00Z",
|
||
)
|
||
.await;
|
||
// The first article has an embedding of the configured shape.
|
||
let config = Config::default();
|
||
let blob =
|
||
crate::curate::embedding::encode_blob(&vec![0.01; config.voyage.output_dimension])
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO article_embeddings (article_id, model, dimension, input_hash, embedding, created_at)
|
||
VALUES (?, ?, ?, 'hash', ?, '2026-08-20T00:00:00Z')",
|
||
)
|
||
.bind(first)
|
||
.bind(&config.voyage.model)
|
||
.bind(config.voyage.output_dimension as i64)
|
||
.bind(blob)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let state = AppState::new(db, config, None);
|
||
let app = crate::server::router(state);
|
||
let cookie = login_cookie(&app, "tyler", "correct horse battery").await;
|
||
|
||
let current = get(&app, "/dashboard/ratings", Some(&cookie)).await;
|
||
assert_eq!(current.status(), StatusCode::OK);
|
||
let body = text(current).await;
|
||
assert!(body.contains("1 rated articles with embeddings"));
|
||
assert!(body.contains("1 rated articles have no embedding"));
|
||
assert!(body.contains("Postgres failover story"));
|
||
assert!(body.contains("A listicle"));
|
||
assert!(body.contains("Cleared later"));
|
||
assert!(body.contains(">tyler<"));
|
||
assert!(body.contains("no embedding"));
|
||
assert!(body.contains("note for 1"));
|
||
assert!(body.contains("How ratings enter the algorithm"));
|
||
assert!(body.contains("/dashboard/articles/1"));
|
||
assert!(body.contains(r#"name="note""#));
|
||
assert!(body.contains("/dashboard/settings#curation.feedback"));
|
||
|
||
let filtered = get(&app, "/dashboard/ratings?label=down", Some(&cookie)).await;
|
||
let body = text(filtered).await;
|
||
assert!(body.contains("A listicle"));
|
||
assert!(!body.contains("Postgres failover story"));
|
||
let searched = get(&app, "/dashboard/ratings?q=postgres", Some(&cookie)).await;
|
||
let body = text(searched).await;
|
||
assert!(body.contains("Postgres failover story"));
|
||
assert!(!body.contains("A listicle"));
|
||
|
||
let by_source_current =
|
||
get(&app, "/dashboard/ratings?source=dashboard", Some(&cookie)).await;
|
||
let body = text(by_source_current).await;
|
||
assert!(body.contains("Postgres failover story"));
|
||
assert!(!body.contains("A listicle"));
|
||
|
||
let events = get(&app, "/dashboard/ratings?tab=events", Some(&cookie)).await;
|
||
assert_eq!(events.status(), StatusCode::OK);
|
||
let body = text(events).await;
|
||
assert_eq!(body.matches(r#"class="superseded""#).count(), 2, "{body}");
|
||
assert!(body.contains("e-reader link"));
|
||
let by_source = get(
|
||
&app,
|
||
"/dashboard/ratings?tab=events&source=dashboard",
|
||
Some(&cookie),
|
||
)
|
||
.await;
|
||
let body = text(by_source).await;
|
||
assert!(body.contains("Postgres failover story"));
|
||
assert!(!body.contains("A listicle"));
|
||
let by_date = get(
|
||
&app,
|
||
"/dashboard/ratings?tab=events&from=2026-08-22&to=2026-08-22",
|
||
Some(&cookie),
|
||
)
|
||
.await;
|
||
let body = text(by_date).await;
|
||
assert!(body.contains("Cleared later"));
|
||
assert!(!body.contains("A listicle"));
|
||
let by_user = get(
|
||
&app,
|
||
"/dashboard/ratings?tab=events&user=tyler",
|
||
Some(&cookie),
|
||
)
|
||
.await;
|
||
let body = text(by_user).await;
|
||
assert!(body.contains("Postgres failover story"));
|
||
assert!(!body.contains("A listicle"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn event_sources_take_the_newest_explicit_event() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||
.await
|
||
.unwrap();
|
||
let rated = seed_article(&db, 1, 1001, "Rated twice").await;
|
||
let unrated = seed_article(&db, 2, 1002, "Never rated").await;
|
||
seed_event(
|
||
&db,
|
||
rated,
|
||
"good",
|
||
0.35,
|
||
"cli",
|
||
None,
|
||
"2026-08-01T00:00:00Z",
|
||
)
|
||
.await;
|
||
seed_event(
|
||
&db,
|
||
rated,
|
||
"loved",
|
||
1.0,
|
||
"dashboard",
|
||
None,
|
||
"2026-08-20T00:00:00Z",
|
||
)
|
||
.await;
|
||
let sources = event_sources_for(&db, &[rated, unrated]).await.unwrap();
|
||
assert_eq!(sources.get(&rated).map(String::as_str), Some("dashboard"));
|
||
assert_eq!(sources.get(&unrated), None);
|
||
assert!(event_sources_for(&db, &[]).await.unwrap().is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn ratings_page_is_admin_only() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||
.await
|
||
.unwrap();
|
||
users::add(&db, "reader", "correct horse battery", false)
|
||
.await
|
||
.unwrap();
|
||
let app = crate::server::router(AppState::new(db, Config::default(), None));
|
||
let anonymous = get(&app, "/dashboard/ratings", None).await;
|
||
assert_eq!(anonymous.status(), StatusCode::FOUND);
|
||
assert_eq!(
|
||
anonymous.headers().get(header::LOCATION).unwrap(),
|
||
"/login?next=%2Fdashboard%2Fratings"
|
||
);
|
||
let reader = login_cookie(&app, "reader", "correct horse battery").await;
|
||
let forbidden = get(&app, "/dashboard/ratings?tab=events", Some(&reader)).await;
|
||
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
|
||
}
|
||
}
|