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:
@@ -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).
|
||||
@@ -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<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 {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -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<AppState> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1343
-2
File diff suppressed because it is too large
Load Diff
@@ -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; } }
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user