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
+1 -6
View File
@@ -66,12 +66,7 @@ pub fn render_all(
];
for name in section_names(issue) {
let intro = issue
.editorial
.section_intros
.get(&name)
.map(|s| s.as_str());
chapters.push(render_section_page(&name, intro)?);
chapters.push(render_section_page(&name)?);
for pick in issue.lineup.section_picks(&name) {
chapters.push(render_article(
issue,
+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,
+11 -4
View File
@@ -86,6 +86,7 @@ pub fn issue() -> Issue {
section: "Top Stories".into(),
position: 0,
is_lead: true,
why: Some("The systems story with enough operational detail to matter".into()),
summary: Some("What it argues, and why it is worth the time.".into()),
llm: None,
discussion: Some(discussion(1, 1001)),
@@ -95,12 +96,11 @@ pub fn issue() -> Issue {
section: "Niche Corner".into(),
position: 0,
is_lead: false,
why: Some("A small-scene delight outside the usual technical orbit".into()),
summary: None,
llm: None,
discussion: None,
};
let mut section_intros = BTreeMap::new();
section_intros.insert("Top Stories".to_string(), "The day in brief.".to_string());
let mut summaries = BTreeMap::new();
summaries.insert(2, "A short abstract for the second piece.".to_string());
@@ -122,7 +122,6 @@ pub fn issue() -> Issue {
},
editorial: Editorial {
front_page_html: "<p>Two stories today, both worth your coffee.</p>".into(),
section_intros,
summaries,
},
world_briefing: Some(WorldBriefing {
@@ -141,7 +140,15 @@ pub fn issue() -> Issue {
}],
}),
colophon: Colophon {
model: "deepseek-v4-flash".into(),
provider_costs: BTreeMap::from([
("deepseek".into(), 0.0231),
("anthropic".into(), 0.05),
]),
models: Models {
bulk: "deepseek-v4-flash".into(),
editor: "claude-opus-5".into(),
summaries: "claude-opus-5".into(),
},
entries_fetched: 431,
feeds_seen: 92,
candidates: 120,
+3
View File
@@ -7,6 +7,9 @@
<p class="byline">{{ line }}</p>
{% endif %}
<p class="meta">{{ meta_line }}</p>
{% if let Some(text) = why %}
<p class="why"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
{% if let Some(line) = social_line %}
<p class="social">{{ line }}</p>
{% endif %}
+7 -2
View File
@@ -9,12 +9,17 @@
</p>
<p class="fact-line"><strong>Issue:</strong> No. {{ issue_number }} &#183; {{ display_date }}</p>
<p class="fact-line"><strong>Generated:</strong> {{ generated_at }}</p>
<p class="fact-line"><strong>Curation model:</strong> {{ model }}</p>
<p class="fact-line"><strong>Bulk model:</strong> {{ bulk_model }}</p>
<p class="fact-line"><strong>Editor model:</strong> {{ editor_model }}</p>
<p class="fact-line"><strong>Summaries model:</strong> {{ summaries_model }}</p>
<p class="fact-line"><strong>Entries considered:</strong> {{ entries_fetched }} from {{ feeds_seen }} feeds</p>
<p class="fact-line"><strong>Candidates scored:</strong> {{ candidates }}</p>
<p class="fact-line"><strong>Articles selected:</strong> {{ article_count }} across {{ section_count }} sections</p>
<p class="fact-line"><strong>Words:</strong> {{ total_words }} &#183; {{ reading_line }}</p>
<p class="fact-line"><strong>Token cost:</strong> {{ cost_usd }}</p>
{% for line in provider_costs %}
<p class="fact-line"><strong>{{ line.provider }} cost:</strong> {{ line.cost }}</p>
{% endfor %}
<p class="fact-line"><strong>Total token cost:</strong> {{ cost_usd }}</p>
<p class="fact-line"><strong>Generator:</strong> {{ generator_version }}</p>
<p class="attribution">
Article text belongs to its authors and publications; excerpts and links are
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="masthead">The Daily EPUB</h1>
<p class="dateline">{{ display_date }} &#183; No. {{ issue_number }}</p>
<hr class="rule"/>
<h2 class="kicker">From the Editor</h2>
<h2 class="kicker">The Brief</h2>
<div class="editorial">
{{ body_html|safe }}
</div>
+3
View File
@@ -12,6 +12,9 @@
<p class="index-meta">{{ entry.source }} &#183; {{ entry.reading_minutes }} min read</p>
{% if !entry.summary.is_empty() %}
<p class="index-summary">{{ entry.summary }}</p>
{% endif %}
{% if let Some(text) = entry.why %}
<p class="index-why"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
</li>
{% endfor %}
-3
View File
@@ -3,7 +3,4 @@
{% block content %}
<h1 class="section-title">{{ name }}</h1>
<hr class="rule"/>
{% if let Some(text) = intro %}
<p class="section-intro">{{ text }}</p>
{% endif %}
{% endblock %}
+2
View File
@@ -190,3 +190,5 @@ p.comment-line {
.fact-line {
margin: 0 0 0.35em 0;
}
.why, .index-why { font-size: 0.9em; font-style: italic; }
+2
View File
@@ -296,3 +296,5 @@ blockquote.comment blockquote.comment {
.fact-line {
margin: 0 0 0.35em 0;
}
.why, .index-why { font-size: 0.9em; font-style: italic; }