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
80 lines
4.7 KiB
Markdown
80 lines
4.7 KiB
Markdown
# 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).
|