diff --git a/docs/plans/briefs/web-dashboard/handoff-step4.md b/docs/plans/briefs/web-dashboard/handoff-step4.md new file mode 100644 index 0000000..c784ab5 --- /dev/null +++ b/docs/plans/briefs/web-dashboard/handoff-step4.md @@ -0,0 +1,79 @@ +# Step 4 handoff — ratings page and profile page + +## Landed + +- `src/web/dashboard/ratings.rs` + `dashboard/ratings.html` (`GET /dashboard/ratings`, + plan §10). Header from `PreferenceState::load(..).summary()` (the same loader + the pipeline uses) plus the "N rated articles have no embedding" notice; the + "How ratings enter the algorithm" `
` with the live config values + inlined and links to `/dashboard/settings#curation.feedback`, + `#curation.ranking` and `#curation.ranking.weights.preliminary`. + **Current** tab: one row per article from + `current_ratings_including_cleared(36_500)` with the verdict badge and the + inline rating widget (`show_note = true`), article → `/dashboard/articles/{id}` + with feed and issue date, when · source · username, note, age → decay, + neighbour weight (`value × decay`, or "no embedding"; a "(beyond lookback)" + marker when older than `rating_lookback_days`), feed credit per direct feed, + in-prompt / in-rebuild ticks from the rank among non-cleared verdicts, and + "used last run" from `signals_json.neighbours` of the latest non-dry run + (count of candidates and how many were selected). Filters: label, source, + feed, q. **Events** tab: every `rating_events` row, 100 per page, + `superseded` marked via an `EXISTS` on a later explicit event; filters label, + source, user, from/to date. Filter values are validated (allow-listed labels, + `[a-z0-9_]` sources, parsed dates/ids) and only ever bound; the dynamic + `WHERE` is assembled from fixed clause strings under `sqlx::AssertSqlSafe`. +- `src/web/dashboard/profile.rs` + `dashboard/profile.html` (plan §11). + `GET /dashboard/profile`: the `profile.md` textarea, the server-rendered + parsed preview (passthrough body in a `
`, extracted `## Interests`
+  lines), history (50 newest `profile_versions` with 200-char previews and a
+  Restore form), OPML standing interests grouped by `group_into_themes` with
+  path and count, the learned adjustments with the prompt version, build time,
+  age and whether `profile::is_stale` says a rebuild is due, the collapsed
+  system prompt (`kv.taste_profile`), and the "Rebuild profile now" form
+  posting to `/dashboard/jobs/profile-rebuild` (disabled with a note when
+  `server.jobs_enabled` is false). `POST /dashboard/profile`: CRLF→LF
+  normalization, reject empty or > 64 KB (400), record the previous file in
+  `profile_versions` with `saved_by`, write `.tmp` + rename preserving
+  the existing mode, flash "Saved; the next run rebuilds the system prompt."
+  Identical content is a no-op flash with no version row.
+  `POST /dashboard/profile/restore` (`version_id`): records the current file
+  as a new version and writes the chosen one back; unknown id → 404.
+- `src/curate/profile/mod.rs`: `MAX_RATINGS_IN_REBUILD` and `stored_version`
+  are now `pub` (the profile page shows the prompt version and build time).
+- `src/web/static/app.css`: a `/* step 4 */` block (tabs, filters, contribution
+  table, profile grid, previews).
+
+## Deviations and notes
+
+- Feed credit is shown as `value × decay / n`, not the plan §10 table's
+  `value / n`: `signals::feed_rates` credits the **decayed** weight ("decayed as
+  in §9.2", curation plan §9.3), and the page shows what the ranker computes.
+- "Used last run" scans the run's `candidate_runs` rows once
+  (`signals_json LIKE '%"neighbours":[{%'`) and parses each `SignalsJson`,
+  instead of one `LIKE '%"article_id":,%'` query per rated article; same
+  result, one query.
+- The verdict block and rebuild set are count-bounded, so the neighbour weight
+  is still shown for ratings older than `rating_lookback_days`, with a
+  "(beyond lookback)" marker, since `PreferenceState::load` would not load them.
+- `RatedArticle` does not carry the event `source`; the Current tab reads it
+  with one small query per row (ratings are sparse — hundreds at most).
+- `TasteProfile.verdicts` is not persisted, so the prompt's verdict count is
+  derived by counting lines after `## Recent verdicts` in the stored text.
+- No new migration, no changes to `db.rs`, `web/mod.rs`, `layout.html` or
+  `app.js`.
+
+## Left for later steps
+
+- Step 5 must provide the settings anchors linked from the ratings page:
+  `curation.feedback`, `curation.ranking`, `curation.ranking.weights.preliminary`.
+- Step 6 implements `POST /dashboard/jobs/profile-rebuild`; the profile page
+  already renders the form (with `data-confirm`).
+- Step 3's article detail pages are linked as `/dashboard/articles/{id}`.
+
+## Verification
+
+- `cargo fmt --check`: pass.
+- `cargo clippy --all-targets -- -D warnings`: pass.
+- `cargo test` (outside the sandbox, nothing skipped): **390 lib tests passed,
+  0 failed** (14 new in `web::dashboard::{ratings,profile}`), plus every
+  integration suite green (7, 2, 3, 4, 7, 9, 2 tests).
diff --git a/src/curate/profile/mod.rs b/src/curate/profile/mod.rs
index e6bf7d3..f2a7952 100644
--- a/src/curate/profile/mod.rs
+++ b/src/curate/profile/mod.rs
@@ -21,7 +21,7 @@ pub const REBUILD_INTERVAL_DAYS: i64 = 7;
 /// so their `current_ratings` lookback is effectively unbounded.
 const RATINGS_LOOKBACK_DAYS: i64 = 36_500;
 pub const KV_LEARNED_ADJUSTMENTS: &str = "taste_profile_learned";
-const MAX_RATINGS_IN_REBUILD: usize = 200;
+pub const MAX_RATINGS_IN_REBUILD: usize = 200;
 
 pub const NO_LEARNED_ADJUSTMENTS: &str = "No reader ratings have been collected yet. Judge purely on the stated preferences and interests above.";
 
@@ -239,7 +239,9 @@ struct ProfileVersion {
     built_at: String,
 }
 
-async fn stored_version(db: &Db) -> anyhow::Result> {
+/// The stored `(version, built_at)` of the taste profile, `None` before the
+/// first build. The dashboard's profile page reads it (web plan §11).
+pub async fn stored_version(db: &Db) -> anyhow::Result> {
     let Some(raw) = db.kv_get(KV_PROFILE_VERSION).await? else {
         return Ok(None);
     };
diff --git a/src/web/dashboard/profile.rs b/src/web/dashboard/profile.rs
index 48c6e79..2e49475 100644
--- a/src/web/dashboard/profile.rs
+++ b/src/web/dashboard/profile.rs
@@ -1,10 +1,786 @@
-//! Dashboard: profile pages. Filled in by web dashboard plan step 4.
+//! Dashboard: the profile page (`/dashboard/profile`, web plan §11).
+//!
+//! Edits `profile.md` with version history, shows what the loader parses out
+//! of it, the standing OPML interests by theme, the stored system prompt and
+//! the weekly learned adjustments, and offers the `profile-rebuild` job.
 
+use std::path::Path;
+
+use askama::Template;
 use axum::Router;
+use axum::extract::{Extension, Form, State};
+use axum::response::{IntoResponse, Redirect, Response};
+use axum::routing::{get, post};
+use axum_login::tower_sessions::Session;
+use jiff::Timestamp;
+use serde::Deserialize;
+use sqlx::Row;
 
+use crate::curate::profile::{self, KV_LEARNED_ADJUSTMENTS, ProfileFile, REBUILD_INTERVAL_DAYS};
+use crate::db::{Db, DbError, KV_TASTE_PROFILE};
 use crate::server::AppState;
+use crate::web::session::{AuthSession, Viewer};
+use crate::web::{Flash, Html, Page, WebError, format_time, take_flash};
+
+/// Largest `profile.md` the editor accepts (§11).
+pub const MAX_PROFILE_BYTES: usize = 64 * 1024;
+const PREVIEW_CHARS: usize = 200;
+const VERSIONS_SHOWN: i64 = 50;
 
 /// Routes contributed by this page group (merged by `dashboard::router`).
 pub fn routes() -> Router {
     Router::new()
+        .route("/dashboard/profile", get(show).post(save))
+        .route("/dashboard/profile/restore", post(restore))
+}
+
+// ---------------------------------------------------------------------------
+// File handling
+// ---------------------------------------------------------------------------
+
+/// Normalize a submitted profile: browser textareas send CRLF, the file is LF.
+pub fn normalize(content: &str) -> String {
+    let mut text = content.replace("\r\n", "\n").replace('\r', "\n");
+    if !text.ends_with('\n') {
+        text.push('\n');
+    }
+    text
+}
+
+/// Reject empty or oversized profiles (§11); everything else parses.
+pub fn validate(content: &str) -> Result<(), String> {
+    if content.trim().is_empty() {
+        return Err("the profile cannot be empty".into());
+    }
+    if content.len() > MAX_PROFILE_BYTES {
+        return Err(format!(
+            "the profile is {} bytes; the limit is {} bytes",
+            content.len(),
+            MAX_PROFILE_BYTES
+        ));
+    }
+    Ok(())
+}
+
+/// Write `.tmp` and rename it over `path`, keeping the existing file's
+/// permissions (§11).
+pub fn write_atomically(path: &Path, content: &str) -> anyhow::Result<()> {
+    let mut tmp_name = path
+        .file_name()
+        .map(|name| name.to_os_string())
+        .unwrap_or_else(|| "profile.md".into());
+    tmp_name.push(".tmp");
+    let tmp = path.with_file_name(tmp_name);
+    let permissions = std::fs::metadata(path)
+        .ok()
+        .map(|metadata| metadata.permissions());
+    std::fs::write(&tmp, content)
+        .map_err(|error| anyhow::anyhow!("writing {}: {error}", tmp.display()))?;
+    if let Some(permissions) = permissions
+        && let Err(error) = std::fs::set_permissions(&tmp, permissions)
+    {
+        let _ = std::fs::remove_file(&tmp);
+        return Err(anyhow::anyhow!(
+            "preserving permissions on {}: {error}",
+            tmp.display()
+        ));
+    }
+    if let Err(error) = std::fs::rename(&tmp, path) {
+        let _ = std::fs::remove_file(&tmp);
+        return Err(anyhow::anyhow!(
+            "renaming {} over {}: {error}",
+            tmp.display(),
+            path.display()
+        ));
+    }
+    Ok(())
+}
+
+fn read_profile(path: &Path) -> anyhow::Result> {
+    match std::fs::read_to_string(path) {
+        Ok(content) => Ok(Some(content)),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+        Err(error) => Err(anyhow::anyhow!("reading {}: {error}", path.display())),
+    }
+}
+
+/// The live preview of what the loader extracts (§11): the passthrough body
+/// and the `## Interests` lines.
+pub fn preview(content: &str) -> ProfileFile {
+    profile::parse_profile_str(content)
+}
+
+fn short_preview(content: &str) -> String {
+    let mut out: String = content.chars().take(PREVIEW_CHARS).collect();
+    if content.chars().count() > PREVIEW_CHARS {
+        out.push('…');
+    }
+    out
+}
+
+// ---------------------------------------------------------------------------
+// Versions
+// ---------------------------------------------------------------------------
+
+async fn record_version(
+    db: &Db,
+    content: &str,
+    saved_by: i64,
+    now: Timestamp,
+) -> Result {
+    let row = sqlx::query(
+        "INSERT INTO profile_versions (content, saved_by, saved_at) VALUES (?, ?, ?) RETURNING id",
+    )
+    .bind(content)
+    .bind(saved_by)
+    .bind(crate::db::fmt_ts(now))
+    .fetch_one(db.pool())
+    .await
+    .map_err(DbError::from)?;
+    Ok(row.get("id"))
+}
+
+async fn version_content(db: &Db, id: i64) -> Result, WebError> {
+    let row = sqlx::query("SELECT content FROM profile_versions WHERE id = ?")
+        .bind(id)
+        .fetch_optional(db.pool())
+        .await
+        .map_err(DbError::from)?;
+    Ok(row.map(|row| row.get("content")))
+}
+
+struct VersionView {
+    id: i64,
+    saved_at: String,
+    saved_by: String,
+    bytes: usize,
+    preview: String,
+}
+
+async fn versions(db: &Db, config: &crate::config::Config) -> Result, WebError> {
+    let rows = sqlx::query(
+        "SELECT pv.id, pv.content, pv.saved_at, u.username
+         FROM profile_versions pv LEFT JOIN users u ON u.id = pv.saved_by
+         ORDER BY pv.id DESC LIMIT ?",
+    )
+    .bind(VERSIONS_SHOWN)
+    .fetch_all(db.pool())
+    .await
+    .map_err(DbError::from)?;
+    let mut out = Vec::with_capacity(rows.len());
+    for row in rows {
+        let content: String = row.get("content");
+        let saved_at = crate::db::parse_ts(
+            "profile_versions.saved_at",
+            &row.get::("saved_at"),
+        )?;
+        out.push(VersionView {
+            id: row.get("id"),
+            saved_at: format_time(saved_at, config),
+            saved_by: row
+                .get::, _>("username")
+                .unwrap_or_else(|| "—".into()),
+            bytes: content.len(),
+            preview: short_preview(&content),
+        });
+    }
+    Ok(out)
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+
+struct ThemeView {
+    name: String,
+    members: String,
+    count: usize,
+}
+
+#[derive(Template)]
+#[template(path = "dashboard/profile.html")]
+struct ProfileTemplate {
+    page: Page,
+    path: String,
+    exists: bool,
+    content: String,
+    bytes: usize,
+    max_bytes: usize,
+    preview_body: String,
+    preview_interests: Vec,
+    versions: Vec,
+    opml_path: String,
+    opml_count: usize,
+    opml_error: String,
+    themes: Vec,
+    prompt: String,
+    prompt_chars: usize,
+    prompt_version: String,
+    prompt_built_at: String,
+    prompt_verdicts: usize,
+    learned: String,
+    learned_age: String,
+    rebuild_interval_days: i64,
+    rebuild_due: bool,
+    jobs_enabled: bool,
+}
+
+fn count_verdict_lines(prompt: &str) -> usize {
+    prompt
+        .split_once("## Recent verdicts")
+        .map(|(_, rest)| rest.lines().filter(|line| !line.trim().is_empty()).count())
+        .unwrap_or(0)
+}
+
+async fn show(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+) -> Result {
+    let viewer = auth
+        .user()
+        .await
+        .map(Viewer::from)
+        .ok_or_else(|| WebError::Unauthenticated {
+            next: "/dashboard/profile".into(),
+        })?;
+    let config = state.config();
+    let db = &state.db;
+    let now = Timestamp::now();
+
+    let path = config.profile_path.clone();
+    let stored = read_profile(&path).map_err(WebError::Internal)?;
+    let exists = stored.is_some();
+    let content = stored.unwrap_or_default();
+    let parsed = preview(&content);
+
+    let (opml_count, opml_error, themes) = match profile::parse_interests(&config.interests_opml) {
+        Ok(interests) => {
+            let themes = profile::group_into_themes(&interests)
+                .into_iter()
+                .map(|(name, members)| ThemeView {
+                    name,
+                    count: members.len(),
+                    members: members.join(", "),
+                })
+                .collect();
+            (interests.len(), String::new(), themes)
+        }
+        Err(error) => (0, format!("{error:#}"), Vec::new()),
+    };
+
+    let prompt = db.kv_get(KV_TASTE_PROFILE).await?.unwrap_or_default();
+    let learned = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
+    let version = profile::stored_version(db)
+        .await
+        .map_err(WebError::Internal)?;
+    let (prompt_version, prompt_built_at, learned_age) = match version {
+        Some((version, built_at)) => {
+            let age_days = (now.as_second() - built_at.as_second()).max(0) / 86_400;
+            (
+                version.to_string(),
+                format_time(built_at, &config),
+                format!("{age_days} days old"),
+            )
+        }
+        None => ("—".into(), "never".into(), "never built".into()),
+    };
+    let rebuild_due = profile::is_stale(db).await.map_err(WebError::Internal)?;
+
+    let mut page = Page::new("Profile", Some(viewer), "profile");
+    page.flash = take_flash(&session).await?;
+    Ok(Html(ProfileTemplate {
+        page,
+        path: path.display().to_string(),
+        exists,
+        bytes: content.len(),
+        max_bytes: MAX_PROFILE_BYTES,
+        content,
+        preview_body: parsed.body,
+        preview_interests: parsed.interests,
+        versions: versions(db, &config).await?,
+        opml_path: config.interests_opml.display().to_string(),
+        opml_count,
+        opml_error,
+        themes,
+        prompt_chars: prompt.len(),
+        prompt_verdicts: count_verdict_lines(&prompt),
+        prompt,
+        prompt_version,
+        prompt_built_at,
+        learned,
+        learned_age,
+        rebuild_interval_days: REBUILD_INTERVAL_DAYS,
+        rebuild_due,
+        jobs_enabled: config.server.jobs_enabled,
+    })
+    .into_response())
+}
+
+// ---------------------------------------------------------------------------
+// Save and restore
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Deserialize)]
+pub struct SaveForm {
+    #[serde(default)]
+    content: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct RestoreForm {
+    version_id: i64,
+}
+
+async fn flash_and_redirect(
+    session: &Session,
+    kind: &str,
+    text: String,
+) -> Result {
+    session
+        .insert(
+            "flash",
+            Flash {
+                kind: kind.into(),
+                text,
+            },
+        )
+        .await
+        .map_err(|error| WebError::Internal(error.into()))?;
+    Ok(Redirect::to("/dashboard/profile").into_response())
+}
+
+/// Replace `profile.md` with `content`, recording the previous text as a
+/// version. Returns the new version row's id when one was written.
+async fn replace_profile(
+    state: &AppState,
+    viewer: &Viewer,
+    content: &str,
+) -> Result, WebError> {
+    let config = state.config();
+    let path = config.profile_path.clone();
+    let previous = read_profile(&path).map_err(WebError::Internal)?;
+    let version = match previous {
+        Some(previous) if previous != content => {
+            Some(record_version(&state.db, &previous, viewer.id, Timestamp::now()).await?)
+        }
+        _ => None,
+    };
+    write_atomically(&path, content).map_err(WebError::Internal)?;
+    Ok(version)
+}
+
+async fn save(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+    Form(form): Form,
+) -> Result {
+    let viewer = auth
+        .user()
+        .await
+        .map(Viewer::from)
+        .ok_or_else(|| WebError::Unauthenticated {
+            next: "/dashboard/profile".into(),
+        })?;
+    let content = normalize(&form.content);
+    validate(&content).map_err(WebError::BadRequest)?;
+    let config = state.config();
+    let current = read_profile(&config.profile_path).map_err(WebError::Internal)?;
+    if current.as_deref() == Some(content.as_str()) {
+        return flash_and_redirect(&session, "info", "No changes to save.".into()).await;
+    }
+    replace_profile(&state, &viewer, &content).await?;
+    tracing::info!(
+        user = %viewer.username,
+        bytes = content.len(),
+        path = %config.profile_path.display(),
+        "profile.md saved from the dashboard"
+    );
+    flash_and_redirect(
+        &session,
+        "success",
+        "Saved; the next run rebuilds the system prompt.".into(),
+    )
+    .await
+}
+
+async fn restore(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+    Form(form): Form,
+) -> Result {
+    let viewer = auth
+        .user()
+        .await
+        .map(Viewer::from)
+        .ok_or_else(|| WebError::Unauthenticated {
+            next: "/dashboard/profile".into(),
+        })?;
+    let content = version_content(&state.db, form.version_id)
+        .await?
+        .ok_or(WebError::NotFound)?;
+    validate(&content).map_err(WebError::BadRequest)?;
+    replace_profile(&state, &viewer, &content).await?;
+    tracing::info!(
+        user = %viewer.username,
+        version = form.version_id,
+        "profile.md restored from the dashboard"
+    );
+    flash_and_redirect(
+        &session,
+        "success",
+        format!(
+            "Restored version #{}; the next run rebuilds the system prompt.",
+            form.version_id
+        ),
+    )
+    .await
+}
+
+#[cfg(test)]
+mod tests {
+    use axum::body::{Body, to_bytes};
+    use axum::http::{Method, Request, StatusCode, header};
+    use tower::ServiceExt;
+
+    use super::*;
+    use crate::config::Config;
+    use crate::web::users;
+
+    #[test]
+    fn preview_matches_the_loader() {
+        let raw = "# Reader profile\n\n## Who\nProse.\n\n## Interests\n- Rust\nBoston\n\n## Notes\nKeep.\n";
+        let parsed = preview(raw);
+        assert_eq!(parsed, profile::parse_profile_str(raw));
+        assert_eq!(
+            parsed.body,
+            "# Reader profile\n\n## Who\nProse.\n\n## Notes\nKeep.\n"
+        );
+        assert_eq!(parsed.interests, ["Rust", "Boston"]);
+    }
+
+    #[test]
+    fn normalize_and_validate_bound_the_profile() {
+        assert_eq!(normalize("a\r\nb"), "a\nb\n");
+        assert_eq!(normalize("a\n"), "a\n");
+        assert!(validate("   \n").is_err());
+        assert!(validate("# ok\n").is_ok());
+        let big = "x".repeat(MAX_PROFILE_BYTES + 1);
+        assert!(validate(&big).is_err());
+        assert!(validate(&"x".repeat(MAX_PROFILE_BYTES)).is_ok());
+        assert_eq!(short_preview("short"), "short");
+        let long = "y".repeat(PREVIEW_CHARS + 5);
+        assert_eq!(short_preview(&long).chars().count(), PREVIEW_CHARS + 1);
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn atomic_write_preserves_mode_and_leaves_no_temp_file() {
+        use std::os::unix::fs::PermissionsExt;
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("profile.md");
+        std::fs::write(&path, "old\n").unwrap();
+        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
+        write_atomically(&path, "new\n").unwrap();
+        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new\n");
+        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
+        assert_eq!(mode, 0o600);
+        assert!(!dir.path().join("profile.md.tmp").exists());
+        // A missing file is created rather than failing.
+        let fresh = dir.path().join("fresh.md");
+        write_atomically(&fresh, "created\n").unwrap();
+        assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "created\n");
+    }
+
+    #[test]
+    fn verdict_lines_are_counted_from_the_prompt() {
+        assert_eq!(count_verdict_lines("no block"), 0);
+        assert_eq!(
+            count_verdict_lines("intro\n\n## Recent verdicts\n\nLOVED | a\nGOOD | b\n"),
+            2
+        );
+    }
+
+    async fn setup() -> (tempfile::TempDir, AppState, axum::Router, String) {
+        let dir = tempfile::tempdir().unwrap();
+        let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
+            .await
+            .unwrap();
+        users::add(&db, "tyler", "correct horse battery", true)
+            .await
+            .unwrap();
+        users::add(&db, "reader", "correct horse battery", false)
+            .await
+            .unwrap();
+        let config = Config {
+            profile_path: dir.path().join("profile.md"),
+            interests_opml: dir.path().join("interests.opml"),
+            ..Config::default()
+        };
+        std::fs::write(&config.profile_path, "# Original\n\nProse.\n").unwrap();
+        std::fs::write(
+            &config.interests_opml,
+            r#""#,
+        )
+        .unwrap();
+        db.kv_set(
+            KV_TASTE_PROFILE,
+            "system prompt text\n\n## Recent verdicts\n\nLOVED | x\n",
+        )
+        .await
+        .unwrap();
+        db.kv_set(KV_LEARNED_ADJUSTMENTS, "- Rank depth higher.")
+            .await
+            .unwrap();
+        let state = AppState::new(db, config, None);
+        let app = crate::server::router(state.clone());
+        let cookie = login(&app, "tyler").await;
+        (dir, state, app, cookie)
+    }
+
+    async fn login(app: &axum::Router, username: &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.45")
+                    .body(Body::from(format!(
+                        "username={username}&password=correct+horse+battery&next=%2F"
+                    )))
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(response.status(), StatusCode::SEE_OTHER);
+        response
+            .headers()
+            .get(header::SET_COOKIE)
+            .unwrap()
+            .to_str()
+            .unwrap()
+            .split(';')
+            .next()
+            .unwrap()
+            .to_string()
+    }
+
+    async fn get(app: &axum::Router, cookie: Option<&str>) -> Response {
+        let mut request = Request::builder().uri("/dashboard/profile");
+        if let Some(cookie) = cookie {
+            request = request.header(header::COOKIE, cookie);
+        }
+        app.clone()
+            .oneshot(request.body(Body::empty()).unwrap())
+            .await
+            .unwrap()
+    }
+
+    async fn post(app: &axum::Router, uri: &str, body: String, cookie: Option<&str>) -> Response {
+        let mut request = Request::builder()
+            .method(Method::POST)
+            .uri(uri)
+            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+            .header("sec-fetch-site", "same-origin");
+        if let Some(cookie) = cookie {
+            request = request.header(header::COOKIE, cookie);
+        }
+        app.clone()
+            .oneshot(request.body(Body::from(body)).unwrap())
+            .await
+            .unwrap()
+    }
+
+    async fn text(response: Response) -> String {
+        String::from_utf8(
+            to_bytes(response.into_body(), 2 * 1024 * 1024)
+                .await
+                .unwrap()
+                .to_vec(),
+        )
+        .unwrap()
+    }
+
+    async fn version_rows(db: &Db) -> Vec<(i64, String, Option)> {
+        sqlx::query("SELECT id, content, saved_by FROM profile_versions ORDER BY id")
+            .fetch_all(db.pool())
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|row| (row.get("id"), row.get("content"), row.get("saved_by")))
+            .collect()
+    }
+
+    #[tokio::test]
+    async fn profile_page_shows_editor_preview_interests_prompt_and_rebuild_form() {
+        let (_dir, _state, app, cookie) = setup().await;
+        let response = get(&app, Some(&cookie)).await;
+        assert_eq!(response.status(), StatusCode::OK);
+        let body = text(response).await;
+        assert!(body.contains("# Original"));
+        assert!(body.contains("Prose."));
+        assert!(body.contains("Rust, Boston") || body.contains("Rust") && body.contains("Boston"));
+        assert!(body.contains("2 interests"));
+        assert!(body.contains("system prompt text"));
+        assert!(body.contains("Rank depth higher."));
+        assert!(body.contains("never built"));
+        assert!(body.contains("rebuild is due"));
+        assert!(body.contains(r#"action="/dashboard/jobs/profile-rebuild""#));
+        assert!(body.contains("No saved versions yet"));
+    }
+
+    #[tokio::test]
+    async fn save_writes_the_file_and_records_the_previous_version() {
+        let (_dir, state, app, cookie) = setup().await;
+        let admin = users::find_by_username(&state.db, "tyler")
+            .await
+            .unwrap()
+            .unwrap();
+        let response = post(
+            &app,
+            "/dashboard/profile",
+            "content=%23+New%0D%0A%0D%0A%23%23+Interests%0D%0A-+Writerdeck%0D%0A".into(),
+            Some(&cookie),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::SEE_OTHER);
+        assert_eq!(
+            response.headers().get(header::LOCATION).unwrap(),
+            "/dashboard/profile"
+        );
+        let path = state.config().profile_path.clone();
+        assert_eq!(
+            std::fs::read_to_string(&path).unwrap(),
+            "# New\n\n## Interests\n- Writerdeck\n"
+        );
+        assert!(!path.with_file_name("profile.md.tmp").exists());
+        let rows = version_rows(&state.db).await;
+        assert_eq!(rows.len(), 1);
+        assert_eq!(rows[0].1, "# Original\n\nProse.\n");
+        assert_eq!(rows[0].2, Some(admin.id));
+
+        let page = text(get(&app, Some(&cookie)).await).await;
+        assert!(page.contains("Saved; the next run rebuilds the system prompt."));
+        assert!(page.contains("Writerdeck"));
+        assert!(page.contains("# Original"));
+        assert!(page.contains(">tyler<"));
+
+        // Saving identical content records nothing.
+        let same = post(
+            &app,
+            "/dashboard/profile",
+            "content=%23+New%0A%0A%23%23+Interests%0A-+Writerdeck%0A".into(),
+            Some(&cookie),
+        )
+        .await;
+        assert_eq!(same.status(), StatusCode::SEE_OTHER);
+        assert_eq!(version_rows(&state.db).await.len(), 1);
+    }
+
+    #[tokio::test]
+    async fn restore_swaps_the_file_and_records_the_current_one() {
+        let (_dir, state, app, cookie) = setup().await;
+        post(
+            &app,
+            "/dashboard/profile",
+            "content=%23+Second%0A".into(),
+            Some(&cookie),
+        )
+        .await;
+        let first = version_rows(&state.db).await[0].0;
+        let response = post(
+            &app,
+            "/dashboard/profile/restore",
+            format!("version_id={first}"),
+            Some(&cookie),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::SEE_OTHER);
+        let path = state.config().profile_path.clone();
+        assert_eq!(
+            std::fs::read_to_string(&path).unwrap(),
+            "# Original\n\nProse.\n"
+        );
+        let rows = version_rows(&state.db).await;
+        assert_eq!(rows.len(), 2);
+        assert_eq!(rows[1].1, "# Second\n");
+        let missing = post(
+            &app,
+            "/dashboard/profile/restore",
+            "version_id=999".into(),
+            Some(&cookie),
+        )
+        .await;
+        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn empty_and_oversized_profiles_are_rejected_and_the_file_is_untouched() {
+        let (_dir, state, app, cookie) = setup().await;
+        let empty = post(
+            &app,
+            "/dashboard/profile",
+            "content=+%0A".into(),
+            Some(&cookie),
+        )
+        .await;
+        assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
+        let big = format!("content={}", "x".repeat(MAX_PROFILE_BYTES + 10));
+        let oversized = post(&app, "/dashboard/profile", big, Some(&cookie)).await;
+        assert_eq!(oversized.status(), StatusCode::BAD_REQUEST);
+        assert_eq!(
+            std::fs::read_to_string(&state.config().profile_path).unwrap(),
+            "# Original\n\nProse.\n"
+        );
+        assert!(version_rows(&state.db).await.is_empty());
+    }
+
+    #[tokio::test]
+    async fn profile_routes_are_admin_only() {
+        let (_dir, state, app, _admin) = setup().await;
+        let anonymous = get(&app, None).await;
+        assert_eq!(anonymous.status(), StatusCode::FOUND);
+        assert_eq!(
+            anonymous.headers().get(header::LOCATION).unwrap(),
+            "/login?next=%2Fdashboard%2Fprofile"
+        );
+        let reader = login(&app, "reader").await;
+        assert_eq!(
+            get(&app, Some(&reader)).await.status(),
+            StatusCode::FORBIDDEN
+        );
+        let save = post(
+            &app,
+            "/dashboard/profile",
+            "content=%23+Hacked%0A".into(),
+            Some(&reader),
+        )
+        .await;
+        assert_eq!(save.status(), StatusCode::FORBIDDEN);
+        let restore = post(
+            &app,
+            "/dashboard/profile/restore",
+            "version_id=1".into(),
+            Some(&reader),
+        )
+        .await;
+        assert_eq!(restore.status(), StatusCode::FORBIDDEN);
+        let anonymous_save = post(
+            &app,
+            "/dashboard/profile",
+            "content=%23+Hacked%0A".into(),
+            None,
+        )
+        .await;
+        assert_eq!(anonymous_save.status(), StatusCode::FOUND);
+        assert_eq!(
+            std::fs::read_to_string(&state.config().profile_path).unwrap(),
+            "# Original\n\nProse.\n"
+        );
+    }
 }
diff --git a/src/web/dashboard/ratings.rs b/src/web/dashboard/ratings.rs
index aaff9a8..4ea2f39 100644
--- a/src/web/dashboard/ratings.rs
+++ b/src/web/dashboard/ratings.rs
@@ -1,10 +1,1351 @@
-//! Dashboard: ratings pages. Filled in by web dashboard plan step 4.
+//! Dashboard: the ratings page (`/dashboard/ratings`, web plan §10).
+//!
+//! The **Current** tab shows one row per rated article with every way the
+//! verdict enters the ranker: its decayed neighbour weight (curation plan
+//! §9.2), the feed credit (§9.3), whether it sits in the prompt's verdict block
+//! (§8.4) and in the weekly rebuild set (§8.3), and how many candidates of the
+//! last run listed it among their nearest rated neighbours. The **Events** tab
+//! is the append-only `rating_events` history.
 
+use std::collections::{BTreeMap, HashMap, HashSet};
+
+use askama::Template;
 use axum::Router;
+use axum::extract::{Extension, Query, State};
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use axum_login::tower_sessions::Session;
+use jiff::Timestamp;
+use serde::Deserialize;
+use sqlx::Row;
 
+use crate::config::Config;
+use crate::curate::profile::{MAX_RATINGS_IN_REBUILD, REBUILD_INTERVAL_DAYS};
+use crate::curate::signals::{self, PreferenceState};
+use crate::curate::telemetry::SignalsJson;
+use crate::db::{Db, DbError};
 use crate::server::AppState;
+use crate::types::{Article, ArticleId, FeedId, RatedArticle};
+use crate::web::rate::RatingWidget;
+use crate::web::session::{AuthSession, Viewer};
+use crate::web::{Html, Page, Pagination, WebError, encode_component, format_time, take_flash};
+
+/// The verdict block and the rebuild set are bounded by count, not age, so the
+/// page lists every current verdict (curation plan §8.3, §8.4).
+const CURRENT_LOOKBACK_DAYS: i64 = 36_500;
+const EVENTS_PER_PAGE: u32 = 100;
 
 /// Routes contributed by this page group (merged by `dashboard::router`).
 pub fn routes() -> Router {
-    Router::new()
+    Router::new().route("/dashboard/ratings", get(index))
+}
+
+// ---------------------------------------------------------------------------
+// Contributions (pure; §10 table columns)
+// ---------------------------------------------------------------------------
+
+/// One direct feed's share of a rating (curation plan §9.3).
+#[derive(Debug, Clone, PartialEq)]
+pub struct FeedCredit {
+    pub feed_id: FeedId,
+    pub feed_title: String,
+    /// `value × decay / n` over the article's `n` direct feeds.
+    pub credit: f64,
+}
+
+/// How one current verdict enters the algorithm (§10).
+#[derive(Debug, Clone, PartialEq)]
+pub struct Contribution {
+    pub age_days: f64,
+    /// `0.5 ^ (age / half_life)` (`signals::decay`).
+    pub decay: f64,
+    /// `value × decay`, the `w_i` of §9.2; `None` without an embedding.
+    pub neighbour_weight: Option,
+    /// Older than `rating_lookback_days`: the preference state skips it.
+    pub beyond_lookback: bool,
+    pub feed_credits: Vec,
+    /// Rank among current non-cleared verdicts, newest first; `None` for `cleared`.
+    pub rank: Option,
+    pub in_prompt: bool,
+    pub in_rebuild: bool,
+}
+
+/// Compute the §10 columns for one verdict. `rank` is the article's position
+/// among the current non-cleared verdicts ordered newest first.
+pub fn contribution(
+    rating: &RatedArticle,
+    article: Option<&Article>,
+    has_embedding: bool,
+    rank: Option,
+    now: Timestamp,
+    config: &Config,
+) -> Contribution {
+    let ranking = &config.curation.ranking;
+    let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0;
+    let decay = signals::decay(age_days, ranking.rating_half_life_days);
+    let weight = rating.value * decay;
+    let feeds = article.map(signals::direct_feeds).unwrap_or_default();
+    let feed_credits = if rating.label == "cleared" || feeds.is_empty() {
+        Vec::new()
+    } else {
+        let credit = weight / feeds.len() as f64;
+        feeds
+            .iter()
+            .map(|feed_id| FeedCredit {
+                feed_id: *feed_id,
+                feed_title: feed_title_for(article, *feed_id),
+                credit,
+            })
+            .collect()
+    };
+    let active = rating.label != "cleared";
+    Contribution {
+        age_days,
+        decay,
+        neighbour_weight: (active && has_embedding).then_some(weight),
+        beyond_lookback: age_days > ranking.rating_lookback_days as f64,
+        feed_credits,
+        rank,
+        in_prompt: rank.is_some_and(|rank| rank < config.curation.feedback.verdicts_in_prompt),
+        in_rebuild: rank.is_some_and(|rank| rank < MAX_RATINGS_IN_REBUILD),
+    }
+}
+
+fn feed_title_for(article: Option<&Article>, feed_id: FeedId) -> String {
+    let Some(article) = article else {
+        return format!("feed {feed_id}");
+    };
+    article
+        .sources
+        .iter()
+        .find(|source| source.feed_id == feed_id)
+        .map(|source| source.feed_title.clone())
+        .or_else(|| (article.feed_id == feed_id).then(|| article.feed_title.clone()))
+        .filter(|title| !title.trim().is_empty())
+        .unwrap_or_else(|| format!("feed {feed_id}"))
+}
+
+/// How often a rated article appeared among candidates' nearest neighbours in
+/// one run (§10 "Used last run").
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct NeighbourUse {
+    pub total: usize,
+    pub selected: usize,
+}
+
+/// The latest run that was not a dry run.
+#[derive(Debug, Clone)]
+pub struct LastRun {
+    pub id: i64,
+    pub date: String,
+    pub status: String,
+}
+
+pub async fn last_real_run(db: &Db) -> Result, DbError> {
+    let row = sqlx::query(
+        "SELECT id, date, status FROM runs WHERE status != 'dry_run' ORDER BY id DESC LIMIT 1",
+    )
+    .fetch_optional(db.pool())
+    .await?;
+    Ok(row.map(|row| LastRun {
+        id: row.get("id"),
+        date: row.get("date"),
+        status: row.get("status"),
+    }))
+}
+
+/// Count, per rated article, the candidates of `run_id` whose
+/// `signals_json.neighbours` list it, and how many of those were selected.
+pub async fn neighbour_usage(
+    db: &Db,
+    run_id: i64,
+) -> Result, DbError> {
+    let rows = sqlx::query(
+        "SELECT stage, signals_json FROM candidate_runs
+         WHERE run_id = ? AND signals_json LIKE '%\"neighbours\":[{%'",
+    )
+    .bind(run_id)
+    .fetch_all(db.pool())
+    .await?;
+    let mut usage: HashMap = HashMap::new();
+    for row in rows {
+        let stage: String = row.get("stage");
+        let raw: String = row.get("signals_json");
+        let Ok(signals) = serde_json::from_str::(&raw) else {
+            continue;
+        };
+        let mut seen = HashSet::new();
+        for neighbour in signals.neighbours {
+            if !seen.insert(neighbour.article_id) {
+                continue;
+            }
+            let entry = usage.entry(neighbour.article_id).or_default();
+            entry.total += 1;
+            if stage == "selected" {
+                entry.selected += 1;
+            }
+        }
+    }
+    Ok(usage)
+}
+
+// ---------------------------------------------------------------------------
+// Query parameters
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct RatingsQuery {
+    #[serde(default)]
+    tab: Option,
+    #[serde(default)]
+    label: Option,
+    #[serde(default)]
+    source: Option,
+    #[serde(default)]
+    feed: Option,
+    #[serde(default)]
+    q: Option,
+    #[serde(default)]
+    user: Option,
+    #[serde(default)]
+    from: Option,
+    #[serde(default)]
+    to: Option,
+    #[serde(default)]
+    page: Option,
+}
+
+fn clean(value: &Option) -> Option {
+    value
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .map(str::to_string)
+}
+
+/// Widget labels (`loved|good|down|cleared`) → the stored event label.
+fn event_label(widget: &str) -> Option<&'static str> {
+    match widget {
+        "loved" => Some("loved"),
+        "good" => Some("good"),
+        "down" => Some("not_for_me"),
+        "cleared" => Some("cleared"),
+        _ => None,
+    }
+}
+
+/// Stored event labels → the widget/badge label and its display text.
+fn widget_label(label: &str) -> (&'static str, &'static str) {
+    match label {
+        "loved" => ("loved", "Loved it"),
+        "good" => ("good", "Good"),
+        "not_for_me" | "down" => ("down", "Not for me"),
+        "cleared" => ("cleared", "Cleared"),
+        _ => ("", "Unknown"),
+    }
+}
+
+fn valid_date(value: Option) -> Option {
+    value.filter(|value| value.parse::().is_ok())
+}
+
+fn source_is_valid(source: &str) -> bool {
+    !source.is_empty()
+        && source.len() <= 32
+        && source
+            .chars()
+            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
+}
+
+#[derive(Debug, Clone, Default)]
+struct Filters {
+    tab: String,
+    label: Option,
+    source: Option,
+    feed: Option,
+    q: Option,
+    user: Option,
+    from: Option,
+    to: Option,
+    page: u32,
+}
+
+impl Filters {
+    fn parse(query: RatingsQuery) -> Self {
+        let tab = match query.tab.as_deref() {
+            Some("events") => "events",
+            _ => "current",
+        }
+        .to_string();
+        Self {
+            tab,
+            label: clean(&query.label).filter(|label| event_label(label).is_some()),
+            source: clean(&query.source).filter(|source| source_is_valid(source)),
+            feed: clean(&query.feed).and_then(|feed| feed.parse::().ok()),
+            q: clean(&query.q).map(|q| q.chars().take(200).collect()),
+            user: clean(&query.user).filter(|user| user.len() <= 32),
+            from: valid_date(clean(&query.from)),
+            to: valid_date(clean(&query.to)),
+            page: clean(&query.page)
+                .and_then(|page| page.parse::().ok())
+                .unwrap_or(1)
+                .max(1),
+        }
+    }
+
+    /// The page's own URL with every active filter, used as the widget's `next`.
+    fn href(&self, page: Option) -> String {
+        let mut params = vec![("tab", self.tab.clone())];
+        if let Some(label) = &self.label {
+            params.push(("label", label.clone()));
+        }
+        if let Some(source) = &self.source {
+            params.push(("source", source.clone()));
+        }
+        if let Some(feed) = self.feed {
+            params.push(("feed", feed.to_string()));
+        }
+        if let Some(q) = &self.q {
+            params.push(("q", q.clone()));
+        }
+        if let Some(user) = &self.user {
+            params.push(("user", user.clone()));
+        }
+        if let Some(from) = &self.from {
+            params.push(("from", from.clone()));
+        }
+        if let Some(to) = &self.to {
+            params.push(("to", to.clone()));
+        }
+        if let Some(page) = page.filter(|page| *page > 1) {
+            params.push(("page", page.to_string()));
+        }
+        let query = params
+            .iter()
+            .map(|(key, value)| format!("{key}={}", encode_component(value)))
+            .collect::>()
+            .join("&");
+        format!("/dashboard/ratings?{query}")
+    }
+}
+
+// ---------------------------------------------------------------------------
+// View models
+// ---------------------------------------------------------------------------
+
+struct FeedCreditView {
+    title: String,
+    credit: String,
+}
+
+struct CurrentRow {
+    article_id: ArticleId,
+    title: String,
+    feed_title: String,
+    issue_date: String,
+    badge: String,
+    verdict: String,
+    widget: RatingWidget,
+    when: String,
+    source: String,
+    username: String,
+    note: String,
+    age_days: String,
+    decay: String,
+    has_embedding: bool,
+    neighbour_weight: String,
+    beyond_lookback: bool,
+    feed_credits: Vec,
+    in_prompt: bool,
+    in_rebuild: bool,
+    used_total: usize,
+    used_selected: usize,
+}
+
+struct EventRow {
+    id: i64,
+    article_id: ArticleId,
+    title: String,
+    issue_date: String,
+    kind: String,
+    badge: String,
+    verdict: String,
+    value: String,
+    when: String,
+    source: String,
+    username: String,
+    note: String,
+    superseded: bool,
+}
+
+struct FeedOption {
+    id: FeedId,
+    title: String,
+}
+
+/// Configuration values inlined into the "How ratings enter the algorithm" block.
+struct HowValues {
+    loved: String,
+    good: String,
+    not_for_me: String,
+    verdicts_in_prompt: usize,
+    rebuild_interval_days: i64,
+    max_ratings_in_rebuild: usize,
+    half_life_days: String,
+    lookback_days: i64,
+    neighbour_k: usize,
+    negative_coefficient: String,
+    knn_floor: usize,
+    knn_full: usize,
+    knn_weight: String,
+    feed_floor: usize,
+    feed_full: usize,
+    feed_weight: String,
+}
+
+impl HowValues {
+    fn from_config(config: &Config) -> Self {
+        let feedback = &config.curation.feedback;
+        let ranking = &config.curation.ranking;
+        Self {
+            loved: format!("{:+.2}", feedback.loved_value),
+            good: format!("{:+.2}", feedback.good_value),
+            not_for_me: format!("{:+.2}", feedback.not_for_me_value),
+            verdicts_in_prompt: feedback.verdicts_in_prompt,
+            rebuild_interval_days: REBUILD_INTERVAL_DAYS,
+            max_ratings_in_rebuild: MAX_RATINGS_IN_REBUILD,
+            half_life_days: format!("{}", ranking.rating_half_life_days),
+            lookback_days: ranking.rating_lookback_days,
+            neighbour_k: ranking.neighbour_k,
+            negative_coefficient: format!("{}", ranking.negative_coefficient),
+            knn_floor: ranking.knn_floor,
+            knn_full: ranking.knn_full,
+            knn_weight: format!("{}", ranking.weights.preliminary.knn),
+            feed_floor: ranking.feed_floor,
+            feed_full: ranking.feed_full,
+            feed_weight: format!("{}", ranking.weights.preliminary.feed),
+        }
+    }
+}
+
+#[derive(Template)]
+#[template(path = "dashboard/ratings.html")]
+struct RatingsTemplate {
+    page: Page,
+    tab: String,
+    summary_line: String,
+    no_embedding_count: usize,
+    how: HowValues,
+    filter_label: String,
+    filter_source: String,
+    filter_feed: String,
+    filter_q: String,
+    filter_user: String,
+    filter_from: String,
+    filter_to: String,
+    sources: Vec,
+    feeds: Vec,
+    usernames: Vec,
+    current_href: String,
+    events_href: String,
+    current: Vec,
+    current_total: usize,
+    last_run: Option,
+    events: Vec,
+    pagination: Pagination,
+    prev_href: String,
+    next_href: String,
+}
+
+// ---------------------------------------------------------------------------
+// Handler
+// ---------------------------------------------------------------------------
+
+async fn index(
+    State(state): State,
+    auth: AuthSession,
+    Extension(session): Extension,
+    Query(query): Query,
+) -> Result {
+    let viewer = auth
+        .user()
+        .await
+        .map(Viewer::from)
+        .ok_or_else(|| WebError::Unauthenticated {
+            next: "/dashboard/ratings".into(),
+        })?;
+    let config = state.config();
+    let filters = Filters::parse(query);
+    let now = Timestamp::now();
+    let db = &state.db;
+
+    let preference = PreferenceState::load(db, &config.voyage, &config.curation.ranking, now)
+        .await
+        .map_err(WebError::Internal)?
+        .summary();
+    let ratings = db
+        .current_ratings_including_cleared(CURRENT_LOOKBACK_DAYS)
+        .await?;
+    let active_count = ratings
+        .iter()
+        .filter(|rating| rating.label != "cleared")
+        .count();
+    let embedded = embedded_article_ids(db, &config).await?;
+    let no_embedding_count = ratings
+        .iter()
+        .filter(|rating| rating.label != "cleared" && !embedded.contains(&rating.article_id))
+        .count();
+    let summary_line = format!(
+        "{} rated articles with embeddings → neighbour signal at {:.0}% (floor {}, full {}) · {} attributable feed ratings → feed affinity at {:.0}% · {} verdicts in the prompt · {} in the weekly rebuild set",
+        preference.rated_with_embeddings,
+        preference.knn_gate * 100.0,
+        config.curation.ranking.knn_floor,
+        config.curation.ranking.knn_full,
+        preference.attributable_feed_ratings,
+        preference.feed_gate * 100.0,
+        active_count.min(config.curation.feedback.verdicts_in_prompt),
+        active_count.min(MAX_RATINGS_IN_REBUILD),
+    );
+
+    let usernames_by_id = usernames(db).await?;
+    let sources = event_sources(db).await?;
+    let last_run = last_real_run(db).await?;
+
+    let mut page = Page::new("Ratings", Some(viewer), "ratings");
+    page.flash = take_flash(&session).await?;
+    let mut template = RatingsTemplate {
+        page,
+        tab: filters.tab.clone(),
+        summary_line,
+        no_embedding_count,
+        how: HowValues::from_config(&config),
+        filter_label: filters.label.clone().unwrap_or_default(),
+        filter_source: filters.source.clone().unwrap_or_default(),
+        filter_feed: filters
+            .feed
+            .map(|feed| feed.to_string())
+            .unwrap_or_default(),
+        filter_q: filters.q.clone().unwrap_or_default(),
+        filter_user: filters.user.clone().unwrap_or_default(),
+        filter_from: filters.from.clone().unwrap_or_default(),
+        filter_to: filters.to.clone().unwrap_or_default(),
+        sources,
+        feeds: Vec::new(),
+        usernames: usernames_by_id.values().cloned().collect(),
+        current_href: Filters {
+            tab: "current".into(),
+            ..filters.clone()
+        }
+        .href(None),
+        events_href: Filters {
+            tab: "events".into(),
+            ..filters.clone()
+        }
+        .href(None),
+        current: Vec::new(),
+        current_total: ratings.len(),
+        last_run,
+        events: Vec::new(),
+        pagination: Pagination {
+            page: 1,
+            per_page: EVENTS_PER_PAGE,
+            total: 0,
+        },
+        prev_href: String::new(),
+        next_href: String::new(),
+    };
+
+    if filters.tab == "events" {
+        let (events, total) = load_events(db, &config, &filters, &usernames_by_id).await?;
+        template.pagination = Pagination {
+            page: filters.page,
+            per_page: EVENTS_PER_PAGE,
+            total,
+        };
+        if filters.page > 1 {
+            template.prev_href = filters.href(Some(filters.page - 1));
+        }
+        if filters.page < template.pagination.pages() {
+            template.next_href = filters.href(Some(filters.page + 1));
+        }
+        template.events = events;
+    } else {
+        let usage = match &template.last_run {
+            Some(run) => neighbour_usage(db, run.id).await?,
+            None => HashMap::new(),
+        };
+        let (rows, feeds) = build_current_rows(
+            db,
+            &config,
+            &filters,
+            &ratings,
+            &embedded,
+            &usernames_by_id,
+            &usage,
+            now,
+        )
+        .await?;
+        template.current = rows;
+        template.feeds = feeds;
+    }
+    Ok(Html(template).into_response())
+}
+
+async fn embedded_article_ids(db: &Db, config: &Config) -> Result, WebError> {
+    let rows =
+        sqlx::query("SELECT article_id FROM article_embeddings WHERE model = ? AND dimension = ?")
+            .bind(&config.voyage.model)
+            .bind(config.voyage.output_dimension as i64)
+            .fetch_all(db.pool())
+            .await
+            .map_err(DbError::from)?;
+    Ok(rows
+        .into_iter()
+        .map(|row| row.get::("article_id"))
+        .collect())
+}
+
+async fn usernames(db: &Db) -> Result, WebError> {
+    let rows = sqlx::query("SELECT id, username FROM users ORDER BY username")
+        .fetch_all(db.pool())
+        .await
+        .map_err(DbError::from)?;
+    Ok(rows
+        .into_iter()
+        .map(|row| (row.get::("id"), row.get::("username")))
+        .collect())
+}
+
+async fn event_sources(db: &Db) -> Result, WebError> {
+    let rows = sqlx::query("SELECT DISTINCT source FROM rating_events ORDER BY source")
+        .fetch_all(db.pool())
+        .await
+        .map_err(DbError::from)?;
+    Ok(rows
+        .into_iter()
+        .map(|row| row.get::("source"))
+        .collect())
+}
+
+fn username_for(usernames: &BTreeMap, user_id: Option, source: &str) -> String {
+    match user_id.and_then(|id| usernames.get(&id)) {
+        Some(username) => username.clone(),
+        None if source == "epub" => "e-reader link".into(),
+        None => "—".into(),
+    }
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn build_current_rows(
+    db: &Db,
+    config: &Config,
+    filters: &Filters,
+    ratings: &[RatedArticle],
+    embedded: &HashSet,
+    usernames: &BTreeMap,
+    usage: &HashMap,
+    now: Timestamp,
+) -> Result<(Vec, Vec), WebError> {
+    let next = filters.href(None);
+    let mut rows = Vec::new();
+    let mut feeds: BTreeMap = BTreeMap::new();
+    let mut rank = 0usize;
+    for rating in ratings {
+        let article = db.get_article(rating.article_id).await?;
+        let this_rank = if rating.label == "cleared" {
+            None
+        } else {
+            let current = rank;
+            rank += 1;
+            Some(current)
+        };
+        let has_embedding = embedded.contains(&rating.article_id);
+        let contribution = contribution(
+            rating,
+            article.as_ref(),
+            has_embedding,
+            this_rank,
+            now,
+            config,
+        );
+        let direct = article
+            .as_ref()
+            .map(signals::direct_feeds)
+            .unwrap_or_default();
+        for feed_id in &direct {
+            feeds
+                .entry(*feed_id)
+                .or_insert_with(|| feed_title_for(article.as_ref(), *feed_id));
+        }
+
+        let (badge, verdict) = widget_label(&rating.label);
+        if filters.label.as_deref().is_some_and(|label| label != badge) {
+            continue;
+        }
+        if let Some(feed) = filters.feed
+            && !direct.contains(&feed)
+        {
+            continue;
+        }
+        if let Some(q) = &filters.q
+            && !rating.title.to_lowercase().contains(&q.to_lowercase())
+        {
+            continue;
+        }
+        let source = event_source_for(db, rating).await?;
+        if filters
+            .source
+            .as_deref()
+            .is_some_and(|wanted| wanted != source)
+        {
+            continue;
+        }
+        let used = usage.get(&rating.article_id).copied().unwrap_or_default();
+        let issue_date = rating
+            .issue_date
+            .map(|date| date.to_string())
+            .unwrap_or_default();
+        rows.push(CurrentRow {
+            article_id: rating.article_id,
+            title: if rating.title.trim().is_empty() {
+                format!("article {}", rating.article_id)
+            } else {
+                rating.title.clone()
+            },
+            feed_title: rating.feed_title.clone(),
+            issue_date: issue_date.clone(),
+            badge: badge.to_string(),
+            verdict: verdict.to_string(),
+            widget: RatingWidget {
+                article_id: rating.article_id,
+                issue_date,
+                next: next.clone(),
+                current: badge.to_string(),
+                show_note: true,
+            },
+            when: format_time(rating.event_at, config),
+            username: username_for(usernames, rating.user_id, &source),
+            source,
+            note: rating.note.clone().unwrap_or_default(),
+            age_days: format!("{:.0}", contribution.age_days),
+            decay: format!("{:.3}", contribution.decay),
+            has_embedding,
+            neighbour_weight: contribution
+                .neighbour_weight
+                .map(|weight| format!("{weight:+.3}"))
+                .unwrap_or_default(),
+            beyond_lookback: contribution.beyond_lookback,
+            feed_credits: contribution
+                .feed_credits
+                .iter()
+                .map(|credit| FeedCreditView {
+                    title: credit.feed_title.clone(),
+                    credit: format!("{:+.3}", credit.credit),
+                })
+                .collect(),
+            in_prompt: contribution.in_prompt,
+            in_rebuild: contribution.in_rebuild,
+            used_total: used.total,
+            used_selected: used.selected,
+        });
+    }
+    let feeds = feeds
+        .into_iter()
+        .map(|(id, title)| FeedOption { id, title })
+        .collect();
+    Ok((rows, feeds))
+}
+
+/// The `source` of the event behind a current verdict (`RatedArticle` does not
+/// carry it).
+async fn event_source_for(db: &Db, rating: &RatedArticle) -> Result {
+    let row = sqlx::query(
+        "SELECT source FROM rating_events
+         WHERE article_id = ? AND kind = 'explicit'
+         ORDER BY event_at DESC, id DESC LIMIT 1",
+    )
+    .bind(rating.article_id)
+    .fetch_optional(db.pool())
+    .await
+    .map_err(DbError::from)?;
+    Ok(row
+        .map(|row| row.get::("source"))
+        .unwrap_or_default())
+}
+
+async fn load_events(
+    db: &Db,
+    config: &Config,
+    filters: &Filters,
+    usernames: &BTreeMap,
+) -> Result<(Vec, i64), WebError> {
+    // Every clause below is fixed text chosen from the allow-listed filters;
+    // only values are bound, which is what `AssertSqlSafe` asserts.
+    let mut clauses: Vec<&'static str> = Vec::new();
+    let mut binds: Vec = Vec::new();
+    if let Some(label) = &filters.label
+        && let Some(stored) = event_label(label)
+    {
+        clauses.push("re.label = ?");
+        binds.push(stored.to_string());
+    }
+    if let Some(source) = &filters.source {
+        clauses.push("re.source = ?");
+        binds.push(source.clone());
+    }
+    if let Some(user) = &filters.user {
+        clauses.push("u.username = ? COLLATE NOCASE");
+        binds.push(user.clone());
+    }
+    if let Some(from) = &filters.from {
+        clauses.push("substr(re.event_at, 1, 10) >= ?");
+        binds.push(from.clone());
+    }
+    if let Some(to) = &filters.to {
+        clauses.push("substr(re.event_at, 1, 10) <= ?");
+        binds.push(to.clone());
+    }
+    let where_sql = if clauses.is_empty() {
+        String::new()
+    } else {
+        format!("WHERE {}", clauses.join(" AND "))
+    };
+    let base = format!(
+        "FROM rating_events re
+         LEFT JOIN articles a ON a.id = re.article_id
+         LEFT JOIN users u ON u.id = re.user_id
+         {where_sql}"
+    );
+
+    let count_sql = format!("SELECT COUNT(*) AS n {base}");
+    let mut count = sqlx::query(sqlx::AssertSqlSafe(count_sql));
+    for value in &binds {
+        count = count.bind(value);
+    }
+    let total: i64 = count
+        .fetch_one(db.pool())
+        .await
+        .map_err(DbError::from)?
+        .get("n");
+
+    let pagination = Pagination {
+        page: filters.page,
+        per_page: EVENTS_PER_PAGE,
+        total,
+    };
+    let sql = format!(
+        "SELECT re.id, re.article_id, re.issue_date, re.kind, re.source, re.label, re.value,
+                re.note, re.event_at, re.user_id, COALESCE(a.title, '') AS title,
+                EXISTS (
+                    SELECT 1 FROM rating_events later
+                    WHERE later.article_id = re.article_id AND later.kind = 'explicit'
+                      AND (later.event_at > re.event_at
+                           OR (later.event_at = re.event_at AND later.id > re.id))
+                ) AS superseded
+         {base}
+         ORDER BY re.event_at DESC, re.id DESC
+         LIMIT ? OFFSET ?"
+    );
+    let mut query = sqlx::query(sqlx::AssertSqlSafe(sql));
+    for value in &binds {
+        query = query.bind(value);
+    }
+    let rows = query
+        .bind(i64::from(EVENTS_PER_PAGE))
+        .bind(pagination.offset())
+        .fetch_all(db.pool())
+        .await
+        .map_err(DbError::from)?;
+    let mut events = Vec::with_capacity(rows.len());
+    for row in rows {
+        let label: String = row.get("label");
+        let (badge, verdict) = widget_label(&label);
+        let source: String = row.get("source");
+        let event_at =
+            crate::db::parse_ts("rating_events.event_at", &row.get::("event_at"))?;
+        let article_id: ArticleId = row.get("article_id");
+        let title: String = row.get("title");
+        events.push(EventRow {
+            id: row.get("id"),
+            article_id,
+            title: if title.trim().is_empty() {
+                format!("article {article_id}")
+            } else {
+                title
+            },
+            issue_date: row
+                .get::, _>("issue_date")
+                .unwrap_or_default(),
+            kind: row.get("kind"),
+            badge: badge.to_string(),
+            verdict: if badge.is_empty() {
+                label
+            } else {
+                verdict.to_string()
+            },
+            value: format!("{:+.2}", row.get::("value")),
+            when: format_time(event_at, config),
+            username: username_for(usernames, row.get("user_id"), &source),
+            source,
+            note: row.get::, _>("note").unwrap_or_default(),
+            superseded: row.get::("superseded"),
+        });
+    }
+    Ok((events, total))
+}
+
+#[cfg(test)]
+mod tests {
+    use axum::body::{Body, to_bytes};
+    use axum::http::{Method, Request, StatusCode, header};
+    use tower::ServiceExt;
+
+    use super::*;
+    use crate::types::{Entry, RatingEvent, SourceKind, SourceRef};
+    use crate::web::users;
+
+    fn rated(article_id: ArticleId, label: &str, value: f64, event_at: &str) -> RatedArticle {
+        RatedArticle {
+            article_id,
+            user_id: None,
+            issue_date: None,
+            title: format!("Article {article_id}"),
+            feed_title: "Example Feed".into(),
+            summary: None,
+            facets: None,
+            note: None,
+            value,
+            label: label.into(),
+            event_at: event_at.parse().unwrap(),
+        }
+    }
+
+    fn two_feed_article() -> Article {
+        let mut article = crate::epub::fixtures::article(1, 1001, "Two feeds");
+        article.sources.push(SourceRef {
+            entry_id: 1002,
+            feed_id: 9,
+            feed_title: "Second Feed".into(),
+            category: None,
+            kind: SourceKind::Feed,
+        });
+        article.sources.push(SourceRef {
+            entry_id: 1003,
+            feed_id: 11,
+            feed_title: "Scour".into(),
+            category: None,
+            kind: SourceKind::Scour,
+        });
+        article
+    }
+
+    #[test]
+    fn contribution_matches_hand_checked_values() {
+        let config = Config::default();
+        let now: Timestamp = "2026-09-03T00:00:00Z".parse().unwrap();
+        // Loved 60 days ago with the 60-day half-life: decay 0.5, weight 0.5,
+        // split over the two direct feeds (the Scour source is not direct).
+        let rating = rated(1, "loved", 1.0, "2026-07-05T00:00:00Z");
+        let article = two_feed_article();
+        let c = contribution(&rating, Some(&article), true, Some(59), now, &config);
+        assert!((c.age_days - 60.0).abs() < 1e-9);
+        assert!((c.decay - 0.5).abs() < 1e-9);
+        assert_eq!(c.neighbour_weight, Some(0.5));
+        assert!(!c.beyond_lookback);
+        let credits = c
+            .feed_credits
+            .iter()
+            .map(|credit| (credit.feed_id, credit.feed_title.as_str(), credit.credit))
+            .collect::>();
+        assert_eq!(
+            credits,
+            [(7, "Example Feed", 0.25), (9, "Second Feed", 0.25)]
+        );
+        assert!(c.in_prompt && c.in_rebuild);
+
+        // Rank 60 falls out of the 60-line prompt block but stays in the
+        // 200-item rebuild set; rank 200 is in neither.
+        let c = contribution(&rating, Some(&article), true, Some(60), now, &config);
+        assert!(!c.in_prompt && c.in_rebuild);
+        let c = contribution(&rating, Some(&article), true, Some(200), now, &config);
+        assert!(!c.in_prompt && !c.in_rebuild);
+
+        // Not-for-me 120 days ago: decay 0.25, weight −0.25, one direct feed
+        // gets the whole (negative) credit; no embedding → no neighbour weight.
+        let rating = rated(2, "not_for_me", -1.0, "2026-05-06T00:00:00Z");
+        let article = crate::epub::fixtures::article(2, 1002, "One feed");
+        let c = contribution(&rating, Some(&article), false, Some(0), now, &config);
+        assert!((c.decay - 0.25).abs() < 1e-9);
+        assert_eq!(c.neighbour_weight, None);
+        assert_eq!(c.feed_credits.len(), 1);
+        assert!((c.feed_credits[0].credit + 0.25).abs() < 1e-9);
+
+        // Cleared verdicts contribute nothing anywhere.
+        let rating = rated(3, "cleared", 0.0, "2026-09-02T00:00:00Z");
+        let c = contribution(&rating, Some(&article), true, None, now, &config);
+        assert_eq!(c.neighbour_weight, None);
+        assert!(c.feed_credits.is_empty());
+        assert!(!c.in_prompt && !c.in_rebuild);
+
+        // Older than the 180-day lookback: flagged, weight still shown.
+        let rating = rated(4, "good", 0.35, "2026-01-01T00:00:00Z");
+        let c = contribution(&rating, Some(&article), true, Some(1), now, &config);
+        assert!(c.beyond_lookback);
+        assert!(c.neighbour_weight.is_some());
+    }
+
+    #[test]
+    fn filters_parse_leniently_and_round_trip_into_hrefs() {
+        let filters = Filters::parse(RatingsQuery {
+            tab: Some("events".into()),
+            label: Some("bogus".into()),
+            source: Some("cli".into()),
+            feed: Some("x".into()),
+            q: Some("  Postgres ".into()),
+            user: None,
+            from: Some("2026-01-01".into()),
+            to: Some("not a date".into()),
+            page: Some("3".into()),
+        });
+        assert_eq!(filters.tab, "events");
+        assert_eq!(filters.label, None);
+        assert_eq!(filters.source.as_deref(), Some("cli"));
+        assert_eq!(filters.feed, None);
+        assert_eq!(filters.q.as_deref(), Some("Postgres"));
+        assert_eq!(filters.from.as_deref(), Some("2026-01-01"));
+        assert_eq!(filters.to, None);
+        assert_eq!(filters.page, 3);
+        assert_eq!(
+            filters.href(Some(2)),
+            "/dashboard/ratings?tab=events&source=cli&q=Postgres&from=2026-01-01&page=2"
+        );
+        assert_eq!(Filters::parse(RatingsQuery::default()).tab, "current");
+    }
+
+    async fn seed_article(db: &Db, id: ArticleId, entry_id: i64, title: &str) -> ArticleId {
+        let article = crate::epub::fixtures::article(id, entry_id, title);
+        db.upsert_entry(&Entry {
+            id: article.best_entry_id,
+            feed_id: article.feed_id,
+            feed_title: Some(article.feed_title.clone()),
+            category: article.category.clone(),
+            title: article.title.clone(),
+            url: article.url.clone(),
+            canonical_url: Some(article.canonical_url.clone()),
+            author: article.author.clone(),
+            published_at: article.published_at,
+            comments_url: article.comments_url.clone(),
+            raw_content: article.content_html.clone(),
+            fetched_at: article.first_seen,
+        })
+        .await
+        .unwrap();
+        db.upsert_article(&article).await.unwrap()
+    }
+
+    async fn seed_event(
+        db: &Db,
+        article_id: ArticleId,
+        label: &str,
+        value: f64,
+        source: &str,
+        user_id: Option,
+        event_at: &str,
+    ) -> i64 {
+        db.append_rating_event(&RatingEvent {
+            id: 0,
+            user_id,
+            article_id,
+            issue_date: None,
+            kind: "explicit".into(),
+            source: source.into(),
+            label: label.into(),
+            value,
+            note: Some(format!("note for {article_id}")),
+            event_at: event_at.parse().unwrap(),
+        })
+        .await
+        .unwrap()
+    }
+
+    #[tokio::test]
+    async fn neighbour_usage_counts_candidates_and_selected_ones() {
+        let dir = tempfile::tempdir().unwrap();
+        let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
+            .await
+            .unwrap();
+        for id in 1..=5 {
+            seed_article(&db, id, 1000 + id, &format!("Article {id}")).await;
+        }
+        let run_id = db
+            .start_run("2026-09-03".parse().unwrap(), Timestamp::now())
+            .await
+            .unwrap();
+        sqlx::query("UPDATE runs SET status = 'ok' WHERE id = ?")
+            .bind(run_id)
+            .execute(db.pool())
+            .await
+            .unwrap();
+        let neighbours = |ids: &[ArticleId]| {
+            let list = ids
+                .iter()
+                .map(|id| {
+                    format!(
+                        r#"{{"article_id":{id},"label":"loved","cos":0.8,"title":"Article {id}"}}"#
+                    )
+                })
+                .collect::>()
+                .join(",");
+            format!(r#"{{"v":1,"neighbours":[{list}]}}"#)
+        };
+        for (article_id, stage, json) in [
+            (3, "selected", neighbours(&[1, 2])),
+            (4, "assessed", neighbours(&[1])),
+            (5, "eligible", r#"{"v":1,"neighbours":[]}"#.to_string()),
+        ] {
+            sqlx::query(
+                "INSERT INTO candidate_runs (run_id, article_id, stage, signals_json)
+                 VALUES (?, ?, ?, ?)",
+            )
+            .bind(run_id)
+            .bind(article_id)
+            .bind(stage)
+            .bind(json)
+            .execute(db.pool())
+            .await
+            .unwrap();
+        }
+        let usage = neighbour_usage(&db, run_id).await.unwrap();
+        assert_eq!(
+            usage.get(&1),
+            Some(&NeighbourUse {
+                total: 2,
+                selected: 1
+            })
+        );
+        assert_eq!(
+            usage.get(&2),
+            Some(&NeighbourUse {
+                total: 1,
+                selected: 1
+            })
+        );
+        assert_eq!(usage.get(&3), None);
+        let last = last_real_run(&db).await.unwrap().unwrap();
+        assert_eq!(last.id, run_id);
+        assert_eq!(last.date, "2026-09-03");
+    }
+
+    async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String {
+        let response = app
+            .clone()
+            .oneshot(
+                Request::builder()
+                    .method(Method::POST)
+                    .uri("/login")
+                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+                    .header("sec-fetch-site", "same-origin")
+                    .header("x-forwarded-for", "192.0.2.44")
+                    .body(Body::from(format!(
+                        "username={username}&password={password}&next=%2F"
+                    )))
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(response.status(), StatusCode::SEE_OTHER);
+        response
+            .headers()
+            .get(header::SET_COOKIE)
+            .unwrap()
+            .to_str()
+            .unwrap()
+            .split(';')
+            .next()
+            .unwrap()
+            .to_string()
+    }
+
+    async fn get(app: &axum::Router, uri: &str, cookie: Option<&str>) -> Response {
+        let mut request = Request::builder().uri(uri);
+        if let Some(cookie) = cookie {
+            request = request.header(header::COOKIE, cookie);
+        }
+        app.clone()
+            .oneshot(request.body(Body::empty()).unwrap())
+            .await
+            .unwrap()
+    }
+
+    async fn text(response: Response) -> String {
+        String::from_utf8(
+            to_bytes(response.into_body(), 2 * 1024 * 1024)
+                .await
+                .unwrap()
+                .to_vec(),
+        )
+        .unwrap()
+    }
+
+    #[tokio::test]
+    async fn ratings_page_renders_current_and_events_tabs() {
+        let dir = tempfile::tempdir().unwrap();
+        let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
+            .await
+            .unwrap();
+        let admin = users::add(&db, "tyler", "correct horse battery", true)
+            .await
+            .unwrap();
+        let first = seed_article(&db, 1, 1001, "Postgres failover story").await;
+        let second = seed_article(&db, 2, 1002, "A listicle").await;
+        let third = seed_article(&db, 3, 1003, "Cleared later").await;
+        seed_event(
+            &db,
+            first,
+            "good",
+            0.35,
+            "cli",
+            None,
+            "2026-08-01T00:00:00Z",
+        )
+        .await;
+        seed_event(
+            &db,
+            first,
+            "loved",
+            1.0,
+            "dashboard",
+            Some(admin.id),
+            "2026-08-20T00:00:00Z",
+        )
+        .await;
+        seed_event(
+            &db,
+            second,
+            "not_for_me",
+            -1.0,
+            "epub",
+            None,
+            "2026-08-21T00:00:00Z",
+        )
+        .await;
+        seed_event(
+            &db,
+            third,
+            "loved",
+            1.0,
+            "cli",
+            None,
+            "2026-08-22T00:00:00Z",
+        )
+        .await;
+        seed_event(
+            &db,
+            third,
+            "cleared",
+            0.0,
+            "cli",
+            None,
+            "2026-08-23T00:00:00Z",
+        )
+        .await;
+        // The first article has an embedding of the configured shape.
+        let config = Config::default();
+        let blob =
+            crate::curate::embedding::encode_blob(&vec![0.01; config.voyage.output_dimension])
+                .unwrap();
+        sqlx::query(
+            "INSERT INTO article_embeddings (article_id, model, dimension, input_hash, embedding, created_at)
+             VALUES (?, ?, ?, 'hash', ?, '2026-08-20T00:00:00Z')",
+        )
+        .bind(first)
+        .bind(&config.voyage.model)
+        .bind(config.voyage.output_dimension as i64)
+        .bind(blob)
+        .execute(db.pool())
+        .await
+        .unwrap();
+        let state = AppState::new(db, config, None);
+        let app = crate::server::router(state);
+        let cookie = login_cookie(&app, "tyler", "correct horse battery").await;
+
+        let current = get(&app, "/dashboard/ratings", Some(&cookie)).await;
+        assert_eq!(current.status(), StatusCode::OK);
+        let body = text(current).await;
+        assert!(body.contains("1 rated articles with embeddings"));
+        assert!(body.contains("1 rated articles have no embedding"));
+        assert!(body.contains("Postgres failover story"));
+        assert!(body.contains("A listicle"));
+        assert!(body.contains("Cleared later"));
+        assert!(body.contains(">tyler<"));
+        assert!(body.contains("no embedding"));
+        assert!(body.contains("note for 1"));
+        assert!(body.contains("How ratings enter the algorithm"));
+        assert!(body.contains("/dashboard/articles/1"));
+        assert!(body.contains(r#"name="note""#));
+        assert!(body.contains("/dashboard/settings#curation.feedback"));
+
+        let filtered = get(&app, "/dashboard/ratings?label=down", Some(&cookie)).await;
+        let body = text(filtered).await;
+        assert!(body.contains("A listicle"));
+        assert!(!body.contains("Postgres failover story"));
+        let searched = get(&app, "/dashboard/ratings?q=postgres", Some(&cookie)).await;
+        let body = text(searched).await;
+        assert!(body.contains("Postgres failover story"));
+        assert!(!body.contains("A listicle"));
+
+        let events = get(&app, "/dashboard/ratings?tab=events", Some(&cookie)).await;
+        assert_eq!(events.status(), StatusCode::OK);
+        let body = text(events).await;
+        assert_eq!(body.matches(r#"class="superseded""#).count(), 2, "{body}");
+        assert!(body.contains("e-reader link"));
+        let by_source = get(
+            &app,
+            "/dashboard/ratings?tab=events&source=dashboard",
+            Some(&cookie),
+        )
+        .await;
+        let body = text(by_source).await;
+        assert!(body.contains("Postgres failover story"));
+        assert!(!body.contains("A listicle"));
+        let by_date = get(
+            &app,
+            "/dashboard/ratings?tab=events&from=2026-08-22&to=2026-08-22",
+            Some(&cookie),
+        )
+        .await;
+        let body = text(by_date).await;
+        assert!(body.contains("Cleared later"));
+        assert!(!body.contains("A listicle"));
+        let by_user = get(
+            &app,
+            "/dashboard/ratings?tab=events&user=tyler",
+            Some(&cookie),
+        )
+        .await;
+        let body = text(by_user).await;
+        assert!(body.contains("Postgres failover story"));
+        assert!(!body.contains("A listicle"));
+    }
+
+    #[tokio::test]
+    async fn ratings_page_is_admin_only() {
+        let dir = tempfile::tempdir().unwrap();
+        let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
+            .await
+            .unwrap();
+        users::add(&db, "reader", "correct horse battery", false)
+            .await
+            .unwrap();
+        let app = crate::server::router(AppState::new(db, Config::default(), None));
+        let anonymous = get(&app, "/dashboard/ratings", None).await;
+        assert_eq!(anonymous.status(), StatusCode::FOUND);
+        assert_eq!(
+            anonymous.headers().get(header::LOCATION).unwrap(),
+            "/login?next=%2Fdashboard%2Fratings"
+        );
+        let reader = login_cookie(&app, "reader", "correct horse battery").await;
+        let forbidden = get(&app, "/dashboard/ratings?tab=events", Some(&reader)).await;
+        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
+    }
 }
diff --git a/src/web/static/app.css b/src/web/static/app.css
index e4c269d..f7fc733 100644
--- a/src/web/static/app.css
+++ b/src/web/static/app.css
@@ -57,3 +57,19 @@ thead { position:sticky; top:0; background:var(--bg); }
 .rating-prompt { margin-right:.25rem; }
 .rating-note { flex-basis:100%; }
 @media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
+/* step 4: ratings and profile pages */
+.preference-summary { font-size:1.05rem; }
+.notice { border-left:3px solid var(--accent); padding:.4rem .8rem; }
+.how ol { padding-left:1.2rem; } .how li { margin:.4rem 0; }
+.tabs { margin:1rem 0; } .tabs a { padding:.3rem .7rem; border:1px solid var(--rule); text-decoration:none; } .tabs a.active { background:var(--fg); color:var(--bg); border-color:var(--fg); }
+form.filters { display:flex; flex-wrap:wrap; align-items:end; gap:.8rem; margin:1rem 0; } form.filters label { gap:.15rem; }
+form.inline { display:inline-flex; flex-wrap:wrap; align-items:center; gap:.6rem; margin:.8rem 0; }
+.contributions td { vertical-align:top; } .contributions .rating { margin-top:.3rem; font-size:.85rem; } .contributions .rating-prompt { display:none; }
+tr.superseded td { color:var(--muted); }
+.pager { display:flex; gap:1rem; align-items:center; margin:1rem 0; }
+.profile-grid { display:grid; grid-template-columns:3fr 2fr; gap:1.5rem; }
+.profile-form textarea { width:100%; font:.9rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; resize:vertical; }
+pre.preview { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; border:1px solid var(--rule); padding:.6rem; max-height:28rem; overflow:auto; margin:0; }
+.versions pre.preview { max-height:6rem; border:0; padding:0; }
+.versions form { margin:0; }
+@media (max-width:60rem) { .profile-grid { display:block; } }
diff --git a/src/web/templates/dashboard/profile.html b/src/web/templates/dashboard/profile.html
new file mode 100644
index 0000000..e8d1038
--- /dev/null
+++ b/src/web/templates/dashboard/profile.html
@@ -0,0 +1,51 @@
+{% extends "layout.html" %}{% block content %}
+

Profile

+ +
+
+

profile.md

+

{{ path }}{% if !exists %} — missing; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any ## Interests section is parsed one interest per line; everything else goes into the system prompt verbatim.

+
+ +
The next run rebuilds the system prompt from the saved file.
+
+
+ +
+

What the loader parses

+

Passthrough sections

+{% if preview_body.trim().is_empty() %}

Nothing passes through — the file is empty or only has an Interests section.

{% else %}
{{ preview_body }}
{% endif %} +

Extracted ## Interests lines

+{% if preview_interests.is_empty() %}

None. The prompt uses the OPML interests alone.

{% else %}
    {% for interest in preview_interests %}
  • {{ interest }}
  • {% endfor %}
{% endif %} +
+
+ +

History

+{% if versions.is_empty() %}

No saved versions yet. The first save records the current file as version 1.

{% else %} +

Each row is the text that was replaced by a save (or a restore). Restore writes it back and records what is on disk now as a new version.

+
+ +{% for version in versions %} + + + + + + +{% endfor %}
VersionReplaced atBySizePreview
#{{ version.id }}{{ version.saved_at }}{{ version.saved_by }}{{ version.bytes }} bytes
{{ version.preview }}
+{% endif %} + +

Standing interests

+

{{ opml_path }} · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the ## Interests lines above is what the prompt uses; edit the OPML file to change them.

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

! {{ opml_error }}

{% endif %} +{% if !themes.is_empty() %}
{% for theme in themes %}
{{ theme.name }} ({{ theme.count }})
{{ theme.members }}
{% endfor %}
{% endif %} + +

Learned adjustments

+

Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}A rebuild is due — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}

+{% if learned.trim().is_empty() %}

No learned adjustments stored yet.

{% else %}
{{ learned }}
{% endif %} +
{% if !jobs_enabled %} Jobs are disabled on this server (server.jobs_enabled = false); run daily-epub profile rebuild instead.{% else %} Starts the profile-rebuild job through systemd.{% endif %}
+ +

System prompt

+

Stored after the last run: version {{ prompt_version }}, {{ prompt_chars }} characters, {{ prompt_verdicts }} verdict lines. Every LLM call of a run receives exactly this text.

+{% if prompt.is_empty() %}

No prompt has been built yet.

{% else %}
Show the system prompt
{{ prompt }}
{% endif %} +
{% endblock %} diff --git a/src/web/templates/dashboard/ratings.html b/src/web/templates/dashboard/ratings.html new file mode 100644 index 0000000..e7c2762 --- /dev/null +++ b/src/web/templates/dashboard/ratings.html @@ -0,0 +1,76 @@ +{% extends "layout.html" %}{% block content %}
+

Ratings

+

{{ summary_line }}

+{% if no_embedding_count > 0 %}

{{ no_embedding_count }} rated articles have no embedding and cannot act as neighbours — run the features-backfill job (features backfill --rated-only).

{% endif %} +
+How ratings enter the algorithm +
    +
  1. Prompt verdict block. The {{ how.verdicts_in_prompt }} most recent non-cleared verdicts, newest first, are written into every LLM call's system prompt as one line each (LOVED | title | feed | summary). Only the rank matters here — a verdict never ages out of this block, it is pushed out by newer ones. Tune curation.feedback.verdicts_in_prompt.
  2. +
  3. Weekly learned adjustments. Every {{ how.rebuild_interval_days }} days the editor model rewrites the profile's "Learned adjustments" bullets from the {{ how.max_ratings_in_rebuild }} most recent non-cleared verdicts, including notes and deep-assessment facets. See the Profile page.
  4. +
  5. Rated-neighbour signal. Each verdict with an embedding is an example with weight value × 0.5^(age / {{ how.half_life_days }} days), where loved = {{ how.loved }}, good = {{ how.good }}, not for me = {{ how.not_for_me }}; ratings older than {{ how.lookback_days }} days are not loaded. A candidate's signal is the weighted mean cosine to its {{ how.neighbour_k }} nearest positive examples minus {{ how.negative_coefficient }} × the same over its nearest negative ones. The signal's preliminary weight ({{ how.knn_weight }}) is scaled by a gate that opens above {{ how.knn_floor }} embedded verdicts and is fully open at {{ how.knn_full }}. Tune curation.ranking.rating_half_life_days, knn_floor, knn_full, neighbour_k and weights.preliminary.knn.
  6. +
  7. Feed affinity. The same decayed weight is credited to the rated article's direct feeds, split evenly; each feed's Beta-smoothed rate (up + 1) / (up + down + 2) becomes a candidate's signal (the mean over its rated direct feeds). Its weight ({{ how.feed_weight }}) is gated between {{ how.feed_floor }} and {{ how.feed_full }} attributable ratings. Tune curation.ranking.feed_floor, feed_full and weights.preliminary.feed.
  8. +
+

Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.

+
+ + + +{% if tab == "events" %} +
+ + + + + + + +
+

{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one superseded. Use the clear verdict to retract.

+
+ + +{% for event in events %} + + + + + + + + + + +{% endfor %} +{% if events.is_empty() %}{% endif %} +
EventVerdictValueArticleIssueWhenSourceByNote
#{{ event.id }}{% if event.kind != "explicit" %} {{ event.kind }}{% endif %}{% if event.superseded %} superseded{% endif %}{{ event.verdict }}{{ event.value }}{{ event.title }}{% if !event.issue_date.is_empty() %}{{ event.issue_date }}{% endif %}{{ event.when }}{{ event.source }}{{ event.username }}{{ event.note }}
No rating events match.
+
{% if !prev_href.is_empty() %}← Newer{% endif %} {% include "_pagination.html" %} {% if !next_href.is_empty() %}Older →{% endif %}
+{% else %} +
+ + + + + + +
+

One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}

+
+ + +{% for row in current %} + + + + + + + + + + + +{% endfor %} +{% if current.is_empty() %}{% endif %} +
VerdictArticleWhen · byNoteAge → decayNeighbour weightFeed creditIn promptIn rebuildUsed last run
{{ row.verdict }}{% let widget = row.widget %}{% include "_rating_widget.html" %}{{ row.title }}
{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · {{ row.issue_date }}{% endif %}
{{ row.when }}
{{ row.source }} · {{ row.username }}
{{ row.note }}{{ row.age_days }} d → {{ row.decay }}{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} (beyond lookback){% endif %}{% else if row.badge == "cleared" %}{% else %}no embedding{% endif %}{% for credit in row.feed_credits %}{{ credit.title }} {{ credit.credit }}{% if !loop.last %}
{% endif %}{% endfor %}
{% if row.in_prompt %}✓{% endif %}{% if row.in_rebuild %}✓{% endif %}{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}
No current verdicts match.
+{% endif %} +
{% endblock %}