Files
the-daily-epub/src/config.rs
T
thalladaandClaude Fable 5.1 57efbb49b4 Curation v2 step 2: Claude Opus 5 editor, the Brief, per-provider budgets
- AnthropicBackend (Messages API, cached system block, output_config.effort,
  server-side fallbacks, refusal surfaced as an error); Llms { bulk, editor }
  with editor_or_bulk(); PriceTable-based UsageMeter per provider.
- [anthropic], [editorial], deepseek.max_concurrent_requests and
  curation.max_article_count config; startup logs resolved providers.
- Budget day is the UTC date of started_at, preloaded from
  runs.provider_costs_json; finish_run writes provider_costs_json and
  config_json. Stage A batches run concurrently with per-batch budget checks.
- Editor prompt with one-line "why" per pick; no minimum lineup size;
  --max-articles is a ceiling; top-up branch deleted; why stored on picks and
  issue_articles.why and rendered in chapters and In this issue.
- Summaries on the editor client (3k-token input, concurrency 4, bulk then
  excerpt fallback); "The Brief" replaces From the Editor; section intros gone.
- Colophon carries per-provider costs and models.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 03:56:14 +00:00

670 lines
23 KiB
Rust
Raw 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.
//! Typed configuration (spec §3.14).
//!
//! Load order, later wins: built-in defaults ← `config.toml` (path from `--config`,
//! else `./config.toml` if present) ← `DAILY_EPUB_*` environment variables, where
//! nesting is expressed with a double underscore (`DAILY_EPUB_MINIFLUX__API_KEY`).
use std::path::{Path, PathBuf};
use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::{Deserialize, Serialize};
/// Environment-variable prefix for every override (§3.14).
pub const ENV_PREFIX: &str = "DAILY_EPUB_";
/// Nesting separator inside env var names.
pub const ENV_SPLIT: &str = "__";
/// Default config file looked up when `--config` is not given.
pub const DEFAULT_CONFIG_FILE: &str = "config.toml";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to load configuration: {0}")]
Figment(#[from] Box<figment::Error>),
#[error("config file not found: {0}")]
Missing(PathBuf),
#[error("invalid configuration: {0}")]
Invalid(String),
}
impl From<figment::Error> for ConfigError {
fn from(e: figment::Error) -> Self {
ConfigError::Figment(Box::new(e))
}
}
/// Legacy/alternate env var for the rating-link HMAC key (spec §1).
pub const ENV_SECRET_ALIAS: &str = "DAILY_EPUB_SECRET";
/// Root configuration document (§3.14).
///
/// Unknown *top-level* keys are ignored on purpose: the prefix `DAILY_EPUB_` is
/// shared with plain operator env vars such as [`ENV_SECRET_ALIAS`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// IANA tz used for day boundaries and `--date` (§3.14, notes §2).
pub timezone: String,
/// Ingest window size in hours (§3.1).
pub lookback_hours: u32,
/// How many articles the lineup should contain (§3.6 stage B).
pub target_article_count: usize,
/// How many articles survive the heuristic pre-filter (§3.5).
pub prefilter_keep: usize,
/// Days of published EPUBs kept in `publish.epub_dir` (§3.11).
pub retention_days: u32,
/// How many XTC issues to keep in `publish.xtc_dir` (§3.11).
///
/// Counted, not dated, because an XTCH issue is ~80100 MB of pre-rendered
/// page bitmaps: the constraint is disk, not age.
pub xtc_retention_count: u32,
/// Hard cost ceiling per run (§3.6 guardrail).
pub max_daily_usd: f64,
/// Include the Wikipedia Current Events section (§3.8).
pub world_briefing: bool,
/// SQLite file; parent dirs are created on open.
pub database_path: PathBuf,
/// Default artifact output directory (overridden by `generate --out`).
pub out_dir: PathBuf,
/// Scour interests OPML used to seed the taste profile (§3.6).
pub interests_opml: PathBuf,
/// Hand-maintained reader profile loaded for every curation run (§8.2).
pub profile_path: PathBuf,
pub miniflux: MinifluxConfig,
pub deepseek: DeepseekConfig,
pub anthropic: AnthropicConfig,
pub curation: CurationConfig,
pub editorial: EditorialConfig,
pub publish: PublishConfig,
pub xtc: XtcConfig,
pub server: ServerConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
timezone: "America/New_York".into(),
lookback_hours: 26,
target_article_count: 20,
prefilter_keep: 120,
retention_days: 21,
xtc_retention_count: 5,
max_daily_usd: 2.0,
world_briefing: true,
database_path: PathBuf::from("/var/lib/daily-epub/daily-epub.db"),
out_dir: PathBuf::from("/var/lib/daily-epub/out"),
interests_opml: PathBuf::from("data/scour-interests.opml"),
profile_path: PathBuf::from("data/profile.md"),
miniflux: MinifluxConfig::default(),
deepseek: DeepseekConfig::default(),
anthropic: AnthropicConfig::default(),
curation: CurationConfig::default(),
editorial: EditorialConfig::default(),
publish: PublishConfig::default(),
xtc: XtcConfig::default(),
server: ServerConfig::default(),
}
}
}
/// `[miniflux]` — API client settings (§3.1).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct MinifluxConfig {
pub base_url: String,
/// `X-Auth-Token`; supply via `DAILY_EPUB_MINIFLUX__API_KEY`.
pub api_key: Option<String>,
/// Page size for `GET /v1/entries` (Miniflux caps this at 250).
pub page_limit: u32,
}
impl Default for MinifluxConfig {
fn default() -> Self {
Self {
base_url: "http://127.0.0.1:8082".into(),
api_key: None,
page_limit: 250,
}
}
}
/// `[deepseek]` — LLM endpoint, model and pricing (§3.6, notes "verified facts").
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct DeepseekConfig {
pub base_url: String,
pub model: String,
/// Supply via `DAILY_EPUB_DEEPSEEK__API_KEY`.
pub api_key: Option<String>,
/// Articles per stage-A scoring request (§3.6).
pub score_batch_size: usize,
pub max_concurrent_requests: usize,
pub score_temperature: f32,
pub editorial_temperature: f32,
/// USD per 1M cache-miss input tokens.
pub price_input_per_mtok: f64,
/// USD per 1M prefix-cache-hit input tokens.
pub price_cached_input_per_mtok: f64,
/// USD per 1M output tokens.
pub price_output_per_mtok: f64,
}
impl Default for DeepseekConfig {
fn default() -> Self {
Self {
base_url: "https://api.deepseek.com/v1".into(),
model: "deepseek-v4-flash".into(),
api_key: None,
score_batch_size: 12,
max_concurrent_requests: 4,
score_temperature: 0.3,
editorial_temperature: 0.8,
price_input_per_mtok: 0.14,
price_cached_input_per_mtok: 0.0028,
price_output_per_mtok: 0.28,
}
}
}
/// `[anthropic]` — Claude editor, editorial and profile settings (§4.2).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AnthropicConfig {
pub enabled: bool,
pub base_url: String,
pub model: String,
/// Supply only via `DAILY_EPUB_ANTHROPIC__API_KEY`.
pub api_key: Option<String>,
pub effort: String,
pub price_input_per_mtok: f64,
pub price_cache_write_per_mtok: f64,
pub price_cache_read_per_mtok: f64,
pub price_output_per_mtok: f64,
pub max_daily_usd: f64,
pub max_concurrent_requests: usize,
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
enabled: true,
base_url: "https://api.anthropic.com".into(),
model: "claude-opus-5".into(),
api_key: None,
effort: "high".into(),
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,
}
}
}
/// Which provider writes per-article summaries (§14.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SummaryModel {
Editor,
Bulk,
}
/// `[editorial]` — summary provider and per-article input budget (§14).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct EditorialConfig {
pub summary_model: SummaryModel,
pub summary_input_tokens: usize,
}
impl Default for EditorialConfig {
fn default() -> Self {
Self {
summary_model: SummaryModel::Editor,
summary_input_tokens: 3_000,
}
}
}
/// `[curation]` — pre-filter and section palette (§3.5, §3.6).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct CurationConfig {
/// Absolute issue-size ceiling; the editor has no minimum (§13).
pub max_article_count: usize,
/// Miniflux feed ids or site URLs that can never be dropped (§3.5).
pub always_include_feeds: Vec<String>,
/// Hosts excluded outright (§3.5).
pub blocked_domains: Vec<String>,
/// Extra paywalled hosts, merged with [`crate::extract::DEFAULT_PAYWALL_DOMAINS`]
/// by the extraction stage's `excerpt_only` heuristic (§3.3).
pub paywall_domains: Vec<String>,
/// The only section names the LLM may use (§3.6 stage B).
pub sections: Vec<String>,
pub feedback: FeedbackConfig,
}
impl Default for CurationConfig {
fn default() -> Self {
Self {
max_article_count: 28,
always_include_feeds: Vec::new(),
blocked_domains: Vec::new(),
paywall_domains: Vec::new(),
sections: [
"Top Stories",
"Tech & Engineering",
"Science & Space",
"AI & Machine Learning",
"Culture & Essays",
"Boston & Local",
"Niche Corner",
"From the Blogroll",
]
.iter()
.map(|s| s.to_string())
.collect(),
feedback: FeedbackConfig::default(),
}
}
}
/// `[curation.feedback]` — explicit verdict weights and prompt history (§6, §8.4).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct FeedbackConfig {
pub loved_value: f64,
pub good_value: f64,
pub not_for_me_value: f64,
pub verdicts_in_prompt: usize,
}
impl Default for FeedbackConfig {
fn default() -> Self {
Self {
loved_value: 1.0,
good_value: 0.35,
not_for_me_value: -1.0,
verdicts_in_prompt: 60,
}
}
}
/// `[publish]` — where finished artifacts land (§3.11).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct PublishConfig {
/// Where both EPUB editions land: the source of the OPDS feed, served at
/// `/files/epub/`, and a BookOrbit watched folder if one is configured.
///
/// Renamed from `bookorbit_dir` once the built-in feed started serving this
/// directory directly — BookOrbit is optional, the directory is not.
pub epub_dir: PathBuf,
/// Directory served at `/files/xtc/`. Not listed in the OPDS feed (§3.11).
pub xtc_dir: PathBuf,
}
impl Default for PublishConfig {
fn default() -> Self {
Self {
epub_dir: PathBuf::from("/srv/bookorbit/libraries/daily-epub"),
xtc_dir: PathBuf::from("/var/lib/daily-epub/xtc"),
}
}
}
/// XTC output flavour (§3.11).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum XtcFormat {
/// 1-bit.
Xtc,
/// 2-bit grayscale — the default (better image quality).
Xtch,
}
impl XtcFormat {
/// Value passed to the converter's `-f` flag.
pub fn as_str(self) -> &'static str {
match self {
XtcFormat::Xtc => "xtc",
XtcFormat::Xtch => "xtch",
}
}
/// File extension of the produced artifact.
pub fn extension(self) -> &'static str {
self.as_str()
}
}
/// `[xtc]` — invocation of `epub-to-xtc-converter` (§3.11, notes "verified facts").
///
/// The converter has no global npm bin, so `command` + `args` form the prefix and
/// the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct XtcConfig {
pub enabled: bool,
pub command: String,
pub args: Vec<String>,
pub format: XtcFormat,
/// Optional settings JSON passed as `-c`.
pub settings: Option<PathBuf>,
}
impl Default for XtcConfig {
fn default() -> Self {
Self {
enabled: true,
command: "node".into(),
args: vec![
"/opt/epub-to-xtc-converter/cli/index.js".into(),
"convert".into(),
],
format: XtcFormat::Xtch,
settings: None,
}
}
}
/// `[server]` — axum listener and rating-link signing (§3.9, §3.12).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ServerConfig {
pub bind: String,
/// Base URL rating links are built from.
pub public_url: String,
/// HMAC key for rating tokens; supply via `DAILY_EPUB_SERVER__HMAC_SECRET`.
pub hmac_secret: Option<String>,
/// Optional Basic auth for `/opds/*` and `/files/*`.
pub basic_auth_user: Option<String>,
pub basic_auth_pass: Option<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:3499".into(),
public_url: "https://daily.hallada.net".into(),
hmac_secret: None,
basic_auth_user: None,
basic_auth_pass: None,
}
}
}
impl Config {
/// Build the figment layer stack. `path` is required to exist when explicit.
fn figment(path: Option<&Path>, require_file: bool) -> Result<Figment, ConfigError> {
let mut fig = Figment::from(Serialized::defaults(Config::default()));
if let Some(p) = path {
if require_file && !p.exists() {
return Err(ConfigError::Missing(p.to_path_buf()));
}
if p.exists() {
fig = fig.merge(Toml::file(p));
}
}
Ok(fig.merge(Env::prefixed(ENV_PREFIX).split(ENV_SPLIT)))
}
/// Load config for the CLI: explicit `--config` path, else `./config.toml`
/// when it exists, then `DAILY_EPUB_*` env overrides (§3.14).
pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
let (path, require) = match explicit {
Some(p) => (Some(p.to_path_buf()), true),
None => (Some(PathBuf::from(DEFAULT_CONFIG_FILE)), false),
};
let mut config: Config = Self::figment(path.as_deref(), require)?.extract()?;
// §1 tells the operator to set `DAILY_EPUB_SECRET`; §3.14 calls the key
// `server.hmac_secret`. Accept both, with the explicit key winning.
if config.server.hmac_secret.is_none() {
config.server.hmac_secret = std::env::var(ENV_SECRET_ALIAS)
.ok()
.filter(|v| !v.is_empty());
}
config.validate()?;
Ok(config)
}
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
pub fn validate(&self) -> Result<(), ConfigError> {
if self.lookback_hours == 0 {
return Err(ConfigError::Invalid("lookback_hours must be > 0".into()));
}
if self.target_article_count == 0 {
return Err(ConfigError::Invalid(
"target_article_count must be > 0".into(),
));
}
if self.prefilter_keep < self.target_article_count {
return Err(ConfigError::Invalid(
"prefilter_keep must be >= target_article_count".into(),
));
}
if self.curation.max_article_count < self.target_article_count {
return Err(ConfigError::Invalid(
"curation.max_article_count must be >= target_article_count".into(),
));
}
if self.deepseek.score_batch_size == 0 {
return Err(ConfigError::Invalid(
"deepseek.score_batch_size must be >= 1".into(),
));
}
if self.deepseek.max_concurrent_requests == 0 {
return Err(ConfigError::Invalid(
"deepseek.max_concurrent_requests must be >= 1".into(),
));
}
if self.anthropic.max_concurrent_requests == 0 {
return Err(ConfigError::Invalid(
"anthropic.max_concurrent_requests must be >= 1".into(),
));
}
if self.editorial.summary_input_tokens == 0 {
return Err(ConfigError::Invalid(
"editorial.summary_input_tokens must be >= 1".into(),
));
}
if !matches!(
self.anthropic.effort.as_str(),
"low" | "medium" | "high" | "xhigh" | "max"
) {
return Err(ConfigError::Invalid(
"anthropic.effort must be one of low, medium, high, xhigh, max".into(),
));
}
if self.curation.sections.is_empty() {
return Err(ConfigError::Invalid(
"curation.sections must not be empty".into(),
));
}
self.tz()?;
Ok(())
}
/// Resolve [`Config::timezone`] into a `jiff` time zone (notes §2).
pub fn tz(&self) -> Result<jiff::tz::TimeZone, ConfigError> {
jiff::tz::TimeZone::get(&self.timezone)
.map_err(|e| ConfigError::Invalid(format!("unknown timezone {}: {e}", self.timezone)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use figment::Jail;
#[test]
fn defaults_match_the_spec() {
let c = Config::default();
assert_eq!(c.timezone, "America/New_York");
assert_eq!(c.lookback_hours, 26);
assert_eq!(c.target_article_count, 20);
assert_eq!(c.prefilter_keep, 120);
assert_eq!(c.retention_days, 21);
assert_eq!(c.max_daily_usd, 2.0);
assert!(c.world_briefing);
assert_eq!(c.deepseek.model, "deepseek-v4-flash");
assert_eq!(c.profile_path, PathBuf::from("data/profile.md"));
assert_eq!(c.curation.feedback.good_value, 0.35);
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.curation.sections.len(), 8);
c.validate().unwrap();
}
#[test]
// `Jail::expect_with` dictates the closure's `figment::Error` return type.
#[allow(clippy::result_large_err)]
fn toml_then_env_layering() {
Jail::expect_with(|jail| {
jail.create_file(
"config.toml",
r#"
lookback_hours = 30
world_briefing = false
[miniflux]
base_url = "http://127.0.0.1:9999"
[curation]
sections = ["Top Stories", "Niche Corner"]
[xtc]
command = "node"
args = ["/opt/epub-to-xtc-converter/cli/index.js", "convert"]
format = "xtc"
"#,
)?;
jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token");
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2");
let c = Config::load(None).map_err(|e| figment::Error::from(e.to_string()))?;
// from file
assert_eq!(c.lookback_hours, 30);
assert!(!c.world_briefing);
assert_eq!(c.miniflux.base_url, "http://127.0.0.1:9999");
assert_eq!(c.curation.sections, ["Top Stories", "Niche Corner"]);
assert_eq!(c.xtc.format, XtcFormat::Xtc);
assert_eq!(c.xtc.args.len(), 2);
// from env
assert_eq!(c.miniflux.api_key.as_deref(), Some("secret-token"));
assert_eq!(c.target_article_count, 12);
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
// untouched default
assert_eq!(c.retention_days, 21);
assert_eq!(c.timezone, "America/New_York");
Ok(())
});
}
#[test]
fn explicit_missing_path_is_an_error() {
assert!(matches!(
Config::load(Some(Path::new("/nonexistent/daily-epub.toml"))),
Err(ConfigError::Missing(_))
));
}
/// `publish.bookorbit_dir` was renamed to `publish.epub_dir`. A config still
/// using the old key must fail loudly and name both — silently falling back
/// to the default would publish the issue into the wrong directory, where
/// the OPDS feed would then find nothing.
#[test]
fn the_renamed_publish_key_fails_loudly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[publish]\nbookorbit_dir = \"/srv/books\"\nxtc_dir = \"/srv/xtc\"\n",
)
.unwrap();
let err = Config::load(Some(&path)).expect_err("the stale key must be rejected");
let message = err.to_string();
assert!(message.contains("bookorbit_dir"), "{message}");
assert!(message.contains("epub_dir"), "{message}");
}
#[test]
fn shipped_example_config_parses() {
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml");
let c = Config::load(Some(&example)).expect("config.example.toml must parse");
assert_eq!(c.xtc.command, "node");
assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.server.bind, "127.0.0.1:3499");
assert_eq!(c.deepseek.base_url, "https://api.deepseek.com/v1");
assert_eq!(c.deepseek.max_concurrent_requests, 4);
assert!(c.anthropic.enabled);
assert_eq!(c.anthropic.model, "claude-opus-5");
assert_eq!(c.anthropic.effort, "high");
assert!(c.anthropic.api_key.is_none(), "keys never live in the file");
assert_eq!(c.anthropic.max_daily_usd, 3.0);
assert_eq!(c.curation.max_article_count, 28);
assert_eq!(c.editorial.summary_model, SummaryModel::Editor);
assert_eq!(c.editorial.summary_input_tokens, 3000);
}
#[test]
fn provider_validation_rejects_nonsense() {
let mut c = Config::default();
c.curation.max_article_count = c.target_article_count - 1;
assert!(c.validate().is_err(), "max_article_count below the target");
let mut c = Config::default();
c.anthropic.effort = "turbo".into();
assert!(c.validate().is_err(), "unknown effort");
for effort in ["low", "medium", "high", "xhigh", "max"] {
let mut c = Config::default();
c.anthropic.effort = effort.into();
assert!(c.validate().is_ok(), "{effort} is a valid effort");
}
let mut c = Config::default();
c.deepseek.max_concurrent_requests = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.anthropic.max_concurrent_requests = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.deepseek.score_batch_size = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.editorial.summary_input_tokens = 0;
assert!(c.validate().is_err());
}
#[test]
fn validation_rejects_nonsense() {
assert!(
Config {
prefilter_keep: 5,
..Config::default()
}
.validate()
.is_err()
);
assert!(
Config {
timezone: "Mars/Olympus_Mons".into(),
..Config::default()
}
.validate()
.is_err()
);
assert!(
Config {
lookback_hours: 0,
..Config::default()
}
.validate()
.is_err()
);
}
}