Files
the-daily-epub/tests/m3_curation.rs
T
thalladaandClaude Fable 5.1 3a9f4b99e0 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
2026-09-02 03:23:39 +00:00

292 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Milestone 3 integration test: the curation contract (§3.5, §3.6).
//!
//! The stage logic is unit-tested inside `src/curate/*`. What this file guards is
//! the contract *between* the curation stages and everything around them:
//!
//! * the recorded DeepSeek fixtures still parse through the real
//! `score.rs` / `select.rs` / `editorial.rs` parsers into the structures the
//! pipeline consumes, and the lenient parsers still cope with the messy one;
//! * the shipped Scour OPML still yields the ~220 interests the taste profile is
//! assembled from (§3.6a);
//! * the binary starts, migrates a fresh database and exposes the `--skip-llm`
//! path that lets the pipeline run without a DeepSeek key (notes §6).
//!
//! No network, no API key, no DeepSeek call.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use daily_epub::curate::editorial::FrontPageResponse;
use daily_epub::curate::profile;
use daily_epub::curate::score::parse_score_response;
use daily_epub::curate::select::parse_selection_response;
use daily_epub::types::LlmScore;
fn repo(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(rel)
}
fn fixture(name: &str) -> String {
let path = repo(&format!("tests/fixtures/{name}"));
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
}
/// Stage A responses must parse into `{id, score, category, rationale,
/// is_paywalled_guess}` per article (§3.6).
#[test]
fn stage_a_fixture_parses_into_scores() {
let items = parse_score_response(&fixture("deepseek_score_batch.json"));
assert!(items.len() >= 4, "fixture should cover a realistic batch");
for item in &items {
assert!(item.id > 0, "every item carries a positive article id");
assert!(
(0.0..=10.0).contains(&item.score),
"score {} out of range",
item.score
);
assert!(!item.category.is_empty());
assert!(
item.rationale.split_whitespace().count() <= 20,
"rationale must stay under 20 words: {:?}",
item.rationale
);
}
assert!(
items.iter().any(|i| i.is_paywalled_guess),
"the fixture should exercise the paywall flag"
);
// The batch spans the rubric rather than clustering at one score.
let scores: Vec<f64> = items.iter().map(|i| i.score).collect();
let spread = scores.iter().cloned().fold(f64::MIN, f64::max)
- scores.iter().cloned().fold(f64::MAX, f64::min);
assert!(spread >= 3.0, "fixture scores are too uniform to be useful");
// Every item converts into the shared curation type.
let converted: Vec<LlmScore> = items.into_iter().map(LlmScore::from).collect();
assert!(converted.iter().all(|s| (0.0..=10.0).contains(&s.score)));
}
/// The messy fixture must stay messy: it is what proves the parser is lenient
/// (string ids, string scores, out-of-range scores, junk entries).
#[test]
fn stage_a_messy_fixture_is_salvaged_not_rejected() {
let raw = fixture("deepseek_score_batch_messy.json");
// The hard cases are still present in the recording…
assert!(raw.contains("\"id\": \""), "needs a string id");
assert!(raw.contains("\"score\": \""), "needs a string score");
// …and the real parser copes with all of them.
let items = parse_score_response(&raw);
assert!(!items.is_empty(), "the parser salvaged nothing");
assert!(
items.iter().all(|i| (0.0..=10.0).contains(&i.score)),
"out-of-range scores must be clamped: {:?}",
items.iter().map(|i| i.score).collect::<Vec<_>>()
);
assert!(items.iter().all(|i| i.id > 0), "id-less items are skipped");
// A response that is not JSON at all degrades to "no scores", never a panic.
assert!(parse_score_response("I'm sorry, I can't do that.").is_empty());
assert!(parse_score_response("").is_empty());
}
/// Stage B responses must carry `{id, section, position, lead_story}` with
/// exactly one lead, and use only palette section names (§3.6).
#[test]
fn stage_b_fixture_parses_into_a_lineup() {
// The palette from `CurationConfig::default()` (§3.14).
let sections = daily_epub::config::CurationConfig::default().sections;
let picks = parse_selection_response(&fixture("deepseek_lineup.json"));
assert!(picks.len() >= 5);
let mut ids = BTreeSet::new();
let mut leads = 0;
for pick in &picks {
assert!(ids.insert(pick.id), "the same article was picked twice");
assert!(
sections.contains(&pick.section),
"{:?} is not in the configured palette",
pick.section
);
assert!(pick.position >= 1);
if pick.lead_story {
leads += 1;
assert_eq!(
pick.section, "Top Stories",
"the lead sits in the first section"
);
}
}
assert_eq!(leads, 1, "exactly one lead story");
// The reserved section is never offered to the model (§3.6, §3.8).
assert!(
!sections
.iter()
.any(|s| s == daily_epub::types::WORLD_BRIEFING_SECTION)
);
}
/// Stage C's front-page response must deserialize into a 250400 word editor's
/// note plus per-section intros (§3.6).
#[test]
fn stage_c_fixture_parses_into_a_front_page() {
let response: FrontPageResponse = serde_json::from_str(&fixture("deepseek_front_page.json"))
.expect("the front-page fixture must match FrontPageResponse");
let words = response.from_the_editor.split_whitespace().count();
assert!(
(150..=450).contains(&words),
"From the Editor is {words} words; the prompt asks for 250-400"
);
assert!(
response.from_the_editor.contains("\n\n"),
"the prompt asks for 2-4 blank-line separated paragraphs"
);
assert!(
!response.from_the_editor.contains("- "),
"no bullet lists on the front page"
);
assert!(response.section_intros.len() >= 2);
for (section, intro) in &response.section_intros {
let words = intro.split_whitespace().count();
assert!(
(10..=90).contains(&words),
"intro for {section} is {words} words; the prompt asks for 35-60"
);
}
}
/// The taste profile is seeded from this file; a broken export would silently
/// gut the system prompt (§3.6a).
#[test]
fn scour_opml_still_yields_the_interest_list() {
let interests = profile::parse_interests(&repo("data/scour-interests.opml"))
.expect("the shipped OPML must parse");
let unique: BTreeSet<String> = interests.iter().map(|n| n.to_lowercase()).collect();
assert!(
unique.len() > 180,
"expected ~220 interests, found {}",
unique.len()
);
for expected in [
"rust",
"boston tech",
"e-ink displays",
"self-hosted",
"sci-fi",
] {
assert!(unique.contains(expected), "{expected} disappeared");
}
assert!(
!interests.iter().any(|n| n.contains("token=")),
"interest names must not leak the Scour token"
);
// The assembled profile is what actually reaches DeepSeek as the system
// prompt; it must mention the stated preferences and the interests (§3.6).
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");
}
/// The binary must migrate a fresh database and advertise the offline
/// `--skip-llm` path (§2, notes §6).
#[test]
fn binary_migrates_and_offers_the_skip_llm_path() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("nested").join("daily-epub.db");
let bin = env!("CARGO_BIN_EXE_daily-epub");
let out = Command::new(bin)
.args(["db", "migrate"])
.env("DAILY_EPUB_DATABASE_PATH", &db_path)
.output()
.expect("running `daily-epub db migrate`");
assert!(
out.status.success(),
"db migrate failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(db_path.exists(), "the database was not created");
// Re-running is idempotent.
let out = Command::new(bin)
.args(["db", "migrate"])
.env("DAILY_EPUB_DATABASE_PATH", &db_path)
.output()
.expect("re-running `daily-epub db migrate`");
assert!(out.status.success());
let out = Command::new(bin)
.args(["generate", "--help"])
.output()
.expect("running `daily-epub generate --help`");
let help = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success());
assert!(
help.contains("--skip-llm"),
"generate must expose --skip-llm"
);
assert!(help.contains("--dry-run"));
assert!(help.contains("--max-articles"));
}
/// An unreachable Miniflux must fail the run cleanly, with the failure recorded
/// and an error chain a human can act on (§5 verification).
#[test]
fn generate_fails_cleanly_when_miniflux_is_unreachable() {
let dir = tempfile::tempdir().expect("tempdir");
let bin = env!("CARGO_BIN_EXE_daily-epub");
let out = Command::new(bin)
.args([
"generate",
"--dry-run",
"--skip-llm",
"--date",
"2026-08-15",
])
.env("DAILY_EPUB_DATABASE_PATH", dir.path().join("daily-epub.db"))
.env("DAILY_EPUB_OUT_DIR", dir.path().join("out"))
// Port 1 is reserved and never listening.
.env("DAILY_EPUB_MINIFLUX__BASE_URL", "http://127.0.0.1:1")
.env("DAILY_EPUB_MINIFLUX__API_KEY", "not-a-real-key")
.output()
.expect("running `daily-epub generate`");
assert!(!out.status.success(), "the run must not report success");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ingesting entries from miniflux"),
"the error chain must name the failing stage:\n{stderr}"
);
assert!(
stderr.contains("miniflux") && stderr.contains("127.0.0.1:1"),
"the error chain must name the unreachable endpoint:\n{stderr}"
);
// Nothing was written to the output directory.
assert!(
!dir.path()
.join("out")
.join("The Daily EPUB - 2026-08-15.epub")
.exists()
);
}