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
+45 -17
View File
@@ -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
300500 candidates down to ~120 with cheap heuristics, and asks DeepSeek to score,
select and introduce 1525 of them. It assembles two EPUB editions (a standard one
300500 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.050.30/day** in DeepSeek tokens, hard-capped by
`max_daily_usd`.
Steady-state cost is roughly **$1/day**: $0.050.30 in DeepSeek tokens plus
~$0.500.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.050.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 | <https://platform.deepseek.com>. Optional: `--skip-llm` runs the whole pipeline without it. |
| **DeepSeek API key** | scoring, and the fallback for every editor call | <https://platform.deepseek.com>. Optional: `--skip-llm` runs the whole pipeline without it. |
| **Anthropic API key** | the editor: selection, summaries, The Brief, the weekly profile rebuild | <https://console.anthropic.com>. 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 <repo>/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 ~80100 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/<name>` 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 <<EOF
DAILY_EPUB_MINIFLUX__API_KEY=…
DAILY_EPUB_DEEPSEEK__API_KEY=…
DAILY_EPUB_ANTHROPIC__API_KEY=…
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
EOF
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
@@ -344,12 +369,13 @@ DAILY_EPUB_OUT_DIR=./out daily-epub generate --dry-run --skip-llm --max-articles
# 3. Inspect the artifacts
ls -la ./out # two .epub files
epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
# open the standard edition in Calibre / KOReader: cover, From the Editor,
# open the standard edition in Calibre / KOReader: cover, The Brief,
# In This Issue, sections, discussions, colophon; TOC depth 2
# 4. Now with DeepSeek, still not publishing
# 4. Now with DeepSeek and Claude, still not publishing
daily-epub generate --dry-run --out ./out --max-articles 6
# → check the lineup is sane and the printed cost is well under $0.50
# → check the lineup is sane (at most 6 picks, each with a "why" line) and the
# printed per-provider cost is well under $1
# 5. Full live run
sudo systemctl start daily-epub-generate
@@ -485,7 +511,9 @@ From spec §7, plus what implementation turned up:
stack, so `curate/llm.rs` speaks the OpenAI-compatible wire protocol over the
shared `reqwest` client instead, behind a `ChatBackend` trait. The dependency
was removed.
- **Only DeepSeek is wired.** Another provider means another `ChatBackend` impl.
- **Two providers are wired**, DeepSeek (bulk) and Anthropic (editor), each a
`ChatBackend` impl with its own `UsageMeter` and price table. A third means
another impl.
- **No embedding-based personal ranker yet** (spec §3.9 future work); the schema
is ready for it once ~200 ratings exist.
- **One reader, one issue per day.** There is no multi-user support and no
+27 -2
View File
@@ -4,6 +4,7 @@
# Nested keys use a double underscore in env vars, e.g.
# DAILY_EPUB_MINIFLUX__API_KEY=...
# DAILY_EPUB_DEEPSEEK__API_KEY=...
# DAILY_EPUB_ANTHROPIC__API_KEY=...
# DAILY_EPUB_SERVER__HMAC_SECRET=...
# DAILY_EPUB_LOOKBACK_HOURS=30
@@ -13,7 +14,7 @@ target_article_count = 20
prefilter_keep = 120
retention_days = 21 # EPUBs, by age
xtc_retention_count = 5 # XTC issues, by count (~80-100 MB each)
max_daily_usd = 2.0
max_daily_usd = 2.0 # DeepSeek ceiling per UTC day; [anthropic] has its own
world_briefing = true
# SQLite database file. Parent directories are created on demand.
@@ -36,14 +37,34 @@ base_url = "https://api.deepseek.com/v1"
model = "deepseek-v4-flash" # DeepSeek-V4-Flash-0731 (confirmed 2026-08-15)
# api_key via DAILY_EPUB_DEEPSEEK__API_KEY env
score_batch_size = 12
max_concurrent_requests = 4 # stage-A batches in flight at once
score_temperature = 0.3
editorial_temperature = 0.8
editorial_temperature = 0.8 # used only when DeepSeek is the fallback editor
# USD per 1M tokens, used for the cost guardrail.
price_input_per_mtok = 0.14
price_cached_input_per_mtok = 0.0028
price_output_per_mtok = 0.28
# Claude is the editor: selection, summaries, The Brief and the weekly profile
# rebuild. Every call degrades to DeepSeek when the key is missing, the daily
# ceiling is hit, or the API refuses/fails. Server-side refusal fallback
# (`fallbacks = "default"`) is always on. Set a spend limit in the Anthropic
# dashboard too: `max_daily_usd` is a runaway guard, not accounting.
[anthropic]
enabled = true
base_url = "https://api.anthropic.com"
model = "claude-opus-5"
# api_key via DAILY_EPUB_ANTHROPIC__API_KEY env
effort = "high" # low | medium | high | xhigh | max
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
[curation]
max_article_count = 28 # hard ceiling; there is no minimum (§13)
always_include_feeds = [] # miniflux feed ids or site urls
blocked_domains = []
# Extra paywalled hosts, merged with the built-in list (nytimes, wsj, ft, …).
@@ -66,6 +87,10 @@ good_value = 0.35
not_for_me_value = -1.0
verdicts_in_prompt = 60
[editorial]
summary_model = "editor" # editor (Claude) | bulk (DeepSeek)
summary_input_tokens = 3000 # article text offered per summary
[publish]
# Where both EPUB editions land, and what the OPDS feed lists. BookOrbit is
# optional — it just watches this folder if you run it.
+1 -1
View File
@@ -369,6 +369,7 @@ fn pick_for(target: &Target, content_html: String) -> 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.</p>"
.into(),
section_intros: Default::default(),
summaries: Default::default(),
},
world_briefing: None,
+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]
+328 -312
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).
pub async fn summarize_all(
llm: &LlmClient,
lineup: &Lineup,
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;
/// `(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(
llm,
primary,
&pick.article.title,
&pick.article.content_html,
config.summary_input_tokens,
temperature,
)
.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"
);
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()
}
}
}
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,
pub async fn summarize_all(
llms: &Llms,
lineup: &Lineup,
summaries: &BTreeMap<ArticleId, String>,
config: &EditorialConfig,
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)
) -> BTreeMap<ArticleId, String> {
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
}
/// 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
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,
});
}
Ok(brief)
}
fn social_note(pick: &Pick) -> String {
if pick.article.social.is_empty() {
return String::new();
}
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(", "))
}
// ---------------------------------------------------------------------------
// 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(", ")
);
}
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)
}
}
+34 -45
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 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());
}
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"
);
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());
}
}
Err(e) => {
tracing::warn!(batch = n + 1, of = batches, error = %e,
"stage A batch failed; its articles stay unscored");
}
}
}
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");
+428 -273
View File
@@ -1,13 +1,19 @@
//! Stage B — lineup selection (spec §3.6).
//! The editor — lineup selection (plan §13).
//!
//! One call: send the top ~40 candidates by [`ScoredArticle::combined_score`] with
//! their rationales; the model returns the final 1525 picks, each with a section
//! from the configured palette, an ordering, and exactly one `lead_story`.
//! One call on the editor client (Claude), falling back to the same prompt on the
//! bulk client (DeepSeek), then to [`select_without_llm`]. The shortlist is the
//! top candidates by [`ScoredArticle::combined_score`] with their Stage A
//! rationales; the model returns picks, each with a section from the configured
//! palette, an ordering, exactly one `lead_story`, and a one-line `why` that is
//! printed under the headline.
//!
//! The model's answer is treated as a proposal, never as gospel: sections are
//! validated against the palette, the lead is forced to be unique, auto-include
//! feeds are re-inserted if they were dropped, and the size is clamped to
//! `target_article_count ± 5`.
//! feeds are re-inserted if they were dropped, duplicate ids are dropped, and the
//! size is trimmed to `hard_max`. There is **no minimum**: a nine-pick answer is
//! published as nine (the "top up" branch is gone). `--max-articles N` is a
//! ceiling: `hard_max = min(curation.max_article_count, N)` and
//! `soft_target = min(target_article_count, hard_max)`.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write as _;
@@ -15,19 +21,16 @@ use std::fmt::Write as _;
use jiff::civil::Date;
use serde::{Deserialize, Serialize};
use super::llm::{LlmClient, LlmError, strip_code_fence};
use super::llm::{LlmError, Llms, strip_code_fence};
use super::{prompt_text, truncate_words};
use crate::types::{ArticleId, Lineup, Pick, ScoredArticle, SourceKind, WORLD_BRIEFING_SECTION};
use crate::types::{ArticleId, Lineup, Pick, ScoredArticle, WORLD_BRIEFING_SECTION};
/// How many candidates are offered to stage B (§3.6).
/// How many candidates are offered to the editor (§13; step 5 raises this to the diversified shortlist).
pub const SHORTLIST_SIZE: usize = 40;
/// How far the final count may drift from `target_article_count` (§3.6: 1525
/// around a default target of 20).
pub const TARGET_TOLERANCE: usize = 5;
/// Words of lead-in text shown per candidate in the stage-B prompt.
const BLURB_WORDS: usize = 45;
/// Words of lead-in text shown per candidate in the editor prompt (§13).
const BLURB_WORDS: usize = 60;
/// One element of the stage-B JSON response (§3.6).
/// One element of the editor's JSON response (§13).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SelectionItem {
pub id: ArticleId,
@@ -36,6 +39,8 @@ pub struct SelectionItem {
pub position: i64,
#[serde(default)]
pub lead_story: bool,
#[serde(default)]
pub why: Option<String>,
}
/// Envelope the model is asked to return.
@@ -45,49 +50,59 @@ pub struct SelectionResponse {
pub picks: Vec<SelectionItem>,
}
/// The invariant instruction block for stage B (§3.6).
pub const SELECT_INSTRUCTIONS: &str = "\
TASK: assemble today's issue of The Daily EPUB from the shortlist below.
/// The invariant instruction block for the editor (§13), with `{soft_target}` and
/// `{hard_max}` substituted at render time.
pub const EDITOR_INSTRUCTIONS: &str = r#"TASK: assemble today's issue of The Daily EPUB from the shortlist below.
You are choosing what one specific reader — the profile in your system prompt — \
will actually read on an e-ink screen over breakfast. Build a paper, not a \
ranking: it should have a shape, a range of subjects, and a clear front page.
You are choosing what one specific reader — the profile, learned adjustments and
recent verdicts in your system prompt — will read on an e-ink screen over breakfast.
Build a paper, not a ranking: it should have a shape, a range of subjects, and a
clear front page.
RULES
1. Pick articles by id from the shortlist only. Never invent an id.
2. Give every pick a section from the palette below, spelled exactly as given.
3. Number picks within each section from 1 upward, best first.
4. Flag exactly one pick as \"lead_story\": true the day's strongest, most \
substantial piece. It must sit in the first section you use.
5. Any candidate marked \"always-include\" MUST appear; place it in \"From the \
Blogroll\" unless it clearly belongs elsewhere.
6. Do not select two articles that tell the same story; keep the better one.
1. Pick by id from the shortlist only.
2. Every pick gets a section from the palette, spelled exactly.
3. Number picks within a section from 1, best first.
4. Exactly one pick is "lead_story": true, in the first section you use.
5. Candidates flagged always-include MUST appear.
6. Never select two articles that tell the same story.
7. SIZE: aim for about {soft_target}; never more than {hard_max}; there is NO minimum.
If only nine pieces deserve the reader's morning, publish nine. Never pad.
8. For every pick write "why": at most 14 words, specific to this article and this
reader, in the second person is fine ("the Postgres failover story you'd argue with").
It is printed under the headline.
EDITORIAL JUDGEMENT
- Favour depth over coverage: a slim issue of excellent pieces beats a full one \
padded with filler. Drop anything you would not defend.
- Mix the day up. Several long technical dives in a row is a bad breakfast; \
alternate register and subject across sections.
- Keep the local and ultra-niche picks — a Boston story and a small-scene story \
are worth more here than a third AI-industry item.
- Score is evidence, not an instruction: overrule it when the paper reads better \
for it, and say so through your placement.
- Leave a section out entirely rather than padding it; empty sections are dropped.
- Depth over coverage. Drop anything you would not defend to him in person.
- Diversity is a feature: do not let one subject, one format, or one feed dominate,
even if it is what he has been loving lately. A paper of eight AI posts is a failure
even if each is good. The "recent verdicts" tell you his taste; they do not tell you
to repeat it.
- Keep the local and ultra-niche picks when they are good; they are worth more here
than a third industry item.
- Candidates flagged exploration were included on purpose to test the edges of his
taste; take one if it is genuinely good, ignore it otherwise.
- Scores are evidence, not instructions. Overrule them when the paper reads better.
Return JSON exactly in this shape and nothing else:
{\"picks\": [{\"id\": 123, \"section\": \"Top Stories\", \"position\": 1, \
\"lead_story\": true}]}";
Return JSON exactly:
{"picks": [{"id": 123, "section": "Top Stories", "position": 1, "lead_story": true, "why": "…"}]}"#;
/// Render the stage-B user prompt (§3.6).
pub fn build_prompt(shortlist: &[ScoredArticle], sections: &[String], target: usize) -> String {
let (min, max) = size_bounds(target);
/// Render the editor's user prompt (§13).
pub fn build_prompt(
shortlist: &[ScoredArticle],
sections: &[String],
soft_target: usize,
hard_max: usize,
) -> String {
let instructions = EDITOR_INSTRUCTIONS
.replace("{soft_target}", &soft_target.to_string())
.replace("{hard_max}", &hard_max.to_string());
let mut prompt = String::with_capacity(2048 + shortlist.len() * 400);
prompt.push_str(SELECT_INSTRUCTIONS);
prompt.push_str(&instructions);
let _ = write!(
prompt,
"\n\nSECTION PALETTE (exact strings, use only these): {}\n\
Reserved and unavailable: \"{WORLD_BRIEFING_SECTION}\" is compiled separately.\n\n\
SIZE: choose {target} articles; never fewer than {min} and never more than {max}.\n\n\
SHORTLIST ({} candidates, best-ranked first)\n",
sections.join(" | "),
shortlist.len()
@@ -124,7 +139,7 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
Some(llm) => {
let _ = writeln!(
block,
"score: {:.1} ({}) — {}",
"score: {:.1} · {} — {}",
llm.score,
if llm.category.is_empty() {
"uncategorized"
@@ -135,24 +150,19 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
);
}
None => {
let _ = writeln!(
block,
"score: unscored (heuristic rank {:.0}/100)",
candidate.prefilter_score
);
let _ = writeln!(block, "score: unscored");
}
}
let _ = writeln!(
block,
"signals: social {:.2}; via {}{}",
candidate.social_score,
source_kinds(candidate),
let mut flags = Vec::new();
if candidate.auto_include {
"; ALWAYS-INCLUDE"
} else {
""
flags.push("always-include");
}
if a.excerpt_only {
flags.push("excerpt only");
}
if !flags.is_empty() {
let _ = writeln!(block, "flags: {}", flags.join(" | "));
}
);
let blurb = truncate_words(&prompt_text(&a.content_html), BLURB_WORDS);
if !blurb.is_empty() {
let _ = writeln!(block, "opening: {blurb}");
@@ -160,37 +170,6 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
block
}
fn source_kinds(candidate: &ScoredArticle) -> String {
let mut kinds: Vec<&str> = candidate
.article
.sources
.iter()
.map(|s| match s.kind {
SourceKind::Scour => "scour",
SourceKind::HnFrontpage => "hn_frontpage",
SourceKind::Lobsters => "lobsters",
SourceKind::Reddit => "reddit",
SourceKind::Feed => "feed",
})
.collect();
kinds.sort_unstable();
kinds.dedup();
if kinds.is_empty() {
"feed".into()
} else {
kinds.join("+")
}
}
/// `target ± TARGET_TOLERANCE`, floored at one article (§3.6).
pub fn size_bounds(target: usize) -> (usize, usize) {
let target = target.max(1);
(
target.saturating_sub(TARGET_TOLERANCE).max(1),
target + TARGET_TOLERANCE,
)
}
// ---------------------------------------------------------------------------
// Section validation (§3.6: the model may only use the configured palette)
// ---------------------------------------------------------------------------
@@ -244,7 +223,7 @@ fn words_of(s: &str) -> HashSet<String> {
.collect()
}
/// Section guess from feed metadata, used by `--skip-llm` and by top-ups (§3.6).
/// Section guess from feed metadata, used by [`select_without_llm`] (§3.6).
pub fn heuristic_section(candidate: &ScoredArticle, sections: &[String]) -> String {
if candidate.auto_include {
return resolve_section("From the Blogroll", sections);
@@ -346,7 +325,7 @@ pub fn heuristic_section(candidate: &ScoredArticle, sections: &[String]) -> Stri
/// Keys the model might wrap the array in.
const ARRAY_KEYS: &[&str] = &["picks", "lineup", "articles", "selection", "items"];
/// Lenient parse of the stage-B response (§3.6).
/// Lenient parse of the editor response (§13). `why` is capped at 14 words.
pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
let cleaned = strip_code_fence(raw);
let value: serde_json::Value = match serde_json::from_str(cleaned) {
@@ -405,6 +384,16 @@ pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
.or_else(|| v.as_str().map(|s| s.eq_ignore_ascii_case("true")))
})
.unwrap_or(false),
why: obj
.get("why")
.and_then(serde_json::Value::as_str)
.map(|why| {
why.split_whitespace()
.take(14)
.collect::<Vec<_>>()
.join(" ")
})
.filter(|why| !why.is_empty()),
});
}
out
@@ -414,96 +403,131 @@ pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
// Stage driver
// ---------------------------------------------------------------------------
/// Ask the model for the day's lineup, validating that every section is from the
/// configured palette and exactly one pick is the lead (§3.6).
/// Ask the editor for the day's lineup (§13).
///
/// Editor first, then the same prompt on the bulk client, then
/// [`select_without_llm`]; never an error unless a mock is misconfigured.
pub async fn select(
llm: &LlmClient,
llms: &Llms,
candidates: Vec<ScoredArticle>,
sections: &[String],
target: usize,
soft_target: usize,
hard_max: usize,
date: Date,
) -> Result<Lineup, LlmError> {
if candidates.is_empty() {
tracing::warn!("stage B had no candidates");
return Ok(Lineup {
date,
picks: Vec::new(),
section_order: Vec::new(),
});
}
if let Err(e) = llm.meter.check_budget() {
tracing::error!(error = %e,
"COST CEILING HIT before stage B selection — falling back to heuristic ranking");
return Ok(select_without_llm(candidates, sections, target, date));
}
let shortlist = shortlist(&candidates, target);
let prompt = build_prompt(&shortlist, sections, target);
let Some(primary) = llms.editor_or_bulk() else {
return Ok(select_without_llm(
candidates,
sections,
soft_target,
hard_max,
date,
));
};
let shortlist = shortlist(&candidates, hard_max);
let prompt = build_prompt(&shortlist, sections, soft_target, hard_max);
tracing::debug!(
shortlist = shortlist.len(),
approx_tokens = super::approx_tokens(&prompt),
"stage B request"
"editor request"
);
let raw = llm.complete(&prompt, llm_temperature(), true).await?;
let raw = match complete_with_fallback(llms, primary, &prompt).await {
Ok(raw) => raw,
Err(error) => {
tracing::error!(%error, "editor and bulk fallback both failed; selecting heuristically");
return Ok(select_without_llm(
candidates,
sections,
soft_target,
hard_max,
date,
));
}
};
let items = parse_selection_response(&raw);
if items.is_empty() {
tracing::error!("stage B returned no usable picks; falling back to heuristic ranking");
return Ok(select_without_llm(candidates, sections, target, date));
tracing::error!("editor returned no usable picks; falling back to heuristic ranking");
return Ok(select_without_llm(
candidates,
sections,
soft_target,
hard_max,
date,
));
}
let by_id: HashMap<ArticleId, &ScoredArticle> =
candidates.iter().map(|c| (c.article.id, c)).collect();
let mut chosen: Vec<(SelectionItem, ScoredArticle)> = Vec::with_capacity(items.len());
let mut seen: HashSet<ArticleId> = HashSet::new();
let mut chosen = Vec::with_capacity(items.len());
let mut seen = HashSet::new();
for item in items {
if !seen.insert(item.id) {
tracing::warn!(id = item.id, "stage B picked the same article twice");
tracing::warn!(id = item.id, "editor picked the same article twice");
continue;
}
match by_id.get(&item.id) {
Some(candidate) => chosen.push((item, (*candidate).clone())),
None => tracing::warn!(id = item.id, "stage B invented an id that was not offered"),
None => tracing::warn!(id = item.id, "editor invented an id that was not offered"),
}
}
// Auto-include feeds can never be dropped (§3.5).
for candidate in &candidates {
if candidate.auto_include && seen.insert(candidate.article.id) {
tracing::info!(
id = candidate.article.id,
title = %candidate.article.title,
"re-inserting an always-include article the model dropped"
);
chosen.push((
SelectionItem {
id: candidate.article.id,
section: "From the Blogroll".into(),
position: i64::MAX,
lead_story: false,
why: Some("A standing source you always want represented".into()),
},
candidate.clone(),
));
}
}
Ok(assemble(chosen, sections, hard_max, date))
}
let lineup = assemble(chosen, &candidates, sections, target, date);
tracing::info!(
picks = lineup.picks.len(),
sections = lineup.section_order.len(),
lead = lineup.lead().map(|p| p.article.id),
"stage B lineup ready"
/// The editor runs at the scoring temperature: this is a judgement call, not
/// prose. The Anthropic backend ignores it (§4.2).
const EDITOR_TEMPERATURE: f32 = 0.4;
/// One attempt on `primary`; on any error (refusal, budget, API) the same prompt
/// goes to the bulk client when that is a different provider (§13, §17).
async fn complete_with_fallback(
llms: &Llms,
primary: &super::llm::LlmClient,
prompt: &str,
) -> Result<String, LlmError> {
match primary.complete(prompt, EDITOR_TEMPERATURE, true).await {
Ok(raw) => Ok(raw),
Err(primary_error) => {
let fallback = llms
.bulk
.as_ref()
.filter(|bulk| primary.provider != bulk.provider);
let Some(fallback) = fallback else {
return Err(primary_error);
};
tracing::warn!(
error = %primary_error,
provider = primary.provider,
"editor failed; retrying the same prompt on bulk"
);
Ok(lineup)
fallback.complete(prompt, EDITOR_TEMPERATURE, true).await
}
}
}
/// Stage B runs at the scoring temperature: this is a judgement call, not prose.
fn llm_temperature() -> f32 {
0.4
}
/// Top [`SHORTLIST_SIZE`] candidates by combined score, always including the
/// auto-includes (§3.6).
/// Top [`SHORTLIST_SIZE`] (or `2 × hard_max`) candidates by combined score,
/// always including the auto-includes.
fn shortlist(candidates: &[ScoredArticle], target: usize) -> Vec<ScoredArticle> {
let mut ranked: Vec<ScoredArticle> = candidates.to_vec();
sort_by_combined(&mut ranked);
@@ -526,19 +550,16 @@ fn sort_by_combined(candidates: &mut [ScoredArticle]) {
});
}
/// Turn validated picks into a [`Lineup`]: clamp the size, force a single lead,
/// order the sections and renumber positions (§3.6).
/// Turn validated picks into a [`Lineup`]: trim to `hard_max`, force a single
/// lead, order the sections and renumber positions (§13). No minimum size.
fn assemble(
mut chosen: Vec<(SelectionItem, ScoredArticle)>,
all: &[ScoredArticle],
sections: &[String],
target: usize,
hard_max: usize,
date: Date,
) -> Lineup {
let (min, max) = size_bounds(target);
// Too many: drop the weakest non-auto-include picks.
if chosen.len() > max {
if chosen.len() > hard_max {
chosen.sort_by(|a, b| {
b.1.auto_include.cmp(&a.1.auto_include).then_with(|| {
b.1.combined_score()
@@ -546,35 +567,9 @@ fn assemble(
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let dropped = chosen.len() - max;
chosen.truncate(max);
tracing::info!(dropped, max, "trimmed the lineup to the size ceiling");
}
// Too few: top up from the best unpicked candidates.
if chosen.len() < min {
let taken: HashSet<ArticleId> = chosen.iter().map(|(i, _)| i.id).collect();
let mut rest: Vec<ScoredArticle> = all
.iter()
.filter(|c| !taken.contains(&c.article.id))
.cloned()
.collect();
sort_by_combined(&mut rest);
let wanted = min - chosen.len();
let added = rest.len().min(wanted);
for candidate in rest.into_iter().take(wanted) {
let section = heuristic_section(&candidate, sections);
chosen.push((
SelectionItem {
id: candidate.article.id,
section,
position: i64::MAX,
lead_story: false,
},
candidate,
));
}
tracing::info!(added, min, "topped the lineup up to the size floor");
let dropped = chosen.len() - hard_max;
chosen.truncate(hard_max);
tracing::info!(dropped, hard_max, "trimmed the lineup to the size ceiling");
}
// Normalize sections and pick the section order.
@@ -632,6 +627,7 @@ fn assemble(
section: item.section,
position: *position,
is_lead: Some(item.id) == lead_id,
why: item.why,
summary: None,
llm: candidate.llm.clone(),
discussion: None,
@@ -647,52 +643,46 @@ fn assemble(
}
}
/// `--skip-llm` fallback: take the top `target` by prefilter score and bucket them
/// into sections by feed category (notes §6).
/// Heuristic fallback (`--skip-llm`, no provider, or both providers failed): the
/// top `soft_target` by prefilter score plus the auto-includes, bucketed into
/// sections by feed category, trimmed to `hard_max` (notes §6).
pub fn select_without_llm(
candidates: Vec<ScoredArticle>,
sections: &[String],
target: usize,
soft_target: usize,
hard_max: usize,
date: Date,
) -> Lineup {
let mut ranked = candidates.clone();
let mut ranked = candidates;
super::prefilter::sort_by_prefilter(&mut ranked);
let mut chosen: Vec<(SelectionItem, ScoredArticle)> = Vec::new();
let mut seen: HashSet<ArticleId> = HashSet::new();
for candidate in ranked.into_iter() {
let auto = candidate.auto_include;
if chosen.len() >= target && !auto {
let mut chosen = Vec::new();
let mut seen = HashSet::new();
for candidate in ranked {
if chosen.len() >= soft_target && !candidate.auto_include {
continue;
}
if !seen.insert(candidate.article.id) {
continue;
}
let section = heuristic_section(&candidate, sections);
chosen.push((
SelectionItem {
id: candidate.article.id,
section,
section: heuristic_section(&candidate, sections),
position: chosen.len() as i64 + 1,
lead_story: false,
why: None,
},
candidate,
));
}
let lineup = assemble(chosen, &candidates, sections, target, date);
tracing::info!(
picks = lineup.picks.len(),
sections = lineup.section_order.len(),
"skip-llm lineup ready"
);
lineup
assemble(chosen, sections, hard_max, date)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CurationConfig, DeepseekConfig};
use crate::curate::llm::{MockBackend, UsageMeter};
use crate::config::{AnthropicConfig, CurationConfig, DeepseekConfig};
use crate::curate::llm::{ChatBackend, LlmClient, MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article;
use crate::types::{LlmScore, TokenUsage};
use std::sync::Arc;
@@ -738,6 +728,49 @@ mod tests {
.collect()
}
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(),
None,
UsageMeter::with_prices(prices, limit),
backend as Arc<dyn ChatBackend>,
)
}
/// DeepSeek only — the shape of a run without an Anthropic key.
fn bulk_only(backend: Arc<MockBackend>) -> Llms {
Llms {
bulk: Some(mock("deepseek", backend, 2.0)),
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 picks_json(n: i64) -> String {
let picks: Vec<String> = (1..=n)
.map(|i| {
format!(
r#"{{"id":{i},"section":"Top Stories","position":{i},"lead_story":{},"why":"pick {i} because"}}"#,
i == 1
)
})
.collect();
format!(r#"{{"picks":[{}]}}"#, picks.join(","))
}
#[test]
fn section_resolution_maps_onto_the_palette() {
let s = sections();
@@ -786,33 +819,64 @@ mod tests {
assert!(items[0].lead_story);
assert_eq!(items[0].section, "Top Stories");
assert_eq!(items.iter().filter(|i| i.lead_story).count(), 1);
assert!(items[0].why.as_deref().is_some_and(|w| !w.is_empty()));
// Junk entries in the fixture are dropped, not fatal.
assert!(items.iter().all(|i| i.id != 0));
}
#[test]
fn size_bounds_follow_the_spec() {
assert_eq!(size_bounds(20), (15, 25));
assert_eq!(size_bounds(6), (1, 11));
assert_eq!(size_bounds(0), (1, 6));
fn why_lines_are_optional_and_capped_at_fourteen_words() {
let long = (1..=30)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" ");
let items = parse_selection_response(&format!(
r#"{{"picks":[{{"id":1,"section":"Top Stories","why":"{long}"}},
{{"id":2,"section":"Top Stories","why":" "}},
{{"id":3,"section":"Top Stories"}}]}}"#
));
assert_eq!(items.len(), 3);
assert_eq!(
items[0]
.why
.as_deref()
.map(|w| w.split_whitespace().count()),
Some(14)
);
assert!(items[1].why.is_none());
assert!(items[2].why.is_none());
}
#[test]
fn the_prompt_substitutes_the_size_targets() {
let prompt = build_prompt(&candidates(3), &sections(), 6, 11);
assert!(prompt.contains("aim for about 6; never more than 11; there is NO minimum"));
assert!(!prompt.contains("{soft_target}") && !prompt.contains("{hard_max}"));
assert!(prompt.contains("--- id: 1\n"));
assert!(prompt.contains("score: 9.9 · Tech & Engineering — solid"));
assert!(prompt.contains("opening: "));
assert!(
!prompt.contains("combined"),
"the numeric blend stays out of the prompt"
);
let mut flagged = candidates(1);
flagged[0].auto_include = true;
flagged[0].article.excerpt_only = true;
let prompt = build_prompt(&flagged, &sections(), 6, 11);
assert!(prompt.contains("flags: always-include | excerpt only"));
}
#[tokio::test]
async fn selection_builds_a_valid_lineup() {
let backend = Arc::new(MockBackend::new());
backend.push(LINEUP_FIXTURE, TokenUsage::default());
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend.clone(),
);
let llms = bulk_only(Arc::clone(&backend));
// ids 101..=112 so the fixture's picks resolve.
let pool: Vec<ScoredArticle> = (101..=112)
.map(|i| candidate(i, &format!("Article {i}"), 800, 7.0))
.collect();
let lineup = select(&llm, pool, &sections(), 6, date())
let lineup = select(&llms, pool, &sections(), 6, 11, date())
.await
.expect("selection");
@@ -845,9 +909,32 @@ mod tests {
);
// The prompt carried the shortlist and the palette.
let prompt = &backend.prompts()[0].user;
assert!(prompt.starts_with(SELECT_INSTRUCTIONS));
assert!(prompt.starts_with("TASK: assemble today's issue of The Daily EPUB"));
assert!(prompt.contains("--- id: 101"));
assert!(prompt.contains("never fewer than 1 and never more than 11"));
assert!(prompt.contains("aim for about 6; never more than 11"));
}
#[tokio::test]
async fn why_lines_land_on_picks() {
let backend = Arc::new(MockBackend::new());
backend.push(picks_json(3), TokenUsage::default());
let lineup = select(
&bulk_only(backend),
candidates(5),
&sections(),
3,
5,
date(),
)
.await
.expect("selection");
assert_eq!(lineup.picks.len(), 3);
for pick in &lineup.picks {
assert_eq!(
pick.why.as_deref(),
Some(format!("pick {} because", pick.article.id).as_str())
);
}
}
#[tokio::test]
@@ -856,19 +943,22 @@ mod tests {
backend.push(
r#"{"picks":[{"id":9999,"section":"Top Stories","position":1,"lead_story":true},
{"id":1,"section":"Sportsball","position":2},
{"id":2,"section":"Niche Corner","position":1}]}"#,
{"id":2,"section":"Niche Corner","position":1},
{"id":2,"section":"Niche Corner","position":2}]}"#,
TokenUsage::default(),
);
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend,
);
let lineup = select(&llm, candidates(6), &sections(), 2, date())
let lineup = select(
&bulk_only(backend),
candidates(6),
&sections(),
2,
7,
date(),
)
.await
.expect("selection");
assert!(lineup.picks.iter().all(|p| p.article.id != 9999));
assert_eq!(lineup.picks.len(), 2, "the duplicate id was dropped");
assert_eq!(lineup.picks.iter().filter(|p| p.is_lead).count(), 1);
for pick in &lineup.picks {
assert!(sections().contains(&pick.section));
@@ -876,93 +966,148 @@ mod tests {
}
#[tokio::test]
async fn oversized_and_undersized_answers_are_clamped() {
// Undersized: the model returns one pick but the floor is 5.
async fn a_nine_pick_answer_is_published_as_nine() {
// Soft target 20, ceiling 28, thirty candidates: the model picks nine.
let backend = Arc::new(MockBackend::new());
backend.push(
r#"{"picks":[{"id":1,"section":"Top Stories","position":1,"lead_story":true}]}"#,
TokenUsage::default(),
);
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend,
);
let lineup = select(&llm, candidates(30), &sections(), 10, date())
backend.push(picks_json(9), TokenUsage::default());
let lineup = select(
&bulk_only(backend),
candidates(30),
&sections(),
20,
28,
date(),
)
.await
.expect("selection");
assert!(lineup.picks.len() >= 5, "{}", lineup.picks.len());
assert_eq!(lineup.picks.len(), 9, "no top-up, no padding");
}
// Oversized: 30 picks against a target of 6 (ceiling 11).
let picks: Vec<String> = (1..=30)
.map(|i| format!(r#"{{"id":{i},"section":"Top Stories","position":{i}}}"#))
.collect();
#[tokio::test]
async fn hard_max_trims_oversized_answers_by_ranking() {
let backend = Arc::new(MockBackend::new());
backend.push(
format!(r#"{{"picks":[{}]}}"#, picks.join(",")),
TokenUsage::default(),
);
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend,
);
let lineup = select(&llm, candidates(30), &sections(), 6, date())
backend.push(picks_json(30), TokenUsage::default());
let lineup = select(
&bulk_only(backend),
candidates(30),
&sections(),
6,
11,
date(),
)
.await
.expect("selection");
assert_eq!(lineup.picks.len(), 11);
// The strongest by today's ranking key survive: ids 1..=11 score highest.
let mut ids: Vec<ArticleId> = lineup.picks.iter().map(|p| p.article.id).collect();
ids.sort_unstable();
assert_eq!(ids, (1..=11).collect::<Vec<_>>());
}
#[tokio::test]
async fn always_include_articles_are_reinserted() {
async fn always_include_articles_are_reinserted_and_survive_the_trim() {
let backend = Arc::new(MockBackend::new());
backend.push(
r#"{"picks":[{"id":1,"section":"Top Stories","position":1,"lead_story":true}]}"#,
TokenUsage::default(),
);
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend,
);
let mut pool = candidates(3);
pool[2].auto_include = true;
let lineup = select(&llm, pool, &sections(), 1, date())
backend.push(picks_json(4), TokenUsage::default());
let mut pool = candidates(30);
pool[29].auto_include = true; // id 30, the weakest by score
let lineup = select(&bulk_only(backend), pool, &sections(), 2, 4, date())
.await
.expect("selection");
let ids: Vec<ArticleId> = lineup.picks.iter().map(|p| p.article.id).collect();
assert!(ids.contains(&3), "auto-include must survive: {ids:?}");
assert_eq!(
lineup
assert!(ids.contains(&30), "auto-include must survive: {ids:?}");
assert_eq!(lineup.picks.len(), 4, "the ceiling still holds");
let reinserted = lineup
.picks
.iter()
.find(|p| p.article.id == 3)
.map(|p| p.section.as_str()),
Some("From the Blogroll")
);
.find(|p| p.article.id == 30)
.expect("reinserted");
assert_eq!(reinserted.section, "From the Blogroll");
assert!(reinserted.why.is_some());
}
#[tokio::test]
async fn a_tripped_budget_falls_back_without_calling_the_model() {
async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() {
let editor = Arc::new(MockBackend::new());
editor.push_llm_error(LlmError::Refusal {
provider: "anthropic",
});
let bulk = Arc::new(MockBackend::new());
bulk.push(picks_json(5), TokenUsage::default());
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
let lineup = select(&llms, candidates(10), &sections(), 5, 10, date())
.await
.expect("selection");
assert_eq!(lineup.picks.len(), 5);
assert_eq!(editor.calls(), 1);
assert_eq!(bulk.calls(), 1);
assert_eq!(
editor.prompts()[0].user,
bulk.prompts()[0].user,
"the bulk client gets the identical prompt"
);
assert_eq!(editor.prompts()[0].system, bulk.prompts()[0].system);
}
#[tokio::test]
async fn an_error_on_both_providers_selects_heuristically() {
let editor = Arc::new(MockBackend::new());
editor.push_error("500 opus is down");
let bulk = Arc::new(MockBackend::new());
bulk.push_error("500 deepseek is down too");
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
let lineup = select(&llms, candidates(10), &sections(), 4, 10, date())
.await
.expect("heuristic fallback");
assert_eq!(lineup.picks.len(), 4);
assert_eq!(editor.calls(), 1);
assert_eq!(bulk.calls(), 1);
}
#[tokio::test]
async fn a_tripped_editor_budget_goes_straight_to_bulk() {
let editor = Arc::new(MockBackend::new());
let bulk = Arc::new(MockBackend::new());
bulk.push(picks_json(3), TokenUsage::default());
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
llms.editor
.as_ref()
.expect("editor")
.meter
.preload_cost(10.0);
let lineup = select(&llms, candidates(10), &sections(), 3, 10, date())
.await
.expect("selection");
assert_eq!(lineup.picks.len(), 3);
assert_eq!(editor.calls(), 0, "a tripped editor is never called");
assert_eq!(bulk.calls(), 1);
}
#[tokio::test]
async fn a_tripped_bulk_budget_falls_back_without_calling_the_model() {
let backend = Arc::new(MockBackend::new());
let meter = UsageMeter::new(&DeepseekConfig::default(), 0.001);
meter.record(TokenUsage {
input_tokens: 1_000_000,
let llms = bulk_only(Arc::clone(&backend));
llms.bulk.as_ref().expect("bulk").meter.record(TokenUsage {
input_tokens: 100_000_000,
cached_tokens: 0,
cache_write_tokens: 0,
output_tokens: 0,
});
let llm =
LlmClient::with_backend("deepseek-v4-flash", "SYSTEM".into(), meter, backend.clone());
let lineup = select(&llm, candidates(20), &sections(), 6, date())
let lineup = select(&llms, candidates(20), &sections(), 6, 28, date())
.await
.expect("fallback");
assert_eq!(backend.calls(), 0);
assert_eq!(lineup.picks.len(), 6);
}
#[tokio::test]
async fn no_provider_selects_heuristically() {
let lineup = select(&Llms::default(), candidates(20), &sections(), 6, 28, date())
.await
.expect("fallback");
assert_eq!(lineup.picks.len(), 6);
}
#[test]
fn skip_llm_lineup_uses_prefilter_order() {
let mut pool = candidates(10);
@@ -971,7 +1116,7 @@ mod tests {
pool[9].auto_include = true; // id 10 is a personal blog
pool[9].prefilter_score = 1.0;
let lineup = select_without_llm(pool, &sections(), 4, date());
let lineup = select_without_llm(pool, &sections(), 4, 28, date());
assert_eq!(lineup.picks.len(), 5, "4 picks + the auto-include");
assert_eq!(lineup.lead().map(|p| p.article.id), Some(8));
assert_eq!(lineup.picks.iter().filter(|p| p.is_lead).count(), 1);
@@ -984,13 +1129,23 @@ mod tests {
for pick in &lineup.picks {
assert!(sections().contains(&pick.section));
assert!(pick.summary.is_none());
assert!(pick.why.is_none());
}
assert!(!lineup.section_order.is_empty());
}
#[test]
fn heuristic_selection_respects_the_ceiling() {
let mut pool = candidates(10);
pool[9].auto_include = true;
let lineup = select_without_llm(pool, &sections(), 10, 4, date());
assert_eq!(lineup.picks.len(), 4);
assert!(lineup.picks.iter().any(|p| p.article.id == 10));
}
#[test]
fn empty_input_yields_an_empty_lineup() {
let lineup = select_without_llm(Vec::new(), &sections(), 20, date());
let lineup = select_without_llm(Vec::new(), &sections(), 20, 28, date());
assert!(lineup.picks.is_empty());
assert!(lineup.section_order.is_empty());
assert!(lineup.lead().is_none());
+130 -12
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)
/// 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?;
Ok(row.get::<f64, _>("total"))
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!(
"tokens: {} input · {} cached · {} output = ${:.4}",
"curation: {} scored · {} unscored · {} selected",
report.counts.llm_scored, report.counts.llm_unscored, report.counts.selected,
);
}
println!(
"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,
+202 -61
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 {
provider_costs,
models: Models {
bulk: if bulk_available {
config.deepseek.model.clone()
} else {
"none (--skip-llm)".into()
"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;
}
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;
tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made");
return Llms::default();
}
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)
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
}
Ok(None) => Some(client),
Err(e) => {
report.warn(format!("weekly profile rebuild failed: {e:#}"));
Some(client)
/// `--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
}
+30 -15
View File
@@ -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 67 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);
}
+3
View File
@@ -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
View File
@@ -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."
}
}
+5 -5
View File
@@ -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
View File
@@ -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 250400 word editor's
/// note plus per-section intros (§3.6).
/// The Brief must deserialize into 120200 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
View File
@@ -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"));