Files
the-daily-epub/src/web/dashboard/mod.rs
T
thalladaandClaude Fable 5.1 87ff3d92c8 Let public visitors request a reader account
New public page GET/POST /request-access explains what a signed-in
reader account gets (complete issues online, EPUB/XTC downloads, no
ratings or admin tools) and takes an email plus an optional reason. A
honeypot field drops bots and a partial unique index keeps one open
request per email, updating it on resubmit. Open requests show on
/dashboard/users with a "Mark done" button and as a tile on the
overview; accounts are still created with the daily-epub users CLI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-06 18:02:18 +00:00

1237 lines
41 KiB
Rust

//! The operator dashboard (`/dashboard/*`, dashboard plan §9–§14).
//!
//! Every route here sits under the admin `permission_required!` layer that
//! `web::router` applies to the merged router; handlers can therefore trust
//! that `AuthSession::user()` is an admin. One submodule per page group; each
//! exposes `routes()` and this module merges them.
//!
//! This file also carries the small helpers the read-only pages share: the
//! pager, number/time formatting, the `signals_json` view behind
//! `_signals_table.html`, the allow-list check and the LIKE-pattern escaper.
pub mod articles;
pub mod jobs;
pub mod profile;
pub mod ratings;
pub mod runs;
pub mod settings;
pub mod stats;
pub mod users;
use askama::Template;
use axum::Router;
use axum::extract::{Extension, State};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use jiff::Timestamp;
use sqlx::Row as _;
use crate::config::Config;
use crate::curate::telemetry::SignalsJson;
use crate::db::Db;
use crate::report::RunReport;
use crate::server::AppState;
use crate::types::ArticleId;
use crate::web::rate::RatingWidget;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, Pagination, WebError, encode_component, format_time, take_flash};
/// Every dashboard route, without the admin layer (applied by the caller).
pub fn router() -> Router<AppState> {
Router::new()
.route("/dashboard", get(overview))
.merge(runs::routes())
.merge(articles::routes())
.merge(ratings::routes())
.merge(profile::routes())
.merge(settings::routes())
.merge(jobs::routes())
.merge(stats::routes())
.merge(users::routes())
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Signal names in the order of curation plan §7.5.
pub const SIGNAL_NAMES: [&str; 8] = [
"interest",
"knn",
"feed",
"social",
"heuristic",
"triage",
"quality",
"fit",
];
/// `candidate_runs.stage` values in pipeline order (curation plan §7.4).
pub const STAGES: [&str; 7] = [
"excluded",
"eligible",
"triaged",
"admitted",
"assessed",
"shortlisted",
"selected",
];
/// `candidate_runs.excluded_reason` values (curation plan §7.4).
pub const REASONS: [&str; 8] = [
"blocked",
"published_before",
"recently_rejected",
"not_admitted",
"cluster_suppressed",
"shortlist_cap",
"not_selected",
"over_max",
];
/// Retriever names that can head `admitted_by` (curation plan §11).
pub const RETRIEVERS: [&str; 6] = [
"auto_include",
"triage",
"interest",
"knn",
"exploration",
"blend",
];
/// A raw `sqlx` error as the dashboard's error type.
pub(crate) fn db_err(error: sqlx::Error) -> WebError {
WebError::Db(error.into())
}
/// `?page=N`, clamped to at least 1.
pub fn page_number(raw: Option<u32>) -> u32 {
raw.unwrap_or(1).max(1)
}
/// Keep a query value only when it is one of `allowed`; anything else is
/// dropped (never an error, never interpolated).
pub fn allow_listed<'a>(value: Option<&'a str>, allowed: &[&str]) -> Option<&'a str> {
value.filter(|value| allowed.contains(value))
}
/// A non-empty, trimmed query value.
pub fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
/// `%…%` with the LIKE metacharacters escaped, for `LIKE ? ESCAPE '\'`.
pub fn like_pattern(needle: &str) -> String {
let mut pattern = String::with_capacity(needle.len() + 2);
pattern.push('%');
for ch in needle.chars() {
if matches!(ch, '%' | '_' | '\\') {
pattern.push('\\');
}
pattern.push(ch);
}
pattern.push('%');
pattern
}
/// A bound query parameter for the dynamically assembled list queries. The
/// SQL text only ever contains `?` placeholders and allow-listed fragments.
#[derive(Debug, Clone)]
pub enum Bind {
Text(String),
Int(i64),
}
pub type SqliteQuery<'q> = sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments>;
/// A query assembled at runtime. Audited: every caller builds `sql` from
/// string constants and allow-listed fragments only, and passes user values
/// through [`bind_all`] as bound parameters.
pub fn dynamic_query<'q>(sql: String) -> SqliteQuery<'q> {
sqlx::query(sqlx::AssertSqlSafe(sql))
}
pub fn bind_all<'q>(mut query: SqliteQuery<'q>, values: &[Bind]) -> SqliteQuery<'q> {
for value in values {
query = match value {
Bind::Text(text) => query.bind(text.clone()),
Bind::Int(number) => query.bind(*number),
};
}
query
}
/// Prev/next links for a paginated table. `path` plus every query parameter
/// except `page` is preserved.
#[derive(Debug, Clone)]
pub struct Pager {
pub page: u32,
pub pages: u32,
pub total: i64,
pub prev_href: Option<String>,
pub next_href: Option<String>,
}
impl Pager {
pub fn new(pagination: Pagination, path: &str, params: &[(&str, Option<String>)]) -> Self {
let query: Vec<String> = params
.iter()
.filter_map(|(key, value)| {
value
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| format!("{key}={}", encode_component(value)))
})
.collect();
let href = |page: u32| {
let mut parts = query.clone();
parts.push(format!("page={page}"));
format!("{path}?{}", parts.join("&"))
};
let pages = pagination.pages();
Self {
page: pagination.page,
pages,
total: pagination.total,
prev_href: (pagination.page > 1).then(|| href(pagination.page - 1)),
next_href: (pagination.page < pages).then(|| href(pagination.page + 1)),
}
}
}
pub fn fmt_usd(value: f64) -> String {
format!("${value:.2}")
}
pub fn fmt_opt(value: Option<f64>, decimals: usize) -> String {
value
.map(|value| format!("{value:.decimals$}"))
.unwrap_or_else(|| "—".into())
}
pub fn fmt_opt_int(value: Option<i64>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "—".into())
}
/// A stored RFC3339 timestamp rendered in the configured zone; a malformed
/// value is shown as stored rather than hidden.
pub fn fmt_stored_time(raw: Option<&str>, config: &Config) -> String {
match raw {
Some(raw) => raw
.parse::<Timestamp>()
.map(|timestamp| format_time(timestamp, config))
.unwrap_or_else(|_| raw.to_string()),
None => "—".into(),
}
}
pub fn duration_between(started: &str, finished: Option<&str>) -> Option<i64> {
let started = started.parse::<Timestamp>().ok()?;
let finished = finished?.parse::<Timestamp>().ok()?;
Some((finished.as_second() - started.as_second()).max(0))
}
pub fn fmt_duration(secs: Option<i64>) -> String {
secs.map(RunReport::format_duration)
.unwrap_or_else(|| "—".into())
}
/// `admitted_by[0]` plus the rest, from the stored JSON array.
pub fn admitted_by_parts(raw: Option<&str>) -> (Option<String>, Vec<String>) {
let mut names = raw
.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
.unwrap_or_default()
.into_iter();
let first = names.next();
(first, names.collect())
}
/// The widget's label for a stored `rating_events.label`.
pub fn widget_label(label: Option<&str>) -> &'static str {
match label {
Some("not_for_me" | "down") => "down",
Some("loved") => "loved",
Some("good") => "good",
Some("cleared") => "cleared",
_ => "",
}
}
/// One line of `_signals_table.html`.
#[derive(Debug, Clone)]
pub struct SignalLine {
pub name: &'static str,
pub raw: String,
pub norm: String,
pub weight: String,
pub present: bool,
}
#[derive(Debug, Clone)]
pub struct InterestLine {
pub name: String,
pub z: String,
pub cos: String,
}
#[derive(Debug, Clone)]
pub struct NeighbourLine {
pub article_id: ArticleId,
pub label: String,
pub cos: String,
pub title: String,
}
/// The parsed `signals_json` of one row, shaped for the partial.
#[derive(Debug, Clone, Default)]
pub struct SignalsView {
pub lines: Vec<SignalLine>,
pub blend: String,
pub top1_cos: Option<String>,
pub top_interests: Vec<InterestLine>,
pub neighbours: Vec<NeighbourLine>,
pub exploration: bool,
pub auto_include: bool,
pub notes: Vec<String>,
/// A thin hygiene row (`{}`) or unparseable JSON: nothing to show.
pub empty: bool,
}
impl SignalsView {
pub fn from_json(raw: &str) -> Self {
let Some(signals) = serde_json::from_str::<SignalsJson>(raw).ok() else {
return Self {
empty: true,
..Self::default()
};
};
Self::from_signals(&signals)
}
pub fn from_signals(signals: &SignalsJson) -> Self {
let lines = SIGNAL_NAMES
.into_iter()
.map(|name| SignalLine {
name,
raw: fmt_opt(signals.raw.get(name).copied(), 3),
norm: fmt_opt(signals.norm.get(name).copied(), 3),
weight: fmt_opt(signals.weights.get(name).copied(), 3),
present: signals.present.get(name).copied().unwrap_or(false),
})
.collect();
let empty = signals.present.is_empty()
&& signals.raw.is_empty()
&& signals.top_interests.is_empty()
&& signals.neighbours.is_empty()
&& signals.notes.is_empty();
Self {
lines,
blend: fmt_opt(signals.blend(), 1),
top1_cos: signals
.raw
.get("interest_top1_cos")
.map(|cos| format!("{cos:.3}")),
top_interests: signals
.top_interests
.iter()
.map(|interest| InterestLine {
name: interest.name.clone(),
z: format!("{:.2}", interest.z),
cos: format!("{:.3}", interest.cos),
})
.collect(),
neighbours: signals
.neighbours
.iter()
.map(|neighbour| NeighbourLine {
article_id: neighbour.article_id,
label: neighbour.label.clone(),
cos: format!("{:.3}", neighbour.cos),
title: neighbour.title.clone(),
})
.collect(),
exploration: signals.exploration,
auto_include: signals.auto_include,
notes: signals.notes.clone(),
empty,
}
}
}
// ---------------------------------------------------------------------------
// Overview (§9.1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct LastRunCard {
id: i64,
date: String,
status: String,
started: String,
duration: String,
lines: Vec<String>,
warnings: usize,
error: Option<String>,
}
#[derive(Debug, Clone)]
struct BudgetLine {
provider: String,
spent: String,
ceiling: String,
/// `<meter>` value and max; `max` is 1 when there is no ceiling.
value: f64,
max: f64,
over: bool,
}
#[derive(Debug, Clone)]
struct LabelCount {
label: String,
count: i64,
}
#[derive(Debug, Clone)]
struct UnratedPick {
title: String,
feed: String,
issue_href: String,
issue_date: String,
widget: RatingWidget,
}
#[derive(Debug, Clone)]
struct JobLine {
id: i64,
name: String,
status: String,
requested: String,
finished: String,
message: Option<String>,
}
#[derive(Template)]
#[template(path = "dashboard/overview.html")]
struct OverviewTemplate {
page: Page,
last_run: Option<LastRunCard>,
budget: Vec<BudgetLine>,
ratings: Vec<LabelCount>,
ratings_total: i64,
access_requests: i64,
unrated: Vec<UnratedPick>,
active_jobs: Vec<JobLine>,
finished_jobs: Vec<JobLine>,
config_warnings: Vec<String>,
sparklines: Vec<stats::Sparkline>,
}
/// `GET /dashboard` — the overview (§9.1).
async fn overview(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
) -> Result<Response, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let config = state.config();
let now = Timestamp::now();
let db = &state.db;
let last_run = last_run_card(db, &config).await?;
let budget = budget_lines(db, &config, now).await?;
let (ratings, ratings_total) = ratings_this_week(db, now).await?;
let access_requests: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM account_requests WHERE status = 'open'")
.fetch_one(db.pool())
.await
.map_err(db_err)?;
let unrated = unrated_picks(db).await?;
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
let sparklines = overview_sparklines(db).await?;
let config_warnings = config
.check_report(state.config_path.as_deref())
.into_iter()
.filter(|line| line.starts_with("! "))
.collect();
let mut page = Page::new("Overview", viewer, "dashboard");
page.flash = take_flash(&session).await?;
Ok(Html(OverviewTemplate {
page,
last_run,
budget,
ratings,
ratings_total,
access_requests,
unrated,
active_jobs,
finished_jobs,
config_warnings,
sparklines,
})
.into_response())
}
/// How many finished non-dry runs the overview sparklines cover (§9.1).
const SPARKLINE_RUNS: i64 = 30;
/// Cost per run, selected per run and generation seconds over the last 30
/// non-dry runs (§9.1), drawn by `dashboard/_sparkline.html`.
async fn overview_sparklines(db: &Db) -> Result<Vec<stats::Sparkline>, WebError> {
let runs = crate::curate::telemetry::run_series(db, None, Some(SPARKLINE_RUNS))
.await
.map_err(db_err)?;
let labels = (
runs.first().map(|run| run.date.as_str()).unwrap_or(""),
runs.last().map(|run| run.date.as_str()).unwrap_or(""),
);
let costs: Vec<f64> = runs.iter().map(|run| run.cost_usd).collect();
let selected: Vec<f64> = runs.iter().map(|run| run.selected as f64).collect();
let seconds: Vec<f64> = runs
.iter()
.map(|run| run.duration_secs.unwrap_or(0) as f64)
.collect();
Ok(vec![
stats::Sparkline::line("Cost per run", &costs, labels, "runs", fmt_usd),
stats::Sparkline::line("Selected per run", &selected, labels, "runs", |n| {
format!("{n:.0}")
}),
stats::Sparkline::line("Generation time", &seconds, labels, "runs", |secs| {
RunReport::format_duration(secs as i64)
}),
])
}
async fn last_run_card(db: &Db, config: &Config) -> Result<Option<LastRunCard>, WebError> {
let Some(row) = sqlx::query(
"SELECT id, date, status, started_at, finished_at, entries_fetched, candidates,
selected, cost_usd, error, report_json
FROM runs ORDER BY id DESC LIMIT 1",
)
.fetch_optional(db.pool())
.await
.map_err(db_err)?
else {
return Ok(None);
};
let started_at: String = row.get("started_at");
let finished_at: Option<String> = row.get("finished_at");
let duration = duration_between(&started_at, finished_at.as_deref());
let report = row
.get::<Option<String>, _>("report_json")
.and_then(|raw| serde_json::from_str::<RunReport>(&raw).ok());
let (lines, warnings) = match &report {
Some(report) => (report.info_block().to_vec(), report.warnings.len()),
None => (
vec![
format!(
"curation: {} entries → {} candidates → {} selected",
row.get::<i64, _>("entries_fetched"),
row.get::<i64, _>("candidates"),
row.get::<i64, _>("selected")
),
format!(
"providers: total {} · {}",
fmt_usd(row.get::<f64, _>("cost_usd")),
fmt_duration(duration)
),
],
0,
),
};
Ok(Some(LastRunCard {
id: row.get("id"),
date: row.get("date"),
status: row.get("status"),
started: fmt_stored_time(Some(&started_at), config),
duration: fmt_duration(duration),
lines,
warnings,
error: row.get("error"),
}))
}
async fn budget_lines(
db: &Db,
config: &Config,
now: Timestamp,
) -> Result<Vec<BudgetLine>, WebError> {
// Every run that started today (UTC) began before `now`, so this already
// includes the last run when it ran today.
let spent = db.provider_spend_for_utc_day(now).await?;
let mut providers: Vec<(String, f64)> = config
.referenced_providers()
.into_iter()
.map(|(name, provider)| (name.to_string(), provider.max_daily_usd))
.collect();
if config.voyage.enabled {
providers.push((
crate::report::VOYAGE_PROVIDER.to_string(),
config.voyage.max_daily_usd,
));
}
Ok(providers
.into_iter()
.map(|(name, ceiling)| {
let used = spent.get(&name).copied().unwrap_or(0.0);
BudgetLine {
provider: name,
spent: fmt_usd(used),
ceiling: if ceiling > 0.0 {
fmt_usd(ceiling)
} else {
"no ceiling".into()
},
value: used.max(0.0),
max: if ceiling > 0.0 { ceiling } else { 1.0 },
over: ceiling > 0.0 && used >= ceiling,
}
})
.collect())
}
async fn ratings_this_week(db: &Db, now: Timestamp) -> Result<(Vec<LabelCount>, i64), WebError> {
let since = now
.checked_sub(jiff::Span::new().hours(7 * 24))
.unwrap_or(Timestamp::UNIX_EPOCH);
let rows = sqlx::query(
"SELECT label, COUNT(*) AS n FROM rating_events
WHERE kind = 'explicit' AND event_at >= ? GROUP BY label ORDER BY label",
)
.bind(crate::db::fmt_ts(since))
.fetch_all(db.pool())
.await
.map_err(db_err)?;
let counts: Vec<LabelCount> = rows
.iter()
.map(|row| LabelCount {
label: row.get("label"),
count: row.get("n"),
})
.collect();
let total = counts
.iter()
.filter(|count| count.label != "cleared")
.map(|count| count.count)
.sum();
Ok((counts, total))
}
async fn unrated_picks(db: &Db) -> Result<Vec<UnratedPick>, WebError> {
let rows = sqlx::query(
"SELECT ia.issue_date, ia.article_id, COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title
FROM issue_articles ia
JOIN articles a ON a.id = ia.article_id
LEFT JOIN entries e ON e.id = a.best_entry_id
WHERE ia.issue_date IN (SELECT date FROM issues ORDER BY date DESC LIMIT 3)
AND NOT EXISTS (SELECT 1 FROM rating_events re
WHERE re.article_id = ia.article_id AND re.kind = 'explicit')
ORDER BY ia.issue_date DESC, ia.section, ia.position",
)
.fetch_all(db.pool())
.await
.map_err(db_err)?;
Ok(rows
.iter()
.map(|row| {
let issue_date: String = row.get("issue_date");
let article_id: ArticleId = row.get("article_id");
UnratedPick {
title: row.get("title"),
feed: row.get("feed_title"),
issue_href: format!("/issues/{issue_date}/articles/{article_id}"),
widget: RatingWidget {
article_id,
issue_date: issue_date.clone(),
next: "/dashboard".into(),
current: String::new(),
show_note: false,
},
issue_date,
}
})
.collect())
}
async fn jobs_summary(db: &Db, config: &Config) -> Result<(Vec<JobLine>, Vec<JobLine>), WebError> {
let job_line = |row: &sqlx::sqlite::SqliteRow| JobLine {
id: row.get("id"),
name: row.get("name"),
status: row.get("status"),
requested: fmt_stored_time(
row.get::<Option<String>, _>("requested_at").as_deref(),
config,
),
finished: fmt_stored_time(
row.get::<Option<String>, _>("finished_at").as_deref(),
config,
),
message: row.get("message"),
};
let active = sqlx::query(
"SELECT id, name, status, requested_at, finished_at, message FROM jobs
WHERE status IN ('requested', 'running') ORDER BY id DESC",
)
.fetch_all(db.pool())
.await
.map_err(db_err)?;
let finished = sqlx::query(
"SELECT id, name, status, requested_at, finished_at, message FROM jobs
WHERE status IN ('ok', 'failed') ORDER BY id DESC LIMIT 5",
)
.fetch_all(db.pool())
.await
.map_err(db_err)?;
Ok((
active.iter().map(job_line).collect(),
finished.iter().map(job_line).collect(),
))
}
#[cfg(test)]
pub(crate) mod tests {
use axum::body::{Body, to_bytes};
use axum::http::{Method, Request, StatusCode, header};
use jiff::civil::Date;
use tower::ServiceExt;
use super::*;
use crate::curate::telemetry::{self, CandidateRun};
use crate::server::router;
use crate::types::Entry;
/// A temp database with eight articles over two feeds, an earlier run,
/// one finished run (`run_id`) with a full funnel, assessments, an
/// embedding, one issue and two rating events on article 1.
pub(crate) struct Seed {
pub(crate) _dir: tempfile::TempDir,
pub(crate) db: Db,
pub(crate) run_id: i64,
pub(crate) earlier_run_id: i64,
pub(crate) date: Date,
}
pub(crate) async fn seed() -> Seed {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
for id in 1..=8i64 {
let feed_id = if id % 2 == 0 { 20 } else { 10 };
db.upsert_entry(&Entry {
id: 100 + id,
feed_id,
feed_title: Some(if feed_id == 10 {
"Alpha Blog".into()
} else {
"Beta Weekly".into()
}),
category: Some("Tech".into()),
title: format!("Article {id}"),
url: format!("https://example.com/{id}"),
canonical_url: Some(format!("https://example.com/{id}")),
author: Some("Ada".into()),
published_at: Some("2026-09-01T08:00:00Z".parse().unwrap()),
comments_url: None,
raw_content: "<p>Body</p>".into(),
fetched_at: "2026-09-02T04:00:00Z".parse().unwrap(),
})
.await
.unwrap();
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, best_entry_id, content_html,
word_count, first_seen)
VALUES (?, ?, ?, ?, '<p>Body</p>', ?, ?)",
)
.bind(id)
.bind(format!("https://example.com/{id}"))
.bind(format!(
"Article {id} about {}",
if id % 2 == 0 { "graphs" } else { "prose" }
))
.bind(100 + id)
.bind(300 * id)
.bind(format!("2026-09-0{}T04:00:00Z", (id % 3) + 1))
.execute(db.pool())
.await
.unwrap();
}
let date: Date = "2026-09-02".parse().unwrap();
let earlier_run_id = db
.start_run(date, "2026-09-01T05:30:00Z".parse().unwrap())
.await
.unwrap();
let mut earlier = RunReport::new(date, "2026-09-01T05:30:00Z".parse().unwrap());
earlier.config_json = serde_json::json!({
"curation": {"deep_keep": 100, "shortlist_keep": 60, "sections": ["A", "B"]},
"llm": {"bulk": "deepseek"}
});
earlier.finish("2026-09-01T05:40:00Z".parse().unwrap());
db.finish_run(earlier_run_id, &earlier).await.unwrap();
let run_id = db
.start_run(date, "2026-09-02T05:30:00Z".parse().unwrap())
.await
.unwrap();
let mut report = RunReport::new(date, "2026-09-02T05:30:00Z".parse().unwrap());
report.counts.articles = 8;
report.counts.eligible = 6;
report.counts.triaged = 6;
report.counts.assessed = 4;
report.counts.shortlisted = 3;
report.counts.selected = 2;
report.counts.rated_with_embeddings = 14;
report.counts.knn_gate = 0.35;
report.counts.verdicts_in_prompt = 41;
report.counts.admitted_by.insert("triage".into(), 3);
report.counts.admitted_by.insert("blend".into(), 1);
report.timings.record("triage", 12_000);
report.timings.record("editor", 30_000);
report.per_feed_counts.insert("Alpha Blog".into(), 4);
report.per_feed_counts.insert("Beta Weekly".into(), 4);
report.provider_costs.insert(
"deepseek".into(),
crate::report::ProviderUsage {
usage: crate::types::TokenUsage {
input_tokens: 1000,
cached_tokens: 200,
cache_write_tokens: 0,
output_tokens: 300,
},
cost_usd: 0.11,
},
);
report.warn("social: lobsters lookup timed out");
report.config_json = serde_json::json!({
"curation": {"deep_keep": 120, "shortlist_keep": 60, "sections": ["A", "B"]},
"llm": {"bulk": "deepseek", "editor": "anthropic"}
});
report.finish("2026-09-02T05:53:12Z".parse().unwrap());
db.finish_run(run_id, &report).await.unwrap();
let signals = |quality: f64, exploration: bool| {
serde_json::json!({
"v": 1,
"raw": {"interest": 1.2, "triage": 7.0, "quality": quality, "fit": 6.0},
"norm": {"interest": 0.9, "triage": 0.7, "quality": quality / 10.0, "fit": 0.6},
"present": {"interest": true, "knn": false, "feed": false, "social": false,
"heuristic": false, "triage": true, "quality": true, "fit": true},
"weights": {"interest": 0.2, "triage": 0.1, "quality": 0.5, "fit": 0.2},
"top_interests": [{"name": "Gaussian Splatting", "z": 3.4, "cos": 0.61}],
"neighbours": [{"article_id": 3, "label": "loved", "cos": 0.71, "title": "Article 3"}],
"exploration": exploration,
"auto_include": false,
"notes": ["knn gate 0.35 (n=14 rated with embeddings)"]
})
.to_string()
};
// 1 selected · 2 selected (exploration) · 3 shortlisted (not_selected)
// · 4 assessed (cluster_suppressed) · 5, 6 triaged (not_admitted)
// · 7 excluded (blocked) · 8 excluded (published_before)
let rows = [
(
1,
"selected",
None,
Some("[\"triage\",\"interest\"]"),
88.0,
1,
Some("Why one"),
),
(
2,
"selected",
None,
Some("[\"blend\"]"),
80.0,
2,
Some("Why two"),
),
(
3,
"shortlisted",
Some("not_selected"),
Some("[\"triage\"]"),
75.0,
3,
None,
),
(
4,
"assessed",
Some("cluster_suppressed"),
Some("[\"triage\"]"),
60.0,
4,
None,
),
];
for (article_id, stage, reason, admitted_by, utility, rank, why) in rows {
let json = signals(utility / 10.0, article_id == 2);
telemetry::write(
&db,
&CandidateRun {
run_id,
article_id,
stage,
excluded_reason: reason,
admitted_by,
signals_json: &json,
utility: Some(utility),
rank_utility: Some(rank),
cluster_id: Some(1),
cluster_rank: Some(rank),
editor_why: why,
},
)
.await
.unwrap();
}
for article_id in [5, 6] {
let json = signals(0.0, false);
telemetry::write(
&db,
&CandidateRun {
run_id,
article_id,
stage: "triaged",
excluded_reason: Some("not_admitted"),
admitted_by: None,
signals_json: &json,
utility: None,
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
.unwrap();
}
telemetry::thin_excluded(&db, run_id, 7, "blocked")
.await
.unwrap();
telemetry::thin_excluded(&db, run_id, 8, "published_before")
.await
.unwrap();
// Article 1 was also seen by the earlier run; its latest row is the
// newer one.
telemetry::write(
&db,
&CandidateRun {
run_id: earlier_run_id,
article_id: 1,
stage: "triaged",
excluded_reason: Some("not_admitted"),
admitted_by: None,
signals_json: "{}",
utility: None,
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
.unwrap();
sqlx::query(
"INSERT INTO article_assessments
(article_id, stage, model, prompt_version, score, fit, kind, facets_json, rationale,
category, paywalled_guess, assessed_at)
VALUES (1, 'triage', 'deepseek-v4-flash', 1, 7.0, NULL, 'essay', NULL,
'A specific argument', NULL, 0, '2026-09-02T05:31:00Z'),
(1, 'deep', 'deepseek-v4-flash', 1, 8.8, 6.0, 'analysis_essay',
'{\"depth\":\"deep\",\"topic_group\":\"ai_ml\"}',
'Careful and first-hand', 'Top Stories', 0, '2026-09-02T05:35:00Z'),
(2, 'triage', 'deepseek-v4-flash', 1, 6.0, NULL, 'news', NULL,
'Newsy', NULL, 0, '2026-09-02T05:31:00Z'),
(3, 'triage', 'deepseek-v4-flash', 1, NULL, NULL, 'provider_rejected', NULL,
'deepseek: 400 Bad Request: Content Exists Risk', NULL, 0,
'2026-09-02T05:31:00Z'),
(4, 'deep', 'deepseek-v4-flash', 1, 5.0, 4.0, 'reported_news', NULL,
'Fine', 'World', 1, '2026-09-02T05:35:00Z')",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO article_embeddings
(article_id, model, dimension, input_hash, embedding, created_at)
VALUES (1, 'voyage-4-lite', 4, 'abc123', X'00000000000000000000000000000000',
'2026-09-02T05:30:30Z')",
)
.execute(db.pool())
.await
.unwrap();
db.upsert_issue(
date,
12,
"2026-09-02T05:53:12Z".parse().unwrap(),
None,
None,
None,
Some("<p>Brief</p>"),
Some(&report.to_json()),
None,
)
.await
.unwrap();
sqlx::query(
"INSERT INTO issue_articles
(issue_date, article_id, section, position, is_lead, summary, why)
VALUES (?, 1, 'Top Stories', 0, 1, 'Summary one', 'Why one'),
(?, 2, 'Top Stories', 1, 0, 'Summary two', 'Why two')",
)
.bind(date.to_string())
.bind(date.to_string())
.execute(db.pool())
.await
.unwrap();
for (label, value, at) in [
("good", 0.35, "2026-09-02T09:00:00Z"),
("loved", 1.0, "2026-09-02T10:00:00Z"),
] {
db.append_rating_event(&crate::types::RatingEvent {
id: 0,
user_id: None,
article_id: 1,
issue_date: Some(date),
kind: "explicit".into(),
source: "cli".into(),
label: label.into(),
value,
note: Some(format!("note {label}")),
event_at: at.parse().unwrap(),
})
.await
.unwrap();
}
Seed {
_dir: dir,
db,
run_id,
earlier_run_id,
date,
}
}
pub(crate) 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.77")
.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()
}
pub(crate) async fn response_text(response: Response) -> String {
String::from_utf8(
to_bytes(response.into_body(), 4 * 1024 * 1024)
.await
.unwrap()
.to_vec(),
)
.unwrap()
}
pub(crate) 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()
}
/// Anonymous → 302 to login, `user` → 403, admin → 200; returns the
/// admin's body.
pub(crate) async fn assert_admin_only(app: &axum::Router, uri: &str) -> String {
let anonymous = get(app, uri, None).await;
assert_eq!(anonymous.status(), StatusCode::FOUND, "{uri}");
assert!(
anonymous
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.starts_with("/login?next="),
"{uri}"
);
let reader = login_cookie(app, "reader", "correct horse battery").await;
let forbidden = get(app, uri, Some(&reader)).await;
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN, "{uri}");
let admin = login_cookie(app, "admin", "correct horse battery").await;
let allowed = get(app, uri, Some(&admin)).await;
assert_eq!(allowed.status(), StatusCode::OK, "{uri}");
assert_eq!(
allowed.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
response_text(allowed).await
}
pub(crate) async fn app_with_users(db: &Db) -> axum::Router {
crate::web::users::add(db, "reader", "correct horse battery", false)
.await
.unwrap();
crate::web::users::add(db, "admin", "correct horse battery", true)
.await
.unwrap();
router(AppState::new(db.clone(), Config::default(), None))
}
#[test]
fn like_patterns_escape_metacharacters() {
assert_eq!(like_pattern("50% off_now\\"), "%50\\% off\\_now\\\\%");
assert_eq!(like_pattern(""), "%%");
}
#[test]
fn allow_list_drops_unknown_values() {
assert_eq!(allow_listed(Some("selected"), &STAGES), Some("selected"));
assert_eq!(allow_listed(Some("selected; DROP"), &STAGES), None);
assert_eq!(allow_listed(None, &STAGES), None);
}
#[test]
fn pager_keeps_other_parameters_and_omits_empty_ones() {
let pager = Pager::new(
Pagination {
page: 2,
per_page: 10,
total: 25,
},
"/dashboard/articles",
&[("q", Some("a b".into())), ("stage", None)],
);
assert_eq!(pager.pages, 3);
assert_eq!(
pager.prev_href.as_deref(),
Some("/dashboard/articles?q=a+b&page=1")
);
assert_eq!(
pager.next_href.as_deref(),
Some("/dashboard/articles?q=a+b&page=3")
);
}
#[test]
fn signals_view_marks_absent_signals_and_flags() {
let view = SignalsView::from_json(
r#"{"v":1,"raw":{"interest":1.2,"interest_top1_cos":0.61},"norm":{"interest":0.9},
"present":{"interest":true,"knn":false},"weights":{"interest":1.0},
"exploration":true,"notes":["n"]}"#,
);
assert!(!view.empty);
assert!(view.lines[0].present);
assert_eq!(view.lines[0].raw, "1.200");
assert!(!view.lines[1].present);
assert_eq!(view.lines[1].raw, "—");
assert_eq!(view.blend, "90.0");
assert_eq!(view.top1_cos.as_deref(), Some("0.610"));
assert!(view.exploration);
assert!(SignalsView::from_json("{}").empty);
assert!(SignalsView::from_json("not json").empty);
}
#[tokio::test]
async fn overview_shows_last_run_budget_unrated_picks_and_ratings() {
let seed = seed().await;
let app = app_with_users(&seed.db).await;
let body = assert_admin_only(&app, "/dashboard").await;
assert!(
body.contains("curation: 8 considered → 6 eligible"),
"{body}"
);
assert!(body.contains("admission: triage 3"), "{body}");
assert!(
body.contains(&format!("/dashboard/runs/{}", seed.run_id)),
"{body}"
);
assert!(body.contains("1 warning"), "{body}");
// Article 2 is published but unrated; article 1 carries a rating.
assert!(body.contains("Article 2 about graphs"), "{body}");
assert!(!body.contains("Article 1 about prose"), "{body}");
assert!(body.contains("name=\"article_id\" value=\"2\""), "{body}");
assert!(body.contains("deepseek"), "{body}");
assert!(body.contains("voyage"), "{body}");
assert!(body.contains("Ratings this week"), "{body}");
// Step 6: three sparklines over the last 30 runs (two finished here).
assert!(body.contains("Cost per run"), "{body}");
assert!(body.contains("Generation time"), "{body}");
assert_eq!(body.matches("<polyline").count(), 3, "{body}");
assert!(body.contains("2 runs · max $0.11"), "{body}");
assert!(!body.contains("style=\""), "no inline styles under the CSP");
}
#[tokio::test]
async fn every_dashboard_route_template_renders_with_fixture_data() {
let seed = seed().await;
sqlx::query(
"INSERT INTO jobs
(id, name, unit, requested_at, started_at, finished_at, status, message, run_id)
VALUES (99, 'features-prune', 'daily-epub-job@features-prune.service',
'2026-09-02T06:00:00Z', '2026-09-02T06:00:01Z',
'2026-09-02T06:00:02Z', 'ok', 'pruned fixture rows', ?)",
)
.bind(seed.run_id)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let routes = vec![
"/dashboard".to_string(),
"/dashboard/runs".to_string(),
format!("/dashboard/runs/{}", seed.run_id),
"/dashboard/articles".to_string(),
"/dashboard/articles/1".to_string(),
"/dashboard/ratings".to_string(),
"/dashboard/ratings?tab=events".to_string(),
"/dashboard/profile".to_string(),
"/dashboard/stats?days=14".to_string(),
"/dashboard/settings".to_string(),
"/dashboard/settings/history".to_string(),
"/dashboard/jobs".to_string(),
"/dashboard/jobs/99".to_string(),
"/dashboard/users".to_string(),
];
for uri in routes {
let response = get(&app, &uri, Some(&admin)).await;
assert_eq!(response.status(), StatusCode::OK, "{uri}");
let body = response_text(response).await;
assert!(body.contains("<!doctype html>"), "{uri}: {body}");
assert!(body.contains("The Daily EPUB"), "{uri}: {body}");
}
}
}