Curation v2 step 6: Behind the paper, stats, run report block, lock

Behind-the-paper chapter (behind.xhtml, both editions) built from the run's
StageCounts and candidate_runs near misses; daily-epub stats [--days N];
StageCounts gains knn/feed gates and verdicts_in_prompt, timings split
into summaries + brief, the four-line §15.4 info block logged once per
run and printed by print_report; src/lock.rs flock guard on
<database_path>.lock for generate, profile rebuild, features backfill and
backfill-social; README updated for the new CLI, env vars and costs.

Implemented by a Claude agent from docs/plans/curation-v2-briefs/step6.md;
reviewed against plan §5, §15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 16:21:06 +00:00
co-authored by Claude Fable 5.1
parent 05a74a0dcf
commit d261cd485d
22 changed files with 1583 additions and 113 deletions
+100 -40
View File
@@ -15,9 +15,9 @@ 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;
use daily_epub::report::{RunReport, VOYAGE_PROVIDER};
use daily_epub::types::{ArticleId, RatingEvent, Vote};
use daily_epub::{curate, http, server, social};
use daily_epub::{curate, http, lock, server, social};
/// A personalized daily newspaper, delivered as an EPUB.
#[derive(Debug, Parser)]
@@ -45,6 +45,8 @@ enum Command {
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),
@@ -199,6 +201,14 @@ struct ExplainArgs {
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.
@@ -243,6 +253,14 @@ async fn main() -> Result<()> {
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?;
@@ -265,6 +283,11 @@ async fn main() -> Result<()> {
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?;
cmd_features(&config, &db, command).await?;
@@ -282,6 +305,24 @@ async fn main() -> Result<()> {
Ok(())
}
/// The commands that write the database and provider budgets and so hold the
/// run lock (§5): `generate`, `profile rebuild`, `features backfill`,
/// `backfill-social`. 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"),
Command::Serve
| Command::Ratings(_)
| Command::Explain(_)
| Command::Stats(_)
| Command::Features(FeaturesCommand::Prune)
| Command::Db(_) => None,
}
}
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
@@ -343,44 +384,10 @@ fn print_report(report: &RunReport) {
report.counts.duplicates_merged,
report.counts.entries_dropped,
);
println!(
"curation: {} considered → {} eligible → {} triaged → {} assessed → {} shortlisted → {} selected",
report.counts.articles,
report.counts.eligible,
report.counts.triaged,
report.counts.assessed,
report.counts.shortlisted,
report.counts.selected,
);
println!(
"admission: triage {} · interest {} · knn {} · exploration {} · blend {} · auto {}",
report
.counts
.admitted_by
.get("triage")
.copied()
.unwrap_or(0),
report
.counts
.admitted_by
.get("interest")
.copied()
.unwrap_or(0),
report.counts.admitted_by.get("knn").copied().unwrap_or(0),
report
.counts
.admitted_by
.get("exploration")
.copied()
.unwrap_or(0),
report.counts.admitted_by.get("blend").copied().unwrap_or(0),
report
.counts
.admitted_by
.get("auto_include")
.copied()
.unwrap_or(0),
);
// 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,
@@ -391,6 +398,9 @@ fn print_report(report: &RunReport) {
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,
@@ -792,6 +802,56 @@ mod tests {
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"],
] {
assert_eq!(lock_holder(&parse(&args)), None, "{args:?}");
}
}
#[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([