diff --git a/README.md b/README.md
index c35b816..554cda0 100644
--- a/README.md
+++ b/README.md
@@ -5,15 +5,20 @@ A personalized daily newspaper, delivered as an EPUB.
Every morning a systemd timer wakes one Rust binary. It pulls the last ~26 hours
from a self-hosted [Miniflux](https://miniflux.app), deduplicates and extracts
the articles, enriches them with HackerNews/Lobsters/Reddit social proof, filters
-300–500 candidates down to ~120 with cheap heuristics, and asks DeepSeek to score,
-select and introduce 15–25 of them. It assembles two EPUB editions (a standard one
+300–500 candidates down to ~120 with cheap heuristics, asks DeepSeek to score them,
+and hands the shortlist to Claude Opus 5 — the editor — which assembles the issue
+(no minimum size, a hard ceiling), writes a one-line *why* under every headline,
+the summaries and *The Brief*. It assembles two EPUB editions (a standard one
and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and
publishes the lot over its own OPDS catalog — which doubles as a
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
Each article chapter ends with Loved it / Good / Not for me links that feed back into tomorrow's curation.
-Steady-state cost is roughly **$0.05–0.30/day** in DeepSeek tokens, hard-capped by
-`max_daily_usd`.
+Steady-state cost is roughly **$1/day**: $0.05–0.30 in DeepSeek tokens plus
+~$0.50–0.80 for the Claude editor, each with its own per-UTC-day ceiling
+(`max_daily_usd` and `anthropic.max_daily_usd`). Those ceilings are runaway
+guards, not accounting — set hard spend limits in both providers' dashboards as
+the real backstop.
- Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md)
- Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md)
@@ -24,7 +29,7 @@ Steady-state cost is roughly **$0.05–0.30/day** in DeepSeek tokens, hard-cappe
```
Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
- ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial
+ ─▶ pre-filter ─▶ scoring (DeepSeek) ─▶ editor (Claude) ─▶ comments ─▶ editorial (Claude)
─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
```
@@ -35,9 +40,12 @@ Every stage writes to SQLite, so a run is idempotent per date: re-running
are fatal — without them there is no issue, and the `runs` row records why.
Social lookups, comment fetching, the world briefing, images and the XTC
conversion are best-effort: they log, add a warning (run status `degraded`) and
-the run continues. Every DeepSeek stage *degrades*: a missing key, a dead API or
-a tripped budget turns the run into the `--skip-llm` shape (prefilter order
-selects, feed excerpts stand in for summaries) instead of losing the day's issue.
+the run continues. Every LLM stage *degrades*: a Claude call that fails, is
+refused, or is over its daily ceiling is retried with the same prompt on
+DeepSeek; if DeepSeek is missing, dead or over budget too, the run takes the
+`--skip-llm` shape (prefilter order selects, feed excerpts stand in for
+summaries) instead of losing the day's issue. Anthropic's server-side refusal
+fallback (`fallbacks = "default"`) is enabled on every editor request.
---
@@ -47,7 +55,8 @@ selects, feed excerpts stand in for summaries) instead of losing the day's issue
|---|---|---|
| Rust (2024 edition toolchain) | building | `cargo build --release` |
| **Miniflux** with an API key | the only content source | Settings → API Keys. The client is read-only and never mutates read state. |
-| **DeepSeek API key** | curation + editorial | . Optional: `--skip-llm` runs the whole pipeline without it. |
+| **DeepSeek API key** | scoring, and the fallback for every editor call | . Optional: `--skip-llm` runs the whole pipeline without it. |
+| **Anthropic API key** | the editor: selection, summaries, The Brief, the weekly profile rebuild | . Optional: without it every editor call runs on DeepSeek. Set a dashboard spend limit; `anthropic.max_daily_usd` is only a runaway guard. |
| A 32+ byte random secret | signs the article rating links | `openssl rand -hex 32` |
| **BookOrbit** library + watched folder | *optional* — a richer library UI on top of the same folder | Delivery does not need it: `daily-epub serve` has its own OPDS catalog over `publish.epub_dir`. If you do run it, create a dedicated "The Daily EPUB" library, enable *Watch folders*, and point `publish.epub_dir` at it. |
| **Node.js 18+** and a clone of [`epub-to-xtc-converter`](https://github.com/bigbag/epub-to-xtc-converter) | XTC/XTCH output for the Xteink X4 | Optional (`xtc.enabled = false` turns it off). Needs `npm install` **inside `cli/`**, and a settings JSON naming a real TTF/OTF — see below. It has **no global npm bin** — it is invoked as `node /cli/index.js convert …`, which is why `xtc.command`/`xtc.args` are fully general. |
@@ -109,11 +118,11 @@ Secrets belong in the environment file, never in the TOML.
|---|---|---|
| `timezone` | `America/New_York` | Day boundaries and `--date` interpretation. |
| `lookback_hours` | `26` | Size of the ingest window ending at the issue day's end (clamped to now). |
-| `target_article_count` | `20` | Lineup size the selector aims for. `--max-articles` overrides it. |
+| `target_article_count` | `20` | Soft target the editor aims for. There is no minimum: a nine-pick issue is published as nine. |
| `prefilter_keep` | `120` | Candidates surviving the heuristic pre-filter. Must be ≥ `target_article_count`. |
| `retention_days` | `21` | EPUBs older than this are deleted from `publish.epub_dir`. SQLite history is kept forever. |
| `xtc_retention_count` | `5` | How many XTC issues to keep in `publish.xtc_dir`. Counted, not dated: each `.xtch` is ~80–100 MB, so the binding constraint is disk, not age. |
-| `max_daily_usd` | `2.0` | Hard ceiling on DeepSeek spend **per day**, not per run — a re-run inherits what earlier runs for that date already spent. Tripping it skips remaining LLM work and degrades to excerpts. |
+| `max_daily_usd` | `2.0` | Ceiling on DeepSeek spend per **UTC day** of the run's start, not per run — a re-run inherits what earlier runs that day already spent (`runs.provider_costs_json`). Tripping it skips remaining DeepSeek calls; in-flight requests finish and the paper still publishes. |
| `world_briefing` | `true` | Include the Wikipedia Current Events section. |
| `database_path` | `/var/lib/daily-epub/daily-epub.db` | SQLite file; parent dirs are created. |
| `out_dir` | `/var/lib/daily-epub/out` | Where `generate` writes artifacts before publishing. |
@@ -126,11 +135,24 @@ Secrets belong in the environment file, never in the TOML.
| `deepseek.model` | `deepseek-v4-flash` | Verified 2026-08-15 (DeepSeek-V4-Flash-0731). |
| `deepseek.api_key` | — | **`DAILY_EPUB_DEEPSEEK__API_KEY`**. Absent ⇒ the run curates heuristically. |
| `deepseek.score_batch_size` | `12` | Articles per stage-A scoring request. |
-| `deepseek.score_temperature` | `0.3` | Scoring/selection temperature. |
-| `deepseek.editorial_temperature` | `0.8` | Summaries, intros, front page. |
+| `deepseek.max_concurrent_requests` | `4` | Stage-A batches in flight at once; the budget is checked before each is spawned. |
+| `deepseek.score_temperature` | `0.3` | Scoring temperature. |
+| `deepseek.editorial_temperature` | `0.8` | Summaries and The Brief, only when DeepSeek is the fallback editor. |
| `deepseek.price_input_per_mtok` | `0.14` | USD per 1M cache-miss input tokens (cost guardrail arithmetic). |
| `deepseek.price_cached_input_per_mtok` | `0.0028` | USD per 1M prefix-cache-hit input tokens. |
| `deepseek.price_output_per_mtok` | `0.28` | USD per 1M output tokens. |
+| `anthropic.enabled` | `true` | `false` runs every editor call on DeepSeek. |
+| `anthropic.base_url` | `https://api.anthropic.com` | Messages API root. |
+| `anthropic.model` | `claude-opus-5` | The editor. Requests carry `output_config.effort`, a cached system block, and `fallbacks = "default"` with the `server-side-fallback-2026-07-01` beta so a classifier refusal is re-routed server-side. |
+| `anthropic.api_key` | — | **`DAILY_EPUB_ANTHROPIC__API_KEY`**. Absent ⇒ editor calls fall back to DeepSeek. |
+| `anthropic.effort` | `high` | `low`, `medium`, `high`, `xhigh` or `max`. |
+| `anthropic.price_input_per_mtok` | `5.0` | USD per 1M uncached input tokens. |
+| `anthropic.price_cache_write_per_mtok` | `6.25` | USD per 1M tokens written to the prompt cache. |
+| `anthropic.price_cache_read_per_mtok` | `0.5` | USD per 1M cache-read input tokens. |
+| `anthropic.price_output_per_mtok` | `25.0` | USD per 1M output tokens. |
+| `anthropic.max_daily_usd` | `3.0` | Claude ceiling per UTC day; tripping it moves the remaining editor work to DeepSeek. |
+| `anthropic.max_concurrent_requests` | `4` | Reserved for the parallel editor stages. |
+| `curation.max_article_count` | `28` | Hard ceiling on issue size. `--max-articles N` lowers it to `min(28, N)` and drags the soft target down with it. Must be ≥ `target_article_count`. |
| `curation.always_include_feeds` | `[]` | Miniflux feed ids or URL substrings that can never be dropped. |
| `curation.blocked_domains` | `[]` | Hosts excluded outright. |
| `curation.paywall_domains` | `[]` | Extra paywalled hosts, merged with the built-in list (nytimes, wsj, ft, economist, …). |
@@ -139,6 +161,8 @@ Secrets belong in the environment file, never in the TOML.
| `curation.feedback.good_value` | `0.35` | Weight for a Good verdict. |
| `curation.feedback.not_for_me_value` | `-1.0` | Weight for a Not for me verdict. |
| `curation.feedback.verdicts_in_prompt` | `60` | Recent explicit verdicts included in the system prompt. |
+| `editorial.summary_model` | `editor` | `editor` (Claude) or `bulk` (DeepSeek) for the per-article summaries. |
+| `editorial.summary_input_tokens` | `3000` | Article text offered to the summary prompt. |
| `publish.epub_dir` | `/srv/bookorbit/libraries/daily-epub` | Both EPUB editions land here by atomic copy, and this is the directory the OPDS feed lists. The editions are distinguished by a `(X4)` tag in **both** the filename and `dc:title` — libraries and OPDS clients list books by title, so the filename alone would make them look identical. Point a BookOrbit watched folder at it if you want its UI too. **Renamed from `bookorbit_dir`**; the old key is a hard config error. |
| `publish.xtc_dir` | `/var/lib/daily-epub/xtc` | XTC artifacts. **Not** listed in the OPDS feed — CrossPoint cannot acquire them — but downloadable at `/files/xtc/` for sideloading. |
| `xtc.enabled` | `true` | Set `false` to skip the converter entirely. |
@@ -174,6 +198,7 @@ sudo -e /etc/daily-epub/config.toml # set publish dirs, xtc args, pub
sudo tee /etc/daily-epub/env >/dev/null < Pick {
section: target.issue.clone(),
position: 0,
is_lead: false,
+ why: None,
summary: None,
llm: None,
discussion: None,
@@ -412,7 +413,6 @@ fn write_audit_epub(out_dir: &Path, picks: &[Pick], assets: &[ImageAsset]) {
Articles are re-extracted live; editorial, discussions and the \
world briefing are absent by design.
"
.into(),
- section_intros: Default::default(),
summaries: Default::default(),
},
world_briefing: None,
diff --git a/src/config.rs b/src/config.rs
index 3c42a58..2ac63ad 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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,
/// 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,
+ 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,
/// 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]
diff --git a/src/curate/editorial.rs b/src/curate/editorial.rs
index 35ce406..842e833 100644
--- a/src/curate/editorial.rs
+++ b/src/curate/editorial.rs
@@ -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\": \"\"}";
-/// 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, 2–4 paragraphs \
-separated by a blank line.
+Return JSON exactly: {"brief": ""}"#;
-2. \"section_intros\" — for EACH section name given below, two or three \
-sentences (35–60 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\": \"\", \
-\"section_intros\": {\"\": \"<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", 250–400 words.
- pub from_the_editor: String,
- /// Section name → 2–3 sentence intro.
+#[derive(Debug, Clone, PartialEq, Deserialize)]
+pub struct BriefResponse {
#[serde(default)]
- pub section_intros: BTreeMap,
+ 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 2–3 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 {
- 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 {
+ 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 {
+ 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 {
- 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,
- temperature: f32,
-) -> Result {
- 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) -> String {
+pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap) -> 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) -> 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,
+ temperature: f32,
+) -> Result {
+ 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::(&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::(&prompt, temperature)
+ .await?
+ }
+ };
+ let brief = response.brief.trim().to_string();
+ if brief.is_empty() {
+ return Err(LlmError::EmptyResponse {
+ provider: primary.provider,
+ });
}
- let parts: Vec = 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!("{}
", 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, limit: f64) -> LlmClient {
- LlmClient::with_backend(
- "deepseek-v4-flash",
+ fn mock(provider: &'static str, backend: Arc, 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,
)
}
+ fn bulk_only(backend: Arc, limit: f64) -> Llms {
+ Llms {
+ bulk: Some(mock("deepseek", backend, limit)),
+ editor: None,
+ }
+ }
+
+ fn editor_and_bulk(editor: Arc, bulk: Arc) -> 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!("{}
", "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(""));
assert!(editorial.front_page_html.contains("
"));
+ assert!(
+ editorial
+ .front_page_html
+ .contains("Migrating 40TB off Postgres")
+ );
assert!(!editorial.front_page_html.contains("