diff --git a/docs/plans/briefs/web-dashboard/handoff-step3.md b/docs/plans/briefs/web-dashboard/handoff-step3.md new file mode 100644 index 0000000..b2e6e8f --- /dev/null +++ b/docs/plans/briefs/web-dashboard/handoff-step3.md @@ -0,0 +1,100 @@ +# Step 3 handoff — dashboard reads: overview, runs, articles + +## Landed + +- `src/web/dashboard/mod.rs`: the overview (§9.1) — last-run card built from + `runs.report_json` via `RunReport::info_block()` (legacy-counter fallback + when the report is missing or unparseable), budget-today `` per + referenced provider plus voyage from `db::provider_spend_for_utc_day(now)`, + ratings this week by label, "Unrated picks" (last three issues' picks with + no explicit event, each with the step 2 rating widget posting back to + `/dashboard`), a jobs summary read straight from the `jobs` table (active + rows plus the last five finished; the pages themselves are step 6), and the + `! ` lines of `Config::check_report`. Sparklines are a marked HTML comment + for step 6. The module also carries the helpers the read pages share: + `Pager`, `SignalsView` (the parsed `signals_json` behind + `_signals_table.html`), `allow_listed`, `like_pattern`, `Bind`/`bind_all`/ + `dynamic_query`, formatting helpers, and the `STAGES`/`REASONS`/ + `RETRIEVERS` allow-lists. +- `src/web/dashboard/runs.rs` (§9.2): `/dashboard/runs` with `?status=` and + pagination (50), and `/dashboard/runs/{id}` with header (issue link when the + issue exists, previous/next run by id), the funnel from `candidate_runs` + grouped by stage and reason, admission mix, preference state, timings, + provider usage, warnings (`#warnings` anchor used by the overview and the + list), top-20 feeds, the config diff (`flatten_json` + `config_diff`) + against the previous non-dry run that recorded a config, ten near misses via + `telemetry::near_misses`, and the candidates table (100 per page) with the + `stage`/`reason`/`admitted_by`/`q`/`flag` filters and `utility` (default)/ + `rank`/`triage`/`quality`/`fit`/`title` sorts. Each row's title is a + `
` that expands `_signals_table.html`. +- `src/web/dashboard/articles.rs` (§9.3): `/dashboard/articles` (50 per page) + joining the best entry, the latest `candidate_runs` row through the + `MAX(run_id)` subquery (the index `idx_candidate_runs_article_run` is in + migration 0004 — confirmed), both assessments, the current explicit rating + and the latest publication; every listed filter and sort; + `/dashboard/articles/{id}` with the six blocks in the plan's order, + `provider_rejected` rows called out as such, run history rows expanding + their signals, neighbours/interests from the latest row, embedding metadata + only (never the vector), all rating events with source/user/note/value, the + rating widget with `show_note = true`, and "Explain (text)" wrapping + `telemetry::render_explain` verbatim in `
`.
+- Templates: `dashboard/overview.html`, `runs.html`, `run.html`,
+  `articles.html`, `article.html`, partials `_signals_table.html`,
+  `_candidate_row.html` and `dashboard/_pager.html`. Every table sits in
+  `.scroll-x`; badges per stage/reason/label/status; tables carry
+  `data-filter`.
+- `app.css` / `app.js`: one appended block each (`/* step 3 … */`): cards,
+  filters, funnel bar, pager, signals details, diff colours, and the
+  filter-as-you-type behaviour for `table[data-filter]` (inserts a search
+  input before the table's `.scroll-x` wrapper, hides non-matching rows on
+  the current page only).
+
+## Deviations and notes
+
+- **sqlx 0.9 dynamic SQL.** `sqlx::query` only accepts `&'static str` or
+  `AssertSqlSafe`, so the list queries go through `dashboard::dynamic_query`,
+  which wraps the assembled string in `AssertSqlSafe`. The audit holds
+  because the string is built only from constants and allow-listed fragments
+  (`CANDIDATE_SORTS`/`ARTICLE_SORTS` map names to fixed `ORDER BY` text);
+  every user value is bound through `bind_all`. An unknown sort or filter
+  value falls back to the default / is dropped, never an error.
+- **Funnel semantics.** `stage` records where a row stopped, so the bars
+  show the cumulative "reached" count (rows at this stage or a later one, so
+  the first bar is everything considered) with "stopped here" and the reason
+  breakdown beside it. Widths are `` children of `.funnel`
+  because the CSP (`style-src 'self'`) forbids inline `style` attributes;
+  the budget card uses `` for the same reason.
+- **Prev/next run** links go to the neighbouring run ids overall (the
+  header already links the date's issue); a rerun of the same date is
+  therefore the immediate neighbour.
+- **Extract method** is not shown on the article page: `db::article_from_row`
+  hard-codes `ExtractMethod::Miniflux` for every loaded article, so the value
+  would be meaningless. `excerpt_only` is shown instead. Source `kind`s render
+  via their `Debug` names (`Feed`, `Scour`, …).
+- **`admitted_by` filter** is allow-listed against the six retriever names
+  and still applied as the plan's prefix `LIKE` on
+  `json_extract(admitted_by, '$[0]')`.
+- The `_pagination.html` partial from step 1 (text only) was left alone; the
+  dashboard uses its own `dashboard/_pager.html` with prev/next links that
+  preserve the other query parameters.
+- Only additive edits outside my files: two appended blocks in `app.css` and
+  `app.js`. No changes to `web/mod.rs`, `db.rs`, `rate.rs` or `telemetry.rs`
+  (a private `first_retriever` in telemetry was reimplemented as
+  `admitted_by_parts` rather than made `pub`).
+
+## Left for later steps
+
+- Step 6: overview sparklines (placeholder comment in `overview.html`), and
+  the `/dashboard/jobs` pages the overview's job links point at.
+- Step 4: `/dashboard/ratings`, linked from the overview's ratings card.
+- The overview's ratings-this-week card counts all explicit events in the
+  last seven days by label (a `cleared` is listed but not counted in the
+  total).
+
+## Verification (outside the sandbox)
+
+- `cargo fmt`: pass.
+- `cargo clippy --all-targets -- -D warnings`: pass.
+- `cargo test web::dashboard`: **11 passed, 0 failed**.
+- `cargo test` (full suite): **387 lib tests passed, 0 failed**, plus every
+  integration test binary green (7, 2, 3, 4, 7, 9, 2), nothing skipped.
diff --git a/src/web/dashboard/articles.rs b/src/web/dashboard/articles.rs
index 82efb40..cc0be08 100644
--- a/src/web/dashboard/articles.rs
+++ b/src/web/dashboard/articles.rs
@@ -1,10 +1,932 @@
-//! Dashboard: articles pages. Filled in by web dashboard plan step 3.
+//! Dashboard: articles list and article detail (dashboard plan §9.3).
+//!
+//! The list joins every article to its best entry, its **latest**
+//! `candidate_runs` row (via `idx_candidate_runs_article_run`), both
+//! assessments, the current explicit rating and the latest publication. The
+//! detail page shows everything the system knows about one article, in the
+//! order of §9.3, and wraps `telemetry::render_explain` verbatim in `
`.
 
+use askama::Template;
 use axum::Router;
+use axum::extract::{Extension, Path, Query, State};
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use axum_login::tower_sessions::Session;
+use jiff::civil::Date;
+use serde::Deserialize;
+use sqlx::Row as _;
 
+use super::{
+    Bind, Pager, REASONS, STAGES, SignalsView, admitted_by_parts, allow_listed, bind_all, db_err,
+    dynamic_query, fmt_opt, fmt_opt_int, fmt_stored_time, like_pattern, non_empty, page_number,
+    widget_label,
+};
+use crate::config::Config;
+use crate::curate::telemetry;
+use crate::curate::triage::{PROVIDER_REJECTED, TRIAGE_KINDS};
+use crate::db::Db;
 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, take_flash};
+
+const ARTICLES_PER_PAGE: u32 = 50;
 
 /// Routes contributed by this page group (merged by `dashboard::router`).
 pub fn routes() -> Router {
     Router::new()
+        .route("/dashboard/articles", get(list))
+        .route("/dashboard/articles/{id}", get(detail))
+}
+
+// ---------------------------------------------------------------------------
+// Articles list
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Default, Deserialize)]
+pub struct ArticlesQuery {
+    pub q: Option,
+    pub feed: Option,
+    pub stage: Option,
+    pub reason: Option,
+    pub rated: Option,
+    pub published: Option,
+    pub from: Option,
+    pub to: Option,
+    pub kind: Option,
+    pub sort: Option,
+    pub page: Option,
+}
+
+const RATED: [&str; 6] = ["any", "loved", "good", "down", "cleared", "none"];
+const PUBLISHED: [&str; 2] = ["yes", "no"];
+
+const ARTICLE_SORTS: [(&str, &str); 7] = [
+    ("first_seen", "x.first_seen DESC, x.id DESC"),
+    ("utility", "x.utility DESC, x.id DESC"),
+    ("quality", "x.quality DESC, x.id DESC"),
+    ("fit", "x.fit DESC, x.id DESC"),
+    ("triage", "x.triage DESC, x.id DESC"),
+    ("words", "x.word_count DESC, x.id DESC"),
+    ("title", "x.title COLLATE NOCASE ASC, x.id ASC"),
+];
+
+/// Validated article filters; see [`CandidateFilters`](super::runs::CandidateFilters).
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ArticleFilters {
+    pub q: Option,
+    pub feed: Option,
+    pub stage: Option,
+    pub reason: Option,
+    pub rated: Option,
+    pub published: Option,
+    pub from: Option,
+    pub to: Option,
+    pub kind: Option,
+    pub sort: &'static str,
+}
+
+impl ArticleFilters {
+    pub fn from_query(query: &ArticlesQuery) -> Self {
+        let owned = |value: Option<&str>| value.map(str::to_string);
+        let date = |value: Option<&str>| {
+            non_empty(value)
+                .and_then(|value| value.parse::().ok())
+                .map(|date| date.to_string())
+        };
+        let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
+        kinds.push(PROVIDER_REJECTED);
+        Self {
+            q: owned(non_empty(query.q.as_deref())),
+            feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::().ok()),
+            stage: owned(allow_listed(query.stage.as_deref(), &STAGES)),
+            reason: owned(allow_listed(query.reason.as_deref(), &REASONS)),
+            rated: owned(allow_listed(query.rated.as_deref(), &RATED)),
+            published: owned(allow_listed(query.published.as_deref(), &PUBLISHED)),
+            from: date(query.from.as_deref()),
+            to: date(query.to.as_deref()),
+            kind: owned(allow_listed(query.kind.as_deref(), &kinds)),
+            sort: ARTICLE_SORTS
+                .iter()
+                .find(|(name, _)| Some(*name) == query.sort.as_deref())
+                .map(|(name, _)| *name)
+                .unwrap_or(ARTICLE_SORTS[0].0),
+        }
+    }
+
+    fn order_by(&self) -> &'static str {
+        ARTICLE_SORTS
+            .iter()
+            .find(|(name, _)| *name == self.sort)
+            .map(|(_, sql)| *sql)
+            .unwrap_or(ARTICLE_SORTS[0].1)
+    }
+
+    fn where_clauses(&self) -> (String, Vec) {
+        let mut sql = String::new();
+        let mut binds = Vec::new();
+        if let Some(q) = &self.q {
+            sql.push_str(" AND (x.title LIKE ? ESCAPE '\\' OR x.canonical_url LIKE ? ESCAPE '\\')");
+            binds.push(Bind::Text(like_pattern(q)));
+            binds.push(Bind::Text(like_pattern(q)));
+        }
+        if let Some(feed) = self.feed {
+            sql.push_str(" AND x.feed_id = ?");
+            binds.push(Bind::Int(feed));
+        }
+        if let Some(stage) = &self.stage {
+            sql.push_str(" AND x.stage = ?");
+            binds.push(Bind::Text(stage.clone()));
+        }
+        if let Some(reason) = &self.reason {
+            sql.push_str(" AND x.excluded_reason = ?");
+            binds.push(Bind::Text(reason.clone()));
+        }
+        match self.rated.as_deref() {
+            Some("any") => sql.push_str(" AND x.rating IS NOT NULL AND x.rating != 'cleared'"),
+            Some("none") => sql.push_str(" AND x.rating IS NULL"),
+            Some(label @ ("loved" | "good" | "cleared")) => {
+                sql.push_str(" AND x.rating = ?");
+                binds.push(Bind::Text(label.to_string()));
+            }
+            Some("down") => sql.push_str(" AND x.rating = 'not_for_me'"),
+            _ => {}
+        }
+        match self.published.as_deref() {
+            Some("yes") => sql.push_str(" AND x.published IS NOT NULL"),
+            Some("no") => sql.push_str(" AND x.published IS NULL"),
+            _ => {}
+        }
+        if let Some(from) = &self.from {
+            sql.push_str(" AND substr(x.first_seen, 1, 10) >= ?");
+            binds.push(Bind::Text(from.clone()));
+        }
+        if let Some(to) = &self.to {
+            sql.push_str(" AND substr(x.first_seen, 1, 10) <= ?");
+            binds.push(Bind::Text(to.clone()));
+        }
+        if let Some(kind) = &self.kind {
+            sql.push_str(" AND x.triage_kind = ?");
+            binds.push(Bind::Text(kind.clone()));
+        }
+        (sql, binds)
+    }
+
+    fn params(&self) -> Vec<(&'static str, Option)> {
+        vec![
+            ("q", self.q.clone()),
+            ("feed", self.feed.map(|feed| feed.to_string())),
+            ("stage", self.stage.clone()),
+            ("reason", self.reason.clone()),
+            ("rated", self.rated.clone()),
+            ("published", self.published.clone()),
+            ("from", self.from.clone()),
+            ("to", self.to.clone()),
+            ("kind", self.kind.clone()),
+            (
+                "sort",
+                (self.sort != ARTICLE_SORTS[0].0).then(|| self.sort.to_string()),
+            ),
+        ]
+    }
+}
+
+/// One row of the articles table.
+#[derive(Debug, Clone)]
+pub struct ArticleListRow {
+    pub id: ArticleId,
+    pub href: String,
+    pub title: String,
+    pub feed_id: Option,
+    pub feed: String,
+    pub first_seen: String,
+    pub words: i64,
+    pub stage: Option,
+    pub reason: Option,
+    pub run_id: Option,
+    pub run_date: Option,
+    pub utility: String,
+    pub triage: String,
+    pub quality: String,
+    pub fit: String,
+    pub rating: Option,
+    pub rating_class: &'static str,
+    pub published: Option,
+}
+
+const ARTICLE_INNER: &str =
+    "SELECT a.id, COALESCE(a.title, '') AS title, a.canonical_url, a.first_seen,
+                a.word_count, e.feed_id, COALESCE(e.feed_title, '') AS feed_title,
+                l.run_id, l.stage, l.excluded_reason, l.utility, r.date AS run_date,
+                t.score AS triage, t.kind AS triage_kind, d.score AS quality, d.fit AS fit,
+                (SELECT re.label FROM rating_events re
+                 WHERE re.article_id = a.id AND re.kind = 'explicit'
+                 ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating,
+                (SELECT ia.issue_date FROM issue_articles ia
+                 WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published
+         FROM articles a
+         LEFT JOIN entries e ON e.id = a.best_entry_id
+         LEFT JOIN candidate_runs l ON l.article_id = a.id
+              AND l.run_id = (SELECT MAX(c2.run_id) FROM candidate_runs c2
+                              WHERE c2.article_id = a.id)
+         LEFT JOIN runs r ON r.id = l.run_id
+         LEFT JOIN article_assessments t ON t.article_id = a.id AND t.stage = 'triage'
+         LEFT JOIN article_assessments d ON d.article_id = a.id AND d.stage = 'deep'";
+
+pub async fn list_articles(
+    db: &Db,
+    config: &Config,
+    filters: &ArticleFilters,
+    page: u32,
+) -> Result<(Vec, Pagination), sqlx::Error> {
+    let (clauses, binds) = filters.where_clauses();
+    let count_sql = format!("SELECT COUNT(*) FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses}");
+    let total: i64 = bind_all(dynamic_query(count_sql), &binds)
+        .fetch_one(db.pool())
+        .await?
+        .get(0);
+    let pagination = Pagination {
+        page,
+        per_page: ARTICLES_PER_PAGE,
+        total,
+    };
+    let select_sql = format!(
+        "SELECT * FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
+        filters.order_by()
+    );
+    let rows = bind_all(dynamic_query(select_sql), &binds)
+        .bind(i64::from(ARTICLES_PER_PAGE))
+        .bind(pagination.offset())
+        .fetch_all(db.pool())
+        .await?;
+    let rows = rows
+        .iter()
+        .map(|row| {
+            let id: ArticleId = row.get("id");
+            let rating: Option = row.get("rating");
+            ArticleListRow {
+                id,
+                href: format!("/dashboard/articles/{id}"),
+                title: row.get("title"),
+                feed_id: row.get("feed_id"),
+                feed: row.get("feed_title"),
+                first_seen: fmt_stored_time(Some(&row.get::("first_seen")), config),
+                words: row.get("word_count"),
+                stage: row.get("stage"),
+                reason: row.get("excluded_reason"),
+                run_id: row.get("run_id"),
+                run_date: row.get("run_date"),
+                utility: fmt_opt(row.get("utility"), 1),
+                triage: fmt_opt(row.get("triage"), 1),
+                quality: fmt_opt(row.get("quality"), 1),
+                fit: fmt_opt(row.get("fit"), 1),
+                rating_class: widget_label(rating.as_deref()),
+                rating,
+                published: row.get("published"),
+            }
+        })
+        .collect();
+    Ok((rows, pagination))
+}
+
+#[derive(Template)]
+#[template(path = "dashboard/articles.html")]
+struct ArticlesTemplate {
+    page: Page,
+    articles: Vec,
+    filters: ArticleFilters,
+    feed_value: String,
+    stages: Vec<&'static str>,
+    reasons: Vec<&'static str>,
+    rated: Vec<&'static str>,
+    kinds: Vec<&'static str>,
+    sorts: Vec<&'static str>,
+    pager: Pager,
+}
+
+async fn list(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+    Query(query): Query,
+) -> Result {
+    let viewer = auth.user().await.map(Viewer::from);
+    let config = state.config();
+    let filters = ArticleFilters::from_query(&query);
+    let page_no = page_number(query.page);
+    let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no)
+        .await
+        .map_err(db_err)?;
+    let pager = Pager::new(pagination, "/dashboard/articles", &filters.params());
+    let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec();
+    kinds.push(PROVIDER_REJECTED);
+    let mut page = Page::new("Articles", viewer, "articles");
+    page.flash = take_flash(&session).await?;
+    Ok(Html(ArticlesTemplate {
+        page,
+        articles,
+        feed_value: filters
+            .feed
+            .map(|feed| feed.to_string())
+            .unwrap_or_default(),
+        filters,
+        stages: STAGES.to_vec(),
+        reasons: REASONS.to_vec(),
+        rated: RATED.to_vec(),
+        kinds,
+        sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(),
+        pager,
+    })
+    .into_response())
+}
+
+// ---------------------------------------------------------------------------
+// Article detail
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone)]
+pub struct Facet {
+    pub name: String,
+    pub value: String,
+}
+
+/// One `article_assessments` row.
+#[derive(Debug, Clone)]
+pub struct AssessmentView {
+    pub stage: String,
+    pub rejected: bool,
+    pub model: String,
+    pub prompt_version: i64,
+    pub profile_version: String,
+    pub score: String,
+    pub fit: String,
+    pub kind: String,
+    pub rationale: String,
+    pub category: String,
+    pub paywalled: bool,
+    pub assessed_at: String,
+    pub facets: Vec,
+}
+
+/// One `candidate_runs` row of the article, newest first.
+#[derive(Debug, Clone)]
+pub struct HistoryRow {
+    pub run_id: i64,
+    pub run_href: String,
+    pub date: String,
+    pub status: String,
+    pub stage: String,
+    pub reason: Option,
+    pub admitted_first: Option,
+    pub admitted_rest: String,
+    pub utility: String,
+    pub rank: String,
+    pub cluster: String,
+    pub editor_why: Option,
+    pub signals: SignalsView,
+}
+
+#[derive(Debug, Clone)]
+pub struct EmbeddingView {
+    pub model: String,
+    pub dimension: i64,
+    pub created_at: String,
+    pub input_hash: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct RatingEventView {
+    pub id: i64,
+    pub event_at: String,
+    pub issue_date: Option,
+    pub kind: String,
+    pub source: String,
+    pub label: String,
+    pub label_class: &'static str,
+    pub value: String,
+    pub note: Option,
+    pub user: Option,
+}
+
+#[derive(Debug, Clone)]
+struct InIssue {
+    date: String,
+    href: String,
+    section: String,
+    position: i64,
+    is_lead: bool,
+}
+
+#[derive(Debug, Clone)]
+struct SourceLine {
+    kind: String,
+    feed: String,
+    category: Option,
+}
+
+#[derive(Debug, Clone)]
+struct SocialLine {
+    source: String,
+    score: i64,
+    comments: i64,
+    url: Option,
+}
+
+#[derive(Template)]
+#[template(path = "dashboard/article.html")]
+struct ArticleTemplate {
+    page: Page,
+    id: ArticleId,
+    title: String,
+    url: String,
+    canonical_url: String,
+    feed: String,
+    feed_id: i64,
+    category: Option,
+    author: Option,
+    published_at: String,
+    first_seen: String,
+    words: i64,
+    excerpt_only: bool,
+    image_count: i64,
+    sources: Vec,
+    social: Vec,
+    in_issues: Vec,
+    rating: Option,
+    rating_class: &'static str,
+    widget: RatingWidget,
+    explain: Option,
+    assessments: Vec,
+    history: Vec,
+    latest_signals: Option,
+    embedding: Option,
+    events: Vec,
+}
+
+pub async fn assessments(
+    db: &Db,
+    article_id: ArticleId,
+    config: &Config,
+) -> Result, sqlx::Error> {
+    let rows = sqlx::query(
+        "SELECT stage, model, prompt_version, profile_version, score, fit, kind, facets_json,
+                rationale, category, paywalled_guess, assessed_at
+         FROM article_assessments WHERE article_id = ? ORDER BY stage DESC",
+    )
+    .bind(article_id)
+    .fetch_all(db.pool())
+    .await?;
+    Ok(rows
+        .iter()
+        .map(|row| {
+            let kind: Option = row.get("kind");
+            let facets = row
+                .get::, _>("facets_json")
+                .and_then(|raw| serde_json::from_str::(&raw).ok())
+                .and_then(|value| value.as_object().cloned())
+                .map(|object| {
+                    object
+                        .into_iter()
+                        .map(|(name, value)| Facet {
+                            name,
+                            value: match value {
+                                serde_json::Value::String(text) => text,
+                                serde_json::Value::Array(items) => items
+                                    .iter()
+                                    .map(|item| {
+                                        item.as_str()
+                                            .map(str::to_string)
+                                            .unwrap_or_else(|| item.to_string())
+                                    })
+                                    .collect::>()
+                                    .join(", "),
+                                other => other.to_string(),
+                            },
+                        })
+                        .collect()
+                })
+                .unwrap_or_default();
+            AssessmentView {
+                stage: row.get("stage"),
+                rejected: kind.as_deref() == Some(PROVIDER_REJECTED),
+                model: row.get("model"),
+                prompt_version: row.get("prompt_version"),
+                profile_version: fmt_opt_int(row.get("profile_version")),
+                score: fmt_opt(row.get("score"), 1),
+                fit: fmt_opt(row.get("fit"), 1),
+                kind: kind.unwrap_or_else(|| "—".into()),
+                rationale: row
+                    .get::, _>("rationale")
+                    .unwrap_or_default(),
+                category: row
+                    .get::, _>("category")
+                    .unwrap_or_else(|| "—".into()),
+                paywalled: row.get::("paywalled_guess") != 0,
+                assessed_at: fmt_stored_time(Some(&row.get::("assessed_at")), config),
+                facets,
+            }
+        })
+        .collect())
+}
+
+pub async fn run_history(db: &Db, article_id: ArticleId) -> Result, sqlx::Error> {
+    let rows = sqlx::query(
+        "SELECT cr.run_id, r.date, r.status, cr.stage, cr.excluded_reason, cr.admitted_by,
+                cr.signals_json, cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank,
+                cr.editor_why
+         FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
+         WHERE cr.article_id = ? ORDER BY cr.run_id DESC",
+    )
+    .bind(article_id)
+    .fetch_all(db.pool())
+    .await?;
+    Ok(rows
+        .iter()
+        .map(|row| {
+            let run_id: i64 = row.get("run_id");
+            let (admitted_first, rest) =
+                admitted_by_parts(row.get::, _>("admitted_by").as_deref());
+            HistoryRow {
+                run_id,
+                run_href: format!("/dashboard/runs/{run_id}"),
+                date: row.get("date"),
+                status: row.get("status"),
+                stage: row.get("stage"),
+                reason: row.get("excluded_reason"),
+                admitted_first,
+                admitted_rest: rest.join(", "),
+                utility: fmt_opt(row.get("utility"), 1),
+                rank: fmt_opt_int(row.get("rank_utility")),
+                cluster: match (
+                    row.get::, _>("cluster_id"),
+                    row.get::, _>("cluster_rank"),
+                ) {
+                    (Some(id), Some(rank)) => format!("{id} · {rank}"),
+                    (Some(id), None) => id.to_string(),
+                    _ => "—".into(),
+                },
+                editor_why: row.get("editor_why"),
+                signals: SignalsView::from_json(&row.get::("signals_json")),
+            }
+        })
+        .collect())
+}
+
+pub async fn rating_events(
+    db: &Db,
+    article_id: ArticleId,
+    config: &Config,
+) -> Result, sqlx::Error> {
+    let rows = sqlx::query(
+        "SELECT re.id, re.issue_date, re.kind, re.source, re.label, re.value, re.note,
+                re.event_at, u.username
+         FROM rating_events re LEFT JOIN users u ON u.id = re.user_id
+         WHERE re.article_id = ? ORDER BY re.event_at DESC, re.id DESC",
+    )
+    .bind(article_id)
+    .fetch_all(db.pool())
+    .await?;
+    Ok(rows
+        .iter()
+        .map(|row| {
+            let label: String = row.get("label");
+            RatingEventView {
+                id: row.get("id"),
+                event_at: fmt_stored_time(Some(&row.get::("event_at")), config),
+                issue_date: row.get("issue_date"),
+                kind: row.get("kind"),
+                source: row.get("source"),
+                label_class: widget_label(Some(&label)),
+                label,
+                value: format!("{:.2}", row.get::("value")),
+                note: row.get("note"),
+                user: row.get("username"),
+            }
+        })
+        .collect())
+}
+
+async fn embedding(
+    db: &Db,
+    article_id: ArticleId,
+    config: &Config,
+) -> Result, sqlx::Error> {
+    let row = sqlx::query(
+        "SELECT model, dimension, created_at, input_hash FROM article_embeddings
+         WHERE article_id = ?",
+    )
+    .bind(article_id)
+    .fetch_optional(db.pool())
+    .await?;
+    Ok(row.map(|row| EmbeddingView {
+        model: row.get("model"),
+        dimension: row.get("dimension"),
+        created_at: fmt_stored_time(Some(&row.get::("created_at")), config),
+        input_hash: row.get("input_hash"),
+    }))
+}
+
+async fn detail(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+    Path(id): Path,
+) -> Result {
+    let viewer = auth.user().await.map(Viewer::from);
+    let config = state.config();
+    let db = &state.db;
+    let Some(article) = db.get_article(id).await? else {
+        return Err(WebError::NotFound);
+    };
+
+    let in_issues = sqlx::query(
+        "SELECT issue_date, section, position, is_lead FROM issue_articles
+         WHERE article_id = ? ORDER BY issue_date DESC",
+    )
+    .bind(id)
+    .fetch_all(db.pool())
+    .await
+    .map_err(db_err)?
+    .iter()
+    .map(|row| {
+        let date: String = row.get("issue_date");
+        InIssue {
+            href: format!("/issues/{date}/articles/{id}"),
+            date,
+            section: row.get("section"),
+            position: row.get("position"),
+            is_lead: row.get("is_lead"),
+        }
+    })
+    .collect::>();
+
+    let events = rating_events(db, id, &config).await.map_err(db_err)?;
+    let rating = events
+        .iter()
+        .find(|event| event.kind == "explicit")
+        .map(|event| event.label.clone());
+    let widget = RatingWidget {
+        article_id: id,
+        issue_date: in_issues
+            .first()
+            .map(|issue| issue.date.clone())
+            .unwrap_or_default(),
+        next: format!("/dashboard/articles/{id}"),
+        current: widget_label(rating.as_deref()).to_string(),
+        show_note: true,
+    };
+
+    let history = run_history(db, id).await.map_err(db_err)?;
+    let explain = match history.first() {
+        Some(latest) => match telemetry::explain_row(db, latest.run_id, id)
+            .await
+            .map_err(db_err)?
+        {
+            Some(row) => Some(telemetry::render_explain(db, &row).await.map_err(db_err)?),
+            None => None,
+        },
+        None => None,
+    };
+    let latest_signals = history
+        .first()
+        .map(|latest| latest.signals.clone())
+        .filter(|signals| !signals.empty);
+    let assessments = assessments(db, id, &config).await.map_err(db_err)?;
+    let embedding = embedding(db, id, &config).await.map_err(db_err)?;
+
+    let mut page = Page::new(article.title.clone(), viewer, "articles");
+    page.flash = take_flash(&session).await?;
+    Ok(Html(ArticleTemplate {
+        page,
+        id,
+        title: article.title.clone(),
+        url: article.url.clone(),
+        canonical_url: article.canonical_url.clone(),
+        feed: article.feed_title.clone(),
+        feed_id: article.feed_id,
+        category: article.category.clone(),
+        author: article.author.clone(),
+        published_at: article
+            .published_at
+            .map(|at| crate::web::format_time(at, &config))
+            .unwrap_or_else(|| "—".into()),
+        first_seen: crate::web::format_time(article.first_seen, &config),
+        words: article.word_count,
+        excerpt_only: article.excerpt_only,
+        image_count: article.image_count,
+        sources: article
+            .sources
+            .iter()
+            .map(|source| SourceLine {
+                kind: format!("{:?}", source.kind),
+                feed: source.feed_title.clone(),
+                category: source.category.clone(),
+            })
+            .collect(),
+        social: article
+            .social
+            .iter()
+            .map(|social| SocialLine {
+                source: format!("{:?}", social.source).to_lowercase(),
+                score: social.score,
+                comments: social.num_comments,
+                url: social.item_url.clone(),
+            })
+            .collect(),
+        in_issues,
+        rating_class: widget_label(rating.as_deref()),
+        rating,
+        widget,
+        explain,
+        assessments,
+        history,
+        latest_signals,
+        embedding,
+        events,
+    })
+    .into_response())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::web::dashboard::tests::{
+        app_with_users, assert_admin_only, get, login_cookie, seed,
+    };
+
+    fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters {
+        let mut query = ArticlesQuery::default();
+        f(&mut query);
+        ArticleFilters::from_query(&query)
+    }
+
+    #[tokio::test]
+    async fn articles_list_filters_and_sorts_are_allow_listed() {
+        let seed = seed().await;
+        let db = &seed.db;
+        let config = Config::default();
+
+        let all = query(|_| {});
+        assert_eq!(all.sort, "first_seen");
+        let (rows, pagination) = list_articles(db, &config, &all, 1).await.unwrap();
+        assert_eq!(pagination.total, 8);
+        assert_eq!(rows.len(), 8);
+        let one = rows.iter().find(|row| row.id == 1).unwrap();
+        assert_eq!(one.stage.as_deref(), Some("selected"), "latest run's row");
+        assert_eq!(one.run_id, Some(seed.run_id));
+        assert_eq!(one.rating.as_deref(), Some("loved"));
+        assert_eq!(one.published.as_deref(), Some("2026-09-02"));
+        assert_eq!(one.quality, "8.8");
+
+        let bogus = query(|q| {
+            q.sort = Some("id; DROP TABLE articles".into());
+            q.stage = Some("nope".into());
+            q.rated = Some("' OR 1=1".into());
+            q.from = Some("not a date".into());
+            q.feed = Some("abc".into());
+        });
+        assert_eq!(bogus.sort, "first_seen");
+        assert_eq!(bogus.stage, None);
+        assert_eq!(bogus.rated, None);
+        assert_eq!(bogus.from, None);
+        assert_eq!(bogus.feed, None);
+        let (rows, _) = list_articles(db, &config, &bogus, 1).await.unwrap();
+        assert_eq!(rows.len(), 8, "unknown values fall back, never error");
+
+        let by_feed = query(|q| q.feed = Some("20".into()));
+        let (rows, _) = list_articles(db, &config, &by_feed, 1).await.unwrap();
+        assert_eq!(rows.len(), 4);
+        assert!(rows.iter().all(|row| row.feed == "Beta Weekly"));
+
+        let selected = query(|q| q.stage = Some("selected".into()));
+        let (rows, _) = list_articles(db, &config, &selected, 1).await.unwrap();
+        assert_eq!(rows.len(), 2);
+
+        let reason = query(|q| q.reason = Some("blocked".into()));
+        let (rows, _) = list_articles(db, &config, &reason, 1).await.unwrap();
+        assert_eq!(rows.len(), 1);
+        assert_eq!(rows[0].id, 7);
+
+        let loved = query(|q| q.rated = Some("loved".into()));
+        let (rows, _) = list_articles(db, &config, &loved, 1).await.unwrap();
+        assert_eq!(rows.len(), 1);
+        assert_eq!(rows[0].id, 1);
+        let good = query(|q| q.rated = Some("good".into()));
+        let (rows, _) = list_articles(db, &config, &good, 1).await.unwrap();
+        assert!(rows.is_empty(), "superseded events do not count");
+        let unrated = query(|q| q.rated = Some("none".into()));
+        let (rows, _) = list_articles(db, &config, &unrated, 1).await.unwrap();
+        assert_eq!(rows.len(), 7);
+
+        let published = query(|q| q.published = Some("yes".into()));
+        let (rows, _) = list_articles(db, &config, &published, 1).await.unwrap();
+        assert_eq!(rows.len(), 2);
+
+        let window = query(|q| {
+            q.from = Some("2026-09-02".into());
+            q.to = Some("2026-09-02".into());
+        });
+        let (rows, _) = list_articles(db, &config, &window, 1).await.unwrap();
+        assert_eq!(rows.len(), 3, "ids 1, 4, 7 were first seen on the 2nd");
+
+        let kind = query(|q| q.kind = Some("provider_rejected".into()));
+        let (rows, _) = list_articles(db, &config, &kind, 1).await.unwrap();
+        assert_eq!(rows.len(), 1);
+        assert_eq!(rows[0].id, 3);
+
+        let search = query(|q| q.q = Some("example.com/8".into()));
+        let (rows, _) = list_articles(db, &config, &search, 1).await.unwrap();
+        assert_eq!(rows.len(), 1);
+        assert_eq!(rows[0].id, 8);
+
+        let by_words = query(|q| q.sort = Some("words".into()));
+        let (rows, _) = list_articles(db, &config, &by_words, 1).await.unwrap();
+        assert_eq!(rows[0].id, 8);
+        let by_utility = query(|q| q.sort = Some("utility".into()));
+        let (rows, _) = list_articles(db, &config, &by_utility, 1).await.unwrap();
+        assert_eq!(rows[0].id, 1);
+        assert_eq!(rows[7].utility, "—");
+        let by_title = query(|q| q.sort = Some("title".into()));
+        let (rows, _) = list_articles(db, &config, &by_title, 1).await.unwrap();
+        assert_eq!(rows[0].id, 1);
+    }
+
+    #[tokio::test]
+    async fn article_detail_shows_assessments_run_history_and_rating_events() {
+        let seed = seed().await;
+        let config = Config::default();
+        let views = assessments(&seed.db, 1, &config).await.unwrap();
+        assert_eq!(views.len(), 2);
+        assert_eq!(views[0].stage, "triage");
+        assert_eq!(views[0].score, "7.0");
+        assert_eq!(views[1].stage, "deep");
+        assert_eq!(views[1].fit, "6.0");
+        assert_eq!(views[1].category, "Top Stories");
+        assert_eq!(views[1].facets[0].name, "depth");
+        let rejected = assessments(&seed.db, 3, &config).await.unwrap();
+        assert!(rejected[0].rejected);
+
+        let history = run_history(&seed.db, 1).await.unwrap();
+        assert_eq!(history.len(), 2);
+        assert_eq!(history[0].run_id, seed.run_id);
+        assert_eq!(history[0].stage, "selected");
+        assert_eq!(history[0].admitted_first.as_deref(), Some("triage"));
+        assert_eq!(history[1].run_id, seed.earlier_run_id);
+        assert!(history[1].signals.empty);
+
+        let events = rating_events(&seed.db, 1, &config).await.unwrap();
+        assert_eq!(events.len(), 2);
+        assert_eq!(events[0].label, "loved");
+        assert_eq!(events[1].label, "good");
+        assert_eq!(events[1].note.as_deref(), Some("note good"));
+
+        let app = app_with_users(&seed.db).await;
+        let list = assert_admin_only(&app, "/dashboard/articles").await;
+        assert!(list.contains("Article 1 about prose"), "{list}");
+        assert!(list.contains("/dashboard/articles/1"), "{list}");
+
+        let body = assert_admin_only(&app, "/dashboard/articles/1").await;
+        assert!(body.contains("Careful and first-hand"), "{body}");
+        assert!(body.contains("A specific argument"), "{body}");
+        assert!(body.contains("topic_group"), "{body}");
+        assert!(
+            body.contains(&format!("/dashboard/runs/{}", seed.earlier_run_id)),
+            "{body}"
+        );
+        assert!(body.contains("note good"), "{body}");
+        assert!(body.contains("note loved"), "{body}");
+        assert!(body.contains("voyage-4-lite"), "{body}");
+        assert!(body.contains("abc123"), "{body}");
+        assert!(body.contains("Gaussian Splatting"), "{body}");
+        assert!(
+            body.contains("article 1: Article 1 about prose"),
+            "explain: {body}"
+        );
+        assert!(body.contains("name=\"note\""), "note field: {body}");
+        assert!(
+            body.contains("value=\"loved\" data-label=\"loved\" class=\"active\""),
+            "{body}"
+        );
+        assert!(body.contains("Top Stories"), "{body}");
+        assert!(body.contains("Alpha Blog"), "{body}");
+
+        let rejected = assert_admin_only(&app, "/dashboard/articles/3").await;
+        assert!(rejected.contains("rejected by provider"), "{rejected}");
+        assert!(rejected.contains("Content Exists Risk"), "{rejected}");
+
+        let admin = login_cookie(&app, "admin", "correct horse battery").await;
+        let missing = get(&app, "/dashboard/articles/999", Some(&admin)).await;
+        assert_eq!(missing.status(), axum::http::StatusCode::NOT_FOUND);
+        let filtered = get(
+            &app,
+            "/dashboard/articles?rated=loved&sort=nope&feed=x&page=0",
+            Some(&admin),
+        )
+        .await;
+        assert_eq!(filtered.status(), axum::http::StatusCode::OK);
+        let filtered = crate::web::dashboard::tests::response_text(filtered).await;
+        assert!(filtered.contains("Article 1 about prose"), "{filtered}");
+        assert!(!filtered.contains("Article 2 about graphs"), "{filtered}");
+    }
 }
diff --git a/src/web/dashboard/mod.rs b/src/web/dashboard/mod.rs
index ada2a3f..513dfb1 100644
--- a/src/web/dashboard/mod.rs
+++ b/src/web/dashboard/mod.rs
@@ -4,6 +4,10 @@
 //! `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;
@@ -16,12 +20,22 @@ 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, WebError};
+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 {
@@ -37,17 +51,1099 @@ pub fn router() -> Router {
         .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 {
+    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,
+    pub next_href: Option,
+}
+
+impl Pager {
+    pub fn new(pagination: Pagination, path: &str, params: &[(&str, Option)]) -> Self {
+        let query: Vec = 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, decimals: usize) -> String {
+    value
+        .map(|value| format!("{value:.decimals$}"))
+        .unwrap_or_else(|| "—".into())
+}
+
+pub fn fmt_opt_int(value: Option) -> 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::()
+            .map(|timestamp| format_time(timestamp, config))
+            .unwrap_or_else(|_| raw.to_string()),
+        None => "—".into(),
+    }
+}
+
+pub fn duration_between(started: &str, finished: Option<&str>) -> Option {
+    let started = started.parse::().ok()?;
+    let finished = finished?.parse::().ok()?;
+    Some((finished.as_second() - started.as_second()).max(0))
+}
+
+pub fn fmt_duration(secs: Option) -> 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, Vec) {
+    let mut names = raw
+        .and_then(|json| serde_json::from_str::>(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,
+    pub blend: String,
+    pub top1_cos: Option,
+    pub top_interests: Vec,
+    pub neighbours: Vec,
+    pub exploration: bool,
+    pub auto_include: bool,
+    pub notes: Vec,
+    /// 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::(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,
+    warnings: usize,
+    error: Option,
+}
+
+#[derive(Debug, Clone)]
+struct BudgetLine {
+    provider: String,
+    spent: String,
+    ceiling: String,
+    /// `` 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,
+}
+
 #[derive(Template)]
 #[template(path = "dashboard/overview.html")]
 struct OverviewTemplate {
     page: Page,
+    last_run: Option,
+    budget: Vec,
+    ratings: Vec,
+    ratings_total: i64,
+    unrated: Vec,
+    active_jobs: Vec,
+    finished_jobs: Vec,
+    config_warnings: Vec,
 }
 
-/// `GET /dashboard` — the overview (§9.1). Step 3 fills this in.
-async fn overview(auth: AuthSession) -> Result {
+/// `GET /dashboard` — the overview (§9.1). Sparklines arrive with step 6.
+async fn overview(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+) -> Result {
     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 unrated = unrated_picks(db).await?;
+    let (active_jobs, finished_jobs) = jobs_summary(db, &config).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: Page::new("Overview", viewer, "dashboard"),
+        page,
+        last_run,
+        budget,
+        ratings,
+        ratings_total,
+        unrated,
+        active_jobs,
+        finished_jobs,
+        config_warnings,
     })
     .into_response())
 }
+
+async fn last_run_card(db: &Db, config: &Config) -> Result, 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 = row.get("finished_at");
+    let duration = duration_between(&started_at, finished_at.as_deref());
+    let report = row
+        .get::, _>("report_json")
+        .and_then(|raw| serde_json::from_str::(&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::("entries_fetched"),
+                    row.get::("candidates"),
+                    row.get::("selected")
+                ),
+                format!(
+                    "providers: total {} · {}",
+                    fmt_usd(row.get::("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, 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, 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 = 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, 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, Vec), 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::, _>("requested_at").as_deref(),
+            config,
+        ),
+        finished: fmt_stored_time(
+            row.get::, _>("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: "

Body

".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 (?, ?, ?, ?, '

Body

', ?, ?)", + ) + .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("

Brief

"), + 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}"); + } +} diff --git a/src/web/dashboard/runs.rs b/src/web/dashboard/runs.rs index 18c324d..6ea0817 100644 --- a/src/web/dashboard/runs.rs +++ b/src/web/dashboard/runs.rs @@ -1,10 +1,1133 @@ -//! Dashboard: runs pages. Filled in by web dashboard plan step 3. +//! Dashboard: runs list and run detail (dashboard plan §9.2). +//! +//! The detail page renders the same `candidate_runs` telemetry that +//! `explain` prints: the funnel, the report's admission/preference/timing/ +//! provider blocks, the config diff against the previous non-dry run, the +//! near misses via `telemetry::near_misses`, and the candidates table with +//! allow-listed filters and sorts. +use std::collections::BTreeMap; + +use askama::Template; use axum::Router; +use axum::extract::{Extension, Path, Query, State}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum_login::tower_sessions::Session; +use serde::Deserialize; +use sqlx::Row as _; +use super::{ + Bind, Pager, REASONS, RETRIEVERS, STAGES, SignalsView, admitted_by_parts, allow_listed, + bind_all, db_err, duration_between, dynamic_query, fmt_duration, fmt_opt, fmt_opt_int, + fmt_stored_time, fmt_usd, like_pattern, non_empty, page_number, +}; +use crate::config::Config; +use crate::curate::telemetry; +use crate::db::Db; +use crate::report::RunReport; use crate::server::AppState; +use crate::types::ArticleId; +use crate::web::session::{AuthSession, Viewer}; +use crate::web::{Html, Page, Pagination, WebError, take_flash}; + +const RUNS_PER_PAGE: u32 = 50; +const CANDIDATES_PER_PAGE: u32 = 100; +const NEAR_MISSES: usize = 10; +const TOP_FEEDS: usize = 20; + +/// `runs.status` values accepted by `?status=`. +const STATUSES: [&str; 5] = ["running", "ok", "degraded", "failed", "dry_run"]; /// Routes contributed by this page group (merged by `dashboard::router`). pub fn routes() -> Router { Router::new() + .route("/dashboard/runs", get(list)) + .route("/dashboard/runs/{id}", get(detail)) +} + +// --------------------------------------------------------------------------- +// Runs list +// --------------------------------------------------------------------------- + +#[derive(Debug, Default, Deserialize)] +pub struct RunsQuery { + pub status: Option, + pub page: Option, +} + +#[derive(Debug, Clone)] +struct RunListRow { + id: i64, + date: String, + status: String, + started: String, + duration: String, + funnel: String, + costs: String, + total: String, + dry_run: bool, + warnings: usize, +} + +#[derive(Template)] +#[template(path = "dashboard/runs.html")] +struct RunsTemplate { + page: Page, + runs: Vec, + status: String, + statuses: Vec<&'static str>, + pager: Pager, +} + +/// One `runs` row with its parsed report, when present. +#[derive(Debug, Clone)] +pub struct RunRow { + pub id: i64, + pub date: String, + pub status: String, + pub started_at: String, + pub finished_at: Option, + pub entries_fetched: i64, + pub candidates: i64, + pub selected: i64, + pub cost_usd: f64, + pub error: Option, + pub config_json: Option, + pub report: Option, +} + +impl RunRow { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Self { + Self { + id: row.get("id"), + date: row.get("date"), + status: row.get("status"), + started_at: row.get("started_at"), + finished_at: row.get("finished_at"), + entries_fetched: row.get("entries_fetched"), + candidates: row.get("candidates"), + selected: row.get("selected"), + cost_usd: row.get("cost_usd"), + error: row.get("error"), + config_json: row.get("config_json"), + report: row + .get::, _>("report_json") + .and_then(|raw| serde_json::from_str::(&raw).ok()), + } + } + + pub fn duration_secs(&self) -> Option { + duration_between(&self.started_at, self.finished_at.as_deref()) + } + + /// `considered → eligible → triaged → assessed → shortlisted → selected` + /// from the report, or the legacy counters when it is missing. + pub fn funnel_line(&self) -> String { + match &self.report { + Some(report) => { + let c = &report.counts; + format!( + "{} → {} → {} → {} → {} → {}", + c.articles, c.eligible, c.triaged, c.assessed, c.shortlisted, c.selected + ) + } + None => format!( + "{} entries → {} → {}", + self.entries_fetched, self.candidates, self.selected + ), + } + } + + pub fn costs_line(&self) -> String { + self.report + .as_ref() + .map(|report| { + report + .provider_costs + .iter() + .map(|(provider, usage)| format!("{provider} {}", fmt_usd(usage.cost_usd))) + .collect::>() + .join(" · ") + }) + .filter(|line| !line.is_empty()) + .unwrap_or_else(|| "—".into()) + } +} + +const RUN_COLUMNS: &str = "id, date, status, started_at, finished_at, entries_fetched, candidates, + selected, cost_usd, error, config_json, report_json"; + +pub async fn list_runs( + db: &Db, + status: Option<&str>, + page: u32, +) -> Result<(Vec, Pagination), sqlx::Error> { + let status = allow_listed(status, &STATUSES); + let total: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM runs WHERE (? IS NULL OR status = ?)") + .bind(status) + .bind(status) + .fetch_one(db.pool()) + .await?; + let pagination = Pagination { + page, + per_page: RUNS_PER_PAGE, + total, + }; + let rows = dynamic_query(format!( + "SELECT {RUN_COLUMNS} FROM runs WHERE (? IS NULL OR status = ?) + ORDER BY id DESC LIMIT ? OFFSET ?" + )) + .bind(status) + .bind(status) + .bind(i64::from(RUNS_PER_PAGE)) + .bind(pagination.offset()) + .fetch_all(db.pool()) + .await?; + Ok((rows.iter().map(RunRow::from_row).collect(), pagination)) +} + +async fn list( + State(state): State, + auth: AuthSession, + Extension(session): Extension, + Query(query): Query, +) -> Result { + let viewer = auth.user().await.map(Viewer::from); + let config = state.config(); + let status = allow_listed(query.status.as_deref(), &STATUSES).unwrap_or(""); + let page = page_number(query.page); + let (rows, pagination) = list_runs(&state.db, non_empty(Some(status)), page) + .await + .map_err(db_err)?; + let runs = rows + .iter() + .map(|run| RunListRow { + id: run.id, + date: run.date.clone(), + status: run.status.clone(), + started: fmt_stored_time(Some(&run.started_at), &config), + duration: fmt_duration(run.duration_secs()), + funnel: run.funnel_line(), + costs: run.costs_line(), + total: fmt_usd(run.cost_usd), + dry_run: run.status == "dry_run", + warnings: run + .report + .as_ref() + .map(|report| report.warnings.len()) + .unwrap_or(0), + }) + .collect(); + let pager = Pager::new( + pagination, + "/dashboard/runs", + &[("status", Some(status.to_string()))], + ); + let mut page = Page::new("Runs", viewer, "runs"); + page.flash = take_flash(&session).await?; + Ok(Html(RunsTemplate { + page, + runs, + status: status.to_string(), + statuses: STATUSES.to_vec(), + pager, + }) + .into_response()) +} + +// --------------------------------------------------------------------------- +// Funnel +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +pub struct ReasonCount { + pub reason: String, + pub count: i64, +} + +/// One bar of the funnel: how many rows reached this stage or a later one, +/// how many stopped exactly here, and why. +#[derive(Debug, Clone, PartialEq)] +pub struct FunnelStage { + pub stage: &'static str, + pub reached: i64, + pub stopped: i64, + /// Bar width as a percentage of the rows considered. + pub percent: u32, + pub reasons: Vec, +} + +/// `SELECT stage, excluded_reason, COUNT(*) … GROUP BY 1, 2` shaped into +/// pipeline order. `reached` is cumulative from the end of the pipeline, so +/// the first bar is every row the run considered. +pub async fn funnel(db: &Db, run_id: i64) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT stage, excluded_reason, COUNT(*) AS n FROM candidate_runs + WHERE run_id = ? GROUP BY stage, excluded_reason ORDER BY n DESC, excluded_reason", + ) + .bind(run_id) + .fetch_all(db.pool()) + .await?; + let mut stopped: BTreeMap<&'static str, (i64, Vec)> = BTreeMap::new(); + let mut total = 0i64; + for row in &rows { + let stage: String = row.get("stage"); + let count: i64 = row.get("n"); + total += count; + let Some(name) = STAGES.iter().copied().find(|name| *name == stage) else { + continue; + }; + let entry = stopped.entry(name).or_insert_with(|| (0, Vec::new())); + entry.0 += count; + if let Some(reason) = row.get::, _>("excluded_reason") { + entry.1.push(ReasonCount { reason, count }); + } + } + let mut remaining = total; + let mut stages = Vec::with_capacity(STAGES.len()); + for (index, stage) in STAGES.iter().copied().enumerate() { + let (count, reasons) = stopped.remove(stage).unwrap_or_default(); + let reached = if index == 0 { total } else { remaining }; + let percent = if total > 0 { + ((reached as f64 / total as f64) * 100.0).round() as u32 + } else { + 0 + }; + stages.push(FunnelStage { + stage, + reached, + stopped: count, + percent, + reasons, + }); + remaining -= count; + } + Ok(stages) +} + +// --------------------------------------------------------------------------- +// Config diff +// --------------------------------------------------------------------------- + +/// Flatten a JSON document to dotted keys. Arrays stay whole (as compact +/// JSON) so a reordered list reads as one change. +pub fn flatten_json(value: &serde_json::Value) -> BTreeMap { + fn walk(prefix: &str, value: &serde_json::Value, out: &mut BTreeMap) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + walk(&path, child, out); + } + } + serde_json::Value::Null if prefix.is_empty() => {} + serde_json::Value::String(text) => { + out.insert(prefix.to_string(), text.clone()); + } + other => { + out.insert(prefix.to_string(), other.to_string()); + } + } + } + let mut out = BTreeMap::new(); + walk("", value, &mut out); + out +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffRow { + pub key: String, + pub before: String, + pub after: String, +} + +/// Keys whose flattened values differ between two config documents; a key +/// missing on one side shows as `—`. +pub fn config_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec { + let before = flatten_json(before); + let after = flatten_json(after); + let mut keys: Vec<&String> = before.keys().chain(after.keys()).collect(); + keys.sort(); + keys.dedup(); + keys.into_iter() + .filter_map(|key| { + let old = before.get(key); + let new = after.get(key); + (old != new).then(|| DiffRow { + key: key.clone(), + before: old.cloned().unwrap_or_else(|| "—".into()), + after: new.cloned().unwrap_or_else(|| "—".into()), + }) + }) + .collect() +} + +fn parse_config(raw: Option<&str>) -> serde_json::Value { + raw.and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or(serde_json::Value::Null) +} + +// --------------------------------------------------------------------------- +// Candidates table +// --------------------------------------------------------------------------- + +#[derive(Debug, Default, Deserialize)] +pub struct CandidatesQuery { + pub stage: Option, + pub reason: Option, + pub admitted_by: Option, + pub q: Option, + pub flag: Option, + pub sort: Option, + pub page: Option, +} + +/// Validated candidate filters: every value is allow-listed or free text +/// bound as a LIKE pattern; `sort` is the allow-list name, never SQL. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CandidateFilters { + pub stage: Option, + pub reason: Option, + pub admitted_by: Option, + pub q: Option, + pub flag: Option, + pub sort: &'static str, +} + +const CANDIDATE_SORTS: [(&str, &str); 6] = [ + ("utility", "cr.utility DESC, cr.article_id ASC"), + ( + "rank", + "cr.rank_utility IS NULL, cr.rank_utility ASC, cr.article_id ASC", + ), + ("triage", "t.score DESC, cr.article_id ASC"), + ("quality", "d.score DESC, cr.article_id ASC"), + ("fit", "d.fit DESC, cr.article_id ASC"), + ("title", "a.title COLLATE NOCASE ASC, cr.article_id ASC"), +]; + +const FLAGS: [&str; 2] = ["exploration", "auto"]; + +impl CandidateFilters { + pub fn from_query(query: &CandidatesQuery) -> Self { + let owned = |value: Option<&str>| value.map(str::to_string); + Self { + stage: owned(allow_listed(query.stage.as_deref(), &STAGES)), + reason: owned(allow_listed(query.reason.as_deref(), &REASONS)), + admitted_by: owned(allow_listed(query.admitted_by.as_deref(), &RETRIEVERS)), + q: owned(non_empty(query.q.as_deref())), + flag: owned(allow_listed(query.flag.as_deref(), &FLAGS)), + sort: CANDIDATE_SORTS + .iter() + .find(|(name, _)| Some(*name) == query.sort.as_deref()) + .map(|(name, _)| *name) + .unwrap_or(CANDIDATE_SORTS[0].0), + } + } + + fn order_by(&self) -> &'static str { + CANDIDATE_SORTS + .iter() + .find(|(name, _)| *name == self.sort) + .map(|(_, sql)| *sql) + .unwrap_or(CANDIDATE_SORTS[0].1) + } + + /// The `AND …` clauses and their bound values. + fn where_clauses(&self) -> (String, Vec) { + let mut sql = String::new(); + let mut binds = Vec::new(); + if let Some(stage) = &self.stage { + sql.push_str(" AND cr.stage = ?"); + binds.push(Bind::Text(stage.clone())); + } + if let Some(reason) = &self.reason { + sql.push_str(" AND cr.excluded_reason = ?"); + binds.push(Bind::Text(reason.clone())); + } + if let Some(retriever) = &self.admitted_by { + sql.push_str(" AND json_extract(cr.admitted_by, '$[0]') LIKE ? ESCAPE '\\'"); + binds.push(Bind::Text(format!("{retriever}%"))); + } + if let Some(q) = &self.q { + sql.push_str(" AND a.title LIKE ? ESCAPE '\\'"); + binds.push(Bind::Text(like_pattern(q))); + } + match self.flag.as_deref() { + Some("exploration") => { + sql.push_str(" AND json_extract(cr.signals_json, '$.exploration') = 1"); + } + Some("auto") => { + sql.push_str(" AND json_extract(cr.signals_json, '$.auto_include') = 1"); + } + _ => {} + } + (sql, binds) + } + + fn params(&self) -> Vec<(&'static str, Option)> { + vec![ + ("stage", self.stage.clone()), + ("reason", self.reason.clone()), + ("admitted_by", self.admitted_by.clone()), + ("q", self.q.clone()), + ("flag", self.flag.clone()), + ( + "sort", + (self.sort != CANDIDATE_SORTS[0].0).then(|| self.sort.to_string()), + ), + ] + } +} + +/// One row of the candidates table. +#[derive(Debug, Clone)] +pub struct CandidateView { + pub article_id: ArticleId, + pub title: String, + pub href: String, + pub feed: String, + pub words: i64, + pub stage: String, + pub reason: Option, + pub admitted_first: Option, + pub admitted_rest: String, + pub utility: String, + pub rank: String, + pub cluster: String, + pub triage: String, + pub quality: String, + pub fit: String, + pub exploration: bool, + pub auto_include: bool, + pub editor_why: Option, + pub signals: SignalsView, +} + +impl CandidateView { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Self { + let article_id: ArticleId = row.get("article_id"); + let (admitted_first, rest) = + admitted_by_parts(row.get::, _>("admitted_by").as_deref()); + let signals = SignalsView::from_json(&row.get::("signals_json")); + Self { + article_id, + title: row.get("title"), + href: format!("/dashboard/articles/{article_id}"), + feed: row.get("feed_title"), + words: row.get("word_count"), + stage: row.get("stage"), + reason: row.get("excluded_reason"), + admitted_first, + admitted_rest: rest.join(", "), + utility: fmt_opt(row.get("utility"), 1), + rank: fmt_opt_int(row.get("rank_utility")), + cluster: match ( + row.get::, _>("cluster_id"), + row.get::, _>("cluster_rank"), + ) { + (Some(id), Some(rank)) => format!("{id} · {rank}"), + (Some(id), None) => id.to_string(), + _ => "—".into(), + }, + triage: fmt_opt(row.get("triage"), 1), + quality: fmt_opt(row.get("quality"), 1), + fit: fmt_opt(row.get("fit"), 1), + exploration: signals.exploration, + auto_include: signals.auto_include, + editor_why: row.get("editor_why"), + signals, + } + } +} + +const CANDIDATE_FROM: &str = "FROM candidate_runs cr + JOIN articles a ON a.id = cr.article_id + LEFT JOIN entries e ON e.id = a.best_entry_id + LEFT JOIN article_assessments t ON t.article_id = cr.article_id AND t.stage = 'triage' + LEFT JOIN article_assessments d ON d.article_id = cr.article_id AND d.stage = 'deep' + WHERE cr.run_id = ?"; + +pub async fn candidates( + db: &Db, + run_id: i64, + filters: &CandidateFilters, + page: u32, +) -> Result<(Vec, Pagination), sqlx::Error> { + let (clauses, binds) = filters.where_clauses(); + let count_sql = format!("SELECT COUNT(*) {CANDIDATE_FROM}{clauses}"); + let total: i64 = bind_all(dynamic_query(count_sql).bind(run_id), &binds) + .fetch_one(db.pool()) + .await? + .get(0); + let pagination = Pagination { + page, + per_page: CANDIDATES_PER_PAGE, + total, + }; + let select_sql = format!( + "SELECT cr.article_id, COALESCE(a.title, '') AS title, + COALESCE(e.feed_title, '') AS feed_title, a.word_count, + cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json, cr.utility, + cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why, + t.score AS triage, d.score AS quality, d.fit AS fit + {CANDIDATE_FROM}{clauses} + ORDER BY {} + LIMIT ? OFFSET ?", + filters.order_by() + ); + let rows = bind_all(dynamic_query(select_sql).bind(run_id), &binds) + .bind(i64::from(CANDIDATES_PER_PAGE)) + .bind(pagination.offset()) + .fetch_all(db.pool()) + .await?; + Ok(( + rows.iter().map(CandidateView::from_row).collect(), + pagination, + )) +} + +// --------------------------------------------------------------------------- +// Run detail +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct RunHeader { + id: i64, + date: String, + status: String, + started: String, + finished: String, + duration: String, + dry_run: bool, + issue_href: Option, + prev_id: Option, + next_id: Option, + error: Option, + total_cost: String, +} + +#[derive(Debug, Clone)] +struct CountLine { + name: String, + count: i64, +} + +#[derive(Debug, Clone)] +struct TimingLine { + stage: String, + seconds: String, +} + +#[derive(Debug, Clone)] +struct ProviderLine { + provider: String, + input: i64, + cached: i64, + cache_write: i64, + output: i64, + cost: String, +} + +#[derive(Debug, Clone, Default)] +struct PreferenceView { + rated_with_embeddings: i64, + knn_gate: String, + feed_gate: String, + verdicts_in_prompt: i64, +} + +#[derive(Debug, Clone)] +struct NearMissLine { + href: String, + title: String, + feed: String, + score: String, + stage: String, + reason: Option, + quality: String, + fit: String, +} + +#[derive(Template)] +#[template(path = "dashboard/run.html")] +struct RunTemplate { + page: Page, + run: RunHeader, + has_report: bool, + funnel: Vec, + admission: Vec, + preference: PreferenceView, + timings: Vec, + timings_total: String, + providers: Vec, + warnings: Vec, + feeds: Vec, + diff_against: Option, + config_diff: Vec, + near_misses: Vec, + candidates: Vec, + filters: CandidateFilters, + stages: Vec<&'static str>, + reasons: Vec<&'static str>, + retrievers: Vec<&'static str>, + sorts: Vec<&'static str>, + pager: Pager, +} + +pub async fn run_by_id(db: &Db, id: i64) -> Result, sqlx::Error> { + let row = dynamic_query(format!("SELECT {RUN_COLUMNS} FROM runs WHERE id = ?")) + .bind(id) + .fetch_optional(db.pool()) + .await?; + Ok(row.as_ref().map(RunRow::from_row)) +} + +/// The nearest earlier run that was not a dry run and recorded its config. +async fn previous_config(db: &Db, id: i64) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT id, config_json FROM runs + WHERE id < ? AND status != 'dry_run' AND config_json IS NOT NULL + ORDER BY id DESC LIMIT 1", + ) + .bind(id) + .fetch_optional(db.pool()) + .await?; + Ok(row.map(|row| (row.get("id"), row.get("config_json")))) +} + +async fn neighbour_run(db: &Db, id: i64, next: bool) -> Result, sqlx::Error> { + let sql = if next { + "SELECT id FROM runs WHERE id > ? ORDER BY id ASC LIMIT 1" + } else { + "SELECT id FROM runs WHERE id < ? ORDER BY id DESC LIMIT 1" + }; + let row = sqlx::query(sql).bind(id).fetch_optional(db.pool()).await?; + Ok(row.map(|row| row.get("id"))) +} + +async fn detail( + State(state): State, + auth: AuthSession, + Extension(session): Extension, + Path(id): Path, + Query(query): Query, +) -> Result { + let viewer = auth.user().await.map(Viewer::from); + let config: std::sync::Arc = state.config(); + let db = &state.db; + let Some(run) = run_by_id(db, id).await.map_err(db_err)? else { + return Err(WebError::NotFound); + }; + let issue_exists: Option = sqlx::query_scalar("SELECT 1 FROM issues WHERE date = ?") + .bind(&run.date) + .fetch_optional(db.pool()) + .await + .map_err(db_err)?; + let header = RunHeader { + id: run.id, + date: run.date.clone(), + status: run.status.clone(), + started: fmt_stored_time(Some(&run.started_at), &config), + finished: fmt_stored_time(run.finished_at.as_deref(), &config), + duration: fmt_duration(run.duration_secs()), + dry_run: run.status == "dry_run", + issue_href: issue_exists.map(|_| format!("/issues/{}", run.date)), + prev_id: neighbour_run(db, id, false).await.map_err(db_err)?, + next_id: neighbour_run(db, id, true).await.map_err(db_err)?, + error: run.error.clone(), + total_cost: fmt_usd(run.cost_usd), + }; + + let funnel = funnel(db, id).await.map_err(db_err)?; + let (admission, preference, timings, timings_total, providers, warnings, feeds) = + match &run.report { + Some(report) => ( + report + .counts + .admitted_by + .iter() + .map(|(name, count)| CountLine { + name: name.clone(), + count: *count, + }) + .collect(), + PreferenceView { + rated_with_embeddings: report.counts.rated_with_embeddings, + knn_gate: format!("{:.2}", report.counts.knn_gate), + feed_gate: format!("{:.2}", report.counts.feed_gate), + verdicts_in_prompt: report.counts.verdicts_in_prompt, + }, + report + .timings + .0 + .iter() + .map(|(stage, millis)| TimingLine { + stage: stage.clone(), + seconds: format!("{:.1}", *millis as f64 / 1000.0), + }) + .collect(), + format!("{:.1}", report.timings.total_ms() as f64 / 1000.0), + report + .provider_costs + .iter() + .map(|(provider, usage)| ProviderLine { + provider: provider.clone(), + input: usage.usage.input_tokens, + cached: usage.usage.cached_tokens, + cache_write: usage.usage.cache_write_tokens, + output: usage.usage.output_tokens, + cost: fmt_usd(usage.cost_usd), + }) + .collect(), + report.warnings.clone(), + report + .top_feeds(TOP_FEEDS) + .into_iter() + .map(|(name, count)| CountLine { + name: name.to_string(), + count, + }) + .collect(), + ), + None => ( + Vec::new(), + PreferenceView::default(), + Vec::new(), + "—".into(), + Vec::new(), + Vec::new(), + Vec::new(), + ), + }; + + let previous = previous_config(db, id).await.map_err(db_err)?; + let (diff_against, config_diff) = match &previous { + Some((previous_id, previous_json)) => ( + Some(*previous_id), + config_diff( + &parse_config(Some(previous_json)), + &parse_config(run.config_json.as_deref()), + ), + ), + None => (None, Vec::new()), + }; + + let near_misses = telemetry::near_misses(db, id, NEAR_MISSES) + .await + .map_err(db_err)? + .into_iter() + .map(|row| { + let signals = row.signals(); + let raw = |name: &str| signals.as_ref().and_then(|s| s.raw.get(name).copied()); + NearMissLine { + href: format!("/dashboard/articles/{}", row.article_id), + title: row.title.clone(), + feed: row.feed_title.clone(), + score: fmt_opt(row.score(), 1), + stage: row.stage.clone(), + reason: row.excluded_reason.clone(), + quality: fmt_opt(raw("quality"), 1), + fit: fmt_opt(raw("fit"), 1), + } + }) + .collect(); + + let filters = CandidateFilters::from_query(&query); + let page_no = page_number(query.page); + let (candidates, pagination) = candidates(db, id, &filters, page_no) + .await + .map_err(db_err)?; + let pager = Pager::new( + pagination, + &format!("/dashboard/runs/{id}"), + &filters.params(), + ); + + let mut page = Page::new(format!("Run {id} · {}", run.date), viewer, "runs"); + page.flash = take_flash(&session).await?; + Ok(Html(RunTemplate { + page, + run: header, + has_report: run.report.is_some(), + funnel, + admission, + preference, + timings, + timings_total, + providers, + warnings, + feeds, + diff_against, + config_diff, + near_misses, + candidates, + filters, + stages: STAGES.to_vec(), + reasons: REASONS.to_vec(), + retrievers: RETRIEVERS.to_vec(), + sorts: CANDIDATE_SORTS.iter().map(|(name, _)| *name).collect(), + pager, + }) + .into_response()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::web::dashboard::tests::{ + app_with_users, assert_admin_only, get, login_cookie, seed, + }; + + #[tokio::test] + async fn funnel_counts_match_the_seeded_rows() { + let seed = seed().await; + let stages = funnel(&seed.db, seed.run_id).await.unwrap(); + let by_name = |name: &str| stages.iter().find(|stage| stage.stage == name).unwrap(); + assert_eq!(by_name("excluded").reached, 8); + assert_eq!(by_name("excluded").stopped, 2); + assert_eq!(by_name("excluded").percent, 100); + assert_eq!( + by_name("excluded").reasons, + vec![ + ReasonCount { + reason: "blocked".into(), + count: 1 + }, + ReasonCount { + reason: "published_before".into(), + count: 1 + }, + ] + ); + assert_eq!(by_name("eligible").reached, 6); + assert_eq!(by_name("eligible").stopped, 0); + assert_eq!(by_name("triaged").reached, 6); + assert_eq!(by_name("triaged").stopped, 2); + assert_eq!(by_name("triaged").reasons[0].reason, "not_admitted"); + assert_eq!(by_name("admitted").reached, 4); + assert_eq!(by_name("assessed").reached, 4); + assert_eq!(by_name("assessed").stopped, 1); + assert_eq!(by_name("shortlisted").reached, 3); + assert_eq!(by_name("selected").reached, 2); + assert_eq!(by_name("selected").stopped, 2); + assert_eq!(by_name("selected").percent, 25); + assert!(by_name("selected").reasons.is_empty()); + + let empty = funnel(&seed.db, 999).await.unwrap(); + assert_eq!(empty.len(), STAGES.len()); + assert!( + empty + .iter() + .all(|stage| stage.reached == 0 && stage.percent == 0) + ); + } + + #[tokio::test] + async fn candidate_filters_and_sorts_are_allow_listed() { + let seed = seed().await; + let db = &seed.db; + let query = |stage: Option<&str>, sort: Option<&str>| CandidatesQuery { + stage: stage.map(str::to_string), + sort: sort.map(str::to_string), + ..CandidatesQuery::default() + }; + + let filters = CandidateFilters::from_query(&query(None, None)); + assert_eq!(filters.sort, "utility"); + let (all, pagination) = candidates(db, seed.run_id, &filters, 1).await.unwrap(); + assert_eq!(pagination.total, 8); + assert_eq!(all[0].article_id, 1, "utility desc, NULLs last"); + assert_eq!(all[3].article_id, 4); + assert!(all[4..].iter().all(|row| row.utility == "—")); + + let unknown = CandidateFilters::from_query(&query( + Some("selected; DROP TABLE runs"), + Some("article_id; DROP TABLE runs"), + )); + assert_eq!(unknown.stage, None); + assert_eq!(unknown.sort, "utility"); + let (rows, _) = candidates(db, seed.run_id, &unknown, 1).await.unwrap(); + assert_eq!(rows.len(), 8, "an unknown sort falls back, never errors"); + + let selected = CandidateFilters::from_query(&query(Some("selected"), Some("title"))); + let (rows, pagination) = candidates(db, seed.run_id, &selected, 1).await.unwrap(); + assert_eq!(pagination.total, 2); + assert_eq!(rows[0].article_id, 1); + assert_eq!(rows[0].admitted_first.as_deref(), Some("triage")); + assert_eq!(rows[0].admitted_rest, "interest"); + assert_eq!(rows[0].editor_why.as_deref(), Some("Why one")); + + let by_rank = CandidateFilters::from_query(&query(None, Some("rank"))); + let (rows, _) = candidates(db, seed.run_id, &by_rank, 1).await.unwrap(); + assert_eq!(rows[0].rank, "1"); + assert_eq!(rows[7].rank, "—"); + + let by_quality = CandidateFilters::from_query(&query(None, Some("quality"))); + let (rows, _) = candidates(db, seed.run_id, &by_quality, 1).await.unwrap(); + assert_eq!(rows[0].article_id, 1, "deep quality 8.8 first"); + assert_eq!(rows[0].quality, "8.8"); + assert_eq!(rows[1].article_id, 4); + + let reason = CandidateFilters::from_query(&CandidatesQuery { + reason: Some("not_admitted".into()), + ..CandidatesQuery::default() + }); + let (rows, _) = candidates(db, seed.run_id, &reason, 1).await.unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row.stage == "triaged")); + + let retriever = CandidateFilters::from_query(&CandidatesQuery { + admitted_by: Some("blend".into()), + ..CandidatesQuery::default() + }); + let (rows, _) = candidates(db, seed.run_id, &retriever, 1).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].article_id, 2); + + let unknown_retriever = CandidateFilters::from_query(&CandidatesQuery { + admitted_by: Some("' OR 1=1 --".into()), + ..CandidatesQuery::default() + }); + assert_eq!(unknown_retriever.admitted_by, None); + + let title = CandidateFilters::from_query(&CandidatesQuery { + q: Some("100% graphs".into()), + ..CandidatesQuery::default() + }); + let (rows, _) = candidates(db, seed.run_id, &title, 1).await.unwrap(); + assert!(rows.is_empty(), "the % is literal, not a wildcard"); + let title = CandidateFilters::from_query(&CandidatesQuery { + q: Some("graphs".into()), + ..CandidatesQuery::default() + }); + let (rows, _) = candidates(db, seed.run_id, &title, 1).await.unwrap(); + assert_eq!(rows.len(), 4); + + let exploration = CandidateFilters::from_query(&CandidatesQuery { + flag: Some("exploration".into()), + ..CandidatesQuery::default() + }); + let (rows, _) = candidates(db, seed.run_id, &exploration, 1).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].article_id, 2); + assert!(rows[0].exploration); + assert!(rows[0].signals.lines.iter().any(|line| line.present)); + + let thin = all.iter().find(|row| row.article_id == 7).unwrap(); + assert!(thin.signals.empty); + assert_eq!(thin.reason.as_deref(), Some("blocked")); + } + + #[test] + fn config_diff_finds_changed_dotted_keys_and_ignores_unchanged() { + let before = json!({ + "curation": {"deep_keep": 100, "shortlist_keep": 60, "sections": ["A", "B"], + "weights": {"quality": 0.4, "fit": 0.2}}, + "llm": {"bulk": "deepseek"}, + "flag": null + }); + let after = json!({ + "curation": {"deep_keep": 120, "shortlist_keep": 60, "sections": ["B", "A"], + "weights": {"quality": 0.4, "fit": 0.25}}, + "llm": {"bulk": "deepseek", "editor": "anthropic"} + }); + let diff = config_diff(&before, &after); + let keys: Vec<&str> = diff.iter().map(|row| row.key.as_str()).collect(); + assert_eq!( + keys, + [ + "curation.deep_keep", + "curation.sections", + "curation.weights.fit", + "flag", + "llm.editor" + ] + ); + assert_eq!(diff[0].before, "100"); + assert_eq!(diff[0].after, "120"); + assert_eq!(diff[1].before, "[\"A\",\"B\"]"); + assert_eq!(diff[3].before, "null"); + assert_eq!(diff[3].after, "—"); + assert_eq!(diff[4].before, "—"); + assert_eq!(diff[4].after, "anthropic"); + assert!(config_diff(&before, &before).is_empty()); + assert!(config_diff(&json!(null), &json!(null)).is_empty()); + assert_eq!(flatten_json(&json!({"a": {"b": "x"}}))["a.b"], "x"); + } + + #[tokio::test] + async fn runs_pages_are_admin_only_and_render_the_seeded_run() { + let seed = seed().await; + let app = app_with_users(&seed.db).await; + let list = assert_admin_only(&app, "/dashboard/runs").await; + assert!(list.contains("8 → 6 → 6 → 4 → 3 → 2"), "{list}"); + assert!(list.contains("deepseek $0.11"), "{list}"); + assert!( + list.contains(&format!("/dashboard/runs/{}", seed.run_id)), + "{list}" + ); + + let admin = login_cookie(&app, "admin", "correct horse battery").await; + let filtered = get(&app, "/dashboard/runs?status=failed", Some(&admin)).await; + let filtered = crate::web::dashboard::tests::response_text(filtered).await; + assert!(!filtered.contains("8 → 6 → 6"), "{filtered}"); + let unknown = get(&app, "/dashboard/runs?status=nope&page=0", Some(&admin)).await; + assert_eq!(unknown.status(), axum::http::StatusCode::OK); + + let detail = assert_admin_only(&app, &format!("/dashboard/runs/{}", seed.run_id)).await; + assert!( + detail.contains("social: lobsters lookup timed out"), + "{detail}" + ); + assert!(detail.contains("curation.deep_keep"), "{detail}"); + assert!(detail.contains("llm.editor"), "{detail}"); + assert!( + detail.contains(&format!("run {}", seed.earlier_run_id)), + "{detail}" + ); + assert!( + detail.contains("Article 3 about prose"), + "near miss: {detail}" + ); + assert!(detail.contains("Gaussian Splatting"), "signals: {detail}"); + assert!(detail.contains("Why one"), "{detail}"); + assert!(detail.contains("Alpha Blog"), "{detail}"); + assert!(detail.contains("class=\"funnel\""), "{detail}"); + assert!( + detail.contains(&format!("/issues/{}", seed.date)), + "{detail}" + ); + assert!( + detail.contains(&format!("/dashboard/runs/{}", seed.earlier_run_id)), + "prev link: {detail}" + ); + + let filtered = get( + &app, + &format!( + "/dashboard/runs/{}?stage=selected&sort=bogus&q=%25", + seed.run_id + ), + Some(&admin), + ) + .await; + assert_eq!(filtered.status(), axum::http::StatusCode::OK); + let filtered = crate::web::dashboard::tests::response_text(filtered).await; + assert!( + !filtered.contains("Article 3 about prose"), + "{filtered}" + ); + + let missing = get(&app, "/dashboard/runs/999", Some(&admin)).await; + assert_eq!(missing.status(), axum::http::StatusCode::NOT_FOUND); + } } diff --git a/src/web/static/app.css b/src/web/static/app.css index f7fc733..68793ee 100644 --- a/src/web/static/app.css +++ b/src/web/static/app.css @@ -73,3 +73,37 @@ pre.preview { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-m .versions pre.preview { max-height:6rem; border:0; padding:0; } .versions form { margin:0; } @media (max-width:60rem) { .profile-grid { display:block; } } +||||||| 849231e +/* step 3: dashboard reads (overview, runs, articles) */ +.dashboard h1 { font-size:1.5rem; margin:.5rem 0; } +.dashboard h1 a { color:inherit; } +.dashboard h2 { font-size:1.1rem; margin:1.75rem 0 .5rem; } +.dashboard h3 { font-size:1rem; margin:.5rem 0; } +.dashboard table td { vertical-align:top; } +.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(20rem,1fr)); gap:1rem; margin:1rem 0; } +.card { border:1px solid var(--rule); padding:.75rem 1rem; min-width:0; } +.card h2 { margin-top:0; } +.block { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; margin:.5rem 0; } +.muted { color:var(--muted); } +.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; } +.filters { display:flex; flex-wrap:wrap; gap:.5rem 1rem; align-items:end; margin:1rem 0; } +.filters label { font-size:.85rem; } +.filters input,.filters select { padding:.3rem; font-size:.9rem; } +.run-nav { display:flex; flex-wrap:wrap; gap:1.5rem; margin:.5rem 0 1rem; } +.funnel-cell { min-width:12rem; width:40%; } +.funnel svg { display:block; } +.funnel-table .reasons { color:var(--muted); font-size:.8rem; } +.badge.reason,.badge.running,.badge.requested { color:var(--muted); } +.badge.ok { color:var(--loved); } .badge.degraded { color:var(--good); } .badge.failed,.badge.dry_run { color:var(--down); } +.pager { display:flex; flex-wrap:wrap; gap:1rem; margin:1rem 0; align-items:center; } +details.signals summary { cursor:pointer; } +details.signals summary a { display:inline; } +.signals-body { margin:.5rem 0 .5rem 1rem; font-size:.85rem; } +.signals-body table { width:auto; } +.signals-body tr.muted td { color:var(--muted); } +details.explain { margin:1rem 0; } +.diff .before { color:var(--down); } .diff .after { color:var(--loved); } +.picks,.jobs,.warnings { list-style:none; padding:0; } +.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; } +.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; } +.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; } diff --git a/src/web/static/app.js b/src/web/static/app.js index 071adc1..9686231 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -39,3 +39,21 @@ document.querySelectorAll("details[id]").forEach((details) => { details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed")); } catch (_) {} }); +/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */ +document.querySelectorAll("table[data-filter]").forEach((table) => { + const rows = table.querySelectorAll("tbody tr"); + if (rows.length < 2) return; + const input = document.createElement("input"); + input.type = "search"; + input.className = "table-filter"; + input.placeholder = "Filter rows on this page"; + input.setAttribute("aria-label", "Filter rows on this page"); + const host = table.closest(".scroll-x") || table; + host.parentNode.insertBefore(input, host); + input.addEventListener("input", () => { + const needle = input.value.trim().toLowerCase(); + rows.forEach((row) => { + row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle); + }); + }); +}); diff --git a/src/web/templates/_candidate_row.html b/src/web/templates/_candidate_row.html new file mode 100644 index 0000000..b15d19e --- /dev/null +++ b/src/web/templates/_candidate_row.html @@ -0,0 +1,16 @@ + +
{{ candidate.title }}{% let signals = candidate.signals %}{% include "_signals_table.html" %}
+{{ candidate.feed }} +{{ candidate.words }} +{{ candidate.stage }} +{% if let Some(reason) = candidate.reason %}{{ reason }}{% endif %} +{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} ({{ candidate.admitted_rest }}){% endif %}{% endif %} +{{ candidate.utility }} +{{ candidate.rank }} +{{ candidate.cluster }} +{{ candidate.triage }} +{{ candidate.quality }} +{{ candidate.fit }} +{% if candidate.exploration %}exploration{% endif %}{% if candidate.auto_include %}auto{% endif %} +{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %} + diff --git a/src/web/templates/_signals_table.html b/src/web/templates/_signals_table.html new file mode 100644 index 0000000..b422869 --- /dev/null +++ b/src/web/templates/_signals_table.html @@ -0,0 +1,8 @@ +{% if signals.empty %}

No signals recorded for this row (hygiene exclusion or thin telemetry).

{% else %}
+ +{% for line in signals.lines %}{% endfor %}
signalrawnormweightpresent
{{ line.name }}{{ line.raw }}{{ line.norm }}{{ line.weight }}{% if line.present %}yes{% else %}absent{% endif %}
+

Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · exploration{% endif %}{% if signals.auto_include %} · auto-include{% endif %}

+{% if !signals.top_interests.is_empty() %}

Top interests

    {% for interest in signals.top_interests %}
  • {{ interest.name }} · z {{ interest.z }} · cos {{ interest.cos }}
  • {% endfor %}
{% endif %} +{% if !signals.neighbours.is_empty() %}

Nearest rated neighbours

    {% for neighbour in signals.neighbours %}
  • {{ neighbour.label }} {{ neighbour.title }} · cos {{ neighbour.cos }}
  • {% endfor %}
{% endif %} +{% if !signals.notes.is_empty() %}
    {% for note in signals.notes %}
  • {{ note }}
  • {% endfor %}
{% endif %} +
{% endif %} diff --git a/src/web/templates/dashboard/_pager.html b/src/web/templates/dashboard/_pager.html new file mode 100644 index 0000000..86f3186 --- /dev/null +++ b/src/web/templates/dashboard/_pager.html @@ -0,0 +1 @@ +{% if pager.pages > 1 %}{% else %}

{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}

{% endif %} diff --git a/src/web/templates/dashboard/article.html b/src/web/templates/dashboard/article.html new file mode 100644 index 0000000..a3a4629 --- /dev/null +++ b/src/web/templates/dashboard/article.html @@ -0,0 +1,69 @@ +{% extends "layout.html" %}{% block content %}
+

{{ title }}

+

Article {{ id }} · {{ feed }}{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}

+ +

Article

+
+
Canonical URL
{{ canonical_url }}
+
Published
{{ published_at }}
+
First seen
{{ first_seen }}
+
Words
{{ words }}{% if excerpt_only %} excerpt only{% endif %}
+
Images
{{ image_count }}
+
Sources
{% if sources.is_empty() %}none recorded{% else %}{% for source in sources %}{{ source.kind }} · {{ source.feed }}{% if let Some(category) = source.category %} ({{ category }}){% endif %}{% if !loop.last %}
{% endif %}{% endfor %}{% endif %}
+
Social
{% if social.is_empty() %}none{% else %}{% for item in social %}{% if let Some(url) = item.url %}{{ item.source }}{% else %}{{ item.source }}{% endif %} · {{ item.score }} points · {{ item.comments }} comments{% if !loop.last %}
{% endif %}{% endfor %}{% endif %}
+
In issues
{% if in_issues.is_empty() %}never published{% else %}{% for issue in in_issues %}{{ issue.date }} · {{ issue.section }} · position {{ issue.position }}{% if issue.is_lead %} · lead{% endif %}{% if !loop.last %}
{% endif %}{% endfor %}{% endif %}
+
Current rating
{% if let Some(rating) = rating %}{{ rating }}{% else %}unrated{% endif %}
+
+{% include "_rating_widget.html" %} +{% if let Some(text) = explain %}
Explain (text)
{{ text }}
{% endif %} + +

Assessments

+{% if assessments.is_empty() %}

No LLM assessment stored.

{% else %}{% for assessment in assessments %}
+

{{ assessment.stage }}{% if assessment.rejected %} rejected by provider{% endif %}

+{% if assessment.rejected %}

{{ assessment.rationale }}

{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}

{% else %}
+{% if assessment.stage == "deep" %}
Quality
{{ assessment.score }}
Fit
{{ assessment.fit }}
Category
{{ assessment.category }}
Format
{{ assessment.kind }}
Paywalled guess
{% if assessment.paywalled %}yes{% else %}no{% endif %}
{% else %}
Interest
{{ assessment.score }}
Kind
{{ assessment.kind }}
{% endif %} +
Rationale
{{ assessment.rationale }}
+
Model
{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · profile v{{ assessment.profile_version }}
+
Assessed
{{ assessment.assessed_at }}
+
+{% if !assessment.facets.is_empty() %}
{% for facet in assessment.facets %}{% endfor %}
facetvalue
{{ facet.name }}{{ facet.value }}
{% endif %}{% endif %} +
{% endfor %}{% endif %} + +

Run history

+{% if history.is_empty() %}

Never considered by a run.

{% else %}
+ +{% for row in history %} + + + + + + + + + +{% endfor %}
rundatestagereasonadmitted byutilityrankclustereditor
run {{ row.run_id }} {{ row.status }}{% let signals = row.signals %}{% include "_signals_table.html" %}
{{ row.date }}{{ row.stage }}{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} ({{ row.admitted_rest }}){% endif %}{% endif %}{{ row.utility }}{{ row.rank }}{{ row.cluster }}{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}
{% endif %} + +

Neighbours and interests

+{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}

The latest run recorded no interests or neighbours for this article.

{% else %}
+

Top interests

{% if signals.top_interests.is_empty() %}

None.

{% else %}
{% for interest in signals.top_interests %}{% endfor %}
interestzcos
{{ interest.name }}{{ interest.z }}{{ interest.cos }}
{% endif %}
+

Nearest rated neighbours

{% if signals.neighbours.is_empty() %}

None.

{% else %}
{% for neighbour in signals.neighbours %}{% endfor %}
labelcosarticle
{{ neighbour.label }}{{ neighbour.cos }}{{ neighbour.title }}
{% endif %}
+
{% endif %}{% else %}

No signals recorded.

{% endif %} + +

Embedding

+{% if let Some(embedding) = embedding %}
Model
{{ embedding.model }}
Dimension
{{ embedding.dimension }}
Created
{{ embedding.created_at }}
Input hash
{{ embedding.input_hash }}
{% else %}

No embedding stored.

{% endif %} + +

Rating events

+{% if events.is_empty() %}

No rating events.

{% else %}
+ +{% for event in events %} + + + + + + + + +{% endfor %}
whenlabelvaluekindsourceuserissuenote
{{ event.event_at }}{{ event.label }}{{ event.value }}{{ event.kind }}{{ event.source }}{% if let Some(user) = event.user %}{{ user }}{% else %}{% endif %}{% if let Some(date) = event.issue_date %}{{ date }}{% endif %}{% if let Some(note) = event.note %}{{ note }}{% endif %}
{% endif %} +
{% endblock %} diff --git a/src/web/templates/dashboard/articles.html b/src/web/templates/dashboard/articles.html new file mode 100644 index 0000000..5befdb5 --- /dev/null +++ b/src/web/templates/dashboard/articles.html @@ -0,0 +1,34 @@ +{% extends "layout.html" %}{% block content %}
+

Articles

+
+ + + + + + + + + + + Reset +
+{% include "dashboard/_pager.html" %} +
+ +{% for article in articles %} + + + + + + + + + + + + +{% endfor %}
first seentitlefeedwordslast stagereasonutilitytriagequalityfitratingpublished
{{ article.first_seen }}{{ article.title }}{% if let Some(feed_id) = article.feed_id %}{{ article.feed }}{% else %}{{ article.feed }}{% endif %}{{ article.words }}{% if let Some(stage) = article.stage %}{{ stage }}{% if let Some(run_id) = article.run_id %} {% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}{% endif %}{% else %}never considered{% endif %}{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}{{ article.utility }}{{ article.triage }}{{ article.quality }}{{ article.fit }}{% if let Some(rating) = article.rating %}{{ rating }}{% endif %}{% if let Some(date) = article.published %}{{ date }}{% endif %}
+{% include "dashboard/_pager.html" %} +
{% endblock %} diff --git a/src/web/templates/dashboard/overview.html b/src/web/templates/dashboard/overview.html index 5f869d7..f7f9faf 100644 --- a/src/web/templates/dashboard/overview.html +++ b/src/web/templates/dashboard/overview.html @@ -1 +1,40 @@ -{% extends "layout.html" %}{% block content %}

Overview

The dashboard foundation is ready. Run and article views land in the next dashboard step.

{% endblock %} +{% extends "layout.html" %}{% block content %}
+

Overview

+
+

Last run

+{% if let Some(run) = last_run %}

Run {{ run.id }} · {{ run.date }} · {{ run.status }}

+

Started {{ run.started }} · {{ run.duration }}

+
{% for line in run.lines %}{{ line }}
+{% endfor %}
+{% if run.warnings > 0 %}

{{ run.warnings }} warning{% if run.warnings != 1 %}s{% endif %}

{% endif %} +{% if let Some(error) = run.error %}

{{ error }}

{% endif %} +{% else %}

No runs recorded yet.

{% endif %} +
+

Budget today

+{% if budget.is_empty() %}

No providers configured.

{% else %}
+{% for line in budget %}
{{ line.provider }}
{{ line.spent }} of {{ line.ceiling }}{% if line.over %} ceiling reached{% endif %}
+{% endfor %}
{% endif %} +

Spend on the current UTC day across every recorded run.

+
+

Ratings this week

+{% if ratings.is_empty() %}

No explicit ratings in the last seven days.

{% else %}

{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}: {% for count in ratings %}{{ count.label }} {{ count.count }}{% if !loop.last %} · {% endif %}{% endfor %}

{% endif %} +

Rating history

+
+

Jobs

+{% if active_jobs.is_empty() && finished_jobs.is_empty() %}

No jobs recorded.

{% else %} +{% if !active_jobs.is_empty() %}
    {% for job in active_jobs %}
  • {{ job.name }} {{ job.status }} requested {{ job.requested }}
  • {% endfor %}
{% endif %} +{% if !finished_jobs.is_empty() %}

Recently finished

    {% for job in finished_jobs %}
  • {{ job.name }} {{ job.status }} {{ job.finished }}{% if let Some(message) = job.message %} — {{ message }}{% endif %}
  • {% endfor %}
{% endif %} +{% endif %} +

All jobs

+
+{% if !config_warnings.is_empty() %}

Config on disk

+
    {% for line in config_warnings %}
  • {{ line }}
  • {% endfor %}
+

Settings

+
{% endif %} +
+ +

Unrated picks

+{% if unrated.is_empty() %}

Every pick from the last three issues has a verdict.

{% else %}

Picks from the last three issues without a verdict yet.

+
    {% for pick in unrated %}
  • {{ pick.title }} · {{ pick.feed }} · {{ pick.issue_date }} +{% let widget = pick.widget %}{% include "_rating_widget.html" %}
  • {% endfor %}
{% endif %} +
{% endblock %} diff --git a/src/web/templates/dashboard/run.html b/src/web/templates/dashboard/run.html new file mode 100644 index 0000000..4d7d2be --- /dev/null +++ b/src/web/templates/dashboard/run.html @@ -0,0 +1,56 @@ +{% extends "layout.html" %}{% block content %}
+

Run {{ run.id }} · {{ run.date }} {{ run.status }}{% if run.dry_run %} dry run{% endif %}

+

Started {{ run.started }} · finished {{ run.finished }} · {{ run.duration }} · {{ run.total_cost }}

+ +{% if let Some(error) = run.error %}

{{ error }}

{% endif %} + +

Funnel

+
+ +{% for stage in funnel %} + + + + + +{% endfor %}
stagereachedreachedstopped herewhy
{{ stage.stage }}
{{ stage.reached }}{{ stage.stopped }}{% for reason in stage.reasons %}{{ reason.reason }} {{ reason.count }}{% if !loop.last %} · {% endif %}{% endfor %}
+ +{% if has_report %}
+

Admission mix

{% if admission.is_empty() %}

Nothing admitted.

{% else %}
{% for line in admission %}
{{ line.name }}
{{ line.count }}
{% endfor %}
{% endif %}
+

Preference state

+
rated with embeddings
{{ preference.rated_with_embeddings }}
+
knn gate
{{ preference.knn_gate }}
+
feed gate
{{ preference.feed_gate }}
+
verdicts in prompt
{{ preference.verdicts_in_prompt }}
+
+

Timings

{% for line in timings %}{% endfor %}
stageseconds
{{ line.stage }}{{ line.seconds }}
total{{ timings_total }}
+

Provider usage

{% for line in providers %}{% endfor %}
providerincachedcache writeoutcost
{{ line.provider }}{{ line.input }}{{ line.cached }}{{ line.cache_write }}{{ line.output }}{{ line.cost }}
+

Warnings

{% if warnings.is_empty() %}

None.

{% else %}
    {% for warning in warnings %}
  • {{ warning }}
  • {% endfor %}
{% endif %}
+

Feeds (top {{ feeds.len() }})

{% for line in feeds %}{% endfor %}
feedentries
{{ line.name }}{{ line.count }}
+
{% else %}

This run has no stored report; only the funnel and candidates are available.

{% endif %} + +

Config diff

+{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}

No settings changed since run {{ against }}.

{% else %}

Against the previous non-dry run, run {{ against }}.

+
{% for row in config_diff %}{% endfor %}
keybeforeafter
{{ row.key }}{{ row.before }}{{ row.after }}
{% endif %}{% else %}

No earlier non-dry run to compare against.

{% endif %} + +

Near misses

+{% if near_misses.is_empty() %}

None recorded.

{% else %}
+ +{% for miss in near_misses %}{% endfor %}
#scoretitlefeedqualityfitstagereason
{{ loop.index }}{{ miss.score }}{{ miss.title }}{{ miss.feed }}{{ miss.quality }}{{ miss.fit }}{{ miss.stage }}{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}
{% endif %} + +

Candidates

+
+ + + + + + + Reset +
+{% include "dashboard/_pager.html" %} +
+ +{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}
titlefeedwordsstagereasonadmitted byutilityrankclustertriagequalityfitflagseditor
+{% include "dashboard/_pager.html" %} +
{% endblock %} diff --git a/src/web/templates/dashboard/runs.html b/src/web/templates/dashboard/runs.html new file mode 100644 index 0000000..b7b02a4 --- /dev/null +++ b/src/web/templates/dashboard/runs.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %}{% block content %}
+

Runs

+
+ + +
+
+ +{% for run in runs %} + + + + + + + + + +{% endfor %}
rundatestatusstarteddurationconsidered → eligible → triaged → assessed → shortlisted → selectedcosttotalwarnings
{{ run.id }}{{ run.date }}{{ run.status }}{% if run.dry_run %} dry{% endif %}{{ run.started }}{{ run.duration }}{{ run.funnel }}{{ run.costs }}{{ run.total }}{% if run.warnings > 0 %}{{ run.warnings }}{% else %}0{% endif %}
+{% include "dashboard/_pager.html" %} +
{% endblock %}