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
+28 -12
View File
@@ -39,6 +39,7 @@ struct IndexEntry {
source: String,
reading_minutes: i64,
summary: String,
why: Option<String>,
}
struct IndexSection {
@@ -59,7 +60,6 @@ struct InThisIssue {
struct SectionPage {
title: String,
name: String,
intro: Option<String>,
}
struct RatingLinks {
@@ -76,6 +76,7 @@ struct ArticleChapter {
byline: Option<String>,
meta_line: String,
social_line: Option<String>,
why: Option<String>,
summary: Option<String>,
excerpt_only: bool,
body_html: String,
@@ -108,7 +109,10 @@ struct ColophonChapter {
issue_number: i64,
display_date: String,
generated_at: String,
model: String,
bulk_model: String,
editor_model: String,
summaries_model: String,
provider_costs: Vec<ProviderCostLine>,
entries_fetched: i64,
feeds_seen: i64,
candidates: i64,
@@ -120,6 +124,11 @@ struct ColophonChapter {
generator_version: String,
}
struct ProviderCostLine {
provider: String,
cost: String,
}
// ---------------------------------------------------------------------------
// Chapters (§3.10)
// ---------------------------------------------------------------------------
@@ -274,6 +283,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
source: pick.article.feed_title.clone(),
reading_minutes: pick.article.reading_minutes(),
summary: summary_for(issue, pick).unwrap_or_default().to_string(),
why: pick.why.clone(),
})
.collect();
sections.push(IndexSection { name, entries });
@@ -287,6 +297,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
source: "Wikipedia Current Events".into(),
reading_minutes: 3,
summary: "The day's events, as recorded by the Current Events portal.".into(),
why: None,
}],
});
}
@@ -304,14 +315,11 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
})
}
/// A section title page: name + LLM intro (§3.10).
pub fn render_section_page(name: &str, intro: Option<&str>) -> Result<Chapter, EpubError> {
/// A section title page: the name only (§14.2 removed the LLM intros).
pub fn render_section_page(name: &str) -> Result<Chapter, EpubError> {
let tpl = SectionPage {
title: name.to_string(),
name: name.to_string(),
intro: intro
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
};
Ok(Chapter {
id: format!("sec-{name}"),
@@ -364,6 +372,7 @@ pub fn render_article(
byline: article.author.as_ref().map(|a| format!("By {a}")),
meta_line: meta_parts.join(" \u{00b7} "),
social_line: social_line(&article.social),
why: pick.why.clone(),
summary: summary_for(issue, pick).map(str::to_string),
excerpt_only: article.excerpt_only,
body_html: prepare_body(&article.content_html, images_),
@@ -429,16 +438,23 @@ pub fn render_world_briefing(issue: &Issue) -> Result<Option<Chapter>, EpubError
/// Colophon: generation timestamp, models used, token cost, feed counts (§3.10).
pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> {
let colophon = &issue.colophon;
let provider_costs = colophon
.provider_costs
.iter()
.map(|(provider, cost)| ProviderCostLine {
provider: provider.clone(),
cost: format!("${cost:.4}"),
})
.collect();
let tpl = ColophonChapter {
title: "Colophon".into(),
issue_number: issue.meta.issue_number,
display_date: issue.meta.display_date.clone(),
generated_at: issue.meta.generated_at.to_string(),
model: if colophon.model.is_empty() {
"none (heuristic selection)".into()
} else {
colophon.model.clone()
},
bulk_model: colophon.models.bulk.clone(),
editor_model: colophon.models.editor.clone(),
summaries_model: colophon.models.summaries.clone(),
provider_costs,
entries_fetched: colophon.entries_fetched,
feeds_seen: colophon.feeds_seen,
candidates: colophon.candidates,