Curation v2 step 2: Claude Opus 5 editor, the Brief, per-provider budgets
- AnthropicBackend (Messages API, cached system block, output_config.effort,
server-side fallbacks, refusal surfaced as an error); Llms { bulk, editor }
with editor_or_bulk(); PriceTable-based UsageMeter per provider.
- [anthropic], [editorial], deepseek.max_concurrent_requests and
curation.max_article_count config; startup logs resolved providers.
- Budget day is the UTC date of started_at, preloaded from
runs.provider_costs_json; finish_run writes provider_costs_json and
config_json. Stage A batches run concurrently with per-batch budget checks.
- Editor prompt with one-line "why" per pick; no minimum lineup size;
--max-articles is a ceiling; top-up branch deleted; why stored on picks and
issue_articles.why and rendered in chapters and In this issue.
- Summaries on the editor client (3k-token input, concurrency 4, bulk then
excerpt fallback); "The Brief" replaces From the Editor; section intros gone.
- Colophon carries per-provider costs and models.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
+30
-15
@@ -18,18 +18,19 @@
|
||||
//! 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::path::Path;
|
||||
|
||||
use jiff::Timestamp;
|
||||
use jiff::civil::Date;
|
||||
|
||||
use daily_epub::config::{Config, PublishConfig, ServerConfig, XtcConfig};
|
||||
use daily_epub::curate::llm::{LlmClient, MockBackend, UsageMeter};
|
||||
use daily_epub::curate::llm::{LlmClient, Llms, MockBackend, UsageMeter};
|
||||
use daily_epub::curate::{Curator, editorial, prefilter};
|
||||
use daily_epub::db::Db;
|
||||
use daily_epub::extract::Extractor;
|
||||
use daily_epub::types::{
|
||||
Article, Colophon, Edition, Entry, Issue, Lineup, ScoredArticle, SourceKind, Vote,
|
||||
Article, Colophon, Edition, Entry, Issue, Lineup, Models, ScoredArticle, SourceKind, Vote,
|
||||
};
|
||||
use daily_epub::{auth, dedupe, epub, miniflux, pipeline, publish};
|
||||
|
||||
@@ -400,7 +401,7 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
|
||||
let articles = ingest_dedupe_extract_persist(&db).await;
|
||||
|
||||
// --- Stages 6–7 with no LLM at all (notes §6) ---
|
||||
let curator = Curator::new(cfg.clone(), db.clone(), None);
|
||||
let curator = Curator::new(cfg.clone(), db.clone(), Llms::default());
|
||||
let candidates = curator
|
||||
.prefilter(articles, date())
|
||||
.await
|
||||
@@ -432,7 +433,12 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
|
||||
);
|
||||
|
||||
let colophon = Colophon {
|
||||
model: "none (--skip-llm)".into(),
|
||||
provider_costs: BTreeMap::new(),
|
||||
models: Models {
|
||||
bulk: "none".into(),
|
||||
editor: "none".into(),
|
||||
summaries: "none".into(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
candidates: 5,
|
||||
@@ -504,6 +510,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let usage = daily_epub::types::TokenUsage {
|
||||
input_tokens: 1000,
|
||||
cached_tokens: 500,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 200,
|
||||
};
|
||||
let scores: Vec<String> = ids
|
||||
@@ -544,8 +551,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
);
|
||||
}
|
||||
backend.push(
|
||||
r#"{"from_the_editor": "Today's issue leans on storage internals.\n\nRead on.",
|
||||
"section_intros": {"Top Stories": "The day in one place."}}"#,
|
||||
r#"{"brief": "Today's issue leans on storage internals.\n\nRead on."}"#,
|
||||
usage,
|
||||
);
|
||||
|
||||
@@ -556,7 +562,14 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
meter.clone(),
|
||||
backend.clone(),
|
||||
);
|
||||
let curator = Curator::new(cfg.clone(), db.clone(), Some(llm));
|
||||
let curator = Curator::new(
|
||||
cfg.clone(),
|
||||
db.clone(),
|
||||
Llms {
|
||||
bulk: Some(llm),
|
||||
editor: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut candidates = candidates;
|
||||
curator
|
||||
@@ -590,12 +603,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
"the model's summaries were used, not excerpts"
|
||||
);
|
||||
assert!(editorial_doc.front_page_html.contains("storage internals"));
|
||||
assert_eq!(
|
||||
editorial_doc
|
||||
.section_intros
|
||||
.get("Top Stories")
|
||||
.map(String::as_str),
|
||||
Some("The day in one place.")
|
||||
assert!(
|
||||
lineup.picks.iter().all(|p| p.why.is_none()),
|
||||
"the scripted editor gave no why lines"
|
||||
);
|
||||
|
||||
// Every scripted response was consumed, and the meter priced them (§3.6).
|
||||
@@ -615,7 +625,12 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
|
||||
// And it all assembles, builds and publishes like the skip-llm route does.
|
||||
let colophon = Colophon {
|
||||
model: cfg.deepseek.model.clone(),
|
||||
provider_costs: BTreeMap::from([("deepseek".to_string(), meter.cost_usd())]),
|
||||
models: Models {
|
||||
bulk: cfg.deepseek.model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.deepseek.model),
|
||||
summaries: cfg.deepseek.model.clone(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
candidates: 5,
|
||||
@@ -625,6 +640,6 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let mut lineup = lineup;
|
||||
pipeline::apply_summaries(&mut lineup, &editorial_doc);
|
||||
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await;
|
||||
assert_eq!(issue.colophon.model, cfg.deepseek.model);
|
||||
assert_eq!(issue.colophon.models.bulk, cfg.deepseek.model);
|
||||
assert!(issue.colophon.cost_usd > 0.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user