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
+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]