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:
@@ -21,9 +21,12 @@ it all cost.
|
||||
|
||||
Steady-state cost is roughly **$1/day**: $0.05–0.30 in DeepSeek tokens plus
|
||||
~$0.50–0.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
|
||||
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)
|
||||
- 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
|
||||
─▶ hygiene ─▶ embeddings (Voyage) + cheap signals ─▶ triage (DeepSeek)
|
||||
─▶ union admission ─▶ deep assessment (DeepSeek) ─▶ utility + diversity
|
||||
─▶ editor (Claude) ─▶ comments ─▶ editorial (Claude)
|
||||
─▶ hygiene ─▶ embeddings (Voyage) + cheap signals ─▶ triage (bulk LLM)
|
||||
─▶ union admission ─▶ deep assessment (bulk LLM) ─▶ utility + diversity
|
||||
─▶ editor (editor LLM) ─▶ comments ─▶ editorial (editor LLM)
|
||||
─▶ 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.
|
||||
Social lookups, comment fetching, the world briefing, images and the XTC
|
||||
conversion are best-effort: they log, add a warning (run status `degraded`) and
|
||||
the run continues. Every LLM stage *degrades*: a Claude call that fails, is
|
||||
refused, or is over its daily ceiling is retried with the same prompt on
|
||||
DeepSeek; if DeepSeek is missing, dead or over budget too, the run takes the
|
||||
`--skip-llm` shape (admission uses cheap signals and feed excerpts stand in for
|
||||
summaries) instead of losing the day's issue. Anthropic's server-side refusal
|
||||
fallback (`fallbacks = "default"`) is enabled on every editor request.
|
||||
the run continues. Every LLM stage *degrades*: an editor call that fails, is
|
||||
refused, or is over its provider's daily ceiling is retried with the same
|
||||
prompt on the bulk provider; if the bulk provider is missing, dead or over
|
||||
budget too, the run takes the `--skip-llm` shape (admission uses cheap signals
|
||||
and feed excerpts stand in for summaries) instead of losing the day's issue.
|
||||
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` |
|
||||
| **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. |
|
||||
| **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 **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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -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 backfill-social [--days 7] # re-poll social scores for recent articles
|
||||
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
|
||||
@@ -160,6 +168,15 @@ than `curation.ranking.embedding_retention_days`, and `candidate_runs` rows and
|
||||
`article_assessments` older than `curation.ranking.telemetry_retention_days`.
|
||||
`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
|
||||
@@ -171,12 +188,43 @@ Start from [`config.example.toml`](config.example.toml). Load order, later wins:
|
||||
3. `DAILY_EPUB_*` environment variables
|
||||
|
||||
Nested keys use a **double underscore**: `[miniflux] api_key` becomes
|
||||
`DAILY_EPUB_MINIFLUX__API_KEY`. 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).
|
||||
`DAILY_EPUB_MINIFLUX__API_KEY`, and a provider's key is
|
||||
`DAILY_EPUB_PROVIDERS__<NAME>__API_KEY` with the `[providers.<name>]` table name
|
||||
upper-cased — the shipped registry reads `DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY`,
|
||||
`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
|
||||
|
||||
@@ -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. |
|
||||
| `retention_days` | `21` | EPUBs older than this are deleted from `publish.epub_dir`. SQLite history is kept forever. |
|
||||
| `xtc_retention_count` | `5` | How many XTC issues to keep in `publish.xtc_dir`. Counted, not dated: each `.xtch` is ~80–100 MB, so the binding constraint is disk, not age. |
|
||||
| `max_daily_usd` | `2.0` | Ceiling on DeepSeek spend per **UTC day** of the run's start, not per run — a re-run inherits what earlier runs that day already spent (`runs.provider_costs_json`). Tripping it skips remaining DeepSeek calls; in-flight requests finish and the paper still publishes. |
|
||||
| `world_briefing` | `true` | Include the Wikipedia Current Events section. |
|
||||
| `database_path` | `/var/lib/daily-epub/daily-epub.db` | SQLite file; parent dirs are created. |
|
||||
| `out_dir` | `/var/lib/daily-epub/out` | Where `generate` writes artifacts before publishing. |
|
||||
@@ -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.api_key` | — | **`DAILY_EPUB_MINIFLUX__API_KEY`**. Required. |
|
||||
| `miniflux.page_limit` | `250` | Entries per page; Miniflux caps this at 250. |
|
||||
| `deepseek.base_url` | `https://api.deepseek.com/v1` | OpenAI-compatible endpoint. |
|
||||
| `deepseek.model` | `deepseek-v4-flash` | Verified 2026-08-15 (DeepSeek-V4-Flash-0731). |
|
||||
| `deepseek.api_key` | — | **`DAILY_EPUB_DEEPSEEK__API_KEY`**. Absent ⇒ the run curates heuristically. |
|
||||
| `deepseek.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. |
|
||||
| `deepseek.max_concurrent_requests` | `4` | Triage and deep-assessment batches in flight at once; the budget is checked before each is spawned. |
|
||||
| `deepseek.score_temperature` | `0.3` | Scoring temperature. |
|
||||
| `deepseek.editorial_temperature` | `0.8` | Summaries and The Brief, only when DeepSeek is the fallback editor. |
|
||||
| `deepseek.price_input_per_mtok` | `0.14` | USD per 1M cache-miss input tokens (cost guardrail arithmetic). |
|
||||
| `deepseek.price_cached_input_per_mtok` | `0.0028` | USD per 1M prefix-cache-hit input tokens. |
|
||||
| `deepseek.price_output_per_mtok` | `0.28` | USD per 1M output tokens. |
|
||||
| `anthropic.enabled` | `true` | `false` runs every editor call on DeepSeek. |
|
||||
| `anthropic.base_url` | `https://api.anthropic.com` | Messages API root. |
|
||||
| `anthropic.model` | `claude-opus-5` | The editor. Requests carry `output_config.effort`, a cached system block, and `fallbacks = "default"` with the `server-side-fallback-2026-07-01` beta so a classifier refusal is re-routed server-side. |
|
||||
| `anthropic.api_key` | — | **`DAILY_EPUB_ANTHROPIC__API_KEY`**. Absent ⇒ editor calls fall back to DeepSeek. |
|
||||
| `anthropic.effort` | `high` | `low`, `medium`, `high`, `xhigh` or `max`. |
|
||||
| `anthropic.price_input_per_mtok` | `5.0` | USD per 1M uncached input tokens. |
|
||||
| `anthropic.price_cache_write_per_mtok` | `6.25` | USD per 1M tokens written to the prompt cache. |
|
||||
| `anthropic.price_cache_read_per_mtok` | `0.5` | USD per 1M cache-read input tokens. |
|
||||
| `anthropic.price_output_per_mtok` | `25.0` | USD per 1M output tokens. |
|
||||
| `anthropic.max_daily_usd` | `3.0` | Claude ceiling per UTC day; tripping it moves the remaining editor work to DeepSeek. |
|
||||
| `anthropic.max_concurrent_requests` | `4` | Reserved for the parallel editor stages. |
|
||||
| `llm.bulk` | `deepseek` | The `[providers.*]` name that runs triage, deep assessment and every fallback. `""` ⇒ no bulk provider (those stages are skipped). |
|
||||
| `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. |
|
||||
| `llm.triage_batch_size` | `25` | Articles per first-pass triage request. |
|
||||
| `llm.deep_batch_size` | `8` | Articles per close-reading assessment request. The removed `score_batch_size` key is a startup error. |
|
||||
| `llm.score_temperature` | `0.3` | Scoring temperature, sent only to `openai`-kind providers. |
|
||||
| `llm.editorial_temperature` | `0.8` | Summaries and The Brief on an `openai`-kind provider. |
|
||||
| `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`. |
|
||||
| `providers.<name>.base_url` | — | Endpoint root. DeepSeek `https://api.deepseek.com/v1`; Anthropic `https://api.anthropic.com`; Gemini `https://generativelanguage.googleapis.com/v1beta/openai`. |
|
||||
| `providers.<name>.model` | — | `deepseek-v4-flash` (verified 2026-08-15), `claude-opus-5`, `gemini-3.8-flash` (verified 2026-09-02). |
|
||||
| `providers.<name>.api_key` | — | **`DAILY_EPUB_PROVIDERS__<NAME>__API_KEY`**, environment only. Absent ⇒ that role is unavailable and degrades (bulk ⇒ heuristic curation, editor ⇒ bulk). |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `providers.<name>.max_concurrent_requests` | `4` | Triage and deep-assessment batches in flight on the bulk provider; summaries in flight on the summary provider. |
|
||||
| `providers.<name>.price_input_per_mtok` | `0.14` / `5.0` / `0.75` | USD per 1M cache-miss input tokens (cost guardrail arithmetic). |
|
||||
| `providers.<name>.price_cache_read_per_mtok` | `0.0028` / `0.5` / `0.075` | USD per 1M cache-hit input tokens. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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.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_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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -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 tee /etc/daily-epub/env >/dev/null <<EOF
|
||||
DAILY_EPUB_MINIFLUX__API_KEY=…
|
||||
DAILY_EPUB_DEEPSEEK__API_KEY=…
|
||||
DAILY_EPUB_ANTHROPIC__API_KEY=…
|
||||
DAILY_EPUB_PROVIDERS__DEEPSEEK__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_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
|
||||
EOF
|
||||
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
|
||||
# `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` are the two
|
||||
# keys curation v2 added; the units read them from this file unchanged. Either
|
||||
# may be left unset: the run then falls back to DeepSeek / cached embeddings.
|
||||
# One key per [providers.<name>] entry a role uses, named after the table
|
||||
# (upper-cased). Any may be left unset: that role is then unavailable and the
|
||||
# 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
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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,
|
||||
# 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
|
||||
# → check the lineup is sane (at most 6 picks, each with a "why" line) and the
|
||||
# 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
|
||||
shared `reqwest` client instead, behind a `ChatBackend` trait. The dependency
|
||||
was removed.
|
||||
- **Two chat providers are wired**, DeepSeek (bulk) and Anthropic (editor),
|
||||
each a `ChatBackend` impl with its own `UsageMeter` and price table. A third
|
||||
means another impl. Voyage AI embeddings sit behind the analogous
|
||||
`EmbeddingBackend` trait in `curate/embedding.rs`.
|
||||
- **Two wire protocols are implemented**, `openai` (chat completions) and
|
||||
`anthropic` (Messages API), each a `ChatBackend` impl. Providers are config
|
||||
entries over those two kinds, each with its own `UsageMeter`, price table and
|
||||
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
|
||||
article gets interest, rated-neighbour, feed-affinity, social and heuristic
|
||||
signals, then DeepSeek reads its opening (up to `triage_max`). The deep set is
|
||||
|
||||
+52
-22
@@ -3,18 +3,19 @@
|
||||
# Load order (later wins): built-in defaults ← this file ← `DAILY_EPUB_*` env vars.
|
||||
# Nested keys use a double underscore in env vars, e.g.
|
||||
# DAILY_EPUB_MINIFLUX__API_KEY=...
|
||||
# DAILY_EPUB_DEEPSEEK__API_KEY=...
|
||||
# DAILY_EPUB_ANTHROPIC__API_KEY=...
|
||||
# DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY=...
|
||||
# DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY=...
|
||||
# DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=...
|
||||
# DAILY_EPUB_VOYAGE__API_KEY=...
|
||||
# DAILY_EPUB_SERVER__HMAC_SECRET=...
|
||||
# DAILY_EPUB_LOOKBACK_HOURS=30
|
||||
# DAILY_EPUB_LLM__EDITOR=gemini # one-off role override, no file edit
|
||||
|
||||
timezone = "America/New_York"
|
||||
lookback_hours = 26
|
||||
target_article_count = 20
|
||||
retention_days = 21 # EPUBs, by age
|
||||
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
|
||||
|
||||
# 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
|
||||
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"
|
||||
model = "deepseek-v4-flash" # DeepSeek-V4-Flash-0731 (confirmed 2026-08-15)
|
||||
# api_key via DAILY_EPUB_DEEPSEEK__API_KEY env
|
||||
deep_batch_size = 8 # articles per close-reading assessment request
|
||||
triage_batch_size = 25 # articles per first-pass triage request
|
||||
# api_key via DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY env
|
||||
max_daily_usd = 2.0
|
||||
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_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
|
||||
|
||||
# Claude is the editor: selection, summaries, The Brief and the weekly profile
|
||||
# rebuild. Every call degrades to DeepSeek when the key is missing, the daily
|
||||
# ceiling is hit, or the API refuses/fails. Server-side refusal fallback
|
||||
# (`fallbacks = "default"`) is always on. Set a spend limit in the Anthropic
|
||||
# dashboard too: `max_daily_usd` is a runaway guard, not accounting.
|
||||
[anthropic]
|
||||
enabled = true
|
||||
# Claude Opus 5 over the Messages API. Requests carry `output_config.effort`,
|
||||
# a cached system block, and `fallbacks = "default"` so a classifier refusal is
|
||||
# re-routed server-side; a refusal that still comes back degrades to bulk.
|
||||
[providers.anthropic]
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.anthropic.com"
|
||||
model = "claude-opus-5"
|
||||
# api_key via DAILY_EPUB_ANTHROPIC__API_KEY env
|
||||
effort = "high" # low | medium | high | xhigh | max
|
||||
# api_key via DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY env
|
||||
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_cache_write_per_mtok = 6.25
|
||||
price_cache_read_per_mtok = 0.5
|
||||
price_cache_write_per_mtok = 6.25
|
||||
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_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
|
||||
# `enabled = false` (or leave the key unset) and the paper still builds: the
|
||||
@@ -154,7 +184,7 @@ per_cluster_cap = 2
|
||||
utility_protected = 10
|
||||
|
||||
[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
|
||||
|
||||
[publish]
|
||||
|
||||
@@ -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
|
||||
call to DeepSeek. Usage fields: `input_tokens` (uncached remainder),
|
||||
`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`
|
||||
with `Authorization: Bearer <key>`; body `{input: [...], model: "voyage-4-lite", input_type:
|
||||
"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.
|
||||
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
|
||||
goes through `curate/llm.rs`: `LlmClient { system_prompt, model, meter, backend, retry }`
|
||||
over the `ChatBackend` trait, with `DeepseekBackend` (OpenAI-compatible chat completions,
|
||||
`response_format: json_object`) and `AnthropicBackend` (Messages API, facts above). The
|
||||
pipeline holds `Llms { bulk, editor }`; `editor_or_bulk()` degrades to DeepSeek when the
|
||||
Claude client is missing or its meter is tripped. One `UsageMeter` per provider
|
||||
(DeepSeek, Anthropic, Voyage) with its own price table and `max_daily_usd`. The system
|
||||
prompt is sent first and byte-identical within a run so both providers' prefix caches hit.
|
||||
goes through `curate/llm.rs`: `LlmClient { provider, system_prompt, model, effort,
|
||||
max_concurrent_requests, meter, backend, retry }` over the `ChatBackend` trait, with two
|
||||
wire protocols — `OpenAiCompatibleBackend` (`{base_url}/chat/completions`, bearer key,
|
||||
`response_format: json_object`, `reasoning_effort` when the provider has an `effort`) and
|
||||
`AnthropicBackend` (Messages API, facts above). **Providers are config, not code**: the
|
||||
`[providers.<name>]` registry (`kind = openai | anthropic`, `base_url`, `model`, `effort`,
|
||||
`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
|
||||
`tests/fixtures/`. Never hit the network in tests: `MockBackend` (`ChatBackend`) and the
|
||||
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
|
||||
`article_assessments` older than `telemetry_retention_days` (180). `features prune` runs it
|
||||
on demand; `generate` runs it once after publishing, best effort.
|
||||
- **Keys**: `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` map onto
|
||||
`AnthropicConfig.api_key` / `VoyageConfig.api_key` through figment; the fields exist only
|
||||
for that mapping and are never documented in TOML, logged, or stored.
|
||||
- **Keys**: `DAILY_EPUB_PROVIDERS__<NAME>__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` map onto
|
||||
`ProviderConfig.api_key` / `VoyageConfig.api_key` through figment; the fields exist only
|
||||
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` |
|
||||
| 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`
|
||||
(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
|
||||
|
||||
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.
|
||||
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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -481,8 +481,8 @@ pub async fn run(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{CurationConfig, DeepseekConfig};
|
||||
use crate::curate::llm::{MockBackend, UsageMeter};
|
||||
use crate::config::{CurationConfig, ProviderConfig};
|
||||
use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::{article, with_social};
|
||||
use crate::curate::signals::{Neighbour, TopInterest};
|
||||
use crate::types::{TokenUsage, Triage};
|
||||
@@ -520,7 +520,7 @@ mod tests {
|
||||
LlmClient::with_backend(
|
||||
"deepseek-v4-flash",
|
||||
"SYSTEM".into(),
|
||||
UsageMeter::new(&DeepseekConfig::default(), limit_usd),
|
||||
UsageMeter::with_prices(PriceTable::from(&ProviderConfig::deepseek()), limit_usd),
|
||||
backend,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -637,7 +637,7 @@ pub fn select_without_llm(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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::prefilter::tests::article;
|
||||
use crate::curate::signals::{Neighbour, TopInterest};
|
||||
@@ -696,9 +696,9 @@ mod tests {
|
||||
|
||||
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
|
||||
let prices = if provider == "anthropic" {
|
||||
PriceTable::anthropic(&AnthropicConfig::default())
|
||||
PriceTable::from(&ProviderConfig::anthropic())
|
||||
} else {
|
||||
PriceTable::deepseek(&DeepseekConfig::default())
|
||||
PriceTable::from(&ProviderConfig::deepseek())
|
||||
};
|
||||
LlmClient::with_backend_options(
|
||||
provider,
|
||||
@@ -1079,9 +1079,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() {
|
||||
let editor = Arc::new(MockBackend::new());
|
||||
editor.push_llm_error(LlmError::Refusal {
|
||||
provider: "anthropic",
|
||||
});
|
||||
editor.push_llm_error(LlmError::refusal("anthropic"));
|
||||
let bulk = Arc::new(MockBackend::new());
|
||||
bulk.push(picks_json(5), TokenUsage::default());
|
||||
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
|
||||
|
||||
+14
-19
@@ -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::fmt::Write as _;
|
||||
@@ -12,7 +12,6 @@ use crate::config::{EditorialConfig, SummaryModel};
|
||||
use crate::types::{ArticleId, Editorial, Lineup, Pick};
|
||||
|
||||
pub const FALLBACK_SUMMARY_WORDS: usize = 45;
|
||||
pub const SUMMARY_CONCURRENCY: usize = 4;
|
||||
|
||||
pub const SUMMARY_INSTRUCTIONS: &str = "\
|
||||
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 summary = response.summary.trim().to_string();
|
||||
if summary.is_empty() {
|
||||
return Err(LlmError::EmptyResponse {
|
||||
provider: llm.provider,
|
||||
});
|
||||
return Err(LlmError::empty_response(llm.provider()));
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -172,12 +169,17 @@ pub async fn summarize_all(
|
||||
temperature: f32,
|
||||
) -> BTreeMap<ArticleId, String> {
|
||||
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())
|
||||
.map(|pick| async move {
|
||||
let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
|
||||
(pick.article.id, summary)
|
||||
})
|
||||
.buffer_unordered(SUMMARY_CONCURRENCY)
|
||||
.buffer_unordered(concurrency)
|
||||
.filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
|
||||
.collect()
|
||||
.await
|
||||
@@ -232,10 +234,7 @@ pub async fn brief(
|
||||
) -> Result<String, LlmError> {
|
||||
let prompt = build_brief_prompt(lineup, summaries);
|
||||
let Some(primary) = llms.editor_or_bulk() else {
|
||||
return Err(LlmError::Api {
|
||||
provider: "editorial",
|
||||
message: "no provider configured".into(),
|
||||
});
|
||||
return Err(LlmError::api("editorial", "no provider configured"));
|
||||
};
|
||||
let response = match primary
|
||||
.complete_json::<BriefResponse>(&prompt, temperature)
|
||||
@@ -258,9 +257,7 @@ pub async fn brief(
|
||||
};
|
||||
let brief = response.brief.trim().to_string();
|
||||
if brief.is_empty() {
|
||||
return Err(LlmError::EmptyResponse {
|
||||
provider: primary.provider,
|
||||
});
|
||||
return Err(LlmError::empty_response(primary.provider()));
|
||||
}
|
||||
Ok(brief)
|
||||
}
|
||||
@@ -381,7 +378,7 @@ pub fn summary_to_html(summary: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AnthropicConfig, DeepseekConfig};
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::article;
|
||||
use crate::types::TokenUsage;
|
||||
@@ -420,9 +417,9 @@ mod tests {
|
||||
|
||||
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
|
||||
let prices = if provider == "anthropic" {
|
||||
PriceTable::anthropic(&AnthropicConfig::default())
|
||||
PriceTable::from(&ProviderConfig::anthropic())
|
||||
} else {
|
||||
PriceTable::deepseek(&DeepseekConfig::default())
|
||||
PriceTable::from(&ProviderConfig::deepseek())
|
||||
};
|
||||
LlmClient::with_backend_options(
|
||||
provider,
|
||||
@@ -534,9 +531,7 @@ mod tests {
|
||||
r#"{"summary": "Opus wrote this one."}"#,
|
||||
TokenUsage::default(),
|
||||
);
|
||||
editor.push_llm_error(LlmError::Refusal {
|
||||
provider: "anthropic",
|
||||
});
|
||||
editor.push_llm_error(LlmError::refusal("anthropic"));
|
||||
editor.push(BRIEF_FIXTURE, TokenUsage::default());
|
||||
let bulk = Arc::new(MockBackend::new());
|
||||
bulk.push(
|
||||
|
||||
+535
-255
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -39,7 +39,7 @@ pub struct Curator {
|
||||
impl Curator {
|
||||
/// An empty [`llm::Llms`] corresponds to `--skip-llm`: cheap-signal order is
|
||||
/// used for selection and feed excerpts stand in for summaries (notes §6).
|
||||
/// With only `bulk`, every editor call runs on DeepSeek (§4.2).
|
||||
/// With only `bulk`, every editor call runs on the bulk provider (§4.2).
|
||||
pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self {
|
||||
Self { config, db, llms }
|
||||
}
|
||||
@@ -67,15 +67,15 @@ impl Curator {
|
||||
assess::run(
|
||||
&self.db,
|
||||
Some(bulk),
|
||||
&self.config.deepseek.model,
|
||||
&bulk.model,
|
||||
candidates,
|
||||
self.config.deepseek.deep_batch_size,
|
||||
self.config.deepseek.max_concurrent_requests,
|
||||
self.config.llm.deep_batch_size,
|
||||
bulk.max_concurrent_requests,
|
||||
self.config.curation.ranking.assessment_reuse_days,
|
||||
rescore,
|
||||
profile_version,
|
||||
assessed_at,
|
||||
self.config.deepseek.score_temperature,
|
||||
self.config.llm.score_temperature,
|
||||
&self.config.curation.sections,
|
||||
)
|
||||
.await
|
||||
@@ -137,7 +137,7 @@ impl Curator {
|
||||
&self.llms,
|
||||
lineup,
|
||||
&self.config.editorial,
|
||||
self.config.deepseek.editorial_temperature,
|
||||
self.config.llm.editorial_temperature,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::llm::{MockBackend, UsageMeter};
|
||||
use crate::config::DeepseekConfig;
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::types::{RatingEvent, TokenUsage};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -667,7 +667,7 @@ mod tests {
|
||||
let llm = LlmClient::with_backend(
|
||||
"deepseek-v4-flash",
|
||||
initial.text,
|
||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
||||
UsageMeter::for_provider(&ProviderConfig::deepseek()),
|
||||
backend.clone(),
|
||||
);
|
||||
let rebuilt = rebuild(&db, &llm, &opml, &profile_path, 60).await.unwrap();
|
||||
|
||||
@@ -1370,7 +1370,7 @@ mod tests {
|
||||
"2026-09-01",
|
||||
"2026-09-01T09:30: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
|
||||
@@ -1452,8 +1452,9 @@ mod tests {
|
||||
"exploration rated positively: 1",
|
||||
"cost per day (anthropic): $0.043",
|
||||
"cost per day (deepseek): $0.020",
|
||||
"cost per day (gemini): $0.005",
|
||||
"cost per day (voyage): $0.001",
|
||||
"cost per day (total): $0.064",
|
||||
"cost per day (total): $0.069",
|
||||
"mean generation time: 15m00s (3 runs)",
|
||||
] {
|
||||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||||
|
||||
@@ -500,8 +500,8 @@ pub async fn run(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DeepseekConfig;
|
||||
use crate::curate::llm::{MockBackend, UsageMeter};
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::article;
|
||||
use crate::curate::signals::{Neighbour, TopInterest};
|
||||
use crate::types::TokenUsage;
|
||||
@@ -584,11 +584,11 @@ mod tests {
|
||||
r#"{"articles":[{"id":42,"interest":8,"kind":"essay","why":"first answer"}]}"#,
|
||||
TokenUsage::default(),
|
||||
);
|
||||
let config = DeepseekConfig::default();
|
||||
let config = ProviderConfig::deepseek();
|
||||
let llm = LlmClient::with_backend(
|
||||
&config.model,
|
||||
"profile".into(),
|
||||
UsageMeter::new(&config, 10.0),
|
||||
UsageMeter::with_prices(PriceTable::from(&config), 10.0),
|
||||
backend.clone(),
|
||||
);
|
||||
let pool = HashSet::from([42]);
|
||||
|
||||
+45
-17
@@ -55,6 +55,15 @@ enum Command {
|
||||
/// Database maintenance.
|
||||
#[command(subcommand)]
|
||||
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)]
|
||||
@@ -309,6 +318,14 @@ async fn main() -> Result<()> {
|
||||
db.migrate().await?;
|
||||
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(())
|
||||
}
|
||||
@@ -327,7 +344,8 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
|
||||
| Command::Explain(_)
|
||||
| Command::Stats(_)
|
||||
| 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).
|
||||
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
|
||||
use curate::llm::{Llms, PriceTable, UsageMeter};
|
||||
let bulk_meter =
|
||||
UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
|
||||
let editor_meter = UsageMeter::with_prices(
|
||||
PriceTable::anthropic(&config.anthropic),
|
||||
config.anthropic.max_daily_usd,
|
||||
);
|
||||
use curate::llm::{Llms, provider_meters};
|
||||
let profile = curate::profile::load_or_build(
|
||||
db,
|
||||
&config.interests_opml,
|
||||
@@ -477,19 +489,23 @@ async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
|
||||
config.curation.feedback.verdicts_in_prompt,
|
||||
)
|
||||
.await?;
|
||||
let llms = Llms::from_config(
|
||||
&config.deepseek,
|
||||
&config.anthropic,
|
||||
profile.text,
|
||||
bulk_meter,
|
||||
editor_meter,
|
||||
);
|
||||
let llms = Llms::from_config(config, profile.text, &provider_meters(config));
|
||||
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!(
|
||||
"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(
|
||||
db,
|
||||
llm,
|
||||
@@ -841,11 +857,23 @@ mod tests {
|
||||
vec!["ratings", "list"],
|
||||
vec!["db", "migrate"],
|
||||
vec!["features", "prune"],
|
||||
vec!["config", "check"],
|
||||
] {
|
||||
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]
|
||||
fn parses_stats() {
|
||||
match Cli::try_parse_from(["daily-epub", "stats"])
|
||||
|
||||
+148
-84
@@ -16,8 +16,8 @@
|
||||
//! * **Best effort** — social enrichment, comments, the world briefing, images and
|
||||
//! the XTC conversion. They log, add a warning to the report (status `degraded`)
|
||||
//! and the run continues.
|
||||
//! * **Degrading** — every DeepSeek stage. A missing key, a dead API or a tripped
|
||||
//! `max_daily_usd` guardrail turns the run into the `--skip-llm` shape
|
||||
//! * **Degrading** — every LLM stage. A missing key, a dead API or a tripped
|
||||
//! provider `max_daily_usd` guardrail turns the run into the `--skip-llm` shape
|
||||
//! (cheap-signal admission, feed excerpts as summaries) rather than
|
||||
//! losing the day's issue.
|
||||
//!
|
||||
@@ -33,7 +33,7 @@ use jiff::civil::Date;
|
||||
use jiff::{Timestamp, Zoned};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::curate::llm::{Llms, PriceTable, UsageMeter};
|
||||
use crate::curate::llm::{Llms, UsageMeter, provider_meters};
|
||||
use crate::curate::{
|
||||
Curator, admit, editorial, embedding, profile, rank, signals, telemetry, triage,
|
||||
};
|
||||
@@ -59,7 +59,7 @@ pub struct GenerateOptions {
|
||||
pub out: Option<PathBuf>,
|
||||
/// `--max-articles N`, overriding `target_article_count`.
|
||||
pub max_articles: Option<usize>,
|
||||
/// `--skip-llm`: no DeepSeek call at all.
|
||||
/// `--skip-llm`: no chat-provider call at all.
|
||||
pub skip_llm: bool,
|
||||
/// `--skip-embeddings`: read the cache but make zero Voyage calls.
|
||||
pub skip_embeddings: bool,
|
||||
@@ -435,26 +435,23 @@ async fn run_stages(
|
||||
let article_embeddings = prepare_features(ctx, &mut personalized, &embeddings, report).await;
|
||||
|
||||
// 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 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 meters = provider_meters(config);
|
||||
match db.provider_spend_for_utc_day(ctx.started_at).await {
|
||||
Ok(spend) => {
|
||||
bulk_meter.preload_cost(spend.get("deepseek").copied().unwrap_or(0.0));
|
||||
editor_meter.preload_cost(spend.get("anthropic").copied().unwrap_or(0.0));
|
||||
for (name, meter) in &meters {
|
||||
meter.preload_cost(spend.get(name).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not preload provider spend; starting from zero")
|
||||
}
|
||||
}
|
||||
|
||||
let llms = build_llms(ctx, &bulk_meter, &editor_meter, report).await;
|
||||
let bulk_available = llms.bulk.is_some();
|
||||
let llms = build_llms(ctx, &meters, report).await;
|
||||
let mut curator_config = config.clone();
|
||||
curator_config.target_article_count = ctx.soft_target;
|
||||
curator_config.curation.max_article_count = ctx.hard_max;
|
||||
@@ -477,13 +474,13 @@ async fn run_stages(
|
||||
bulk,
|
||||
&mut personalized,
|
||||
&triage_pool,
|
||||
config.deepseek.triage_batch_size,
|
||||
config.deepseek.max_concurrent_requests,
|
||||
config.llm.triage_batch_size,
|
||||
bulk.max_concurrent_requests,
|
||||
config.curation.ranking.assessment_reuse_days,
|
||||
ctx.rescore,
|
||||
profile_version,
|
||||
Timestamp::now(),
|
||||
config.deepseek.score_temperature,
|
||||
config.llm.score_temperature,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -492,7 +489,7 @@ async fn run_stages(
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tracing::info!("--skip-llm or DeepSeek unavailable: triage skipped");
|
||||
tracing::info!("--skip-llm or no bulk provider: triage skipped");
|
||||
}
|
||||
report.counts.triaged = personalized
|
||||
.iter()
|
||||
@@ -661,20 +658,7 @@ async fn run_stages(
|
||||
.next_issue_number(date)
|
||||
.await
|
||||
.context("computing the issue number")?;
|
||||
report.provider_costs.insert(
|
||||
"deepseek".into(),
|
||||
ProviderUsage {
|
||||
usage: bulk_meter.total(),
|
||||
cost_usd: bulk_meter.cost_usd(),
|
||||
},
|
||||
);
|
||||
report.provider_costs.insert(
|
||||
"anthropic".into(),
|
||||
ProviderUsage {
|
||||
usage: editor_meter.total(),
|
||||
cost_usd: editor_meter.cost_usd(),
|
||||
},
|
||||
);
|
||||
let llm_cost = record_provider_costs(report, &meters);
|
||||
// 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.
|
||||
report.provider_costs.insert(
|
||||
@@ -687,31 +671,30 @@ async fn run_stages(
|
||||
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 {
|
||||
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(),
|
||||
_ => "none".into(),
|
||||
};
|
||||
_ => curator.llms.bulk.as_ref().map(|c| c.model.clone()),
|
||||
}
|
||||
.unwrap_or_else(|| "none".into());
|
||||
let provider_costs = report
|
||||
.provider_costs
|
||||
.iter()
|
||||
.map(|(provider, usage)| (provider.clone(), usage.cost_usd))
|
||||
.collect();
|
||||
let models = Models {
|
||||
bulk: if bulk_available {
|
||||
config.deepseek.model.clone()
|
||||
} else {
|
||||
"none".into()
|
||||
},
|
||||
editor: if curator.llms.editor.is_some() {
|
||||
config.anthropic.model.clone()
|
||||
} else if bulk_available {
|
||||
format!("{} (bulk fallback)", config.deepseek.model)
|
||||
} else {
|
||||
"none".into()
|
||||
bulk: curator
|
||||
.llms
|
||||
.bulk
|
||||
.as_ref()
|
||||
.map(|c| c.model.clone())
|
||||
.unwrap_or_else(|| "none".into()),
|
||||
editor: match (&curator.llms.editor, &curator.llms.bulk) {
|
||||
(Some(editor), _) => editor.model.clone(),
|
||||
(None, Some(bulk)) => format!("{} (bulk fallback)", bulk.model),
|
||||
(None, None) => "none".into(),
|
||||
},
|
||||
summaries: summary_model,
|
||||
};
|
||||
@@ -1069,15 +1052,14 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<(
|
||||
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.
|
||||
///
|
||||
/// Each client is `None` for `--skip-llm` and for every configuration/API
|
||||
/// problem: the pipeline then degrades per §17 instead of failing the run.
|
||||
async fn build_llms(
|
||||
ctx: &StageContext<'_>,
|
||||
bulk_meter: &UsageMeter,
|
||||
editor_meter: &UsageMeter,
|
||||
meters: &BTreeMap<String, UsageMeter>,
|
||||
report: &mut RunReport,
|
||||
) -> Llms {
|
||||
let profile = match profile::load_or_build(
|
||||
@@ -1102,15 +1084,7 @@ async fn build_llms(
|
||||
return Llms::default();
|
||||
}
|
||||
|
||||
let make_clients = |prompt: String| {
|
||||
Llms::from_config(
|
||||
&ctx.config.deepseek,
|
||||
&ctx.config.anthropic,
|
||||
prompt,
|
||||
bulk_meter.clone(),
|
||||
editor_meter.clone(),
|
||||
)
|
||||
};
|
||||
let make_clients = |prompt: String| Llms::from_config(ctx.config, prompt, meters);
|
||||
|
||||
let mut llms = make_clients(profile.text);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Startup line naming the resolved models and whether each provider is on
|
||||
/// (§19): the root config ignores unknown sections, so an `[anthropics]` or
|
||||
/// `[voyages]` typo would otherwise be silent. Keys are never logged, only
|
||||
/// their presence.
|
||||
/// Startup lines naming each role's resolved provider and whether it is on
|
||||
/// (§19): the root config ignores unknown sections, so a `[voyages]` typo
|
||||
/// would otherwise be silent. Keys are never logged, only their presence.
|
||||
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());
|
||||
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!(
|
||||
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,
|
||||
embedding_model = %config.voyage.model,
|
||||
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).
|
||||
/// Bump a number when the corresponding instruction block changes.
|
||||
const PROMPT_VERSIONS: &[(&str, u32)] = &[
|
||||
@@ -1189,13 +1193,17 @@ const PROMPT_VERSIONS: &[(&str, u32)] = &[
|
||||
];
|
||||
|
||||
/// The resolved `[curation]` (ranking included), `[editorial]`, `[voyage]`,
|
||||
/// model names and prompt versions written to `runs.config_json` (§7.6, §19).
|
||||
/// Never includes keys.
|
||||
/// `[llm]`, the provider registry, model names and prompt versions written to
|
||||
/// `runs.config_json` (§7.6, §19). Never includes keys.
|
||||
fn resolved_run_config(config: &Config, soft_target: usize, hard_max: usize) -> serde_json::Value {
|
||||
let mut curation = config.curation.clone();
|
||||
curation.max_article_count = hard_max;
|
||||
let mut voyage = config.voyage.clone();
|
||||
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!({
|
||||
"target_article_count": soft_target,
|
||||
"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,
|
||||
"editorial": config.editorial,
|
||||
"voyage": voyage,
|
||||
"llm": config.llm,
|
||||
"providers": config.providers_redacted(),
|
||||
"models": {
|
||||
"bulk": config.deepseek.model,
|
||||
"editor": if config.anthropic.enabled { config.anthropic.model.as_str() } else { "disabled" },
|
||||
"editor_effort": config.anthropic.effort,
|
||||
"bulk": model_of(config.bulk_provider()),
|
||||
"editor": model_of(config.editor_provider()),
|
||||
"editor_effort": config
|
||||
.editor_provider()
|
||||
.and_then(|(_, provider)| provider.effort.clone()),
|
||||
"embedding": if config.voyage.enabled { config.voyage.model.as_str() } else { "disabled" },
|
||||
},
|
||||
"prompt_versions": PROMPT_VERSIONS
|
||||
@@ -1277,8 +1289,9 @@ mod tests {
|
||||
#[test]
|
||||
fn run_config_json_records_the_resolved_settings_and_no_keys() {
|
||||
let mut config = Config::default();
|
||||
config.anthropic.api_key = Some("sk-secret".into());
|
||||
config.deepseek.api_key = Some("ds-secret".into());
|
||||
for provider in config.providers.values_mut() {
|
||||
provider.api_key = Some("sk-secret".into());
|
||||
}
|
||||
config.voyage.api_key = Some("pa-secret".into());
|
||||
let value = resolved_run_config(&config, 6, 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["models"]["bulk"], "deepseek-v4-flash");
|
||||
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_eq!(
|
||||
value["TRIAGE_PROMPT_VERSION"],
|
||||
@@ -1314,6 +1340,44 @@ mod tests {
|
||||
!text.contains("secret"),
|
||||
"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]
|
||||
@@ -1602,14 +1666,14 @@ mod tests {
|
||||
assert_eq!(thin, "{}");
|
||||
|
||||
// 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
|
||||
// to utility order (§17).
|
||||
let bulk_backend = Arc::new(ChatMockBackend::new());
|
||||
let bulk = LlmClient::with_backend(
|
||||
&h.config.deepseek.model,
|
||||
&h.config.providers["deepseek"].model,
|
||||
"SYSTEM".into(),
|
||||
UsageMeter::new(&h.config.deepseek, h.config.max_daily_usd),
|
||||
UsageMeter::for_provider(&h.config.providers["deepseek"]),
|
||||
bulk_backend.clone(),
|
||||
);
|
||||
let curator = Curator::new(
|
||||
|
||||
+5
-2
@@ -718,11 +718,14 @@ mod tests {
|
||||
backend: std::sync::Arc<crate::curate::llm::MockBackend>,
|
||||
limit: f64,
|
||||
) -> LlmClient {
|
||||
let config = crate::config::DeepseekConfig::default();
|
||||
let config = crate::config::ProviderConfig::deepseek();
|
||||
LlmClient::with_backend(
|
||||
"mock",
|
||||
"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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -570,9 +570,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
usage,
|
||||
);
|
||||
|
||||
let meter = UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd);
|
||||
let meter = UsageMeter::for_provider(&cfg.providers["deepseek"]);
|
||||
let llm = LlmClient::with_backend(
|
||||
&cfg.deepseek.model,
|
||||
&cfg.providers["deepseek"].model,
|
||||
"You are the editor of The Daily EPUB.".into(),
|
||||
meter.clone(),
|
||||
backend.clone(),
|
||||
@@ -642,9 +642,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let colophon = Colophon {
|
||||
provider_costs: BTreeMap::from([("deepseek".to_string(), meter.cost_usd())]),
|
||||
models: Models {
|
||||
bulk: cfg.deepseek.model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.deepseek.model),
|
||||
summaries: cfg.deepseek.model.clone(),
|
||||
bulk: cfg.providers["deepseek"].model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.providers["deepseek"].model),
|
||||
summaries: cfg.providers["deepseek"].model.clone(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
@@ -655,7 +655,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let mut lineup = lineup;
|
||||
pipeline::apply_summaries(&mut lineup, &editorial_doc);
|
||||
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await;
|
||||
assert_eq!(issue.colophon.models.bulk, cfg.deepseek.model);
|
||||
assert_eq!(issue.colophon.models.bulk, cfg.providers["deepseek"].model);
|
||||
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 client = LlmClient::with_backend(
|
||||
&cfg.deepseek.model,
|
||||
&cfg.providers["deepseek"].model,
|
||||
"reader profile".into(),
|
||||
UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd),
|
||||
UsageMeter::for_provider(&cfg.providers["deepseek"]),
|
||||
backend.clone(),
|
||||
);
|
||||
let curator = Curator::new(
|
||||
@@ -732,9 +732,9 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
|
||||
Colophon {
|
||||
provider_costs: BTreeMap::new(),
|
||||
models: Models {
|
||||
bulk: cfg.deepseek.model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.deepseek.model),
|
||||
summaries: cfg.deepseek.model.clone(),
|
||||
bulk: cfg.providers["deepseek"].model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.providers["deepseek"].model),
|
||||
summaries: cfg.providers["deepseek"].model.clone(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
|
||||
Reference in New Issue
Block a user