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:
2026-09-02 03:56:14 +00:00
co-authored by Claude Fable 5.1
parent 3a9f4b99e0
commit 57efbb49b4
30 changed files with 2539 additions and 1079 deletions
+25 -19
View File
@@ -17,7 +17,7 @@ use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use daily_epub::curate::editorial::FrontPageResponse;
use daily_epub::curate::editorial::BriefResponse;
use daily_epub::curate::profile;
use daily_epub::curate::score::parse_score_response;
use daily_epub::curate::select::parse_selection_response;
@@ -130,35 +130,41 @@ fn stage_b_fixture_parses_into_a_lineup() {
);
}
/// Stage C's front-page response must deserialize into a 250400 word editor's
/// note plus per-section intros (§3.6).
/// The Brief must deserialize into 120200 words of plain prose that names at
/// least three picks by title (§14.2). Section intros are gone.
#[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");
fn stage_c_fixture_parses_into_the_brief() {
let response: BriefResponse = serde_json::from_str(&fixture("claude_brief.json"))
.expect("the brief fixture must match BriefResponse");
let words = response.from_the_editor.split_whitespace().count();
let words = response.brief.split_whitespace().count();
assert!(
(150..=450).contains(&words),
"From the Editor is {words} words; the prompt asks for 250-400"
(100..=220).contains(&words),
"The Brief is {words} words; the prompt asks for 120-200"
);
assert!(
response.from_the_editor.contains("\n\n"),
"the prompt asks for 2-4 blank-line separated paragraphs"
!response.brief.contains("- ") && !response.brief.contains('#'),
"no bullets or headings in the brief"
);
let titles = response.brief.matches('"').count() / 2;
assert!(
!response.from_the_editor.contains("- "),
"no bullet lists on the front page"
titles >= 3,
"the brief names at least three picks; found {titles}"
);
assert!(response.section_intros.len() >= 2);
for (section, intro) in &response.section_intros {
let words = intro.split_whitespace().count();
for banned in [
"delve",
"dive",
"explore",
"a mix of",
"something for everyone",
] {
assert!(
(10..=90).contains(&words),
"intro for {section} is {words} words; the prompt asks for 35-60"
!response.brief.to_lowercase().contains(banned),
"banned phrase {banned}"
);
}
let value: serde_json::Value = serde_json::from_str(&fixture("claude_brief.json")).unwrap();
assert!(value.get("section_intros").is_none());
}
/// The taste profile is seeded from this file; a broken export would silently