Provider-agnostic LLM registry, config check, migration runbook

[llm] assigns the bulk and editor roles by name over a [providers.*]
registry (kind = openai | anthropic, per-provider model, effort, daily
ceiling and price table); DeepseekBackend becomes OpenAiCompatibleBackend
(reasoning_effort passthrough), AnthropicBackend builds from the same
ProviderConfig, meters and provider_costs are keyed by provider name.
Gemini 3.8 Flash is declared via Google's OpenAI-compatible endpoint so
switching the editor is one line (or DAILY_EPUB_LLM__EDITOR=gemini for an
A/B dry run). Stale [deepseek]/[anthropic] tables, the top-level
max_daily_usd and the old key env vars fail loudly.

daily-epub config check validates and prints the resolved roles, models,
key presence and paths without opening the database.

docs/runbooks/curation-v2-migration.md walks the server upgrade from v1.

Registry implemented by a Claude agent from an orchestrator brief;
verified fmt/clippy(-W dead_code)/test green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 18:44:57 +00:00
co-authored by Claude Fable 5.1
parent 5edbeb509f
commit 99d1338890
19 changed files with 2136 additions and 620 deletions
+101 -52
View File
@@ -21,9 +21,12 @@ it all cost.
Steady-state cost is roughly **$1/day**: $0.050.30 in DeepSeek tokens plus Steady-state cost is roughly **$1/day**: $0.050.30 in DeepSeek tokens plus
~$0.500.80 for the Claude editor and a few cents of Voyage AI embeddings, each ~$0.500.80 for the Claude editor and a few cents of Voyage AI embeddings, each
with its own per-UTC-day ceiling (`max_daily_usd`, `anthropic.max_daily_usd` and provider with its own per-UTC-day ceiling (`providers.<name>.max_daily_usd` and
`voyage.max_daily_usd`). Those ceilings are runaway guards, not accounting — set `voyage.max_daily_usd`). Those ceilings are runaway guards, not accounting — set
hard spend limits in the providers' dashboards as the real backstop. hard spend limits in the providers' dashboards as the real backstop. The two
LLM roles — *bulk* (triage, assessment, fallbacks) and *editor* — are assigned
by name to entries of a provider registry, so swapping DeepSeek or Claude for
Gemini (or anything OpenAI-compatible) is a config line plus an API key.
- 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)
@@ -34,9 +37,9 @@ hard spend limits in the providers' dashboards as the real backstop.
``` ```
Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
─▶ hygiene ─▶ embeddings (Voyage) + cheap signals ─▶ triage (DeepSeek) ─▶ hygiene ─▶ embeddings (Voyage) + cheap signals ─▶ triage (bulk LLM)
─▶ union admission ─▶ deep assessment (DeepSeek) ─▶ utility + diversity ─▶ union admission ─▶ deep assessment (bulk LLM) ─▶ utility + diversity
─▶ editor (Claude) ─▶ comments ─▶ editorial (Claude) ─▶ editor (editor LLM) ─▶ comments ─▶ editorial (editor LLM)
─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
``` ```
@@ -66,12 +69,16 @@ per-provider usage) is stored on the `runs` row and in `issues.report_json`.
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 LLM stage *degrades*: a Claude call that fails, is the run continues. Every LLM stage *degrades*: an editor call that fails, is
refused, or is over its daily ceiling is retried with the same prompt on refused, or is over its provider's daily ceiling is retried with the same
DeepSeek; if DeepSeek is missing, dead or over budget too, the run takes the prompt on the bulk provider; if the bulk provider is missing, dead or over
`--skip-llm` shape (admission uses cheap signals and feed excerpts stand in for budget too, the run takes the `--skip-llm` shape (admission uses cheap signals
summaries) instead of losing the day's issue. Anthropic's server-side refusal and feed excerpts stand in for summaries) instead of losing the day's issue.
fallback (`fallbacks = "default"`) is enabled on every editor request. Which provider plays which role is the `[llm]` table (`bulk = "deepseek"`,
`editor = "anthropic"` by default); a role whose key is absent is simply
unavailable, and `daily-epub config check` shows the resolved assignment before
a run. Anthropic's server-side refusal fallback (`fallbacks = "default"`) is
enabled on every request to an `anthropic`-kind provider.
--- ---
@@ -81,8 +88,8 @@ fallback (`fallbacks = "default"`) is enabled on every editor request.
|---|---|---| |---|---|---|
| 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** | triage and deep assessment, and the fallback for every editor call | <https://platform.deepseek.com>. Optional: `--skip-llm` runs the whole pipeline without it. | | A key for the **bulk** provider (DeepSeek by default) | triage and deep assessment, and the fallback for every editor call | <https://platform.deepseek.com>. `DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY`. 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 key for the **editor** provider (Anthropic by default) | the editor: selection, summaries, The Brief, the weekly profile rebuild | <https://console.anthropic.com>. `DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY`. Optional: without it every editor call runs on the bulk provider. Set a dashboard spend limit; `providers.anthropic.max_daily_usd` is only a runaway guard. Any other `[providers.*]` entry (Gemini is shipped) can take either role — see *Switching providers*. |
| **Voyage AI API key** | article and interest embeddings behind the learned ranking signals | <https://www.voyageai.com>. Optional: without it (or with `--skip-embeddings`) the run uses cached vectors only and the learned signals are absent, never a penalty. | | **Voyage AI API key** | article and interest embeddings behind the learned ranking signals | <https://www.voyageai.com>. Optional: without it (or with `--skip-embeddings`) the run uses cached vectors only and the learned signals are absent, never a penalty. |
| 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. |
@@ -119,6 +126,7 @@ daily-epub features backfill [--days 30] [--rated-only] [--all] [--yes]
daily-epub features prune # stale embeddings, old telemetry and assessments daily-epub features prune # stale embeddings, old telemetry and assessments
daily-epub backfill-social [--days 7] # re-poll social scores for recent articles daily-epub backfill-social [--days 7] # re-poll social scores for recent articles
daily-epub db migrate # run migrations (also automatic on every start) daily-epub db migrate # run migrations (also automatic on every start)
daily-epub config check # validate the config, print the resolved roles, keys and paths
``` ```
`--dry-run` does everything except deliver: it still ingests, persists entries and `--dry-run` does everything except deliver: it still ingests, persists entries and
@@ -160,6 +168,15 @@ than `curation.ranking.embedding_retention_days`, and `candidate_runs` rows and
`article_assessments` older than `curation.ranking.telemetry_retention_days`. `article_assessments` older than `curation.ranking.telemetry_retention_days`.
`generate` runs the same sweep once after publishing, best effort. `generate` runs the same sweep once after publishing, best effort.
`config check` loads and validates the configuration exactly as `generate`
would and prints one fact per line: the config path, the database, profile,
interests and publish paths with `exists`/`MISSING`, each `[llm]` role with its
provider name, kind, model, effort, ceiling and whether its key is present, the
Voyage line likewise, and `editorial.summary_model`. Lines that need attention
start with `!`. It exits non-zero only on a validation error — a missing key or
file is a warning, since the run degrades rather than fails — and never opens
the database or takes the lock, so it is safe to run next to a live `generate`.
--- ---
## Configuration ## Configuration
@@ -171,12 +188,43 @@ Start from [`config.example.toml`](config.example.toml). Load order, later wins:
3. `DAILY_EPUB_*` environment variables 3. `DAILY_EPUB_*` environment variables
Nested keys use a **double underscore**: `[miniflux] api_key` becomes Nested keys use a **double underscore**: `[miniflux] api_key` becomes
`DAILY_EPUB_MINIFLUX__API_KEY`. Top-level keys are just uppercased: `DAILY_EPUB_MINIFLUX__API_KEY`, and a provider's key is
`DAILY_EPUB_LOOKBACK_HOURS=30`. As a convenience, plain **`DAILY_EPUB_SECRET`** `DAILY_EPUB_PROVIDERS__<NAME>__API_KEY` with the `[providers.<name>]` table name
is accepted as an alias for `server.hmac_secret` (the explicit key wins if both upper-cased — the shipped registry reads `DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY`,
are set). `DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY` and
`DAILY_EPUB_PROVIDERS__GEMINI__API_KEY` (provider names are therefore lowercase
`a-z0-9_`). Top-level keys are just uppercased: `DAILY_EPUB_LOOKBACK_HOURS=30`.
As a convenience, plain **`DAILY_EPUB_SECRET`** is accepted as an alias for
`server.hmac_secret` (the explicit key wins if both are set).
Secrets belong in the environment file, never in the TOML. Secrets belong in the environment file, never in the TOML. Stale configuration
fails at startup rather than silently curating without a provider: a
`[deepseek]` or `[anthropic]` table, a top-level `max_daily_usd`, a batch-size
or temperature key outside `[llm]`, or a `DAILY_EPUB_DEEPSEEK__*` /
`DAILY_EPUB_ANTHROPIC__*` environment variable is an error naming the new key.
### Switching providers
The roles are names, the providers are tables. To run the editor on Gemini:
```toml
[llm]
editor = "gemini" # [providers.gemini] is already declared in config.example.toml
```
and put `DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=…` in the env file. A one-off
A/B without touching the file, since every key is also an env var:
```sh
DAILY_EPUB_LLM__EDITOR=gemini daily-epub generate --dry-run --date 2026-09-02
```
The same works for `bulk`. Both roles may name one provider (they then share
one client and one `max_daily_usd`), `editor = ""` runs everything on bulk, and
a new endpoint is a new `[providers.<name>]` table: `kind = "openai"` for any
OpenAI-compatible chat-completions API (DeepSeek, Gemini, OpenAI, a local
server), `kind = "anthropic"` for the Messages API. `daily-epub config check`
prints what resolved.
### Reference ### Reference
@@ -187,7 +235,6 @@ Secrets belong in the environment file, never in the TOML.
| `target_article_count` | `20` | Soft target the editor aims for. There is no minimum: a nine-pick issue is published as nine. | | `target_article_count` | `20` | Soft target the editor aims for. There is no minimum: a nine-pick issue is published as nine. |
| `retention_days` | `21` | EPUBs older than this are deleted from `publish.epub_dir`. SQLite history is kept forever. | | `retention_days` | `21` | EPUBs older than this are deleted from `publish.epub_dir`. SQLite history is kept forever. |
| `xtc_retention_count` | `5` | How many XTC issues to keep in `publish.xtc_dir`. Counted, not dated: each `.xtch` is ~80100 MB, so the binding constraint is disk, not age. | | `xtc_retention_count` | `5` | How many XTC issues to keep in `publish.xtc_dir`. Counted, not dated: each `.xtch` is ~80100 MB, so the binding constraint is disk, not age. |
| `max_daily_usd` | `2.0` | 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. |
@@ -196,28 +243,23 @@ Secrets belong in the environment file, never in the TOML.
| `miniflux.base_url` | `http://127.0.0.1:8082` | Miniflux root (no `/v1`). | | `miniflux.base_url` | `http://127.0.0.1:8082` | Miniflux root (no `/v1`). |
| `miniflux.api_key` | — | **`DAILY_EPUB_MINIFLUX__API_KEY`**. Required. | | `miniflux.api_key` | — | **`DAILY_EPUB_MINIFLUX__API_KEY`**. Required. |
| `miniflux.page_limit` | `250` | Entries per page; Miniflux caps this at 250. | | `miniflux.page_limit` | `250` | Entries per page; Miniflux caps this at 250. |
| `deepseek.base_url` | `https://api.deepseek.com/v1` | OpenAI-compatible endpoint. | | `llm.bulk` | `deepseek` | The `[providers.*]` name that runs triage, deep assessment and every fallback. `""` ⇒ no bulk provider (those stages are skipped). |
| `deepseek.model` | `deepseek-v4-flash` | Verified 2026-08-15 (DeepSeek-V4-Flash-0731). | | `llm.editor` | `anthropic` | The provider that assembles the lineup, writes the summaries and The Brief and rebuilds the profile. `""` or absent ⇒ everything runs on `bulk`. Naming the same provider as `bulk` shares one client and one ceiling. |
| `deepseek.api_key` | — | **`DAILY_EPUB_DEEPSEEK__API_KEY`**. Absent ⇒ the run curates heuristically. | | `llm.triage_batch_size` | `25` | Articles per first-pass triage request. |
| `deepseek.deep_batch_size` | `8` | Articles per close-reading assessment request. The removed `score_batch_size` key is a startup error. | | `llm.deep_batch_size` | `8` | Articles per close-reading assessment request. The removed `score_batch_size` key is a startup error. |
| `deepseek.triage_batch_size` | `25` | Articles per first-pass triage request. | | `llm.score_temperature` | `0.3` | Scoring temperature, sent only to `openai`-kind providers. |
| `deepseek.max_concurrent_requests` | `4` | Triage and deep-assessment batches in flight at once; the budget is checked before each is spawned. | | `llm.editorial_temperature` | `0.8` | Summaries and The Brief on an `openai`-kind provider. |
| `deepseek.score_temperature` | `0.3` | Scoring temperature. | | `providers.<name>.kind` | — | `openai` (chat completions at `{base_url}/chat/completions`, bearer key) or `anthropic` (Messages API: `output_config.effort`, a cached system block, `fallbacks = "default"` with the `server-side-fallback-2026-07-01` beta). Shipped entries: `deepseek`, `anthropic`, `gemini`. |
| `deepseek.editorial_temperature` | `0.8` | Summaries and The Brief, only when DeepSeek is the fallback editor. | | `providers.<name>.base_url` | — | Endpoint root. DeepSeek `https://api.deepseek.com/v1`; Anthropic `https://api.anthropic.com`; Gemini `https://generativelanguage.googleapis.com/v1beta/openai`. |
| `deepseek.price_input_per_mtok` | `0.14` | USD per 1M cache-miss input tokens (cost guardrail arithmetic). | | `providers.<name>.model` | — | `deepseek-v4-flash` (verified 2026-08-15), `claude-opus-5`, `gemini-3.8-flash` (verified 2026-09-02). |
| `deepseek.price_cached_input_per_mtok` | `0.0028` | USD per 1M prefix-cache-hit input tokens. | | `providers.<name>.api_key` | — | **`DAILY_EPUB_PROVIDERS__<NAME>__API_KEY`**, environment only. Absent ⇒ that role is unavailable and degrades (bulk ⇒ heuristic curation, editor ⇒ bulk). |
| `deepseek.price_output_per_mtok` | `0.28` | USD per 1M output tokens. | | `providers.<name>.effort` | `high` (anthropic, gemini) | `anthropic`: `low`, `medium`, `high`, `xhigh` or `max``output_config.effort`. `openai`: passed through as `reasoning_effort` (Gemini takes `minimal``high`); omit it for models without one (DeepSeek). |
| `anthropic.enabled` | `true` | `false` runs every editor call on DeepSeek. | | `providers.<name>.max_daily_usd` | `2.0` / `3.0` / `3.0` | That provider's ceiling per **UTC day** of the run's start, not per run — a re-run inherits what earlier runs that day already spent on it (`runs.provider_costs_json`). Tripping it skips that provider's remaining calls; in-flight requests finish and the paper still publishes. `0` disables the guard. |
| `anthropic.base_url` | `https://api.anthropic.com` | Messages API root. | | `providers.<name>.max_concurrent_requests` | `4` | Triage and deep-assessment batches in flight on the bulk provider; summaries in flight on the summary provider. |
| `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. | | `providers.<name>.price_input_per_mtok` | `0.14` / `5.0` / `0.75` | USD per 1M cache-miss input tokens (cost guardrail arithmetic). |
| `anthropic.api_key` | — | **`DAILY_EPUB_ANTHROPIC__API_KEY`**. Absent ⇒ editor calls fall back to DeepSeek. | | `providers.<name>.price_cache_read_per_mtok` | `0.0028` / `0.5` / `0.075` | USD per 1M cache-hit input tokens. |
| `anthropic.effort` | `high` | `low`, `medium`, `high`, `xhigh` or `max`. | | `providers.<name>.price_cache_write_per_mtok` | `0.0` / `6.25` / `0.0` | USD per 1M tokens written to the prompt cache (implicit caches charge nothing). |
| `anthropic.price_input_per_mtok` | `5.0` | USD per 1M uncached input tokens. | | `providers.<name>.price_output_per_mtok` | `0.28` / `25.0` / `3.75` | USD per 1M output tokens, thinking tokens included where the provider bills them as output. |
| `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. |
| `voyage.enabled` | `true` | Embed articles and interests with Voyage AI. `false` ⇒ cached vectors only. | | `voyage.enabled` | `true` | Embed articles and interests with Voyage AI. `false` ⇒ cached vectors only. |
| `voyage.base_url` | `https://api.voyageai.com/v1` | `POST {base_url}/embeddings`. | | `voyage.base_url` | `https://api.voyageai.com/v1` | `POST {base_url}/embeddings`. |
| `voyage.model` | `voyage-4-lite` | Embedding model; changing it invalidates the cache. | | `voyage.model` | `voyage-4-lite` | Embedding model; changing it invalidates the cache. |
@@ -239,7 +281,7 @@ Secrets belong in the environment file, never in the TOML.
| `curation.recent_rejection_days` | `7` | Churn window for recent low triage/deep assessments. | | `curation.recent_rejection_days` | `7` | Churn window for recent low triage/deep assessments. |
| `curation.recent_rejection_floor` | `3.0` | Scores below this floor are excluded during the churn window (except auto-includes). | | `curation.recent_rejection_floor` | `3.0` | Scores below this floor are excluded during the churn window (except auto-includes). |
| `curation.ranking.*` | see below | Every weight, quota, gate and threshold of the personalized ranker. | | `curation.ranking.*` | see below | Every weight, quota, gate and threshold of the personalized ranker. |
| `editorial.summary_model` | `editor` | `editor` (Claude) or `bulk` (DeepSeek) for the per-article summaries. | | `editorial.summary_model` | `editor` | Which `[llm]` role writes the per-article summaries: `editor` (with per-article bulk fallback) or `bulk`. |
| `editorial.summary_input_tokens` | `3000` | Article text offered to the summary prompt. | | `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. |
@@ -298,15 +340,20 @@ sudo install -m0640 -o daily-epub -g daily-epub config.example.toml /etc/daily-e
sudo -e /etc/daily-epub/config.toml # set publish dirs, xtc args, public_url sudo -e /etc/daily-epub/config.toml # set publish dirs, xtc args, public_url
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_PROVIDERS__DEEPSEEK__API_KEY=…
DAILY_EPUB_ANTHROPIC__API_KEY=… DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY=…
# DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=… # only if a role names "gemini"
DAILY_EPUB_VOYAGE__API_KEY=… DAILY_EPUB_VOYAGE__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
# `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` are the two # One key per [providers.<name>] entry a role uses, named after the table
# keys curation v2 added; the units read them from this file unchanged. Either # (upper-cased). Any may be left unset: that role is then unavailable and the
# may be left unset: the run then falls back to DeepSeek / cached embeddings. # run degrades (editor → bulk, bulk → heuristic curation, Voyage → cached
# vectors). The pre-registry DAILY_EPUB_DEEPSEEK__API_KEY /
# DAILY_EPUB_ANTHROPIC__API_KEY names are a startup error, not a silent no-op.
sudo -u daily-epub bash -c 'set -a; . /etc/daily-epub/env; set +a;
daily-epub --config /etc/daily-epub/config.toml config check' # roles, keys present?, paths
# publish dirs must exist and be writable by the service user # publish dirs must exist and be writable by the service user
sudo install -d -o daily-epub -g daily-epub /var/lib/daily-epub/xtc sudo install -d -o daily-epub -g daily-epub /var/lib/daily-epub/xtc
@@ -465,6 +512,7 @@ Condensed from spec §5. Run it in this order the first time.
```sh ```sh
# 1. Config and schema # 1. Config and schema
daily-epub --config /etc/daily-epub/config.toml config check # roles → providers, keys present?
daily-epub --config /etc/daily-epub/config.toml db migrate daily-epub --config /etc/daily-epub/config.toml db migrate
# 2. Ingest only, no keys spent: does Miniflux answer, and with how much? # 2. Ingest only, no keys spent: does Miniflux answer, and with how much?
@@ -477,7 +525,7 @@ epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
# open the standard edition in Calibre / KOReader: cover, The Brief, # open the standard edition in Calibre / KOReader: cover, The Brief,
# In This Issue, sections, discussions, Behind the paper, colophon; TOC depth 2 # In This Issue, sections, discussions, Behind the paper, colophon; TOC depth 2
# 4. Now with DeepSeek and Claude, still not publishing # 4. Now with the bulk and editor providers, 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 (at most 6 picks, each with a "why" line) and the # → check the lineup is sane (at most 6 picks, each with a "why" line) and the
# printed per-provider cost is well under $1 # printed per-provider cost is well under $1
@@ -622,10 +670,11 @@ 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.
- **Two chat providers are wired**, DeepSeek (bulk) and Anthropic (editor), - **Two wire protocols are implemented**, `openai` (chat completions) and
each a `ChatBackend` impl with its own `UsageMeter` and price table. A third `anthropic` (Messages API), each a `ChatBackend` impl. Providers are config
means another impl. Voyage AI embeddings sit behind the analogous entries over those two kinds, each with its own `UsageMeter`, price table and
`EmbeddingBackend` trait in `curate/embedding.rs`. ceiling; a protocol that is neither means another impl. Voyage AI embeddings
sit behind the analogous `EmbeddingBackend` trait in `curate/embedding.rs`.
- **Triage and union admission replace the heuristic gate.** Every eligible - **Triage and union admission replace the heuristic gate.** Every eligible
article gets interest, rated-neighbour, feed-affinity, social and heuristic article gets interest, rated-neighbour, feed-affinity, social and heuristic
signals, then DeepSeek reads its opening (up to `triage_max`). The deep set is signals, then DeepSeek reads its opening (up to `triage_max`). The deep set is
+52 -22
View File
@@ -3,18 +3,19 @@
# Load order (later wins): built-in defaults ← this file ← `DAILY_EPUB_*` env vars. # Load order (later wins): built-in defaults ← this file ← `DAILY_EPUB_*` env vars.
# 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_PROVIDERS__DEEPSEEK__API_KEY=...
# DAILY_EPUB_ANTHROPIC__API_KEY=... # DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY=...
# DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=...
# DAILY_EPUB_VOYAGE__API_KEY=... # DAILY_EPUB_VOYAGE__API_KEY=...
# DAILY_EPUB_SERVER__HMAC_SECRET=... # DAILY_EPUB_SERVER__HMAC_SECRET=...
# DAILY_EPUB_LOOKBACK_HOURS=30 # DAILY_EPUB_LOOKBACK_HOURS=30
# DAILY_EPUB_LLM__EDITOR=gemini # one-off role override, no file edit
timezone = "America/New_York" timezone = "America/New_York"
lookback_hours = 26 lookback_hours = 26
target_article_count = 20 target_article_count = 20
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 # DeepSeek ceiling per UTC day; [anthropic] and [voyage] have their 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.
@@ -32,37 +33,66 @@ base_url = "http://127.0.0.1:8082"
# api_key via DAILY_EPUB_MINIFLUX__API_KEY env # api_key via DAILY_EPUB_MINIFLUX__API_KEY env
page_limit = 250 page_limit = 250
[deepseek] # The two LLM roles, each assigned to a provider declared in [providers.*]
# below. Switching the editor to Gemini is `editor = "gemini"` plus its key in
# the env file; nothing else changes. An empty editor ("") runs everything on
# the bulk provider. Every editor call degrades to bulk when the editor's key
# is missing, its daily ceiling is hit, or the API refuses/fails.
[llm]
bulk = "deepseek" # triage, deep assessment, and every fallback
editor = "anthropic" # lineup, summaries, The Brief, the weekly profile rebuild
triage_batch_size = 25 # articles per first-pass triage request
deep_batch_size = 8 # articles per close-reading assessment request
score_temperature = 0.3 # sent only by providers that take a temperature (kind = "openai")
editorial_temperature = 0.8 # summaries and The Brief on an openai-kind provider
# The provider registry. Any number of entries; a role above names one by its
# table name. Keys never live here: DAILY_EPUB_PROVIDERS__<NAME>__API_KEY.
# `max_daily_usd` is a per-provider runaway guard per UTC day (0 = none), not
# accounting — set hard spend limits in each provider's dashboard as well.
# Prices are USD per 1M tokens and only feed the guard's arithmetic.
[providers.deepseek]
kind = "openai" # openai | anthropic
base_url = "https://api.deepseek.com/v1" 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_PROVIDERS__DEEPSEEK__API_KEY env
deep_batch_size = 8 # articles per close-reading assessment request max_daily_usd = 2.0
triage_batch_size = 25 # articles per first-pass triage request
max_concurrent_requests = 4 # triage and deep-assessment batches in flight max_concurrent_requests = 4 # triage and deep-assessment batches in flight
score_temperature = 0.3
editorial_temperature = 0.8 # used only when DeepSeek is the fallback editor
# USD per 1M tokens, used for the cost guardrail.
price_input_per_mtok = 0.14 price_input_per_mtok = 0.14
price_cached_input_per_mtok = 0.0028 price_cache_read_per_mtok = 0.0028 # prefix-cache hits
price_cache_write_per_mtok = 0.0 # DeepSeek caches implicitly, no write charge
price_output_per_mtok = 0.28 price_output_per_mtok = 0.28
# Claude is the editor: selection, summaries, The Brief and the weekly profile # Claude Opus 5 over the Messages API. Requests carry `output_config.effort`,
# rebuild. Every call degrades to DeepSeek when the key is missing, the daily # a cached system block, and `fallbacks = "default"` so a classifier refusal is
# ceiling is hit, or the API refuses/fails. Server-side refusal fallback # re-routed server-side; a refusal that still comes back degrades to bulk.
# (`fallbacks = "default"`) is always on. Set a spend limit in the Anthropic [providers.anthropic]
# dashboard too: `max_daily_usd` is a runaway guard, not accounting. kind = "anthropic"
[anthropic]
enabled = true
base_url = "https://api.anthropic.com" base_url = "https://api.anthropic.com"
model = "claude-opus-5" model = "claude-opus-5"
# api_key via DAILY_EPUB_ANTHROPIC__API_KEY env # api_key via DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY env
effort = "high" # low | medium | high | xhigh | max effort = "high" # low | medium | high | xhigh | max → output_config.effort
max_daily_usd = 3.0
max_concurrent_requests = 4 # summaries in flight when this is the summary provider
price_input_per_mtok = 5.0 price_input_per_mtok = 5.0
price_cache_write_per_mtok = 6.25
price_cache_read_per_mtok = 0.5 price_cache_read_per_mtok = 0.5
price_cache_write_per_mtok = 6.25
price_output_per_mtok = 25.0 price_output_per_mtok = 25.0
# Gemini 3.8 Flash through Google's OpenAI-compatible endpoint (beta, verified
# 2026-09-02). Declared but unreferenced until a role names it.
[providers.gemini]
kind = "openai"
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
model = "gemini-3.8-flash"
# api_key via DAILY_EPUB_PROVIDERS__GEMINI__API_KEY env
effort = "high" # minimal | low | medium | high → reasoning_effort
max_daily_usd = 3.0 max_daily_usd = 3.0
max_concurrent_requests = 4 max_concurrent_requests = 4
price_input_per_mtok = 0.75 # promotional through 2026-12-31; $1.50 from 2027-01-01
price_cache_read_per_mtok = 0.075 # implicit cache hits; $0.15 from 2027-01-01
price_cache_write_per_mtok = 0.0
price_output_per_mtok = 3.75 # includes thinking tokens; $7.50 from 2027-01-01
# Voyage AI embeddings behind the interest and rated-neighbour signals. Set # Voyage AI embeddings behind the interest and rated-neighbour signals. Set
# `enabled = false` (or leave the key unset) and the paper still builds: the # `enabled = false` (or leave the key unset) and the paper still builds: the
@@ -154,7 +184,7 @@ per_cluster_cap = 2
utility_protected = 10 utility_protected = 10
[editorial] [editorial]
summary_model = "editor" # editor (Claude) | bulk (DeepSeek) summary_model = "editor" # editor | bulk — which [llm] role writes the summaries
summary_input_tokens = 3000 # article text offered per summary summary_input_tokens = 3000 # article text offered per summary
[publish] [publish]
+51 -11
View File
@@ -87,7 +87,23 @@ This file records implementation-time decisions and verified external facts. Fol
`stop_reason: "refusal"` on HTTP 200, which the code treats as an error that degrades the `stop_reason: "refusal"` on HTTP 200, which the code treats as an error that degrades the
call to DeepSeek. Usage fields: `input_tokens` (uncached remainder), call to DeepSeek. Usage fields: `input_tokens` (uncached remainder),
`cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`. Timeout 300 s; `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`. Timeout 300 s;
retry 429/5xx/network, never 400. Key only from `DAILY_EPUB_ANTHROPIC__API_KEY`. retry 429/5xx/network, never 400. Key only from `DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY`
(the provider registry below; the pre-registry `DAILY_EPUB_ANTHROPIC__API_KEY` is a startup
error).
- **Gemini 3.8 Flash over the OpenAI-compatible endpoint** (beta, verified 2026-09-02):
`POST https://generativelanguage.googleapis.com/v1beta/openai/chat/completions` with
`Authorization: Bearer <key>`, the standard `messages` / `temperature` /
`response_format: {"type": "json_object"}` body. Model id `gemini-3.8-flash`. Reasoning depth
is the OpenAI `reasoning_effort` field, which Google maps onto Gemini 3.x's `thinking_level`
(`minimal | low | medium | high`; `none` is not accepted by 3.x models). Usage: implicit
cache hits are reported in `prompt_tokens_details.cached_tokens` (the same field DeepSeek
now fills), and `completion_tokens` already includes the thinking tokens that
`completion_tokens_details.reasoning_tokens` breaks out — so output is priced from
`completion_tokens` alone, never the sum. Prices per 1M tokens (promotional through
2026-12-31): **$0.75 input, $0.075 cache read, $3.75 output** (thinking included); from
2027-01-01 **$1.50 / $0.15 / $7.50**. No cache-write charge. Key only from
`DAILY_EPUB_PROVIDERS__GEMINI__API_KEY`. Shipped as `[providers.gemini]`, unreferenced until
a role names it.
- **Voyage AI embeddings** (verified 2026-09-02): `POST https://api.voyageai.com/v1/embeddings` - **Voyage AI embeddings** (verified 2026-09-02): `POST https://api.voyageai.com/v1/embeddings`
with `Authorization: Bearer <key>`; body `{input: [...], model: "voyage-4-lite", input_type: with `Authorization: Bearer <key>`; body `{input: [...], model: "voyage-4-lite", input_type:
"document" | "query", truncation: true, output_dimension: 512, output_dtype: "float"}`. Up "document" | "query", truncation: true, output_dimension: 512, output_dtype: "float"}`. Up
@@ -109,13 +125,25 @@ This file records implementation-time decisions and verified external facts. Fol
`the-daily-epub/1.0 (personal rss digest; contact tyler@hallada.net)`), passed by clone. `the-daily-epub/1.0 (personal rss digest; contact tyler@hallada.net)`), passed by clone.
5. **LLM**: a hand-rolled `reqwest` client, not `async-openai` (the published crate exposes 5. **LLM**: a hand-rolled `reqwest` client, not `async-openai` (the published crate exposes
neither `Client` nor `CreateChatCompletionRequest` at the pinned version). Every LLM call neither `Client` nor `CreateChatCompletionRequest` at the pinned version). Every LLM call
goes through `curate/llm.rs`: `LlmClient { system_prompt, model, meter, backend, retry }` goes through `curate/llm.rs`: `LlmClient { provider, system_prompt, model, effort,
over the `ChatBackend` trait, with `DeepseekBackend` (OpenAI-compatible chat completions, max_concurrent_requests, meter, backend, retry }` over the `ChatBackend` trait, with two
`response_format: json_object`) and `AnthropicBackend` (Messages API, facts above). The wire protocols — `OpenAiCompatibleBackend` (`{base_url}/chat/completions`, bearer key,
pipeline holds `Llms { bulk, editor }`; `editor_or_bulk()` degrades to DeepSeek when the `response_format: json_object`, `reasoning_effort` when the provider has an `effort`) and
Claude client is missing or its meter is tripped. One `UsageMeter` per provider `AnthropicBackend` (Messages API, facts above). **Providers are config, not code**: the
(DeepSeek, Anthropic, Voyage) with its own price table and `max_daily_usd`. The system `[providers.<name>]` registry (`kind = openai | anthropic`, `base_url`, `model`, `effort`,
prompt is sent first and byte-identical within a run so both providers' prefix caches hit. `max_daily_usd`, `max_concurrent_requests`, `price_*`) is a `BTreeMap<String,
ProviderConfig>`, and `[llm] bulk = "<name>"` / `editor = "<name>"` assign the two roles by
name (`editor = ""` means everything runs on bulk; both roles on one provider share one
client and one ceiling). `LlmClient::for_provider(name, &cfg, ..)` dispatches on `kind`;
`Llms::from_config(&config, prompt, &meters)` builds the roles; `editor_or_bulk()` degrades
to bulk when the editor client is missing or its meter is tripped. One `UsageMeter` per
*referenced* provider (`llm::provider_meters`), keyed by provider name — the same key used
for `runs.provider_costs_json`, the UTC-day spend preload and the log lines — plus Voyage's
own. `LlmClient.provider` is the config name, never the kind. The system prompt is sent
first and byte-identical within a run so every provider's prefix cache hits. Keys come only
from `DAILY_EPUB_PROVIDERS__<NAME>__API_KEY` (figment lower-cases the path, so provider
names are `[a-z0-9_]+`); `daily-epub config check` prints the resolved roles without
opening the database.
6. **Testing**: unit tests inline per module; integration tests in `tests/` over fixture JSON in 6. **Testing**: unit tests inline per module; integration tests in `tests/` over fixture JSON in
`tests/fixtures/`. Never hit the network in tests: `MockBackend` (`ChatBackend`) and the `tests/fixtures/`. Never hit the network in tests: `MockBackend` (`ChatBackend`) and the
embedding mock (`EmbeddingBackend`) stand in for all three providers. `--skip-llm` makes embedding mock (`EmbeddingBackend`) stand in for all three providers. `--skip-llm` makes
@@ -168,6 +196,18 @@ implementer needs that are easy to get wrong:
articles older than `embedding_retention_days` (120) and `candidate_runs` rows plus articles older than `embedding_retention_days` (120) and `candidate_runs` rows plus
`article_assessments` older than `telemetry_retention_days` (180). `features prune` runs it `article_assessments` older than `telemetry_retention_days` (180). `features prune` runs it
on demand; `generate` runs it once after publishing, best effort. on demand; `generate` runs it once after publishing, best effort.
- **Keys**: `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` map onto - **Keys**: `DAILY_EPUB_PROVIDERS__<NAME>__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` map onto
`AnthropicConfig.api_key` / `VoyageConfig.api_key` through figment; the fields exist only `ProviderConfig.api_key` / `VoyageConfig.api_key` through figment; the fields exist only
for that mapping and are never documented in TOML, logged, or stored. for that mapping and are never documented in TOML, logged, or stored
(`Config::providers_redacted()` is what reaches `runs.config_json`).
- **Provider registry** (2026-09-02, after step 7): the `[deepseek]` and `[anthropic]` tables
and the top-level `max_daily_usd` are gone. `[llm]` holds the role names and the role-level
knobs (`triage_batch_size`, `deep_batch_size`, `score_temperature`,
`editorial_temperature`); `[providers.deepseek]`, `[providers.anthropic]` and
`[providers.gemini]` ship in `config.example.toml` and are `Config::default()` key for key.
Stale shapes fail at load, naming the new key: a `[deepseek]`/`[anthropic]` header, a
top-level `max_daily_usd`, any of the four role keys outside `[llm]`, or a
`DAILY_EPUB_DEEPSEEK__*` / `DAILY_EPUB_ANTHROPIC__*` environment variable. Batching
concurrency (`triage`, `assess`) is the bulk provider's `max_concurrent_requests`; the
summaries fan out at the summary provider's. `Models { bulk, editor, summaries }` in the
colophon and Behind the paper stay model ids taken from the built clients.
@@ -18,6 +18,13 @@ brief was handed to an implementation agent as `cat 00-preamble.md stepN.md`.
| 6. Paper telemetry, stats, lock | **done** (Claude agent), reviewed | `d261cd4` | | 6. Paper telemetry, stats, lock | **done** (Claude agent), reviewed | `d261cd4` |
| 7. Cleanup + implementation notes | **done** (Claude agent), reviewed | `d403c51` | | 7. Cleanup + implementation notes | **done** (Claude agent), reviewed | `d403c51` |
**Post-plan addition (same day):** a provider-agnostic LLM registry — `[llm]` roles (`bulk`,
`editor`) over named `[providers.*]` entries of `kind = openai | anthropic`, keys from
`DAILY_EPUB_PROVIDERS__<NAME>__API_KEY`, a `gemini` entry (Gemini 3.8 Flash via Google's
OpenAI-compatible endpoint) declared but unreferenced, and a `daily-epub config check` subcommand.
The old `[deepseek]`/`[anthropic]` tables, top-level `max_daily_usd` and the old key env vars
fail loudly. The server upgrade is written up in `docs/runbooks/curation-v2-migration.md`.
All seven plan steps are implemented. `cargo fmt --check`, `cargo clippy --all-targets` All seven plan steps are implemented. `cargo fmt --check`, `cargo clippy --all-targets`
(including `-W dead_code`) and `cargo test` (329 lib tests + 6 bin tests + the 7 integration suites) are green at HEAD. (including `-W dead_code`) and `cargo test` (329 lib tests + 6 bin tests + the 7 integration suites) are green at HEAD.
@@ -124,7 +131,8 @@ Step 7:
## Next session: what remains ## Next session: what remains
1. `git checkout curation-v2 && cargo test` (expect green). 1. `git checkout curation-v2 && cargo test` (expect green). Follow
`docs/runbooks/curation-v2-migration.md` on the server.
2. Optional: a Codex `review --background --scope branch --base main` pass over the whole branch. 2. Optional: a Codex `review --background --scope branch --base main` pass over the whole branch.
3. `git merge --no-ff curation-v2` into `main`, build, deploy, and do the operator to-dos above. 3. `git merge --no-ff curation-v2` into `main`, build, deploy, and do the operator to-dos above.
4. After a week of real runs: read `stats`, tune `[curation.ranking]` from what `explain` shows, 4. After a week of real runs: read `stats`, tune `[curation.ranking]` from what `explain` shows,
+278
View File
@@ -0,0 +1,278 @@
# Runbook — upgrading the server from v1 to curation v2
**Written:** 2026-09-02, for the `curation-v2` branch (all seven plan steps plus the provider
registry). Every command below is meant to be run on the server, as `root` via `sudo`, unless it
says otherwise. Expect the whole thing to take about an hour, most of it waiting on the embedding
backfill and one dry run.
What changes for the operator, in one paragraph: the binary is replaced; the SQLite schema gains
tables and drops `ratings`, `feed_priors` and `scores` (the migration copies your ratings first);
`config.toml` loses a few keys and gains the `[llm]` / `[providers.*]` registry plus a
`profile_path`; the env file gains two API keys and renames the DeepSeek one; a hand-maintained
`profile.md` is installed next to the OPML; the systemd units are unchanged.
## 0. Before touching the server
On the dev box:
```sh
git checkout curation-v2
cargo test # expect green
cargo build --release # or build on the server, as you do today
```
The crate uses `edition = "2024"` and let-chains, so the server's toolchain must be current
(`rustup update stable`). Read `data/profile.md` once; it is the reader profile you will be
editing by hand from now on, and the first thing to tune when the paper feels off.
## 1. Freeze the timer, back everything up
```sh
sudo systemctl stop daily-epub-generate.timer daily-epub-generate.service
sudo systemctl stop daily-epub.service # the server also runs migrations on start
sudo install -d -m0750 -o daily-epub -g daily-epub /var/lib/daily-epub/backup
sudo -u daily-epub sqlite3 /var/lib/daily-epub/daily-epub.db \
".backup '/var/lib/daily-epub/backup/daily-epub-pre-v2-$(date +%F).db'"
sudo cp -a /etc/daily-epub/config.toml /etc/daily-epub/config.toml.v1
sudo cp -a /etc/daily-epub/env /etc/daily-epub/env.v1
sudo cp -a /usr/local/bin/daily-epub /usr/local/bin/daily-epub.v1
```
`.backup` is the safe way to copy a WAL-mode database; a plain `cp` while anything is open can
miss the WAL. The migration is one-way (`ratings` is dropped after being copied), so this backup
is the rollback path.
## 2. Install the new binary
```sh
sudo install -m0755 target/release/daily-epub /usr/local/bin/
daily-epub --version
daily-epub --help # confirm `config`, `stats`, `explain`, `ratings`, `features` exist
```
Do **not** start any service yet: every subcommand except `config check` opens the database and
applies pending migrations, and you want the config right first.
The units in `systemd/` did not change. If you want to be sure your installed copies match:
```sh
diff systemd/daily-epub-generate.service /etc/systemd/system/daily-epub-generate.service
diff systemd/daily-epub.service /etc/systemd/system/daily-epub.service
```
## 3. Edit `config.toml` in place
Everything you do not mention keeps its documented default, so the edit is small. Open it with
`sudo -e /etc/daily-epub/config.toml` and apply this checklist.
**Remove** (each one now fails loudly at startup, naming its replacement):
| Old key | Why |
|---|---|
| `prefilter_keep = …` (top level) | replaced by `curation.ranking.deep_keep` (default 120) |
| `max_daily_usd = …` (top level) | now per provider: `providers.deepseek.max_daily_usd` |
| the whole `[deepseek]` table | becomes `[providers.deepseek]` + `[llm]` (see below) |
| any `[anthropic]` table (only if you added one from an interim build) | becomes `[providers.anthropic]` |
**Add** near the top, next to `interests_opml`:
```toml
profile_path = "/var/lib/daily-epub/data/profile.md"
```
Use an absolute path. The default is `data/profile.md` *relative to the working directory*, which
under the unit is `/var/lib/daily-epub`, so the default would resolve to the same place, but an
explicit path survives running one-off commands from another directory. Point
`interests_opml` at an absolute path too if it is still relative.
**Add** the LLM registry. Carry over the `base_url`, `model` and `price_*` values from your old
`[deepseek]` table if you had changed them; the values shown are the defaults.
```toml
[llm]
bulk = "deepseek" # triage, deep assessment, and the fallback for every editor call
editor = "anthropic" # lineup, summaries, the Brief, the weekly profile rebuild
triage_batch_size = 25
deep_batch_size = 8
score_temperature = 0.3
editorial_temperature = 0.8
[providers.deepseek]
kind = "openai"
base_url = "https://api.deepseek.com/v1"
model = "deepseek-v4-flash"
max_daily_usd = 2.0
max_concurrent_requests = 4
price_input_per_mtok = 0.14
price_cache_read_per_mtok = 0.0028
price_cache_write_per_mtok = 0.0
price_output_per_mtok = 0.28
[providers.anthropic]
kind = "anthropic"
base_url = "https://api.anthropic.com"
model = "claude-opus-5"
effort = "high"
max_daily_usd = 3.0
max_concurrent_requests = 4
price_input_per_mtok = 5.0
price_cache_read_per_mtok = 0.5
price_cache_write_per_mtok = 6.25
price_output_per_mtok = 25.0
```
Optional, for the Gemini comparison (section 9):
```toml
[providers.gemini]
kind = "openai" # Gemini's OpenAI-compatible endpoint
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
model = "gemini-3.8-flash"
effort = "high"
max_daily_usd = 3.0
max_concurrent_requests = 4
price_input_per_mtok = 0.75 # $1.50 from 2027-01-01
price_cache_read_per_mtok = 0.075 # $0.15 from 2027-01-01
price_cache_write_per_mtok = 0.0
price_output_per_mtok = 3.75 # includes thinking tokens; $7.50 from 2027-01-01
```
**Leave alone** `[miniflux]`, `[server]`, `[publish]`, `[xtc]`, `[world]`, `[curation]
sections`, `always_include_feeds`, `blocked_domains`. `[voyage]`, `[editorial]`,
`[curation.feedback]` and `[curation.ranking]` all have sensible defaults; copy a section from
`config.example.toml` only when you want to change a value in it.
## 4. Edit the env file
```sh
sudo -e /etc/daily-epub/env
```
| Variable | Action |
|---|---|
| `DAILY_EPUB_DEEPSEEK__API_KEY` | **rename** to `DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY` (the old name is rejected at startup so it cannot silently disable the bulk model) |
| `DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY` | add |
| `DAILY_EPUB_VOYAGE__API_KEY` | add |
| `DAILY_EPUB_PROVIDERS__GEMINI__API_KEY` | add only if you configured `[providers.gemini]` |
| `DAILY_EPUB_MINIFLUX__API_KEY`, `DAILY_EPUB_SERVER__HMAC_SECRET` | unchanged |
Keep it `0600 daily-epub:daily-epub`. Then set hard spend limits in the DeepSeek, Anthropic and
Voyage dashboards: the in-app `max_daily_usd` meters are runaway guards, not accounting.
## 5. Install the reader profile
```sh
sudo install -d -m0750 -o daily-epub -g daily-epub /var/lib/daily-epub/data
sudo install -m0640 -o daily-epub -g daily-epub data/profile.md /var/lib/daily-epub/data/profile.md
# if the OPML is not already there:
sudo install -m0640 -o daily-epub -g daily-epub data/scour-interests.opml /var/lib/daily-epub/data/
```
If the file is missing the run does not fail; it logs a warning and uses the OPML interests only,
which is a much worse prompt. `config check` in the next step tells you whether it was found.
## 6. Check the config as the service user
The units run as `daily-epub` with the env file loaded, so check the same way. This helper runs
one command in that identity, with the env file and the working directory the unit uses, and no
other hardening:
```sh
de() { sudo systemd-run --quiet --wait --pty --collect \
--uid=daily-epub --gid=daily-epub \
-p WorkingDirectory=/var/lib/daily-epub -p EnvironmentFile=/etc/daily-epub/env \
/usr/local/bin/daily-epub --config /etc/daily-epub/config.toml "$@"; }
de config check
```
Expected: every line is a fact, no line starts with `!`. Fix anything marked `MISSING` (a key
name, a path) before continuing. A stale key in the TOML is reported as an error naming its
replacement; go back to section 3.
## 7. Migrate the schema
```sh
de db migrate
sudo -u daily-epub sqlite3 /var/lib/daily-epub/daily-epub.db '.tables'
# expect rating_events, article_embeddings, interest_embeddings, article_assessments,
# candidate_runs; no ratings, feed_priors or scores
sudo -u daily-epub sqlite3 /var/lib/daily-epub/daily-epub.db \
"select label, count(*) from rating_events group by label;"
# your old votes: up → loved, down → not_for_me, source = 'migration'
```
## 8. Warm the embedding cache, then a dry run
```sh
de features backfill --rated-only # the learned set; prints a token estimate first
de features backfill --days 30 # recent articles, so day one is not all cache misses
de generate --dry-run --out /var/lib/daily-epub/out-check
```
The dry run makes real DeepSeek, Claude and Voyage calls but publishes nothing and writes no
`issues` row. Read the printed lineup and the four report lines (`curation:`, `admission:`,
`preference:`, `providers:`); the cost should be well under $1. Then read the EPUB it wrote
(Calibre or KOReader): The Brief, the `Why it's here` line under each headline, and the new
Behind-the-paper chapter before the colophon. Finally:
```sh
de explain --date "$(date +%F)" --near-misses
```
Remove `/var/lib/daily-epub/out-check` when done.
## 9. Go live
```sh
sudo systemctl daemon-reload
sudo systemctl start daily-epub.service
sudo systemctl enable --now daily-epub-generate.timer
sudo systemctl list-timers daily-epub-generate.timer
```
If you want today's paper regenerated by the new pipeline now rather than tomorrow at 05:30:
`sudo systemctl start daily-epub-generate`. A same-date rerun replaces today's issue and is a
new run id in telemetry.
Afterwards:
```sh
journalctl -u daily-epub-generate -n 60 --no-pager # the four-line info block near the end
de stats --days 14
```
## 10. Comparing editors (Claude Opus 5 vs Gemini 3.8 Flash)
With `[providers.gemini]` and its key in place, an A/B needs no config edit: environment variables
override the TOML, so
```sh
sudo systemd-run --quiet --wait --pty --collect --uid=daily-epub --gid=daily-epub \
-p WorkingDirectory=/var/lib/daily-epub -p EnvironmentFile=/etc/daily-epub/env \
-E DAILY_EPUB_LLM__EDITOR=gemini \
/usr/local/bin/daily-epub --config /etc/daily-epub/config.toml \
generate --dry-run --date "$(date +%F)" --out /var/lib/daily-epub/out-gemini
```
produces the same date's paper with Gemini as the editor. Triage and deep assessments are cached
for three days, so the second run costs only the editor, summaries and the Brief. Compare the two
lineups, the `why` lines and the Brief side by side, and the `providers:` cost line. To switch
for good, set `editor = "gemini"` in `[llm]` (and `summary_model` stays `editor`, so summaries
move with it). The same trick works for the bulk role: `DAILY_EPUB_LLM__BULK=gemini`.
## Rollback
```sh
sudo systemctl stop daily-epub-generate.timer daily-epub.service
sudo install -m0755 /usr/local/bin/daily-epub.v1 /usr/local/bin/daily-epub
sudo cp -a /etc/daily-epub/config.toml.v1 /etc/daily-epub/config.toml
sudo cp -a /etc/daily-epub/env.v1 /etc/daily-epub/env
sudo -u daily-epub cp /var/lib/daily-epub/backup/daily-epub-pre-v2-<date>.db /var/lib/daily-epub/daily-epub.db
sudo rm -f /var/lib/daily-epub/daily-epub.db-wal /var/lib/daily-epub/daily-epub.db-shm
sudo systemctl start daily-epub.service daily-epub-generate.timer
```
The database restore is required, not optional: the v1 binary expects `ratings`, `feed_priors`
and `scores`, which v2's migrations drop.
+793 -120
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -481,8 +481,8 @@ pub async fn run(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{CurationConfig, DeepseekConfig}; use crate::config::{CurationConfig, ProviderConfig};
use crate::curate::llm::{MockBackend, UsageMeter}; use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::{article, with_social}; use crate::curate::prefilter::tests::{article, with_social};
use crate::curate::signals::{Neighbour, TopInterest}; use crate::curate::signals::{Neighbour, TopInterest};
use crate::types::{TokenUsage, Triage}; use crate::types::{TokenUsage, Triage};
@@ -520,7 +520,7 @@ mod tests {
LlmClient::with_backend( LlmClient::with_backend(
"deepseek-v4-flash", "deepseek-v4-flash",
"SYSTEM".into(), "SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), limit_usd), UsageMeter::with_prices(PriceTable::from(&ProviderConfig::deepseek()), limit_usd),
backend, backend,
) )
} }
+4 -6
View File
@@ -637,7 +637,7 @@ pub fn select_without_llm(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{AnthropicConfig, CurationConfig, DeepseekConfig}; use crate::config::{CurationConfig, ProviderConfig};
use crate::curate::llm::{ChatBackend, LlmClient, MockBackend, PriceTable, UsageMeter}; use crate::curate::llm::{ChatBackend, LlmClient, MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article; use crate::curate::prefilter::tests::article;
use crate::curate::signals::{Neighbour, TopInterest}; use crate::curate::signals::{Neighbour, TopInterest};
@@ -696,9 +696,9 @@ mod tests {
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient { fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
let prices = if provider == "anthropic" { let prices = if provider == "anthropic" {
PriceTable::anthropic(&AnthropicConfig::default()) PriceTable::from(&ProviderConfig::anthropic())
} else { } else {
PriceTable::deepseek(&DeepseekConfig::default()) PriceTable::from(&ProviderConfig::deepseek())
}; };
LlmClient::with_backend_options( LlmClient::with_backend_options(
provider, provider,
@@ -1079,9 +1079,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() { async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() {
let editor = Arc::new(MockBackend::new()); let editor = Arc::new(MockBackend::new());
editor.push_llm_error(LlmError::Refusal { editor.push_llm_error(LlmError::refusal("anthropic"));
provider: "anthropic",
});
let bulk = Arc::new(MockBackend::new()); let bulk = Arc::new(MockBackend::new());
bulk.push(picks_json(5), TokenUsage::default()); bulk.push(picks_json(5), TokenUsage::default());
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk)); let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
+14 -19
View File
@@ -1,4 +1,4 @@
//! Claude-first summaries and The Brief, with per-call DeepSeek fallback (§14). //! Editor-first summaries and The Brief, with per-call bulk fallback (§14).
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt::Write as _; use std::fmt::Write as _;
@@ -12,7 +12,6 @@ use crate::config::{EditorialConfig, SummaryModel};
use crate::types::{ArticleId, Editorial, Lineup, Pick}; use crate::types::{ArticleId, Editorial, Lineup, Pick};
pub const FALLBACK_SUMMARY_WORDS: usize = 45; pub const FALLBACK_SUMMARY_WORDS: usize = 45;
pub const SUMMARY_CONCURRENCY: usize = 4;
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.
@@ -102,9 +101,7 @@ pub async fn summarize_article(
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::empty_response(llm.provider()));
provider: llm.provider,
});
} }
Ok(summary) Ok(summary)
} }
@@ -172,12 +169,17 @@ pub async fn summarize_all(
temperature: f32, temperature: f32,
) -> BTreeMap<ArticleId, String> { ) -> BTreeMap<ArticleId, String> {
let (primary, fallback) = summary_clients(llms, config.summary_model); let (primary, fallback) = summary_clients(llms, config.summary_model);
// The summary provider's own `max_concurrent_requests` bounds the fan-out.
let concurrency = primary
.map(|client| client.max_concurrent_requests)
.unwrap_or(1)
.max(1);
stream::iter(lineup.picks.iter()) stream::iter(lineup.picks.iter())
.map(|pick| async move { .map(|pick| async move {
let summary = summarize_pick(pick, primary, fallback, config, temperature).await; let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
(pick.article.id, summary) (pick.article.id, summary)
}) })
.buffer_unordered(SUMMARY_CONCURRENCY) .buffer_unordered(concurrency)
.filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) }) .filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
.collect() .collect()
.await .await
@@ -232,10 +234,7 @@ pub async fn brief(
) -> Result<String, LlmError> { ) -> Result<String, LlmError> {
let prompt = build_brief_prompt(lineup, summaries); let prompt = build_brief_prompt(lineup, summaries);
let Some(primary) = llms.editor_or_bulk() else { let Some(primary) = llms.editor_or_bulk() else {
return Err(LlmError::Api { return Err(LlmError::api("editorial", "no provider configured"));
provider: "editorial",
message: "no provider configured".into(),
});
}; };
let response = match primary let response = match primary
.complete_json::<BriefResponse>(&prompt, temperature) .complete_json::<BriefResponse>(&prompt, temperature)
@@ -258,9 +257,7 @@ pub async fn brief(
}; };
let brief = response.brief.trim().to_string(); let brief = response.brief.trim().to_string();
if brief.is_empty() { if brief.is_empty() {
return Err(LlmError::EmptyResponse { return Err(LlmError::empty_response(primary.provider()));
provider: primary.provider,
});
} }
Ok(brief) Ok(brief)
} }
@@ -381,7 +378,7 @@ pub fn summary_to_html(summary: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{AnthropicConfig, DeepseekConfig}; use crate::config::ProviderConfig;
use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, 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;
@@ -420,9 +417,9 @@ mod tests {
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient { fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
let prices = if provider == "anthropic" { let prices = if provider == "anthropic" {
PriceTable::anthropic(&AnthropicConfig::default()) PriceTable::from(&ProviderConfig::anthropic())
} else { } else {
PriceTable::deepseek(&DeepseekConfig::default()) PriceTable::from(&ProviderConfig::deepseek())
}; };
LlmClient::with_backend_options( LlmClient::with_backend_options(
provider, provider,
@@ -534,9 +531,7 @@ mod tests {
r#"{"summary": "Opus wrote this one."}"#, r#"{"summary": "Opus wrote this one."}"#,
TokenUsage::default(), TokenUsage::default(),
); );
editor.push_llm_error(LlmError::Refusal { editor.push_llm_error(LlmError::refusal("anthropic"));
provider: "anthropic",
});
editor.push(BRIEF_FIXTURE, TokenUsage::default()); editor.push(BRIEF_FIXTURE, TokenUsage::default());
let bulk = Arc::new(MockBackend::new()); let bulk = Arc::new(MockBackend::new());
bulk.push( bulk.push(
+535 -255
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -39,7 +39,7 @@ pub struct Curator {
impl Curator { impl Curator {
/// An empty [`llm::Llms`] corresponds to `--skip-llm`: cheap-signal order is /// An empty [`llm::Llms`] corresponds to `--skip-llm`: cheap-signal order is
/// used for selection and feed excerpts stand in for summaries (notes §6). /// used for selection and feed excerpts stand in for summaries (notes §6).
/// With only `bulk`, every editor call runs on DeepSeek (§4.2). /// With only `bulk`, every editor call runs on the bulk provider (§4.2).
pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self { pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self {
Self { config, db, llms } Self { config, db, llms }
} }
@@ -67,15 +67,15 @@ impl Curator {
assess::run( assess::run(
&self.db, &self.db,
Some(bulk), Some(bulk),
&self.config.deepseek.model, &bulk.model,
candidates, candidates,
self.config.deepseek.deep_batch_size, self.config.llm.deep_batch_size,
self.config.deepseek.max_concurrent_requests, bulk.max_concurrent_requests,
self.config.curation.ranking.assessment_reuse_days, self.config.curation.ranking.assessment_reuse_days,
rescore, rescore,
profile_version, profile_version,
assessed_at, assessed_at,
self.config.deepseek.score_temperature, self.config.llm.score_temperature,
&self.config.curation.sections, &self.config.curation.sections,
) )
.await .await
@@ -137,7 +137,7 @@ impl Curator {
&self.llms, &self.llms,
lineup, lineup,
&self.config.editorial, &self.config.editorial,
self.config.deepseek.editorial_temperature, self.config.llm.editorial_temperature,
) )
.await) .await)
} }
+2 -2
View File
@@ -624,7 +624,7 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use super::super::llm::{MockBackend, UsageMeter}; use super::super::llm::{MockBackend, UsageMeter};
use crate::config::DeepseekConfig; use crate::config::ProviderConfig;
use crate::types::{RatingEvent, TokenUsage}; use crate::types::{RatingEvent, TokenUsage};
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -667,7 +667,7 @@ mod tests {
let llm = LlmClient::with_backend( let llm = LlmClient::with_backend(
"deepseek-v4-flash", "deepseek-v4-flash",
initial.text, initial.text,
UsageMeter::new(&DeepseekConfig::default(), 2.0), UsageMeter::for_provider(&ProviderConfig::deepseek()),
backend.clone(), backend.clone(),
); );
let rebuilt = rebuild(&db, &llm, &opml, &profile_path, 60).await.unwrap(); let rebuilt = rebuild(&db, &llm, &opml, &profile_path, 60).await.unwrap();
+3 -2
View File
@@ -1370,7 +1370,7 @@ mod tests {
"2026-09-01", "2026-09-01",
"2026-09-01T09:30:00Z", "2026-09-01T09:30:00Z",
"2026-09-01T09:45:00Z", "2026-09-01T09:45:00Z",
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14}}"#, r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14},"gemini":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.07}}"#,
), ),
] { ] {
let run_id = db let run_id = db
@@ -1452,8 +1452,9 @@ mod tests {
"exploration rated positively: 1", "exploration rated positively: 1",
"cost per day (anthropic): $0.043", "cost per day (anthropic): $0.043",
"cost per day (deepseek): $0.020", "cost per day (deepseek): $0.020",
"cost per day (gemini): $0.005",
"cost per day (voyage): $0.001", "cost per day (voyage): $0.001",
"cost per day (total): $0.064", "cost per day (total): $0.069",
"mean generation time: 15m00s (3 runs)", "mean generation time: 15m00s (3 runs)",
] { ] {
assert!(text.contains(line), "missing {line:?} in:\n{text}"); assert!(text.contains(line), "missing {line:?} in:\n{text}");
+4 -4
View File
@@ -500,8 +500,8 @@ pub async fn run(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::DeepseekConfig; use crate::config::ProviderConfig;
use crate::curate::llm::{MockBackend, UsageMeter}; use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article; use crate::curate::prefilter::tests::article;
use crate::curate::signals::{Neighbour, TopInterest}; use crate::curate::signals::{Neighbour, TopInterest};
use crate::types::TokenUsage; use crate::types::TokenUsage;
@@ -584,11 +584,11 @@ mod tests {
r#"{"articles":[{"id":42,"interest":8,"kind":"essay","why":"first answer"}]}"#, r#"{"articles":[{"id":42,"interest":8,"kind":"essay","why":"first answer"}]}"#,
TokenUsage::default(), TokenUsage::default(),
); );
let config = DeepseekConfig::default(); let config = ProviderConfig::deepseek();
let llm = LlmClient::with_backend( let llm = LlmClient::with_backend(
&config.model, &config.model,
"profile".into(), "profile".into(),
UsageMeter::new(&config, 10.0), UsageMeter::with_prices(PriceTable::from(&config), 10.0),
backend.clone(), backend.clone(),
); );
let pool = HashSet::from([42]); let pool = HashSet::from([42]);
+45 -17
View File
@@ -55,6 +55,15 @@ enum Command {
/// Database maintenance. /// Database maintenance.
#[command(subcommand)] #[command(subcommand)]
Db(DbCommand), Db(DbCommand),
/// Inspect the resolved configuration.
#[command(subcommand)]
Config(ConfigCommand),
}
#[derive(Debug, Subcommand)]
enum ConfigCommand {
/// Load and validate the config as `generate` would, then print one fact per line.
Check,
} }
#[derive(Debug, clap::Args)] #[derive(Debug, clap::Args)]
@@ -309,6 +318,14 @@ async fn main() -> Result<()> {
db.migrate().await?; db.migrate().await?;
println!("migrations up to date: {}", config.database_path.display()); println!("migrations up to date: {}", config.database_path.display());
} }
Command::Config(ConfigCommand::Check) => {
// Reaching here means `Config::load` already validated it; a bad
// config exited non-zero above. Nothing is opened, nothing locked.
let path = Config::resolve_path(cli.config.as_deref());
for line in config.check_report(path.as_deref()) {
println!("{line}");
}
}
} }
Ok(()) Ok(())
} }
@@ -327,7 +344,8 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
| Command::Explain(_) | Command::Explain(_)
| Command::Stats(_) | Command::Stats(_)
| Command::Features(FeaturesCommand::Prune) | Command::Features(FeaturesCommand::Prune)
| Command::Db(_) => None, | Command::Db(_)
| Command::Config(_) => None,
} }
} }
@@ -463,13 +481,7 @@ fn print_lineup(issue: &daily_epub::types::Issue) {
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3). /// `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<()> {
use curate::llm::{Llms, PriceTable, UsageMeter}; use curate::llm::{Llms, provider_meters};
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,
@@ -477,19 +489,23 @@ 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 llms = Llms::from_config( let llms = Llms::from_config(config, profile.text, &provider_meters(config));
&config.deepseek,
&config.anthropic,
profile.text,
bulk_meter,
editor_meter,
);
let Some(llm) = llms.editor_or_bulk() else { let Some(llm) = llms.editor_or_bulk() else {
let keys = config
.referenced_providers()
.iter()
.map(|(name, _)| daily_epub::config::ProviderConfig::api_key_env_var(name))
.collect::<Vec<_>>();
anyhow::bail!( anyhow::bail!(
"no LLM provider is configured; set DAILY_EPUB_ANTHROPIC__API_KEY or DAILY_EPUB_DEEPSEEK__API_KEY" "no LLM provider is available; assign [llm] roles and set {}",
if keys.is_empty() {
"a provider key".to_string()
} else {
keys.join(" or ")
}
); );
}; };
tracing::info!(provider = llm.provider, model = %llm.model, "rebuilding the profile"); 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,
@@ -841,11 +857,23 @@ mod tests {
vec!["ratings", "list"], vec!["ratings", "list"],
vec!["db", "migrate"], vec!["db", "migrate"],
vec!["features", "prune"], vec!["features", "prune"],
vec!["config", "check"],
] { ] {
assert_eq!(lock_holder(&parse(&args)), None, "{args:?}"); assert_eq!(lock_holder(&parse(&args)), None, "{args:?}");
} }
} }
#[test]
fn parses_config_check() {
assert!(matches!(
Cli::try_parse_from(["daily-epub", "--config", "/etc/x.toml", "config", "check"])
.unwrap()
.command,
Command::Config(ConfigCommand::Check)
));
assert!(Cli::try_parse_from(["daily-epub", "config"]).is_err());
}
#[test] #[test]
fn parses_stats() { fn parses_stats() {
match Cli::try_parse_from(["daily-epub", "stats"]) match Cli::try_parse_from(["daily-epub", "stats"])
+148 -84
View File
@@ -16,8 +16,8 @@
//! * **Best effort** — social enrichment, comments, the world briefing, images and //! * **Best effort** — social enrichment, comments, the world briefing, images and
//! the XTC conversion. They log, add a warning to the report (status `degraded`) //! the XTC conversion. They log, add a warning to the report (status `degraded`)
//! and the run continues. //! and the run continues.
//! * **Degrading** — every DeepSeek stage. A missing key, a dead API or a tripped //! * **Degrading** — every LLM stage. A missing key, a dead API or a tripped
//! `max_daily_usd` guardrail turns the run into the `--skip-llm` shape //! provider `max_daily_usd` guardrail turns the run into the `--skip-llm` shape
//! (cheap-signal admission, feed excerpts as summaries) rather than //! (cheap-signal admission, feed excerpts as summaries) rather than
//! losing the day's issue. //! losing the day's issue.
//! //!
@@ -33,7 +33,7 @@ use jiff::civil::Date;
use jiff::{Timestamp, Zoned}; use jiff::{Timestamp, Zoned};
use crate::config::Config; use crate::config::Config;
use crate::curate::llm::{Llms, PriceTable, UsageMeter}; use crate::curate::llm::{Llms, UsageMeter, provider_meters};
use crate::curate::{ use crate::curate::{
Curator, admit, editorial, embedding, profile, rank, signals, telemetry, triage, Curator, admit, editorial, embedding, profile, rank, signals, telemetry, triage,
}; };
@@ -59,7 +59,7 @@ pub struct GenerateOptions {
pub out: Option<PathBuf>, pub out: Option<PathBuf>,
/// `--max-articles N`, overriding `target_article_count`. /// `--max-articles N`, overriding `target_article_count`.
pub max_articles: Option<usize>, pub max_articles: Option<usize>,
/// `--skip-llm`: no DeepSeek call at all. /// `--skip-llm`: no chat-provider call at all.
pub skip_llm: bool, pub skip_llm: bool,
/// `--skip-embeddings`: read the cache but make zero Voyage calls. /// `--skip-embeddings`: read the cache but make zero Voyage calls.
pub skip_embeddings: bool, pub skip_embeddings: bool,
@@ -435,26 +435,23 @@ async fn run_stages(
let article_embeddings = prepare_features(ctx, &mut personalized, &embeddings, report).await; let article_embeddings = prepare_features(ctx, &mut personalized, &embeddings, report).await;
// Build the provider clients before triage. A missing or failed bulk client // Build the provider clients before triage. A missing or failed bulk client
// skips triage and deep assessment, while the editor can still run on Claude (§17). // skips triage and deep assessment, while the editor can still run (§17).
// One meter per referenced provider, keyed by its `[providers.*]` name and
// preloaded with what earlier runs on this UTC day already spent on it.
let stage = Timestamp::now(); let stage = Timestamp::now();
let bulk_meter = let meters = provider_meters(config);
UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
let editor_meter = UsageMeter::with_prices(
PriceTable::anthropic(&config.anthropic),
config.anthropic.max_daily_usd,
);
match db.provider_spend_for_utc_day(ctx.started_at).await { match db.provider_spend_for_utc_day(ctx.started_at).await {
Ok(spend) => { Ok(spend) => {
bulk_meter.preload_cost(spend.get("deepseek").copied().unwrap_or(0.0)); for (name, meter) in &meters {
editor_meter.preload_cost(spend.get("anthropic").copied().unwrap_or(0.0)); meter.preload_cost(spend.get(name).copied().unwrap_or(0.0));
}
} }
Err(error) => { Err(error) => {
tracing::warn!(%error, "could not preload provider spend; starting from zero") tracing::warn!(%error, "could not preload provider spend; starting from zero")
} }
} }
let llms = build_llms(ctx, &bulk_meter, &editor_meter, report).await; let llms = build_llms(ctx, &meters, report).await;
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.soft_target; curator_config.target_article_count = ctx.soft_target;
curator_config.curation.max_article_count = ctx.hard_max; curator_config.curation.max_article_count = ctx.hard_max;
@@ -477,13 +474,13 @@ async fn run_stages(
bulk, bulk,
&mut personalized, &mut personalized,
&triage_pool, &triage_pool,
config.deepseek.triage_batch_size, config.llm.triage_batch_size,
config.deepseek.max_concurrent_requests, bulk.max_concurrent_requests,
config.curation.ranking.assessment_reuse_days, config.curation.ranking.assessment_reuse_days,
ctx.rescore, ctx.rescore,
profile_version, profile_version,
Timestamp::now(), Timestamp::now(),
config.deepseek.score_temperature, config.llm.score_temperature,
) )
.await .await
{ {
@@ -492,7 +489,7 @@ async fn run_stages(
)); ));
} }
} else { } else {
tracing::info!("--skip-llm or DeepSeek unavailable: triage skipped"); tracing::info!("--skip-llm or no bulk provider: triage skipped");
} }
report.counts.triaged = personalized report.counts.triaged = personalized
.iter() .iter()
@@ -661,20 +658,7 @@ 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( let llm_cost = record_provider_costs(report, &meters);
"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(),
},
);
// Voyage rides along in `provider_costs_json` (§7.6) so `stats` can price // Voyage rides along in `provider_costs_json` (§7.6) so `stats` can price
// it per day; its tokens are embedding input, kept out of the LLM aggregate. // it per day; its tokens are embedding input, kept out of the LLM aggregate.
report.provider_costs.insert( report.provider_costs.insert(
@@ -687,31 +671,30 @@ async fn run_stages(
cost_usd: report.voyage_cost_usd, cost_usd: report.voyage_cost_usd,
}, },
); );
let total_cost = bulk_meter.cost_usd() + editor_meter.cost_usd() + report.voyage_cost_usd; let total_cost = llm_cost + report.voyage_cost_usd;
let summary_model = match config.editorial.summary_model { let summary_model = match config.editorial.summary_model {
crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => { crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => {
config.anthropic.model.clone() curator.llms.editor.as_ref().map(|c| c.model.clone())
} }
_ if curator.llms.bulk.is_some() => config.deepseek.model.clone(), _ => curator.llms.bulk.as_ref().map(|c| c.model.clone()),
_ => "none".into(), }
}; .unwrap_or_else(|| "none".into());
let provider_costs = report let provider_costs = report
.provider_costs .provider_costs
.iter() .iter()
.map(|(provider, usage)| (provider.clone(), usage.cost_usd)) .map(|(provider, usage)| (provider.clone(), usage.cost_usd))
.collect(); .collect();
let models = Models { let models = Models {
bulk: if bulk_available { bulk: curator
config.deepseek.model.clone() .llms
} else { .bulk
"none".into() .as_ref()
}, .map(|c| c.model.clone())
editor: if curator.llms.editor.is_some() { .unwrap_or_else(|| "none".into()),
config.anthropic.model.clone() editor: match (&curator.llms.editor, &curator.llms.bulk) {
} else if bulk_available { (Some(editor), _) => editor.model.clone(),
format!("{} (bulk fallback)", config.deepseek.model) (None, Some(bulk)) => format!("{} (bulk fallback)", bulk.model),
} else { (None, None) => "none".into(),
"none".into()
}, },
summaries: summary_model, summaries: summary_model,
}; };
@@ -1069,15 +1052,14 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<(
Ok(()) Ok(())
} }
/// Build the bulk (DeepSeek) and editor (Claude) clients, running the weekly /// Build the bulk and editor clients named in `[llm]`, running the weekly
/// profile rebuild when it is due. /// profile rebuild when it is due.
/// ///
/// Each client is `None` for `--skip-llm` and for every configuration/API /// Each client is `None` for `--skip-llm` and for every configuration/API
/// problem: the pipeline then degrades per §17 instead of failing the run. /// problem: the pipeline then degrades per §17 instead of failing the run.
async fn build_llms( async fn build_llms(
ctx: &StageContext<'_>, ctx: &StageContext<'_>,
bulk_meter: &UsageMeter, meters: &BTreeMap<String, UsageMeter>,
editor_meter: &UsageMeter,
report: &mut RunReport, report: &mut RunReport,
) -> Llms { ) -> Llms {
let profile = match profile::load_or_build( let profile = match profile::load_or_build(
@@ -1102,15 +1084,7 @@ async fn build_llms(
return Llms::default(); return Llms::default();
} }
let make_clients = |prompt: String| { let make_clients = |prompt: String| Llms::from_config(ctx.config, prompt, meters);
Llms::from_config(
&ctx.config.deepseek,
&ctx.config.anthropic,
prompt,
bulk_meter.clone(),
editor_meter.clone(),
)
};
let mut llms = make_clients(profile.text); let mut llms = make_clients(profile.text);
let Some(rebuild_client) = llms.editor_or_bulk() else { let Some(rebuild_client) = llms.editor_or_bulk() else {
@@ -1150,22 +1124,34 @@ pub fn issue_size_bounds(config: &Config, max_articles: Option<usize>) -> (usize
(config.target_article_count.min(hard_max), hard_max) (config.target_article_count.min(hard_max), hard_max)
} }
/// Startup line naming the resolved models and whether each provider is on /// Startup lines naming each role's resolved provider and whether it is on
/// (§19): the root config ignores unknown sections, so an `[anthropics]` or /// (§19): the root config ignores unknown sections, so a `[voyages]` typo
/// `[voyages]` typo would otherwise be silent. Keys are never logged, only /// would otherwise be silent. Keys are never logged, only their presence.
/// their presence.
fn log_resolved_providers(config: &Config, skip_llm: bool, skip_embeddings: bool) { fn log_resolved_providers(config: &Config, skip_llm: bool, skip_embeddings: bool) {
let has_key = |key: Option<&str>| key.is_some_and(|k| !k.trim().is_empty()); let has_key = |key: Option<&str>| key.is_some_and(|k| !k.trim().is_empty());
for (role, name) in config.llm.roles() {
match config.providers.get(name) {
Some(provider) => tracing::info!(
role,
provider = name,
kind = provider.kind.as_str(),
model = %provider.model,
effort = provider.effort.as_deref().unwrap_or("-"),
enabled = !skip_llm && provider.api_key().is_some(),
key_present = provider.api_key().is_some(),
max_daily_usd = provider.max_daily_usd,
"resolved llm role"
),
None => tracing::error!(role, provider = name, "role names an unknown provider"),
}
}
if config.llm.bulk_name().is_none() {
tracing::info!("no bulk provider: triage and deep assessment are skipped");
}
if config.llm.editor_name().is_none() {
tracing::info!("no editor provider: editor work runs on bulk");
}
tracing::info!( 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, summary_model = ?config.editorial.summary_model,
embedding_model = %config.voyage.model, embedding_model = %config.voyage.model,
embedding_enabled = !skip_embeddings embedding_enabled = !skip_embeddings
@@ -1177,6 +1163,24 @@ fn log_resolved_providers(config: &Config, skip_llm: bool, skip_embeddings: bool
); );
} }
/// Every referenced provider's usage into `report.provider_costs`, keyed by
/// its `[providers.*]` name; returns the summed LLM cost.
fn record_provider_costs(report: &mut RunReport, meters: &BTreeMap<String, UsageMeter>) -> f64 {
let mut total = 0.0;
for (name, meter) in meters {
let cost_usd = meter.cost_usd();
total += cost_usd;
report.provider_costs.insert(
name.clone(),
ProviderUsage {
usage: meter.total(),
cost_usd,
},
);
}
total
}
/// Prompt versions recorded per run so old telemetry stays interpretable (§7.6). /// Prompt versions recorded per run so old telemetry stays interpretable (§7.6).
/// Bump a number when the corresponding instruction block changes. /// Bump a number when the corresponding instruction block changes.
const PROMPT_VERSIONS: &[(&str, u32)] = &[ const PROMPT_VERSIONS: &[(&str, u32)] = &[
@@ -1189,13 +1193,17 @@ const PROMPT_VERSIONS: &[(&str, u32)] = &[
]; ];
/// The resolved `[curation]` (ranking included), `[editorial]`, `[voyage]`, /// The resolved `[curation]` (ranking included), `[editorial]`, `[voyage]`,
/// model names and prompt versions written to `runs.config_json` (§7.6, §19). /// `[llm]`, the provider registry, model names and prompt versions written to
/// Never includes keys. /// `runs.config_json` (§7.6, §19). Never includes keys.
fn resolved_run_config(config: &Config, soft_target: usize, hard_max: usize) -> serde_json::Value { fn resolved_run_config(config: &Config, soft_target: usize, hard_max: usize) -> serde_json::Value {
let mut curation = config.curation.clone(); let mut curation = config.curation.clone();
curation.max_article_count = hard_max; curation.max_article_count = hard_max;
let mut voyage = config.voyage.clone(); let mut voyage = config.voyage.clone();
voyage.api_key = None; voyage.api_key = None;
let model_of = |role: Option<(&str, &crate::config::ProviderConfig)>| {
role.map(|(_, provider)| provider.model.clone())
.unwrap_or_else(|| "disabled".into())
};
serde_json::json!({ serde_json::json!({
"target_article_count": soft_target, "target_article_count": soft_target,
"TRIAGE_PROMPT_VERSION": triage::TRIAGE_PROMPT_VERSION, "TRIAGE_PROMPT_VERSION": triage::TRIAGE_PROMPT_VERSION,
@@ -1203,10 +1211,14 @@ fn resolved_run_config(config: &Config, soft_target: usize, hard_max: usize) ->
"curation": curation, "curation": curation,
"editorial": config.editorial, "editorial": config.editorial,
"voyage": voyage, "voyage": voyage,
"llm": config.llm,
"providers": config.providers_redacted(),
"models": { "models": {
"bulk": config.deepseek.model, "bulk": model_of(config.bulk_provider()),
"editor": if config.anthropic.enabled { config.anthropic.model.as_str() } else { "disabled" }, "editor": model_of(config.editor_provider()),
"editor_effort": config.anthropic.effort, "editor_effort": config
.editor_provider()
.and_then(|(_, provider)| provider.effort.clone()),
"embedding": if config.voyage.enabled { config.voyage.model.as_str() } else { "disabled" }, "embedding": if config.voyage.enabled { config.voyage.model.as_str() } else { "disabled" },
}, },
"prompt_versions": PROMPT_VERSIONS "prompt_versions": PROMPT_VERSIONS
@@ -1277,8 +1289,9 @@ mod tests {
#[test] #[test]
fn run_config_json_records_the_resolved_settings_and_no_keys() { fn run_config_json_records_the_resolved_settings_and_no_keys() {
let mut config = Config::default(); let mut config = Config::default();
config.anthropic.api_key = Some("sk-secret".into()); for provider in config.providers.values_mut() {
config.deepseek.api_key = Some("ds-secret".into()); provider.api_key = Some("sk-secret".into());
}
config.voyage.api_key = Some("pa-secret".into()); config.voyage.api_key = Some("pa-secret".into());
let value = resolved_run_config(&config, 6, 6); let value = resolved_run_config(&config, 6, 6);
assert_eq!(value["target_article_count"], 6); assert_eq!(value["target_article_count"], 6);
@@ -1296,6 +1309,19 @@ mod tests {
assert_eq!(value["editorial"]["summary_input_tokens"], 3000); assert_eq!(value["editorial"]["summary_input_tokens"], 3000);
assert_eq!(value["models"]["bulk"], "deepseek-v4-flash"); assert_eq!(value["models"]["bulk"], "deepseek-v4-flash");
assert_eq!(value["models"]["editor"], "claude-opus-5"); assert_eq!(value["models"]["editor"], "claude-opus-5");
assert_eq!(value["models"]["editor_effort"], "high");
assert_eq!(value["llm"]["bulk"], "deepseek");
assert_eq!(value["llm"]["editor"], "anthropic");
assert_eq!(value["llm"]["triage_batch_size"], 25);
assert_eq!(value["providers"]["gemini"]["kind"], "openai");
assert_eq!(value["providers"]["anthropic"]["max_daily_usd"], 3.0);
for provider in value["providers"].as_object().expect("providers") {
assert!(
provider.1["api_key"].is_null(),
"{} leaked its key",
provider.0
);
}
assert!(value["prompt_versions"]["editor"].is_number()); assert!(value["prompt_versions"]["editor"].is_number());
assert_eq!( assert_eq!(
value["TRIAGE_PROMPT_VERSION"], value["TRIAGE_PROMPT_VERSION"],
@@ -1314,6 +1340,44 @@ mod tests {
!text.contains("secret"), !text.contains("secret"),
"keys must never reach the database" "keys must never reach the database"
); );
let mut config = Config::default();
config.llm.editor.clear();
let value = resolved_run_config(&config, 6, 6);
assert_eq!(value["models"]["editor"], "disabled");
assert!(value["models"]["editor_effort"].is_null());
}
/// `provider_costs` is keyed by whatever the operator named the providers,
/// never by a hard-coded "deepseek" / "anthropic".
#[test]
fn provider_costs_are_keyed_by_the_configured_provider_names() {
let mut config = Config::default();
let bulk = config.providers.remove("deepseek").expect("deepseek");
config.providers.insert("bulkprov".into(), bulk);
config.llm.bulk = "bulkprov".into();
config.llm.editor = "gemini".into();
config.validate().expect("renamed provider validates");
let meters = provider_meters(&config);
assert_eq!(
meters.keys().collect::<Vec<_>>(),
vec!["bulkprov", "gemini"]
);
meters["bulkprov"].record(TokenUsage {
input_tokens: 1_000_000,
..TokenUsage::default()
});
let mut report = RunReport::new(run_date(), now());
let total = record_provider_costs(&mut report, &meters);
assert_eq!(
report.provider_costs.keys().collect::<Vec<_>>(),
vec!["bulkprov", "gemini"]
);
assert!(!report.provider_costs.contains_key("deepseek"));
assert!((report.provider_costs["bulkprov"].cost_usd - 0.14).abs() < 1e-9);
assert_eq!(report.provider_costs["gemini"].cost_usd, 0.0);
assert!((total - 0.14).abs() < 1e-9);
} }
#[test] #[test]
@@ -1602,14 +1666,14 @@ mod tests {
assert_eq!(thin, "{}"); assert_eq!(thin, "{}");
// Admission replaces the old prefilter and carries retriever telemetry. // Admission replaces the old prefilter and carries retriever telemetry.
// DeepSeek is "down": the bulk client exists but every call fails, so // The bulk provider is "down": the client exists but every call fails, so
// the deep set is ranked on present signals and the editor falls back // the deep set is ranked on present signals and the editor falls back
// to utility order (§17). // to utility order (§17).
let bulk_backend = Arc::new(ChatMockBackend::new()); let bulk_backend = Arc::new(ChatMockBackend::new());
let bulk = LlmClient::with_backend( let bulk = LlmClient::with_backend(
&h.config.deepseek.model, &h.config.providers["deepseek"].model,
"SYSTEM".into(), "SYSTEM".into(),
UsageMeter::new(&h.config.deepseek, h.config.max_daily_usd), UsageMeter::for_provider(&h.config.providers["deepseek"]),
bulk_backend.clone(), bulk_backend.clone(),
); );
let curator = Curator::new( let curator = Curator::new(
+5 -2
View File
@@ -718,11 +718,14 @@ mod tests {
backend: std::sync::Arc<crate::curate::llm::MockBackend>, backend: std::sync::Arc<crate::curate::llm::MockBackend>,
limit: f64, limit: f64,
) -> LlmClient { ) -> LlmClient {
let config = crate::config::DeepseekConfig::default(); let config = crate::config::ProviderConfig::deepseek();
LlmClient::with_backend( LlmClient::with_backend(
"mock", "mock",
"World Briefing test".into(), "World Briefing test".into(),
crate::curate::llm::UsageMeter::new(&config, limit), crate::curate::llm::UsageMeter::with_prices(
crate::curate::llm::PriceTable::from(&config),
limit,
),
backend, backend,
) )
} }
+69
View File
@@ -0,0 +1,69 @@
//! `daily-epub config check` end to end: the built binary, a temp config, no
//! environment. It must validate like `generate`, print the provider table
//! with `key MISSING` warnings, exit 0, and exit non-zero on an invalid file —
//! all without a database or the run lock.
use std::path::Path;
use std::process::Command;
fn run(config_body: &str) -> (i32, String, String) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
std::fs::write(&path, config_body).expect("write config");
let output = Command::new(env!("CARGO_BIN_EXE_daily-epub"))
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.args(["--config"])
.arg(&path)
.args(["config", "check"])
.output()
.expect("run daily-epub");
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn config_check_prints_the_facts_and_exits_zero_without_keys() {
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml");
let body = std::fs::read_to_string(example).expect("example config");
let (code, stdout, stderr) = run(&body);
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
for needle in [
"config: ",
"database_path: /var/lib/daily-epub/daily-epub.db",
"profile_path: data/profile.md",
"interests_opml: data/scour-interests.opml",
"llm.bulk: deepseek · openai · deepseek-v4-flash",
"key MISSING (set DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY)",
"llm.editor: anthropic · anthropic · claude-opus-5 · effort high · max_daily_usd $3.00",
"key MISSING (set DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY)",
"providers.gemini: unreferenced",
"voyage: voyage-4-lite · enabled · max_daily_usd $0.50 · key MISSING (set DAILY_EPUB_VOYAGE__API_KEY)",
"editorial.summary_model: editor",
"publish.epub_dir: /srv/bookorbit/libraries/daily-epub",
"publish.xtc_dir: /var/lib/daily-epub/xtc",
] {
assert!(stdout.contains(needle), "missing {needle:?} in:\n{stdout}");
}
assert!(
stdout.lines().any(|line| line.starts_with("! ")),
"missing keys are flagged with a `!` prefix:\n{stdout}"
);
// No lock file, no database: the command is read-only.
assert!(!Path::new("/var/lib/daily-epub/daily-epub.db.lock").exists());
}
#[test]
fn config_check_exits_non_zero_on_an_invalid_config() {
let (code, stdout, stderr) = run("[llm]\nbulk = \"nope\"\n");
assert_ne!(code, 0);
assert!(stdout.is_empty(), "{stdout}");
assert!(stderr.contains("providers.nope"), "{stderr}");
let (code, _, stderr) = run("[deepseek]\nmodel = \"x\"\n");
assert_ne!(code, 0);
assert!(stderr.contains("[providers.deepseek]"), "{stderr}");
}
+11 -11
View File
@@ -570,9 +570,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
usage, usage,
); );
let meter = UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd); let meter = UsageMeter::for_provider(&cfg.providers["deepseek"]);
let llm = LlmClient::with_backend( let llm = LlmClient::with_backend(
&cfg.deepseek.model, &cfg.providers["deepseek"].model,
"You are the editor of The Daily EPUB.".into(), "You are the editor of The Daily EPUB.".into(),
meter.clone(), meter.clone(),
backend.clone(), backend.clone(),
@@ -642,9 +642,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
let colophon = Colophon { let colophon = Colophon {
provider_costs: BTreeMap::from([("deepseek".to_string(), meter.cost_usd())]), provider_costs: BTreeMap::from([("deepseek".to_string(), meter.cost_usd())]),
models: Models { models: Models {
bulk: cfg.deepseek.model.clone(), bulk: cfg.providers["deepseek"].model.clone(),
editor: format!("{} (bulk fallback)", cfg.deepseek.model), editor: format!("{} (bulk fallback)", cfg.providers["deepseek"].model),
summaries: cfg.deepseek.model.clone(), summaries: cfg.providers["deepseek"].model.clone(),
}, },
entries_fetched: 8, entries_fetched: 8,
feeds_seen: 8, feeds_seen: 8,
@@ -655,7 +655,7 @@ 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.models.bulk, cfg.deepseek.model); assert_eq!(issue.colophon.models.bulk, cfg.providers["deepseek"].model);
assert!(issue.colophon.cost_usd > 0.0); assert!(issue.colophon.cost_usd > 0.0);
} }
@@ -682,9 +682,9 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
let backend = std::sync::Arc::new(MockBackend::new()); let backend = std::sync::Arc::new(MockBackend::new());
let client = LlmClient::with_backend( let client = LlmClient::with_backend(
&cfg.deepseek.model, &cfg.providers["deepseek"].model,
"reader profile".into(), "reader profile".into(),
UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd), UsageMeter::for_provider(&cfg.providers["deepseek"]),
backend.clone(), backend.clone(),
); );
let curator = Curator::new( let curator = Curator::new(
@@ -732,9 +732,9 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
Colophon { Colophon {
provider_costs: BTreeMap::new(), provider_costs: BTreeMap::new(),
models: Models { models: Models {
bulk: cfg.deepseek.model.clone(), bulk: cfg.providers["deepseek"].model.clone(),
editor: format!("{} (bulk fallback)", cfg.deepseek.model), editor: format!("{} (bulk fallback)", cfg.providers["deepseek"].model),
summaries: cfg.deepseek.model.clone(), summaries: cfg.providers["deepseek"].model.clone(),
}, },
entries_fetched: 8, entries_fetched: 8,
feeds_seen: 8, feeds_seen: 8,