Files

87 lines
7.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
# YOUR STEP: 2 — Claude editor and editorial (plan §21 step 2)
Step 1 has landed (three-way votes, `rating_events`, `data/profile.md`, the rebuilt system
prompt). Read `git log -3` and the current `src/curate/llm.rs`, `select.rs`, `editorial.rs`,
`pipeline.rs`, `config.rs`, `report.rs`, `db.rs`, `src/epub/chapters.rs` + templates before coding.
Scope (plan §4.1, §4.2, §5, §7.6, §13, §14, §15.1 chapter/in-this-issue `why`, §18 types, §19 config):
1. **`AnthropicBackend`** in `src/curate/llm.rs` implementing `ChatBackend` exactly per §4.2:
`POST {base_url}/v1/messages`; headers `x-api-key`, `anthropic-version: 2023-06-01`,
`content-type: application/json`, `anthropic-beta: server-side-fallback-2026-07-01`;
body `{model, max_tokens: 16000, system: [{type:"text", text, cache_control:{type:"ephemeral"}}],
messages:[{role:"user", content}], output_config:{effort}, fallbacks:"default"}`.
**Never** send `temperature`, `top_p`, `top_k`, `thinking`, or an assistant prefill. Ask for JSON
in the prompt text and parse tolerantly (reuse `strip_code_fence` + the existing tolerant parsing).
Response: concatenate `content[]` blocks with `type == "text"`. Usage: `input_tokens`,
`cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`; cost = input×price_input
+ cache_creation×price_cache_write + cache_read×price_cache_read + output×price_output (per M).
`stop_reason == "refusal"` (HTTP 200) surfaces as a distinct `LlmError` variant that triggers the
fallback; 429/5xx/network are `Transient` (retried by the existing `RetryPolicy`); 400 is never
retried. Request timeout 300 s. API key only from `DAILY_EPUB_ANTHROPIC__API_KEY`.
`ChatRequest` gains `effort: Option<String>`; the Anthropic backend ignores `temperature`/`json`
and maps `system` to the cached system block. `TokenUsage` needs a cache-write counter (keep the
DeepSeek path's cost identical to today). Generalize `UsageMeter` to take a per-provider price
table instead of `&DeepseekConfig`.
2. **`Llms { bulk: Option<LlmClient>, editor: Option<LlmClient> }`** with `editor_or_bulk()` (the
editor when configured and its meter is not tripped, else bulk). Both clients share the exact same
system prompt string (§8.4). `LlmError::MissingApiKey` and friends must name the provider.
3. **Config** (§4.2, §19): `[anthropic]` with `enabled`, `base_url`, `model = "claude-opus-5"`,
`api_key` (env only), `effort = "high"`, `price_input_per_mtok = 5.0`,
`price_cache_write_per_mtok = 6.25`, `price_cache_read_per_mtok = 0.5`, `price_output_per_mtok = 25.0`,
`max_daily_usd = 3.0`, `max_concurrent_requests = 4`; `[deepseek].max_concurrent_requests = 4`;
`[curation].max_article_count = 28`; `[editorial]` with `summary_model = "editor"` (`editor|bulk`)
and `summary_input_tokens = 3000`. Validate `max_article_count >= target_article_count`, effort ∈
{low, medium, high, xhigh, max}, batch/concurrency ≥ 1. Startup logs the resolved models and whether
each provider is enabled (§19). Update `config.example.toml` and the README (prerequisites, cost
line, the note that server-side fallback is enabled, dashboard spend limits as the real backstop).
4. **Budget and concurrency** (§5): one `UsageMeter` per provider, each with its own `max_daily_usd`.
The budget day is the **UTC date of the run's `started_at`**, preloaded by summing
`runs.provider_costs_json` for earlier runs that UTC day; this replaces `db::spend_for_date`
(remove it). Write `runs.provider_costs_json` (`{"deepseek": {...usage, cost_usd}, "anthropic": {...}}`)
and `runs.config_json` (the resolved `[curation]`, `[editorial]`, model names and prompt versions,
as JSON) in `finish_run`. Keep `runs.cost_usd` as the total across providers. Existing Stage A
scoring batches run through `futures::stream::iter(...).buffer_unordered(max_concurrent_requests)`
with the budget check before each request is spawned; a tripped meter skips remaining calls,
lets in-flight finish, records the number of unscored candidates in the report, and continues.
5. **The editor** (§13): `EDITOR_INSTRUCTIONS` replaces `SELECT_INSTRUCTIONS` verbatim from the plan
with `{soft_target}`/`{hard_max}` substituted. Runs on `editor_or_bulk()`; on refusal/error fall
back to the same prompt on the bulk client; if that fails too, `select_without_llm`. Per-item
rendering follows §13 with what exists today (Stage A score/category/rationale instead of
quality/fit/facets — those arrive in step 5; render `flags: always-include | excerpt only`;
`opening:` first 60 words). Do not put the numeric blend in the prompt. `assemble()` keeps section
validation, unique lead, auto-include reinsertion, duplicate-id defence, malformed-response
fallback and the `hard_max` trim (by today's ranking key, utility arrives in step 5).
**Delete the "too few: top up" branch.** `--max-articles N` is a ceiling:
`hard_max = min(config.curation.max_article_count, N)`, `soft_target = min(target_article_count, hard_max)`.
`select_without_llm` respects `soft_target` as its size. Each pick's `why` (≤14 words) lands on
`Pick.why: Option<String>` and in `issue_articles.why` (`replace_issue_articles`).
6. **Editorial** (§14): summaries run on the editor client per `editorial.summary_model` with the
input budget from config (3,000 tokens), concurrency 4 via `buffer_unordered`, fallback per article:
bulk client, then the excerpt. `BRIEF_INSTRUCTIONS` replaces `FRONT_PAGE_INSTRUCTIONS` verbatim
from §14.2 (JSON `{"brief": "..."}`); input is the lineup with sections, each pick's title, feed,
`why`, summary and the Stage A score. `Editorial.section_intros` is removed (or always empty) and
`section.xhtml` renders only the section name; `front_page.xhtml` renders the brief under the
masthead; fallback stays `fallback_front_page_html`. The weekly profile rebuild runs on
`editor_or_bulk()` (pipeline and the `profile rebuild` CLI).
7. **Paper** (§15.1): `chapter.xhtml` gets a small italic `Why it's here: <why>` line under the meta
line; the In-this-issue page shows the `why` line under each summary. `Colophon` gains
`provider_costs` and `models`; the colophon template prints per-provider cost lines and the models
(editor and summaries model, bulk model). `StageCounts`/`RunReport` carry per-provider usage;
`print_report` in `main.rs` prints per-provider cost.
8. Remove `Vote`-era leftovers you notice only if they are in your files; do not touch embeddings,
signals, telemetry or triage (steps 35).
Tests to add/adapt (§20 "Anthropic backend", "Editor", parts of "Pipeline"): request body has the
system block with `cache_control`, no `temperature`, `output_config.effort`, `fallbacks`, the beta
header; usage fields parsed into cost with cache read/write prices; `stop_reason: refusal` surfaces as
the fallback-triggering error; 429 retried, 400 not (use a mock backend or a local `axum`/`tokio`
listener — never the real network); a nine-pick response is published as nine; `hard_max` trims;
`--max-articles` is a ceiling; auto-includes reinserted; `why` lines land on picks and in
`issue_articles.why`; refusal/error on the Anthropic mock falls back to the DeepSeek mock with the
same prompt; the brief is parsed and rendered, section intros gone; `provider_costs_json` and
`config_json` are written; budget day preload sums by UTC date.