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:
+30
-15
@@ -18,18 +18,19 @@
|
||||
//! no article in the fixtures carries an image, so the EPUB builder's image
|
||||
//! downloader has nothing to fetch.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use jiff::Timestamp;
|
||||
use jiff::civil::Date;
|
||||
|
||||
use daily_epub::config::{Config, PublishConfig, ServerConfig, XtcConfig};
|
||||
use daily_epub::curate::llm::{LlmClient, MockBackend, UsageMeter};
|
||||
use daily_epub::curate::llm::{LlmClient, Llms, MockBackend, UsageMeter};
|
||||
use daily_epub::curate::{Curator, editorial, prefilter};
|
||||
use daily_epub::db::Db;
|
||||
use daily_epub::extract::Extractor;
|
||||
use daily_epub::types::{
|
||||
Article, Colophon, Edition, Entry, Issue, Lineup, ScoredArticle, SourceKind, Vote,
|
||||
Article, Colophon, Edition, Entry, Issue, Lineup, Models, ScoredArticle, SourceKind, Vote,
|
||||
};
|
||||
use daily_epub::{auth, dedupe, epub, miniflux, pipeline, publish};
|
||||
|
||||
@@ -400,7 +401,7 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
|
||||
let articles = ingest_dedupe_extract_persist(&db).await;
|
||||
|
||||
// --- Stages 6–7 with no LLM at all (notes §6) ---
|
||||
let curator = Curator::new(cfg.clone(), db.clone(), None);
|
||||
let curator = Curator::new(cfg.clone(), db.clone(), Llms::default());
|
||||
let candidates = curator
|
||||
.prefilter(articles, date())
|
||||
.await
|
||||
@@ -432,7 +433,12 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
|
||||
);
|
||||
|
||||
let colophon = Colophon {
|
||||
model: "none (--skip-llm)".into(),
|
||||
provider_costs: BTreeMap::new(),
|
||||
models: Models {
|
||||
bulk: "none".into(),
|
||||
editor: "none".into(),
|
||||
summaries: "none".into(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
candidates: 5,
|
||||
@@ -504,6 +510,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let usage = daily_epub::types::TokenUsage {
|
||||
input_tokens: 1000,
|
||||
cached_tokens: 500,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 200,
|
||||
};
|
||||
let scores: Vec<String> = ids
|
||||
@@ -544,8 +551,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
);
|
||||
}
|
||||
backend.push(
|
||||
r#"{"from_the_editor": "Today's issue leans on storage internals.\n\nRead on.",
|
||||
"section_intros": {"Top Stories": "The day in one place."}}"#,
|
||||
r#"{"brief": "Today's issue leans on storage internals.\n\nRead on."}"#,
|
||||
usage,
|
||||
);
|
||||
|
||||
@@ -556,7 +562,14 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
meter.clone(),
|
||||
backend.clone(),
|
||||
);
|
||||
let curator = Curator::new(cfg.clone(), db.clone(), Some(llm));
|
||||
let curator = Curator::new(
|
||||
cfg.clone(),
|
||||
db.clone(),
|
||||
Llms {
|
||||
bulk: Some(llm),
|
||||
editor: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut candidates = candidates;
|
||||
curator
|
||||
@@ -590,12 +603,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
"the model's summaries were used, not excerpts"
|
||||
);
|
||||
assert!(editorial_doc.front_page_html.contains("storage internals"));
|
||||
assert_eq!(
|
||||
editorial_doc
|
||||
.section_intros
|
||||
.get("Top Stories")
|
||||
.map(String::as_str),
|
||||
Some("The day in one place.")
|
||||
assert!(
|
||||
lineup.picks.iter().all(|p| p.why.is_none()),
|
||||
"the scripted editor gave no why lines"
|
||||
);
|
||||
|
||||
// Every scripted response was consumed, and the meter priced them (§3.6).
|
||||
@@ -615,7 +625,12 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
|
||||
// And it all assembles, builds and publishes like the skip-llm route does.
|
||||
let colophon = Colophon {
|
||||
model: cfg.deepseek.model.clone(),
|
||||
provider_costs: BTreeMap::from([("deepseek".to_string(), meter.cost_usd())]),
|
||||
models: Models {
|
||||
bulk: cfg.deepseek.model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.deepseek.model),
|
||||
summaries: cfg.deepseek.model.clone(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
candidates: 5,
|
||||
@@ -625,6 +640,6 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let mut lineup = lineup;
|
||||
pipeline::apply_summaries(&mut lineup, &editorial_doc);
|
||||
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await;
|
||||
assert_eq!(issue.colophon.model, cfg.deepseek.model);
|
||||
assert_eq!(issue.colophon.models.bulk, cfg.deepseek.model);
|
||||
assert!(issue.colophon.cost_usd > 0.0);
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"brief": "The lead, \"Migrating 40TB off Postgres\", is the rare migration write-up that keeps its failures in: two aborted cutovers, the rollback that took longer than the move, and a bill at the end. Read it first while the coffee is hot; it rewards attention and it is long.\n\nThe local desk answers with \"The MBTA slow-zone dataset\", which finally puts the T's own numbers into a shape a rider can argue with, and the charts do more persuading than a year of press releases. \"A failover story you'd argue with\" rounds out the engineering pages with a Postgres HA design that disagrees with the lead on almost every point, which is exactly why the two belong in the same issue. The issue is shorter than usual because a thin Friday is a good excuse to finish the long one properly rather than skim six."
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"from_the_editor": "Two of today's pieces are, underneath, the same story: what it costs to move data you no longer trust. The lead — a team hauling forty terabytes off Postgres, rollback plans and all — is the version with the invoices attached, and it earns the front page by refusing to tidy up its failures. Read it first, while the coffee is hot; it rewards attention and it is long.\n\nThe local desk supplies the counterpoint. Somebody has finally put the MBTA's slow-zone data into a shape a rider can argue with, and the charts do more persuading than a year of press releases. It is a short read and a satisfying one, and it pairs unreasonably well with the migration story: both are about institutions discovering what they actually have.\n\nThe rest of the issue is quieter than usual. That is not a complaint — a thin Friday is a good excuse to finish the long one properly rather than skimming six. If you only get through the lead today, you will not have missed much else.",
|
||||
"section_intros": {
|
||||
"Top Stories": "The day's most substantial piece: a full account of a forty-terabyte migration, with the failures left in. It is long, technical and unusually honest about what went wrong.",
|
||||
"Boston & Local": "Transit data gets the treatment it deserves. A rider-built analysis of MBTA slow zones, with charts you can check yourself and a methodology section that holds up.",
|
||||
"Niche Corner": "A section the model wrote an intro for even though nothing was placed in it today — a stray thread about tape-drive firmware and the people who still maintain it. The issue drops intros for sections that never ran."
|
||||
}
|
||||
}
|
||||
Vendored
+5
-5
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"picks": [
|
||||
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true },
|
||||
{ "id": 102, "section": "Tech & Engineering", "position": 1, "lead_story": false },
|
||||
{ "id": 103, "section": "Tech & Engineering", "position": 2, "lead_story": false },
|
||||
{ "id": 104, "section": "AI & Machine Learning", "position": 1, "lead_story": false },
|
||||
{ "id": 105, "section": "Boston & Local", "position": 1, "lead_story": false },
|
||||
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true, "why": "The migration post-mortem with the invoices still attached" },
|
||||
{ "id": 102, "section": "Tech & Engineering", "position": 1, "lead_story": false, "why": "A failover story you'd argue with over coffee" },
|
||||
{ "id": 103, "section": "Tech & Engineering", "position": 2, "lead_story": false, "why": "Rare first-hand detail on a tool you use daily" },
|
||||
{ "id": 104, "section": "AI & Machine Learning", "position": 1, "lead_story": false, "why": "The one benchmark piece this week that shows its work" },
|
||||
{ "id": 105, "section": "Boston & Local", "position": 1, "lead_story": false, "why": "MBTA slow zones charted by a rider, not a press office" },
|
||||
{ "id": 106, "section": "Culture & Essays", "position": 1 },
|
||||
{ "section": "Niche Corner", "position": 2, "lead_story": false },
|
||||
"the model sometimes trails off like this"
|
||||
|
||||
+25
-19
@@ -17,7 +17,7 @@ use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use daily_epub::curate::editorial::FrontPageResponse;
|
||||
use daily_epub::curate::editorial::BriefResponse;
|
||||
use daily_epub::curate::profile;
|
||||
use daily_epub::curate::score::parse_score_response;
|
||||
use daily_epub::curate::select::parse_selection_response;
|
||||
@@ -130,35 +130,41 @@ fn stage_b_fixture_parses_into_a_lineup() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Stage C's front-page response must deserialize into a 250–400 word editor's
|
||||
/// note plus per-section intros (§3.6).
|
||||
/// The Brief must deserialize into 120–200 words of plain prose that names at
|
||||
/// least three picks by title (§14.2). Section intros are gone.
|
||||
#[test]
|
||||
fn stage_c_fixture_parses_into_a_front_page() {
|
||||
let response: FrontPageResponse = serde_json::from_str(&fixture("deepseek_front_page.json"))
|
||||
.expect("the front-page fixture must match FrontPageResponse");
|
||||
fn stage_c_fixture_parses_into_the_brief() {
|
||||
let response: BriefResponse = serde_json::from_str(&fixture("claude_brief.json"))
|
||||
.expect("the brief fixture must match BriefResponse");
|
||||
|
||||
let words = response.from_the_editor.split_whitespace().count();
|
||||
let words = response.brief.split_whitespace().count();
|
||||
assert!(
|
||||
(150..=450).contains(&words),
|
||||
"From the Editor is {words} words; the prompt asks for 250-400"
|
||||
(100..=220).contains(&words),
|
||||
"The Brief is {words} words; the prompt asks for 120-200"
|
||||
);
|
||||
assert!(
|
||||
response.from_the_editor.contains("\n\n"),
|
||||
"the prompt asks for 2-4 blank-line separated paragraphs"
|
||||
!response.brief.contains("- ") && !response.brief.contains('#'),
|
||||
"no bullets or headings in the brief"
|
||||
);
|
||||
let titles = response.brief.matches('"').count() / 2;
|
||||
assert!(
|
||||
!response.from_the_editor.contains("- "),
|
||||
"no bullet lists on the front page"
|
||||
titles >= 3,
|
||||
"the brief names at least three picks; found {titles}"
|
||||
);
|
||||
|
||||
assert!(response.section_intros.len() >= 2);
|
||||
for (section, intro) in &response.section_intros {
|
||||
let words = intro.split_whitespace().count();
|
||||
for banned in [
|
||||
"delve",
|
||||
"dive",
|
||||
"explore",
|
||||
"a mix of",
|
||||
"something for everyone",
|
||||
] {
|
||||
assert!(
|
||||
(10..=90).contains(&words),
|
||||
"intro for {section} is {words} words; the prompt asks for 35-60"
|
||||
!response.brief.to_lowercase().contains(banned),
|
||||
"banned phrase {banned}"
|
||||
);
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(&fixture("claude_brief.json")).unwrap();
|
||||
assert!(value.get("section_intros").is_none());
|
||||
}
|
||||
|
||||
/// The taste profile is seeded from this file; a broken export would silently
|
||||
|
||||
+8
-1
@@ -320,7 +320,14 @@ fn colophon_facts_are_x4_safe_distinct_paragraphs() {
|
||||
for edition in [Edition::Standard, Edition::X4] {
|
||||
let (_dir, _, zip) = build_edition_to_bytes(&issue, edition);
|
||||
let colophon = read_entry(&zip, "OEBPS/colophon.xhtml");
|
||||
assert_eq!(colophon.matches("<p class=\"fact-line\">").count(), 9);
|
||||
// Issue, generated, three model lines, entries, candidates, articles,
|
||||
// words, two per-provider cost lines, the total, generator (§15.1).
|
||||
assert_eq!(colophon.matches("<p class=\"fact-line\">").count(), 13);
|
||||
assert!(colophon.contains("<strong>Editor model:</strong> claude-opus-5"));
|
||||
assert!(colophon.contains("<strong>Bulk model:</strong> deepseek-v4-flash"));
|
||||
assert!(colophon.contains("<strong>anthropic cost:</strong> $0.0500"));
|
||||
assert!(colophon.contains("<strong>deepseek cost:</strong> $0.0231"));
|
||||
assert!(colophon.contains("<strong>Total token cost:</strong>"));
|
||||
assert!(!colophon.contains("<dl"));
|
||||
assert!(!colophon.contains("<dt"));
|
||||
assert!(!colophon.contains("<dd"));
|
||||
|
||||
Reference in New Issue
Block a user