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:
+1
-6
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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's here: {{ text }}</em></p>
|
||||
{% endif %}
|
||||
{% if let Some(line) = social_line %}
|
||||
<p class="social">{{ line }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
</p>
|
||||
<p class="fact-line"><strong>Issue:</strong> No. {{ issue_number }} · {{ 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 }} · {{ 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1 class="masthead">The Daily EPUB</h1>
|
||||
<p class="dateline">{{ display_date }} · 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>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<p class="index-meta">{{ entry.source }} · {{ 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's here: {{ text }}</em></p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user