Job catalogue and daily-epub job run, the systemd job unit and polkit rule, SystemdRunner/MockRunner, the Jobs pages with status and journal tail, the stats_data refactor with a byte-identical CLI, the stats page and the overview sparklines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
1426 lines
48 KiB
Rust
1426 lines
48 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::io::Write as _;
|
|
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::curate::embedding::{self, BACKFILL_CONFIRM_TOKENS};
|
|
use daily_epub::curate::telemetry;
|
|
use daily_epub::db::Db;
|
|
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
|
|
use daily_epub::report::{RunReport, VOYAGE_PROVIDER};
|
|
use daily_epub::types::{ArticleId, RatingEvent, Vote};
|
|
use daily_epub::{curate, http, jobs, lock, 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),
|
|
/// Why an article was (not) in the paper, from persisted run telemetry.
|
|
Explain(ExplainArgs),
|
|
/// The weekly numbers: issues, ratings, retriever yield, cost, timing.
|
|
Stats(StatsArgs),
|
|
/// Embedding cache and telemetry maintenance.
|
|
#[command(subcommand)]
|
|
Features(FeaturesCommand),
|
|
/// Re-poll social scores for recent entries.
|
|
BackfillSocial(BackfillSocialArgs),
|
|
/// Database maintenance.
|
|
#[command(subcommand)]
|
|
Db(DbCommand),
|
|
/// Inspect the resolved configuration.
|
|
#[command(subcommand)]
|
|
Config(ConfigCommand),
|
|
/// Manage dashboard users without taking the pipeline run lock.
|
|
#[command(subcommand)]
|
|
Users(UsersCommand),
|
|
/// Operator jobs (what `daily-epub-job@<name>.service` runs).
|
|
#[command(subcommand)]
|
|
Job(JobCommand),
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum JobCommand {
|
|
/// Run one catalogue job in-process and record it in the `jobs` table.
|
|
Run {
|
|
/// `generate`, `generate-YYYY-MM-DD`, `dry-run`, `profile-rebuild`,
|
|
/// `features-backfill`, `backfill-social` or `features-prune`.
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum UsersCommand {
|
|
/// Add a user.
|
|
Add(UserAddArgs),
|
|
/// Change a user's password and revoke existing sessions.
|
|
Passwd(UserPasswordArgs),
|
|
/// Change a user's role.
|
|
Role { username: String, role: String },
|
|
/// Disable a user and revoke existing sessions.
|
|
Disable { username: String },
|
|
/// Enable a user.
|
|
Enable { username: String },
|
|
/// List users and open-session counts.
|
|
List,
|
|
/// Revoke all sessions for a user.
|
|
Logout { username: String },
|
|
}
|
|
|
|
#[derive(Debug, clap::Args)]
|
|
struct UserAddArgs {
|
|
username: String,
|
|
#[arg(long)]
|
|
admin: bool,
|
|
#[arg(long)]
|
|
password_stdin: bool,
|
|
}
|
|
|
|
#[derive(Debug, clap::Args)]
|
|
struct UserPasswordArgs {
|
|
username: String,
|
|
#[arg(long)]
|
|
password_stdin: bool,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ConfigCommand {
|
|
/// Load and validate the config as `generate` would, then print one fact per line.
|
|
Check,
|
|
}
|
|
|
|
#[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: cheap-signal admission, excerpt summaries.
|
|
#[arg(long)]
|
|
skip_llm: bool,
|
|
/// Use cached embeddings only: zero Voyage calls.
|
|
#[arg(long)]
|
|
skip_embeddings: bool,
|
|
/// Ignore reusable triage/deep assessments and ask the bulk model again.
|
|
#[arg(long)]
|
|
rescore: 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,
|
|
skip_embeddings: args.skip_embeddings,
|
|
rescore: args.rescore,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 {
|
|
/// How many days of verdicts to list.
|
|
#[arg(long, default_value_t = 90)]
|
|
days: i64,
|
|
/// Only verdicts with this label.
|
|
#[arg(long, value_enum)]
|
|
label: Option<RatingListLabel>,
|
|
}
|
|
|
|
#[derive(Debug, clap::Args)]
|
|
struct RatingsSetArgs {
|
|
/// Article id, as printed by `ratings list` or `explain`.
|
|
#[arg(long, required_unless_present = "url", conflicts_with = "url")]
|
|
article: Option<ArticleId>,
|
|
/// Article URL; canonicalized before lookup.
|
|
#[arg(long, required_unless_present = "article", conflicts_with = "article")]
|
|
url: Option<String>,
|
|
/// The verdict to record.
|
|
#[arg(long, value_enum)]
|
|
label: RatingSetLabel,
|
|
/// Free-text note shown to the weekly profile rebuild.
|
|
#[arg(long)]
|
|
note: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, clap::Args)]
|
|
struct RatingsClearArgs {
|
|
/// Article id, as printed by `ratings list` or `explain`.
|
|
#[arg(long, required_unless_present = "url", conflicts_with = "url")]
|
|
article: Option<ArticleId>,
|
|
/// Article URL; canonicalized before lookup.
|
|
#[arg(long, required_unless_present = "article", conflicts_with = "article")]
|
|
url: Option<String>,
|
|
}
|
|
|
|
/// `explain --date D (--article ID | --url URL) [--run-id N]` or
|
|
/// `explain --date D --near-misses [N]` (plan §15.2).
|
|
#[derive(Debug, clap::Args)]
|
|
struct ExplainArgs {
|
|
/// Issue date whose run to read.
|
|
#[arg(long, value_name = "YYYY-MM-DD")]
|
|
date: String,
|
|
/// Article id, as printed by `ratings list` or `explain --near-misses`.
|
|
#[arg(
|
|
long,
|
|
required_unless_present_any = ["url", "near_misses"],
|
|
conflicts_with_all = ["url", "near_misses"]
|
|
)]
|
|
article: Option<ArticleId>,
|
|
/// Article URL; canonicalized before lookup.
|
|
#[arg(long, conflicts_with = "near_misses")]
|
|
url: Option<String>,
|
|
/// A specific run of that date instead of the latest non-dry one.
|
|
#[arg(long, value_name = "N")]
|
|
run_id: Option<i64>,
|
|
/// The top N articles that were considered but not selected (default 10).
|
|
#[arg(long, value_name = "N", num_args = 0..=1, default_missing_value = "10")]
|
|
near_misses: Option<usize>,
|
|
}
|
|
|
|
/// `stats [--days 14]` (plan §15.3).
|
|
#[derive(Debug, clap::Args)]
|
|
struct StatsArgs {
|
|
/// How many days back to summarize.
|
|
#[arg(long, default_value_t = 14)]
|
|
days: i64,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum FeaturesCommand {
|
|
/// Embed rated and published articles, then interests, into the cache.
|
|
Backfill(BackfillArgs),
|
|
/// Drop stale embeddings, old candidate telemetry and old assessments per the retention config.
|
|
Prune,
|
|
}
|
|
|
|
#[derive(Debug, clap::Args)]
|
|
struct BackfillArgs {
|
|
/// Window for published (and, with --all, other) articles.
|
|
#[arg(long, default_value_t = 30)]
|
|
days: i64,
|
|
/// Only the rated set.
|
|
#[arg(long, conflicts_with = "all")]
|
|
rated_only: bool,
|
|
/// Also every other article first seen inside the window.
|
|
#[arg(long)]
|
|
all: bool,
|
|
/// Skip the confirmation prompt above the token threshold.
|
|
#[arg(long)]
|
|
yes: bool,
|
|
}
|
|
|
|
#[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");
|
|
|
|
// One writer at a time (§5); read-only commands never wait on it.
|
|
let _lock = match lock_holder(&cli.command) {
|
|
Some(name) => {
|
|
Some(lock::acquire(&config.database_path, name).map_err(|e| anyhow::anyhow!("{e}"))?)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
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?;
|
|
let config_path = Config::resolve_path(cli.config.as_deref());
|
|
server::serve(config, config_path, db).await?;
|
|
}
|
|
Command::Profile(ProfileCommand::Rebuild) => {
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
println!("{}", 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::Explain(args) => {
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
cmd_explain(&db, args).await?;
|
|
}
|
|
Command::Stats(args) => {
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
let text = telemetry::stats(&db, args.days, jiff::Timestamp::now()).await?;
|
|
print!("{text}");
|
|
}
|
|
Command::Features(command) => {
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
println!("{}", cmd_features(&config, &db, command).await?);
|
|
}
|
|
Command::BackfillSocial(args) => {
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
println!("{}", 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());
|
|
}
|
|
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}");
|
|
}
|
|
}
|
|
Command::Users(command) => {
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
cmd_users(&db, command).await?;
|
|
}
|
|
Command::Job(JobCommand::Run { name }) => {
|
|
let Some(job) = jobs::Job::parse(&name) else {
|
|
eprintln!("unknown job {name:?}; the catalogue is:");
|
|
for job in jobs::Job::CATALOGUE {
|
|
eprintln!(" {:<20} {}", job.name(), job.description());
|
|
}
|
|
eprintln!(
|
|
" {:<20} {}",
|
|
"generate-YYYY-MM-DD",
|
|
jobs::Job::Generate {
|
|
date: Some(jiff::civil::Date::default())
|
|
}
|
|
.description()
|
|
);
|
|
std::process::exit(2);
|
|
};
|
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
|
cmd_job_run(&config, &db, &job).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The commands that write the database and provider budgets and so hold the
|
|
/// run lock (§5): `generate`, `profile rebuild`, `features backfill`,
|
|
/// `backfill-social`, and a `job run` of any of them. Everything else is
|
|
/// read-only or its own writer.
|
|
fn lock_holder(command: &Command) -> Option<&'static str> {
|
|
match command {
|
|
Command::Generate(_) => Some("generate"),
|
|
Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"),
|
|
Command::Features(FeaturesCommand::Backfill(_)) => Some("features backfill"),
|
|
Command::BackfillSocial(_) => Some("backfill-social"),
|
|
// An unknown name takes no lock; the dispatch exits 2 before opening
|
|
// the database.
|
|
Command::Job(JobCommand::Run { name }) => {
|
|
jobs::Job::parse(name).and_then(|job| job.takes_lock())
|
|
}
|
|
Command::Serve
|
|
| Command::Ratings(_)
|
|
| Command::Explain(_)
|
|
| Command::Stats(_)
|
|
| Command::Features(FeaturesCommand::Prune)
|
|
| Command::Db(_)
|
|
| Command::Config(_)
|
|
| Command::Users(_) => None,
|
|
}
|
|
}
|
|
|
|
async fn cmd_users(db: &Db, command: UsersCommand) -> Result<()> {
|
|
use daily_epub::web::users;
|
|
match command {
|
|
UsersCommand::Add(args) => {
|
|
let password = read_new_password(args.password_stdin)?;
|
|
let user = users::add(db, &args.username, &password, args.admin).await?;
|
|
println!("added {} ({})", user.username, user.role);
|
|
}
|
|
UsersCommand::Passwd(args) => {
|
|
let password = read_new_password(args.password_stdin)?;
|
|
let sessions = users::passwd(db, &args.username, &password).await?;
|
|
println!(
|
|
"changed password for {} and revoked {sessions} session(s)",
|
|
args.username
|
|
);
|
|
}
|
|
UsersCommand::Role { username, role } => {
|
|
let role = role.parse().map_err(anyhow::Error::msg)?;
|
|
users::set_role(db, &username, role).await?;
|
|
println!("set {username} role to {role}");
|
|
}
|
|
UsersCommand::Disable { username } => {
|
|
let sessions = users::set_disabled(db, &username, true).await?;
|
|
println!("disabled {username} and revoked {sessions} session(s)");
|
|
}
|
|
UsersCommand::Enable { username } => {
|
|
users::set_disabled(db, &username, false).await?;
|
|
println!("enabled {username}");
|
|
}
|
|
UsersCommand::List => {
|
|
for row in users::list(db).await? {
|
|
let last_login = row
|
|
.user
|
|
.last_login_at
|
|
.map(|timestamp| timestamp.to_string())
|
|
.unwrap_or_else(|| "never".into());
|
|
println!(
|
|
"{} · {}{} · created {} · last login {} · {} open session(s)",
|
|
row.user.username,
|
|
row.user.role,
|
|
if row.user.disabled {
|
|
" · disabled"
|
|
} else {
|
|
""
|
|
},
|
|
row.user.created_at,
|
|
last_login,
|
|
row.open_sessions
|
|
);
|
|
}
|
|
}
|
|
UsersCommand::Logout { username } => {
|
|
let sessions = users::logout(db, &username).await?;
|
|
println!("revoked {sessions} session(s) for {username}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_new_password(from_stdin: bool) -> Result<String> {
|
|
if from_stdin {
|
|
let mut password = String::new();
|
|
std::io::stdin().read_line(&mut password)?;
|
|
while password.ends_with(['\n', '\r']) {
|
|
password.pop();
|
|
}
|
|
return Ok(password);
|
|
}
|
|
let first = read_password_hidden("Password: ")?;
|
|
let second = read_password_hidden("Confirm password: ")?;
|
|
if first != second {
|
|
anyhow::bail!("passwords do not match");
|
|
}
|
|
Ok(first)
|
|
}
|
|
|
|
fn read_password_hidden(prompt: &str) -> Result<String> {
|
|
rpassword::prompt_password(prompt).context("reading the password from the terminal")
|
|
}
|
|
|
|
/// `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,
|
|
);
|
|
// The same four lines the run logged (§15.4).
|
|
for line in report.info_block() {
|
|
println!("{line}");
|
|
}
|
|
println!(
|
|
"tokens: {} input · {} cache read · {} cache write · {} output · {} voyage = ${:.4}",
|
|
report.usage.input_tokens,
|
|
report.usage.cached_tokens,
|
|
report.usage.cache_write_tokens,
|
|
report.usage.output_tokens,
|
|
report.voyage_tokens,
|
|
report.cost_usd,
|
|
);
|
|
for (provider, usage) in &report.provider_costs {
|
|
if provider == VOYAGE_PROVIDER {
|
|
continue; // embedding tokens are printed on their own line below
|
|
}
|
|
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,
|
|
);
|
|
}
|
|
println!(
|
|
" voyage: {} tokens = ${:.4}",
|
|
report.voyage_tokens, report.voyage_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).
|
|
/// Returns the one-line summary the CLI prints and `job run` records.
|
|
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<String> {
|
|
use curate::llm::{Llms, provider_meters};
|
|
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, profile.text, &provider_meters(config));
|
|
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!(
|
|
"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");
|
|
let rebuilt = curate::profile::rebuild(
|
|
db,
|
|
llm,
|
|
&config.interests_opml,
|
|
&config.profile_path,
|
|
config.curation.feedback.verdicts_in_prompt,
|
|
)
|
|
.await?;
|
|
Ok(format!(
|
|
"taste profile rebuilt (version {}, {} chars)",
|
|
rebuilt.version,
|
|
rebuilt.text.len()
|
|
))
|
|
}
|
|
|
|
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,
|
|
user_id: None,
|
|
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_explain(db: &Db, args: ExplainArgs) -> Result<()> {
|
|
let date: jiff::civil::Date = args
|
|
.date
|
|
.parse()
|
|
.with_context(|| format!("invalid --date {:?}, expected YYYY-MM-DD", args.date))?;
|
|
let text = if let Some(limit) = args.near_misses {
|
|
telemetry::explain_near_misses(db, date, args.run_id, limit).await?
|
|
} else {
|
|
let target = match (args.article, args.url) {
|
|
(Some(id), _) => telemetry::ExplainTarget::Article(id),
|
|
(None, Some(url)) => telemetry::ExplainTarget::Url(url),
|
|
(None, None) => anyhow::bail!("provide --article, --url or --near-misses"),
|
|
};
|
|
telemetry::explain(db, date, args.run_id, &target).await?
|
|
};
|
|
print!("{text}");
|
|
Ok(())
|
|
}
|
|
|
|
/// `features backfill` / `features prune`; returns the final summary line
|
|
/// (progress lines are printed as they happen).
|
|
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> {
|
|
match command {
|
|
FeaturesCommand::Backfill(args) => {
|
|
if !config.voyage.enabled {
|
|
anyhow::bail!("voyage.enabled is false; nothing to backfill");
|
|
}
|
|
let service = embedding::EmbeddingService::real(db.clone(), config.voyage.clone())
|
|
.context("building the Voyage client")?;
|
|
let opts = embedding::BackfillOptions {
|
|
days: args.days,
|
|
rated_only: args.rated_only,
|
|
all: args.all,
|
|
};
|
|
let plan = embedding::plan_backfill(db, config, &service, &opts).await?;
|
|
println!(
|
|
"backfill: {} articles ({} learned, {} other) + {} interests to embed, {} already cached",
|
|
plan.article_count(),
|
|
plan.learned.len(),
|
|
plan.others.len(),
|
|
plan.interests.len(),
|
|
plan.cached
|
|
);
|
|
if plan.is_empty() {
|
|
return Ok("cache is warm; nothing to do".into());
|
|
}
|
|
println!(
|
|
"estimate: ~{} tokens ≈ ${:.4} with {} at ${:.2}/M",
|
|
plan.estimated_tokens,
|
|
plan.estimated_cost_usd(),
|
|
config.voyage.model,
|
|
embedding::VOYAGE_PRICE_PER_MTOK
|
|
);
|
|
if plan.estimated_tokens > BACKFILL_CONFIRM_TOKENS
|
|
&& !args.yes
|
|
&& !confirm("continue?")?
|
|
{
|
|
return Ok("aborted".into());
|
|
}
|
|
let outcome = embedding::run_backfill(&service, &plan).await?;
|
|
Ok(format!(
|
|
"embedded {} articles and {} interests · {} tokens · ${:.4}",
|
|
outcome.articles_embedded,
|
|
outcome.interests_embedded,
|
|
outcome.tokens,
|
|
outcome.cost_usd
|
|
))
|
|
}
|
|
FeaturesCommand::Prune => {
|
|
let ranking = &config.curation.ranking;
|
|
let pruned = telemetry::prune(
|
|
db,
|
|
ranking.embedding_retention_days,
|
|
ranking.telemetry_retention_days,
|
|
jiff::Timestamp::now(),
|
|
)
|
|
.await?;
|
|
Ok(format!(
|
|
"pruned {} embeddings older than {} days, {} candidate rows and {} assessments older than {} days",
|
|
pruned.embeddings,
|
|
ranking.embedding_retention_days,
|
|
pruned.telemetry,
|
|
pruned.assessments,
|
|
ranking.telemetry_retention_days
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A y/N question on stdin; anything but a leading `y` is a no.
|
|
fn confirm(question: &str) -> Result<bool> {
|
|
print!("{question} [y/N] ");
|
|
std::io::stdout().flush()?;
|
|
let mut answer = String::new();
|
|
std::io::stdin().read_line(&mut answer)?;
|
|
Ok(answer.trim().to_lowercase().starts_with('y'))
|
|
}
|
|
|
|
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<String> {
|
|
let http = http::build_client(http::DEFAULT_TIMEOUT)?;
|
|
let enricher = social::SocialEnricher::new(http, db.clone());
|
|
let updated = enricher.backfill(days).await?;
|
|
Ok(format!("refreshed social scores for {updated} articles"))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `job run` (dashboard plan §14.2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `job run <name>`: claim the newest `requested` row of this job (or insert
|
|
/// one when the unit was started by hand), run the mapped command in-process
|
|
/// with the same functions the plain subcommands use, and record `ok` /
|
|
/// `failed` with a one-line message and, for generate, the run id. A failure
|
|
/// propagates so the unit exits non-zero (`Result=exit-code`).
|
|
async fn cmd_job_run(config: &Config, db: &Db, job: &jobs::Job) -> Result<()> {
|
|
let id = jobs::claim(db, job, jiff::Timestamp::now())
|
|
.await
|
|
.context("claiming the jobs row")?;
|
|
tracing::info!(job = %job.name(), job_id = id, "job started");
|
|
let result = run_job(config, db, job).await;
|
|
let now = jiff::Timestamp::now();
|
|
match result {
|
|
Ok((message, run_id)) => {
|
|
jobs::finish(db, id, jobs::Outcome::Ok, &message, run_id, now)
|
|
.await
|
|
.context("recording the job outcome")?;
|
|
tracing::info!(job = %job.name(), job_id = id, %message, "job finished");
|
|
Ok(())
|
|
}
|
|
Err(error) => {
|
|
let message = format!("{error:#}");
|
|
jobs::finish(db, id, jobs::Outcome::Failed, &message, None, now)
|
|
.await
|
|
.context("recording the job failure")?;
|
|
tracing::error!(job = %job.name(), job_id = id, %message, "job failed");
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The command each catalogue job maps to (§14.1), returning its one-line
|
|
/// message and the run id it produced.
|
|
async fn run_job(config: &Config, db: &Db, job: &jobs::Job) -> Result<(String, Option<i64>)> {
|
|
match job {
|
|
jobs::Job::Generate { date } => {
|
|
generate_job(config, db, date.map(|date| date.to_string()), false).await
|
|
}
|
|
jobs::Job::DryRun => generate_job(config, db, None, true).await,
|
|
jobs::Job::ProfileRebuild => Ok((cmd_profile_rebuild(config, db).await?, None)),
|
|
jobs::Job::FeaturesBackfill => {
|
|
let args = BackfillArgs {
|
|
days: 30,
|
|
rated_only: false,
|
|
all: false,
|
|
yes: true,
|
|
};
|
|
Ok((
|
|
cmd_features(config, db, FeaturesCommand::Backfill(args)).await?,
|
|
None,
|
|
))
|
|
}
|
|
jobs::Job::BackfillSocial => Ok((cmd_backfill_social(db, 7).await?, None)),
|
|
jobs::Job::FeaturesPrune => Ok((
|
|
cmd_features(config, db, FeaturesCommand::Prune).await?,
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// `generate [--date D] [--dry-run]` as a job: the `curation:` line is the
|
|
/// message, the run id links the job to its run.
|
|
async fn generate_job(
|
|
config: &Config,
|
|
db: &Db,
|
|
date: Option<String>,
|
|
dry_run: bool,
|
|
) -> Result<(String, Option<i64>)> {
|
|
let opts = GenerateOptions {
|
|
date,
|
|
dry_run,
|
|
..GenerateOptions::default()
|
|
};
|
|
let outcome = pipeline::generate(config, db, &opts).await?;
|
|
print_outcome(&outcome);
|
|
let [curation, ..] = outcome.report.info_block();
|
|
Ok((curation, Some(outcome.run_id)))
|
|
}
|
|
|
|
#[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",
|
|
"--skip-embeddings",
|
|
"--rescore",
|
|
])
|
|
.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);
|
|
assert!(a.skip_embeddings);
|
|
assert!(a.rescore);
|
|
|
|
let opts = GenerateOptions::from(&a);
|
|
assert_eq!(opts.date.as_deref(), Some("2026-08-15"));
|
|
assert!(opts.dry_run && opts.skip_llm && opts.skip_embeddings && opts.rescore);
|
|
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")));
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_writing_commands_take_the_lock() {
|
|
let parse = |args: &[&str]| {
|
|
Cli::try_parse_from(std::iter::once("daily-epub").chain(args.iter().copied()))
|
|
.unwrap()
|
|
.command
|
|
};
|
|
assert_eq!(lock_holder(&parse(&["generate"])), Some("generate"));
|
|
assert_eq!(
|
|
lock_holder(&parse(&["profile", "rebuild"])),
|
|
Some("profile rebuild")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["features", "backfill"])),
|
|
Some("features backfill")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["backfill-social"])),
|
|
Some("backfill-social")
|
|
);
|
|
for args in [
|
|
vec!["serve"],
|
|
vec!["explain", "--date", "2026-09-02", "--near-misses"],
|
|
vec!["stats"],
|
|
vec!["ratings", "list"],
|
|
vec!["db", "migrate"],
|
|
vec!["features", "prune"],
|
|
vec!["config", "check"],
|
|
vec!["job", "run", "features-prune"],
|
|
vec!["job", "run", "not-a-job"],
|
|
] {
|
|
assert_eq!(lock_holder(&parse(&args)), None, "{args:?}");
|
|
}
|
|
// `job run` takes the same lock as the command it maps to.
|
|
assert_eq!(
|
|
lock_holder(&parse(&["job", "run", "generate"])),
|
|
Some("generate")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["job", "run", "generate-2026-09-03"])),
|
|
Some("generate")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["job", "run", "dry-run"])),
|
|
Some("generate")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["job", "run", "profile-rebuild"])),
|
|
Some("profile rebuild")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["job", "run", "features-backfill"])),
|
|
Some("features backfill")
|
|
);
|
|
assert_eq!(
|
|
lock_holder(&parse(&["job", "run", "backfill-social"])),
|
|
Some("backfill-social")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_job_run() {
|
|
match Cli::try_parse_from(["daily-epub", "job", "run", "features-prune"])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Job(JobCommand::Run { name }) => assert_eq!(name, "features-prune"),
|
|
other => panic!("expected job run, got {other:?}"),
|
|
}
|
|
assert!(Cli::try_parse_from(["daily-epub", "job", "run"]).is_err());
|
|
assert!(Cli::try_parse_from(["daily-epub", "job"]).is_err());
|
|
}
|
|
|
|
/// Dashboard plan §17 "Jobs": `job run` flips the dashboard's `requested`
|
|
/// row to `running` and then `ok` with the command's message, in-process
|
|
/// and without systemd.
|
|
#[tokio::test]
|
|
async fn job_run_flips_requested_to_running_to_ok() {
|
|
use sqlx::Row as _;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut config = Config {
|
|
database_path: dir.path().join("jobs.db"),
|
|
..Config::default()
|
|
};
|
|
let db = Db::open_and_migrate(&config.database_path).await.unwrap();
|
|
let now = jiff::Timestamp::now();
|
|
let job = jobs::Job::FeaturesPrune;
|
|
let requested = jobs::insert_requested(&db, &job, None, now).await.unwrap();
|
|
assert_eq!(
|
|
jobs::get(&db, requested).await.unwrap().unwrap().status,
|
|
"requested"
|
|
);
|
|
|
|
cmd_job_run(&config, &db, &job).await.unwrap();
|
|
|
|
let row = jobs::get(&db, requested).await.unwrap().unwrap();
|
|
assert_eq!(row.status, "ok", "{row:?}");
|
|
assert!(row.started_at.is_some());
|
|
assert!(row.finished_at.is_some());
|
|
assert!(
|
|
row.message
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.starts_with("pruned 0 embeddings"),
|
|
"{row:?}"
|
|
);
|
|
assert_eq!(row.run_id, None);
|
|
let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM jobs")
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap()
|
|
.get("n");
|
|
assert_eq!(rows, 1, "the requested row was claimed, not duplicated");
|
|
|
|
// A failing command records `failed` with the error and propagates it.
|
|
config.voyage.enabled = false;
|
|
let backfill = jobs::Job::FeaturesBackfill;
|
|
let error = cmd_job_run(&config, &db, &backfill).await.unwrap_err();
|
|
assert!(error.to_string().contains("voyage.enabled is false"));
|
|
let failed = jobs::list(&db, 1).await.unwrap().remove(0);
|
|
assert_eq!(failed.name, "features-backfill");
|
|
assert_eq!(failed.status, "failed");
|
|
assert_eq!(failed.requested_by, None, "started by hand: no requester");
|
|
assert!(
|
|
failed
|
|
.message
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.contains("voyage.enabled is false"),
|
|
"{failed:?}"
|
|
);
|
|
}
|
|
|
|
#[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]
|
|
fn parses_stats() {
|
|
match Cli::try_parse_from(["daily-epub", "stats"])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Stats(args) => assert_eq!(args.days, 14),
|
|
other => panic!("expected stats, got {other:?}"),
|
|
}
|
|
match Cli::try_parse_from(["daily-epub", "stats", "--days", "7"])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Stats(args) => assert_eq!(args.days, 7),
|
|
other => panic!("expected stats, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_explain_and_features() {
|
|
match Cli::try_parse_from([
|
|
"daily-epub",
|
|
"explain",
|
|
"--date",
|
|
"2026-09-02",
|
|
"--article",
|
|
"42",
|
|
"--run-id",
|
|
"7",
|
|
])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Explain(args) => {
|
|
assert_eq!(args.date, "2026-09-02");
|
|
assert_eq!(args.article, Some(42));
|
|
assert_eq!(args.run_id, Some(7));
|
|
assert_eq!(args.near_misses, None);
|
|
}
|
|
other => panic!("expected explain, got {other:?}"),
|
|
}
|
|
match Cli::try_parse_from([
|
|
"daily-epub",
|
|
"explain",
|
|
"--date",
|
|
"2026-09-02",
|
|
"--url",
|
|
"https://example.com/post",
|
|
])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Explain(args) => {
|
|
assert_eq!(args.url.as_deref(), Some("https://example.com/post"))
|
|
}
|
|
other => panic!("expected explain, got {other:?}"),
|
|
}
|
|
match Cli::try_parse_from([
|
|
"daily-epub",
|
|
"explain",
|
|
"--date",
|
|
"2026-09-02",
|
|
"--near-misses",
|
|
])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Explain(args) => assert_eq!(args.near_misses, Some(10)),
|
|
other => panic!("expected explain, got {other:?}"),
|
|
}
|
|
match Cli::try_parse_from([
|
|
"daily-epub",
|
|
"explain",
|
|
"--date",
|
|
"2026-09-02",
|
|
"--near-misses",
|
|
"3",
|
|
])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Explain(args) => assert_eq!(args.near_misses, Some(3)),
|
|
other => panic!("expected explain, got {other:?}"),
|
|
}
|
|
assert!(Cli::try_parse_from(["daily-epub", "explain", "--date", "2026-09-02"]).is_err());
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"daily-epub",
|
|
"explain",
|
|
"--date",
|
|
"2026-09-02",
|
|
"--article",
|
|
"1",
|
|
"--near-misses"
|
|
])
|
|
.is_err()
|
|
);
|
|
|
|
match Cli::try_parse_from([
|
|
"daily-epub",
|
|
"features",
|
|
"backfill",
|
|
"--days",
|
|
"60",
|
|
"--all",
|
|
"--yes",
|
|
])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Features(FeaturesCommand::Backfill(args)) => {
|
|
assert_eq!(args.days, 60);
|
|
assert!(args.all && args.yes && !args.rated_only);
|
|
}
|
|
other => panic!("expected features backfill, got {other:?}"),
|
|
}
|
|
match Cli::try_parse_from(["daily-epub", "features", "backfill"])
|
|
.unwrap()
|
|
.command
|
|
{
|
|
Command::Features(FeaturesCommand::Backfill(args)) => assert_eq!(args.days, 30),
|
|
other => panic!("expected features backfill, got {other:?}"),
|
|
}
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"daily-epub",
|
|
"features",
|
|
"backfill",
|
|
"--rated-only",
|
|
"--all"
|
|
])
|
|
.is_err()
|
|
);
|
|
assert!(matches!(
|
|
Cli::try_parse_from(["daily-epub", "features", "prune"])
|
|
.unwrap()
|
|
.command,
|
|
Command::Features(FeaturesCommand::Prune)
|
|
));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|