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
+139
View File
@@ -74,7 +74,9 @@ pub struct Config {
pub miniflux: MinifluxConfig,
pub deepseek: DeepseekConfig,
pub anthropic: AnthropicConfig,
pub curation: CurationConfig,
pub editorial: EditorialConfig,
pub publish: PublishConfig,
pub xtc: XtcConfig,
pub server: ServerConfig,
@@ -97,7 +99,9 @@ impl Default for Config {
profile_path: PathBuf::from("data/profile.md"),
miniflux: MinifluxConfig::default(),
deepseek: DeepseekConfig::default(),
anthropic: AnthropicConfig::default(),
curation: CurationConfig::default(),
editorial: EditorialConfig::default(),
publish: PublishConfig::default(),
xtc: XtcConfig::default(),
server: ServerConfig::default(),
@@ -136,6 +140,7 @@ pub struct DeepseekConfig {
pub api_key: Option<String>,
/// Articles per stage-A scoring request (§3.6).
pub score_batch_size: usize,
pub max_concurrent_requests: usize,
pub score_temperature: f32,
pub editorial_temperature: f32,
/// USD per 1M cache-miss input tokens.
@@ -153,6 +158,7 @@ impl Default for DeepseekConfig {
model: "deepseek-v4-flash".into(),
api_key: None,
score_batch_size: 12,
max_concurrent_requests: 4,
score_temperature: 0.3,
editorial_temperature: 0.8,
price_input_per_mtok: 0.14,
@@ -162,10 +168,73 @@ impl Default for DeepseekConfig {
}
}
/// `[anthropic]` — Claude editor, editorial and profile settings (§4.2).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AnthropicConfig {
pub enabled: bool,
pub base_url: String,
pub model: String,
/// Supply only via `DAILY_EPUB_ANTHROPIC__API_KEY`.
pub api_key: Option<String>,
pub effort: String,
pub price_input_per_mtok: f64,
pub price_cache_write_per_mtok: f64,
pub price_cache_read_per_mtok: f64,
pub price_output_per_mtok: f64,
pub max_daily_usd: f64,
pub max_concurrent_requests: usize,
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
enabled: true,
base_url: "https://api.anthropic.com".into(),
model: "claude-opus-5".into(),
api_key: None,
effort: "high".into(),
price_input_per_mtok: 5.0,
price_cache_write_per_mtok: 6.25,
price_cache_read_per_mtok: 0.5,
price_output_per_mtok: 25.0,
max_daily_usd: 3.0,
max_concurrent_requests: 4,
}
}
}
/// Which provider writes per-article summaries (§14.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SummaryModel {
Editor,
Bulk,
}
/// `[editorial]` — summary provider and per-article input budget (§14).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct EditorialConfig {
pub summary_model: SummaryModel,
pub summary_input_tokens: usize,
}
impl Default for EditorialConfig {
fn default() -> Self {
Self {
summary_model: SummaryModel::Editor,
summary_input_tokens: 3_000,
}
}
}
/// `[curation]` — pre-filter and section palette (§3.5, §3.6).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct CurationConfig {
/// Absolute issue-size ceiling; the editor has no minimum (§13).
pub max_article_count: usize,
/// Miniflux feed ids or site URLs that can never be dropped (§3.5).
pub always_include_feeds: Vec<String>,
/// Hosts excluded outright (§3.5).
@@ -181,6 +250,7 @@ pub struct CurationConfig {
impl Default for CurationConfig {
fn default() -> Self {
Self {
max_article_count: 28,
always_include_feeds: Vec::new(),
blocked_domains: Vec::new(),
paywall_domains: Vec::new(),
@@ -376,6 +446,39 @@ impl Config {
"prefilter_keep must be >= target_article_count".into(),
));
}
if self.curation.max_article_count < self.target_article_count {
return Err(ConfigError::Invalid(
"curation.max_article_count must be >= target_article_count".into(),
));
}
if self.deepseek.score_batch_size == 0 {
return Err(ConfigError::Invalid(
"deepseek.score_batch_size must be >= 1".into(),
));
}
if self.deepseek.max_concurrent_requests == 0 {
return Err(ConfigError::Invalid(
"deepseek.max_concurrent_requests must be >= 1".into(),
));
}
if self.anthropic.max_concurrent_requests == 0 {
return Err(ConfigError::Invalid(
"anthropic.max_concurrent_requests must be >= 1".into(),
));
}
if self.editorial.summary_input_tokens == 0 {
return Err(ConfigError::Invalid(
"editorial.summary_input_tokens must be >= 1".into(),
));
}
if !matches!(
self.anthropic.effort.as_str(),
"low" | "medium" | "high" | "xhigh" | "max"
) {
return Err(ConfigError::Invalid(
"anthropic.effort must be one of low, medium, high, xhigh, max".into(),
));
}
if self.curation.sections.is_empty() {
return Err(ConfigError::Invalid(
"curation.sections must not be empty".into(),
@@ -498,6 +601,42 @@ mod tests {
assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.server.bind, "127.0.0.1:3499");
assert_eq!(c.deepseek.base_url, "https://api.deepseek.com/v1");
assert_eq!(c.deepseek.max_concurrent_requests, 4);
assert!(c.anthropic.enabled);
assert_eq!(c.anthropic.model, "claude-opus-5");
assert_eq!(c.anthropic.effort, "high");
assert!(c.anthropic.api_key.is_none(), "keys never live in the file");
assert_eq!(c.anthropic.max_daily_usd, 3.0);
assert_eq!(c.curation.max_article_count, 28);
assert_eq!(c.editorial.summary_model, SummaryModel::Editor);
assert_eq!(c.editorial.summary_input_tokens, 3000);
}
#[test]
fn provider_validation_rejects_nonsense() {
let mut c = Config::default();
c.curation.max_article_count = c.target_article_count - 1;
assert!(c.validate().is_err(), "max_article_count below the target");
let mut c = Config::default();
c.anthropic.effort = "turbo".into();
assert!(c.validate().is_err(), "unknown effort");
for effort in ["low", "medium", "high", "xhigh", "max"] {
let mut c = Config::default();
c.anthropic.effort = effort.into();
assert!(c.validate().is_ok(), "{effort} is a valid effort");
}
let mut c = Config::default();
c.deepseek.max_concurrent_requests = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.anthropic.max_concurrent_requests = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.deepseek.score_batch_size = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.editorial.summary_input_tokens = 0;
assert!(c.validate().is_err());
}
#[test]
+339 -323
View File
@@ -1,34 +1,19 @@
//! Stage C — summaries, section intros and the front page (spec §3.6).
//!
//! Voice: warm, literate, a little playful; never fabricates facts that are not
//! present in the summaries.
//!
//! Everything here is best-effort. If the cost ceiling trips mid-way (§3.6) or a
//! call fails, the affected article silently falls back to its own opening words
//! and the run continues — an issue with plain excerpts is far better than no
//! issue at all.
//! Claude-first summaries and The Brief, with per-call DeepSeek fallback (§14).
use std::collections::BTreeMap;
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use futures::{StreamExt, stream};
use serde::Deserialize;
use super::llm::{LlmClient, LlmError};
use super::llm::{LlmClient, LlmError, Llms};
use super::{escape_html, prompt_text, text_to_paragraphs, truncate_tokens, truncate_words};
use crate::config::{EditorialConfig, SummaryModel};
use crate::types::{ArticleId, Editorial, Lineup, Pick};
/// Article text is truncated to roughly this many tokens per summary call (§3.6).
pub const SUMMARY_INPUT_TOKEN_BUDGET: usize = 5000;
/// Target length of the "From the Editor" front page, in words (§3.6).
pub const FRONT_PAGE_WORDS: (usize, usize) = (250, 400);
/// Words of body text used when a summary has to fall back to the excerpt.
pub const FALLBACK_SUMMARY_WORDS: usize = 45;
pub const SUMMARY_CONCURRENCY: usize = 4;
// ---------------------------------------------------------------------------
// Prompts (reusable instructions here; per-call material in the user message)
// ---------------------------------------------------------------------------
/// Per-article summary instructions (§3.6 stage C).
pub const SUMMARY_INSTRUCTIONS: &str = "\
TASK: write the newspaper abstract for one article in today's issue.
@@ -57,66 +42,35 @@ excerpt.
Return JSON exactly: {\"summary\": \"<two or three sentences>\"}";
/// Front-page + section-intro instructions (§3.6 stage C).
pub const FRONT_PAGE_INSTRUCTIONS: &str = "\
TASK: write the front page of today's issue of The Daily EPUB.
/// The Brief instructions (§14.2).
pub const BRIEF_INSTRUCTIONS: &str = r#"TASK: write "The Brief" for today's issue — the note at the top of the paper.
You are given the whole lineup: sections, headlines, sources and the abstract \
written for each article. Everything you write must come from those abstracts — \
you have not read the articles themselves, and inventing a fact would be worse \
than saying less.
120-200 words, one or two paragraphs. It must earn its place: if a reader skipped
it, what would he miss? Name at least three of today's picks by title and say the
specific thing that makes each worth his time (the result, the argument, the scale,
the person). If there is a thread connecting several pieces, say it in one sentence;
if there is not, do not invent one. If the issue is short, say why in one clause.
Produce two things.
Do not: welcome the reader, describe the weather, summarize every section, use
"delve", "dive", "explore", "a mix of", "something for everyone", or any sentence
that could introduce any other issue. No headings. No bullet points.
1. \"from_the_editor\" — 250 to 400 words of prose addressed to the paper's one \
reader. Find the two or three threads that actually run through today's lineup \
(a shared question, an argument between two pieces, an accidental theme) and use \
them to guide the read: what to start with over coffee, what to save for the \
commute, what rewards patience. Name the lead story and say why it leads. It is \
fine — good, even — to note when a day is quiet or lopsided. Voice: warm, \
literate, lightly playful, never breathless; a real editor writing to someone \
whose taste he knows. No bullet lists, no headings, no emoji, 24 paragraphs \
separated by a blank line.
Return JSON exactly: {"brief": "<the text, plain prose>"}"#;
2. \"section_intros\" — for EACH section name given below, two or three \
sentences (3560 words) introducing what is in it today. Concrete, specific to \
these articles, no filler like \"a variety of interesting stories\". Use the \
section names exactly as spelled in the lineup.
Return JSON exactly:
{\"from_the_editor\": \"<paragraphs separated by \\n\\n>\", \
\"section_intros\": {\"<section name>\": \"<2-3 sentences>\"}}";
/// The single front-page call's JSON response (§3.6).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrontPageResponse {
/// "From the Editor", 250400 words.
pub from_the_editor: String,
/// Section name → 23 sentence intro.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BriefResponse {
#[serde(default)]
pub section_intros: BTreeMap<String, String>,
pub brief: String,
}
/// The per-article summary call's JSON response.
#[derive(Debug, Clone, Default, Deserialize)]
struct SummaryResponse {
#[serde(default)]
summary: String,
}
// ---------------------------------------------------------------------------
// Per-article summaries
// ---------------------------------------------------------------------------
/// One 23 sentence newspaper abstract: what it argues, why it's worth reading (§3.6).
pub async fn summarize_article(
llm: &LlmClient,
title: &str,
body_html: &str,
temperature: f32,
) -> Result<String, LlmError> {
llm.meter.check_budget()?;
let body = truncate_tokens(&prompt_text(body_html), SUMMARY_INPUT_TOKEN_BUDGET);
fn summary_prompt(title: &str, body_html: &str, input_tokens: usize) -> String {
let body = truncate_tokens(&prompt_text(body_html), input_tokens);
let mut prompt = String::with_capacity(body.len() + SUMMARY_INSTRUCTIONS.len() + 256);
prompt.push_str(SUMMARY_INSTRUCTIONS);
let _ = write!(
@@ -129,184 +83,182 @@ pub async fn summarize_article(
""
},
if body.is_empty() {
"(no body text was extracted; summarize from the headline alone and say the \
full text was unavailable)"
"(no body text was extracted; summarize from the headline alone and say the full text was unavailable)"
} else {
&body
}
);
prompt
}
pub async fn summarize_article(
llm: &LlmClient,
title: &str,
body_html: &str,
input_tokens: usize,
temperature: f32,
) -> Result<String, LlmError> {
let prompt = summary_prompt(title, body_html, input_tokens);
let response: SummaryResponse = llm.complete_json(&prompt, temperature).await?;
let summary = response.summary.trim().to_string();
if summary.is_empty() {
return Err(LlmError::EmptyResponse);
return Err(LlmError::EmptyResponse {
provider: llm.provider,
});
}
Ok(summary)
}
/// Summarize every pick, returning `article_id → summary` (§3.6).
///
/// Stops early and returns what it has when the cost guardrail trips (§3.6).
/// `(primary, fallback)` for the summaries per `editorial.summary_model` (§14.1).
fn summary_clients(llms: &Llms, model: SummaryModel) -> (Option<&LlmClient>, Option<&LlmClient>) {
match model {
SummaryModel::Bulk => (llms.bulk.as_ref(), None),
SummaryModel::Editor => {
let primary = llms.editor_or_bulk();
let fallback = primary.and_then(|client| {
llms.bulk
.as_ref()
.filter(|bulk| bulk.provider != client.provider)
});
(primary, fallback)
}
}
}
async fn summarize_pick(
pick: &Pick,
primary: Option<&LlmClient>,
fallback: Option<&LlmClient>,
config: &EditorialConfig,
temperature: f32,
) -> Option<String> {
let primary = primary?;
match summarize_article(
primary,
&pick.article.title,
&pick.article.content_html,
config.summary_input_tokens,
temperature,
)
.await
{
Ok(summary) => Some(summary),
Err(error) => {
let Some(fallback) = fallback else {
tracing::warn!(article_id = pick.article.id, %error, "summary failed; using excerpt");
return None;
};
tracing::warn!(article_id = pick.article.id, %error, "editor summary failed; retrying on bulk");
summarize_article(
fallback,
&pick.article.title,
&pick.article.content_html,
config.summary_input_tokens,
temperature,
)
.await
.map_err(|fallback_error| {
tracing::warn!(article_id = pick.article.id, %fallback_error, "bulk summary failed; using excerpt");
})
.ok()
}
}
}
pub async fn summarize_all(
llm: &LlmClient,
llms: &Llms,
lineup: &Lineup,
config: &EditorialConfig,
temperature: f32,
) -> BTreeMap<ArticleId, String> {
let mut out = BTreeMap::new();
for (n, pick) in lineup.picks.iter().enumerate() {
if llm.meter.budget_exceeded() {
tracing::error!(
summarized = out.len(),
remaining = lineup.picks.len() - n,
spent_usd = llm.meter.cost_usd(),
"COST CEILING HIT during stage C — the remaining articles fall back to \
feed excerpts as summaries"
);
break;
}
match summarize_article(
llm,
&pick.article.title,
&pick.article.content_html,
temperature,
)
let (primary, fallback) = summary_clients(llms, config.summary_model);
stream::iter(lineup.picks.iter())
.map(|pick| async move {
let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
(pick.article.id, summary)
})
.buffer_unordered(SUMMARY_CONCURRENCY)
.filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
.collect()
.await
{
Ok(summary) => {
out.insert(pick.article.id, summary);
}
Err(LlmError::BudgetExceeded { spent, limit }) => {
tracing::error!(spent, limit, "COST CEILING HIT during stage C");
break;
}
Err(e) => {
tracing::warn!(
article_id = pick.article.id,
title = %pick.article.title,
error = %e,
"summary failed; falling back to the article's own opening"
);
}
}
}
tracing::info!(
summarized = out.len(),
picks = lineup.picks.len(),
"stage C summaries complete"
);
out
}
// ---------------------------------------------------------------------------
// Front page
// ---------------------------------------------------------------------------
/// The single front-page + section-intro call (§3.6).
pub async fn front_page(
llm: &LlmClient,
lineup: &Lineup,
summaries: &BTreeMap<ArticleId, String>,
temperature: f32,
) -> Result<FrontPageResponse, LlmError> {
llm.meter.check_budget()?;
let prompt = build_front_page_prompt(lineup, summaries);
tracing::debug!(
approx_tokens = super::approx_tokens(&prompt),
"stage C front-page request"
);
let mut response: FrontPageResponse = llm.complete_json(&prompt, temperature).await?;
response.from_the_editor = response.from_the_editor.trim().to_string();
if response.from_the_editor.is_empty() {
return Err(LlmError::EmptyResponse);
}
// Keep only intros for sections that actually exist in the issue.
response
.section_intros
.retain(|name, text| lineup.section_order.contains(name) && !text.trim().is_empty());
Ok(response)
}
/// Render the front-page user prompt: the whole lineup with its abstracts (§3.6).
pub fn build_front_page_prompt(lineup: &Lineup, summaries: &BTreeMap<ArticleId, String>) -> String {
pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap<ArticleId, String>) -> String {
let mut prompt = String::with_capacity(4096);
prompt.push_str(FRONT_PAGE_INSTRUCTIONS);
let minutes: i64 = lineup
.picks
.iter()
.map(|p| p.article.reading_minutes())
.sum();
prompt.push_str(BRIEF_INSTRUCTIONS);
let _ = write!(
prompt,
"\n\nISSUE: {} · {} articles across {} sections · about {} minutes of reading\n\
SECTIONS, in order: {}\n\nLINEUP\n",
"\n\nISSUE: {} · {} articles\n\nLINEUP\n",
lineup.date,
lineup.picks.len(),
lineup.section_order.len(),
minutes,
lineup.section_order.join(" | ")
lineup.picks.len()
);
for section in &lineup.section_order {
let _ = write!(prompt, "\n## {section}\n");
let _ = writeln!(prompt, "\n## {section}");
for pick in lineup.section_picks(section) {
let _ = write!(prompt, "{}", render_pick(pick, summaries));
let score = pick
.llm
.as_ref()
.map(|score| format!("{:.1}", score.score))
.unwrap_or_else(|| "unscored".into());
let summary = summaries
.get(&pick.article.id)
.cloned()
.unwrap_or_else(|| excerpt_summary(pick));
let _ = writeln!(
prompt,
"- {}\n feed: {}\n why: {}\n score: {}\n summary: {}",
pick.article.title.trim(),
pick.article.feed_title.trim(),
pick.why.as_deref().unwrap_or("not supplied"),
score,
summary
);
}
}
prompt
}
fn render_pick(pick: &Pick, summaries: &BTreeMap<ArticleId, String>) -> String {
let a = &pick.article;
let mut block = String::with_capacity(400);
let _ = writeln!(
block,
"\n- {}{}",
a.title.trim(),
if pick.is_lead { " [LEAD STORY]" } else { "" }
);
let _ = writeln!(
block,
" source: {} · {} words (~{} min){}",
if a.feed_title.is_empty() {
"unknown"
} else {
a.feed_title.trim()
},
a.word_count,
a.reading_minutes(),
social_note(pick)
);
let abstract_text = summaries
.get(&a.id)
.cloned()
.unwrap_or_else(|| excerpt_summary(pick));
let _ = writeln!(block, " abstract: {abstract_text}");
block
}
fn social_note(pick: &Pick) -> String {
if pick.article.social.is_empty() {
return String::new();
pub async fn brief(
llms: &Llms,
lineup: &Lineup,
summaries: &BTreeMap<ArticleId, String>,
temperature: f32,
) -> Result<String, LlmError> {
let prompt = build_brief_prompt(lineup, summaries);
let Some(primary) = llms.editor_or_bulk() else {
return Err(LlmError::Api {
provider: "editorial",
message: "no provider configured".into(),
});
};
let response = match primary
.complete_json::<BriefResponse>(&prompt, temperature)
.await
{
Ok(response) => response,
Err(error) => {
let Some(fallback) = llms
.bulk
.as_ref()
.filter(|bulk| bulk.provider != primary.provider)
else {
return Err(error);
};
tracing::warn!(%error, "brief failed on editor; retrying on bulk");
fallback
.complete_json::<BriefResponse>(&prompt, temperature)
.await?
}
};
let brief = response.brief.trim().to_string();
if brief.is_empty() {
return Err(LlmError::EmptyResponse {
provider: primary.provider,
});
}
let parts: Vec<String> = pick
.article
.social
.iter()
.map(|s| {
format!(
"{} {} pts/{} comments",
s.source.display_name(),
s.score,
s.num_comments
)
})
.collect();
format!(" · {}", parts.join(", "))
Ok(brief)
}
// ---------------------------------------------------------------------------
// Fallbacks (§3.6, notes §6)
// ---------------------------------------------------------------------------
/// The article's own opening words, used when no LLM summary exists (§3.6).
pub fn excerpt_summary(pick: &Pick) -> String {
let text = truncate_words(
&prompt_text(&pick.article.content_html),
@@ -326,17 +278,14 @@ pub fn excerpt_summary(pick: &Pick) -> String {
}
}
/// A plain, factual front page used when the model is unavailable (§3.6, notes §6).
pub fn fallback_front_page_html(lineup: &Lineup) -> String {
let minutes: i64 = lineup
.picks
.iter()
.map(|p| p.article.reading_minutes())
.map(|pick| pick.article.reading_minutes())
.sum();
let mut text = format!(
"Today's issue collects {} articles across {} sections — about {} minutes of \
reading. Editorial notes are unavailable for this issue, so the lineup speaks \
for itself.",
"Today's issue collects {} articles across {} sections — about {} minutes of reading. Editorial notes are unavailable for this issue, so the lineup speaks for itself.",
lineup.picks.len(),
lineup.section_order.len(),
minutes
@@ -346,29 +295,15 @@ pub fn fallback_front_page_html(lineup: &Lineup) -> String {
text,
"\n\nLeading today: “{}” ({}).",
lead.article.title.trim(),
if lead.article.feed_title.is_empty() {
"source unknown"
} else {
lead.article.feed_title.trim()
}
);
}
if !lineup.section_order.is_empty() {
let _ = write!(
text,
"\n\nIn this issue: {}.",
lineup.section_order.join(", ")
lead.article.feed_title.trim()
);
}
text_to_paragraphs(&text)
}
/// `--skip-llm` / budget-exceeded fallback: feed excerpts stand in for summaries
/// and the front page is a plain stats line (§3.6, notes §6).
pub fn fallback_editorial(lineup: &Lineup) -> Editorial {
Editorial {
front_page_html: fallback_front_page_html(lineup),
section_intros: BTreeMap::new(),
summaries: lineup
.picks
.iter()
@@ -377,54 +312,34 @@ pub fn fallback_editorial(lineup: &Lineup) -> Editorial {
}
}
// ---------------------------------------------------------------------------
// Stage driver
// ---------------------------------------------------------------------------
/// Stage C end to end: summaries, then one front-page call, with excerpts filling
/// every gap (§3.6).
pub async fn run(llm: &LlmClient, lineup: &Lineup, temperature: f32) -> Editorial {
pub async fn run(
llms: &Llms,
lineup: &Lineup,
config: &EditorialConfig,
temperature: f32,
) -> Editorial {
if lineup.picks.is_empty() {
return fallback_editorial(lineup);
}
let mut summaries = summarize_all(llm, lineup, temperature).await;
let missing: Vec<&Pick> = lineup
.picks
.iter()
.filter(|p| !summaries.contains_key(&p.article.id))
.collect();
if !missing.is_empty() {
tracing::warn!(
count = missing.len(),
"using feed excerpts as summaries for articles the model did not cover"
);
for pick in missing {
summaries.insert(pick.article.id, excerpt_summary(pick));
}
let mut summaries = summarize_all(llms, lineup, config, temperature).await;
for pick in &lineup.picks {
summaries
.entry(pick.article.id)
.or_insert_with(|| excerpt_summary(pick));
}
let (front_page_html, section_intros) =
match front_page(llm, lineup, &summaries, temperature).await {
Ok(response) => (
text_to_paragraphs(&response.from_the_editor),
response.section_intros,
),
Err(e) => {
tracing::error!(error = %e,
"front-page generation failed; using the plain front page");
(fallback_front_page_html(lineup), BTreeMap::new())
}
};
let front_page_html = match brief(llms, lineup, &summaries, temperature).await {
Ok(text) => text_to_paragraphs(&text),
Err(error) => {
tracing::warn!(%error, "brief failed; using fallback front page");
fallback_front_page_html(lineup)
}
};
Editorial {
front_page_html,
section_intros,
summaries,
}
}
/// Escape-and-wrap helper for callers rendering a summary straight into XHTML.
pub fn summary_to_html(summary: &str) -> String {
format!("<p>{}</p>", escape_html(summary.trim()))
}
@@ -432,15 +347,15 @@ pub fn summary_to_html(summary: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DeepseekConfig;
use crate::curate::llm::{MockBackend, UsageMeter};
use crate::config::{AnthropicConfig, DeepseekConfig};
use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article;
use crate::types::TokenUsage;
use std::sync::Arc;
const FRONT_PAGE_FIXTURE: &str = include_str!(concat!(
const BRIEF_FIXTURE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/deepseek_front_page.json"
"/tests/fixtures/claude_brief.json"
));
fn pick(id: i64, title: &str, section: &str, is_lead: bool) -> Pick {
@@ -451,6 +366,7 @@ mod tests {
section: section.into(),
position: 1,
is_lead,
why: Some(format!("the {title} piece you'd argue with")),
summary: None,
llm: None,
discussion: None,
@@ -468,15 +384,40 @@ mod tests {
}
}
fn client(backend: Arc<MockBackend>, limit: f64) -> LlmClient {
LlmClient::with_backend(
"deepseek-v4-flash",
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
let prices = if provider == "anthropic" {
PriceTable::anthropic(&AnthropicConfig::default())
} else {
PriceTable::deepseek(&DeepseekConfig::default())
};
LlmClient::with_backend_options(
provider,
"model",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), limit),
backend,
None,
UsageMeter::with_prices(prices, limit),
backend as Arc<dyn ChatBackend>,
)
}
fn bulk_only(backend: Arc<MockBackend>, limit: f64) -> Llms {
Llms {
bulk: Some(mock("deepseek", backend, limit)),
editor: None,
}
}
fn editor_and_bulk(editor: Arc<MockBackend>, bulk: Arc<MockBackend>) -> Llms {
Llms {
bulk: Some(mock("deepseek", bulk, 2.0)),
editor: Some(mock("anthropic", editor, 3.0)),
}
}
fn config() -> EditorialConfig {
EditorialConfig::default()
}
#[tokio::test]
async fn summary_prompt_carries_headline_and_truncated_body() {
let backend = Arc::new(MockBackend::new());
@@ -484,9 +425,9 @@ mod tests {
r#"{"summary": "A team moves 40TB of relational data off Postgres and documents every rollback."}"#,
TokenUsage::default(),
);
let llm = client(Arc::clone(&backend), 2.0);
let llm = mock("deepseek", Arc::clone(&backend), 2.0);
let body = format!("<p>{}</p>", "word ".repeat(20_000));
let summary = summarize_article(&llm, "Migrating 40TB", &body, 0.8)
let summary = summarize_article(&llm, "Migrating 40TB", &body, 3_000, 0.8)
.await
.expect("summary");
assert!(summary.starts_with("A team moves 40TB"));
@@ -495,60 +436,133 @@ mod tests {
assert!(prompt.starts_with(SUMMARY_INSTRUCTIONS));
assert!(prompt.contains("HEADLINE: Migrating 40TB"));
assert!(prompt.contains("(truncated for length)"));
// ~5k tokens ≈ 20k characters of body, not the full 100k.
assert!(prompt.len() < 26_000, "prompt was {} bytes", prompt.len());
// 3k tokens ≈ 12k characters of body, not the full 100k.
assert!(prompt.len() < 16_000, "prompt was {} bytes", prompt.len());
}
#[tokio::test]
async fn front_page_parses_and_filters_unknown_sections() {
async fn the_brief_is_parsed_and_rendered() {
let backend = Arc::new(MockBackend::new());
backend.push(FRONT_PAGE_FIXTURE, TokenUsage::default());
let llm = client(Arc::clone(&backend), 2.0);
backend.push(BRIEF_FIXTURE, TokenUsage::default());
let llms = bulk_only(Arc::clone(&backend), 2.0);
let lineup = lineup();
let summaries = BTreeMap::from([
(1, "A migration story with numbers.".to_string()),
(2, "Transit data, charted.".to_string()),
]);
let response = front_page(&llm, &lineup, &summaries, 0.8)
.await
.expect("front page");
assert!(response.from_the_editor.split_whitespace().count() > 40);
assert_eq!(response.section_intros.len(), 2);
assert!(response.section_intros.contains_key("Top Stories"));
assert!(
!response.section_intros.contains_key("Niche Corner"),
"intros for absent sections are dropped"
);
let text = brief(&llms, &lineup, &summaries, 0.8).await.expect("brief");
assert!(text.split_whitespace().count() > 100);
assert!(text.contains("Migrating 40TB off Postgres"));
let prompt = &backend.prompts()[0].user;
assert!(prompt.starts_with(FRONT_PAGE_INSTRUCTIONS));
assert!(prompt.starts_with(BRIEF_INSTRUCTIONS));
assert!(prompt.contains("## Top Stories"));
assert!(prompt.contains("[LEAD STORY]"));
assert!(prompt.contains("abstract: A migration story with numbers."));
assert!(prompt.contains("## Boston & Local"));
assert!(prompt.contains("- Migrating 40TB off Postgres"));
assert!(prompt.contains("why: the Migrating 40TB off Postgres piece you'd argue with"));
assert!(prompt.contains("summary: A migration story with numbers."));
assert!(prompt.contains("score: unscored"));
assert!(prompt.contains("2026-08-15"));
assert!(
!prompt.contains("section_intros"),
"section intros are gone"
);
}
#[tokio::test]
async fn full_stage_c_produces_summaries_intros_and_front_page() {
async fn full_stage_c_produces_summaries_and_the_brief() {
let backend = Arc::new(MockBackend::new());
backend.push(r#"{"summary": "First abstract."}"#, TokenUsage::default());
backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default());
backend.push(FRONT_PAGE_FIXTURE, TokenUsage::default());
let llm = client(Arc::clone(&backend), 2.0);
backend.push(BRIEF_FIXTURE, TokenUsage::default());
let llms = bulk_only(Arc::clone(&backend), 2.0);
let editorial = run(&llm, &lineup(), 0.8).await;
assert_eq!(
backend.calls(),
3,
"one call per article plus the front page"
);
let editorial = run(&llms, &lineup(), &config(), 0.8).await;
assert_eq!(backend.calls(), 3, "one call per article plus the brief");
assert_eq!(editorial.summaries.len(), 2);
assert_eq!(editorial.summaries[&1], "First abstract.");
assert!(editorial.front_page_html.starts_with("<p>"));
assert!(editorial.front_page_html.contains("</p>"));
assert!(
editorial
.front_page_html
.contains("Migrating 40TB off Postgres")
);
assert!(!editorial.front_page_html.contains("<script"));
assert_eq!(editorial.section_intros.len(), 2);
}
#[tokio::test]
async fn summaries_run_on_the_editor_and_fall_back_per_article() {
let editor = Arc::new(MockBackend::new());
editor.push(
r#"{"summary": "Opus wrote this one."}"#,
TokenUsage::default(),
);
editor.push_llm_error(LlmError::Refusal {
provider: "anthropic",
});
editor.push(BRIEF_FIXTURE, TokenUsage::default());
let bulk = Arc::new(MockBackend::new());
bulk.push(
r#"{"summary": "DeepSeek covered the refusal."}"#,
TokenUsage::default(),
);
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
let editorial = run(&llms, &lineup(), &config(), 0.8).await;
assert_eq!(
editor.calls(),
3,
"two summaries and the brief on the editor"
);
assert_eq!(bulk.calls(), 1, "only the refused summary went to bulk");
assert_eq!(editorial.summaries[&1], "Opus wrote this one.");
assert_eq!(editorial.summaries[&2], "DeepSeek covered the refusal.");
assert_eq!(
editor.prompts()[1].user,
bulk.prompts()[0].user,
"the bulk client gets the identical summary prompt"
);
assert!(
editorial
.front_page_html
.contains("Migrating 40TB off Postgres")
);
}
#[tokio::test]
async fn the_brief_falls_back_to_bulk_with_the_same_prompt() {
let editor = Arc::new(MockBackend::new());
editor.push_error("500 opus is down");
let bulk = Arc::new(MockBackend::new());
bulk.push(BRIEF_FIXTURE, TokenUsage::default());
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
let text = brief(&llms, &lineup(), &BTreeMap::new(), 0.8)
.await
.expect("bulk brief");
assert!(text.contains("Migrating 40TB off Postgres"));
assert_eq!(editor.prompts()[0].user, bulk.prompts()[0].user);
}
#[tokio::test]
async fn summary_model_bulk_skips_the_editor_for_summaries() {
let editor = Arc::new(MockBackend::new());
editor.push(BRIEF_FIXTURE, TokenUsage::default());
let bulk = Arc::new(MockBackend::new());
bulk.push(r#"{"summary": "First abstract."}"#, TokenUsage::default());
bulk.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default());
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
let config = EditorialConfig {
summary_model: SummaryModel::Bulk,
..EditorialConfig::default()
};
let editorial = run(&llms, &lineup(), &config, 0.8).await;
assert_eq!(bulk.calls(), 2);
assert_eq!(editor.calls(), 1, "the brief still runs on the editor");
assert_eq!(editorial.summaries[&2], "Second abstract.");
}
#[tokio::test]
@@ -560,14 +574,15 @@ mod tests {
TokenUsage {
input_tokens: 1_000_000,
cached_tokens: 0,
cache_write_tokens: 0,
output_tokens: 0,
},
);
let llm = client(Arc::clone(&backend), 0.05);
let llms = bulk_only(Arc::clone(&backend), 0.05);
let editorial = run(&llm, &lineup(), 0.8).await;
let editorial = run(&llms, &lineup(), &config(), 0.8).await;
assert_eq!(backend.calls(), 1, "no further calls after the ceiling");
assert!(llm.meter.budget_exceeded());
assert!(llms.bulk.as_ref().expect("bulk").meter.budget_exceeded());
assert_eq!(
editorial.summaries.len(),
2,
@@ -581,7 +596,6 @@ mod tests {
);
// The front page degraded to the plain version.
assert!(editorial.front_page_html.contains("2 articles"));
assert!(editorial.section_intros.is_empty());
}
#[tokio::test]
@@ -589,28 +603,30 @@ mod tests {
let backend = Arc::new(MockBackend::new());
backend.push_error("400 bad request");
backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default());
backend.push_error("500 front page exploded");
let llm = client(Arc::clone(&backend), 2.0);
backend.push_error("500 brief exploded");
let llms = bulk_only(Arc::clone(&backend), 2.0);
let editorial = run(&llm, &lineup(), 0.8).await;
let editorial = run(&llms, &lineup(), &config(), 0.8).await;
assert_eq!(editorial.summaries.len(), 2);
assert!(editorial.summaries[&1].contains("opens with a specific"));
assert_eq!(editorial.summaries[&2], "Second abstract.");
assert!(editorial.front_page_html.contains("Leading today"));
}
#[tokio::test]
async fn no_provider_means_the_fallback_editorial() {
let editorial = run(&Llms::default(), &lineup(), &config(), 0.8).await;
assert_eq!(editorial.summaries.len(), 2);
assert!(editorial.front_page_html.contains("2 articles"));
}
#[test]
fn fallback_editorial_covers_every_pick() {
let lineup = lineup();
let editorial = fallback_editorial(&lineup);
assert_eq!(editorial.summaries.len(), lineup.picks.len());
assert!(editorial.section_intros.is_empty());
assert!(editorial.front_page_html.contains("2 articles"));
assert!(
editorial
.front_page_html
.contains("Top Stories, Boston &amp; Local")
);
assert!(editorial.front_page_html.contains("2 sections"));
assert!(editorial.front_page_html.starts_with("<p>"));
// An empty lineup is still a valid editorial.
+867 -193
View File
File diff suppressed because it is too large Load Diff
+40 -25
View File
@@ -6,8 +6,9 @@
//!
//! [`Curator`] is the thin orchestration layer the `generate` pipeline calls; the
//! interesting logic lives in the stage modules. Every stage is safe to run with
//! `llm == None` (`--skip-llm`): the prefilter order stands in for selection and
//! feed excerpts stand in for summaries (notes §6).
//! no provider at all (`--skip-llm`): the prefilter order stands in for selection
//! and feed excerpts stand in for summaries (notes §6). Scoring runs on the bulk
//! client; selection and editorial on the editor with per-call bulk fallback.
pub mod editorial;
pub mod llm;
@@ -26,14 +27,15 @@ use crate::types::{Article, Editorial, Lineup, ScoredArticle};
pub struct Curator {
pub config: Config,
pub db: Db,
pub llm: Option<llm::LlmClient>,
pub llms: llm::Llms,
}
impl Curator {
/// `llm == None` corresponds to `--skip-llm`: prefilter order is used for
/// selection and feed excerpts stand in for summaries (notes §6).
pub fn new(config: Config, db: Db, llm: Option<llm::LlmClient>) -> Self {
Self { config, db, llm }
/// An empty [`llm::Llms`] corresponds to `--skip-llm`: prefilter order is
/// used for selection and feed excerpts stand in for summaries (notes §6).
/// With only `bulk`, every editor call runs on DeepSeek (§4.2).
pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self {
Self { config, db, llms }
}
/// Heuristic pre-filter: 300500 articles → `prefilter_keep` (§3.5).
@@ -75,7 +77,7 @@ impl Curator {
///
/// A no-op under `--skip-llm`. Scores are persisted per `(article, date)`.
pub async fn score(&self, candidates: &mut [ScoredArticle], date: Date) -> anyhow::Result<()> {
let Some(llm) = self.llm.as_ref() else {
let Some(llm) = self.llms.bulk.as_ref() else {
tracing::info!("--skip-llm: stage A scoring skipped");
return Ok(());
};
@@ -86,6 +88,7 @@ impl Curator {
llm,
candidates,
self.config.deepseek.score_batch_size,
self.config.deepseek.max_concurrent_requests,
&self.config.curation.sections,
self.config.deepseek.score_temperature,
)
@@ -116,23 +119,29 @@ impl Curator {
date: Date,
) -> anyhow::Result<Lineup> {
let sections = &self.config.curation.sections;
let target = self.config.target_article_count;
let Some(llm) = self.llm.as_ref() else {
tracing::info!("--skip-llm: selecting by prefilter order");
return Ok(select::select_without_llm(
candidates, sections, target, date,
));
};
let span = tracing::info_span!("llm_select", candidates = candidates.len());
let soft_target = self.config.target_article_count;
let hard_max = self.config.curation.max_article_count;
let span = tracing::info_span!("llm_editor", candidates = candidates.len());
let _guard = span.enter();
match select::select(llm, candidates.clone(), sections, target, date).await {
match select::select(
&self.llms,
candidates.clone(),
sections,
soft_target,
hard_max,
date,
)
.await
{
Ok(lineup) => Ok(lineup),
Err(e) => {
tracing::error!(error = %e,
"stage B selection failed; falling back to prefilter order");
Err(error) => {
tracing::error!(%error, "editor and bulk fallback failed; selecting heuristically");
Ok(select::select_without_llm(
candidates, sections, target, date,
candidates,
sections,
soft_target,
hard_max,
date,
))
}
}
@@ -142,13 +151,19 @@ impl Curator {
///
/// Never fails the run: a budget trip or an API error degrades to excerpts.
pub async fn editorial(&self, lineup: &Lineup) -> anyhow::Result<Editorial> {
let Some(llm) = self.llm.as_ref() else {
if self.llms.editor_or_bulk().is_none() {
tracing::info!("--skip-llm: using feed excerpts as summaries");
return Ok(editorial::fallback_editorial(lineup));
};
}
let span = tracing::info_span!("llm_editorial", picks = lineup.picks.len());
let _guard = span.enter();
Ok(editorial::run(llm, lineup, self.config.deepseek.editorial_temperature).await)
Ok(editorial::run(
&self.llms,
lineup,
&self.config.editorial,
self.config.deepseek.editorial_temperature,
)
.await)
}
}
+37 -48
View File
@@ -10,6 +10,7 @@
use std::collections::HashMap;
use std::fmt::Write as _;
use futures::{StreamExt, stream};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -331,6 +332,7 @@ pub async fn score_all(
llm: &LlmClient,
candidates: &mut [ScoredArticle],
batch_size: usize,
max_concurrent_requests: usize,
sections: &[String],
temperature: f32,
) -> Result<usize, LlmError> {
@@ -339,61 +341,47 @@ pub async fn score_all(
}
let batch_size = batch_size.max(1);
let batches = candidates.len().div_ceil(batch_size);
let mut scores: HashMap<ArticleId, LlmScore> = HashMap::with_capacity(candidates.len());
let prompts = candidates
.chunks(batch_size)
.enumerate()
.map(|(index, batch)| (index, batch.len(), build_batch_prompt(batch, sections)))
.collect::<Vec<_>>();
for (n, batch) in candidates.chunks(batch_size).enumerate() {
if let Err(e) = llm.meter.check_budget() {
tracing::error!(
error = %e,
batch = n + 1,
of = batches,
unscored = candidates.len() - scores.len(),
"COST CEILING HIT during stage A scoring — remaining batches skipped; \
the lineup will fall back to heuristic ranking for them"
);
break;
}
let prompt = build_batch_prompt(batch, sections);
tracing::debug!(
batch = n + 1,
of = batches,
articles = batch.len(),
approx_tokens = super::approx_tokens(&prompt),
"stage A request"
);
match llm.complete(&prompt, temperature, true).await {
Ok(raw) => {
let items = parse_score_response(&raw);
if items.is_empty() {
tracing::warn!(
batch = n + 1,
of = batches,
"stage A batch returned no scores"
);
}
for item in items {
scores.insert(item.id, item.into());
}
}
Err(e) => {
tracing::warn!(batch = n + 1, of = batches, error = %e,
"stage A batch failed; its articles stay unscored");
let results = stream::iter(prompts)
.map(|(index, article_count, prompt)| async move {
if let Err(error) = llm.meter.check_budget() {
tracing::warn!(batch = index + 1, of = batches, %error, "bulk budget tripped; skipping stage A batch");
return (index, Vec::new());
}
tracing::debug!(batch = index + 1, of = batches, articles = article_count, approx_tokens = super::approx_tokens(&prompt), "stage A request");
let items = match llm.complete(&prompt, temperature, true).await {
Ok(raw) => parse_score_response(&raw),
Err(error) => {
tracing::warn!(batch = index + 1, of = batches, %error, "stage A batch failed; its articles stay unscored");
Vec::new()
}
};
(index, items)
})
.buffer_unordered(max_concurrent_requests.max(1))
.collect::<Vec<_>>()
.await;
let mut scores: HashMap<ArticleId, LlmScore> = HashMap::with_capacity(candidates.len());
for (_, items) in results {
for item in items {
scores.insert(item.id, item.into());
}
}
let mut applied = 0usize;
for candidate in candidates.iter_mut() {
let mut applied = 0;
for candidate in candidates {
if let Some(score) = scores.remove(&candidate.article.id) {
candidate.llm = Some(score);
applied += 1;
}
}
if !scores.is_empty() {
tracing::warn!(
unknown_ids = scores.len(),
"stage A returned scores for ids that were not in the batch"
);
tracing::warn!(unknown_ids = scores.len(), "stage A returned unknown ids");
}
Ok(applied)
}
@@ -535,7 +523,7 @@ mod tests {
candidate(2, "Two", 1000),
candidate(3, "Three", 1000),
];
let scored = score_all(&llm, &mut candidates, 2, &sections(), 0.3)
let scored = score_all(&llm, &mut candidates, 2, 4, &sections(), 0.3)
.await
.expect("scoring");
assert_eq!(scored, 3);
@@ -556,7 +544,7 @@ mod tests {
);
let llm = client(Arc::clone(&backend), 2.0);
let mut candidates = vec![candidate(1, "One", 900), candidate(2, "Two", 900)];
let scored = score_all(&llm, &mut candidates, 1, &sections(), 0.3)
let scored = score_all(&llm, &mut candidates, 1, 4, &sections(), 0.3)
.await
.expect("scoring must not abort");
assert_eq!(scored, 1);
@@ -573,6 +561,7 @@ mod tests {
TokenUsage {
input_tokens: 1_000_000,
cached_tokens: 0,
cache_write_tokens: 0,
output_tokens: 0,
},
);
@@ -582,7 +571,7 @@ mod tests {
);
let llm = client(Arc::clone(&backend), 0.05);
let mut candidates = vec![candidate(1, "One", 900), candidate(2, "Two", 900)];
let scored = score_all(&llm, &mut candidates, 1, &sections(), 0.3)
let scored = score_all(&llm, &mut candidates, 1, 4, &sections(), 0.3)
.await
.expect("scoring");
assert_eq!(scored, 1, "only the first batch ran");
+441 -286
View File
File diff suppressed because it is too large Load Diff
+131 -13
View File
@@ -5,6 +5,7 @@
//! (implementation notes §2). Pipeline writes are idempotent upserts so that
//! `generate --date X` can be re-run safely; feedback events are append-only.
use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
@@ -43,6 +44,12 @@ pub enum DbError {
},
#[error("malformed value in column `{column}`: {value}")]
Decode { column: &'static str, value: String },
#[error("malformed JSON in column `{column}`: {source}")]
Json {
column: &'static str,
#[source]
source: serde_json::Error,
},
}
type Result<T> = std::result::Result<T, DbError>;
@@ -480,8 +487,8 @@ impl Db {
.await?;
for pick in picks {
sqlx::query(
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary)
VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary, why)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(date.to_string())
.bind(pick.article.id)
@@ -489,6 +496,7 @@ impl Db {
.bind(pick.position)
.bind(pick.is_lead)
.bind(pick.summary.as_deref())
.bind(pick.why.as_deref())
.execute(&mut *tx)
.await?;
}
@@ -656,7 +664,7 @@ impl Db {
sqlx::query(
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?,
status = ?, error = ?
status = ?, error = ?, provider_costs_json = ?, config_json = ?
WHERE id = ?",
)
.bind(report.finished_at.map(fmt_ts))
@@ -669,20 +677,55 @@ impl Db {
.bind(report.cost_usd)
.bind(report.status.as_str())
.bind(report.error.as_deref())
.bind(
serde_json::to_string(&report.provider_costs).map_err(|source| DbError::Json {
column: "runs.provider_costs_json",
source,
})?,
)
.bind(
serde_json::to_string(&report.config_json).map_err(|source| DbError::Json {
column: "runs.config_json",
source,
})?,
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Total spend recorded for a date, for the `max_daily_usd` guardrail (§3.6).
pub async fn spend_for_date(&self, date: Date) -> Result<f64> {
let row =
sqlx::query("SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM runs WHERE date = ?")
.bind(date.to_string())
.fetch_one(&self.pool)
.await?;
Ok(row.get::<f64, _>("total"))
/// Earlier provider spend on the UTC date containing this run's start (§5).
pub async fn provider_spend_for_utc_day(
&self,
started_at: Timestamp,
) -> Result<BTreeMap<String, f64>> {
let utc_date = started_at
.to_zoned(jiff::tz::TimeZone::UTC)
.date()
.to_string();
let rows = sqlx::query(
"SELECT provider_costs_json FROM runs
WHERE substr(started_at, 1, 10) = ? AND started_at < ?
AND provider_costs_json IS NOT NULL",
)
.bind(utc_date)
.bind(fmt_ts(started_at))
.fetch_all(&self.pool)
.await?;
let mut totals = BTreeMap::new();
for row in rows {
let raw = row.get::<String, _>("provider_costs_json");
let providers: BTreeMap<String, crate::report::ProviderUsage> =
serde_json::from_str(&raw).map_err(|source| DbError::Json {
column: "runs.provider_costs_json",
source,
})?;
for (provider, usage) in providers {
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
}
}
Ok(totals)
}
}
@@ -988,7 +1031,7 @@ mod tests {
report.counts.entries_fetched = 412;
report.counts.candidates = 120;
report.counts.selected = 20;
report.finish(ts("2026-08-15T05:36:00Z"), 0.14, 0.0028, 0.28);
report.finish(ts("2026-08-15T05:36:00Z"));
db.finish_run(run_id, &report).await.unwrap();
db.upsert_issue(
@@ -1006,7 +1049,82 @@ mod tests {
let next: Date = "2026-08-16".parse().unwrap();
assert_eq!(db.next_issue_number(next).await.unwrap(), 2);
assert_eq!(db.recent_reports(5).await.unwrap().len(), 1);
assert_eq!(db.spend_for_date(date).await.unwrap(), 0.0);
// No provider spend was recorded, so the budget-day preload is empty.
let spend = db
.provider_spend_for_utc_day(ts("2026-08-15T23:00:00Z"))
.await
.unwrap();
assert!(spend.values().all(|usd| *usd == 0.0));
}
async fn record_run(db: &Db, date: Date, started: &str, deepseek: f64, anthropic: f64) {
use crate::report::{ProviderUsage, RunReport};
let started_at = ts(started);
let run_id = db.start_run(date, started_at).await.unwrap();
let mut report = RunReport::new(date, started_at);
report.provider_costs.insert(
"deepseek".into(),
ProviderUsage {
usage: crate::types::TokenUsage {
input_tokens: 10,
..Default::default()
},
cost_usd: deepseek,
},
);
report.provider_costs.insert(
"anthropic".into(),
ProviderUsage {
usage: crate::types::TokenUsage::default(),
cost_usd: anthropic,
},
);
report.config_json = serde_json::json!({"models": {"editor": "claude-opus-5"}});
report.finish(started_at);
db.finish_run(run_id, &report).await.unwrap();
}
#[tokio::test]
async fn provider_spend_is_summed_by_the_utc_day_of_started_at() {
let (_dir, db) = temp_db().await;
let date: Date = "2026-08-15".parse().unwrap();
record_run(&db, date, "2026-08-15T03:00:00Z", 0.10, 0.50).await;
record_run(&db, date, "2026-08-15T23:30:00Z", 0.05, 0.0).await;
// Nominal issue date 08-15 in New York, but already 08-16 in UTC: a
// different budget day (§5).
record_run(&db, date, "2026-08-16T01:00:00Z", 1.0, 1.0).await;
let spend = db
.provider_spend_for_utc_day(ts("2026-08-15T23:45:00Z"))
.await
.unwrap();
assert!((spend["deepseek"] - 0.15).abs() < 1e-9);
assert!((spend["anthropic"] - 0.5).abs() < 1e-9);
// Only runs that started earlier than this one count.
let spend = db
.provider_spend_for_utc_day(ts("2026-08-15T12:00:00Z"))
.await
.unwrap();
assert!((spend["deepseek"] - 0.10).abs() < 1e-9);
let spend = db
.provider_spend_for_utc_day(ts("2026-08-17T12:00:00Z"))
.await
.unwrap();
assert!(spend.is_empty());
// `provider_costs_json` and `config_json` were written and round-trip.
let row =
sqlx::query("SELECT provider_costs_json, config_json FROM runs ORDER BY id LIMIT 1")
.fetch_one(&db.pool)
.await
.unwrap();
let costs: BTreeMap<String, crate::report::ProviderUsage> =
serde_json::from_str(&row.get::<String, _>("provider_costs_json")).unwrap();
assert_eq!(costs["deepseek"].usage.input_tokens, 10);
assert!((costs["anthropic"].cost_usd - 0.5).abs() < 1e-9);
let config: serde_json::Value =
serde_json::from_str(&row.get::<String, _>("config_json")).unwrap();
assert_eq!(config["models"]["editor"], "claude-opus-5");
}
#[tokio::test]
+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; }
+40 -4
View File
@@ -270,13 +270,30 @@ fn print_report(report: &RunReport) {
report.counts.duplicates_merged,
report.counts.entries_dropped,
);
if report.counts.llm_unscored > 0 {
println!(
"curation: {} scored · {} unscored · {} selected",
report.counts.llm_scored, report.counts.llm_unscored, report.counts.selected,
);
}
println!(
"tokens: {} input · {} cached · {} output = ${:.4}",
"tokens: {} input · {} cache read · {} cache write · {} output = ${:.4}",
report.usage.input_tokens,
report.usage.cached_tokens,
report.usage.cache_write_tokens,
report.usage.output_tokens,
report.cost_usd,
);
for (provider, usage) in &report.provider_costs {
println!(
" {provider}: {} input · {} cache read · {} cache write · {} output = ${:.4}",
usage.usage.input_tokens,
usage.usage.cached_tokens,
usage.usage.cache_write_tokens,
usage.usage.output_tokens,
usage.cost_usd,
);
}
for warning in &report.warnings {
println!("warning: {warning}");
}
@@ -316,8 +333,15 @@ fn print_lineup(issue: &daily_epub::types::Issue) {
// Other subcommands
// ---------------------------------------------------------------------------
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3).
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
let meter = curate::llm::UsageMeter::new(&config.deepseek, config.max_daily_usd);
use curate::llm::{Llms, PriceTable, UsageMeter};
let bulk_meter =
UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
let editor_meter = UsageMeter::with_prices(
PriceTable::anthropic(&config.anthropic),
config.anthropic.max_daily_usd,
);
let profile = curate::profile::load_or_build(
db,
&config.interests_opml,
@@ -325,10 +349,22 @@ async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
config.curation.feedback.verdicts_in_prompt,
)
.await?;
let llm = curate::llm::LlmClient::new(&config.deepseek, profile.text, meter)?;
let llms = Llms::from_config(
&config.deepseek,
&config.anthropic,
profile.text,
bulk_meter,
editor_meter,
);
let Some(llm) = llms.editor_or_bulk() else {
anyhow::bail!(
"no LLM provider is configured; set DAILY_EPUB_ANTHROPIC__API_KEY or DAILY_EPUB_DEEPSEEK__API_KEY"
);
};
tracing::info!(provider = llm.provider, model = %llm.model, "rebuilding the profile");
let rebuilt = curate::profile::rebuild(
db,
&llm,
llm,
&config.interests_opml,
&config.profile_path,
config.curation.feedback.verdicts_in_prompt,
+207 -66
View File
@@ -31,15 +31,15 @@ use jiff::civil::Date;
use jiff::{Timestamp, Zoned};
use crate::config::Config;
use crate::curate::llm::{LlmClient, UsageMeter};
use crate::curate::llm::{Llms, PriceTable, UsageMeter};
use crate::curate::{Curator, editorial, profile};
use crate::db::Db;
use crate::extract::Extractor;
use crate::miniflux::MinifluxClient;
use crate::publish::Published;
use crate::report::{RunReport, RunStatus};
use crate::report::{ProviderUsage, RunReport, RunStatus};
use crate::types::{
Article, Artifact, Colophon, Edition, Issue, IssueMeta, Lineup, reading_minutes,
Article, Artifact, Colophon, Edition, Issue, IssueMeta, Lineup, Models, reading_minutes,
};
use crate::{comments, dedupe, epub, http, miniflux, publish, social, world};
@@ -185,7 +185,7 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
let started_at = Timestamp::now();
let (window_start, window_end) = ingest_window(config, date)?;
let out_dir = opts.out.clone().unwrap_or_else(|| config.out_dir.clone());
let target = opts.max_articles.unwrap_or(config.target_article_count);
let (soft_target, hard_max) = issue_size_bounds(config, opts.max_articles);
let span = tracing::info_span!("generate", %date, dry_run = opts.dry_run);
let _guard = span.enter();
@@ -193,16 +193,19 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
%window_start,
%window_end,
lookback_hours = config.lookback_hours,
target,
soft_target,
hard_max,
skip_llm = opts.skip_llm,
out = %out_dir.display(),
"starting run"
);
log_resolved_providers(config, opts.skip_llm);
let run_id = db.start_run(date, started_at).await?;
let mut report = RunReport::new(date, started_at);
report.window_start = Some(window_start);
report.window_end = Some(window_end);
report.config_json = resolved_run_config(config, soft_target, hard_max);
if opts.dry_run {
report.status = RunStatus::DryRun;
}
@@ -211,19 +214,16 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
config,
db,
date,
target,
soft_target,
hard_max,
started_at,
out_dir,
dry_run: opts.dry_run,
skip_llm: opts.skip_llm,
};
let stages = match run_stages(&ctx, window_start, window_end, &mut report).await {
Ok(stages) => {
report.finish(
Timestamp::now(),
config.deepseek.price_input_per_mtok,
config.deepseek.price_cached_input_per_mtok,
config.deepseek.price_output_per_mtok,
);
report.finish(Timestamp::now());
stages
}
Err(e) => {
@@ -276,7 +276,9 @@ struct StageContext<'a> {
config: &'a Config,
db: &'a Db,
date: Date,
target: usize,
soft_target: usize,
hard_max: usize,
started_at: Timestamp,
out_dir: PathBuf,
dry_run: bool,
skip_llm: bool,
@@ -369,23 +371,28 @@ async fn run_stages(
// --- Stage 6: heuristic pre-filter (§3.5) ---
let stage = Timestamp::now();
let meter = UsageMeter::new(&config.deepseek, config.max_daily_usd);
// `max_daily_usd` is a ceiling for the *day*, not for one invocation, so a
// re-run inherits what earlier runs for this date already spent (§3.6).
match db.spend_for_date(date).await {
Ok(spent) if spent > 0.0 => {
tracing::info!(spent, "preloading today's recorded DeepSeek spend");
meter.preload_cost(spent);
let bulk_meter =
UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
let editor_meter = UsageMeter::with_prices(
PriceTable::anthropic(&config.anthropic),
config.anthropic.max_daily_usd,
);
match db.provider_spend_for_utc_day(ctx.started_at).await {
Ok(spend) => {
bulk_meter.preload_cost(spend.get("deepseek").copied().unwrap_or(0.0));
editor_meter.preload_cost(spend.get("anthropic").copied().unwrap_or(0.0));
}
Err(error) => {
tracing::warn!(%error, "could not preload provider spend; starting from zero")
}
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "could not read today's spend; starting from zero"),
}
let llm = build_llm(ctx, &meter, report).await;
let llm_available = llm.is_some();
let llms = build_llms(ctx, &bulk_meter, &editor_meter, report).await;
let bulk_available = llms.bulk.is_some();
let mut curator_config = config.clone();
curator_config.target_article_count = ctx.target;
let curator = Curator::new(curator_config, db.clone(), llm);
curator_config.target_article_count = ctx.soft_target;
curator_config.curation.max_article_count = ctx.hard_max;
let curator = Curator::new(curator_config, db.clone(), llms);
let mut candidates = curator
.prefilter(articles, date)
@@ -396,12 +403,13 @@ async fn run_stages(
// --- Stage 7: LLM scoring, then selection (§3.6 A + B) ---
let stage = Timestamp::now();
if llm_available && let Err(e) = curator.score(&mut candidates, date).await {
if bulk_available && let Err(e) = curator.score(&mut candidates, date).await {
// A dead API or a tripped budget must not cost us the issue: selection
// degrades to prefilter order exactly as `--skip-llm` does.
report.warn(format!("LLM scoring failed; ranking heuristically: {e:#}"));
}
report.counts.llm_scored = candidates.iter().filter(|c| c.llm.is_some()).count() as i64;
report.counts.llm_unscored = report.counts.candidates - report.counts.llm_scored;
let mut lineup = curator
.select(candidates, date)
@@ -440,7 +448,7 @@ async fn run_stages(
if config.world_briefing {
match world_briefing.as_mut() {
Some(briefing) => {
for warning in world::enrich(&http, briefing, curator.llm.as_ref()).await {
for warning in world::enrich(&http, briefing, curator.llms.bulk.as_ref()).await {
report.warn(warning);
}
}
@@ -454,19 +462,55 @@ async fn run_stages(
.next_issue_number(date)
.await
.context("computing the issue number")?;
report.provider_costs.insert(
"deepseek".into(),
ProviderUsage {
usage: bulk_meter.total(),
cost_usd: bulk_meter.cost_usd(),
},
);
report.provider_costs.insert(
"anthropic".into(),
ProviderUsage {
usage: editor_meter.total(),
cost_usd: editor_meter.cost_usd(),
},
);
let summary_model = match config.editorial.summary_model {
crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => {
config.anthropic.model.clone()
}
_ if curator.llms.bulk.is_some() => config.deepseek.model.clone(),
_ => "none".into(),
};
let provider_costs = report
.provider_costs
.iter()
.map(|(provider, usage)| (provider.clone(), usage.cost_usd))
.collect();
let colophon = Colophon {
model: if llm_available {
config.deepseek.model.clone()
} else {
"none (--skip-llm)".into()
provider_costs,
models: Models {
bulk: if bulk_available {
config.deepseek.model.clone()
} else {
"none".into()
},
editor: if curator.llms.editor.is_some() {
config.anthropic.model.clone()
} else if bulk_available {
format!("{} (bulk fallback)", config.deepseek.model)
} else {
"none".into()
},
summaries: summary_model,
},
entries_fetched: report.counts.entries_fetched,
feeds_seen: report.counts.feeds_seen,
candidates: report.counts.candidates,
cost_usd: meter.cost_usd(),
cost_usd: bulk_meter.cost_usd() + editor_meter.cost_usd(),
generator_version: format!("daily-epub {}", crate::VERSION),
};
report.usage = meter.total();
let issue = build_issue(
date,
issue_number,
@@ -582,11 +626,12 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<(
///
/// Returns `None` for `--skip-llm` and for every configuration/API problem: the
/// caller then curates heuristically instead of failing the run (§3.6).
async fn build_llm(
async fn build_llms(
ctx: &StageContext<'_>,
meter: &UsageMeter,
bulk_meter: &UsageMeter,
editor_meter: &UsageMeter,
report: &mut RunReport,
) -> Option<LlmClient> {
) -> Llms {
let profile = match profile::load_or_build(
ctx.db,
&ctx.config.interests_opml,
@@ -596,32 +641,36 @@ async fn build_llm(
.await
{
Ok(profile) => profile,
Err(e) => {
Err(error) => {
report.warn(format!(
"could not build the taste profile; curating heuristically: {e:#}"
"could not build the taste profile; curating heuristically: {error:#}"
));
return None;
return Llms::default();
}
};
if ctx.skip_llm {
tracing::info!("--skip-llm: profile rebuilt; no DeepSeek call will be made");
return None;
tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made");
return Llms::default();
}
let client = match LlmClient::new(&ctx.config.deepseek, profile.text, meter.clone()) {
Ok(client) => client,
Err(e) => {
report.warn(format!(
"DeepSeek is unavailable; curating heuristically: {e}"
));
return None;
}
let make_clients = |prompt: String| {
Llms::from_config(
&ctx.config.deepseek,
&ctx.config.anthropic,
prompt,
bulk_meter.clone(),
editor_meter.clone(),
)
};
// Weekly rewrite of the "learned adjustments" section (§3.6c). It changes the
// system prompt, so the client is rebuilt around the new profile.
let mut llms = make_clients(profile.text);
let Some(rebuild_client) = llms.editor_or_bulk() else {
report.warn("no LLM provider is available; curating heuristically");
return llms;
};
match profile::weekly_rebuild_if_due(
ctx.db,
&client,
rebuild_client,
&ctx.config.interests_opml,
&ctx.config.profile_path,
ctx.config.curation.feedback.verdicts_in_prompt,
@@ -629,21 +678,78 @@ async fn build_llm(
.await
{
Ok(Some(rebuilt)) => {
tracing::info!(version = rebuilt.version, "taste profile rebuilt");
match LlmClient::new(&ctx.config.deepseek, rebuilt.text, meter.clone()) {
Ok(refreshed) => Some(refreshed),
Err(e) => {
tracing::warn!(error = %e, "keeping the previous profile client");
Some(client)
}
}
}
Ok(None) => Some(client),
Err(e) => {
report.warn(format!("weekly profile rebuild failed: {e:#}"));
Some(client)
tracing::info!(
version = rebuilt.version,
"taste profile rebuilt with editor-or-bulk"
);
llms = make_clients(rebuilt.text);
}
Ok(None) => {}
Err(error) => report.warn(format!("weekly profile rebuild failed: {error:#}")),
}
llms
}
/// `--max-articles N` is a ceiling, never a target (§13): the hard ceiling is
/// the smaller of `curation.max_article_count` and `N`, and the soft target
/// never exceeds it. Returns `(soft_target, hard_max)`.
pub fn issue_size_bounds(config: &Config, max_articles: Option<usize>) -> (usize, usize) {
let hard_max = max_articles.map_or(config.curation.max_article_count, |ceiling| {
ceiling.min(config.curation.max_article_count)
});
(config.target_article_count.min(hard_max), hard_max)
}
/// Startup line naming the resolved models and whether each provider is on
/// (§19): the root config ignores unknown sections, so an `[anthropics]` typo
/// would otherwise be silent. Keys are never logged, only their presence.
fn log_resolved_providers(config: &Config, skip_llm: bool) {
let has_key = |key: Option<&str>| key.is_some_and(|k| !k.trim().is_empty());
tracing::info!(
bulk_model = %config.deepseek.model,
bulk_enabled = !skip_llm && has_key(config.deepseek.api_key.as_deref()),
bulk_max_daily_usd = config.max_daily_usd,
editor_model = %config.anthropic.model,
editor_enabled = !skip_llm
&& config.anthropic.enabled
&& has_key(config.anthropic.api_key.as_deref()),
editor_effort = %config.anthropic.effort,
editor_max_daily_usd = config.anthropic.max_daily_usd,
summary_model = ?config.editorial.summary_model,
"resolved providers"
);
}
/// Prompt versions recorded per run so old telemetry stays interpretable (§7.6).
/// Bump a number when the corresponding instruction block changes.
const PROMPT_VERSIONS: &[(&str, u32)] = &[
("score", 1),
("editor", 2),
("summary", 1),
("brief", 2),
("profile", 2),
];
/// The resolved `[curation]`, `[editorial]`, model names and prompt versions
/// written to `runs.config_json` (§7.6, §19). Never includes keys.
fn resolved_run_config(config: &Config, soft_target: usize, hard_max: usize) -> serde_json::Value {
let mut curation = config.curation.clone();
curation.max_article_count = hard_max;
serde_json::json!({
"target_article_count": soft_target,
"prefilter_keep": config.prefilter_keep,
"curation": curation,
"editorial": config.editorial,
"models": {
"bulk": config.deepseek.model,
"editor": if config.anthropic.enabled { config.anthropic.model.as_str() } else { "disabled" },
"editor_effort": config.anthropic.effort,
},
"prompt_versions": PROMPT_VERSIONS
.iter()
.map(|(name, version)| ((*name).to_string(), serde_json::Value::from(*version)))
.collect::<serde_json::Map<_, _>>(),
})
}
fn elapsed_ms(since: Timestamp) -> i64 {
@@ -689,6 +795,41 @@ mod tests {
assert!(resolve_date(&config, None).is_ok());
}
#[test]
fn max_articles_is_a_ceiling_not_a_target() {
let config = Config {
target_article_count: 20,
..Config::default()
};
assert_eq!(config.curation.max_article_count, 28);
assert_eq!(issue_size_bounds(&config, None), (20, 28));
// A ceiling below the target drags the target down with it.
assert_eq!(issue_size_bounds(&config, Some(6)), (6, 6));
// A ceiling above the configured maximum does not raise it.
assert_eq!(issue_size_bounds(&config, Some(40)), (20, 28));
assert_eq!(issue_size_bounds(&config, Some(24)), (20, 24));
}
#[test]
fn run_config_json_records_the_resolved_settings_and_no_keys() {
let mut config = Config::default();
config.anthropic.api_key = Some("sk-secret".into());
config.deepseek.api_key = Some("ds-secret".into());
let value = resolved_run_config(&config, 6, 6);
assert_eq!(value["target_article_count"], 6);
assert_eq!(value["curation"]["max_article_count"], 6);
assert_eq!(value["editorial"]["summary_model"], "editor");
assert_eq!(value["editorial"]["summary_input_tokens"], 3000);
assert_eq!(value["models"]["bulk"], "deepseek-v4-flash");
assert_eq!(value["models"]["editor"], "claude-opus-5");
assert!(value["prompt_versions"]["editor"].is_number());
let text = value.to_string();
assert!(
!text.contains("secret"),
"keys must never reach the database"
);
}
#[test]
fn issue_meta_is_derived_from_the_lineup() {
let lineup = crate::epub::build::fixtures::issue().lineup;
+71 -19
View File
@@ -71,6 +71,8 @@ pub struct StageCounts {
pub candidates: i64,
/// Articles scored by the LLM (§3.6 stage A).
pub llm_scored: i64,
/// Candidates left unscored after failures or a bulk-provider budget trip (§5).
pub llm_unscored: i64,
/// Articles in the final lineup (§3.6 stage B).
pub selected: i64,
/// Discussion chapters rendered (§3.7).
@@ -93,6 +95,14 @@ impl StageTimings {
}
}
/// Usage and computed cost for one provider in this run (§5).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ProviderUsage {
#[serde(flatten)]
pub usage: TokenUsage,
pub cost_usd: f64,
}
/// The full summary of one `generate` invocation (§3.13).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunReport {
@@ -101,7 +111,12 @@ pub struct RunReport {
pub finished_at: Option<Timestamp>,
pub status: RunStatus,
pub counts: StageCounts,
/// Aggregate usage retained for the legacy `runs` columns.
pub usage: TokenUsage,
/// Provider-keyed usage and cost written to `runs.provider_costs_json`.
pub provider_costs: BTreeMap<String, ProviderUsage>,
/// Resolved curation/editorial/model settings for this run.
pub config_json: serde_json::Value,
pub cost_usd: f64,
pub timings: StageTimings,
/// Ingest window actually used, RFC3339 (§3.1).
@@ -124,6 +139,8 @@ impl RunReport {
status: RunStatus::Running,
counts: StageCounts::default(),
usage: TokenUsage::default(),
provider_costs: BTreeMap::new(),
config_json: serde_json::Value::Null,
cost_usd: 0.0,
timings: StageTimings::default(),
window_start: None,
@@ -146,16 +163,15 @@ impl RunReport {
self.error = Some(err.to_string());
}
/// Stamp the end time, compute cost from [`TokenUsage`] and settle the status.
pub fn finish(
&mut self,
finished_at: Timestamp,
price_input: f64,
price_cached: f64,
price_output: f64,
) {
/// Stamp the end time, total provider costs and settle the status.
pub fn finish(&mut self, finished_at: Timestamp) {
self.finished_at = Some(finished_at);
self.cost_usd = self.usage.cost_usd(price_input, price_cached, price_output);
self.usage = TokenUsage::default();
self.cost_usd = 0.0;
for provider in self.provider_costs.values() {
self.usage.add(provider.usage);
self.cost_usd += provider.cost_usd;
}
if self.status == RunStatus::Running {
self.status = if self.warnings.is_empty() {
RunStatus::Ok
@@ -215,17 +231,37 @@ mod tests {
s.parse().unwrap()
}
fn usage(input: i64, cached: i64, cache_write: i64, output: i64) -> TokenUsage {
TokenUsage {
input_tokens: input,
cached_tokens: cached,
cache_write_tokens: cache_write,
output_tokens: output,
}
}
#[test]
fn finish_computes_cost_and_status() {
fn finish_totals_provider_costs_and_settles_status() {
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
r.usage.add(TokenUsage {
input_tokens: 1_000_000,
cached_tokens: 1_000_000,
output_tokens: 1_000_000,
});
r.finish(ts("2026-08-15T05:36:00Z"), 0.14, 0.0028, 0.28);
r.provider_costs.insert(
"deepseek".into(),
ProviderUsage {
usage: usage(1_000_000, 1_000_000, 0, 1_000_000),
cost_usd: 0.4228,
},
);
r.provider_costs.insert(
"anthropic".into(),
ProviderUsage {
usage: usage(100, 3_000, 2_000, 800),
cost_usd: 0.05,
},
);
r.finish(ts("2026-08-15T05:36:00Z"));
assert_eq!(r.status, RunStatus::Ok);
assert!((r.cost_usd - 0.4228).abs() < 1e-9);
assert!((r.cost_usd - 0.4728).abs() < 1e-9);
// The legacy aggregate columns are the sum across providers.
assert_eq!(r.usage, usage(1_000_100, 1_003_000, 2_000, 1_000_800));
assert_eq!(r.duration_secs(), Some(360));
}
@@ -233,7 +269,7 @@ mod tests {
fn warnings_degrade_the_run() {
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
r.warn("xtc converter missing");
r.finish(ts("2026-08-15T05:31:00Z"), 0.14, 0.0028, 0.28);
r.finish(ts("2026-08-15T05:31:00Z"));
assert_eq!(r.status, RunStatus::Degraded);
assert_eq!(r.warnings.len(), 1);
}
@@ -242,15 +278,31 @@ mod tests {
fn serializes_round_trip() {
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
r.counts.entries_fetched = 412;
r.counts.llm_unscored = 3;
r.per_feed_counts.insert("Hacker News".into(), 30);
r.per_feed_counts.insert("Lobsters".into(), 12);
r.timings.record("ingest", 1500);
r.finish(ts("2026-08-15T05:31:00Z"), 0.14, 0.0028, 0.28);
r.config_json = serde_json::json!({"models": {"editor": "claude-opus-5"}});
r.provider_costs.insert(
"anthropic".into(),
ProviderUsage {
usage: usage(1, 2, 3, 4),
cost_usd: 0.01,
},
);
r.finish(ts("2026-08-15T05:31:00Z"));
let json = r.to_json();
let back: RunReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, r);
assert_eq!(back.top_feeds(1), vec![("Hacker News", 30)]);
assert_eq!(back.timings.total_ms(), 1500);
assert!(back.summary_line().contains("412 entries"));
// `ProviderUsage` flattens the token counts next to the cost.
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
value["provider_costs"]["anthropic"]["cache_write_tokens"],
3
);
assert_eq!(value["provider_costs"]["anthropic"]["cost_usd"], 0.01);
}
}
+25 -6
View File
@@ -292,6 +292,8 @@ pub struct Pick {
/// Order within the section, ascending.
pub position: i64,
pub is_lead: bool,
/// Editor-written reason, at most 14 words (§13).
pub why: Option<String>,
/// Newspaper-abstract summary from stage C; `None` until editorial runs.
pub summary: Option<String>,
pub llm: Option<LlmScore>,
@@ -331,8 +333,6 @@ impl Lineup {
pub struct Editorial {
/// "From the Editor", 250400 words, already sanitized XHTML.
pub front_page_html: String,
/// Section name → 23 sentence intro.
pub section_intros: BTreeMap<String, String>,
/// Article id → 23 sentence newspaper abstract.
pub summaries: BTreeMap<ArticleId, String>,
}
@@ -525,10 +525,19 @@ pub struct Issue {
pub colophon: Colophon,
}
/// Resolved model names printed in the colophon (§15.1).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Models {
pub bulk: String,
pub editor: String,
pub summaries: String,
}
/// Back-matter facts printed in the colophon chapter (§3.10).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Colophon {
pub model: String,
pub provider_costs: BTreeMap<String, f64>,
pub models: Models,
pub entries_fetched: i64,
pub feeds_seen: i64,
pub candidates: i64,
@@ -653,8 +662,10 @@ pub struct RatedArticle {
pub struct TokenUsage {
/// Cache-miss input tokens (billed at the full input rate).
pub input_tokens: i64,
/// Prefix-cache hits (billed at the cached rate).
/// Prefix-cache reads (billed at the provider's cache-read rate).
pub cached_tokens: i64,
/// Tokens written into a prompt cache (Anthropic only).
pub cache_write_tokens: i64,
pub output_tokens: i64,
}
@@ -662,13 +673,21 @@ impl TokenUsage {
pub fn add(&mut self, other: TokenUsage) {
self.input_tokens += other.input_tokens;
self.cached_tokens += other.cached_tokens;
self.cache_write_tokens += other.cache_write_tokens;
self.output_tokens += other.output_tokens;
}
/// USD cost given the per-1M-token prices from `[deepseek]` config (§3.6).
pub fn cost_usd(&self, price_input: f64, price_cached: f64, price_output: f64) -> f64 {
pub fn cost_usd(
&self,
price_input: f64,
price_cache_write: f64,
price_cache_read: f64,
price_output: f64,
) -> f64 {
(self.input_tokens as f64 * price_input
+ self.cached_tokens as f64 * price_cached
+ self.cache_write_tokens as f64 * price_cache_write
+ self.cached_tokens as f64 * price_cache_read
+ self.output_tokens as f64 * price_output)
/ 1_000_000.0
}