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
|
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
|
~$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
|
`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 ~80–100 MB, so the binding constraint is disk, not age. |
|
| `xtc_retention_count` | `5` | How many XTC issues to keep in `publish.xtc_dir`. Counted, not dated: each `.xtch` is ~80–100 MB, so the binding constraint is disk, not age. |
|
||||||
| `max_daily_usd` | `2.0` | 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
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
#[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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user