Curation v2 step 5: deep assessment, utility, diversified shortlist
assess.rs replaces score.rs (DEEP_INSTRUCTIONS, representative sample with [BEGINNING]/[MIDDLE]/[END], facets, cached deep rows with --rescore bypass), rank.rs adds the utility blend over present signals with gate ramps and the leader-clustered shortlist (cap 2 → 3 → uncapped, protected top-N, exploration reserve), editor.rs replaces select.rs with the §13 rendering and utility-ordered fallbacks. ScoredArticle is gone; Candidate is the only flow type. deep_batch_size replaces score_batch_size. Started by Codex (cut off by its usage limit mid-verification) and finished by a Claude agent from docs/plans/curation-v2-briefs/step5.md; reviewed against plan §12–§13. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
+28
-17
@@ -18,7 +18,7 @@
|
||||
//! no article in the fixtures carries an image, so the EPUB builder's image
|
||||
//! downloader has nothing to fetch.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
use jiff::Timestamp;
|
||||
@@ -26,7 +26,7 @@ use jiff::civil::Date;
|
||||
|
||||
use daily_epub::config::{Config, PublishConfig, ServerConfig, XtcConfig};
|
||||
use daily_epub::curate::llm::{LlmClient, Llms, MockBackend, UsageMeter};
|
||||
use daily_epub::curate::{Curator, admit, editorial};
|
||||
use daily_epub::curate::{Curator, admit, editorial, rank};
|
||||
use daily_epub::db::Db;
|
||||
use daily_epub::extract::Extractor;
|
||||
use daily_epub::types::{
|
||||
@@ -412,7 +412,6 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
|
||||
let candidates = personalized
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.stage == "admitted")
|
||||
.map(Candidate::into_legacy_scored)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(candidates.len(), 5, "nothing is dropped at this volume");
|
||||
// The excerpt-only story is penalized (§3.5).
|
||||
@@ -421,10 +420,10 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
|
||||
.find(|c| c.article.excerpt_only)
|
||||
.expect("the allocator teaser survived");
|
||||
assert!(
|
||||
allocator.prefilter_score
|
||||
allocator.signals.heuristic.unwrap_or_default()
|
||||
< candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.prefilter_score)
|
||||
.filter_map(|candidate| candidate.signals.heuristic)
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
);
|
||||
|
||||
@@ -516,13 +515,12 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let candidates = personalized
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.stage == "admitted")
|
||||
.map(Candidate::into_legacy_scored)
|
||||
.collect::<Vec<_>>();
|
||||
let ids: Vec<i64> = candidates.iter().map(|c| c.article.id).collect();
|
||||
assert_eq!(ids.len(), 5);
|
||||
|
||||
// --- Script DeepSeek: one stage-A batch, one stage-B call, five stage-C
|
||||
// summaries and one front page (§3.6). ---
|
||||
// --- Script DeepSeek: one deep-assessment batch, one editor call, five
|
||||
// summaries and one brief. ---
|
||||
let backend = std::sync::Arc::new(MockBackend::new());
|
||||
let usage = daily_epub::types::TokenUsage {
|
||||
input_tokens: 1000,
|
||||
@@ -535,8 +533,8 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
.enumerate()
|
||||
.map(|(i, id)| {
|
||||
format!(
|
||||
r#"{{"id": {id}, "score": {}, "category": "Tech & Engineering",
|
||||
"rationale": "solid systems writeup", "is_paywalled_guess": false}}"#,
|
||||
r#"{{"id": {id}, "quality": {}, "fit": 7, "category": "Tech & Engineering",
|
||||
"rationale": "solid systems writeup", "paywalled_guess": false, "facets": {{"format":"analysis_essay"}}}}"#,
|
||||
9 - i
|
||||
)
|
||||
})
|
||||
@@ -590,12 +588,12 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
|
||||
let mut candidates = candidates;
|
||||
curator
|
||||
.score(&mut candidates, date())
|
||||
.assess(&mut candidates, true, None, jiff::Timestamp::now())
|
||||
.await
|
||||
.expect("stage A");
|
||||
.expect("deep assessment");
|
||||
assert!(
|
||||
candidates.iter().all(|c| c.llm.is_some()),
|
||||
"every candidate came back scored"
|
||||
candidates.iter().all(|c| c.assessment.deep.is_some()),
|
||||
"every candidate came back assessed"
|
||||
);
|
||||
|
||||
let lineup = curator.select(candidates, date()).await.expect("stage B");
|
||||
@@ -680,7 +678,6 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
|
||||
let mut candidates = personalized
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.stage == "admitted")
|
||||
.map(Candidate::into_legacy_scored)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let backend = std::sync::Arc::new(MockBackend::new());
|
||||
@@ -699,10 +696,24 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
|
||||
},
|
||||
);
|
||||
curator
|
||||
.score(&mut candidates, date())
|
||||
.assess(&mut candidates, true, None, jiff::Timestamp::now())
|
||||
.await
|
||||
.expect("failed batches degrade, not abort");
|
||||
assert!(candidates.iter().all(|candidate| candidate.llm.is_none()));
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.all(|candidate| candidate.assessment.deep.is_none())
|
||||
);
|
||||
// Utility over the present signals, clustered as singletons without
|
||||
// embeddings: every admitted article is shortlisted with a cluster id.
|
||||
let ranked = rank::shortlist(&mut candidates, &HashMap::new(), &cfg.curation.ranking);
|
||||
assert_eq!(ranked.shortlisted, candidates.len());
|
||||
assert_eq!(ranked.clusters, candidates.len());
|
||||
for candidate in &candidates {
|
||||
assert_eq!(candidate.stage, "shortlisted");
|
||||
assert!(candidate.utility.is_some() && candidate.cluster.is_some());
|
||||
assert!(candidate.rank_utility.is_some());
|
||||
}
|
||||
let mut lineup = curator
|
||||
.select(candidates, date())
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user