DeepSeek's content filter rejects a whole request (400 "Content Exists Risk") when one article trips it, which cost the other articles in the batch their assessment and retried them every run. New curate/batch.rs runs both stages through a bisecting runner: a rejected batch is split until the offending article is isolated, that article is retried once on the editor provider when it is a different one, and a still-rejected article is recorded as a provider_rejected assessment row so it is not retried for assessment_reuse_days. Cache reuse accepts rows from either configured model. Each stage logs reused/requested/rejected counts, the curation: line shows rejections when non-zero, explain prints them, and the llm_assess span reports the deep-set size. Implemented by a Claude agent from an orchestrator brief; verified fmt/clippy(-W dead_code)/test green (354 lib tests). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
17 KiB
17 KiB
Implementation notes (shared brief for all implementation agents)
Authoritative spec: docs/plans/2026-08-15-the-daily-epub.md. Read it fully before writing code.
For curation (§3.5, §3.6 and §3.9 of that spec) the authority is now
docs/plans/2026-09-02-personalized-curation-v2.md; see "Curation v2" below.
This file records implementation-time decisions and verified external facts. Follow both.
Verified external facts (2026-08-15)
- DeepSeek model id is confirmed:
deepseek-v4-flash(version DeepSeek-V4-Flash-0731). Pricing per 1M tokens: $0.0028 cache-hit input, $0.14 cache-miss input, $0.28 output. OpenAI-compatible API athttps://api.deepseek.com/v1, supportsresponse_format: {"type":"json_object"}. - epub-to-xtc-converter (github.com/bigbag/epub-to-xtc-converter) has no global npm bin.
It is invoked as:
node <repo>/cli/index.js convert book.epub -o book.xtch -f xtch -c settings.json(-f xtc= 1-bit,-f xtch= 2-bit grayscale;initsubcommand generates default settings). Therefore config must be fully general:xtc.command = "node",xtc.args = ["/path/to/epub-to-xtc-converter/cli/index.js", "convert"]and the code appends<input.epub> -o <output.xtch> -f <format>(plus-c <settings>). Missing/failed converter is non-fatal (log + continue).- Corrected 2026-08-15 (post-M8, verified by running it).
-cis not optional in practice: the converter validates settings before opening the EPUB and exits 2 withConfiguration errors: - Font path is required. Set font.path in your config file.There is no built-in font default, and the shippedcli/settings.jsonpoints at/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf, which most servers do not have. Soxtc.settingsis effectively required wheneverxtc.enabled. Deps also neednpm installinsidecli/(commander, jszip, minimatch, sharp). - That same error also means "I could not read your config."
loadSettingsincli/settings.jsguards withfs.existsSync(configPath), which returnsfalseonEACCESexactly as it does for a missing file, then silently falls back toDEFAULT_SETTINGS(font.path: null). Since/etc/daily-epubis0750 daily-epub:daily-epub, running the converter by hand as any other account reproduces the "Font path is required" error against a valid file. Verify assudo -u daily-epub. - Output size. XTCH is a pre-rendered page bitmap: 480×800 at 2bpp = ~96 KB/page. A
20-article issue rendered at
font.size = 34came to 1,088 pages ≈ 104 MB, in ~13 s.retention_days = 21therefore implies ~2 GB inpublish.xtc_dir.
- Corrected 2026-08-15 (post-M8, verified by running it).
- CrossPoint's OPDS browser can only acquire EPUBs. Verified against the
yokki-vans/InkPointXsources (an open fork of CrossPoint/CrossInk). Two independent blockers, either one fatal for serving XTC over OPDS:lib/OpdsParser/OpdsParser.cppsets an entry'shrefonly for an acquisition link whosetypeis exactlyapplication/epub+zip(strcmp), or for a navigation link (application/atom+xml).endElementthen drops any entry with an emptyhref. Anapplication/octet-streamacquisition link therefore yields an empty list and the UI reportsSTR_NO_ENTRIES— "No entries found".OpdsBookBrowserActivity::downloadBookbuilds the destination filename assanitizeFilename(author + " - " + title) + ".epub"— hardcoded, ignoring the URL andContent-Disposition— andReaderActivitydispatches on extension only (FsHelpers::hasXtcExtension→.xtc/.xtch, no magic-byte sniffing). So even a mistyped XTC link downloads into a file the device will not open. The three OPDS failure strings are distinct and worth reading precisely:STR_FETCH_FEED_FAILED("Failed to fetch feed"),STR_PARSE_FEED_FAILED("Failed to parse feed") andSTR_NO_ENTRIES("No entries found") — only the last is reachable after a successful fetch and parse. Consequence: the built-in feed serves EPUBs (both editions), XTC is published but not advertised, andpublish.xtc_diris swept by count. See the amendment in spec §3.11.
- X4 firmware rendering limits (from the
epub-to-xtc-converteroptimizer's header, which cites papyrix-reader): 464×788 usable viewport, max image decode 2048×3072, baseline JPEG only, no GIF/SVG/WebP, max 1500 CSS rules and simple selectors only (tag,.class,tag.class— no descendant combinators), max word length 200 chars, images under 20 px treated as decorative. These bind the(X4).epubread natively off BookOrbit; they do not bind the.xtch, which CREngine pre-renders to page bitmaps (convertnever callsoptimizeEpub— the two subcommands are independent).epub/x4.rsandstyle-x4.csssatisfy all of them;x4::simplify_xhtmlsoft-hyphenates pastMAX_WORD_CHARSandthe_x4_stylesheet_uses_no_descendant_selectorsguards the selector rule. - Wikipedia Current Events portal pages are created empty a day ahead.
Portal:Current_events/2026_August_15was created 2026-08-14T03:30Z as a 192-byte stub and did not get its first news item until 2026-08-15T13:28Z. The 05:30 America/New_York timer fires at ~09:30Z, so the issue day's own page is always an unpopulated stub — its only<li>elements are thecurrent-events-navbaredit/history/watch links, which the extractor drops, soextract_eventscorrectly returnsNone.world::fetch_with_fallbacktherefore walks back up toMAX_LOOKBACK_DAYSand the section is datelined with the day it actually covers, not the masthead date.
Verified external facts (2026-09-02, curation v2)
- Anthropic Messages API (verified 2026-09-02 against the bundled Claude API reference):
POST https://api.anthropic.com/v1/messageswith headersx-api-key,anthropic-version: 2023-06-01,content-type: application/jsonandanthropic-beta: server-side-fallback-2026-07-01. Model idclaude-opus-5; pricing $5.00 / M input, $25.00 / M output, cache reads 0.1× input ($0.50/M), cache writes 1.25× ($6.25/M); the minimum cacheable prefix is 512 tokens. No sampling parameters (temperature,top_p,top_kare a 400) and nothinkingblock — adaptive thinking is on by default and depth is set withoutput_config: {"effort": "high"}(low | medium | high | xhigh | max). The system prompt goes insystem: [{type: "text", text, cache_control: {type: "ephemeral"}}]; no assistant prefill, JSON is asked for in the prompt and parsed tolerantly."fallbacks": "default"(with the beta header) routes a request the safety classifiers would refuse to a fallback model server-side; a response can still end withstop_reason: "refusal"on HTTP 200, which the code treats as an error that degrades the call to DeepSeek. Usage fields:input_tokens(uncached remainder),cache_creation_input_tokens,cache_read_input_tokens,output_tokens. Timeout 300 s; retry 429/5xx/network, never 400. Key only fromDAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY(the provider registry below; the pre-registryDAILY_EPUB_ANTHROPIC__API_KEYis 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/completionswithAuthorization: Bearer <key>, the standardmessages/temperature/response_format: {"type": "json_object"}body. Model idgemini-3.8-flash. Reasoning depth is the OpenAIreasoning_effortfield, which Google maps onto Gemini 3.x'sthinking_level(minimal | low | medium | high;noneis not accepted by 3.x models). Usage: implicit cache hits are reported inprompt_tokens_details.cached_tokens(the same field DeepSeek now fills), andcompletion_tokensalready includes the thinking tokens thatcompletion_tokens_details.reasoning_tokensbreaks out — so output is priced fromcompletion_tokensalone, 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 fromDAILY_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/embeddingswithAuthorization: Bearer <key>; body{input: [...], model: "voyage-4-lite", input_type: "document" | "query", truncation: true, output_dimension: 512, output_dtype: "float"}. Up to 1,000 inputs and 1M tokens per request, 32k tokens per input. Vectors are unit-normalized, so dot product = cosine. $0.02 / M tokens after a 200M-token free allocation. Key only fromDAILY_EPUB_VOYAGE__API_KEY.
Cross-cutting implementation decisions
- sqlx usage: use runtime queries (
sqlx::query(...).bind(...)) and manual row mapping (orsqlx::FromRowderive withquery_as). Do not use the compile-time checkedquery!/query_as!macros (they require DATABASE_URL/offline data at build time). Migrations viasqlx::migrate!("./migrations")embedded at compile time. - Time:
jiffeverywhere; day boundaries and--dateinterpretation in the configured timezone (America/New_Yorkdefault). Store timestamps in SQLite as RFC3339 UTC strings. - Errors: modules return
thiserrorerror types oranyhow::Result;main.rsusesanyhow. Pipeline stages are best-effort where the spec says so (social, XTC, world briefing, images). - HTTP: one shared
reqwest::Client(rustls, gzip, no cookies, 10s timeouts, UAthe-daily-epub/1.0 (personal rss digest; contact tyler@hallada.net)), passed by clone. - LLM: a hand-rolled
reqwestclient, notasync-openai(the published crate exposes neitherClientnorCreateChatCompletionRequestat the pinned version). Every LLM call goes throughcurate/llm.rs:LlmClient { provider, system_prompt, model, effort, max_concurrent_requests, meter, backend, retry }over theChatBackendtrait, with two wire protocols —OpenAiCompatibleBackend({base_url}/chat/completions, bearer key,response_format: json_object,reasoning_effortwhen the provider has aneffort) andAnthropicBackend(Messages API, facts above). Providers are config, not code: the[providers.<name>]registry (kind = openai | anthropic,base_url,model,effort,max_daily_usd,max_concurrent_requests,price_*) is aBTreeMap<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 onkind;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. OneUsageMeterper referenced provider (llm::provider_meters), keyed by provider name — the same key used forruns.provider_costs_json, the UTC-day spend preload and the log lines — plus Voyage's own.LlmClient.provideris 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 fromDAILY_EPUB_PROVIDERS__<NAME>__API_KEY(figment lower-cases the path, so provider names are[a-z0-9_]+);daily-epub config checkprints the resolved roles without opening the database. - Testing: unit tests inline per module; integration tests in
tests/over fixture JSON intests/fixtures/. Never hit the network in tests:MockBackend(ChatBackend) and the embedding mock (EmbeddingBackend) stand in for all three providers.--skip-llmmakes zero LLM calls (admission by cheap signals,select_without_llmby utility, excerpt summaries);--skip-embeddingsmakes zero Voyage calls. - Style: rustfmt defaults,
cargo clippyclean-ish, nounwrap()outside tests, tracing spans per pipeline stage. - File ownership: waves of agents work in parallel on disjoint files. Do not edit files
outside your assigned set (module wiring in
main.rs/mod.rsis done by the scaffold and the integration wave). If you need a helper from another module that doesn't exist yet, add a// TODO(integration): ...note and code against the stub signature. - Dedupe module: normalize/dedupe (§3.2) lives in
src/dedupe.rs(canonical URL fn + clustering), called from the generate pipeline between ingest and extraction. - World briefing (§3.8) lives in
src/world.rs. - Askama templates in
src/epub/templates/(*.xhtmlaskama templates +style.css,style-x4.css). Askama 0.12+ configured viaaskama.tomlif needed. - Determinism: chapter ids
art-{entry_id}, stable filenames, issue regeneration for the same date replaces prior rows/files (idempotent upsert everywhere).
Curation v2 (2026-09-02)
The personalized ranker is specified in docs/plans/2026-09-02-personalized-curation-v2.md
(§0 settled decisions, §3 target pipeline, §19 configuration, §21 the seven landed steps);
docs/plans/2026-09-02-curation-v2-progress.md records per-step deviations. Facts an
implementer needs that are easy to get wrong:
- Tables (
migrations/0002_curation_v2.sql,0003_drop_scores.sql; never edit0001_init.sql):rating_events(append-only; the current verdict is the latestexplicitevent),article_embeddingsandinterest_embeddings(f32 little-endian BLOBs,input_hash= sha256 of the embedded text),article_assessments(stage IN ('triage', 'deep'), reused whilemodelandprompt_versionmatch andassessed_atis withinassessment_reuse_days;--rescoreignores the cache),candidate_runs(one row per considered article per run, upserted with every column set on each stage transition),runs.config_json/runs.provider_costs_json,issue_articles.why.ratings,feed_priorsandscoresare dropped;kvkeepsingest_watermark,taste_profile,taste_profile_learned,profile_version. - Feedback:
Voteisloved | good | down(NotForMe); the HMAC message stays{issue_date}/{article_id}/{vote}.Vote::parse("up")→Lovedandauth::verify_tokenstill accepts tokens signed over the literalupsegment because published issues carry those links. Keep both. - Budget day: each provider's
UsageMeteris preloaded with the spend of earlier runs on the UTC date of the run'sstarted_at, summed fromruns.provider_costs_json(db::spend_for_dateby nominal issue date is gone). A tripped meter skips that provider's remaining calls; the paper always publishes. - Lock:
src/lock.rstakeslibc::flock(LOCK_EX | LOCK_NB)on<database_path>.lockforgenerate,profile rebuild,features backfillandbackfill-social; a second writer exits with " is already running".serve,explain,stats,ratings,features pruneanddb migratenever take it. - Retention:
telemetry::pruneremovesarticle_embeddingsof unrated, unpublished articles older thanembedding_retention_days(120) andcandidate_runsrows plusarticle_assessmentsolder thantelemetry_retention_days(180).features pruneruns it on demand;generateruns it once after publishing, best effort. - Keys:
DAILY_EPUB_PROVIDERS__<NAME>__API_KEYandDAILY_EPUB_VOYAGE__API_KEYmap ontoProviderConfig.api_key/VoyageConfig.api_keythrough figment; the fields exist only for that mapping and are never documented in TOML, logged, or stored (Config::providers_redacted()is what reachesruns.config_json). - Provider registry (2026-09-02, after step 7): the
[deepseek]and[anthropic]tables and the top-levelmax_daily_usdare 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 inconfig.example.tomland areConfig::default()key for key. Stale shapes fail at load, naming the new key: a[deepseek]/[anthropic]header, a top-levelmax_daily_usd, any of the four role keys outside[llm], or aDAILY_EPUB_DEEPSEEK__*/DAILY_EPUB_ANTHROPIC__*environment variable. Batching concurrency (triage,assess) is the bulk provider'smax_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. - Provider rejections (2026-09-02):
curate::batchbisects a triage or deep batch that fails with a non-transient error (Api,Refusal,EmptyResponse, or a response that parses to zero items) down to single articles; a rejected single is retried once on the editor client when it is another provider. An article both refuse gets anarticle_assessmentsrow withkind = 'provider_rejected',score/fitNULL,rationale = "<provider>: <message ≤ 200 chars>", the bulkmodeland the stage'sprompt_version; the cache loaders skip it (leaving the assessment absent) while it is withinassessment_reuse_days,--rescoreignores it, andadmit::hygienenever treats it as a low score. Because a recovered article's row carries the editor's model, reuse accepts rows whosemodelis either configured model (triage::reusable_models).