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