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:
Generated
+1
@@ -831,6 +831,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"image",
|
||||
"jiff",
|
||||
"libc",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"resvg",
|
||||
|
||||
@@ -18,6 +18,7 @@ hex = "0.4.3"
|
||||
hmac = "0.13.0"
|
||||
image = "0.25.10"
|
||||
jiff = { version = "0.2.35", features = ["serde"] }
|
||||
libc = "0.2.189"
|
||||
rand = "0.10.2"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "gzip", "json", "charset", "http2", "query", "system-proxy"] }
|
||||
resvg = "0.48.1"
|
||||
|
||||
@@ -13,7 +13,11 @@ the summaries and *The Brief*. It assembles two EPUB editions (a standard one
|
||||
and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and
|
||||
publishes the lot over its own OPDS catalog — which doubles as a
|
||||
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
|
||||
Each article chapter ends with Loved it / Good / Not for me links that feed back into tomorrow's curation.
|
||||
Each article chapter ends with Loved it / Good / Not for me links that feed back
|
||||
into tomorrow's curation, and a short *Behind the paper* chapter before the
|
||||
colophon says what the run considered, how the deep set was admitted, whether
|
||||
the learned signals were active, the ten highest-utility near misses, and what
|
||||
it all cost.
|
||||
|
||||
Steady-state cost is roughly **$1/day**: $0.05–0.30 in DeepSeek tokens plus
|
||||
~$0.50–0.80 for the Claude editor and a few cents of Voyage AI embeddings, each
|
||||
@@ -38,6 +42,25 @@ Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enr
|
||||
|
||||
Every stage writes to SQLite, so a run is idempotent per date: re-running
|
||||
`generate --date 2026-08-15` replaces that issue rather than duplicating it.
|
||||
Only one writer runs at a time: `generate`, `profile rebuild`, `features
|
||||
backfill` and `backfill-social` take an advisory `flock` on
|
||||
`<database_path>.lock`, and a second invocation exits with `generate is already
|
||||
running` (naming whichever command holds it). `serve`, `explain`, `stats`,
|
||||
`ratings`, `features prune` and `db migrate` never wait on it, and the kernel
|
||||
releases the lock when the holder exits, however it exits.
|
||||
|
||||
Every run ends with a four-line summary in the log and on stdout:
|
||||
|
||||
```
|
||||
curation: 412 considered → 398 eligible → 398 triaged → 120 assessed → 60 shortlisted → 17 selected
|
||||
admission: triage 60 · interest 20 · knn 12 · exploration 5 · blend 23 · auto 0
|
||||
preference: 14 rated w/ embeddings → knn 0.35 · feed off · 41 verdicts in prompt
|
||||
providers: anthropic $0.62 · deepseek $0.11 · voyage $0.02 · total $0.75 · 23m12s
|
||||
```
|
||||
|
||||
The full report (per-stage counts and timings — `embed`, `signals`, `triage`,
|
||||
`admit`, `assess`, `rank`, `editor`, `summaries`, `brief` among them — and
|
||||
per-provider usage) is stored on the `runs` row and in `issues.report_json`.
|
||||
|
||||
**Failure policy.** Miniflux ingest, SQLite writes, EPUB assembly and publishing
|
||||
are fatal — without them there is no issue, and the `runs` row records why.
|
||||
@@ -91,6 +114,7 @@ daily-epub ratings set --article 42 --label loved --note "excellent"
|
||||
daily-epub ratings clear --url https://example.com/article
|
||||
daily-epub explain --date YYYY-MM-DD (--article ID | --url URL) [--run-id N]
|
||||
daily-epub explain --date YYYY-MM-DD --near-misses [N]
|
||||
daily-epub stats [--days 14] # the evaluation framework, one fact per line
|
||||
daily-epub features backfill [--days 30] [--rated-only] [--all] [--yes]
|
||||
daily-epub features prune # stale embeddings + old candidate telemetry
|
||||
daily-epub backfill-social # re-poll social scores for recent articles
|
||||
@@ -104,6 +128,8 @@ does not advance the ingest watermark. It prints the lineup and the cost report.
|
||||
|
||||
`--skip-embeddings` reads the embedding cache but makes zero Voyage calls.
|
||||
`--rescore` ignores reusable triage/deep assessments for this run.
|
||||
`--max-articles N` is a ceiling, never a target: the hard maximum becomes
|
||||
`min(curation.max_article_count, N)` and the soft target is lowered to fit.
|
||||
|
||||
`explain` answers "why was this (not) in the paper" from the `candidate_runs`
|
||||
row the run persisted for every considered article: the stage it reached and the
|
||||
@@ -116,6 +142,15 @@ database at all is reported as never ingested (a feed problem, not a ranking
|
||||
one). `--near-misses` lists the highest-utility articles that were not selected
|
||||
(by preliminary blend for articles the ranker never reached).
|
||||
|
||||
`stats` is the whole evaluation framework, on purpose: for the last `--days`
|
||||
(14) it prints the issues and articles published, the mean issue size, explicit
|
||||
ratings by label and per issue, the up/down ratio of rated picks per admitting
|
||||
retriever (`admitted_by[0]` — triage, interest, knn, exploration, blend,
|
||||
auto_include), the exploration yield (admitted, selected, rated positively),
|
||||
cost per day per provider from `runs.provider_costs_json`, and the mean
|
||||
generation time. Plain text, one fact per line. Tune from it and from reading
|
||||
the paper; anything more waits for more ratings.
|
||||
|
||||
`features backfill` embeds the rated and published articles first (the learned
|
||||
set), then the standing interests, then — only with `--all` — every other
|
||||
article first seen in the window. It prints an estimate and asks before spending
|
||||
@@ -268,6 +303,9 @@ DAILY_EPUB_VOYAGE__API_KEY=…
|
||||
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
|
||||
EOF
|
||||
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
|
||||
# `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` are the two
|
||||
# keys curation v2 added; the units read them from this file unchanged. Either
|
||||
# may be left unset: the run then falls back to DeepSeek / cached embeddings.
|
||||
|
||||
# publish dirs must exist and be writable by the service user
|
||||
sudo install -d -o daily-epub -g daily-epub /var/lib/daily-epub/xtc
|
||||
@@ -436,7 +474,7 @@ DAILY_EPUB_OUT_DIR=./out daily-epub generate --dry-run --skip-llm --max-articles
|
||||
ls -la ./out # two .epub files
|
||||
epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
|
||||
# open the standard edition in Calibre / KOReader: cover, The Brief,
|
||||
# In This Issue, sections, discussions, colophon; TOC depth 2
|
||||
# In This Issue, sections, discussions, Behind the paper, colophon; TOC depth 2
|
||||
|
||||
# 4. Now with DeepSeek and Claude, still not publishing
|
||||
daily-epub generate --dry-run --out ./out --max-articles 6
|
||||
@@ -460,12 +498,13 @@ curl -s https://daily.hallada.net/issues.json | jq '.[0]'
|
||||
sqlite3 /var/lib/daily-epub/daily-epub.db 'select * from rating_events order by event_at desc;'
|
||||
|
||||
# 8. Watch cost and quality for a week
|
||||
sqlite3 /var/lib/daily-epub/daily-epub.db \
|
||||
'select date, status, entries_fetched, candidates, selected, cost_usd from runs order by id desc limit 7;'
|
||||
daily-epub stats --days 7
|
||||
daily-epub explain --date $(date +%F) --near-misses
|
||||
```
|
||||
|
||||
Tune `curation.ranking.deep_keep`, `target_article_count` and `curation.always_include_feeds`
|
||||
from what you see in step 8.
|
||||
from what you see in step 8, and read the paper's *Behind the paper* chapter
|
||||
each morning: it is the same numbers, on the device.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
@@ -496,7 +535,10 @@ The crate is a library plus a thin binary, so tests drive the pipeline directly.
|
||||
`tests/e2e_pipeline.rs` is the capstone: synthetic entries → dedupe → offline
|
||||
extraction → signals → triage → admission → selection (both the `--skip-llm` route and a
|
||||
`MockBackend` DeepSeek route) → editorial → both EPUB editions → publish → OPDS
|
||||
and database rows, with no network access anywhere.
|
||||
and database rows, with no network access anywhere. `tests/m4_epub.rs` covers
|
||||
the rendered chapters, including *Behind the paper* in both editions; the
|
||||
`stats` and lock tests live next to their modules (`curate/telemetry.rs`,
|
||||
`lock.rs`).
|
||||
|
||||
### Layout
|
||||
|
||||
@@ -516,6 +558,7 @@ images/ article images comments.rs discussion chapters
|
||||
encode re-encode publish.rs BookOrbit + XTC
|
||||
embed into the page server.rs ratings, OPDS
|
||||
html.rs markup helpers db.rs SQLite
|
||||
lock.rs one writer at a time (flock on <database_path>.lock)
|
||||
```
|
||||
|
||||
Two modules are worth knowing about before you go looking for their contents.
|
||||
|
||||
@@ -417,6 +417,7 @@ fn write_audit_epub(out_dir: &Path, picks: &[Pick], assets: &[ImageAsset]) {
|
||||
},
|
||||
world_briefing: None,
|
||||
colophon: Colophon::default(),
|
||||
behind: Default::default(),
|
||||
};
|
||||
let cfg = daily_epub::config::Config::default();
|
||||
match daily_epub::epub::build_edition_with_images(
|
||||
|
||||
+33
-5
@@ -318,21 +318,42 @@ pub fn fallback_editorial(lineup: &Lineup) -> Editorial {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-clock milliseconds of the two editorial calls, for the run report's
|
||||
/// `summaries` and `brief` stage timings (§15.4).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct EditorialTimings {
|
||||
pub summaries_ms: i64,
|
||||
pub brief_ms: i64,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
llms: &Llms,
|
||||
lineup: &Lineup,
|
||||
config: &EditorialConfig,
|
||||
temperature: f32,
|
||||
) -> Editorial {
|
||||
run_timed(llms, lineup, config, temperature).await.0
|
||||
}
|
||||
|
||||
/// [`run`], also reporting how long the summaries and the brief took.
|
||||
pub async fn run_timed(
|
||||
llms: &Llms,
|
||||
lineup: &Lineup,
|
||||
config: &EditorialConfig,
|
||||
temperature: f32,
|
||||
) -> (Editorial, EditorialTimings) {
|
||||
if lineup.picks.is_empty() {
|
||||
return fallback_editorial(lineup);
|
||||
return (fallback_editorial(lineup), EditorialTimings::default());
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let mut summaries = summarize_all(llms, lineup, config, temperature).await;
|
||||
for pick in &lineup.picks {
|
||||
summaries
|
||||
.entry(pick.article.id)
|
||||
.or_insert_with(|| excerpt_summary(pick));
|
||||
}
|
||||
let summaries_ms = started.elapsed().as_millis() as i64;
|
||||
let started = std::time::Instant::now();
|
||||
let front_page_html = match brief(llms, lineup, &summaries, temperature).await {
|
||||
Ok(text) => text_to_paragraphs(&text),
|
||||
Err(error) => {
|
||||
@@ -340,10 +361,17 @@ pub async fn run(
|
||||
fallback_front_page_html(lineup)
|
||||
}
|
||||
};
|
||||
Editorial {
|
||||
front_page_html,
|
||||
summaries,
|
||||
}
|
||||
let brief_ms = started.elapsed().as_millis() as i64;
|
||||
(
|
||||
Editorial {
|
||||
front_page_html,
|
||||
summaries,
|
||||
},
|
||||
EditorialTimings {
|
||||
summaries_ms,
|
||||
brief_ms,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn summary_to_html(summary: &str) -> String {
|
||||
|
||||
+14
-3
@@ -112,17 +112,28 @@ impl Curator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage C: per-article summaries, section intros and the front page (§3.6).
|
||||
/// Stage C: per-article summaries and the Brief (§14).
|
||||
///
|
||||
/// Never fails the run: a budget trip or an API error degrades to excerpts.
|
||||
pub async fn editorial(&self, lineup: &Lineup) -> anyhow::Result<Editorial> {
|
||||
Ok(self.editorial_timed(lineup).await?.0)
|
||||
}
|
||||
|
||||
/// [`Self::editorial`] plus the `summaries` / `brief` stage timings (§15.4).
|
||||
pub async fn editorial_timed(
|
||||
&self,
|
||||
lineup: &Lineup,
|
||||
) -> anyhow::Result<(Editorial, editorial::EditorialTimings)> {
|
||||
if self.llms.editor_or_bulk().is_none() {
|
||||
tracing::info!("--skip-llm: using feed excerpts as summaries");
|
||||
return Ok(editorial::fallback_editorial(lineup));
|
||||
return Ok((
|
||||
editorial::fallback_editorial(lineup),
|
||||
editorial::EditorialTimings::default(),
|
||||
));
|
||||
}
|
||||
let span = tracing::info_span!("llm_editorial", picks = lineup.picks.len());
|
||||
let _guard = span.enter();
|
||||
Ok(editorial::run(
|
||||
Ok(editorial::run_timed(
|
||||
&self.llms,
|
||||
lineup,
|
||||
&self.config.editorial,
|
||||
|
||||
@@ -304,6 +304,7 @@ pub async fn load_or_build(
|
||||
),
|
||||
version,
|
||||
built_at,
|
||||
verdicts: ratings.len().min(verdict_limit),
|
||||
};
|
||||
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
||||
tracing::debug!(
|
||||
@@ -472,6 +473,7 @@ pub async fn rebuild(
|
||||
),
|
||||
version: next_version,
|
||||
built_at,
|
||||
verdicts: current.len().min(verdict_limit),
|
||||
};
|
||||
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
||||
tracing::info!(
|
||||
|
||||
+498
-1
@@ -15,7 +15,8 @@ use sqlx::Row as _;
|
||||
|
||||
use crate::curate::signals::{Neighbour, Signals, TopInterest};
|
||||
use crate::db::{Db, fmt_ts};
|
||||
use crate::types::{ArticleId, Candidate};
|
||||
use crate::report::RunReport;
|
||||
use crate::types::{ArticleId, Candidate, NearMiss};
|
||||
|
||||
/// The stage vocabulary of §7.4, in pipeline order.
|
||||
pub const STAGES: [&str; 7] = [
|
||||
@@ -230,6 +231,8 @@ pub struct ExplainRow {
|
||||
pub run_id: i64,
|
||||
pub article_id: ArticleId,
|
||||
pub title: String,
|
||||
/// The best entry's feed, for the paper's near-miss list.
|
||||
pub feed_title: String,
|
||||
pub stage: String,
|
||||
pub excluded_reason: Option<String>,
|
||||
pub admitted_by: Option<String>,
|
||||
@@ -247,6 +250,7 @@ impl ExplainRow {
|
||||
run_id: row.get("run_id"),
|
||||
article_id: row.get("article_id"),
|
||||
title: row.get("title"),
|
||||
feed_title: row.get("feed_title"),
|
||||
stage: row.get("stage"),
|
||||
excluded_reason: row.get("excluded_reason"),
|
||||
admitted_by: row.get("admitted_by"),
|
||||
@@ -305,9 +309,11 @@ pub async fn explain_row(
|
||||
) -> Result<Option<ExplainRow>, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||||
COALESCE(e.feed_title, '') AS feed_title,
|
||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
||||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||||
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||
WHERE cr.run_id = ? AND cr.article_id = ?",
|
||||
)
|
||||
.bind(run_id)
|
||||
@@ -326,9 +332,11 @@ pub async fn near_misses(
|
||||
) -> Result<Vec<ExplainRow>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||||
COALESCE(e.feed_title, '') AS feed_title,
|
||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
||||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||||
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||
WHERE cr.run_id = ? AND cr.stage != 'selected' AND cr.stage != 'excluded'",
|
||||
)
|
||||
.bind(run_id)
|
||||
@@ -579,6 +587,265 @@ pub async fn explain_near_misses(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behind the paper (§15.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `limit` highest-utility articles the run did not select, shaped for
|
||||
/// the "Behind the paper" chapter: the same query as `explain --near-misses`.
|
||||
pub async fn paper_near_misses(
|
||||
db: &Db,
|
||||
run_id: i64,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NearMiss>, sqlx::Error> {
|
||||
Ok(near_misses(db, run_id, limit)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let signals = row.signals();
|
||||
let raw = |name: &str| signals.as_ref().and_then(|s| s.raw.get(name).copied());
|
||||
NearMiss {
|
||||
article_id: row.article_id,
|
||||
title: row.title.clone(),
|
||||
feed_title: row.feed_title.clone(),
|
||||
quality: raw("quality"),
|
||||
fit: raw("fit"),
|
||||
stage: row.stage.clone(),
|
||||
reason: row.excluded_reason.clone(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `stats` (§15.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `admitted_by[0]`: the retriever that admitted a pick (§11).
|
||||
fn first_retriever(admitted_by: Option<&str>) -> String {
|
||||
admitted_by
|
||||
.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
|
||||
.and_then(|names| names.into_iter().next())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
struct UpDown {
|
||||
rated: i64,
|
||||
up: i64,
|
||||
down: i64,
|
||||
}
|
||||
|
||||
/// `daily-epub stats [--days N]` as text: the whole evaluation framework
|
||||
/// (§15.3). One fact per line, nothing wider than 80 columns.
|
||||
pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String> {
|
||||
let days = days.max(1);
|
||||
let since_ts = now
|
||||
.checked_sub(jiff::Span::new().hours(days.saturating_mul(24)))
|
||||
.unwrap_or(Timestamp::UNIX_EPOCH);
|
||||
let since = fmt_ts(since_ts);
|
||||
let utc = jiff::tz::TimeZone::UTC;
|
||||
let since_date = since_ts.to_zoned(utc.clone()).date().to_string();
|
||||
let today = now.to_zoned(utc).date().to_string();
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "stats: last {days} days ({since_date} → {today})");
|
||||
|
||||
// --- issues and articles ---
|
||||
let issues: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?")
|
||||
.bind(&since_date)
|
||||
.fetch_one(db.pool())
|
||||
.await?;
|
||||
let published: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM issue_articles WHERE issue_date >= ?")
|
||||
.bind(&since_date)
|
||||
.fetch_one(db.pool())
|
||||
.await?;
|
||||
let _ = writeln!(out, "issues: {issues}");
|
||||
let _ = writeln!(out, "articles published: {published}");
|
||||
let per_issue = |n: i64| {
|
||||
if issues > 0 {
|
||||
format!("{:.1}", n as f64 / issues as f64)
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
}
|
||||
};
|
||||
let _ = writeln!(out, "mean issue size: {} articles", per_issue(published));
|
||||
|
||||
// --- explicit ratings by label ---
|
||||
let labels = sqlx::query(
|
||||
"SELECT label, COUNT(*) AS n FROM rating_events
|
||||
WHERE kind = 'explicit' AND event_at >= ? GROUP BY label ORDER BY label",
|
||||
)
|
||||
.bind(&since)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut total_ratings = 0i64;
|
||||
let mut by_label = Vec::new();
|
||||
for row in &labels {
|
||||
let label = row.get::<String, _>("label");
|
||||
let n = row.get::<i64, _>("n");
|
||||
if label != "cleared" {
|
||||
total_ratings += n;
|
||||
}
|
||||
by_label.push((label, n));
|
||||
}
|
||||
let _ = writeln!(out, "explicit ratings: {total_ratings}");
|
||||
for (label, n) in &by_label {
|
||||
let _ = writeln!(out, "explicit ratings ({label}): {n}");
|
||||
}
|
||||
let _ = writeln!(out, "ratings per issue: {}", per_issue(total_ratings));
|
||||
|
||||
// --- up/down per admitting retriever, from rated picks ---
|
||||
let rated_picks = sqlx::query(
|
||||
"WITH latest AS (
|
||||
SELECT re.article_id, re.issue_date, re.label, re.value,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY re.article_id
|
||||
ORDER BY re.event_at DESC, re.id DESC
|
||||
) AS rn
|
||||
FROM rating_events re
|
||||
WHERE re.kind = 'explicit' AND re.event_at >= ?
|
||||
)
|
||||
SELECT l.article_id, l.value, cr.admitted_by, cr.signals_json
|
||||
FROM latest l
|
||||
JOIN candidate_runs cr ON cr.article_id = l.article_id AND cr.stage = 'selected'
|
||||
JOIN runs r ON r.id = cr.run_id AND r.status != 'dry_run'
|
||||
WHERE l.rn = 1 AND l.label != 'cleared'
|
||||
AND (l.issue_date IS NULL OR r.date = l.issue_date)
|
||||
ORDER BY l.article_id, cr.run_id DESC",
|
||||
)
|
||||
.bind(&since)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut per_retriever: BTreeMap<String, UpDown> = BTreeMap::new();
|
||||
let mut exploration_positive = 0i64;
|
||||
let mut seen: Option<ArticleId> = None;
|
||||
for row in &rated_picks {
|
||||
let article_id = row.get::<ArticleId, _>("article_id");
|
||||
if seen == Some(article_id) {
|
||||
continue; // a rerun of the date: keep the latest run only
|
||||
}
|
||||
seen = Some(article_id);
|
||||
let value = row.get::<f64, _>("value");
|
||||
let retriever = first_retriever(row.get::<Option<String>, _>("admitted_by").as_deref());
|
||||
let entry = per_retriever.entry(retriever).or_default();
|
||||
entry.rated += 1;
|
||||
if value > 0.0 {
|
||||
entry.up += 1;
|
||||
} else if value < 0.0 {
|
||||
entry.down += 1;
|
||||
}
|
||||
let exploration =
|
||||
serde_json::from_str::<SignalsJson>(&row.get::<String, _>("signals_json"))
|
||||
.map(|signals| signals.exploration)
|
||||
.unwrap_or(false);
|
||||
if exploration && value > 0.0 {
|
||||
exploration_positive += 1;
|
||||
}
|
||||
}
|
||||
if per_retriever.is_empty() {
|
||||
let _ = writeln!(out, "rated picks by admitting retriever: none");
|
||||
}
|
||||
for (retriever, counts) in &per_retriever {
|
||||
let ratio = if counts.rated > 0 {
|
||||
format!("{:.0}% up", 100.0 * counts.up as f64 / counts.rated as f64)
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"admitted by {retriever}: {} rated · {} up · {} down · {ratio}",
|
||||
counts.rated, counts.up, counts.down
|
||||
);
|
||||
}
|
||||
|
||||
// --- exploration yield ---
|
||||
let exploration_rows = sqlx::query(
|
||||
"SELECT cr.stage FROM candidate_runs cr
|
||||
JOIN runs r ON r.id = cr.run_id
|
||||
WHERE r.status != 'dry_run' AND r.started_at >= ?
|
||||
AND cr.signals_json LIKE '%\"exploration\":true%'",
|
||||
)
|
||||
.bind(&since)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut exploration_admitted = 0i64;
|
||||
let mut exploration_selected = 0i64;
|
||||
for row in &exploration_rows {
|
||||
match row.get::<String, _>("stage").as_str() {
|
||||
"selected" => {
|
||||
exploration_admitted += 1;
|
||||
exploration_selected += 1;
|
||||
}
|
||||
"admitted" | "assessed" | "shortlisted" => exploration_admitted += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = writeln!(out, "exploration admitted: {exploration_admitted}");
|
||||
let _ = writeln!(out, "exploration selected: {exploration_selected}");
|
||||
let _ = writeln!(out, "exploration rated positively: {exploration_positive}");
|
||||
|
||||
// --- cost per day per provider (§7.6) ---
|
||||
let cost_rows = sqlx::query(
|
||||
"SELECT provider_costs_json FROM runs
|
||||
WHERE started_at >= ? AND provider_costs_json IS NOT NULL",
|
||||
)
|
||||
.bind(&since)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut totals: BTreeMap<String, f64> = BTreeMap::new();
|
||||
for row in &cost_rows {
|
||||
let raw = row.get::<String, _>("provider_costs_json");
|
||||
let Ok(providers) =
|
||||
serde_json::from_str::<BTreeMap<String, crate::report::ProviderUsage>>(&raw)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (provider, usage) in providers {
|
||||
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
|
||||
}
|
||||
}
|
||||
let mut grand = 0.0;
|
||||
for (provider, total) in &totals {
|
||||
grand += total;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"cost per day ({provider}): ${:.3}",
|
||||
total / days as f64
|
||||
);
|
||||
}
|
||||
let _ = writeln!(out, "cost per day (total): ${:.3}", grand / days as f64);
|
||||
|
||||
// --- mean generation time ---
|
||||
let run_rows = sqlx::query(
|
||||
"SELECT started_at, finished_at FROM runs
|
||||
WHERE started_at >= ? AND finished_at IS NOT NULL",
|
||||
)
|
||||
.bind(&since)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut durations = Vec::new();
|
||||
for row in &run_rows {
|
||||
let started = row.get::<String, _>("started_at").parse::<Timestamp>();
|
||||
let finished = row.get::<String, _>("finished_at").parse::<Timestamp>();
|
||||
if let (Ok(started), Ok(finished)) = (started, finished) {
|
||||
durations.push((finished.as_second() - started.as_second()).max(0));
|
||||
}
|
||||
}
|
||||
if durations.is_empty() {
|
||||
let _ = writeln!(out, "mean generation time: n/a (0 runs)");
|
||||
} else {
|
||||
let mean = durations.iter().sum::<i64>() / durations.len() as i64;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"mean generation time: {} ({} runs)",
|
||||
RunReport::format_duration(mean),
|
||||
durations.len()
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `features prune` (§7.1, §7.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -972,6 +1239,236 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paper_near_misses_carry_feed_quality_fit_and_stage() {
|
||||
let (_dir, db) = db_with_articles(&[1, 2]).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO entries (id, feed_id, feed_title, title, url, raw_content, fetched_at)
|
||||
VALUES (11, 5, 'Example Feed', 'Article 1', 'https://example.com/1', '', '2026-08-15T00:00:00Z')",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE articles SET best_entry_id = 11 WHERE id = 1")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||||
let mut candidate = crate::types::Candidate::new(
|
||||
crate::curate::prefilter::tests::article(1, "Article 1", 900),
|
||||
false,
|
||||
);
|
||||
candidate.signals = signals(41.0, 0.55);
|
||||
candidate.assessment.deep = Some(crate::types::Deep {
|
||||
quality: 8.0,
|
||||
fit: 6.5,
|
||||
category: None,
|
||||
rationale: String::new(),
|
||||
paywalled_guess: false,
|
||||
facets: Default::default(),
|
||||
model: "mock".into(),
|
||||
prompt_version: 1,
|
||||
assessed_at: "2026-09-02T05:30:00Z".parse().unwrap(),
|
||||
});
|
||||
let json = serialize_candidate(&candidate);
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id: 1,
|
||||
stage: "shortlisted",
|
||||
excluded_reason: Some("not_selected"),
|
||||
admitted_by: Some("[\"triage\"]"),
|
||||
signals_json: &json,
|
||||
utility: Some(71.0),
|
||||
rank_utility: Some(3),
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id: 2,
|
||||
stage: "selected",
|
||||
excluded_reason: None,
|
||||
admitted_by: Some("[\"triage\"]"),
|
||||
signals_json: "{}",
|
||||
utility: Some(90.0),
|
||||
rank_utility: Some(1),
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: Some("because"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let misses = paper_near_misses(&db, run_id, 10).await.unwrap();
|
||||
assert_eq!(misses.len(), 1, "selected picks are not near misses");
|
||||
let miss = &misses[0];
|
||||
assert_eq!(miss.article_id, 1);
|
||||
assert_eq!(miss.title, "Article 1");
|
||||
assert_eq!(miss.feed_title, "Example Feed");
|
||||
assert_eq!(miss.quality, Some(8.0));
|
||||
assert_eq!(miss.fit, Some(6.5));
|
||||
assert_eq!(miss.stage, "shortlisted");
|
||||
assert_eq!(miss.reason.as_deref(), Some("not_selected"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_prints_every_fact_from_runs_issues_and_ratings() {
|
||||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await;
|
||||
let now: Timestamp = "2026-09-02T12:00:00Z".parse().unwrap();
|
||||
// Two issues inside the window, one outside it.
|
||||
sqlx::query(
|
||||
"INSERT INTO issues (date, issue_number, generated_at) VALUES
|
||||
('2026-08-01', 1, '2026-08-01T10:00:00Z'),
|
||||
('2026-08-30', 30, '2026-08-30T10:00:00Z'),
|
||||
('2026-09-01', 32, '2026-09-01T10:00:00Z');
|
||||
INSERT INTO issue_articles (issue_date, article_id, section) VALUES
|
||||
('2026-08-01', 4, 'Top Stories'),
|
||||
('2026-08-30', 1, 'Top Stories'),
|
||||
('2026-08-30', 2, 'Top Stories'),
|
||||
('2026-09-01', 3, 'Top Stories');",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
// Two finished runs with provider costs, one of them a rerun of 08-30.
|
||||
let mut run_ids = Vec::new();
|
||||
for (date, started, finished, costs) in [
|
||||
(
|
||||
"2026-08-30",
|
||||
"2026-08-30T09:30:00Z",
|
||||
"2026-08-30T09:50:00Z",
|
||||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.10},"anthropic":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.60},"voyage":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":0,"cost_usd":0.02}}"#,
|
||||
),
|
||||
(
|
||||
"2026-08-30",
|
||||
"2026-08-30T11:00:00Z",
|
||||
"2026-08-30T11:10:00Z",
|
||||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.04}}"#,
|
||||
),
|
||||
(
|
||||
"2026-09-01",
|
||||
"2026-09-01T09:30:00Z",
|
||||
"2026-09-01T09:45:00Z",
|
||||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14}}"#,
|
||||
),
|
||||
] {
|
||||
let run_id = db
|
||||
.start_run(date.parse().unwrap(), started.parse().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"UPDATE runs SET finished_at = ?, status = 'ok', provider_costs_json = ? WHERE id = ?",
|
||||
)
|
||||
.bind(finished)
|
||||
.bind(costs)
|
||||
.bind(run_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
run_ids.push(run_id);
|
||||
}
|
||||
// Article 1 was admitted by triage in the first 08-30 run and by knn
|
||||
// in the rerun; the latest run wins. Article 2 was an exploration
|
||||
// pick admitted by exploration. Article 3 was admitted by blend.
|
||||
let exploration = r#"{"v":1,"exploration":true}"#;
|
||||
for (run_id, article_id, stage, admitted_by, json) in [
|
||||
(run_ids[0], 1, "selected", "[\"triage\"]", "{}"),
|
||||
(run_ids[1], 1, "selected", "[\"knn\"]", "{}"),
|
||||
(run_ids[1], 2, "selected", "[\"exploration\"]", exploration),
|
||||
(run_ids[1], 4, "assessed", "[\"exploration\"]", exploration),
|
||||
(run_ids[2], 3, "selected", "[\"blend\"]", "{}"),
|
||||
] {
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id,
|
||||
stage,
|
||||
excluded_reason: None,
|
||||
admitted_by: Some(admitted_by),
|
||||
signals_json: json,
|
||||
utility: Some(50.0),
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO rating_events (article_id, issue_date, kind, source, label, value, event_at) VALUES
|
||||
(1, '2026-08-30', 'explicit', 'epub', 'good', 0.35, '2026-08-31T08:00:00Z'),
|
||||
(1, '2026-08-30', 'explicit', 'epub', 'loved', 1.0, '2026-08-31T09:00:00Z'),
|
||||
(2, '2026-08-30', 'explicit', 'epub', 'loved', 1.0, '2026-08-31T09:30:00Z'),
|
||||
(3, '2026-09-01', 'explicit', 'epub', 'not_for_me', -1.0, '2026-09-01T12:00:00Z'),
|
||||
(4, '2026-08-01', 'explicit', 'epub', 'loved', 1.0, '2026-08-02T12:00:00Z'),
|
||||
(3, NULL, 'explicit', 'cli', 'cleared', 0.0, '2026-08-20T12:00:00Z');",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = stats(&db, 14, now).await.unwrap();
|
||||
println!("{text}");
|
||||
for line in [
|
||||
"stats: last 14 days (2026-08-19 → 2026-09-02)",
|
||||
"issues: 2",
|
||||
"articles published: 3",
|
||||
"mean issue size: 1.5 articles",
|
||||
"explicit ratings: 4",
|
||||
"explicit ratings (cleared): 1",
|
||||
"explicit ratings (good): 1",
|
||||
"explicit ratings (loved): 2",
|
||||
"explicit ratings (not_for_me): 1",
|
||||
"ratings per issue: 2.0",
|
||||
"admitted by blend: 1 rated · 0 up · 1 down · 0% up",
|
||||
"admitted by exploration: 1 rated · 1 up · 0 down · 100% up",
|
||||
"admitted by knn: 1 rated · 1 up · 0 down · 100% up",
|
||||
"exploration admitted: 2",
|
||||
"exploration selected: 1",
|
||||
"exploration rated positively: 1",
|
||||
"cost per day (anthropic): $0.043",
|
||||
"cost per day (deepseek): $0.020",
|
||||
"cost per day (voyage): $0.001",
|
||||
"cost per day (total): $0.064",
|
||||
"mean generation time: 15m00s (3 runs)",
|
||||
] {
|
||||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||||
}
|
||||
assert!(
|
||||
!text.contains("admitted by triage"),
|
||||
"the rerun's row replaces the first run's: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.lines().all(|line| line.chars().count() <= 80),
|
||||
"no line wider than 80 columns"
|
||||
);
|
||||
|
||||
// An empty database still prints every heading.
|
||||
let (_dir, empty) = db_with_articles(&[]).await;
|
||||
let text = stats(&empty, 7, now).await.unwrap();
|
||||
for line in [
|
||||
"issues: 0",
|
||||
"mean issue size: n/a articles",
|
||||
"ratings per issue: n/a",
|
||||
"rated picks by admitting retriever: none",
|
||||
"cost per day (total): $0.000",
|
||||
"mean generation time: n/a (0 runs)",
|
||||
] {
|
||||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_respects_rated_and_published() {
|
||||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await;
|
||||
|
||||
+8
-5
@@ -1,7 +1,8 @@
|
||||
//! Chapter ordering and `epub-builder` assembly (spec §3.10).
|
||||
//!
|
||||
//! Structure: cover → From the Editor → In This Issue → sections (title page,
|
||||
//! article chapters, discussion chapters) → World Briefing → colophon. The
|
||||
//! Structure: cover → The Brief → In This Issue → sections (title page,
|
||||
//! article chapters, discussion chapters) → World Briefing → Behind the paper
|
||||
//! → colophon. The
|
||||
//! chapters themselves are rendered by [`super::chapters`] and the cover by
|
||||
//! [`super::cover`]; this module decides the order and zips the result.
|
||||
|
||||
@@ -14,8 +15,8 @@ use crate::types::{Edition, ImageAsset, Issue};
|
||||
|
||||
use super::EpubError;
|
||||
use super::chapters::{
|
||||
render_colophon, render_front_page, render_in_this_issue, render_section_page,
|
||||
render_world_briefing, section_names,
|
||||
render_behind_the_paper, render_colophon, render_front_page, render_in_this_issue,
|
||||
render_section_page, render_world_briefing, section_names,
|
||||
};
|
||||
use super::cover::{CoverAsset, render_cover_page};
|
||||
use super::x4;
|
||||
@@ -85,6 +86,7 @@ pub fn render_all(
|
||||
if let Some(world) = render_world_briefing(issue)? {
|
||||
chapters.push(world);
|
||||
}
|
||||
chapters.push(render_behind_the_paper(issue)?);
|
||||
chapters.push(render_colophon(issue)?);
|
||||
|
||||
if edition == Edition::X4 {
|
||||
@@ -272,11 +274,12 @@ mod tests {
|
||||
"sec-Niche Corner",
|
||||
"art-1002",
|
||||
"world",
|
||||
"behind",
|
||||
"colophon",
|
||||
]
|
||||
);
|
||||
let levels: Vec<u8> = chapters.iter().map(|c| c.toc_level).collect();
|
||||
assert_eq!(levels, vec![1, 1, 1, 1, 2, 3, 1, 2, 1, 1]);
|
||||
assert_eq!(levels, vec![1, 1, 1, 1, 2, 3, 1, 2, 1, 1, 1]);
|
||||
for chapter in &chapters {
|
||||
assert_xml_ok(&chapter.xhtml);
|
||||
}
|
||||
|
||||
+203
-1
@@ -10,7 +10,10 @@ use askama::Template;
|
||||
use crate::comments;
|
||||
use crate::html::{text_escape, to_xhtml};
|
||||
use crate::images;
|
||||
use crate::types::{Edition, ImageAsset, Issue, Pick, SocialRef, Vote, WORLD_BRIEFING_SECTION};
|
||||
use crate::types::{
|
||||
BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote,
|
||||
WORLD_BRIEFING_SECTION,
|
||||
};
|
||||
use crate::world;
|
||||
|
||||
use super::EpubError;
|
||||
@@ -102,6 +105,17 @@ struct WorldBriefingChapter {
|
||||
body_html: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "behind.xhtml", escape = "html")]
|
||||
struct BehindChapter {
|
||||
title: String,
|
||||
summary_line: String,
|
||||
admitted_line: String,
|
||||
learned_line: String,
|
||||
near_misses: Vec<String>,
|
||||
models_line: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "colophon.xhtml", escape = "html")]
|
||||
struct ColophonChapter {
|
||||
@@ -435,6 +449,131 @@ pub fn render_world_briefing(issue: &Issue) -> Result<Option<Chapter>, EpubError
|
||||
}))
|
||||
}
|
||||
|
||||
/// "1,465" — thousands separators for the counts in Behind the paper.
|
||||
fn thousands(n: i64) -> String {
|
||||
let digits = n.abs().to_string();
|
||||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
if n < 0 { format!("-{out}") } else { out }
|
||||
}
|
||||
|
||||
/// `Considered 412 articles from 1,465 feeds · 398 eligible · …` (§15.1).
|
||||
pub fn behind_summary_line(b: &BehindThePaper) -> String {
|
||||
format!(
|
||||
"Considered {} articles from {} feeds \u{00b7} {} eligible \u{00b7} {} triaged \u{00b7} {} read closely \u{00b7} {} shortlisted \u{00b7} {} selected.",
|
||||
thousands(b.considered),
|
||||
thousands(b.feeds_seen),
|
||||
thousands(b.eligible),
|
||||
thousands(b.triaged),
|
||||
thousands(b.read_closely),
|
||||
thousands(b.shortlisted),
|
||||
thousands(b.selected),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Admitted via: triage 60 · interests 20 · your ratings 12 · exploration 5 · blend 23.`
|
||||
pub fn behind_admitted_line(b: &BehindThePaper) -> String {
|
||||
let count = |name: &str| b.admitted_by.get(name).copied().unwrap_or(0);
|
||||
let mut parts = vec![
|
||||
format!("triage {}", count("triage")),
|
||||
format!("interests {}", count("interest")),
|
||||
format!("your ratings {}", count("knn")),
|
||||
format!("exploration {}", count("exploration")),
|
||||
format!("blend {}", count("blend")),
|
||||
];
|
||||
if count("auto_include") > 0 {
|
||||
parts.push(format!("always-include {}", count("auto_include")));
|
||||
}
|
||||
format!("Admitted via: {}.", parts.join(" \u{00b7} "))
|
||||
}
|
||||
|
||||
/// `Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off.`
|
||||
pub fn behind_learned_line(b: &BehindThePaper) -> String {
|
||||
let percent = |gate: f64| (gate.clamp(0.0, 1.0) * 100.0).round() as i64;
|
||||
let neighbour = if b.knn_gate > 0.0 {
|
||||
format!("neighbour signal at {}%", percent(b.knn_gate))
|
||||
} else {
|
||||
"neighbour signal off".to_string()
|
||||
};
|
||||
let feed = if b.feed_gate > 0.0 {
|
||||
format!("feed affinity at {}%", percent(b.feed_gate))
|
||||
} else {
|
||||
"feed affinity off".to_string()
|
||||
};
|
||||
format!(
|
||||
"Learned signals: {} rated articles with embeddings ({neighbour}); {feed}.",
|
||||
thousands(b.rated_with_embeddings)
|
||||
)
|
||||
}
|
||||
|
||||
/// `<title> — <feed> · quality 8.0 · fit 6.5 · shortlisted, not selected`.
|
||||
pub fn behind_near_miss_line(miss: &NearMiss) -> String {
|
||||
let mut parts = vec![format!("{} \u{2014} {}", miss.title, miss.feed_title)];
|
||||
if let Some(quality) = miss.quality {
|
||||
parts.push(format!("quality {quality:.1}"));
|
||||
}
|
||||
if let Some(fit) = miss.fit {
|
||||
parts.push(format!("fit {fit:.1}"));
|
||||
}
|
||||
parts.push(match &miss.reason {
|
||||
Some(reason) => format!("{}, {}", miss.stage, reason.replace('_', " ")),
|
||||
None => miss.stage.clone(),
|
||||
});
|
||||
parts.join(" \u{00b7} ")
|
||||
}
|
||||
|
||||
/// `Models: triage and assessment … · editor and summaries … · embeddings ….
|
||||
/// Cost $0.81. Generation 23 min.`
|
||||
pub fn behind_models_line(b: &BehindThePaper) -> String {
|
||||
let editorial = if b.models.summaries == b.models.editor {
|
||||
format!("editor and summaries {}", b.models.editor)
|
||||
} else {
|
||||
format!(
|
||||
"editor {} \u{00b7} summaries {}",
|
||||
b.models.editor, b.models.summaries
|
||||
)
|
||||
};
|
||||
let generation = if b.generation_secs < 60 {
|
||||
format!("{} s", b.generation_secs.max(0))
|
||||
} else {
|
||||
format!("{} min", (b.generation_secs as f64 / 60.0).round() as i64)
|
||||
};
|
||||
format!(
|
||||
"Models: triage and assessment {} \u{00b7} {editorial} \u{00b7} embeddings {}. Cost ${:.2}. Generation {generation}.",
|
||||
b.models.bulk, b.embedding_model, b.cost_usd
|
||||
)
|
||||
}
|
||||
|
||||
/// "Behind the paper": the run's counts, admission mix, learned-signal state,
|
||||
/// near misses and models (§15.1). Same text in both editions; no links.
|
||||
pub fn render_behind_the_paper(issue: &Issue) -> Result<Chapter, EpubError> {
|
||||
let behind = &issue.behind;
|
||||
let tpl = BehindChapter {
|
||||
title: "Behind the paper".into(),
|
||||
summary_line: behind_summary_line(behind),
|
||||
admitted_line: behind_admitted_line(behind),
|
||||
learned_line: behind_learned_line(behind),
|
||||
near_misses: behind
|
||||
.near_misses
|
||||
.iter()
|
||||
.map(behind_near_miss_line)
|
||||
.collect(),
|
||||
models_line: behind_models_line(behind),
|
||||
};
|
||||
Ok(Chapter {
|
||||
id: "behind".into(),
|
||||
href: "behind.xhtml".into(),
|
||||
title: "Behind the paper".into(),
|
||||
xhtml: tpl.render()?,
|
||||
toc_level: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Colophon: generation timestamp, models used, token cost, feed counts (§3.10).
|
||||
pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> {
|
||||
let colophon = &issue.colophon;
|
||||
@@ -676,6 +815,69 @@ mod tests {
|
||||
assert!(render_discussion(&issue.lineup.picks[1]).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_the_paper_lines_follow_the_plan_shape() {
|
||||
let issue = issue();
|
||||
let behind = &issue.behind;
|
||||
assert_eq!(
|
||||
behind_summary_line(behind),
|
||||
"Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected."
|
||||
);
|
||||
assert_eq!(
|
||||
behind_admitted_line(behind),
|
||||
"Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23."
|
||||
);
|
||||
assert_eq!(
|
||||
behind_learned_line(behind),
|
||||
"Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off."
|
||||
);
|
||||
assert_eq!(
|
||||
behind_near_miss_line(&behind.near_misses[0]),
|
||||
"The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected"
|
||||
);
|
||||
assert_eq!(
|
||||
behind_near_miss_line(&behind.near_misses[1]),
|
||||
"Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted"
|
||||
);
|
||||
assert_eq!(
|
||||
behind_models_line(behind),
|
||||
"Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min."
|
||||
);
|
||||
let chapter = render_behind_the_paper(&issue).unwrap();
|
||||
assert_eq!(chapter.id, "behind");
|
||||
assert_eq!(chapter.href, "behind.xhtml");
|
||||
assert!(chapter.xhtml.contains("Behind the paper"));
|
||||
assert!(chapter.xhtml.contains("1,465 feeds"));
|
||||
assert!(chapter.xhtml.contains("The One That Got Away"));
|
||||
assert!(!chapter.xhtml.contains("<a "), "no links in the chapter");
|
||||
assert_xml_ok(&chapter.xhtml);
|
||||
|
||||
// Auto-includes are named only when there were any; a short run is in
|
||||
// seconds; a differing summaries model is listed separately.
|
||||
let mut short = behind.clone();
|
||||
short.admitted_by.insert("auto_include".into(), 2);
|
||||
short.generation_secs = 48;
|
||||
short.feed_gate = 0.6;
|
||||
short.models.summaries = "deepseek-v4-flash".into();
|
||||
short.near_misses.clear();
|
||||
assert!(behind_admitted_line(&short).ends_with("blend 23 \u{00b7} always-include 2."));
|
||||
assert!(behind_learned_line(&short).ends_with("feed affinity at 60%."));
|
||||
assert!(
|
||||
behind_models_line(&short)
|
||||
.contains("editor claude-opus-5 \u{00b7} summaries deepseek-v4-flash")
|
||||
);
|
||||
assert!(behind_models_line(&short).ends_with("Generation 48 s."));
|
||||
let mut issue = issue;
|
||||
issue.behind = short;
|
||||
let chapter = render_behind_the_paper(&issue).unwrap();
|
||||
assert!(chapter.xhtml.contains("None recorded"));
|
||||
assert_xml_ok(&chapter.xhtml);
|
||||
assert_eq!(thousands(0), "0");
|
||||
assert_eq!(thousands(999), "999");
|
||||
assert_eq!(thousands(1_000), "1,000");
|
||||
assert_eq!(thousands(1_234_567), "1,234,567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_and_colophon_chapters_render() {
|
||||
let issue = issue();
|
||||
|
||||
@@ -155,6 +155,53 @@ pub fn issue() -> Issue {
|
||||
cost_usd: 0.0731,
|
||||
generator_version: "daily-epub 0.1.0".into(),
|
||||
},
|
||||
behind: BehindThePaper {
|
||||
considered: 412,
|
||||
feeds_seen: 1465,
|
||||
eligible: 398,
|
||||
triaged: 398,
|
||||
read_closely: 120,
|
||||
shortlisted: 60,
|
||||
selected: 2,
|
||||
admitted_by: BTreeMap::from([
|
||||
("triage".to_string(), 60),
|
||||
("interest".to_string(), 20),
|
||||
("knn".to_string(), 12),
|
||||
("exploration".to_string(), 5),
|
||||
("blend".to_string(), 23),
|
||||
]),
|
||||
rated_with_embeddings: 14,
|
||||
knn_gate: 0.35,
|
||||
feed_gate: 0.0,
|
||||
near_misses: vec![
|
||||
NearMiss {
|
||||
article_id: 3,
|
||||
title: "The One That Got Away".into(),
|
||||
feed_title: "Example Feed".into(),
|
||||
quality: Some(8.0),
|
||||
fit: Some(6.5),
|
||||
stage: "shortlisted".into(),
|
||||
reason: Some("not_selected".into()),
|
||||
},
|
||||
NearMiss {
|
||||
article_id: 4,
|
||||
title: "Never Read Closely".into(),
|
||||
feed_title: "Other Feed".into(),
|
||||
quality: None,
|
||||
fit: None,
|
||||
stage: "triaged".into(),
|
||||
reason: Some("not_admitted".into()),
|
||||
},
|
||||
],
|
||||
models: Models {
|
||||
bulk: "deepseek-v4-flash".into(),
|
||||
editor: "claude-opus-5".into(),
|
||||
summaries: "claude-opus-5".into(),
|
||||
},
|
||||
embedding_model: "voyage-4-lite".into(),
|
||||
cost_usd: 0.81,
|
||||
generation_secs: 23 * 60 + 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ pub const CHAPTER_ORDER: &[&str] = &[
|
||||
"in-this-issue",
|
||||
"sections",
|
||||
"world-briefing",
|
||||
"behind-the-paper",
|
||||
"colophon",
|
||||
];
|
||||
|
||||
@@ -192,6 +193,7 @@ mod tests {
|
||||
"OEBPS/disc-1001.xhtml",
|
||||
"OEBPS/art-1002.xhtml",
|
||||
"OEBPS/world.xhtml",
|
||||
"OEBPS/behind.xhtml",
|
||||
"OEBPS/colophon.xhtml",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -8,12 +8,13 @@ the crate root points askama here (`dirs = ["src/epub/templates"]`).
|
||||
|---|---|---|
|
||||
| `base.xhtml` | — | Shared XHTML skeleton (`{% block body_class %}`, `{% block content %}`) |
|
||||
| `cover_page.xhtml` | `CoverPage` | Page that displays the rasterized cover image |
|
||||
| `front_page.xhtml` | `FrontPage` | "From the Editor" + issue stats line |
|
||||
| `front_page.xhtml` | `FrontPage` | "The Brief" + issue stats line |
|
||||
| `in_this_issue.xhtml` | `InThisIssue` | Introduction chapter: per-section linked index |
|
||||
| `section.xhtml` | `SectionPage` | Section title page + LLM intro |
|
||||
| `section.xhtml` | `SectionPage` | Section title page (name only) |
|
||||
| `chapter.xhtml` | `ArticleChapter` | Article: header, body, rating/read-online footer |
|
||||
| `discussion.xhtml` | `DiscussionChapter` | Comment chapter (§3.7); body from `comments::render_xhtml` |
|
||||
| `world_briefing.xhtml` | `WorldBriefingChapter` | Wikipedia Current Events (§3.8), body from `world::render_xhtml` |
|
||||
| `behind.xhtml` | `BehindChapter` | "Behind the paper": run counts, admission mix, near misses, models (§15.1) |
|
||||
| `colophon.xhtml` | `ColophonChapter` | Back matter: models, cost, counts |
|
||||
| `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia |
|
||||
| `style.css` | — | Standard-edition stylesheet, embedded as `stylesheet.css` |
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.xhtml" %}
|
||||
{% block body_class %}behind{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Behind the paper</h1>
|
||||
<p class="fact-line">{{ summary_line }}</p>
|
||||
<p class="fact-line">{{ admitted_line }}</p>
|
||||
<p class="fact-line">{{ learned_line }}</p>
|
||||
<h2>Near misses</h2>
|
||||
<p class="fact-line"><em>Highest utility not selected.</em></p>
|
||||
{% if near_misses.is_empty() %}
|
||||
<p class="fact-line">None recorded for this run.</p>
|
||||
{% else %}
|
||||
<ul class="near-misses">
|
||||
{% for line in near_misses %}
|
||||
<li>{{ line }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p class="fact-line">{{ models_line }}</p>
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,7 @@ pub mod extract;
|
||||
pub mod html;
|
||||
pub mod http;
|
||||
pub mod images;
|
||||
pub mod lock;
|
||||
pub mod miniflux;
|
||||
pub mod pipeline;
|
||||
pub mod publish;
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
//! One writer at a time (plan §5): an advisory `flock(LOCK_EX | LOCK_NB)` on
|
||||
//! `<database_path>.lock`, taken in `main` for `generate`, `profile rebuild`,
|
||||
//! `features backfill` and `backfill-social`. No table, no TTL: the kernel
|
||||
//! releases the lock when the holder exits, however it exits.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Held for as long as the value lives; dropping it (or dying) releases it.
|
||||
#[derive(Debug)]
|
||||
pub struct RunLock {
|
||||
_file: File,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LockError {
|
||||
/// Another process holds the lock; `holder` names its command when known.
|
||||
#[error("{holder} is already running")]
|
||||
Held { holder: String, path: PathBuf },
|
||||
#[error("lock file {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// `<database_path>.lock`, next to the database.
|
||||
pub fn lock_path(database_path: &Path) -> PathBuf {
|
||||
let mut name = database_path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(".lock");
|
||||
database_path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Take the lock for `command`, writing the command's name into the file so a
|
||||
/// second invocation can say who is holding it.
|
||||
pub fn acquire(database_path: &Path, command: &str) -> Result<RunLock, LockError> {
|
||||
let path = lock_path(database_path);
|
||||
let io_err = |source: io::Error| LockError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
};
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(io_err)?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.map_err(io_err)?;
|
||||
// SAFETY: `file` owns a valid open descriptor for the duration of the call.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let source = io::Error::last_os_error();
|
||||
if source.kind() == io::ErrorKind::WouldBlock {
|
||||
let holder = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "generate".to_string());
|
||||
return Err(LockError::Held { holder, path });
|
||||
}
|
||||
return Err(io_err(source));
|
||||
}
|
||||
// Best effort: the name is a courtesy for the error message, never load-bearing.
|
||||
let _ = file.set_len(0);
|
||||
let _ = writeln!(file, "{command}");
|
||||
Ok(RunLock { _file: file, path })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const HELPER_ENV: &str = "DAILY_EPUB_LOCK_TEST_HOLDER";
|
||||
|
||||
#[test]
|
||||
fn lock_path_sits_next_to_the_database() {
|
||||
assert_eq!(
|
||||
lock_path(Path::new("/var/lib/daily-epub/daily-epub.db")),
|
||||
PathBuf::from("/var/lib/daily-epub/daily-epub.db.lock")
|
||||
);
|
||||
assert_eq!(lock_path(Path::new("x.db")), PathBuf::from("x.db.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_acquire_in_the_same_process_loses_and_names_the_holder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("nested").join("daily-epub.db");
|
||||
let first = acquire(&db, "generate").expect("first lock");
|
||||
assert!(first.path.exists(), "the lock file is created on demand");
|
||||
match acquire(&db, "profile rebuild") {
|
||||
Err(LockError::Held { holder, .. }) => {
|
||||
assert_eq!(holder, "generate");
|
||||
assert_eq!(
|
||||
LockError::Held {
|
||||
holder,
|
||||
path: first.path.clone()
|
||||
}
|
||||
.to_string(),
|
||||
"generate is already running"
|
||||
);
|
||||
}
|
||||
other => panic!("expected the lock to be held, got {other:?}"),
|
||||
}
|
||||
drop(first);
|
||||
let again = acquire(&db, "backfill-social").expect("released on drop");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&again.path).unwrap().trim(),
|
||||
"backfill-social"
|
||||
);
|
||||
}
|
||||
|
||||
/// Not a test of its own: when `HELPER_ENV` names a database path, this
|
||||
/// body takes the lock and holds it until it is killed. The parent below
|
||||
/// spawns the test binary with that variable set.
|
||||
#[test]
|
||||
fn lock_holder_helper() {
|
||||
let Ok(db) = std::env::var(HELPER_ENV) else {
|
||||
return;
|
||||
};
|
||||
let _held = acquire(Path::new(&db), "helper").expect("helper takes the lock");
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_killed_holder_frees_the_lock_for_the_next_process() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("daily-epub.db");
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "lock::tests::lock_holder_helper", "--nocapture"])
|
||||
.env(HELPER_ENV, &db)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn the holder process");
|
||||
|
||||
// Wait (well under a second) until the child has written its name.
|
||||
let path = lock_path(&db);
|
||||
let deadline = Instant::now() + Duration::from_millis(900);
|
||||
while std::fs::read_to_string(&path)
|
||||
.map(|s| s.trim() != "helper")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
assert!(Instant::now() < deadline, "the helper never took the lock");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
match acquire(&db, "generate") {
|
||||
Err(LockError::Held { holder, .. }) => assert_eq!(holder, "helper"),
|
||||
other => panic!("another process holds the lock, got {other:?}"),
|
||||
}
|
||||
|
||||
child.kill().expect("kill the holder");
|
||||
child.wait().expect("reap the holder");
|
||||
let lock = acquire(&db, "generate").expect("the kernel released the dead holder's lock");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&lock.path).unwrap().trim(),
|
||||
"generate"
|
||||
);
|
||||
}
|
||||
}
|
||||
+100
-40
@@ -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([
|
||||
|
||||
+164
-45
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
|
||||
//! ─▶ signals ─▶ triage ─▶ admission ─▶ LLM scoring ─▶ selection
|
||||
//! ─▶ comments ─▶ editorial
|
||||
//! ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
|
||||
//! ─▶ hygiene ─▶ embeddings + signals ─▶ triage ─▶ admission ─▶ deep assessment
|
||||
//! ─▶ utility + shortlist ─▶ editor ─▶ comments ─▶ summaries + brief
|
||||
//! ─▶ world briefing ─▶ behind the paper ─▶ EPUB (standard + X4) ─▶ XTC
|
||||
//! ─▶ publish ─▶ report
|
||||
//! ```
|
||||
//!
|
||||
//! Failure policy (notes §3):
|
||||
@@ -40,10 +41,10 @@ use crate::db::Db;
|
||||
use crate::extract::Extractor;
|
||||
use crate::miniflux::MinifluxClient;
|
||||
use crate::publish::Published;
|
||||
use crate::report::{ProviderUsage, RunReport, RunStatus};
|
||||
use crate::report::{ProviderUsage, RunReport, RunStatus, VOYAGE_PROVIDER};
|
||||
use crate::types::{
|
||||
Article, ArticleId, Artifact, Candidate, Colophon, Edition, Issue, IssueMeta, Lineup, Models,
|
||||
reading_minutes,
|
||||
Article, ArticleId, Artifact, BehindThePaper, Candidate, Colophon, Edition, Issue, IssueMeta,
|
||||
Lineup, Models, TokenUsage, reading_minutes,
|
||||
};
|
||||
use crate::{comments, dedupe, epub, http, miniflux, publish, social, world};
|
||||
|
||||
@@ -169,6 +170,7 @@ pub fn build_issue(
|
||||
editorial,
|
||||
world_briefing,
|
||||
colophon,
|
||||
behind: BehindThePaper::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +240,10 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
|
||||
let stages = match run_stages(&ctx, window_start, window_end, &mut report).await {
|
||||
Ok(stages) => {
|
||||
report.finish(Timestamp::now());
|
||||
// The once-per-run info block of §15.4.
|
||||
for line in report.info_block() {
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
stages
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -483,23 +489,7 @@ async fn run_stages(
|
||||
record_candidates(ctx, &personalized)
|
||||
.await
|
||||
.context("recording admission telemetry")?;
|
||||
tracing::info!(
|
||||
"admission: triage {} · interest {} · knn {} · exploration {} · blend {} · auto {}",
|
||||
admission.admitted_by.get("triage").copied().unwrap_or(0),
|
||||
admission.admitted_by.get("interest").copied().unwrap_or(0),
|
||||
admission.admitted_by.get("knn").copied().unwrap_or(0),
|
||||
admission
|
||||
.admitted_by
|
||||
.get("exploration")
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
admission.admitted_by.get("blend").copied().unwrap_or(0),
|
||||
admission
|
||||
.admitted_by
|
||||
.get("auto_include")
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
tracing::debug!(admitted_by = ?admission.admitted_by, "admission complete");
|
||||
report.timings.record("admit", elapsed_ms(stage));
|
||||
|
||||
// --- Stage 9: deep assessment (§12.1) ---
|
||||
@@ -606,19 +596,23 @@ async fn run_stages(
|
||||
report.counts.discussions = comments::fetch_all(&http, &mut lineup.picks).await as i64;
|
||||
report.timings.record("comments", elapsed_ms(stage));
|
||||
|
||||
// --- Stage 9: editorial (§3.6 C) ---
|
||||
let stage = Timestamp::now();
|
||||
let editorial = match curator.editorial(&lineup).await {
|
||||
Ok(editorial) => editorial,
|
||||
// --- Stage 9: editorial — summaries and the Brief (§14) ---
|
||||
let editorial = match curator.editorial_timed(&lineup).await {
|
||||
Ok((editorial, timings)) => {
|
||||
report.timings.record("summaries", timings.summaries_ms);
|
||||
report.timings.record("brief", timings.brief_ms);
|
||||
editorial
|
||||
}
|
||||
Err(e) => {
|
||||
report.warn(format!(
|
||||
"editorial generation failed; using excerpts: {e:#}"
|
||||
));
|
||||
report.timings.record("summaries", 0);
|
||||
report.timings.record("brief", 0);
|
||||
editorial::fallback_editorial(&lineup)
|
||||
}
|
||||
};
|
||||
apply_summaries(&mut lineup, &editorial);
|
||||
report.timings.record("editorial", elapsed_ms(stage));
|
||||
|
||||
// --- Stage 10: completed-day World Briefing (§3.8), best effort ---
|
||||
// Editorial retains budget priority; only the remaining metered budget is
|
||||
@@ -656,6 +650,19 @@ async fn run_stages(
|
||||
cost_usd: editor_meter.cost_usd(),
|
||||
},
|
||||
);
|
||||
// Voyage rides along in `provider_costs_json` (§7.6) so `stats` can price
|
||||
// it per day; its tokens are embedding input, kept out of the LLM aggregate.
|
||||
report.provider_costs.insert(
|
||||
VOYAGE_PROVIDER.into(),
|
||||
ProviderUsage {
|
||||
usage: TokenUsage {
|
||||
input_tokens: report.voyage_tokens,
|
||||
..TokenUsage::default()
|
||||
},
|
||||
cost_usd: report.voyage_cost_usd,
|
||||
},
|
||||
);
|
||||
let total_cost = bulk_meter.cost_usd() + editor_meter.cost_usd() + report.voyage_cost_usd;
|
||||
let summary_model = match config.editorial.summary_model {
|
||||
crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => {
|
||||
config.anthropic.model.clone()
|
||||
@@ -668,30 +675,32 @@ async fn run_stages(
|
||||
.iter()
|
||||
.map(|(provider, usage)| (provider.clone(), usage.cost_usd))
|
||||
.collect();
|
||||
let models = Models {
|
||||
bulk: if bulk_available {
|
||||
config.deepseek.model.clone()
|
||||
} else {
|
||||
"none".into()
|
||||
},
|
||||
editor: if curator.llms.editor.is_some() {
|
||||
config.anthropic.model.clone()
|
||||
} else if bulk_available {
|
||||
format!("{} (bulk fallback)", config.deepseek.model)
|
||||
} else {
|
||||
"none".into()
|
||||
},
|
||||
summaries: summary_model,
|
||||
};
|
||||
let colophon = Colophon {
|
||||
provider_costs,
|
||||
models: Models {
|
||||
bulk: if bulk_available {
|
||||
config.deepseek.model.clone()
|
||||
} else {
|
||||
"none".into()
|
||||
},
|
||||
editor: if curator.llms.editor.is_some() {
|
||||
config.anthropic.model.clone()
|
||||
} else if bulk_available {
|
||||
format!("{} (bulk fallback)", config.deepseek.model)
|
||||
} else {
|
||||
"none".into()
|
||||
},
|
||||
summaries: summary_model,
|
||||
},
|
||||
models: models.clone(),
|
||||
entries_fetched: report.counts.entries_fetched,
|
||||
feeds_seen: report.counts.feeds_seen,
|
||||
candidates: report.counts.candidates,
|
||||
cost_usd: bulk_meter.cost_usd() + editor_meter.cost_usd(),
|
||||
cost_usd: total_cost,
|
||||
generator_version: format!("daily-epub {}", crate::VERSION),
|
||||
};
|
||||
let issue = build_issue(
|
||||
let behind = behind_the_paper(ctx, report, models, total_cost).await;
|
||||
let mut issue = build_issue(
|
||||
date,
|
||||
issue_number,
|
||||
Timestamp::now(),
|
||||
@@ -700,6 +709,7 @@ async fn run_stages(
|
||||
world_briefing,
|
||||
colophon,
|
||||
);
|
||||
issue.behind = behind;
|
||||
|
||||
// --- Stage 12: build both EPUB editions (§3.10) — fatal on failure ---
|
||||
let stage = Timestamp::now();
|
||||
@@ -757,6 +767,51 @@ async fn run_stages(
|
||||
})
|
||||
}
|
||||
|
||||
/// Near misses listed in the "Behind the paper" chapter (§15.1).
|
||||
const NEAR_MISSES_IN_PAPER: usize = 10;
|
||||
|
||||
/// The facts of §15.1, from the report so far and the run's `candidate_runs`
|
||||
/// rows (selection telemetry must already be written). Never fails: a
|
||||
/// telemetry read error leaves the near-miss list empty.
|
||||
async fn behind_the_paper(
|
||||
ctx: &StageContext<'_>,
|
||||
report: &RunReport,
|
||||
models: Models,
|
||||
cost_usd: f64,
|
||||
) -> BehindThePaper {
|
||||
let near_misses =
|
||||
match telemetry::paper_near_misses(ctx.db, ctx.run_id, NEAR_MISSES_IN_PAPER).await {
|
||||
Ok(misses) => misses,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not read near misses for the paper");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let counts = &report.counts;
|
||||
BehindThePaper {
|
||||
considered: counts.articles,
|
||||
feeds_seen: counts.feeds_seen,
|
||||
eligible: counts.eligible,
|
||||
triaged: counts.triaged,
|
||||
read_closely: counts.assessed,
|
||||
shortlisted: counts.shortlisted,
|
||||
selected: counts.selected,
|
||||
admitted_by: counts.admitted_by.clone(),
|
||||
rated_with_embeddings: counts.rated_with_embeddings,
|
||||
knn_gate: counts.knn_gate,
|
||||
feed_gate: counts.feed_gate,
|
||||
near_misses,
|
||||
models,
|
||||
embedding_model: if counts.embedded > 0 {
|
||||
ctx.config.voyage.model.clone()
|
||||
} else {
|
||||
"none".into()
|
||||
},
|
||||
cost_usd,
|
||||
generation_secs: (Timestamp::now().as_second() - ctx.started_at.as_second()).max(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedding cache with a Voyage client behind it, or cache-only under
|
||||
/// `--skip-embeddings`, `voyage.enabled = false` or a missing key (§16, §17).
|
||||
fn build_embedding_service(
|
||||
@@ -872,6 +927,8 @@ async fn prepare_features(
|
||||
}
|
||||
};
|
||||
report.counts.rated_with_embeddings = preference.rated_with_embeddings as i64;
|
||||
report.counts.knn_gate = preference.knn_gate;
|
||||
report.counts.feed_gate = preference.feed_gate;
|
||||
for candidate in candidates.iter_mut() {
|
||||
candidate.signals = computed
|
||||
.remove(&candidate.article.id)
|
||||
@@ -1013,6 +1070,7 @@ async fn build_llms(
|
||||
return Llms::default();
|
||||
}
|
||||
};
|
||||
report.counts.verdicts_in_prompt = profile.verdicts as i64;
|
||||
if ctx.skip_llm {
|
||||
tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made");
|
||||
return Llms::default();
|
||||
@@ -1047,6 +1105,7 @@ async fn build_llms(
|
||||
version = rebuilt.version,
|
||||
"taste profile rebuilt with editor-or-bulk"
|
||||
);
|
||||
report.counts.verdicts_in_prompt = rebuilt.verdicts as i64;
|
||||
llms = make_clients(rebuilt.text);
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -1663,6 +1722,66 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(misses.contains("not selected, by utility"), "{misses}");
|
||||
assert!(misses.contains("shortlisted, not_selected"), "{misses}");
|
||||
|
||||
// The Behind-the-paper facts come from the same rows and the report.
|
||||
report.counts.articles = 4;
|
||||
report.counts.feeds_seen = 4;
|
||||
report.counts.admitted = 2;
|
||||
report.counts.admitted_by = BTreeMap::from([("interest".to_string(), 2)]);
|
||||
report.counts.shortlisted = 2;
|
||||
report.counts.selected = 1;
|
||||
let behind = behind_the_paper(&ctx, &report, Models::default(), 0.25).await;
|
||||
assert_eq!(behind.considered, 4);
|
||||
assert_eq!(behind.feeds_seen, 4);
|
||||
assert_eq!(behind.eligible, 2);
|
||||
assert_eq!(behind.shortlisted, 2);
|
||||
assert_eq!(behind.selected, 1);
|
||||
assert_eq!(behind.admitted_by.get("interest"), Some(&2));
|
||||
assert_eq!(behind.rated_with_embeddings, 0);
|
||||
assert_eq!(behind.knn_gate, 0.0);
|
||||
assert_eq!(behind.embedding_model, h.config.voyage.model);
|
||||
assert_eq!(behind.cost_usd, 0.25);
|
||||
assert_eq!(behind.near_misses.len(), 1, "one shortlisted, not selected");
|
||||
let miss = &behind.near_misses[0];
|
||||
assert_eq!(miss.article_id, loser);
|
||||
assert_eq!(
|
||||
miss.title,
|
||||
format!(
|
||||
"Post {}",
|
||||
h.articles
|
||||
.iter()
|
||||
.find(|a| a.id == loser)
|
||||
.unwrap()
|
||||
.best_entry_id
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
miss.feed_title,
|
||||
h.articles
|
||||
.iter()
|
||||
.find(|a| a.id == loser)
|
||||
.unwrap()
|
||||
.feed_title
|
||||
);
|
||||
assert_eq!(miss.stage, "shortlisted");
|
||||
assert_eq!(miss.reason.as_deref(), Some("not_selected"));
|
||||
assert!(miss.quality.is_none(), "no deep assessment ran");
|
||||
let chapter = crate::epub::chapters::render_behind_the_paper(&Issue {
|
||||
behind: behind.clone(),
|
||||
..crate::epub::fixtures::issue()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
chapter.xhtml.contains("Considered 4 articles from 4 feeds"),
|
||||
"{}",
|
||||
chapter.xhtml
|
||||
);
|
||||
assert!(chapter.xhtml.contains(&miss.title), "{}", chapter.xhtml);
|
||||
assert!(
|
||||
chapter.xhtml.contains("shortlisted, not selected"),
|
||||
"{}",
|
||||
chapter.xhtml
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -752,6 +752,7 @@ mod tests {
|
||||
editorial: Editorial::default(),
|
||||
world_briefing: None,
|
||||
colophon: Colophon::default(),
|
||||
behind: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+151
-5
@@ -49,7 +49,7 @@ impl fmt::Display for RunStatus {
|
||||
}
|
||||
|
||||
/// Per-stage article counts as the pipeline narrows the day's feed volume (§2).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StageCounts {
|
||||
/// Entries returned by Miniflux inside the lookback window (§3.1).
|
||||
pub entries_fetched: i64,
|
||||
@@ -73,6 +73,15 @@ pub struct StageCounts {
|
||||
pub embedded: i64,
|
||||
/// Current rated articles with a valid embedding.
|
||||
pub rated_with_embeddings: i64,
|
||||
/// The neighbour signal's gate ramp, 0–1 (§9.2); 0 means the signal is absent.
|
||||
#[serde(default)]
|
||||
pub knn_gate: f64,
|
||||
/// The feed-affinity gate ramp, 0–1 (§9.3).
|
||||
#[serde(default)]
|
||||
pub feed_gate: f64,
|
||||
/// Explicit verdicts rendered into the system prompt (§8.4).
|
||||
#[serde(default)]
|
||||
pub verdicts_in_prompt: i64,
|
||||
/// Articles with a reusable or newly produced triage assessment.
|
||||
pub triaged: i64,
|
||||
/// Articles admitted to close reading.
|
||||
@@ -97,6 +106,10 @@ pub struct StageCounts {
|
||||
pub images_embedded: i64,
|
||||
}
|
||||
|
||||
/// Key under which Voyage usage sits in `provider_costs` (§7.6). Its token count
|
||||
/// is embedding input, so it stays out of the LLM `usage` aggregate.
|
||||
pub const VOYAGE_PROVIDER: &str = "voyage";
|
||||
|
||||
/// Wall-clock milliseconds per pipeline stage.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StageTimings(pub BTreeMap<String, i64>);
|
||||
@@ -186,13 +199,21 @@ impl RunReport {
|
||||
|
||||
/// Stamp the end time, total provider costs (LLM providers plus Voyage) and
|
||||
/// settle the status.
|
||||
///
|
||||
/// Voyage is counted once: through its `provider_costs` entry when the
|
||||
/// pipeline recorded one, else through `voyage_cost_usd`.
|
||||
pub fn finish(&mut self, finished_at: Timestamp) {
|
||||
self.finished_at = Some(finished_at);
|
||||
self.usage = TokenUsage::default();
|
||||
self.cost_usd = self.voyage_cost_usd;
|
||||
for provider in self.provider_costs.values() {
|
||||
self.usage.add(provider.usage);
|
||||
self.cost_usd += provider.cost_usd;
|
||||
self.cost_usd = 0.0;
|
||||
for (provider, usage) in &self.provider_costs {
|
||||
self.cost_usd += usage.cost_usd;
|
||||
if provider != VOYAGE_PROVIDER {
|
||||
self.usage.add(usage.usage);
|
||||
}
|
||||
}
|
||||
if !self.provider_costs.contains_key(VOYAGE_PROVIDER) {
|
||||
self.cost_usd += self.voyage_cost_usd;
|
||||
}
|
||||
if self.status == RunStatus::Running {
|
||||
self.status = if self.warnings.is_empty() {
|
||||
@@ -233,6 +254,68 @@ impl RunReport {
|
||||
)
|
||||
}
|
||||
|
||||
/// "23m12s" / "48s" for the log block and `stats`.
|
||||
pub fn format_duration(secs: i64) -> String {
|
||||
let secs = secs.max(0);
|
||||
if secs >= 60 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{secs}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// The four-line info block of §15.4 (`curation:`, `admission:`,
|
||||
/// `preference:`, `providers:`), logged once per run and printed by the CLI.
|
||||
pub fn info_block(&self) -> [String; 4] {
|
||||
let c = &self.counts;
|
||||
let admitted = |name: &str| c.admitted_by.get(name).copied().unwrap_or(0);
|
||||
let gate = |value: f64| {
|
||||
if value > 0.0 {
|
||||
format!("{value:.2}")
|
||||
} else {
|
||||
"off".to_string()
|
||||
}
|
||||
};
|
||||
let providers = self
|
||||
.provider_costs
|
||||
.iter()
|
||||
.map(|(provider, usage)| format!("{provider} ${:.2}", usage.cost_usd))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let providers = if providers.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
providers
|
||||
};
|
||||
[
|
||||
format!(
|
||||
"curation: {} considered → {} eligible → {} triaged → {} assessed → {} shortlisted → {} selected",
|
||||
c.articles, c.eligible, c.triaged, c.assessed, c.shortlisted, c.selected
|
||||
),
|
||||
format!(
|
||||
"admission: triage {} · interest {} · knn {} · exploration {} · blend {} · auto {}",
|
||||
admitted("triage"),
|
||||
admitted("interest"),
|
||||
admitted("knn"),
|
||||
admitted("exploration"),
|
||||
admitted("blend"),
|
||||
admitted("auto_include"),
|
||||
),
|
||||
format!(
|
||||
"preference: {} rated w/ embeddings → knn {} · feed {} · {} verdicts in prompt",
|
||||
c.rated_with_embeddings,
|
||||
gate(c.knn_gate),
|
||||
gate(c.feed_gate),
|
||||
c.verdicts_in_prompt
|
||||
),
|
||||
format!(
|
||||
"providers: {providers} · total ${:.2} · {}",
|
||||
self.cost_usd,
|
||||
Self::format_duration(self.duration_secs().unwrap_or(0))
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Feeds ordered by entry count, descending — the M1 dry-run breakdown.
|
||||
pub fn top_feeds(&self, limit: usize) -> Vec<(&str, i64)> {
|
||||
let mut v: Vec<(&str, i64)> = self
|
||||
@@ -289,6 +372,69 @@ mod tests {
|
||||
// The legacy aggregate columns are the sum across providers.
|
||||
assert_eq!(r.usage, usage(1_000_100, 1_003_000, 2_000, 1_000_800));
|
||||
assert_eq!(r.duration_secs(), Some(360));
|
||||
|
||||
// Once the pipeline records Voyage as a provider (§7.6) it is counted
|
||||
// there, not twice, and its tokens still stay out of the LLM aggregate.
|
||||
r.provider_costs.insert(
|
||||
VOYAGE_PROVIDER.into(),
|
||||
ProviderUsage {
|
||||
usage: usage(250_000, 0, 0, 0),
|
||||
cost_usd: 0.005,
|
||||
},
|
||||
);
|
||||
r.finish(ts("2026-08-15T05:36:00Z"));
|
||||
assert!((r.cost_usd - 0.4778).abs() < 1e-9);
|
||||
assert_eq!(r.usage, usage(1_000_100, 1_003_000, 2_000, 1_000_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_block_has_the_four_lines_of_the_plan() {
|
||||
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
|
||||
r.counts.articles = 412;
|
||||
r.counts.eligible = 398;
|
||||
r.counts.triaged = 398;
|
||||
r.counts.assessed = 120;
|
||||
r.counts.shortlisted = 60;
|
||||
r.counts.selected = 17;
|
||||
r.counts.admitted_by = BTreeMap::from([
|
||||
("triage".to_string(), 60),
|
||||
("interest".to_string(), 20),
|
||||
("knn".to_string(), 12),
|
||||
("exploration".to_string(), 5),
|
||||
("blend".to_string(), 23),
|
||||
]);
|
||||
r.counts.rated_with_embeddings = 14;
|
||||
r.counts.knn_gate = 0.35;
|
||||
r.counts.verdicts_in_prompt = 41;
|
||||
for (provider, cost) in [("deepseek", 0.11), ("anthropic", 0.62), ("voyage", 0.02)] {
|
||||
r.provider_costs.insert(
|
||||
provider.into(),
|
||||
ProviderUsage {
|
||||
usage: usage(1, 0, 0, 1),
|
||||
cost_usd: cost,
|
||||
},
|
||||
);
|
||||
}
|
||||
r.finish(ts("2026-08-15T05:53:12Z"));
|
||||
let [curation, admission, preference, providers] = r.info_block();
|
||||
assert_eq!(
|
||||
curation,
|
||||
"curation: 412 considered → 398 eligible → 398 triaged → 120 assessed → 60 shortlisted → 17 selected"
|
||||
);
|
||||
assert_eq!(
|
||||
admission,
|
||||
"admission: triage 60 · interest 20 · knn 12 · exploration 5 · blend 23 · auto 0"
|
||||
);
|
||||
assert_eq!(
|
||||
preference,
|
||||
"preference: 14 rated w/ embeddings → knn 0.35 · feed off · 41 verdicts in prompt"
|
||||
);
|
||||
assert_eq!(
|
||||
providers,
|
||||
"providers: anthropic $0.62 · deepseek $0.11 · voyage $0.02 · total $0.75 · 23m12s"
|
||||
);
|
||||
assert_eq!(RunReport::format_duration(48), "48s");
|
||||
assert_eq!(RunReport::format_duration(3600), "60m00s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -383,6 +383,9 @@ pub struct TasteProfile {
|
||||
pub text: String,
|
||||
pub version: i64,
|
||||
pub built_at: Timestamp,
|
||||
/// Explicit verdicts rendered into the prompt's "Recent verdicts" block (§8.4).
|
||||
#[serde(default)]
|
||||
pub verdicts: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -562,6 +565,9 @@ pub struct Issue {
|
||||
pub world_briefing: Option<WorldBriefing>,
|
||||
/// Colophon facts: models used, token cost, feed counts (§3.10).
|
||||
pub colophon: Colophon,
|
||||
/// The "Behind the paper" chapter's facts (§15.1); filled by the pipeline.
|
||||
#[serde(default)]
|
||||
pub behind: BehindThePaper,
|
||||
}
|
||||
|
||||
/// Resolved model names printed in the colophon (§15.1).
|
||||
@@ -584,6 +590,49 @@ pub struct Colophon {
|
||||
pub generator_version: String,
|
||||
}
|
||||
|
||||
/// One of the highest-utility articles that did not make the paper (§15.1).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NearMiss {
|
||||
pub article_id: ArticleId,
|
||||
pub title: String,
|
||||
pub feed_title: String,
|
||||
pub quality: Option<f64>,
|
||||
pub fit: Option<f64>,
|
||||
/// `candidate_runs.stage` reached.
|
||||
pub stage: String,
|
||||
/// `candidate_runs.excluded_reason`, when the row carries one.
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Facts for the "Behind the paper" chapter (§15.1), derived from the run's
|
||||
/// report and its `candidate_runs` rows. Templates render it verbatim.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BehindThePaper {
|
||||
/// Deduplicated articles the run looked at.
|
||||
pub considered: i64,
|
||||
pub feeds_seen: i64,
|
||||
pub eligible: i64,
|
||||
pub triaged: i64,
|
||||
/// Deep-assessed ("read closely").
|
||||
pub read_closely: i64,
|
||||
pub shortlisted: i64,
|
||||
pub selected: i64,
|
||||
/// First admitting retriever → count (§11).
|
||||
pub admitted_by: BTreeMap<String, i64>,
|
||||
pub rated_with_embeddings: i64,
|
||||
/// The neighbour signal's gate ramp, 0–1 (§9.2).
|
||||
pub knn_gate: f64,
|
||||
/// The feed-affinity gate ramp, 0–1 (§9.3).
|
||||
pub feed_gate: f64,
|
||||
pub near_misses: Vec<NearMiss>,
|
||||
pub models: Models,
|
||||
pub embedding_model: String,
|
||||
/// Every provider, Voyage included.
|
||||
pub cost_usd: f64,
|
||||
/// Wall clock from the run's start to issue assembly.
|
||||
pub generation_secs: i64,
|
||||
}
|
||||
|
||||
/// A downloaded, re-encoded image embedded in an edition (§3.10 images).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ImageAsset {
|
||||
|
||||
@@ -89,6 +89,7 @@ fn standard_edition_is_a_well_formed_epub3_archive() {
|
||||
"OEBPS/sec-niche-corner.xhtml",
|
||||
"OEBPS/art-1002.xhtml",
|
||||
"OEBPS/world.xhtml",
|
||||
"OEBPS/behind.xhtml",
|
||||
"OEBPS/colophon.xhtml",
|
||||
] {
|
||||
assert!(contains_entry(&zip, entry), "missing {entry}");
|
||||
@@ -186,11 +187,77 @@ fn chapter_ids_hrefs_and_toc_levels_are_stable() {
|
||||
),
|
||||
("art-1002".into(), "art-1002.xhtml".into(), 2),
|
||||
("world".into(), "world.xhtml".into(), 1),
|
||||
("behind".into(), "behind.xhtml".into(), 1),
|
||||
("colophon".into(), "colophon.xhtml".into(), 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// "Behind the paper" sits between the World Briefing and the colophon in both
|
||||
/// editions, carries the counts, the admission mix, the learned-signal state,
|
||||
/// the near misses and the models, and has no links (§15.1).
|
||||
#[test]
|
||||
fn behind_the_paper_renders_counts_and_near_misses_in_both_editions() {
|
||||
let issue = fixtures::issue();
|
||||
for edition in [Edition::Standard, Edition::X4] {
|
||||
let (_dir, _, zip) = build_edition_to_bytes(&issue, edition);
|
||||
let behind = read_entry(&zip, "OEBPS/behind.xhtml");
|
||||
assert!(behind.contains("<h1>Behind the paper</h1>"), "{behind}");
|
||||
assert!(
|
||||
behind.contains(
|
||||
"Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected."
|
||||
),
|
||||
"{behind}"
|
||||
);
|
||||
assert!(
|
||||
behind.contains(
|
||||
"Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23."
|
||||
),
|
||||
"{behind}"
|
||||
);
|
||||
assert!(
|
||||
behind.contains(
|
||||
"Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off."
|
||||
),
|
||||
"{behind}"
|
||||
);
|
||||
assert!(behind.contains("<h2>Near misses</h2>"), "{behind}");
|
||||
assert!(
|
||||
behind.contains(
|
||||
"<li>The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected</li>"
|
||||
),
|
||||
"{behind}"
|
||||
);
|
||||
assert!(
|
||||
behind.contains(
|
||||
"<li>Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted</li>"
|
||||
),
|
||||
"{behind}"
|
||||
);
|
||||
assert!(
|
||||
behind.contains(
|
||||
"Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min."
|
||||
),
|
||||
"{behind}"
|
||||
);
|
||||
assert!(!behind.contains("<a "), "no links in {edition:?}: {behind}");
|
||||
assert!(!behind.contains(" "));
|
||||
for tag in ["html", "head", "body", "div", "p", "ul", "li", "h1", "h2"] {
|
||||
// `<li` would also match `<link`; count real opening tags only.
|
||||
let opens = behind.matches(&format!("<{tag}>")).count()
|
||||
+ behind.matches(&format!("<{tag} ")).count();
|
||||
let closes = behind.matches(&format!("</{tag}>")).count();
|
||||
assert_eq!(opens, closes, "unbalanced <{tag}> in {edition:?}");
|
||||
}
|
||||
|
||||
// Ordering: world → behind → colophon in the spine.
|
||||
let opf = read_entry(&zip, "OEBPS/content.opf");
|
||||
let position = |href: &str| opf.find(&format!("href=\"{href}\"")).expect(href);
|
||||
assert!(position("world.xhtml") < position("behind.xhtml"));
|
||||
assert!(position("behind.xhtml") < position("colophon.xhtml"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_chapter_is_parseable_xhtml() {
|
||||
let issue = fixtures::issue();
|
||||
|
||||
Reference in New Issue
Block a user