Web dashboard step 4: ratings page and profile editor

Add /dashboard/ratings with the preference-state header, the "How ratings
enter the algorithm" explainer, the Current tab (decayed neighbour weight,
feed credit, prompt/rebuild membership, last-run neighbour usage, inline
rating widget with note) and the paginated, filterable Events tab with
superseded marking.

Add /dashboard/profile: profile.md editor with atomic mode-preserving
writes and profile_versions history plus restore, the parsed preview,
OPML interests by theme, learned adjustments with staleness, the stored
system prompt, and the profile-rebuild job form.

Make profile::MAX_RATINGS_IN_REBUILD and profile::stored_version pub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 05:21:33 +00:00
co-authored by Claude Fable 5.1
parent 849231e49a
commit da065437c0
7 changed files with 2346 additions and 5 deletions
@@ -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" `<details>` 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 `<pre>`, 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 `<path>.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":<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).
+4 -2
View File
@@ -21,7 +21,7 @@ pub const REBUILD_INTERVAL_DAYS: i64 = 7;
/// so their `current_ratings` lookback is effectively unbounded. /// so their `current_ratings` lookback is effectively unbounded.
const RATINGS_LOOKBACK_DAYS: i64 = 36_500; const RATINGS_LOOKBACK_DAYS: i64 = 36_500;
pub const KV_LEARNED_ADJUSTMENTS: &str = "taste_profile_learned"; 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."; 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, built_at: String,
} }
async fn stored_version(db: &Db) -> anyhow::Result<Option<(i64, Timestamp)>> { /// 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<Option<(i64, Timestamp)>> {
let Some(raw) = db.kv_get(KV_PROFILE_VERSION).await? else { let Some(raw) = db.kv_get(KV_PROFILE_VERSION).await? else {
return Ok(None); return Ok(None);
}; };
+777 -1
View File
@@ -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::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::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`). /// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() 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 `<path>.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<Option<String>> {
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<i64, WebError> {
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<Option<String>, 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<Vec<VersionView>, 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::<String, _>("saved_at"),
)?;
out.push(VersionView {
id: row.get("id"),
saved_at: format_time(saved_at, config),
saved_by: row
.get::<Option<String>, _>("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<String>,
versions: Vec<VersionView>,
opml_path: String,
opml_count: usize,
opml_error: String,
themes: Vec<ThemeView>,
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<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
) -> Result<Response, WebError> {
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<Response, WebError> {
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<Option<i64>, 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<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Form(form): Form<SaveForm>,
) -> Result<Response, WebError> {
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<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Form(form): Form<RestoreForm>,
) -> Result<Response, WebError> {
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#"<outline text="Rust"/><outline text="Boston"/>"#,
)
.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<i64>)> {
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"
);
}
} }
File diff suppressed because it is too large Load Diff
+16
View File
@@ -57,3 +57,19 @@ thead { position:sticky; top:0; background:var(--bg); }
.rating-prompt { margin-right:.25rem; } .rating-prompt { margin-right:.25rem; }
.rating-note { flex-basis:100%; } .rating-note { flex-basis:100%; }
@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } } @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; } }
+51
View File
@@ -0,0 +1,51 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard profile">
<h1>Profile</h1>
<div class="profile-grid">
<div class="profile-editor">
<h2>profile.md</h2>
<p class="meta"><code>{{ path }}</code>{% if !exists %} — <strong>missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
<form method="post" action="/dashboard/profile" class="profile-form">
<textarea name="content" rows="28" spellcheck="true" required>{{ content }}</textarea>
<div><button type="submit">Save</button> <span class="meta">The next run rebuilds the system prompt from the saved file.</span></div>
</form>
</div>
<div class="profile-preview">
<h2>What the loader parses</h2>
<h3>Passthrough sections</h3>
{% if preview_body.trim().is_empty() %}<p class="meta">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview">{{ preview_body }}</pre>{% endif %}
<h3>Extracted <code>## Interests</code> lines</h3>
{% if preview_interests.is_empty() %}<p class="meta">None. The prompt uses the OPML interests alone.</p>{% else %}<ul>{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
</div>
</div>
<h2>History</h2>
{% if versions.is_empty() %}<p class="meta">No saved versions yet. The first save records the current file as version 1.</p>{% else %}
<p class="meta">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.</p>
<div class="scroll-x"><table class="versions">
<thead><tr><th>Version</th><th>Replaced at</th><th>By</th><th>Size</th><th>Preview</th><th></th></tr></thead>
<tbody>{% for version in versions %}<tr>
<td>#{{ version.id }}</td>
<td>{{ version.saved_at }}</td>
<td>{{ version.saved_by }}</td>
<td>{{ version.bytes }} bytes</td>
<td><pre class="preview">{{ version.preview }}</pre></td>
<td><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button type="submit">Restore</button></form></td>
</tr>{% endfor %}</tbody></table></div>
{% endif %}
<h2>Standing interests</h2>
<p class="meta"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
{% if !opml_error.is_empty() %}<p class="error">! {{ opml_error }}</p>{% endif %}
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt>{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd>{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
<h2>Learned adjustments</h2>
<p class="meta">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong>A rebuild is due</strong> — 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 %}</p>
{% if learned.trim().is_empty() %}<p class="meta">No learned adjustments stored yet.</p>{% else %}<pre class="preview learned">{{ learned }}</pre>{% endif %}
<form method="post" action="/dashboard/jobs/profile-rebuild" class="inline" data-confirm="Rebuild the learned adjustments now? This calls the editor model."><button type="submit"{% if !jobs_enabled %} disabled{% endif %}>Rebuild profile now</button>{% if !jobs_enabled %} <span class="meta">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub profile rebuild</code> instead.</span>{% else %} <span class="meta">Starts the <code>profile-rebuild</code> job through systemd.</span>{% endif %}</form>
<h2>System prompt</h2>
<p class="meta">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.</p>
{% if prompt.is_empty() %}<p class="meta">No prompt has been built yet.</p>{% else %}<details id="profile-prompt"><summary>Show the system prompt</summary><pre class="preview prompt">{{ prompt }}</pre></details>{% endif %}
</section>{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard ratings">
<h1>Ratings</h1>
<p class="preference-summary">{{ summary_line }}</p>
{% if no_embedding_count > 0 %}<p class="notice">{{ no_embedding_count }} rated articles have no embedding and cannot act as neighbours — run the <code>features-backfill</code> job (<code>features backfill --rated-only</code>).</p>{% endif %}
<details id="ratings-how" class="how">
<summary>How ratings enter the algorithm</summary>
<ol>
<li><strong>Prompt verdict block.</strong> 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 (<code>LOVED | title | feed | summary</code>). Only the rank matters here — a verdict never ages out of this block, it is pushed out by newer ones. Tune <a href="/dashboard/settings#curation.feedback"><code>curation.feedback.verdicts_in_prompt</code></a>.</li>
<li><strong>Weekly learned adjustments.</strong> 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 <a href="/dashboard/profile">Profile</a> page.</li>
<li><strong>Rated-neighbour signal.</strong> Each verdict with an embedding is an example with weight <code>value × 0.5^(age / {{ how.half_life_days }} days)</code>, 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 <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.rating_half_life_days</code>, <code>knn_floor</code>, <code>knn_full</code>, <code>neighbour_k</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.knn</code></a>.</li>
<li><strong>Feed affinity.</strong> The same decayed weight is credited to the rated article's direct feeds, split evenly; each feed's Beta-smoothed rate <code>(up + 1) / (up + down + 2)</code> 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 <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.feed_floor</code>, <code>feed_full</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.feed</code></a>.</li>
</ol>
<p>Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
</details>
<nav class="tabs"><a href="{{ current_href }}"{% if tab == "current" %} class="active"{% endif %}>Current ({{ current_total }})</a> <a href="{{ events_href }}"{% if tab == "events" %} class="active"{% endif %}>Events</a></nav>
{% if tab == "events" %}
<form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="events">
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
<label>User <select name="user"><option value="">any</option>{% for username in usernames %}<option value="{{ username }}"{% if filter_user == username.as_str() %} selected{% endif %}>{{ username }}</option>{% endfor %}</select></label>
<label>From <input type="date" name="from" value="{{ filter_from }}"></label>
<label>To <input type="date" name="to" value="{{ filter_to }}"></label>
<button type="submit">Filter</button>
</form>
<p class="meta">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
<div class="scroll-x"><table>
<thead><tr><th>Event</th><th>Verdict</th><th>Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
<tbody>
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
<td>#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
<td><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
<td>{{ event.value }}</td>
<td><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
<td>{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
<td>{{ event.when }}</td>
<td>{{ event.source }}</td>
<td>{{ event.username }}</td>
<td>{{ event.note }}</td>
</tr>
{% endfor %}
{% if events.is_empty() %}<tr><td colspan="9">No rating events match.</td></tr>{% endif %}
</tbody></table></div>
<div class="pager">{% if !prev_href.is_empty() %}<a href="{{ prev_href }}">← Newer</a>{% endif %} {% include "_pagination.html" %} {% if !next_href.is_empty() %}<a href="{{ next_href }}">Older →</a>{% endif %}</div>
{% else %}
<form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="current">
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
<label>Feed <select name="feed"><option value="">any</option>{% for feed in feeds %}<option value="{{ feed.id }}"{% if filter_feed == feed.id.to_string() %} selected{% endif %}>{{ feed.title }}</option>{% endfor %}</select></label>
<label>Title <input type="search" name="q" value="{{ filter_q }}" placeholder="contains…"></label>
<button type="submit">Filter</button>
</form>
<p class="meta">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 %}</p>
<div class="scroll-x"><table class="contributions">
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th>Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
<tbody>
{% for row in current %}<tr>
<td><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
<td><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
<td>{{ row.when }}<br><span class="meta">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
<td>{{ row.note }}</td>
<td>{{ row.age_days }} d → {{ row.decay }}</td>
<td>{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta"></span>{% else %}<span class="meta">no embedding</span>{% endif %}</td>
<td>{% for credit in row.feed_credits %}{{ credit.title }} {{ credit.credit }}{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
<td>{% if row.in_prompt %}✓{% endif %}</td>
<td>{% if row.in_rebuild %}✓{% endif %}</td>
<td>{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
</tr>
{% endfor %}
{% if current.is_empty() %}<tr><td colspan="10">No current verdicts match.</td></tr>{% endif %}
</tbody></table></div>
{% endif %}
</section>{% endblock %}