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:
@@ -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
|
||||||
300–500 candidates down to ~120 with cheap heuristics, and asks DeepSeek to score,
|
300–500 candidates down to ~120 with cheap heuristics, asks DeepSeek to score them,
|
||||||
select and introduce 15–25 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.05–0.30/day** in DeepSeek tokens, hard-capped by
|
Steady-state cost is roughly **$1/day**: $0.05–0.30 in DeepSeek tokens plus
|
||||||
`max_daily_usd`.
|
~$0.50–0.80 for the Claude editor, each with its own per-UTC-day ceiling
|
||||||
|
(`max_daily_usd` and `anthropic.max_daily_usd`). Those ceilings are runaway
|
||||||
|
guards, not accounting — set hard spend limits in both providers' dashboards as
|
||||||
|
the real backstop.
|
||||||
|
|
||||||
- Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md)
|
- 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.05–0.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 ~80–100 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 ~80–100 MB, so the binding constraint is disk, not age. |
|
||||||
| `max_daily_usd` | `2.0` | Hard ceiling on DeepSeek spend **per day**, not per run — a re-run inherits what earlier runs for that date already spent. Tripping it skips remaining LLM work and degrades to excerpts. |
|
| `max_daily_usd` | `2.0` | Ceiling on DeepSeek spend per **UTC day** of the run's start, not per run — a re-run inherits what earlier runs that day already spent (`runs.provider_costs_json`). Tripping it skips remaining DeepSeek calls; in-flight requests finish and the paper still publishes. |
|
||||||
| `world_briefing` | `true` | Include the Wikipedia Current Events section. |
|
| `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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
+327
-311
@@ -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, 2–4 paragraphs \
|
|
||||||
separated by a blank line.
|
|
||||||
|
|
||||||
2. \"section_intros\" — for EACH section name given below, two or three \
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
sentences (35–60 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", 250–400 words.
|
|
||||||
pub from_the_editor: String,
|
|
||||||
/// Section name → 2–3 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 2–3 sentence newspaper abstract: what it argues, why it's worth reading (§3.6).
|
|
||||||
pub async fn summarize_article(
|
|
||||||
llm: &LlmClient,
|
|
||||||
title: &str,
|
|
||||||
body_html: &str,
|
|
||||||
temperature: f32,
|
|
||||||
) -> Result<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 {
|
||||||
pub async fn summarize_all(
|
SummaryModel::Bulk => (llms.bulk.as_ref(), None),
|
||||||
llm: &LlmClient,
|
SummaryModel::Editor => {
|
||||||
lineup: &Lineup,
|
let primary = llms.editor_or_bulk();
|
||||||
temperature: f32,
|
let fallback = primary.and_then(|client| {
|
||||||
) -> BTreeMap<ArticleId, String> {
|
llms.bulk
|
||||||
let mut out = BTreeMap::new();
|
.as_ref()
|
||||||
for (n, pick) in lineup.picks.iter().enumerate() {
|
.filter(|bulk| bulk.provider != client.provider)
|
||||||
if llm.meter.budget_exceeded() {
|
});
|
||||||
tracing::error!(
|
(primary, fallback)
|
||||||
summarized = out.len(),
|
|
||||||
remaining = lineup.picks.len() - n,
|
|
||||||
spent_usd = llm.meter.cost_usd(),
|
|
||||||
"COST CEILING HIT during stage C — the remaining articles fall back to \
|
|
||||||
feed excerpts as summaries"
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn summarize_pick(
|
||||||
|
pick: &Pick,
|
||||||
|
primary: Option<&LlmClient>,
|
||||||
|
fallback: Option<&LlmClient>,
|
||||||
|
config: &EditorialConfig,
|
||||||
|
temperature: f32,
|
||||||
|
) -> Option<String> {
|
||||||
|
let primary = primary?;
|
||||||
match summarize_article(
|
match summarize_article(
|
||||||
llm,
|
primary,
|
||||||
&pick.article.title,
|
&pick.article.title,
|
||||||
&pick.article.content_html,
|
&pick.article.content_html,
|
||||||
|
config.summary_input_tokens,
|
||||||
temperature,
|
temperature,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(summary) => {
|
Ok(summary) => Some(summary),
|
||||||
out.insert(pick.article.id, summary);
|
Err(error) => {
|
||||||
}
|
let Some(fallback) = fallback else {
|
||||||
Err(LlmError::BudgetExceeded { spent, limit }) => {
|
tracing::warn!(article_id = pick.article.id, %error, "summary failed; using excerpt");
|
||||||
tracing::error!(spent, limit, "COST CEILING HIT during stage C");
|
return None;
|
||||||
break;
|
};
|
||||||
}
|
tracing::warn!(article_id = pick.article.id, %error, "editor summary failed; retrying on bulk");
|
||||||
Err(e) => {
|
summarize_article(
|
||||||
tracing::warn!(
|
fallback,
|
||||||
article_id = pick.article.id,
|
&pick.article.title,
|
||||||
title = %pick.article.title,
|
&pick.article.content_html,
|
||||||
error = %e,
|
config.summary_input_tokens,
|
||||||
"summary failed; falling back to the article's own opening"
|
temperature,
|
||||||
);
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|fallback_error| {
|
||||||
|
tracing::warn!(article_id = pick.article.id, %fallback_error, "bulk summary failed; using excerpt");
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
summarized = out.len(),
|
|
||||||
picks = lineup.picks.len(),
|
|
||||||
"stage C summaries complete"
|
|
||||||
);
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
pub async fn summarize_all(
|
||||||
// Front page
|
llms: &Llms,
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// The single front-page + section-intro call (§3.6).
|
|
||||||
pub async fn front_page(
|
|
||||||
llm: &LlmClient,
|
|
||||||
lineup: &Lineup,
|
lineup: &Lineup,
|
||||||
summaries: &BTreeMap<ArticleId, String>,
|
config: &EditorialConfig,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
) -> Result<FrontPageResponse, LlmError> {
|
) -> BTreeMap<ArticleId, String> {
|
||||||
llm.meter.check_budget()?;
|
let (primary, fallback) = summary_clients(llms, config.summary_model);
|
||||||
let prompt = build_front_page_prompt(lineup, summaries);
|
stream::iter(lineup.picks.iter())
|
||||||
tracing::debug!(
|
.map(|pick| async move {
|
||||||
approx_tokens = super::approx_tokens(&prompt),
|
let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
|
||||||
"stage C front-page request"
|
(pick.article.id, summary)
|
||||||
);
|
})
|
||||||
let mut response: FrontPageResponse = llm.complete_json(&prompt, temperature).await?;
|
.buffer_unordered(SUMMARY_CONCURRENCY)
|
||||||
response.from_the_editor = response.from_the_editor.trim().to_string();
|
.filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
|
||||||
if response.from_the_editor.is_empty() {
|
.collect()
|
||||||
return Err(LlmError::EmptyResponse);
|
.await
|
||||||
}
|
|
||||||
// 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_brief_prompt(lineup: &Lineup, summaries: &BTreeMap<ArticleId, String>) -> String {
|
||||||
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 parts: Vec<String> = pick
|
};
|
||||||
.article
|
let brief = response.brief.trim().to_string();
|
||||||
.social
|
if brief.is_empty() {
|
||||||
.iter()
|
return Err(LlmError::EmptyResponse {
|
||||||
.map(|s| {
|
provider: primary.provider,
|
||||||
format!(
|
});
|
||||||
"{} {} pts/{} comments",
|
}
|
||||||
s.source.display_name(),
|
Ok(brief)
|
||||||
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() {
|
|
||||||
"source unknown"
|
|
||||||
} else {
|
|
||||||
lead.article.feed_title.trim()
|
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 {
|
||||||
|
Ok(text) => text_to_paragraphs(&text),
|
||||||
let (front_page_html, section_intros) =
|
Err(error) => {
|
||||||
match front_page(llm, lineup, &summaries, temperature).await {
|
tracing::warn!(%error, "brief failed; using fallback front page");
|
||||||
Ok(response) => (
|
fallback_front_page_html(lineup)
|
||||||
text_to_paragraphs(&response.from_the_editor),
|
|
||||||
response.section_intros,
|
|
||||||
),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e,
|
|
||||||
"front-page generation failed; using the plain front page");
|
|
||||||
(fallback_front_page_html(lineup), BTreeMap::new())
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 & 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
File diff suppressed because it is too large
Load Diff
+40
-25
@@ -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: 300–500 articles → `prefilter_keep` (§3.5).
|
/// Heuristic pre-filter: 300–500 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-45
@@ -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 = index + 1, of = batches, articles = article_count, approx_tokens = super::approx_tokens(&prompt), "stage A request");
|
||||||
tracing::debug!(
|
let items = match llm.complete(&prompt, temperature, true).await {
|
||||||
batch = n + 1,
|
Ok(raw) => parse_score_response(&raw),
|
||||||
of = batches,
|
Err(error) => {
|
||||||
articles = batch.len(),
|
tracing::warn!(batch = index + 1, of = batches, %error, "stage A batch failed; its articles stay unscored");
|
||||||
approx_tokens = super::approx_tokens(&prompt),
|
Vec::new()
|
||||||
"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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
(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 {
|
for item in items {
|
||||||
scores.insert(item.id, item.into());
|
scores.insert(item.id, item.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
let mut applied = 0;
|
||||||
tracing::warn!(batch = n + 1, of = batches, error = %e,
|
for candidate in candidates {
|
||||||
"stage A batch failed; its articles stay unscored");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut applied = 0usize;
|
|
||||||
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, §ions(), 0.3)
|
let scored = score_all(&llm, &mut candidates, 2, 4, §ions(), 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, §ions(), 0.3)
|
let scored = score_all(&llm, &mut candidates, 1, 4, §ions(), 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, §ions(), 0.3)
|
let scored = score_all(&llm, &mut candidates, 1, 4, §ions(), 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");
|
||||||
|
|||||||
+428
-273
@@ -1,13 +1,19 @@
|
|||||||
//! Stage B — lineup selection (spec §3.6).
|
//! The editor — lineup selection (plan §13).
|
||||||
//!
|
//!
|
||||||
//! One call: send the top ~40 candidates by [`ScoredArticle::combined_score`] with
|
//! One call on the editor client (Claude), falling back to the same prompt on the
|
||||||
//! their rationales; the model returns the final 15–25 picks, each with a section
|
//! bulk client (DeepSeek), then to [`select_without_llm`]. The shortlist is the
|
||||||
//! from the configured palette, an ordering, and exactly one `lead_story`.
|
//! top candidates by [`ScoredArticle::combined_score`] with their Stage A
|
||||||
|
//! rationales; the model returns picks, each with a section from the configured
|
||||||
|
//! palette, an ordering, exactly one `lead_story`, and a one-line `why` that is
|
||||||
|
//! printed under the headline.
|
||||||
//!
|
//!
|
||||||
//! The model's answer is treated as a proposal, never as gospel: sections are
|
//! The model's answer is treated as a proposal, never as gospel: sections are
|
||||||
//! validated against the palette, the lead is forced to be unique, auto-include
|
//! validated against the palette, the lead is forced to be unique, auto-include
|
||||||
//! feeds are re-inserted if they were dropped, and the size is clamped to
|
//! feeds are re-inserted if they were dropped, duplicate ids are dropped, and the
|
||||||
//! `target_article_count ± 5`.
|
//! size is trimmed to `hard_max`. There is **no minimum**: a nine-pick answer is
|
||||||
|
//! published as nine (the "top up" branch is gone). `--max-articles N` is a
|
||||||
|
//! ceiling: `hard_max = min(curation.max_article_count, N)` and
|
||||||
|
//! `soft_target = min(target_article_count, hard_max)`.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
@@ -15,19 +21,16 @@ use std::fmt::Write as _;
|
|||||||
use jiff::civil::Date;
|
use jiff::civil::Date;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::llm::{LlmClient, LlmError, strip_code_fence};
|
use super::llm::{LlmError, Llms, strip_code_fence};
|
||||||
use super::{prompt_text, truncate_words};
|
use super::{prompt_text, truncate_words};
|
||||||
use crate::types::{ArticleId, Lineup, Pick, ScoredArticle, SourceKind, WORLD_BRIEFING_SECTION};
|
use crate::types::{ArticleId, Lineup, Pick, ScoredArticle, WORLD_BRIEFING_SECTION};
|
||||||
|
|
||||||
/// How many candidates are offered to stage B (§3.6).
|
/// How many candidates are offered to the editor (§13; step 5 raises this to the diversified shortlist).
|
||||||
pub const SHORTLIST_SIZE: usize = 40;
|
pub const SHORTLIST_SIZE: usize = 40;
|
||||||
/// How far the final count may drift from `target_article_count` (§3.6: 15–25
|
/// Words of lead-in text shown per candidate in the editor prompt (§13).
|
||||||
/// around a default target of 20).
|
const BLURB_WORDS: usize = 60;
|
||||||
pub const TARGET_TOLERANCE: usize = 5;
|
|
||||||
/// Words of lead-in text shown per candidate in the stage-B prompt.
|
|
||||||
const BLURB_WORDS: usize = 45;
|
|
||||||
|
|
||||||
/// One element of the stage-B JSON response (§3.6).
|
/// One element of the editor's JSON response (§13).
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct SelectionItem {
|
pub struct SelectionItem {
|
||||||
pub id: ArticleId,
|
pub id: ArticleId,
|
||||||
@@ -36,6 +39,8 @@ pub struct SelectionItem {
|
|||||||
pub position: i64,
|
pub position: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lead_story: bool,
|
pub lead_story: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub why: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Envelope the model is asked to return.
|
/// Envelope the model is asked to return.
|
||||||
@@ -45,49 +50,59 @@ pub struct SelectionResponse {
|
|||||||
pub picks: Vec<SelectionItem>,
|
pub picks: Vec<SelectionItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The invariant instruction block for stage B (§3.6).
|
/// The invariant instruction block for the editor (§13), with `{soft_target}` and
|
||||||
pub const SELECT_INSTRUCTIONS: &str = "\
|
/// `{hard_max}` substituted at render time.
|
||||||
TASK: assemble today's issue of The Daily EPUB from the shortlist below.
|
pub const EDITOR_INSTRUCTIONS: &str = r#"TASK: assemble today's issue of The Daily EPUB from the shortlist below.
|
||||||
|
|
||||||
You are choosing what one specific reader — the profile in your system prompt — \
|
You are choosing what one specific reader — the profile, learned adjustments and
|
||||||
will actually read on an e-ink screen over breakfast. Build a paper, not a \
|
recent verdicts in your system prompt — will read on an e-ink screen over breakfast.
|
||||||
ranking: it should have a shape, a range of subjects, and a clear front page.
|
Build a paper, not a ranking: it should have a shape, a range of subjects, and a
|
||||||
|
clear front page.
|
||||||
|
|
||||||
RULES
|
RULES
|
||||||
1. Pick articles by id from the shortlist only. Never invent an id.
|
1. Pick by id from the shortlist only.
|
||||||
2. Give every pick a section from the palette below, spelled exactly as given.
|
2. Every pick gets a section from the palette, spelled exactly.
|
||||||
3. Number picks within each section from 1 upward, best first.
|
3. Number picks within a section from 1, best first.
|
||||||
4. Flag exactly one pick as \"lead_story\": true — the day's strongest, most \
|
4. Exactly one pick is "lead_story": true, in the first section you use.
|
||||||
substantial piece. It must sit in the first section you use.
|
5. Candidates flagged always-include MUST appear.
|
||||||
5. Any candidate marked \"always-include\" MUST appear; place it in \"From the \
|
6. Never select two articles that tell the same story.
|
||||||
Blogroll\" unless it clearly belongs elsewhere.
|
7. SIZE: aim for about {soft_target}; never more than {hard_max}; there is NO minimum.
|
||||||
6. Do not select two articles that tell the same story; keep the better one.
|
If only nine pieces deserve the reader's morning, publish nine. Never pad.
|
||||||
|
8. For every pick write "why": at most 14 words, specific to this article and this
|
||||||
|
reader, in the second person is fine ("the Postgres failover story you'd argue with").
|
||||||
|
It is printed under the headline.
|
||||||
|
|
||||||
EDITORIAL JUDGEMENT
|
EDITORIAL JUDGEMENT
|
||||||
- Favour depth over coverage: a slim issue of excellent pieces beats a full one \
|
- Depth over coverage. Drop anything you would not defend to him in person.
|
||||||
padded with filler. Drop anything you would not defend.
|
- Diversity is a feature: do not let one subject, one format, or one feed dominate,
|
||||||
- Mix the day up. Several long technical dives in a row is a bad breakfast; \
|
even if it is what he has been loving lately. A paper of eight AI posts is a failure
|
||||||
alternate register and subject across sections.
|
even if each is good. The "recent verdicts" tell you his taste; they do not tell you
|
||||||
- Keep the local and ultra-niche picks — a Boston story and a small-scene story \
|
to repeat it.
|
||||||
are worth more here than a third AI-industry item.
|
- Keep the local and ultra-niche picks when they are good; they are worth more here
|
||||||
- Score is evidence, not an instruction: overrule it when the paper reads better \
|
than a third industry item.
|
||||||
for it, and say so through your placement.
|
- Candidates flagged exploration were included on purpose to test the edges of his
|
||||||
- Leave a section out entirely rather than padding it; empty sections are dropped.
|
taste; take one if it is genuinely good, ignore it otherwise.
|
||||||
|
- Scores are evidence, not instructions. Overrule them when the paper reads better.
|
||||||
|
|
||||||
Return JSON exactly in this shape and nothing else:
|
Return JSON exactly:
|
||||||
{\"picks\": [{\"id\": 123, \"section\": \"Top Stories\", \"position\": 1, \
|
{"picks": [{"id": 123, "section": "Top Stories", "position": 1, "lead_story": true, "why": "…"}]}"#;
|
||||||
\"lead_story\": true}]}";
|
|
||||||
|
|
||||||
/// Render the stage-B user prompt (§3.6).
|
/// Render the editor's user prompt (§13).
|
||||||
pub fn build_prompt(shortlist: &[ScoredArticle], sections: &[String], target: usize) -> String {
|
pub fn build_prompt(
|
||||||
let (min, max) = size_bounds(target);
|
shortlist: &[ScoredArticle],
|
||||||
|
sections: &[String],
|
||||||
|
soft_target: usize,
|
||||||
|
hard_max: usize,
|
||||||
|
) -> String {
|
||||||
|
let instructions = EDITOR_INSTRUCTIONS
|
||||||
|
.replace("{soft_target}", &soft_target.to_string())
|
||||||
|
.replace("{hard_max}", &hard_max.to_string());
|
||||||
let mut prompt = String::with_capacity(2048 + shortlist.len() * 400);
|
let mut prompt = String::with_capacity(2048 + shortlist.len() * 400);
|
||||||
prompt.push_str(SELECT_INSTRUCTIONS);
|
prompt.push_str(&instructions);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
prompt,
|
prompt,
|
||||||
"\n\nSECTION PALETTE (exact strings, use only these): {}\n\
|
"\n\nSECTION PALETTE (exact strings, use only these): {}\n\
|
||||||
Reserved and unavailable: \"{WORLD_BRIEFING_SECTION}\" is compiled separately.\n\n\
|
Reserved and unavailable: \"{WORLD_BRIEFING_SECTION}\" is compiled separately.\n\n\
|
||||||
SIZE: choose {target} articles; never fewer than {min} and never more than {max}.\n\n\
|
|
||||||
SHORTLIST ({} candidates, best-ranked first)\n",
|
SHORTLIST ({} candidates, best-ranked first)\n",
|
||||||
sections.join(" | "),
|
sections.join(" | "),
|
||||||
shortlist.len()
|
shortlist.len()
|
||||||
@@ -124,7 +139,7 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
|
|||||||
Some(llm) => {
|
Some(llm) => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
block,
|
block,
|
||||||
"score: {:.1} ({}) — {}",
|
"score: {:.1} · {} — {}",
|
||||||
llm.score,
|
llm.score,
|
||||||
if llm.category.is_empty() {
|
if llm.category.is_empty() {
|
||||||
"uncategorized"
|
"uncategorized"
|
||||||
@@ -135,24 +150,19 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(block, "score: unscored");
|
||||||
block,
|
|
||||||
"score: unscored (heuristic rank {:.0}/100)",
|
|
||||||
candidate.prefilter_score
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = writeln!(
|
let mut flags = Vec::new();
|
||||||
block,
|
|
||||||
"signals: social {:.2}; via {}{}",
|
|
||||||
candidate.social_score,
|
|
||||||
source_kinds(candidate),
|
|
||||||
if candidate.auto_include {
|
if candidate.auto_include {
|
||||||
"; ALWAYS-INCLUDE"
|
flags.push("always-include");
|
||||||
} else {
|
}
|
||||||
""
|
if a.excerpt_only {
|
||||||
|
flags.push("excerpt only");
|
||||||
|
}
|
||||||
|
if !flags.is_empty() {
|
||||||
|
let _ = writeln!(block, "flags: {}", flags.join(" | "));
|
||||||
}
|
}
|
||||||
);
|
|
||||||
let blurb = truncate_words(&prompt_text(&a.content_html), BLURB_WORDS);
|
let blurb = truncate_words(&prompt_text(&a.content_html), BLURB_WORDS);
|
||||||
if !blurb.is_empty() {
|
if !blurb.is_empty() {
|
||||||
let _ = writeln!(block, "opening: {blurb}");
|
let _ = writeln!(block, "opening: {blurb}");
|
||||||
@@ -160,37 +170,6 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
|
|||||||
block
|
block
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_kinds(candidate: &ScoredArticle) -> String {
|
|
||||||
let mut kinds: Vec<&str> = candidate
|
|
||||||
.article
|
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.map(|s| match s.kind {
|
|
||||||
SourceKind::Scour => "scour",
|
|
||||||
SourceKind::HnFrontpage => "hn_frontpage",
|
|
||||||
SourceKind::Lobsters => "lobsters",
|
|
||||||
SourceKind::Reddit => "reddit",
|
|
||||||
SourceKind::Feed => "feed",
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
kinds.sort_unstable();
|
|
||||||
kinds.dedup();
|
|
||||||
if kinds.is_empty() {
|
|
||||||
"feed".into()
|
|
||||||
} else {
|
|
||||||
kinds.join("+")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `target ± TARGET_TOLERANCE`, floored at one article (§3.6).
|
|
||||||
pub fn size_bounds(target: usize) -> (usize, usize) {
|
|
||||||
let target = target.max(1);
|
|
||||||
(
|
|
||||||
target.saturating_sub(TARGET_TOLERANCE).max(1),
|
|
||||||
target + TARGET_TOLERANCE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Section validation (§3.6: the model may only use the configured palette)
|
// Section validation (§3.6: the model may only use the configured palette)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -244,7 +223,7 @@ fn words_of(s: &str) -> HashSet<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Section guess from feed metadata, used by `--skip-llm` and by top-ups (§3.6).
|
/// Section guess from feed metadata, used by [`select_without_llm`] (§3.6).
|
||||||
pub fn heuristic_section(candidate: &ScoredArticle, sections: &[String]) -> String {
|
pub fn heuristic_section(candidate: &ScoredArticle, sections: &[String]) -> String {
|
||||||
if candidate.auto_include {
|
if candidate.auto_include {
|
||||||
return resolve_section("From the Blogroll", sections);
|
return resolve_section("From the Blogroll", sections);
|
||||||
@@ -346,7 +325,7 @@ pub fn heuristic_section(candidate: &ScoredArticle, sections: &[String]) -> Stri
|
|||||||
/// Keys the model might wrap the array in.
|
/// Keys the model might wrap the array in.
|
||||||
const ARRAY_KEYS: &[&str] = &["picks", "lineup", "articles", "selection", "items"];
|
const ARRAY_KEYS: &[&str] = &["picks", "lineup", "articles", "selection", "items"];
|
||||||
|
|
||||||
/// Lenient parse of the stage-B response (§3.6).
|
/// Lenient parse of the editor response (§13). `why` is capped at 14 words.
|
||||||
pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
|
pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
|
||||||
let cleaned = strip_code_fence(raw);
|
let cleaned = strip_code_fence(raw);
|
||||||
let value: serde_json::Value = match serde_json::from_str(cleaned) {
|
let value: serde_json::Value = match serde_json::from_str(cleaned) {
|
||||||
@@ -405,6 +384,16 @@ pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
|
|||||||
.or_else(|| v.as_str().map(|s| s.eq_ignore_ascii_case("true")))
|
.or_else(|| v.as_str().map(|s| s.eq_ignore_ascii_case("true")))
|
||||||
})
|
})
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
|
why: obj
|
||||||
|
.get("why")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(|why| {
|
||||||
|
why.split_whitespace()
|
||||||
|
.take(14)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
})
|
||||||
|
.filter(|why| !why.is_empty()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -414,96 +403,131 @@ pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
|
|||||||
// Stage driver
|
// Stage driver
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Ask the model for the day's lineup, validating that every section is from the
|
/// Ask the editor for the day's lineup (§13).
|
||||||
/// configured palette and exactly one pick is the lead (§3.6).
|
///
|
||||||
|
/// Editor first, then the same prompt on the bulk client, then
|
||||||
|
/// [`select_without_llm`]; never an error unless a mock is misconfigured.
|
||||||
pub async fn select(
|
pub async fn select(
|
||||||
llm: &LlmClient,
|
llms: &Llms,
|
||||||
candidates: Vec<ScoredArticle>,
|
candidates: Vec<ScoredArticle>,
|
||||||
sections: &[String],
|
sections: &[String],
|
||||||
target: usize,
|
soft_target: usize,
|
||||||
|
hard_max: usize,
|
||||||
date: Date,
|
date: Date,
|
||||||
) -> Result<Lineup, LlmError> {
|
) -> Result<Lineup, LlmError> {
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
tracing::warn!("stage B had no candidates");
|
|
||||||
return Ok(Lineup {
|
return Ok(Lineup {
|
||||||
date,
|
date,
|
||||||
picks: Vec::new(),
|
picks: Vec::new(),
|
||||||
section_order: Vec::new(),
|
section_order: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Err(e) = llm.meter.check_budget() {
|
let Some(primary) = llms.editor_or_bulk() else {
|
||||||
tracing::error!(error = %e,
|
return Ok(select_without_llm(
|
||||||
"COST CEILING HIT before stage B selection — falling back to heuristic ranking");
|
candidates,
|
||||||
return Ok(select_without_llm(candidates, sections, target, date));
|
sections,
|
||||||
}
|
soft_target,
|
||||||
|
hard_max,
|
||||||
let shortlist = shortlist(&candidates, target);
|
date,
|
||||||
let prompt = build_prompt(&shortlist, sections, target);
|
));
|
||||||
|
};
|
||||||
|
let shortlist = shortlist(&candidates, hard_max);
|
||||||
|
let prompt = build_prompt(&shortlist, sections, soft_target, hard_max);
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
shortlist = shortlist.len(),
|
shortlist = shortlist.len(),
|
||||||
approx_tokens = super::approx_tokens(&prompt),
|
approx_tokens = super::approx_tokens(&prompt),
|
||||||
"stage B request"
|
"editor request"
|
||||||
);
|
);
|
||||||
|
|
||||||
let raw = llm.complete(&prompt, llm_temperature(), true).await?;
|
let raw = match complete_with_fallback(llms, primary, &prompt).await {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, "editor and bulk fallback both failed; selecting heuristically");
|
||||||
|
return Ok(select_without_llm(
|
||||||
|
candidates,
|
||||||
|
sections,
|
||||||
|
soft_target,
|
||||||
|
hard_max,
|
||||||
|
date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
let items = parse_selection_response(&raw);
|
let items = parse_selection_response(&raw);
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
tracing::error!("stage B returned no usable picks; falling back to heuristic ranking");
|
tracing::error!("editor returned no usable picks; falling back to heuristic ranking");
|
||||||
return Ok(select_without_llm(candidates, sections, target, date));
|
return Ok(select_without_llm(
|
||||||
|
candidates,
|
||||||
|
sections,
|
||||||
|
soft_target,
|
||||||
|
hard_max,
|
||||||
|
date,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let by_id: HashMap<ArticleId, &ScoredArticle> =
|
let by_id: HashMap<ArticleId, &ScoredArticle> =
|
||||||
candidates.iter().map(|c| (c.article.id, c)).collect();
|
candidates.iter().map(|c| (c.article.id, c)).collect();
|
||||||
let mut chosen: Vec<(SelectionItem, ScoredArticle)> = Vec::with_capacity(items.len());
|
let mut chosen = Vec::with_capacity(items.len());
|
||||||
let mut seen: HashSet<ArticleId> = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
if !seen.insert(item.id) {
|
if !seen.insert(item.id) {
|
||||||
tracing::warn!(id = item.id, "stage B picked the same article twice");
|
tracing::warn!(id = item.id, "editor picked the same article twice");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match by_id.get(&item.id) {
|
match by_id.get(&item.id) {
|
||||||
Some(candidate) => chosen.push((item, (*candidate).clone())),
|
Some(candidate) => chosen.push((item, (*candidate).clone())),
|
||||||
None => tracing::warn!(id = item.id, "stage B invented an id that was not offered"),
|
None => tracing::warn!(id = item.id, "editor invented an id that was not offered"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-include feeds can never be dropped (§3.5).
|
|
||||||
for candidate in &candidates {
|
for candidate in &candidates {
|
||||||
if candidate.auto_include && seen.insert(candidate.article.id) {
|
if candidate.auto_include && seen.insert(candidate.article.id) {
|
||||||
tracing::info!(
|
|
||||||
id = candidate.article.id,
|
|
||||||
title = %candidate.article.title,
|
|
||||||
"re-inserting an always-include article the model dropped"
|
|
||||||
);
|
|
||||||
chosen.push((
|
chosen.push((
|
||||||
SelectionItem {
|
SelectionItem {
|
||||||
id: candidate.article.id,
|
id: candidate.article.id,
|
||||||
section: "From the Blogroll".into(),
|
section: "From the Blogroll".into(),
|
||||||
position: i64::MAX,
|
position: i64::MAX,
|
||||||
lead_story: false,
|
lead_story: false,
|
||||||
|
why: Some("A standing source you always want represented".into()),
|
||||||
},
|
},
|
||||||
candidate.clone(),
|
candidate.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(assemble(chosen, sections, hard_max, date))
|
||||||
|
}
|
||||||
|
|
||||||
let lineup = assemble(chosen, &candidates, sections, target, date);
|
/// The editor runs at the scoring temperature: this is a judgement call, not
|
||||||
tracing::info!(
|
/// prose. The Anthropic backend ignores it (§4.2).
|
||||||
picks = lineup.picks.len(),
|
const EDITOR_TEMPERATURE: f32 = 0.4;
|
||||||
sections = lineup.section_order.len(),
|
|
||||||
lead = lineup.lead().map(|p| p.article.id),
|
/// One attempt on `primary`; on any error (refusal, budget, API) the same prompt
|
||||||
"stage B lineup ready"
|
/// goes to the bulk client when that is a different provider (§13, §17).
|
||||||
|
async fn complete_with_fallback(
|
||||||
|
llms: &Llms,
|
||||||
|
primary: &super::llm::LlmClient,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<String, LlmError> {
|
||||||
|
match primary.complete(prompt, EDITOR_TEMPERATURE, true).await {
|
||||||
|
Ok(raw) => Ok(raw),
|
||||||
|
Err(primary_error) => {
|
||||||
|
let fallback = llms
|
||||||
|
.bulk
|
||||||
|
.as_ref()
|
||||||
|
.filter(|bulk| primary.provider != bulk.provider);
|
||||||
|
let Some(fallback) = fallback else {
|
||||||
|
return Err(primary_error);
|
||||||
|
};
|
||||||
|
tracing::warn!(
|
||||||
|
error = %primary_error,
|
||||||
|
provider = primary.provider,
|
||||||
|
"editor failed; retrying the same prompt on bulk"
|
||||||
);
|
);
|
||||||
Ok(lineup)
|
fallback.complete(prompt, EDITOR_TEMPERATURE, true).await
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage B runs at the scoring temperature: this is a judgement call, not prose.
|
/// Top [`SHORTLIST_SIZE`] (or `2 × hard_max`) candidates by combined score,
|
||||||
fn llm_temperature() -> f32 {
|
/// always including the auto-includes.
|
||||||
0.4
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Top [`SHORTLIST_SIZE`] candidates by combined score, always including the
|
|
||||||
/// auto-includes (§3.6).
|
|
||||||
fn shortlist(candidates: &[ScoredArticle], target: usize) -> Vec<ScoredArticle> {
|
fn shortlist(candidates: &[ScoredArticle], target: usize) -> Vec<ScoredArticle> {
|
||||||
let mut ranked: Vec<ScoredArticle> = candidates.to_vec();
|
let mut ranked: Vec<ScoredArticle> = candidates.to_vec();
|
||||||
sort_by_combined(&mut ranked);
|
sort_by_combined(&mut ranked);
|
||||||
@@ -526,19 +550,16 @@ fn sort_by_combined(candidates: &mut [ScoredArticle]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turn validated picks into a [`Lineup`]: clamp the size, force a single lead,
|
/// Turn validated picks into a [`Lineup`]: trim to `hard_max`, force a single
|
||||||
/// order the sections and renumber positions (§3.6).
|
/// lead, order the sections and renumber positions (§13). No minimum size.
|
||||||
fn assemble(
|
fn assemble(
|
||||||
mut chosen: Vec<(SelectionItem, ScoredArticle)>,
|
mut chosen: Vec<(SelectionItem, ScoredArticle)>,
|
||||||
all: &[ScoredArticle],
|
|
||||||
sections: &[String],
|
sections: &[String],
|
||||||
target: usize,
|
hard_max: usize,
|
||||||
date: Date,
|
date: Date,
|
||||||
) -> Lineup {
|
) -> Lineup {
|
||||||
let (min, max) = size_bounds(target);
|
|
||||||
|
|
||||||
// Too many: drop the weakest non-auto-include picks.
|
// Too many: drop the weakest non-auto-include picks.
|
||||||
if chosen.len() > max {
|
if chosen.len() > hard_max {
|
||||||
chosen.sort_by(|a, b| {
|
chosen.sort_by(|a, b| {
|
||||||
b.1.auto_include.cmp(&a.1.auto_include).then_with(|| {
|
b.1.auto_include.cmp(&a.1.auto_include).then_with(|| {
|
||||||
b.1.combined_score()
|
b.1.combined_score()
|
||||||
@@ -546,35 +567,9 @@ fn assemble(
|
|||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let dropped = chosen.len() - max;
|
let dropped = chosen.len() - hard_max;
|
||||||
chosen.truncate(max);
|
chosen.truncate(hard_max);
|
||||||
tracing::info!(dropped, max, "trimmed the lineup to the size ceiling");
|
tracing::info!(dropped, hard_max, "trimmed the lineup to the size ceiling");
|
||||||
}
|
|
||||||
|
|
||||||
// Too few: top up from the best unpicked candidates.
|
|
||||||
if chosen.len() < min {
|
|
||||||
let taken: HashSet<ArticleId> = chosen.iter().map(|(i, _)| i.id).collect();
|
|
||||||
let mut rest: Vec<ScoredArticle> = all
|
|
||||||
.iter()
|
|
||||||
.filter(|c| !taken.contains(&c.article.id))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
sort_by_combined(&mut rest);
|
|
||||||
let wanted = min - chosen.len();
|
|
||||||
let added = rest.len().min(wanted);
|
|
||||||
for candidate in rest.into_iter().take(wanted) {
|
|
||||||
let section = heuristic_section(&candidate, sections);
|
|
||||||
chosen.push((
|
|
||||||
SelectionItem {
|
|
||||||
id: candidate.article.id,
|
|
||||||
section,
|
|
||||||
position: i64::MAX,
|
|
||||||
lead_story: false,
|
|
||||||
},
|
|
||||||
candidate,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
tracing::info!(added, min, "topped the lineup up to the size floor");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize sections and pick the section order.
|
// Normalize sections and pick the section order.
|
||||||
@@ -632,6 +627,7 @@ fn assemble(
|
|||||||
section: item.section,
|
section: item.section,
|
||||||
position: *position,
|
position: *position,
|
||||||
is_lead: Some(item.id) == lead_id,
|
is_lead: Some(item.id) == lead_id,
|
||||||
|
why: item.why,
|
||||||
summary: None,
|
summary: None,
|
||||||
llm: candidate.llm.clone(),
|
llm: candidate.llm.clone(),
|
||||||
discussion: None,
|
discussion: None,
|
||||||
@@ -647,52 +643,46 @@ fn assemble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--skip-llm` fallback: take the top `target` by prefilter score and bucket them
|
/// Heuristic fallback (`--skip-llm`, no provider, or both providers failed): the
|
||||||
/// into sections by feed category (notes §6).
|
/// top `soft_target` by prefilter score plus the auto-includes, bucketed into
|
||||||
|
/// sections by feed category, trimmed to `hard_max` (notes §6).
|
||||||
pub fn select_without_llm(
|
pub fn select_without_llm(
|
||||||
candidates: Vec<ScoredArticle>,
|
candidates: Vec<ScoredArticle>,
|
||||||
sections: &[String],
|
sections: &[String],
|
||||||
target: usize,
|
soft_target: usize,
|
||||||
|
hard_max: usize,
|
||||||
date: Date,
|
date: Date,
|
||||||
) -> Lineup {
|
) -> Lineup {
|
||||||
let mut ranked = candidates.clone();
|
let mut ranked = candidates;
|
||||||
super::prefilter::sort_by_prefilter(&mut ranked);
|
super::prefilter::sort_by_prefilter(&mut ranked);
|
||||||
|
let mut chosen = Vec::new();
|
||||||
let mut chosen: Vec<(SelectionItem, ScoredArticle)> = Vec::new();
|
let mut seen = HashSet::new();
|
||||||
let mut seen: HashSet<ArticleId> = HashSet::new();
|
for candidate in ranked {
|
||||||
for candidate in ranked.into_iter() {
|
if chosen.len() >= soft_target && !candidate.auto_include {
|
||||||
let auto = candidate.auto_include;
|
|
||||||
if chosen.len() >= target && !auto {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !seen.insert(candidate.article.id) {
|
if !seen.insert(candidate.article.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let section = heuristic_section(&candidate, sections);
|
|
||||||
chosen.push((
|
chosen.push((
|
||||||
SelectionItem {
|
SelectionItem {
|
||||||
id: candidate.article.id,
|
id: candidate.article.id,
|
||||||
section,
|
section: heuristic_section(&candidate, sections),
|
||||||
position: chosen.len() as i64 + 1,
|
position: chosen.len() as i64 + 1,
|
||||||
lead_story: false,
|
lead_story: false,
|
||||||
|
why: None,
|
||||||
},
|
},
|
||||||
candidate,
|
candidate,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let lineup = assemble(chosen, &candidates, sections, target, date);
|
assemble(chosen, sections, hard_max, date)
|
||||||
tracing::info!(
|
|
||||||
picks = lineup.picks.len(),
|
|
||||||
sections = lineup.section_order.len(),
|
|
||||||
"skip-llm lineup ready"
|
|
||||||
);
|
|
||||||
lineup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{CurationConfig, DeepseekConfig};
|
use crate::config::{AnthropicConfig, CurationConfig, DeepseekConfig};
|
||||||
use crate::curate::llm::{MockBackend, UsageMeter};
|
use crate::curate::llm::{ChatBackend, LlmClient, MockBackend, PriceTable, UsageMeter};
|
||||||
use crate::curate::prefilter::tests::article;
|
use crate::curate::prefilter::tests::article;
|
||||||
use crate::types::{LlmScore, TokenUsage};
|
use crate::types::{LlmScore, TokenUsage};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -738,6 +728,49 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
|
||||||
|
let prices = if provider == "anthropic" {
|
||||||
|
PriceTable::anthropic(&AnthropicConfig::default())
|
||||||
|
} else {
|
||||||
|
PriceTable::deepseek(&DeepseekConfig::default())
|
||||||
|
};
|
||||||
|
LlmClient::with_backend_options(
|
||||||
|
provider,
|
||||||
|
"model",
|
||||||
|
"SYSTEM".into(),
|
||||||
|
None,
|
||||||
|
UsageMeter::with_prices(prices, limit),
|
||||||
|
backend as Arc<dyn ChatBackend>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DeepSeek only — the shape of a run without an Anthropic key.
|
||||||
|
fn bulk_only(backend: Arc<MockBackend>) -> Llms {
|
||||||
|
Llms {
|
||||||
|
bulk: Some(mock("deepseek", backend, 2.0)),
|
||||||
|
editor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_and_bulk(editor: Arc<MockBackend>, bulk: Arc<MockBackend>) -> Llms {
|
||||||
|
Llms {
|
||||||
|
bulk: Some(mock("deepseek", bulk, 2.0)),
|
||||||
|
editor: Some(mock("anthropic", editor, 3.0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn picks_json(n: i64) -> String {
|
||||||
|
let picks: Vec<String> = (1..=n)
|
||||||
|
.map(|i| {
|
||||||
|
format!(
|
||||||
|
r#"{{"id":{i},"section":"Top Stories","position":{i},"lead_story":{},"why":"pick {i} because"}}"#,
|
||||||
|
i == 1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(r#"{{"picks":[{}]}}"#, picks.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn section_resolution_maps_onto_the_palette() {
|
fn section_resolution_maps_onto_the_palette() {
|
||||||
let s = sections();
|
let s = sections();
|
||||||
@@ -786,33 +819,64 @@ mod tests {
|
|||||||
assert!(items[0].lead_story);
|
assert!(items[0].lead_story);
|
||||||
assert_eq!(items[0].section, "Top Stories");
|
assert_eq!(items[0].section, "Top Stories");
|
||||||
assert_eq!(items.iter().filter(|i| i.lead_story).count(), 1);
|
assert_eq!(items.iter().filter(|i| i.lead_story).count(), 1);
|
||||||
|
assert!(items[0].why.as_deref().is_some_and(|w| !w.is_empty()));
|
||||||
// Junk entries in the fixture are dropped, not fatal.
|
// Junk entries in the fixture are dropped, not fatal.
|
||||||
assert!(items.iter().all(|i| i.id != 0));
|
assert!(items.iter().all(|i| i.id != 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn size_bounds_follow_the_spec() {
|
fn why_lines_are_optional_and_capped_at_fourteen_words() {
|
||||||
assert_eq!(size_bounds(20), (15, 25));
|
let long = (1..=30)
|
||||||
assert_eq!(size_bounds(6), (1, 11));
|
.map(|i| format!("w{i}"))
|
||||||
assert_eq!(size_bounds(0), (1, 6));
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let items = parse_selection_response(&format!(
|
||||||
|
r#"{{"picks":[{{"id":1,"section":"Top Stories","why":"{long}"}},
|
||||||
|
{{"id":2,"section":"Top Stories","why":" "}},
|
||||||
|
{{"id":3,"section":"Top Stories"}}]}}"#
|
||||||
|
));
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
items[0]
|
||||||
|
.why
|
||||||
|
.as_deref()
|
||||||
|
.map(|w| w.split_whitespace().count()),
|
||||||
|
Some(14)
|
||||||
|
);
|
||||||
|
assert!(items[1].why.is_none());
|
||||||
|
assert!(items[2].why.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prompt_substitutes_the_size_targets() {
|
||||||
|
let prompt = build_prompt(&candidates(3), §ions(), 6, 11);
|
||||||
|
assert!(prompt.contains("aim for about 6; never more than 11; there is NO minimum"));
|
||||||
|
assert!(!prompt.contains("{soft_target}") && !prompt.contains("{hard_max}"));
|
||||||
|
assert!(prompt.contains("--- id: 1\n"));
|
||||||
|
assert!(prompt.contains("score: 9.9 · Tech & Engineering — solid"));
|
||||||
|
assert!(prompt.contains("opening: "));
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("combined"),
|
||||||
|
"the numeric blend stays out of the prompt"
|
||||||
|
);
|
||||||
|
let mut flagged = candidates(1);
|
||||||
|
flagged[0].auto_include = true;
|
||||||
|
flagged[0].article.excerpt_only = true;
|
||||||
|
let prompt = build_prompt(&flagged, §ions(), 6, 11);
|
||||||
|
assert!(prompt.contains("flags: always-include | excerpt only"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn selection_builds_a_valid_lineup() {
|
async fn selection_builds_a_valid_lineup() {
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
backend.push(LINEUP_FIXTURE, TokenUsage::default());
|
backend.push(LINEUP_FIXTURE, TokenUsage::default());
|
||||||
let llm = LlmClient::with_backend(
|
let llms = bulk_only(Arc::clone(&backend));
|
||||||
"deepseek-v4-flash",
|
|
||||||
"SYSTEM".into(),
|
|
||||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
|
||||||
backend.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ids 101..=112 so the fixture's picks resolve.
|
// ids 101..=112 so the fixture's picks resolve.
|
||||||
let pool: Vec<ScoredArticle> = (101..=112)
|
let pool: Vec<ScoredArticle> = (101..=112)
|
||||||
.map(|i| candidate(i, &format!("Article {i}"), 800, 7.0))
|
.map(|i| candidate(i, &format!("Article {i}"), 800, 7.0))
|
||||||
.collect();
|
.collect();
|
||||||
let lineup = select(&llm, pool, §ions(), 6, date())
|
let lineup = select(&llms, pool, §ions(), 6, 11, date())
|
||||||
.await
|
.await
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
|
|
||||||
@@ -845,9 +909,32 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// The prompt carried the shortlist and the palette.
|
// The prompt carried the shortlist and the palette.
|
||||||
let prompt = &backend.prompts()[0].user;
|
let prompt = &backend.prompts()[0].user;
|
||||||
assert!(prompt.starts_with(SELECT_INSTRUCTIONS));
|
assert!(prompt.starts_with("TASK: assemble today's issue of The Daily EPUB"));
|
||||||
assert!(prompt.contains("--- id: 101"));
|
assert!(prompt.contains("--- id: 101"));
|
||||||
assert!(prompt.contains("never fewer than 1 and never more than 11"));
|
assert!(prompt.contains("aim for about 6; never more than 11"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn why_lines_land_on_picks() {
|
||||||
|
let backend = Arc::new(MockBackend::new());
|
||||||
|
backend.push(picks_json(3), TokenUsage::default());
|
||||||
|
let lineup = select(
|
||||||
|
&bulk_only(backend),
|
||||||
|
candidates(5),
|
||||||
|
§ions(),
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
date(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("selection");
|
||||||
|
assert_eq!(lineup.picks.len(), 3);
|
||||||
|
for pick in &lineup.picks {
|
||||||
|
assert_eq!(
|
||||||
|
pick.why.as_deref(),
|
||||||
|
Some(format!("pick {} because", pick.article.id).as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -856,19 +943,22 @@ mod tests {
|
|||||||
backend.push(
|
backend.push(
|
||||||
r#"{"picks":[{"id":9999,"section":"Top Stories","position":1,"lead_story":true},
|
r#"{"picks":[{"id":9999,"section":"Top Stories","position":1,"lead_story":true},
|
||||||
{"id":1,"section":"Sportsball","position":2},
|
{"id":1,"section":"Sportsball","position":2},
|
||||||
{"id":2,"section":"Niche Corner","position":1}]}"#,
|
{"id":2,"section":"Niche Corner","position":1},
|
||||||
|
{"id":2,"section":"Niche Corner","position":2}]}"#,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
);
|
);
|
||||||
let llm = LlmClient::with_backend(
|
let lineup = select(
|
||||||
"deepseek-v4-flash",
|
&bulk_only(backend),
|
||||||
"SYSTEM".into(),
|
candidates(6),
|
||||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
§ions(),
|
||||||
backend,
|
2,
|
||||||
);
|
7,
|
||||||
let lineup = select(&llm, candidates(6), §ions(), 2, date())
|
date(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
assert!(lineup.picks.iter().all(|p| p.article.id != 9999));
|
assert!(lineup.picks.iter().all(|p| p.article.id != 9999));
|
||||||
|
assert_eq!(lineup.picks.len(), 2, "the duplicate id was dropped");
|
||||||
assert_eq!(lineup.picks.iter().filter(|p| p.is_lead).count(), 1);
|
assert_eq!(lineup.picks.iter().filter(|p| p.is_lead).count(), 1);
|
||||||
for pick in &lineup.picks {
|
for pick in &lineup.picks {
|
||||||
assert!(sections().contains(&pick.section));
|
assert!(sections().contains(&pick.section));
|
||||||
@@ -876,93 +966,148 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn oversized_and_undersized_answers_are_clamped() {
|
async fn a_nine_pick_answer_is_published_as_nine() {
|
||||||
// Undersized: the model returns one pick but the floor is 5.
|
// Soft target 20, ceiling 28, thirty candidates: the model picks nine.
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
backend.push(
|
backend.push(picks_json(9), TokenUsage::default());
|
||||||
r#"{"picks":[{"id":1,"section":"Top Stories","position":1,"lead_story":true}]}"#,
|
let lineup = select(
|
||||||
TokenUsage::default(),
|
&bulk_only(backend),
|
||||||
);
|
candidates(30),
|
||||||
let llm = LlmClient::with_backend(
|
§ions(),
|
||||||
"deepseek-v4-flash",
|
20,
|
||||||
"SYSTEM".into(),
|
28,
|
||||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
date(),
|
||||||
backend,
|
)
|
||||||
);
|
|
||||||
let lineup = select(&llm, candidates(30), §ions(), 10, date())
|
|
||||||
.await
|
.await
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
assert!(lineup.picks.len() >= 5, "{}", lineup.picks.len());
|
assert_eq!(lineup.picks.len(), 9, "no top-up, no padding");
|
||||||
|
}
|
||||||
|
|
||||||
// Oversized: 30 picks against a target of 6 (ceiling 11).
|
#[tokio::test]
|
||||||
let picks: Vec<String> = (1..=30)
|
async fn hard_max_trims_oversized_answers_by_ranking() {
|
||||||
.map(|i| format!(r#"{{"id":{i},"section":"Top Stories","position":{i}}}"#))
|
|
||||||
.collect();
|
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
backend.push(
|
backend.push(picks_json(30), TokenUsage::default());
|
||||||
format!(r#"{{"picks":[{}]}}"#, picks.join(",")),
|
let lineup = select(
|
||||||
TokenUsage::default(),
|
&bulk_only(backend),
|
||||||
);
|
candidates(30),
|
||||||
let llm = LlmClient::with_backend(
|
§ions(),
|
||||||
"deepseek-v4-flash",
|
6,
|
||||||
"SYSTEM".into(),
|
11,
|
||||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
date(),
|
||||||
backend,
|
)
|
||||||
);
|
|
||||||
let lineup = select(&llm, candidates(30), §ions(), 6, date())
|
|
||||||
.await
|
.await
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
assert_eq!(lineup.picks.len(), 11);
|
assert_eq!(lineup.picks.len(), 11);
|
||||||
|
// The strongest by today's ranking key survive: ids 1..=11 score highest.
|
||||||
|
let mut ids: Vec<ArticleId> = lineup.picks.iter().map(|p| p.article.id).collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
assert_eq!(ids, (1..=11).collect::<Vec<_>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn always_include_articles_are_reinserted() {
|
async fn always_include_articles_are_reinserted_and_survive_the_trim() {
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
backend.push(
|
backend.push(picks_json(4), TokenUsage::default());
|
||||||
r#"{"picks":[{"id":1,"section":"Top Stories","position":1,"lead_story":true}]}"#,
|
let mut pool = candidates(30);
|
||||||
TokenUsage::default(),
|
pool[29].auto_include = true; // id 30, the weakest by score
|
||||||
);
|
let lineup = select(&bulk_only(backend), pool, §ions(), 2, 4, date())
|
||||||
let llm = LlmClient::with_backend(
|
|
||||||
"deepseek-v4-flash",
|
|
||||||
"SYSTEM".into(),
|
|
||||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
|
||||||
backend,
|
|
||||||
);
|
|
||||||
let mut pool = candidates(3);
|
|
||||||
pool[2].auto_include = true;
|
|
||||||
let lineup = select(&llm, pool, §ions(), 1, date())
|
|
||||||
.await
|
.await
|
||||||
.expect("selection");
|
.expect("selection");
|
||||||
let ids: Vec<ArticleId> = lineup.picks.iter().map(|p| p.article.id).collect();
|
let ids: Vec<ArticleId> = lineup.picks.iter().map(|p| p.article.id).collect();
|
||||||
assert!(ids.contains(&3), "auto-include must survive: {ids:?}");
|
assert!(ids.contains(&30), "auto-include must survive: {ids:?}");
|
||||||
assert_eq!(
|
assert_eq!(lineup.picks.len(), 4, "the ceiling still holds");
|
||||||
lineup
|
let reinserted = lineup
|
||||||
.picks
|
.picks
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.article.id == 3)
|
.find(|p| p.article.id == 30)
|
||||||
.map(|p| p.section.as_str()),
|
.expect("reinserted");
|
||||||
Some("From the Blogroll")
|
assert_eq!(reinserted.section, "From the Blogroll");
|
||||||
);
|
assert!(reinserted.why.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_tripped_budget_falls_back_without_calling_the_model() {
|
async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() {
|
||||||
|
let editor = Arc::new(MockBackend::new());
|
||||||
|
editor.push_llm_error(LlmError::Refusal {
|
||||||
|
provider: "anthropic",
|
||||||
|
});
|
||||||
|
let bulk = Arc::new(MockBackend::new());
|
||||||
|
bulk.push(picks_json(5), TokenUsage::default());
|
||||||
|
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
|
||||||
|
|
||||||
|
let lineup = select(&llms, candidates(10), §ions(), 5, 10, date())
|
||||||
|
.await
|
||||||
|
.expect("selection");
|
||||||
|
assert_eq!(lineup.picks.len(), 5);
|
||||||
|
assert_eq!(editor.calls(), 1);
|
||||||
|
assert_eq!(bulk.calls(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
editor.prompts()[0].user,
|
||||||
|
bulk.prompts()[0].user,
|
||||||
|
"the bulk client gets the identical prompt"
|
||||||
|
);
|
||||||
|
assert_eq!(editor.prompts()[0].system, bulk.prompts()[0].system);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_error_on_both_providers_selects_heuristically() {
|
||||||
|
let editor = Arc::new(MockBackend::new());
|
||||||
|
editor.push_error("500 opus is down");
|
||||||
|
let bulk = Arc::new(MockBackend::new());
|
||||||
|
bulk.push_error("500 deepseek is down too");
|
||||||
|
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
|
||||||
|
let lineup = select(&llms, candidates(10), §ions(), 4, 10, date())
|
||||||
|
.await
|
||||||
|
.expect("heuristic fallback");
|
||||||
|
assert_eq!(lineup.picks.len(), 4);
|
||||||
|
assert_eq!(editor.calls(), 1);
|
||||||
|
assert_eq!(bulk.calls(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_tripped_editor_budget_goes_straight_to_bulk() {
|
||||||
|
let editor = Arc::new(MockBackend::new());
|
||||||
|
let bulk = Arc::new(MockBackend::new());
|
||||||
|
bulk.push(picks_json(3), TokenUsage::default());
|
||||||
|
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
|
||||||
|
llms.editor
|
||||||
|
.as_ref()
|
||||||
|
.expect("editor")
|
||||||
|
.meter
|
||||||
|
.preload_cost(10.0);
|
||||||
|
let lineup = select(&llms, candidates(10), §ions(), 3, 10, date())
|
||||||
|
.await
|
||||||
|
.expect("selection");
|
||||||
|
assert_eq!(lineup.picks.len(), 3);
|
||||||
|
assert_eq!(editor.calls(), 0, "a tripped editor is never called");
|
||||||
|
assert_eq!(bulk.calls(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_tripped_bulk_budget_falls_back_without_calling_the_model() {
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
let meter = UsageMeter::new(&DeepseekConfig::default(), 0.001);
|
let llms = bulk_only(Arc::clone(&backend));
|
||||||
meter.record(TokenUsage {
|
llms.bulk.as_ref().expect("bulk").meter.record(TokenUsage {
|
||||||
input_tokens: 1_000_000,
|
input_tokens: 100_000_000,
|
||||||
cached_tokens: 0,
|
cached_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
});
|
});
|
||||||
let llm =
|
let lineup = select(&llms, candidates(20), §ions(), 6, 28, date())
|
||||||
LlmClient::with_backend("deepseek-v4-flash", "SYSTEM".into(), meter, backend.clone());
|
|
||||||
let lineup = select(&llm, candidates(20), §ions(), 6, date())
|
|
||||||
.await
|
.await
|
||||||
.expect("fallback");
|
.expect("fallback");
|
||||||
assert_eq!(backend.calls(), 0);
|
assert_eq!(backend.calls(), 0);
|
||||||
assert_eq!(lineup.picks.len(), 6);
|
assert_eq!(lineup.picks.len(), 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_provider_selects_heuristically() {
|
||||||
|
let lineup = select(&Llms::default(), candidates(20), §ions(), 6, 28, date())
|
||||||
|
.await
|
||||||
|
.expect("fallback");
|
||||||
|
assert_eq!(lineup.picks.len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skip_llm_lineup_uses_prefilter_order() {
|
fn skip_llm_lineup_uses_prefilter_order() {
|
||||||
let mut pool = candidates(10);
|
let mut pool = candidates(10);
|
||||||
@@ -971,7 +1116,7 @@ mod tests {
|
|||||||
pool[9].auto_include = true; // id 10 is a personal blog
|
pool[9].auto_include = true; // id 10 is a personal blog
|
||||||
pool[9].prefilter_score = 1.0;
|
pool[9].prefilter_score = 1.0;
|
||||||
|
|
||||||
let lineup = select_without_llm(pool, §ions(), 4, date());
|
let lineup = select_without_llm(pool, §ions(), 4, 28, date());
|
||||||
assert_eq!(lineup.picks.len(), 5, "4 picks + the auto-include");
|
assert_eq!(lineup.picks.len(), 5, "4 picks + the auto-include");
|
||||||
assert_eq!(lineup.lead().map(|p| p.article.id), Some(8));
|
assert_eq!(lineup.lead().map(|p| p.article.id), Some(8));
|
||||||
assert_eq!(lineup.picks.iter().filter(|p| p.is_lead).count(), 1);
|
assert_eq!(lineup.picks.iter().filter(|p| p.is_lead).count(), 1);
|
||||||
@@ -984,13 +1129,23 @@ mod tests {
|
|||||||
for pick in &lineup.picks {
|
for pick in &lineup.picks {
|
||||||
assert!(sections().contains(&pick.section));
|
assert!(sections().contains(&pick.section));
|
||||||
assert!(pick.summary.is_none());
|
assert!(pick.summary.is_none());
|
||||||
|
assert!(pick.why.is_none());
|
||||||
}
|
}
|
||||||
assert!(!lineup.section_order.is_empty());
|
assert!(!lineup.section_order.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_selection_respects_the_ceiling() {
|
||||||
|
let mut pool = candidates(10);
|
||||||
|
pool[9].auto_include = true;
|
||||||
|
let lineup = select_without_llm(pool, §ions(), 10, 4, date());
|
||||||
|
assert_eq!(lineup.picks.len(), 4);
|
||||||
|
assert!(lineup.picks.iter().any(|p| p.article.id == 10));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_input_yields_an_empty_lineup() {
|
fn empty_input_yields_an_empty_lineup() {
|
||||||
let lineup = select_without_llm(Vec::new(), §ions(), 20, date());
|
let lineup = select_without_llm(Vec::new(), §ions(), 20, 28, date());
|
||||||
assert!(lineup.picks.is_empty());
|
assert!(lineup.picks.is_empty());
|
||||||
assert!(lineup.section_order.is_empty());
|
assert!(lineup.section_order.is_empty());
|
||||||
assert!(lineup.lead().is_none());
|
assert!(lineup.lead().is_none());
|
||||||
|
|||||||
@@ -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
|
||||||
|
.to_zoned(jiff::tz::TimeZone::UTC)
|
||||||
|
.date()
|
||||||
|
.to_string();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT provider_costs_json FROM runs
|
||||||
|
WHERE substr(started_at, 1, 10) = ? AND started_at < ?
|
||||||
|
AND provider_costs_json IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(utc_date)
|
||||||
|
.bind(fmt_ts(started_at))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.get::<f64, _>("total"))
|
let mut totals = BTreeMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let raw = row.get::<String, _>("provider_costs_json");
|
||||||
|
let providers: BTreeMap<String, crate::report::ProviderUsage> =
|
||||||
|
serde_json::from_str(&raw).map_err(|source| DbError::Json {
|
||||||
|
column: "runs.provider_costs_json",
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
for (provider, usage) in providers {
|
||||||
|
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(totals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +1031,7 @@ mod tests {
|
|||||||
report.counts.entries_fetched = 412;
|
report.counts.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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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'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 %}
|
||||||
|
|||||||
@@ -9,12 +9,17 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="fact-line"><strong>Issue:</strong> No. {{ issue_number }} · {{ display_date }}</p>
|
<p class="fact-line"><strong>Issue:</strong> No. {{ issue_number }} · {{ 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 }} · {{ reading_line }}</p>
|
<p class="fact-line"><strong>Words:</strong> {{ total_words }} · {{ 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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<h1 class="masthead">The Daily EPUB</h1>
|
<h1 class="masthead">The Daily EPUB</h1>
|
||||||
<p class="dateline">{{ display_date }} · No. {{ issue_number }}</p>
|
<p class="dateline">{{ display_date }} · 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>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<p class="index-meta">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>
|
<p class="index-meta">{{ entry.source }} · {{ 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's here: {{ text }}</em></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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!(
|
println!(
|
||||||
"tokens: {} input · {} cached · {} output = ${:.4}",
|
"curation: {} scored · {} unscored · {} selected",
|
||||||
|
report.counts.llm_scored, report.counts.llm_unscored, report.counts.selected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"tokens: {} input · {} cache read · {} cache write · {} output = ${:.4}",
|
||||||
report.usage.input_tokens,
|
report.usage.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,
|
||||||
|
|||||||
+205
-64
@@ -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,
|
||||||
|
models: Models {
|
||||||
|
bulk: if bulk_available {
|
||||||
config.deepseek.model.clone()
|
config.deepseek.model.clone()
|
||||||
} else {
|
} else {
|
||||||
"none (--skip-llm)".into()
|
"none".into()
|
||||||
|
},
|
||||||
|
editor: if curator.llms.editor.is_some() {
|
||||||
|
config.anthropic.model.clone()
|
||||||
|
} else if bulk_available {
|
||||||
|
format!("{} (bulk fallback)", config.deepseek.model)
|
||||||
|
} else {
|
||||||
|
"none".into()
|
||||||
|
},
|
||||||
|
summaries: summary_model,
|
||||||
},
|
},
|
||||||
entries_fetched: report.counts.entries_fetched,
|
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,
|
|
||||||
Err(e) => {
|
|
||||||
report.warn(format!(
|
|
||||||
"DeepSeek is unavailable; curating heuristically: {e}"
|
|
||||||
));
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let make_clients = |prompt: String| {
|
||||||
|
Llms::from_config(
|
||||||
|
&ctx.config.deepseek,
|
||||||
|
&ctx.config.anthropic,
|
||||||
|
prompt,
|
||||||
|
bulk_meter.clone(),
|
||||||
|
editor_meter.clone(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Weekly rewrite of the "learned adjustments" section (§3.6c). It changes the
|
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
@@ -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
@@ -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", 250–400 words, already sanitized XHTML.
|
/// "From the Editor", 250–400 words, already sanitized XHTML.
|
||||||
pub front_page_html: String,
|
pub front_page_html: String,
|
||||||
/// Section name → 2–3 sentence intro.
|
|
||||||
pub section_intros: BTreeMap<String, String>,
|
|
||||||
/// Article id → 2–3 sentence newspaper abstract.
|
/// Article id → 2–3 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
@@ -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 6–7 with no LLM at all (notes §6) ---
|
// --- Stages 6–7 with no LLM at all (notes §6) ---
|
||||||
let curator = Curator::new(cfg.clone(), db.clone(), None);
|
let curator = Curator::new(cfg.clone(), db.clone(), Llms::default());
|
||||||
let candidates = curator
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"brief": "The lead, \"Migrating 40TB off Postgres\", is the rare migration write-up that keeps its failures in: two aborted cutovers, the rollback that took longer than the move, and a bill at the end. Read it first while the coffee is hot; it rewards attention and it is long.\n\nThe local desk answers with \"The MBTA slow-zone dataset\", which finally puts the T's own numbers into a shape a rider can argue with, and the charts do more persuading than a year of press releases. \"A failover story you'd argue with\" rounds out the engineering pages with a Postgres HA design that disagrees with the lead on almost every point, which is exactly why the two belong in the same issue. The issue is shorter than usual because a thin Friday is a good excuse to finish the long one properly rather than skim six."
|
||||||
|
}
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"from_the_editor": "Two of today's pieces are, underneath, the same story: what it costs to move data you no longer trust. The lead — a team hauling forty terabytes off Postgres, rollback plans and all — is the version with the invoices attached, and it earns the front page by refusing to tidy up its failures. Read it first, while the coffee is hot; it rewards attention and it is long.\n\nThe local desk supplies the counterpoint. Somebody has finally put the MBTA's slow-zone data into a shape a rider can argue with, and the charts do more persuading than a year of press releases. It is a short read and a satisfying one, and it pairs unreasonably well with the migration story: both are about institutions discovering what they actually have.\n\nThe rest of the issue is quieter than usual. That is not a complaint — a thin Friday is a good excuse to finish the long one properly rather than skimming six. If you only get through the lead today, you will not have missed much else.",
|
|
||||||
"section_intros": {
|
|
||||||
"Top Stories": "The day's most substantial piece: a full account of a forty-terabyte migration, with the failures left in. It is long, technical and unusually honest about what went wrong.",
|
|
||||||
"Boston & Local": "Transit data gets the treatment it deserves. A rider-built analysis of MBTA slow zones, with charts you can check yourself and a methodology section that holds up.",
|
|
||||||
"Niche Corner": "A section the model wrote an intro for even though nothing was placed in it today — a stray thread about tape-drive firmware and the people who still maintain it. The issue drops intros for sections that never ran."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"picks": [
|
"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
@@ -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 250–400 word editor's
|
/// The Brief must deserialize into 120–200 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
@@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user