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 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 from a self-hosted [Miniflux](https://miniflux.app), deduplicates and extracts
the articles, enriches them with HackerNews/Lobsters/Reddit social proof, filters the articles, enriches them with HackerNews/Lobsters/Reddit social proof, filters
300500 candidates down to ~120 with cheap heuristics, and asks DeepSeek to score, 300500 candidates down to ~120 with cheap heuristics, asks DeepSeek to score them,
select and introduce 1525 of them. It assembles two EPUB editions (a standard one 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 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 publishes the lot over its own OPDS catalog — which doubles as a
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one. [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. 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 Steady-state cost is roughly **$1/day**: $0.050.30 in DeepSeek tokens plus
`max_daily_usd`. ~$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) - 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) - 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 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 ─▶ 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. 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 Social lookups, comment fetching, the world briefing, images and the XTC
conversion are best-effort: they log, add a warning (run status `degraded`) and 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 the run continues. Every LLM stage *degrades*: a Claude call that fails, is
a tripped budget turns the run into the `--skip-llm` shape (prefilter order refused, or is over its daily ceiling is retried with the same prompt on
selects, feed excerpts stand in for summaries) instead of losing the day's issue. 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` | | 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. | | **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` | | 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. | | **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. | | **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. | | `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). | | `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`. | | `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. | | `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. | | `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. | | `world_briefing` | `true` | Include the Wikipedia Current Events section. |
| `database_path` | `/var/lib/daily-epub/daily-epub.db` | SQLite file; parent dirs are created. | | `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. | | `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.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.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_batch_size` | `12` | Articles per stage-A scoring request. |
| `deepseek.score_temperature` | `0.3` | Scoring/selection temperature. | | `deepseek.max_concurrent_requests` | `4` | Stage-A batches in flight at once; the budget is checked before each is spawned. |
| `deepseek.editorial_temperature` | `0.8` | Summaries, intros, front page. | | `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_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_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. | | `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.always_include_feeds` | `[]` | Miniflux feed ids or URL substrings that can never be dropped. |
| `curation.blocked_domains` | `[]` | Hosts excluded outright. | | `curation.blocked_domains` | `[]` | Hosts excluded outright. |
| `curation.paywall_domains` | `[]` | Extra paywalled hosts, merged with the built-in list (nytimes, wsj, ft, economist, …). | | `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.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.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. | | `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.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. | | `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. | | `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 sudo tee /etc/daily-epub/env >/dev/null <<EOF
DAILY_EPUB_MINIFLUX__API_KEY=… DAILY_EPUB_MINIFLUX__API_KEY=…
DAILY_EPUB_DEEPSEEK__API_KEY=… DAILY_EPUB_DEEPSEEK__API_KEY=…
DAILY_EPUB_ANTHROPIC__API_KEY=…
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32) DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
EOF EOF
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env 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 # 3. Inspect the artifacts
ls -la ./out # two .epub files ls -la ./out # two .epub files
epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors 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 # 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 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 # 5. Full live run
sudo systemctl start daily-epub-generate 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 stack, so `curate/llm.rs` speaks the OpenAI-compatible wire protocol over the
shared `reqwest` client instead, behind a `ChatBackend` trait. The dependency shared `reqwest` client instead, behind a `ChatBackend` trait. The dependency
was removed. 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 - **No embedding-based personal ranker yet** (spec §3.9 future work); the schema
is ready for it once ~200 ratings exist. is ready for it once ~200 ratings exist.
- **One reader, one issue per day.** There is no multi-user support and no - **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. # Nested keys use a double underscore in env vars, e.g.
# DAILY_EPUB_MINIFLUX__API_KEY=... # DAILY_EPUB_MINIFLUX__API_KEY=...
# DAILY_EPUB_DEEPSEEK__API_KEY=... # DAILY_EPUB_DEEPSEEK__API_KEY=...
# DAILY_EPUB_ANTHROPIC__API_KEY=...
# DAILY_EPUB_SERVER__HMAC_SECRET=... # DAILY_EPUB_SERVER__HMAC_SECRET=...
# DAILY_EPUB_LOOKBACK_HOURS=30 # DAILY_EPUB_LOOKBACK_HOURS=30
@@ -13,7 +14,7 @@ target_article_count = 20
prefilter_keep = 120 prefilter_keep = 120
retention_days = 21 # EPUBs, by age retention_days = 21 # EPUBs, by age
xtc_retention_count = 5 # XTC issues, by count (~80-100 MB each) 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 world_briefing = true
# SQLite database file. Parent directories are created on demand. # 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) model = "deepseek-v4-flash" # DeepSeek-V4-Flash-0731 (confirmed 2026-08-15)
# api_key via DAILY_EPUB_DEEPSEEK__API_KEY env # api_key via DAILY_EPUB_DEEPSEEK__API_KEY env
score_batch_size = 12 score_batch_size = 12
max_concurrent_requests = 4 # stage-A batches in flight at once
score_temperature = 0.3 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. # USD per 1M tokens, used for the cost guardrail.
price_input_per_mtok = 0.14 price_input_per_mtok = 0.14
price_cached_input_per_mtok = 0.0028 price_cached_input_per_mtok = 0.0028
price_output_per_mtok = 0.28 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] [curation]
max_article_count = 28 # hard ceiling; there is no minimum (§13)
always_include_feeds = [] # miniflux feed ids or site urls always_include_feeds = [] # miniflux feed ids or site urls
blocked_domains = [] blocked_domains = []
# Extra paywalled hosts, merged with the built-in list (nytimes, wsj, ft, …). # 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 not_for_me_value = -1.0
verdicts_in_prompt = 60 verdicts_in_prompt = 60
[editorial]
summary_model = "editor" # editor (Claude) | bulk (DeepSeek)
summary_input_tokens = 3000 # article text offered per summary
[publish] [publish]
# Where both EPUB editions land, and what the OPDS feed lists. BookOrbit is # Where both EPUB editions land, and what the OPDS feed lists. BookOrbit is
# optional — it just watches this folder if you run it. # 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(), section: target.issue.clone(),
position: 0, position: 0,
is_lead: false, is_lead: false,
why: None,
summary: None, summary: None,
llm: None, llm: None,
discussion: 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 \ Articles are re-extracted live; editorial, discussions and the \
world briefing are absent by design.</p>" world briefing are absent by design.</p>"
.into(), .into(),
section_intros: Default::default(),
summaries: Default::default(), summaries: Default::default(),
}, },
world_briefing: None, world_briefing: None,
+139
View File
@@ -74,7 +74,9 @@ pub struct Config {
pub miniflux: MinifluxConfig, pub miniflux: MinifluxConfig,
pub deepseek: DeepseekConfig, pub deepseek: DeepseekConfig,
pub anthropic: AnthropicConfig,
pub curation: CurationConfig, pub curation: CurationConfig,
pub editorial: EditorialConfig,
pub publish: PublishConfig, pub publish: PublishConfig,
pub xtc: XtcConfig, pub xtc: XtcConfig,
pub server: ServerConfig, pub server: ServerConfig,
@@ -97,7 +99,9 @@ impl Default for Config {
profile_path: PathBuf::from("data/profile.md"), profile_path: PathBuf::from("data/profile.md"),
miniflux: MinifluxConfig::default(), miniflux: MinifluxConfig::default(),
deepseek: DeepseekConfig::default(), deepseek: DeepseekConfig::default(),
anthropic: AnthropicConfig::default(),
curation: CurationConfig::default(), curation: CurationConfig::default(),
editorial: EditorialConfig::default(),
publish: PublishConfig::default(), publish: PublishConfig::default(),
xtc: XtcConfig::default(), xtc: XtcConfig::default(),
server: ServerConfig::default(), server: ServerConfig::default(),
@@ -136,6 +140,7 @@ pub struct DeepseekConfig {
pub api_key: Option<String>, pub api_key: Option<String>,
/// Articles per stage-A scoring request (§3.6). /// Articles per stage-A scoring request (§3.6).
pub score_batch_size: usize, pub score_batch_size: usize,
pub max_concurrent_requests: usize,
pub score_temperature: f32, pub score_temperature: f32,
pub editorial_temperature: f32, pub editorial_temperature: f32,
/// USD per 1M cache-miss input tokens. /// USD per 1M cache-miss input tokens.
@@ -153,6 +158,7 @@ impl Default for DeepseekConfig {
model: "deepseek-v4-flash".into(), model: "deepseek-v4-flash".into(),
api_key: None, api_key: None,
score_batch_size: 12, score_batch_size: 12,
max_concurrent_requests: 4,
score_temperature: 0.3, score_temperature: 0.3,
editorial_temperature: 0.8, editorial_temperature: 0.8,
price_input_per_mtok: 0.14, 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). /// `[curation]` — pre-filter and section palette (§3.5, §3.6).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
pub struct CurationConfig { 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). /// Miniflux feed ids or site URLs that can never be dropped (§3.5).
pub always_include_feeds: Vec<String>, pub always_include_feeds: Vec<String>,
/// Hosts excluded outright (§3.5). /// Hosts excluded outright (§3.5).
@@ -181,6 +250,7 @@ pub struct CurationConfig {
impl Default for CurationConfig { impl Default for CurationConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_article_count: 28,
always_include_feeds: Vec::new(), always_include_feeds: Vec::new(),
blocked_domains: Vec::new(), blocked_domains: Vec::new(),
paywall_domains: Vec::new(), paywall_domains: Vec::new(),
@@ -376,6 +446,39 @@ impl Config {
"prefilter_keep must be >= target_article_count".into(), "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() { if self.curation.sections.is_empty() {
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
"curation.sections must not be empty".into(), "curation.sections must not be empty".into(),
@@ -498,6 +601,42 @@ mod tests {
assert_eq!(c.xtc.format, XtcFormat::Xtch); assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.server.bind, "127.0.0.1:3499"); 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.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] #[test]
+339 -323
View File
@@ -1,34 +1,19 @@
//! Stage C — summaries, section intros and the front page (spec §3.6). //! Claude-first summaries and The Brief, with per-call DeepSeek fallback (§14).
//!
//! 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.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt::Write as _; 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 super::{escape_html, prompt_text, text_to_paragraphs, truncate_tokens, truncate_words};
use crate::config::{EditorialConfig, SummaryModel};
use crate::types::{ArticleId, Editorial, Lineup, Pick}; 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 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 = "\ pub const SUMMARY_INSTRUCTIONS: &str = "\
TASK: write the newspaper abstract for one article in today's issue. 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>\"}"; Return JSON exactly: {\"summary\": \"<two or three sentences>\"}";
/// Front-page + section-intro instructions (§3.6 stage C). /// The Brief instructions (§14.2).
pub const FRONT_PAGE_INSTRUCTIONS: &str = "\ pub const BRIEF_INSTRUCTIONS: &str = r#"TASK: write "The Brief" for today's issue — the note at the top of the paper.
TASK: write the front page of today's issue of The Daily EPUB.
You are given the whole lineup: sections, headlines, sources and the abstract \ 120-200 words, one or two paragraphs. It must earn its place: if a reader skipped
written for each article. Everything you write must come from those abstracts — \ it, what would he miss? Name at least three of today's picks by title and say the
you have not read the articles themselves, and inventing a fact would be worse \ specific thing that makes each worth his time (the result, the argument, the scale,
than saying less. 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 \ Return JSON exactly: {"brief": "<the text, plain prose>"}"#;
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.
2. \"section_intros\" — for EACH section name given below, two or three \ #[derive(Debug, Clone, PartialEq, Deserialize)]
sentences (3560 words) introducing what is in it today. Concrete, specific to \ pub struct BriefResponse {
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.
#[serde(default)] #[serde(default)]
pub section_intros: BTreeMap<String, String>, pub brief: String,
} }
/// The per-article summary call's JSON response.
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
struct SummaryResponse { struct SummaryResponse {
#[serde(default)] #[serde(default)]
summary: String, summary: String,
} }
// --------------------------------------------------------------------------- fn summary_prompt(title: &str, body_html: &str, input_tokens: usize) -> String {
// Per-article summaries let body = truncate_tokens(&prompt_text(body_html), input_tokens);
// ---------------------------------------------------------------------------
/// 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);
let mut prompt = String::with_capacity(body.len() + SUMMARY_INSTRUCTIONS.len() + 256); let mut prompt = String::with_capacity(body.len() + SUMMARY_INSTRUCTIONS.len() + 256);
prompt.push_str(SUMMARY_INSTRUCTIONS); prompt.push_str(SUMMARY_INSTRUCTIONS);
let _ = write!( let _ = write!(
@@ -129,184 +83,182 @@ pub async fn summarize_article(
"" ""
}, },
if body.is_empty() { if body.is_empty() {
"(no body text was extracted; summarize from the headline alone and say the \ "(no body text was extracted; summarize from the headline alone and say the full text was unavailable)"
full text was unavailable)"
} else { } else {
&body &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 response: SummaryResponse = llm.complete_json(&prompt, temperature).await?;
let summary = response.summary.trim().to_string(); let summary = response.summary.trim().to_string();
if summary.is_empty() { if summary.is_empty() {
return Err(LlmError::EmptyResponse); return Err(LlmError::EmptyResponse {
provider: llm.provider,
});
} }
Ok(summary) Ok(summary)
} }
/// Summarize every pick, returning `article_id → summary` (§3.6). /// `(primary, fallback)` for the summaries per `editorial.summary_model` (§14.1).
/// fn summary_clients(llms: &Llms, model: SummaryModel) -> (Option<&LlmClient>, Option<&LlmClient>) {
/// Stops early and returns what it has when the cost guardrail trips (§3.6). 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(
primary,
&pick.article.title,
&pick.article.content_html,
config.summary_input_tokens,
temperature,
)
.await
{
Ok(summary) => Some(summary),
Err(error) => {
let Some(fallback) = fallback else {
tracing::warn!(article_id = pick.article.id, %error, "summary failed; using excerpt");
return None;
};
tracing::warn!(article_id = pick.article.id, %error, "editor summary failed; retrying on bulk");
summarize_article(
fallback,
&pick.article.title,
&pick.article.content_html,
config.summary_input_tokens,
temperature,
)
.await
.map_err(|fallback_error| {
tracing::warn!(article_id = pick.article.id, %fallback_error, "bulk summary failed; using excerpt");
})
.ok()
}
}
}
pub async fn summarize_all( pub async fn summarize_all(
llm: &LlmClient, llms: &Llms,
lineup: &Lineup, lineup: &Lineup,
config: &EditorialConfig,
temperature: f32, temperature: f32,
) -> BTreeMap<ArticleId, String> { ) -> BTreeMap<ArticleId, String> {
let mut out = BTreeMap::new(); let (primary, fallback) = summary_clients(llms, config.summary_model);
for (n, pick) in lineup.picks.iter().enumerate() { stream::iter(lineup.picks.iter())
if llm.meter.budget_exceeded() { .map(|pick| async move {
tracing::error!( let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
summarized = out.len(), (pick.article.id, summary)
remaining = lineup.picks.len() - n, })
spent_usd = llm.meter.cost_usd(), .buffer_unordered(SUMMARY_CONCURRENCY)
"COST CEILING HIT during stage C — the remaining articles fall back to \ .filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
feed excerpts as summaries" .collect()
);
break;
}
match summarize_article(
llm,
&pick.article.title,
&pick.article.content_html,
temperature,
)
.await .await
{
Ok(summary) => {
out.insert(pick.article.id, summary);
}
Err(LlmError::BudgetExceeded { spent, limit }) => {
tracing::error!(spent, limit, "COST CEILING HIT during stage C");
break;
}
Err(e) => {
tracing::warn!(
article_id = pick.article.id,
title = %pick.article.title,
error = %e,
"summary failed; falling back to the article's own opening"
);
}
}
}
tracing::info!(
summarized = out.len(),
picks = lineup.picks.len(),
"stage C summaries complete"
);
out
} }
// --------------------------------------------------------------------------- pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap<ArticleId, String>) -> String {
// Front page
// ---------------------------------------------------------------------------
/// The single front-page + section-intro call (§3.6).
pub async fn front_page(
llm: &LlmClient,
lineup: &Lineup,
summaries: &BTreeMap<ArticleId, String>,
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)
}
/// 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 {
let mut prompt = String::with_capacity(4096); let mut prompt = String::with_capacity(4096);
prompt.push_str(FRONT_PAGE_INSTRUCTIONS); prompt.push_str(BRIEF_INSTRUCTIONS);
let minutes: i64 = lineup
.picks
.iter()
.map(|p| p.article.reading_minutes())
.sum();
let _ = write!( let _ = write!(
prompt, prompt,
"\n\nISSUE: {} · {} articles across {} sections · about {} minutes of reading\n\ "\n\nISSUE: {} · {} articles\n\nLINEUP\n",
SECTIONS, in order: {}\n\nLINEUP\n",
lineup.date, lineup.date,
lineup.picks.len(), lineup.picks.len()
lineup.section_order.len(),
minutes,
lineup.section_order.join(" | ")
); );
for section in &lineup.section_order { for section in &lineup.section_order {
let _ = write!(prompt, "\n## {section}\n"); let _ = writeln!(prompt, "\n## {section}");
for pick in lineup.section_picks(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 prompt
} }
fn render_pick(pick: &Pick, summaries: &BTreeMap<ArticleId, String>) -> String { pub async fn brief(
let a = &pick.article; llms: &Llms,
let mut block = String::with_capacity(400); lineup: &Lineup,
let _ = writeln!( summaries: &BTreeMap<ArticleId, String>,
block, temperature: f32,
"\n- {}{}", ) -> Result<String, LlmError> {
a.title.trim(), let prompt = build_brief_prompt(lineup, summaries);
if pick.is_lead { " [LEAD STORY]" } else { "" } let Some(primary) = llms.editor_or_bulk() else {
); return Err(LlmError::Api {
let _ = writeln!( provider: "editorial",
block, message: "no provider configured".into(),
" source: {} · {} words (~{} min){}", });
if a.feed_title.is_empty() { };
"unknown" let response = match primary
} else { .complete_json::<BriefResponse>(&prompt, temperature)
a.feed_title.trim() .await
}, {
a.word_count, Ok(response) => response,
a.reading_minutes(), Err(error) => {
social_note(pick) let Some(fallback) = llms
); .bulk
let abstract_text = summaries .as_ref()
.get(&a.id) .filter(|bulk| bulk.provider != primary.provider)
.cloned() else {
.unwrap_or_else(|| excerpt_summary(pick)); return Err(error);
let _ = writeln!(block, " abstract: {abstract_text}"); };
block tracing::warn!(%error, "brief failed on editor; retrying on bulk");
} fallback
.complete_json::<BriefResponse>(&prompt, temperature)
fn social_note(pick: &Pick) -> String { .await?
if pick.article.social.is_empty() { }
return String::new(); };
let brief = response.brief.trim().to_string();
if brief.is_empty() {
return Err(LlmError::EmptyResponse {
provider: primary.provider,
});
} }
let parts: Vec<String> = pick Ok(brief)
.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 { pub fn excerpt_summary(pick: &Pick) -> String {
let text = truncate_words( let text = truncate_words(
&prompt_text(&pick.article.content_html), &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 { pub fn fallback_front_page_html(lineup: &Lineup) -> String {
let minutes: i64 = lineup let minutes: i64 = lineup
.picks .picks
.iter() .iter()
.map(|p| p.article.reading_minutes()) .map(|pick| pick.article.reading_minutes())
.sum(); .sum();
let mut text = format!( let mut text = format!(
"Today's issue collects {} articles across {} sections — about {} minutes of \ "Today's issue collects {} articles across {} sections — about {} minutes of reading. Editorial notes are unavailable for this issue, so the lineup speaks for itself.",
reading. Editorial notes are unavailable for this issue, so the lineup speaks \
for itself.",
lineup.picks.len(), lineup.picks.len(),
lineup.section_order.len(), lineup.section_order.len(),
minutes minutes
@@ -346,29 +295,15 @@ pub fn fallback_front_page_html(lineup: &Lineup) -> String {
text, text,
"\n\nLeading today: “{}” ({}).", "\n\nLeading today: “{}” ({}).",
lead.article.title.trim(), lead.article.title.trim(),
if lead.article.feed_title.is_empty() { lead.article.feed_title.trim()
"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) 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 { pub fn fallback_editorial(lineup: &Lineup) -> Editorial {
Editorial { Editorial {
front_page_html: fallback_front_page_html(lineup), front_page_html: fallback_front_page_html(lineup),
section_intros: BTreeMap::new(),
summaries: lineup summaries: lineup
.picks .picks
.iter() .iter()
@@ -377,54 +312,34 @@ pub fn fallback_editorial(lineup: &Lineup) -> Editorial {
} }
} }
// --------------------------------------------------------------------------- pub async fn run(
// Stage driver llms: &Llms,
// --------------------------------------------------------------------------- lineup: &Lineup,
config: &EditorialConfig,
/// Stage C end to end: summaries, then one front-page call, with excerpts filling temperature: f32,
/// every gap (§3.6). ) -> Editorial {
pub async fn run(llm: &LlmClient, lineup: &Lineup, temperature: f32) -> Editorial {
if lineup.picks.is_empty() { if lineup.picks.is_empty() {
return fallback_editorial(lineup); return fallback_editorial(lineup);
} }
let mut summaries = summarize_all(llms, lineup, config, temperature).await;
let mut summaries = summarize_all(llm, lineup, temperature).await; for pick in &lineup.picks {
let missing: Vec<&Pick> = lineup summaries
.picks .entry(pick.article.id)
.iter() .or_insert_with(|| excerpt_summary(pick));
.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 front_page_html = match brief(llms, lineup, &summaries, temperature).await {
let (front_page_html, section_intros) = Ok(text) => text_to_paragraphs(&text),
match front_page(llm, lineup, &summaries, temperature).await { Err(error) => {
Ok(response) => ( tracing::warn!(%error, "brief failed; using fallback front page");
text_to_paragraphs(&response.from_the_editor), fallback_front_page_html(lineup)
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())
}
};
Editorial { Editorial {
front_page_html, front_page_html,
section_intros,
summaries, summaries,
} }
} }
/// Escape-and-wrap helper for callers rendering a summary straight into XHTML.
pub fn summary_to_html(summary: &str) -> String { pub fn summary_to_html(summary: &str) -> String {
format!("<p>{}</p>", escape_html(summary.trim())) format!("<p>{}</p>", escape_html(summary.trim()))
} }
@@ -432,15 +347,15 @@ pub fn summary_to_html(summary: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::DeepseekConfig; use crate::config::{AnthropicConfig, DeepseekConfig};
use crate::curate::llm::{MockBackend, UsageMeter}; use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article; use crate::curate::prefilter::tests::article;
use crate::types::TokenUsage; use crate::types::TokenUsage;
use std::sync::Arc; use std::sync::Arc;
const FRONT_PAGE_FIXTURE: &str = include_str!(concat!( const BRIEF_FIXTURE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"), 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 { fn pick(id: i64, title: &str, section: &str, is_lead: bool) -> Pick {
@@ -451,6 +366,7 @@ mod tests {
section: section.into(), section: section.into(),
position: 1, position: 1,
is_lead, is_lead,
why: Some(format!("the {title} piece you'd argue with")),
summary: None, summary: None,
llm: None, llm: None,
discussion: None, discussion: None,
@@ -468,15 +384,40 @@ mod tests {
} }
} }
fn client(backend: Arc<MockBackend>, limit: f64) -> LlmClient { fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
LlmClient::with_backend( let prices = if provider == "anthropic" {
"deepseek-v4-flash", PriceTable::anthropic(&AnthropicConfig::default())
} else {
PriceTable::deepseek(&DeepseekConfig::default())
};
LlmClient::with_backend_options(
provider,
"model",
"SYSTEM".into(), "SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), limit), None,
backend, 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] #[tokio::test]
async fn summary_prompt_carries_headline_and_truncated_body() { async fn summary_prompt_carries_headline_and_truncated_body() {
let backend = Arc::new(MockBackend::new()); 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."}"#, r#"{"summary": "A team moves 40TB of relational data off Postgres and documents every rollback."}"#,
TokenUsage::default(), 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 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 .await
.expect("summary"); .expect("summary");
assert!(summary.starts_with("A team moves 40TB")); assert!(summary.starts_with("A team moves 40TB"));
@@ -495,60 +436,133 @@ mod tests {
assert!(prompt.starts_with(SUMMARY_INSTRUCTIONS)); assert!(prompt.starts_with(SUMMARY_INSTRUCTIONS));
assert!(prompt.contains("HEADLINE: Migrating 40TB")); assert!(prompt.contains("HEADLINE: Migrating 40TB"));
assert!(prompt.contains("(truncated for length)")); assert!(prompt.contains("(truncated for length)"));
// ~5k tokens ≈ 20k characters of body, not the full 100k. // 3k tokens ≈ 12k characters of body, not the full 100k.
assert!(prompt.len() < 26_000, "prompt was {} bytes", prompt.len()); assert!(prompt.len() < 16_000, "prompt was {} bytes", prompt.len());
} }
#[tokio::test] #[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()); let backend = Arc::new(MockBackend::new());
backend.push(FRONT_PAGE_FIXTURE, TokenUsage::default()); backend.push(BRIEF_FIXTURE, TokenUsage::default());
let llm = client(Arc::clone(&backend), 2.0); let llms = bulk_only(Arc::clone(&backend), 2.0);
let lineup = lineup(); let lineup = lineup();
let summaries = BTreeMap::from([ let summaries = BTreeMap::from([
(1, "A migration story with numbers.".to_string()), (1, "A migration story with numbers.".to_string()),
(2, "Transit data, charted.".to_string()), (2, "Transit data, charted.".to_string()),
]); ]);
let response = front_page(&llm, &lineup, &summaries, 0.8) let text = brief(&llms, &lineup, &summaries, 0.8).await.expect("brief");
.await assert!(text.split_whitespace().count() > 100);
.expect("front page"); assert!(text.contains("Migrating 40TB off Postgres"));
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 prompt = &backend.prompts()[0].user; 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("## Top Stories"));
assert!(prompt.contains("[LEAD STORY]")); assert!(prompt.contains("## Boston & Local"));
assert!(prompt.contains("abstract: A migration story with numbers.")); 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("2026-08-15"));
assert!(
!prompt.contains("section_intros"),
"section intros are gone"
);
} }
#[tokio::test] #[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()); let backend = Arc::new(MockBackend::new());
backend.push(r#"{"summary": "First abstract."}"#, TokenUsage::default()); backend.push(r#"{"summary": "First abstract."}"#, TokenUsage::default());
backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default()); backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default());
backend.push(FRONT_PAGE_FIXTURE, TokenUsage::default()); backend.push(BRIEF_FIXTURE, TokenUsage::default());
let llm = client(Arc::clone(&backend), 2.0); 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!( assert_eq!(backend.calls(), 3, "one call per article plus the brief");
backend.calls(),
3,
"one call per article plus the front page"
);
assert_eq!(editorial.summaries.len(), 2); assert_eq!(editorial.summaries.len(), 2);
assert_eq!(editorial.summaries[&1], "First abstract."); assert_eq!(editorial.summaries[&1], "First abstract.");
assert!(editorial.front_page_html.starts_with("<p>")); assert!(editorial.front_page_html.starts_with("<p>"));
assert!(editorial.front_page_html.contains("</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!(!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] #[tokio::test]
@@ -560,14 +574,15 @@ mod tests {
TokenUsage { TokenUsage {
input_tokens: 1_000_000, input_tokens: 1_000_000,
cached_tokens: 0, cached_tokens: 0,
cache_write_tokens: 0,
output_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_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!( assert_eq!(
editorial.summaries.len(), editorial.summaries.len(),
2, 2,
@@ -581,7 +596,6 @@ mod tests {
); );
// The front page degraded to the plain version. // The front page degraded to the plain version.
assert!(editorial.front_page_html.contains("2 articles")); assert!(editorial.front_page_html.contains("2 articles"));
assert!(editorial.section_intros.is_empty());
} }
#[tokio::test] #[tokio::test]
@@ -589,28 +603,30 @@ mod tests {
let backend = Arc::new(MockBackend::new()); let backend = Arc::new(MockBackend::new());
backend.push_error("400 bad request"); backend.push_error("400 bad request");
backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default()); backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default());
backend.push_error("500 front page exploded"); backend.push_error("500 brief exploded");
let llm = client(Arc::clone(&backend), 2.0); 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_eq!(editorial.summaries.len(), 2);
assert!(editorial.summaries[&1].contains("opens with a specific")); assert!(editorial.summaries[&1].contains("opens with a specific"));
assert_eq!(editorial.summaries[&2], "Second abstract."); assert_eq!(editorial.summaries[&2], "Second abstract.");
assert!(editorial.front_page_html.contains("Leading today")); 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] #[test]
fn fallback_editorial_covers_every_pick() { fn fallback_editorial_covers_every_pick() {
let lineup = lineup(); let lineup = lineup();
let editorial = fallback_editorial(&lineup); let editorial = fallback_editorial(&lineup);
assert_eq!(editorial.summaries.len(), lineup.picks.len()); 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("2 articles"));
assert!( assert!(editorial.front_page_html.contains("2 sections"));
editorial
.front_page_html
.contains("Top Stories, Boston &amp; Local")
);
assert!(editorial.front_page_html.starts_with("<p>")); assert!(editorial.front_page_html.starts_with("<p>"));
// An empty lineup is still a valid editorial. // 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 //! [`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 //! 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 //! no provider at all (`--skip-llm`): the prefilter order stands in for selection
//! feed excerpts stand in for summaries (notes §6). //! 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 editorial;
pub mod llm; pub mod llm;
@@ -26,14 +27,15 @@ use crate::types::{Article, Editorial, Lineup, ScoredArticle};
pub struct Curator { pub struct Curator {
pub config: Config, pub config: Config,
pub db: Db, pub db: Db,
pub llm: Option<llm::LlmClient>, pub llms: llm::Llms,
} }
impl Curator { impl Curator {
/// `llm == None` corresponds to `--skip-llm`: prefilter order is used for /// An empty [`llm::Llms`] corresponds to `--skip-llm`: prefilter order is
/// selection and feed excerpts stand in for summaries (notes §6). /// used for selection and feed excerpts stand in for summaries (notes §6).
pub fn new(config: Config, db: Db, llm: Option<llm::LlmClient>) -> Self { /// With only `bulk`, every editor call runs on DeepSeek (§4.2).
Self { config, db, llm } pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self {
Self { config, db, llms }
} }
/// Heuristic pre-filter: 300500 articles → `prefilter_keep` (§3.5). /// 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)`. /// A no-op under `--skip-llm`. Scores are persisted per `(article, date)`.
pub async fn score(&self, candidates: &mut [ScoredArticle], date: Date) -> anyhow::Result<()> { 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"); tracing::info!("--skip-llm: stage A scoring skipped");
return Ok(()); return Ok(());
}; };
@@ -86,6 +88,7 @@ impl Curator {
llm, llm,
candidates, candidates,
self.config.deepseek.score_batch_size, self.config.deepseek.score_batch_size,
self.config.deepseek.max_concurrent_requests,
&self.config.curation.sections, &self.config.curation.sections,
self.config.deepseek.score_temperature, self.config.deepseek.score_temperature,
) )
@@ -116,23 +119,29 @@ impl Curator {
date: Date, date: Date,
) -> anyhow::Result<Lineup> { ) -> anyhow::Result<Lineup> {
let sections = &self.config.curation.sections; let sections = &self.config.curation.sections;
let target = self.config.target_article_count; let soft_target = self.config.target_article_count;
let Some(llm) = self.llm.as_ref() else { let hard_max = self.config.curation.max_article_count;
tracing::info!("--skip-llm: selecting by prefilter order"); let span = tracing::info_span!("llm_editor", candidates = candidates.len());
return Ok(select::select_without_llm(
candidates, sections, target, date,
));
};
let span = tracing::info_span!("llm_select", candidates = candidates.len());
let _guard = span.enter(); let _guard = span.enter();
match select::select(
match select::select(llm, candidates.clone(), sections, target, date).await { &self.llms,
candidates.clone(),
sections,
soft_target,
hard_max,
date,
)
.await
{
Ok(lineup) => Ok(lineup), Ok(lineup) => Ok(lineup),
Err(e) => { Err(error) => {
tracing::error!(error = %e, tracing::error!(%error, "editor and bulk fallback failed; selecting heuristically");
"stage B selection failed; falling back to prefilter order");
Ok(select::select_without_llm( 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. /// Never fails the run: a budget trip or an API error degrades to excerpts.
pub async fn editorial(&self, lineup: &Lineup) -> anyhow::Result<Editorial> { 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"); tracing::info!("--skip-llm: using feed excerpts as summaries");
return Ok(editorial::fallback_editorial(lineup)); return Ok(editorial::fallback_editorial(lineup));
}; }
let span = tracing::info_span!("llm_editorial", picks = lineup.picks.len()); let span = tracing::info_span!("llm_editorial", picks = lineup.picks.len());
let _guard = span.enter(); 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)
} }
} }
+37 -48
View File
@@ -10,6 +10,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Write as _; use std::fmt::Write as _;
use futures::{StreamExt, stream};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -331,6 +332,7 @@ pub async fn score_all(
llm: &LlmClient, llm: &LlmClient,
candidates: &mut [ScoredArticle], candidates: &mut [ScoredArticle],
batch_size: usize, batch_size: usize,
max_concurrent_requests: usize,
sections: &[String], sections: &[String],
temperature: f32, temperature: f32,
) -> Result<usize, LlmError> { ) -> Result<usize, LlmError> {
@@ -339,61 +341,47 @@ pub async fn score_all(
} }
let batch_size = batch_size.max(1); let batch_size = batch_size.max(1);
let batches = candidates.len().div_ceil(batch_size); 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() { let results = stream::iter(prompts)
if let Err(e) = llm.meter.check_budget() { .map(|(index, article_count, prompt)| async move {
tracing::error!( if let Err(error) = llm.meter.check_budget() {
error = %e, tracing::warn!(batch = index + 1, of = batches, %error, "bulk budget tripped; skipping stage A batch");
batch = n + 1, return (index, Vec::new());
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 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"
);
}
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");
} }
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());
} }
} }
let mut applied = 0;
let mut applied = 0usize; for candidate in candidates {
for candidate in candidates.iter_mut() {
if let Some(score) = scores.remove(&candidate.article.id) { if let Some(score) = scores.remove(&candidate.article.id) {
candidate.llm = Some(score); candidate.llm = Some(score);
applied += 1; applied += 1;
} }
} }
if !scores.is_empty() { if !scores.is_empty() {
tracing::warn!( tracing::warn!(unknown_ids = scores.len(), "stage A returned unknown ids");
unknown_ids = scores.len(),
"stage A returned scores for ids that were not in the batch"
);
} }
Ok(applied) Ok(applied)
} }
@@ -535,7 +523,7 @@ mod tests {
candidate(2, "Two", 1000), candidate(2, "Two", 1000),
candidate(3, "Three", 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 .await
.expect("scoring"); .expect("scoring");
assert_eq!(scored, 3); assert_eq!(scored, 3);
@@ -556,7 +544,7 @@ mod tests {
); );
let llm = client(Arc::clone(&backend), 2.0); let llm = client(Arc::clone(&backend), 2.0);
let mut candidates = vec![candidate(1, "One", 900), candidate(2, "Two", 900)]; 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 .await
.expect("scoring must not abort"); .expect("scoring must not abort");
assert_eq!(scored, 1); assert_eq!(scored, 1);
@@ -573,6 +561,7 @@ mod tests {
TokenUsage { TokenUsage {
input_tokens: 1_000_000, input_tokens: 1_000_000,
cached_tokens: 0, cached_tokens: 0,
cache_write_tokens: 0,
output_tokens: 0, output_tokens: 0,
}, },
); );
@@ -582,7 +571,7 @@ mod tests {
); );
let llm = client(Arc::clone(&backend), 0.05); let llm = client(Arc::clone(&backend), 0.05);
let mut candidates = vec![candidate(1, "One", 900), candidate(2, "Two", 900)]; 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 .await
.expect("scoring"); .expect("scoring");
assert_eq!(scored, 1, "only the first batch ran"); assert_eq!(scored, 1, "only the first batch ran");
+441 -286
View File
File diff suppressed because it is too large Load Diff
+131 -13
View File
@@ -5,6 +5,7 @@
//! (implementation notes §2). Pipeline writes are idempotent upserts so that //! (implementation notes §2). Pipeline writes are idempotent upserts so that
//! `generate --date X` can be re-run safely; feedback events are append-only. //! `generate --date X` can be re-run safely; feedback events are append-only.
use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
@@ -43,6 +44,12 @@ pub enum DbError {
}, },
#[error("malformed value in column `{column}`: {value}")] #[error("malformed value in column `{column}`: {value}")]
Decode { column: &'static str, value: String }, 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>; type Result<T> = std::result::Result<T, DbError>;
@@ -480,8 +487,8 @@ impl Db {
.await?; .await?;
for pick in picks { for pick in picks {
sqlx::query( sqlx::query(
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary) "INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary, why)
VALUES (?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?)",
) )
.bind(date.to_string()) .bind(date.to_string())
.bind(pick.article.id) .bind(pick.article.id)
@@ -489,6 +496,7 @@ impl Db {
.bind(pick.position) .bind(pick.position)
.bind(pick.is_lead) .bind(pick.is_lead)
.bind(pick.summary.as_deref()) .bind(pick.summary.as_deref())
.bind(pick.why.as_deref())
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
} }
@@ -656,7 +664,7 @@ impl Db {
sqlx::query( sqlx::query(
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?, "UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?, input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?,
status = ?, error = ? status = ?, error = ?, provider_costs_json = ?, config_json = ?
WHERE id = ?", WHERE id = ?",
) )
.bind(report.finished_at.map(fmt_ts)) .bind(report.finished_at.map(fmt_ts))
@@ -669,20 +677,55 @@ impl Db {
.bind(report.cost_usd) .bind(report.cost_usd)
.bind(report.status.as_str()) .bind(report.status.as_str())
.bind(report.error.as_deref()) .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) .bind(id)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(()) Ok(())
} }
/// Total spend recorded for a date, for the `max_daily_usd` guardrail (§3.6). /// Earlier provider spend on the UTC date containing this run's start (§5).
pub async fn spend_for_date(&self, date: Date) -> Result<f64> { pub async fn provider_spend_for_utc_day(
let row = &self,
sqlx::query("SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM runs WHERE date = ?") started_at: Timestamp,
.bind(date.to_string()) ) -> Result<BTreeMap<String, f64>> {
.fetch_one(&self.pool) let utc_date = started_at
.await?; .to_zoned(jiff::tz::TimeZone::UTC)
Ok(row.get::<f64, _>("total")) .date()
.to_string();
let rows = sqlx::query(
"SELECT provider_costs_json FROM runs
WHERE substr(started_at, 1, 10) = ? AND started_at < ?
AND provider_costs_json IS NOT NULL",
)
.bind(utc_date)
.bind(fmt_ts(started_at))
.fetch_all(&self.pool)
.await?;
let mut totals = BTreeMap::new();
for row in rows {
let raw = row.get::<String, _>("provider_costs_json");
let providers: BTreeMap<String, crate::report::ProviderUsage> =
serde_json::from_str(&raw).map_err(|source| DbError::Json {
column: "runs.provider_costs_json",
source,
})?;
for (provider, usage) in providers {
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
}
}
Ok(totals)
} }
} }
@@ -988,7 +1031,7 @@ mod tests {
report.counts.entries_fetched = 412; report.counts.entries_fetched = 412;
report.counts.candidates = 120; report.counts.candidates = 120;
report.counts.selected = 20; 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.finish_run(run_id, &report).await.unwrap();
db.upsert_issue( db.upsert_issue(
@@ -1006,7 +1049,82 @@ mod tests {
let next: Date = "2026-08-16".parse().unwrap(); let next: Date = "2026-08-16".parse().unwrap();
assert_eq!(db.next_issue_number(next).await.unwrap(), 2); assert_eq!(db.next_issue_number(next).await.unwrap(), 2);
assert_eq!(db.recent_reports(5).await.unwrap().len(), 1); 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] #[tokio::test]
+1 -6
View File
@@ -66,12 +66,7 @@ pub fn render_all(
]; ];
for name in section_names(issue) { for name in section_names(issue) {
let intro = issue chapters.push(render_section_page(&name)?);
.editorial
.section_intros
.get(&name)
.map(|s| s.as_str());
chapters.push(render_section_page(&name, intro)?);
for pick in issue.lineup.section_picks(&name) { for pick in issue.lineup.section_picks(&name) {
chapters.push(render_article( chapters.push(render_article(
issue, issue,
+28 -12
View File
@@ -39,6 +39,7 @@ struct IndexEntry {
source: String, source: String,
reading_minutes: i64, reading_minutes: i64,
summary: String, summary: String,
why: Option<String>,
} }
struct IndexSection { struct IndexSection {
@@ -59,7 +60,6 @@ struct InThisIssue {
struct SectionPage { struct SectionPage {
title: String, title: String,
name: String, name: String,
intro: Option<String>,
} }
struct RatingLinks { struct RatingLinks {
@@ -76,6 +76,7 @@ struct ArticleChapter {
byline: Option<String>, byline: Option<String>,
meta_line: String, meta_line: String,
social_line: Option<String>, social_line: Option<String>,
why: Option<String>,
summary: Option<String>, summary: Option<String>,
excerpt_only: bool, excerpt_only: bool,
body_html: String, body_html: String,
@@ -108,7 +109,10 @@ struct ColophonChapter {
issue_number: i64, issue_number: i64,
display_date: String, display_date: String,
generated_at: String, generated_at: String,
model: String, bulk_model: String,
editor_model: String,
summaries_model: String,
provider_costs: Vec<ProviderCostLine>,
entries_fetched: i64, entries_fetched: i64,
feeds_seen: i64, feeds_seen: i64,
candidates: i64, candidates: i64,
@@ -120,6 +124,11 @@ struct ColophonChapter {
generator_version: String, generator_version: String,
} }
struct ProviderCostLine {
provider: String,
cost: String,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chapters (§3.10) // Chapters (§3.10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -274,6 +283,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
source: pick.article.feed_title.clone(), source: pick.article.feed_title.clone(),
reading_minutes: pick.article.reading_minutes(), reading_minutes: pick.article.reading_minutes(),
summary: summary_for(issue, pick).unwrap_or_default().to_string(), summary: summary_for(issue, pick).unwrap_or_default().to_string(),
why: pick.why.clone(),
}) })
.collect(); .collect();
sections.push(IndexSection { name, entries }); 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(), source: "Wikipedia Current Events".into(),
reading_minutes: 3, reading_minutes: 3,
summary: "The day's events, as recorded by the Current Events portal.".into(), 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). /// A section title page: the name only (§14.2 removed the LLM intros).
pub fn render_section_page(name: &str, intro: Option<&str>) -> Result<Chapter, EpubError> { pub fn render_section_page(name: &str) -> Result<Chapter, EpubError> {
let tpl = SectionPage { let tpl = SectionPage {
title: name.to_string(), title: name.to_string(),
name: name.to_string(), name: name.to_string(),
intro: intro
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
}; };
Ok(Chapter { Ok(Chapter {
id: format!("sec-{name}"), id: format!("sec-{name}"),
@@ -364,6 +372,7 @@ pub fn render_article(
byline: article.author.as_ref().map(|a| format!("By {a}")), byline: article.author.as_ref().map(|a| format!("By {a}")),
meta_line: meta_parts.join(" \u{00b7} "), meta_line: meta_parts.join(" \u{00b7} "),
social_line: social_line(&article.social), social_line: social_line(&article.social),
why: pick.why.clone(),
summary: summary_for(issue, pick).map(str::to_string), summary: summary_for(issue, pick).map(str::to_string),
excerpt_only: article.excerpt_only, excerpt_only: article.excerpt_only,
body_html: prepare_body(&article.content_html, images_), 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). /// Colophon: generation timestamp, models used, token cost, feed counts (§3.10).
pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> { pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> {
let colophon = &issue.colophon; 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 { let tpl = ColophonChapter {
title: "Colophon".into(), title: "Colophon".into(),
issue_number: issue.meta.issue_number, issue_number: issue.meta.issue_number,
display_date: issue.meta.display_date.clone(), display_date: issue.meta.display_date.clone(),
generated_at: issue.meta.generated_at.to_string(), generated_at: issue.meta.generated_at.to_string(),
model: if colophon.model.is_empty() { bulk_model: colophon.models.bulk.clone(),
"none (heuristic selection)".into() editor_model: colophon.models.editor.clone(),
} else { summaries_model: colophon.models.summaries.clone(),
colophon.model.clone() provider_costs,
},
entries_fetched: colophon.entries_fetched, entries_fetched: colophon.entries_fetched,
feeds_seen: colophon.feeds_seen, feeds_seen: colophon.feeds_seen,
candidates: colophon.candidates, candidates: colophon.candidates,
+11 -4
View File
@@ -86,6 +86,7 @@ pub fn issue() -> Issue {
section: "Top Stories".into(), section: "Top Stories".into(),
position: 0, position: 0,
is_lead: true, 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()), summary: Some("What it argues, and why it is worth the time.".into()),
llm: None, llm: None,
discussion: Some(discussion(1, 1001)), discussion: Some(discussion(1, 1001)),
@@ -95,12 +96,11 @@ pub fn issue() -> Issue {
section: "Niche Corner".into(), section: "Niche Corner".into(),
position: 0, position: 0,
is_lead: false, is_lead: false,
why: Some("A small-scene delight outside the usual technical orbit".into()),
summary: None, summary: None,
llm: None, llm: None,
discussion: 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(); let mut summaries = BTreeMap::new();
summaries.insert(2, "A short abstract for the second piece.".to_string()); summaries.insert(2, "A short abstract for the second piece.".to_string());
@@ -122,7 +122,6 @@ pub fn issue() -> Issue {
}, },
editorial: Editorial { editorial: Editorial {
front_page_html: "<p>Two stories today, both worth your coffee.</p>".into(), front_page_html: "<p>Two stories today, both worth your coffee.</p>".into(),
section_intros,
summaries, summaries,
}, },
world_briefing: Some(WorldBriefing { world_briefing: Some(WorldBriefing {
@@ -141,7 +140,15 @@ pub fn issue() -> Issue {
}], }],
}), }),
colophon: Colophon { 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, entries_fetched: 431,
feeds_seen: 92, feeds_seen: 92,
candidates: 120, candidates: 120,
+3
View File
@@ -7,6 +7,9 @@
<p class="byline">{{ line }}</p> <p class="byline">{{ line }}</p>
{% endif %} {% endif %}
<p class="meta">{{ meta_line }}</p> <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 %} {% if let Some(line) = social_line %}
<p class="social">{{ line }}</p> <p class="social">{{ line }}</p>
{% endif %} {% endif %}
+7 -2
View File
@@ -9,12 +9,17 @@
</p> </p>
<p class="fact-line"><strong>Issue:</strong> No. {{ issue_number }} &#183; {{ display_date }}</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>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>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>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>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>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="fact-line"><strong>Generator:</strong> {{ generator_version }}</p>
<p class="attribution"> <p class="attribution">
Article text belongs to its authors and publications; excerpts and links are 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> <h1 class="masthead">The Daily EPUB</h1>
<p class="dateline">{{ display_date }} &#183; No. {{ issue_number }}</p> <p class="dateline">{{ display_date }} &#183; No. {{ issue_number }}</p>
<hr class="rule"/> <hr class="rule"/>
<h2 class="kicker">From the Editor</h2> <h2 class="kicker">The Brief</h2>
<div class="editorial"> <div class="editorial">
{{ body_html|safe }} {{ body_html|safe }}
</div> </div>
+3
View File
@@ -12,6 +12,9 @@
<p class="index-meta">{{ entry.source }} &#183; {{ entry.reading_minutes }} min read</p> <p class="index-meta">{{ entry.source }} &#183; {{ entry.reading_minutes }} min read</p>
{% if !entry.summary.is_empty() %} {% if !entry.summary.is_empty() %}
<p class="index-summary">{{ entry.summary }}</p> <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 %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
-3
View File
@@ -3,7 +3,4 @@
{% block content %} {% block content %}
<h1 class="section-title">{{ name }}</h1> <h1 class="section-title">{{ name }}</h1>
<hr class="rule"/> <hr class="rule"/>
{% if let Some(text) = intro %}
<p class="section-intro">{{ text }}</p>
{% endif %}
{% endblock %} {% endblock %}
+2
View File
@@ -190,3 +190,5 @@ p.comment-line {
.fact-line { .fact-line {
margin: 0 0 0.35em 0; 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 { .fact-line {
margin: 0 0 0.35em 0; 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.duplicates_merged,
report.counts.entries_dropped, report.counts.entries_dropped,
); );
if report.counts.llm_unscored > 0 {
println!(
"curation: {} scored · {} unscored · {} selected",
report.counts.llm_scored, report.counts.llm_unscored, report.counts.selected,
);
}
println!( println!(
"tokens: {} input · {} cached · {} output = ${:.4}", "tokens: {} input · {} cache read · {} cache write · {} output = ${:.4}",
report.usage.input_tokens, report.usage.input_tokens,
report.usage.cached_tokens, report.usage.cached_tokens,
report.usage.cache_write_tokens,
report.usage.output_tokens, report.usage.output_tokens,
report.cost_usd, 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 { for warning in &report.warnings {
println!("warning: {warning}"); println!("warning: {warning}");
} }
@@ -316,8 +333,15 @@ fn print_lineup(issue: &daily_epub::types::Issue) {
// Other subcommands // Other subcommands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3).
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> { 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( let profile = curate::profile::load_or_build(
db, db,
&config.interests_opml, &config.interests_opml,
@@ -325,10 +349,22 @@ async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
config.curation.feedback.verdicts_in_prompt, config.curation.feedback.verdicts_in_prompt,
) )
.await?; .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( let rebuilt = curate::profile::rebuild(
db, db,
&llm, llm,
&config.interests_opml, &config.interests_opml,
&config.profile_path, &config.profile_path,
config.curation.feedback.verdicts_in_prompt, config.curation.feedback.verdicts_in_prompt,
+207 -66
View File
@@ -31,15 +31,15 @@ use jiff::civil::Date;
use jiff::{Timestamp, Zoned}; use jiff::{Timestamp, Zoned};
use crate::config::Config; 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::curate::{Curator, editorial, profile};
use crate::db::Db; use crate::db::Db;
use crate::extract::Extractor; use crate::extract::Extractor;
use crate::miniflux::MinifluxClient; use crate::miniflux::MinifluxClient;
use crate::publish::Published; use crate::publish::Published;
use crate::report::{RunReport, RunStatus}; use crate::report::{ProviderUsage, RunReport, RunStatus};
use crate::types::{ 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}; 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 started_at = Timestamp::now();
let (window_start, window_end) = ingest_window(config, date)?; let (window_start, window_end) = ingest_window(config, date)?;
let out_dir = opts.out.clone().unwrap_or_else(|| config.out_dir.clone()); 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 span = tracing::info_span!("generate", %date, dry_run = opts.dry_run);
let _guard = span.enter(); let _guard = span.enter();
@@ -193,16 +193,19 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
%window_start, %window_start,
%window_end, %window_end,
lookback_hours = config.lookback_hours, lookback_hours = config.lookback_hours,
target, soft_target,
hard_max,
skip_llm = opts.skip_llm, skip_llm = opts.skip_llm,
out = %out_dir.display(), out = %out_dir.display(),
"starting run" "starting run"
); );
log_resolved_providers(config, opts.skip_llm);
let run_id = db.start_run(date, started_at).await?; let run_id = db.start_run(date, started_at).await?;
let mut report = RunReport::new(date, started_at); let mut report = RunReport::new(date, started_at);
report.window_start = Some(window_start); report.window_start = Some(window_start);
report.window_end = Some(window_end); report.window_end = Some(window_end);
report.config_json = resolved_run_config(config, soft_target, hard_max);
if opts.dry_run { if opts.dry_run {
report.status = RunStatus::DryRun; report.status = RunStatus::DryRun;
} }
@@ -211,19 +214,16 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
config, config,
db, db,
date, date,
target, soft_target,
hard_max,
started_at,
out_dir, out_dir,
dry_run: opts.dry_run, dry_run: opts.dry_run,
skip_llm: opts.skip_llm, skip_llm: opts.skip_llm,
}; };
let stages = match run_stages(&ctx, window_start, window_end, &mut report).await { let stages = match run_stages(&ctx, window_start, window_end, &mut report).await {
Ok(stages) => { Ok(stages) => {
report.finish( report.finish(Timestamp::now());
Timestamp::now(),
config.deepseek.price_input_per_mtok,
config.deepseek.price_cached_input_per_mtok,
config.deepseek.price_output_per_mtok,
);
stages stages
} }
Err(e) => { Err(e) => {
@@ -276,7 +276,9 @@ struct StageContext<'a> {
config: &'a Config, config: &'a Config,
db: &'a Db, db: &'a Db,
date: Date, date: Date,
target: usize, soft_target: usize,
hard_max: usize,
started_at: Timestamp,
out_dir: PathBuf, out_dir: PathBuf,
dry_run: bool, dry_run: bool,
skip_llm: bool, skip_llm: bool,
@@ -369,23 +371,28 @@ async fn run_stages(
// --- Stage 6: heuristic pre-filter (§3.5) --- // --- Stage 6: heuristic pre-filter (§3.5) ---
let stage = Timestamp::now(); let stage = Timestamp::now();
let meter = UsageMeter::new(&config.deepseek, config.max_daily_usd); let bulk_meter =
// `max_daily_usd` is a ceiling for the *day*, not for one invocation, so a UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
// re-run inherits what earlier runs for this date already spent (§3.6). let editor_meter = UsageMeter::with_prices(
match db.spend_for_date(date).await { PriceTable::anthropic(&config.anthropic),
Ok(spent) if spent > 0.0 => { config.anthropic.max_daily_usd,
tracing::info!(spent, "preloading today's recorded DeepSeek spend"); );
meter.preload_cost(spent); 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 llms = build_llms(ctx, &bulk_meter, &editor_meter, report).await;
let llm_available = llm.is_some(); let bulk_available = llms.bulk.is_some();
let mut curator_config = config.clone(); let mut curator_config = config.clone();
curator_config.target_article_count = ctx.target; curator_config.target_article_count = ctx.soft_target;
let curator = Curator::new(curator_config, db.clone(), llm); curator_config.curation.max_article_count = ctx.hard_max;
let curator = Curator::new(curator_config, db.clone(), llms);
let mut candidates = curator let mut candidates = curator
.prefilter(articles, date) .prefilter(articles, date)
@@ -396,12 +403,13 @@ async fn run_stages(
// --- Stage 7: LLM scoring, then selection (§3.6 A + B) --- // --- Stage 7: LLM scoring, then selection (§3.6 A + B) ---
let stage = Timestamp::now(); 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 // A dead API or a tripped budget must not cost us the issue: selection
// degrades to prefilter order exactly as `--skip-llm` does. // degrades to prefilter order exactly as `--skip-llm` does.
report.warn(format!("LLM scoring failed; ranking heuristically: {e:#}")); 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_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 let mut lineup = curator
.select(candidates, date) .select(candidates, date)
@@ -440,7 +448,7 @@ async fn run_stages(
if config.world_briefing { if config.world_briefing {
match world_briefing.as_mut() { match world_briefing.as_mut() {
Some(briefing) => { 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); report.warn(warning);
} }
} }
@@ -454,19 +462,55 @@ async fn run_stages(
.next_issue_number(date) .next_issue_number(date)
.await .await
.context("computing the issue number")?; .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 { let colophon = Colophon {
model: if llm_available { provider_costs,
config.deepseek.model.clone() models: Models {
} else { bulk: if bulk_available {
"none (--skip-llm)".into() config.deepseek.model.clone()
} else {
"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, entries_fetched: report.counts.entries_fetched,
feeds_seen: report.counts.feeds_seen, feeds_seen: report.counts.feeds_seen,
candidates: report.counts.candidates, 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), generator_version: format!("daily-epub {}", crate::VERSION),
}; };
report.usage = meter.total();
let issue = build_issue( let issue = build_issue(
date, date,
issue_number, 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 /// Returns `None` for `--skip-llm` and for every configuration/API problem: the
/// caller then curates heuristically instead of failing the run (§3.6). /// caller then curates heuristically instead of failing the run (§3.6).
async fn build_llm( async fn build_llms(
ctx: &StageContext<'_>, ctx: &StageContext<'_>,
meter: &UsageMeter, bulk_meter: &UsageMeter,
editor_meter: &UsageMeter,
report: &mut RunReport, report: &mut RunReport,
) -> Option<LlmClient> { ) -> Llms {
let profile = match profile::load_or_build( let profile = match profile::load_or_build(
ctx.db, ctx.db,
&ctx.config.interests_opml, &ctx.config.interests_opml,
@@ -596,32 +641,36 @@ async fn build_llm(
.await .await
{ {
Ok(profile) => profile, Ok(profile) => profile,
Err(e) => { Err(error) => {
report.warn(format!( 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 { if ctx.skip_llm {
tracing::info!("--skip-llm: profile rebuilt; no DeepSeek call will be made"); tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made");
return None; return Llms::default();
} }
let client = match LlmClient::new(&ctx.config.deepseek, profile.text, meter.clone()) {
Ok(client) => client, let make_clients = |prompt: String| {
Err(e) => { Llms::from_config(
report.warn(format!( &ctx.config.deepseek,
"DeepSeek is unavailable; curating heuristically: {e}" &ctx.config.anthropic,
)); prompt,
return None; bulk_meter.clone(),
} editor_meter.clone(),
)
}; };
// Weekly rewrite of the "learned adjustments" section (§3.6c). It changes the let mut llms = make_clients(profile.text);
// system prompt, so the client is rebuilt around the new profile. 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( match profile::weekly_rebuild_if_due(
ctx.db, ctx.db,
&client, rebuild_client,
&ctx.config.interests_opml, &ctx.config.interests_opml,
&ctx.config.profile_path, &ctx.config.profile_path,
ctx.config.curation.feedback.verdicts_in_prompt, ctx.config.curation.feedback.verdicts_in_prompt,
@@ -629,21 +678,78 @@ async fn build_llm(
.await .await
{ {
Ok(Some(rebuilt)) => { Ok(Some(rebuilt)) => {
tracing::info!(version = rebuilt.version, "taste profile rebuilt"); tracing::info!(
match LlmClient::new(&ctx.config.deepseek, rebuilt.text, meter.clone()) { version = rebuilt.version,
Ok(refreshed) => Some(refreshed), "taste profile rebuilt with editor-or-bulk"
Err(e) => { );
tracing::warn!(error = %e, "keeping the previous profile client"); llms = make_clients(rebuilt.text);
Some(client)
}
}
}
Ok(None) => Some(client),
Err(e) => {
report.warn(format!("weekly profile rebuild failed: {e:#}"));
Some(client)
} }
Ok(None) => {}
Err(error) => report.warn(format!("weekly profile rebuild failed: {error:#}")),
} }
llms
}
/// `--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 { fn elapsed_ms(since: Timestamp) -> i64 {
@@ -689,6 +795,41 @@ mod tests {
assert!(resolve_date(&config, None).is_ok()); 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] #[test]
fn issue_meta_is_derived_from_the_lineup() { fn issue_meta_is_derived_from_the_lineup() {
let lineup = crate::epub::build::fixtures::issue().lineup; let lineup = crate::epub::build::fixtures::issue().lineup;
+71 -19
View File
@@ -71,6 +71,8 @@ pub struct StageCounts {
pub candidates: i64, pub candidates: i64,
/// Articles scored by the LLM (§3.6 stage A). /// Articles scored by the LLM (§3.6 stage A).
pub llm_scored: i64, 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). /// Articles in the final lineup (§3.6 stage B).
pub selected: i64, pub selected: i64,
/// Discussion chapters rendered (§3.7). /// 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). /// The full summary of one `generate` invocation (§3.13).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunReport { pub struct RunReport {
@@ -101,7 +111,12 @@ pub struct RunReport {
pub finished_at: Option<Timestamp>, pub finished_at: Option<Timestamp>,
pub status: RunStatus, pub status: RunStatus,
pub counts: StageCounts, pub counts: StageCounts,
/// Aggregate usage retained for the legacy `runs` columns.
pub usage: TokenUsage, 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 cost_usd: f64,
pub timings: StageTimings, pub timings: StageTimings,
/// Ingest window actually used, RFC3339 (§3.1). /// Ingest window actually used, RFC3339 (§3.1).
@@ -124,6 +139,8 @@ impl RunReport {
status: RunStatus::Running, status: RunStatus::Running,
counts: StageCounts::default(), counts: StageCounts::default(),
usage: TokenUsage::default(), usage: TokenUsage::default(),
provider_costs: BTreeMap::new(),
config_json: serde_json::Value::Null,
cost_usd: 0.0, cost_usd: 0.0,
timings: StageTimings::default(), timings: StageTimings::default(),
window_start: None, window_start: None,
@@ -146,16 +163,15 @@ impl RunReport {
self.error = Some(err.to_string()); self.error = Some(err.to_string());
} }
/// Stamp the end time, compute cost from [`TokenUsage`] and settle the status. /// Stamp the end time, total provider costs and settle the status.
pub fn finish( pub fn finish(&mut self, finished_at: Timestamp) {
&mut self,
finished_at: Timestamp,
price_input: f64,
price_cached: f64,
price_output: f64,
) {
self.finished_at = Some(finished_at); 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 { if self.status == RunStatus::Running {
self.status = if self.warnings.is_empty() { self.status = if self.warnings.is_empty() {
RunStatus::Ok RunStatus::Ok
@@ -215,17 +231,37 @@ mod tests {
s.parse().unwrap() 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] #[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")); let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
r.usage.add(TokenUsage { r.provider_costs.insert(
input_tokens: 1_000_000, "deepseek".into(),
cached_tokens: 1_000_000, ProviderUsage {
output_tokens: 1_000_000, usage: usage(1_000_000, 1_000_000, 0, 1_000_000),
}); cost_usd: 0.4228,
r.finish(ts("2026-08-15T05:36:00Z"), 0.14, 0.0028, 0.28); },
);
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_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)); assert_eq!(r.duration_secs(), Some(360));
} }
@@ -233,7 +269,7 @@ mod tests {
fn warnings_degrade_the_run() { fn warnings_degrade_the_run() {
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z")); let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
r.warn("xtc converter missing"); 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.status, RunStatus::Degraded);
assert_eq!(r.warnings.len(), 1); assert_eq!(r.warnings.len(), 1);
} }
@@ -242,15 +278,31 @@ mod tests {
fn serializes_round_trip() { fn serializes_round_trip() {
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z")); let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
r.counts.entries_fetched = 412; r.counts.entries_fetched = 412;
r.counts.llm_unscored = 3;
r.per_feed_counts.insert("Hacker News".into(), 30); r.per_feed_counts.insert("Hacker News".into(), 30);
r.per_feed_counts.insert("Lobsters".into(), 12); r.per_feed_counts.insert("Lobsters".into(), 12);
r.timings.record("ingest", 1500); 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 json = r.to_json();
let back: RunReport = serde_json::from_str(&json).unwrap(); let back: RunReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, r); assert_eq!(back, r);
assert_eq!(back.top_feeds(1), vec![("Hacker News", 30)]); assert_eq!(back.top_feeds(1), vec![("Hacker News", 30)]);
assert_eq!(back.timings.total_ms(), 1500); assert_eq!(back.timings.total_ms(), 1500);
assert!(back.summary_line().contains("412 entries")); 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. /// Order within the section, ascending.
pub position: i64, pub position: i64,
pub is_lead: bool, 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. /// Newspaper-abstract summary from stage C; `None` until editorial runs.
pub summary: Option<String>, pub summary: Option<String>,
pub llm: Option<LlmScore>, pub llm: Option<LlmScore>,
@@ -331,8 +333,6 @@ impl Lineup {
pub struct Editorial { pub struct Editorial {
/// "From the Editor", 250400 words, already sanitized XHTML. /// "From the Editor", 250400 words, already sanitized XHTML.
pub front_page_html: String, pub front_page_html: String,
/// Section name → 23 sentence intro.
pub section_intros: BTreeMap<String, String>,
/// Article id → 23 sentence newspaper abstract. /// Article id → 23 sentence newspaper abstract.
pub summaries: BTreeMap<ArticleId, String>, pub summaries: BTreeMap<ArticleId, String>,
} }
@@ -525,10 +525,19 @@ pub struct Issue {
pub colophon: Colophon, 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). /// Back-matter facts printed in the colophon chapter (§3.10).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Colophon { pub struct Colophon {
pub model: String, pub provider_costs: BTreeMap<String, f64>,
pub models: Models,
pub entries_fetched: i64, pub entries_fetched: i64,
pub feeds_seen: i64, pub feeds_seen: i64,
pub candidates: i64, pub candidates: i64,
@@ -653,8 +662,10 @@ pub struct RatedArticle {
pub struct TokenUsage { pub struct TokenUsage {
/// Cache-miss input tokens (billed at the full input rate). /// Cache-miss input tokens (billed at the full input rate).
pub input_tokens: i64, 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, pub cached_tokens: i64,
/// Tokens written into a prompt cache (Anthropic only).
pub cache_write_tokens: i64,
pub output_tokens: i64, pub output_tokens: i64,
} }
@@ -662,13 +673,21 @@ impl TokenUsage {
pub fn add(&mut self, other: TokenUsage) { pub fn add(&mut self, other: TokenUsage) {
self.input_tokens += other.input_tokens; self.input_tokens += other.input_tokens;
self.cached_tokens += other.cached_tokens; self.cached_tokens += other.cached_tokens;
self.cache_write_tokens += other.cache_write_tokens;
self.output_tokens += other.output_tokens; self.output_tokens += other.output_tokens;
} }
/// USD cost given the per-1M-token prices from `[deepseek]` config (§3.6). /// 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.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) + self.output_tokens as f64 * price_output)
/ 1_000_000.0 / 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 //! no article in the fixtures carries an image, so the EPUB builder's image
//! downloader has nothing to fetch. //! downloader has nothing to fetch.
use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use jiff::Timestamp; use jiff::Timestamp;
use jiff::civil::Date; use jiff::civil::Date;
use daily_epub::config::{Config, PublishConfig, ServerConfig, XtcConfig}; 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::curate::{Curator, editorial, prefilter};
use daily_epub::db::Db; use daily_epub::db::Db;
use daily_epub::extract::Extractor; use daily_epub::extract::Extractor;
use daily_epub::types::{ 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}; 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; let articles = ingest_dedupe_extract_persist(&db).await;
// --- Stages 67 with no LLM at all (notes §6) --- // --- 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 let candidates = curator
.prefilter(articles, date()) .prefilter(articles, date())
.await .await
@@ -432,7 +433,12 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
); );
let colophon = Colophon { 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, entries_fetched: 8,
feeds_seen: 8, feeds_seen: 8,
candidates: 5, candidates: 5,
@@ -504,6 +510,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
let usage = daily_epub::types::TokenUsage { let usage = daily_epub::types::TokenUsage {
input_tokens: 1000, input_tokens: 1000,
cached_tokens: 500, cached_tokens: 500,
cache_write_tokens: 0,
output_tokens: 200, output_tokens: 200,
}; };
let scores: Vec<String> = ids let scores: Vec<String> = ids
@@ -544,8 +551,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
); );
} }
backend.push( backend.push(
r#"{"from_the_editor": "Today's issue leans on storage internals.\n\nRead on.", r#"{"brief": "Today's issue leans on storage internals.\n\nRead on."}"#,
"section_intros": {"Top Stories": "The day in one place."}}"#,
usage, usage,
); );
@@ -556,7 +562,14 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
meter.clone(), meter.clone(),
backend.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; let mut candidates = candidates;
curator curator
@@ -590,12 +603,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
"the model's summaries were used, not excerpts" "the model's summaries were used, not excerpts"
); );
assert!(editorial_doc.front_page_html.contains("storage internals")); assert!(editorial_doc.front_page_html.contains("storage internals"));
assert_eq!( assert!(
editorial_doc lineup.picks.iter().all(|p| p.why.is_none()),
.section_intros "the scripted editor gave no why lines"
.get("Top Stories")
.map(String::as_str),
Some("The day in one place.")
); );
// Every scripted response was consumed, and the meter priced them (§3.6). // 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. // And it all assembles, builds and publishes like the skip-llm route does.
let colophon = Colophon { 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, entries_fetched: 8,
feeds_seen: 8, feeds_seen: 8,
candidates: 5, candidates: 5,
@@ -625,6 +640,6 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
let mut lineup = lineup; let mut lineup = lineup;
pipeline::apply_summaries(&mut lineup, &editorial_doc); pipeline::apply_summaries(&mut lineup, &editorial_doc);
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await; 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); 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": [ "picks": [
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true }, { "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 }, { "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 }, { "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 }, { "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 }, { "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 }, { "id": 106, "section": "Culture & Essays", "position": 1 },
{ "section": "Niche Corner", "position": 2, "lead_story": false }, { "section": "Niche Corner", "position": 2, "lead_story": false },
"the model sometimes trails off like this" "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::path::{Path, PathBuf};
use std::process::Command; 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::profile;
use daily_epub::curate::score::parse_score_response; use daily_epub::curate::score::parse_score_response;
use daily_epub::curate::select::parse_selection_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 /// The Brief must deserialize into 120200 words of plain prose that names at
/// note plus per-section intros (§3.6). /// least three picks by title (§14.2). Section intros are gone.
#[test] #[test]
fn stage_c_fixture_parses_into_a_front_page() { fn stage_c_fixture_parses_into_the_brief() {
let response: FrontPageResponse = serde_json::from_str(&fixture("deepseek_front_page.json")) let response: BriefResponse = serde_json::from_str(&fixture("claude_brief.json"))
.expect("the front-page fixture must match FrontPageResponse"); .expect("the brief fixture must match BriefResponse");
let words = response.from_the_editor.split_whitespace().count(); let words = response.brief.split_whitespace().count();
assert!( assert!(
(150..=450).contains(&words), (100..=220).contains(&words),
"From the Editor is {words} words; the prompt asks for 250-400" "The Brief is {words} words; the prompt asks for 120-200"
); );
assert!( assert!(
response.from_the_editor.contains("\n\n"), !response.brief.contains("- ") && !response.brief.contains('#'),
"the prompt asks for 2-4 blank-line separated paragraphs" "no bullets or headings in the brief"
); );
let titles = response.brief.matches('"').count() / 2;
assert!( assert!(
!response.from_the_editor.contains("- "), titles >= 3,
"no bullet lists on the front page" "the brief names at least three picks; found {titles}"
); );
for banned in [
assert!(response.section_intros.len() >= 2); "delve",
for (section, intro) in &response.section_intros { "dive",
let words = intro.split_whitespace().count(); "explore",
"a mix of",
"something for everyone",
] {
assert!( assert!(
(10..=90).contains(&words), !response.brief.to_lowercase().contains(banned),
"intro for {section} is {words} words; the prompt asks for 35-60" "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 /// 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] { for edition in [Edition::Standard, Edition::X4] {
let (_dir, _, zip) = build_edition_to_bytes(&issue, edition); let (_dir, _, zip) = build_edition_to_bytes(&issue, edition);
let colophon = read_entry(&zip, "OEBPS/colophon.xhtml"); 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("<dl"));
assert!(!colophon.contains("<dt")); assert!(!colophon.contains("<dt"));
assert!(!colophon.contains("<dd")); assert!(!colophon.contains("<dd"));