Files
the-daily-epub/src/main.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

650 lines
20 KiB
Rust

//! `daily-epub` — CLI entry point (spec §2).
//!
//! Everything of substance lives in the library (`src/lib.rs`); this binary only
//! parses flags, loads config, opens the database and dispatches.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use tracing_subscriber::EnvFilter;
use daily_epub::config::Config;
use daily_epub::db::Db;
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
use daily_epub::report::RunReport;
use daily_epub::types::{ArticleId, RatingEvent, Vote};
use daily_epub::{curate, http, server, social};
/// A personalized daily newspaper, delivered as an EPUB.
#[derive(Debug, Parser)]
#[command(name = "daily-epub", version, about, long_about = None)]
struct Cli {
/// Config file path (defaults to ./config.toml when present).
#[arg(long, short, global = true, value_name = "FILE")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Build (and publish) one issue.
Generate(GenerateArgs),
/// Run the rating endpoints, the OPDS catalog and downloads.
Serve,
/// Taste-profile maintenance.
#[command(subcommand)]
Profile(ProfileCommand),
/// Inspect and edit explicit article verdicts.
#[command(subcommand)]
Ratings(RatingsCommand),
/// Re-poll social scores for recent entries.
BackfillSocial(BackfillSocialArgs),
/// Database maintenance.
#[command(subcommand)]
Db(DbCommand),
}
#[derive(Debug, clap::Args)]
struct GenerateArgs {
/// Issue date in the configured timezone (defaults to today).
#[arg(long, value_name = "YYYY-MM-DD")]
date: Option<String>,
/// Build everything but publish nothing: no BookOrbit copy, no issue record.
#[arg(long)]
dry_run: bool,
/// Write artifacts here instead of `out_dir`.
#[arg(long, value_name = "DIR")]
out: Option<PathBuf>,
/// Cap the lineup size (overrides `target_article_count`).
#[arg(long, value_name = "N")]
max_articles: Option<usize>,
/// Skip every LLM call: prefilter order selects, excerpts stand in for summaries.
#[arg(long)]
skip_llm: bool,
}
impl From<&GenerateArgs> for GenerateOptions {
fn from(args: &GenerateArgs) -> Self {
Self {
date: args.date.clone(),
dry_run: args.dry_run,
out: args.out.clone(),
max_articles: args.max_articles,
skip_llm: args.skip_llm,
}
}
}
#[derive(Debug, Subcommand)]
enum ProfileCommand {
/// Regenerate the taste profile from ratings history.
Rebuild,
}
#[derive(Debug, Subcommand)]
enum RatingsCommand {
/// List current explicit ratings, newest first.
List(RatingsListArgs),
/// Set or correct an article's explicit rating.
Set(RatingsSetArgs),
/// Clear an article from the learned rating set.
Clear(RatingsClearArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RatingListLabel {
Loved,
Good,
Down,
Cleared,
}
impl RatingListLabel {
fn event_label(self) -> &'static str {
match self {
Self::Loved => "loved",
Self::Good => "good",
Self::Down => "not_for_me",
Self::Cleared => "cleared",
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RatingSetLabel {
Loved,
Good,
Down,
}
impl RatingSetLabel {
fn vote(self) -> Vote {
match self {
Self::Loved => Vote::Loved,
Self::Good => Vote::Good,
Self::Down => Vote::NotForMe,
}
}
}
#[derive(Debug, clap::Args)]
struct RatingsListArgs {
#[arg(long, default_value_t = 90)]
days: i64,
#[arg(long, value_enum)]
label: Option<RatingListLabel>,
}
#[derive(Debug, clap::Args)]
struct RatingsSetArgs {
#[arg(long, required_unless_present = "url", conflicts_with = "url")]
article: Option<ArticleId>,
#[arg(long, required_unless_present = "article", conflicts_with = "article")]
url: Option<String>,
#[arg(long, value_enum)]
label: RatingSetLabel,
#[arg(long)]
note: Option<String>,
}
#[derive(Debug, clap::Args)]
struct RatingsClearArgs {
#[arg(long, required_unless_present = "url", conflicts_with = "url")]
article: Option<ArticleId>,
#[arg(long, required_unless_present = "article", conflicts_with = "article")]
url: Option<String>,
}
#[derive(Debug, clap::Args)]
struct BackfillSocialArgs {
/// How many days back to re-poll.
#[arg(long, default_value_t = 7)]
days: u32,
}
#[derive(Debug, Subcommand)]
enum DbCommand {
/// Run pending sqlx migrations.
Migrate,
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let config = Config::load(cli.config.as_deref()).context("loading configuration")?;
tracing::debug!(?config.database_path, "configuration loaded");
match cli.command {
Command::Generate(args) => {
let db = Db::open_and_migrate(&config.database_path).await?;
let outcome = pipeline::generate(&config, &db, &GenerateOptions::from(&args)).await?;
print_outcome(&outcome);
}
Command::Serve => {
let db = Db::open_and_migrate(&config.database_path).await?;
server::serve(config, db).await?;
}
Command::Profile(ProfileCommand::Rebuild) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_profile_rebuild(&config, &db).await?;
}
Command::Ratings(command) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_ratings(&config, &db, command).await?;
}
Command::BackfillSocial(args) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_backfill_social(&db, args.days).await?;
}
Command::Db(DbCommand::Migrate) => {
let db = Db::open(&config.database_path).await?;
db.migrate().await?;
println!("migrations up to date: {}", config.database_path.display());
}
}
Ok(())
}
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn,reqwest=warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_writer(std::io::stderr)
.init();
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
/// Human-readable end-of-run output; the machine-readable form lives in `runs`
/// and in the issue's `report_json` (§3.13).
fn print_outcome(outcome: &GenerateOutcome) {
print_report(&outcome.report);
if let Some(issue) = &outcome.issue {
print_lineup(issue);
}
for artifact in &outcome.artifacts {
println!(
"built: {} ({:.1} MiB)",
artifact.path.display(),
artifact.bytes as f64 / (1024.0 * 1024.0)
);
}
if let Some(xtc) = &outcome.xtc {
println!("xtc: {}", xtc.display());
}
match &outcome.published {
Some(published) => {
for artifact in &published.epubs {
println!("published: {}", artifact.path.display());
}
if let Some(xtc) = &published.xtc {
println!("published: {}", xtc.display());
}
if published.pruned > 0 {
println!("pruned: {} expired files", published.pruned);
}
}
None => println!("dry run: nothing was published"),
}
}
fn print_report(report: &RunReport) {
println!("{}", report.summary_line());
if let (Some(start), Some(end)) = (report.window_start, report.window_end) {
println!("window: {start}{end}");
}
println!(
"entries: {} from {} feeds → {} articles ({} merged, {} dropped)",
report.counts.entries_fetched,
report.counts.feeds_seen,
report.counts.articles,
report.counts.duplicates_merged,
report.counts.entries_dropped,
);
if report.counts.llm_unscored > 0 {
println!(
"curation: {} scored · {} unscored · {} selected",
report.counts.llm_scored, report.counts.llm_unscored, report.counts.selected,
);
}
println!(
"tokens: {} input · {} cache read · {} cache write · {} output = ${:.4}",
report.usage.input_tokens,
report.usage.cached_tokens,
report.usage.cache_write_tokens,
report.usage.output_tokens,
report.cost_usd,
);
for (provider, usage) in &report.provider_costs {
println!(
" {provider}: {} input · {} cache read · {} cache write · {} output = ${:.4}",
usage.usage.input_tokens,
usage.usage.cached_tokens,
usage.usage.cache_write_tokens,
usage.usage.output_tokens,
usage.cost_usd,
);
}
for warning in &report.warnings {
println!("warning: {warning}");
}
if let Some(err) = &report.error {
println!("error: {err}");
}
tracing::debug!("{}", report.to_json());
}
/// The day's lineup, section by section — the `--dry-run` deliverable (§4 M3).
fn print_lineup(issue: &daily_epub::types::Issue) {
println!(
"\nThe Daily EPUB No. {}{} · {}",
issue.meta.issue_number,
issue.meta.display_date,
issue.meta.stats_line()
);
for section in &issue.lineup.section_order {
println!("\n {section}");
for pick in issue.lineup.section_picks(section) {
let lead = if pick.is_lead { "★ " } else { " " };
println!(
" {lead}{}{} ({} min)",
pick.article.title,
pick.article.feed_title,
pick.article.reading_minutes()
);
}
}
if issue.world_briefing.is_some() {
println!("\n {}", daily_epub::types::WORLD_BRIEFING_SECTION);
}
println!();
}
// ---------------------------------------------------------------------------
// Other subcommands
// ---------------------------------------------------------------------------
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3).
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
use curate::llm::{Llms, PriceTable, UsageMeter};
let bulk_meter =
UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
let editor_meter = UsageMeter::with_prices(
PriceTable::anthropic(&config.anthropic),
config.anthropic.max_daily_usd,
);
let profile = curate::profile::load_or_build(
db,
&config.interests_opml,
&config.profile_path,
config.curation.feedback.verdicts_in_prompt,
)
.await?;
let llms = Llms::from_config(
&config.deepseek,
&config.anthropic,
profile.text,
bulk_meter,
editor_meter,
);
let Some(llm) = llms.editor_or_bulk() else {
anyhow::bail!(
"no LLM provider is configured; set DAILY_EPUB_ANTHROPIC__API_KEY or DAILY_EPUB_DEEPSEEK__API_KEY"
);
};
tracing::info!(provider = llm.provider, model = %llm.model, "rebuilding the profile");
let rebuilt = curate::profile::rebuild(
db,
llm,
&config.interests_opml,
&config.profile_path,
config.curation.feedback.verdicts_in_prompt,
)
.await?;
println!(
"taste profile rebuilt (version {}, {} chars)",
rebuilt.version,
rebuilt.text.len()
);
Ok(())
}
async fn resolve_rating_article(
db: &Db,
article: Option<ArticleId>,
url: Option<&str>,
) -> Result<ArticleId> {
let article_id = match (article, url) {
(Some(article_id), None) => article_id,
(None, Some(url)) => {
let canonical = daily_epub::dedupe::canonical_url(url)
.with_context(|| format!("invalid article URL {url:?}"))?;
db.article_id_for_url(&canonical)
.await?
.with_context(|| format!("no article found for {canonical}"))?
}
_ => anyhow::bail!("provide exactly one of --article or --url"),
};
if db.get_article(article_id).await?.is_none() {
anyhow::bail!("article {article_id} was not found");
}
Ok(article_id)
}
async fn append_cli_event(
config: &Config,
db: &Db,
article_id: ArticleId,
vote: Option<Vote>,
note: Option<String>,
) -> Result<i64> {
let (label, value) = match vote {
Some(Vote::Loved) => ("loved", Vote::Loved.value(&config.curation.feedback)),
Some(Vote::Good) => ("good", Vote::Good.value(&config.curation.feedback)),
Some(Vote::NotForMe) => (
"not_for_me",
Vote::NotForMe.value(&config.curation.feedback),
),
None => ("cleared", 0.0),
};
let event = RatingEvent {
id: 0,
article_id,
issue_date: db.latest_issue_date_for_article(article_id).await?,
kind: "explicit".into(),
source: "cli".into(),
label: label.into(),
value,
note,
event_at: jiff::Timestamp::now(),
};
Ok(db.append_rating_event(&event).await?)
}
async fn cmd_ratings(config: &Config, db: &Db, command: RatingsCommand) -> Result<()> {
match command {
RatingsCommand::List(args) => {
let ratings = db.current_ratings_including_cleared(args.days).await?;
let mut shown = 0usize;
for rating in ratings {
if args
.label
.is_some_and(|label| rating.label != label.event_label())
{
continue;
}
let note = rating
.note
.as_deref()
.map(|note| format!(" · note: {note}"))
.unwrap_or_default();
println!(
"{} · article {} · {} · {}{}{}",
rating.event_at,
rating.article_id,
rating.label,
rating.title,
rating.feed_title,
note
);
shown += 1;
}
println!("{shown} current rating(s)");
}
RatingsCommand::Set(args) => {
let article_id = resolve_rating_article(db, args.article, args.url.as_deref()).await?;
let vote = args.label.vote();
let event_id = append_cli_event(config, db, article_id, Some(vote), args.note).await?;
println!(
"recorded {} for article {article_id} (event {event_id})",
vote.as_str()
);
}
RatingsCommand::Clear(args) => {
let article_id = resolve_rating_article(db, args.article, args.url.as_deref()).await?;
let event_id = append_cli_event(config, db, article_id, None, None).await?;
println!("cleared article {article_id} (event {event_id})");
}
}
Ok(())
}
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<()> {
let http = http::build_client(http::DEFAULT_TIMEOUT)?;
let enricher = social::SocialEnricher::new(http, db.clone());
let updated = enricher.backfill(days).await?;
println!("refreshed social scores for {updated} articles");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parses_every_subcommand_from_the_spec() {
let cli = Cli::try_parse_from([
"daily-epub",
"generate",
"--date",
"2026-08-15",
"--dry-run",
"--out",
"./out",
"--max-articles",
"6",
"--skip-llm",
])
.unwrap();
match cli.command {
Command::Generate(a) => {
assert_eq!(a.date.as_deref(), Some("2026-08-15"));
assert!(a.dry_run);
assert_eq!(a.out, Some(PathBuf::from("./out")));
assert_eq!(a.max_articles, Some(6));
assert!(a.skip_llm);
let opts = GenerateOptions::from(&a);
assert_eq!(opts.date.as_deref(), Some("2026-08-15"));
assert!(opts.dry_run && opts.skip_llm);
assert_eq!(opts.max_articles, Some(6));
}
other => panic!("expected generate, got {other:?}"),
}
assert!(matches!(
Cli::try_parse_from(["daily-epub", "serve"])
.unwrap()
.command,
Command::Serve
));
assert!(matches!(
Cli::try_parse_from(["daily-epub", "profile", "rebuild"])
.unwrap()
.command,
Command::Profile(ProfileCommand::Rebuild)
));
assert!(matches!(
Cli::try_parse_from([
"daily-epub",
"ratings",
"set",
"--article",
"42",
"--label",
"good"
])
.unwrap()
.command,
Command::Ratings(RatingsCommand::Set(_))
));
assert!(matches!(
Cli::try_parse_from([
"daily-epub",
"ratings",
"clear",
"--url",
"https://example.com"
])
.unwrap()
.command,
Command::Ratings(RatingsCommand::Clear(_))
));
assert!(matches!(
Cli::try_parse_from(["daily-epub", "backfill-social", "--days", "14"])
.unwrap()
.command,
Command::BackfillSocial(BackfillSocialArgs { days: 14 })
));
assert!(matches!(
Cli::try_parse_from(["daily-epub", "db", "migrate"])
.unwrap()
.command,
Command::Db(DbCommand::Migrate)
));
let cli = Cli::try_parse_from(["daily-epub", "--config", "/tmp/x.toml", "serve"]).unwrap();
assert_eq!(cli.config, Some(PathBuf::from("/tmp/x.toml")));
}
#[tokio::test]
async fn cli_set_and_clear_append_cli_events_with_latest_issue_date() {
use sqlx::Row as _;
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("ratings.db"))
.await
.unwrap();
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, first_seen) VALUES
(42, 'https://example.com/article', 'Article', '2026-08-15T00:00:00Z')",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO issues (date, issue_number, generated_at) VALUES
('2026-08-14', 1, '2026-08-14T12:00:00Z'),
('2026-08-15', 2, '2026-08-15T12:00:00Z')",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO issue_articles (issue_date, article_id, section) VALUES
('2026-08-14', 42, 'Top Stories'),
('2026-08-15', 42, 'Top Stories')",
)
.execute(db.pool())
.await
.unwrap();
let config = Config::default();
append_cli_event(
&config,
&db,
42,
Some(Vote::Good),
Some("useful note".into()),
)
.await
.unwrap();
append_cli_event(&config, &db, 42, None, None)
.await
.unwrap();
let rows = sqlx::query(
"SELECT issue_date, source, label, value, note FROM rating_events ORDER BY id",
)
.fetch_all(db.pool())
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].get::<String, _>("source"), "cli");
assert_eq!(rows[0].get::<String, _>("issue_date"), "2026-08-15");
assert_eq!(rows[0].get::<String, _>("label"), "good");
assert_eq!(rows[0].get::<f64, _>("value"), 0.35);
assert_eq!(rows[0].get::<String, _>("note"), "useful note");
assert_eq!(rows[1].get::<String, _>("source"), "cli");
assert_eq!(rows[1].get::<String, _>("label"), "cleared");
assert_eq!(rows[1].get::<f64, _>("value"), 0.0);
assert!(db.current_ratings(36500).await.unwrap().is_empty());
}
}