Curation v2 step 1: three-way feedback, rating events, reader profile

- Migration 0002: rating_events, article_embeddings, interest_embeddings,
  article_assessments, candidate_runs, runs.config_json/provider_costs_json,
  issue_articles.why; copies ratings into rating_events and drops ratings and
  feed_priors (scores stays until step 4).
- Vote is Loved | Good | NotForMe; legacy `up` links still verify as Loved.
- Footer offers Loved it / Good / Not for me; the confirmation page offers the
  other two so a mis-tap can be corrected. handle_rating appends one event.
- db::current_ratings implements the latest-explicit-event rule with summaries
  and facets; `ratings list|set|clear` CLI appends source='cli' events.
- data/profile.md replaces the hard-coded reader prose; the system prompt is
  rebuilt every run in the §8.4 order with a recent-verdicts block.
- Weekly rebuild reads summaries, facets and notes; feed priors removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 03:23:39 +00:00
co-authored by Claude Fable 5.1
parent d0560afa1a
commit 3a9f4b99e0
21 changed files with 1584 additions and 996 deletions
+3 -3
View File
@@ -283,7 +283,7 @@ async fn assemble_build_publish(
)
.expect("render chapters");
let first = issue.lineup.picks[0].article.id;
let expected = auth::rating_url(&cfg.server.public_url, SECRET, date(), first, Vote::Up);
let expected = auth::rating_url(&cfg.server.public_url, SECRET, date(), first, Vote::Loved);
let chapter = chapters
.iter()
.find(|c| c.id == format!("art-{}", issue.lineup.picks[0].article.best_entry_id))
@@ -298,8 +298,8 @@ async fn assemble_build_publish(
SECRET,
date(),
first,
Vote::Up,
&auth::rating_token(SECRET, date(), first, Vote::Up)
Vote::Loved,
&auth::rating_token(SECRET, date(), first, Vote::Loved)
),
"the server must accept the token the EPUB minted"
);
+12 -1
View File
@@ -190,7 +190,18 @@ fn scour_opml_still_yields_the_interest_list() {
// The assembled profile is what actually reaches DeepSeek as the system
// prompt; it must mention the stated preferences and the interests (§3.6).
let document = profile::build(&interests, profile::NO_LEARNED_ADJUSTMENTS);
let profile_file = profile::load_profile(Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/data/profile.md"
)))
.expect("profile file");
let document = profile::build(
&profile_file.body,
&interests,
profile::NO_LEARNED_ADJUSTMENTS,
&[],
60,
);
assert!(document.contains("Rust"));
assert!(document.len() > 1000, "the profile is suspiciously short");
}
+14 -10
View File
@@ -270,16 +270,20 @@ fn rating_links_carry_the_spec_token() {
.expect("article chapter");
let date = issue.meta.date;
let up = build::rating_token("integration-secret", date, 1, Vote::Up);
let down = build::rating_token("integration-secret", date, 1, Vote::Down);
assert_eq!(up.len(), 16);
assert_ne!(up, down);
assert!(chapter.xhtml.contains(&format!(
"https://daily.hallada.net/r/2026-08-15/1/up?t={up}"
)));
assert!(chapter.xhtml.contains(&format!(
"https://daily.hallada.net/r/2026-08-15/1/down?t={down}"
)));
let loved = build::rating_token("integration-secret", date, 1, Vote::Loved);
let good = build::rating_token("integration-secret", date, 1, Vote::Good);
let down = build::rating_token("integration-secret", date, 1, Vote::NotForMe);
assert_eq!(loved.len(), 16);
assert_ne!(loved, good);
assert_ne!(good, down);
for (segment, token) in [("loved", loved), ("good", good), ("down", down)] {
assert!(chapter.xhtml.contains(&format!(
"https://daily.hallada.net/r/2026-08-15/1/{segment}?t={token}"
)));
}
assert!(chapter.xhtml.contains("[ Loved it ]"));
assert!(chapter.xhtml.contains("[ Good ]"));
assert!(chapter.xhtml.contains("[ Not for me ]"));
}
#[test]