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
+70 -51
View File
@@ -269,8 +269,6 @@ pub struct ScoredArticle {
pub prefilter_score: f64,
/// Cached [`composite_social_score`] for the article.
pub social_score: f64,
/// Beta-smoothed per-feed upvote rate applied by the pre-filter (§3.9).
pub feed_prior: f64,
/// `None` until stage A has run (or when `--skip-llm`).
pub llm: Option<LlmScore>,
/// From `curation.always_include_feeds`: may be scored but never dropped (§3.5).
@@ -278,10 +276,10 @@ pub struct ScoredArticle {
}
impl ScoredArticle {
/// Ranking key for stage B: LLM score weighted with social proof and priors (§3.6).
/// Ranking key for stage B: LLM score weighted with social proof (§3.6).
pub fn combined_score(&self) -> f64 {
let llm = self.llm.as_ref().map(|l| l.score).unwrap_or(0.0);
llm * 10.0 + self.social_score * 4.0 + self.feed_prior * 10.0 + self.prefilter_score * 0.1
llm * 10.0 + self.social_score * 4.0 + self.prefilter_score * 0.1
}
}
@@ -565,62 +563,85 @@ pub struct Artifact {
// Feedback (§3.9)
// ---------------------------------------------------------------------------
/// 👍 / 👎 stored as `+1` / `-1` in `ratings.vote` (§3.9).
/// Explicit reader verdict embedded in rating-link URLs (§6.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Vote {
Up,
Down,
#[serde(rename = "loved", alias = "up")]
Loved,
#[serde(rename = "good")]
Good,
#[serde(rename = "down")]
NotForMe,
}
impl Vote {
pub fn as_i64(self) -> i64 {
match self {
Vote::Up => 1,
Vote::Down => -1,
}
}
/// Path segment used in rating links: `up` / `down` (§3.9).
/// Stable path segment used in rating links (§6.1).
pub fn as_str(self) -> &'static str {
match self {
Vote::Up => "up",
Vote::Down => "down",
Vote::Loved => "loved",
Vote::Good => "good",
Vote::NotForMe => "down",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"up" => Some(Vote::Up),
"down" => Some(Vote::Down),
"loved" | "up" => Some(Vote::Loved),
"good" => Some(Vote::Good),
"down" => Some(Vote::NotForMe),
_ => None,
}
}
pub fn value(self, cfg: &crate::config::FeedbackConfig) -> f64 {
match self {
Vote::Loved => cfg.loved_value,
Vote::Good => cfg.good_value,
Vote::NotForMe => cfg.not_for_me_value,
}
}
}
/// A recorded reader vote (`ratings` table, §3.9).
/// One append-only feedback event (`rating_events`, §6.2).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Rating {
pub issue_date: Date,
pub struct RatingEvent {
pub id: i64,
pub article_id: ArticleId,
pub vote: Vote,
pub rated_at: Timestamp,
pub issue_date: Option<Date>,
pub kind: String,
pub source: String,
pub label: String,
pub value: f64,
pub note: Option<String>,
pub event_at: Timestamp,
}
/// Beta-smoothed per-feed upvote rate used by the pre-filter (`feed_priors`, §3.9).
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct FeedPrior {
pub feed_id: FeedId,
pub upvotes: i64,
pub downvotes: i64,
pub included: i64,
/// Descriptive deep-assessment facets (§12.1), populated beginning in step 5.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Facets {
pub format: Option<String>,
pub depth: Option<String>,
pub evidence: Option<String>,
pub commerciality: Option<String>,
pub topic_group: Option<String>,
pub technicality: Option<String>,
pub locality: Option<String>,
pub specific_topics: Option<Vec<String>>,
}
impl FeedPrior {
/// `(up + 1) / (up + down + 2)` — 0.5 with no evidence (§3.9).
pub fn rate(&self) -> f64 {
(self.upvotes + 1) as f64 / (self.upvotes + self.downvotes + 2) as f64
}
/// The current explicit verdict for an article, enriched for prompts (§6.2).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RatedArticle {
pub article_id: ArticleId,
pub issue_date: Option<Date>,
pub title: String,
pub feed_title: String,
pub summary: Option<String>,
pub facets: Option<Facets>,
pub note: Option<String>,
pub value: f64,
pub label: String,
pub event_at: Timestamp,
}
// ---------------------------------------------------------------------------
@@ -684,18 +705,6 @@ mod tests {
assert_eq!(composite_social_score(&[]), 0.0);
}
#[test]
fn feed_prior_is_beta_smoothed() {
assert_eq!(FeedPrior::default().rate(), 0.5);
let p = FeedPrior {
feed_id: 1,
upvotes: 3,
downvotes: 1,
included: 4,
};
assert!((p.rate() - 4.0 / 6.0).abs() < 1e-12);
}
#[test]
fn stats_line_and_reading_time() {
assert_eq!(reading_minutes(0), 1);
@@ -730,8 +739,18 @@ mod tests {
#[test]
fn vote_and_social_source_round_trip() {
assert_eq!(Vote::parse("up"), Some(Vote::Up));
assert_eq!(Vote::Down.as_i64(), -1);
let feedback = crate::config::FeedbackConfig::default();
assert_eq!(Vote::parse("loved"), Some(Vote::Loved));
assert_eq!(Vote::parse("up"), Some(Vote::Loved));
assert_eq!(Vote::parse("good"), Some(Vote::Good));
assert_eq!(Vote::parse("down"), Some(Vote::NotForMe));
assert_eq!(Vote::Loved.as_str(), "loved");
assert_eq!(Vote::Good.as_str(), "good");
assert_eq!(Vote::NotForMe.as_str(), "down");
assert_eq!(Vote::Loved.value(&feedback), 1.0);
assert_eq!(Vote::Good.value(&feedback), 0.35);
assert_eq!(Vote::NotForMe.value(&feedback), -1.0);
assert_eq!(serde_json::to_string(&Vote::NotForMe).unwrap(), "\"down\"");
assert_eq!(
SocialSource::parse("lobsters"),
Some(SocialSource::Lobsters)