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",
|
"hmac",
|
||||||
"image",
|
"image",
|
||||||
"jiff",
|
"jiff",
|
||||||
|
"libc",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"resvg",
|
"resvg",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ hex = "0.4.3"
|
|||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
jiff = { version = "0.2.35", features = ["serde"] }
|
jiff = { version = "0.2.35", features = ["serde"] }
|
||||||
|
libc = "0.2.189"
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "gzip", "json", "charset", "http2", "query", "system-proxy"] }
|
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "gzip", "json", "charset", "http2", "query", "system-proxy"] }
|
||||||
resvg = "0.48.1"
|
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
|
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
|
publishes the lot over its own OPDS catalog — which doubles as a
|
||||||
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
|
[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
|
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
|
~$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
|
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.
|
`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
|
**Failure policy.** Miniflux ingest, SQLite writes, EPUB assembly and publishing
|
||||||
are fatal — without them there is no issue, and the `runs` row records why.
|
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 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 (--article ID | --url URL) [--run-id N]
|
||||||
daily-epub explain --date YYYY-MM-DD --near-misses [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 backfill [--days 30] [--rated-only] [--all] [--yes]
|
||||||
daily-epub features prune # stale embeddings + old candidate telemetry
|
daily-epub features prune # stale embeddings + old candidate telemetry
|
||||||
daily-epub backfill-social # re-poll social scores for recent articles
|
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.
|
`--skip-embeddings` reads the embedding cache but makes zero Voyage calls.
|
||||||
`--rescore` ignores reusable triage/deep assessments for this run.
|
`--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`
|
`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
|
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
|
one). `--near-misses` lists the highest-utility articles that were not selected
|
||||||
(by preliminary blend for articles the ranker never reached).
|
(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
|
`features backfill` embeds the rated and published articles first (the learned
|
||||||
set), then the standing interests, then — only with `--all` — every other
|
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
|
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)
|
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
|
||||||
EOF
|
EOF
|
||||||
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
|
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
|
# 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
|
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
|
ls -la ./out # two .epub files
|
||||||
epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
|
epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
|
||||||
# open the standard edition in Calibre / KOReader: cover, The Brief,
|
# 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
|
# 4. Now with DeepSeek and Claude, still not publishing
|
||||||
daily-epub generate --dry-run --out ./out --max-articles 6
|
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;'
|
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
|
# 8. Watch cost and quality for a week
|
||||||
sqlite3 /var/lib/daily-epub/daily-epub.db \
|
daily-epub stats --days 7
|
||||||
'select date, status, entries_fetched, candidates, selected, cost_usd from runs order by id desc limit 7;'
|
daily-epub explain --date $(date +%F) --near-misses
|
||||||
```
|
```
|
||||||
|
|
||||||
Tune `curation.ranking.deep_keep`, `target_article_count` and `curation.always_include_feeds`
|
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
|
### 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
|
`tests/e2e_pipeline.rs` is the capstone: synthetic entries → dedupe → offline
|
||||||
extraction → signals → triage → admission → selection (both the `--skip-llm` route and a
|
extraction → signals → triage → admission → selection (both the `--skip-llm` route and a
|
||||||
`MockBackend` DeepSeek route) → editorial → both EPUB editions → publish → OPDS
|
`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
|
### Layout
|
||||||
|
|
||||||
@@ -516,6 +558,7 @@ images/ article images comments.rs discussion chapters
|
|||||||
encode re-encode publish.rs BookOrbit + XTC
|
encode re-encode publish.rs BookOrbit + XTC
|
||||||
embed into the page server.rs ratings, OPDS
|
embed into the page server.rs ratings, OPDS
|
||||||
html.rs markup helpers db.rs SQLite
|
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.
|
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,
|
world_briefing: None,
|
||||||
colophon: Colophon::default(),
|
colophon: Colophon::default(),
|
||||||
|
behind: Default::default(),
|
||||||
};
|
};
|
||||||
let cfg = daily_epub::config::Config::default();
|
let cfg = daily_epub::config::Config::default();
|
||||||
match daily_epub::epub::build_edition_with_images(
|
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(
|
pub async fn run(
|
||||||
llms: &Llms,
|
llms: &Llms,
|
||||||
lineup: &Lineup,
|
lineup: &Lineup,
|
||||||
config: &EditorialConfig,
|
config: &EditorialConfig,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
) -> Editorial {
|
) -> 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() {
|
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;
|
let mut summaries = summarize_all(llms, lineup, config, temperature).await;
|
||||||
for pick in &lineup.picks {
|
for pick in &lineup.picks {
|
||||||
summaries
|
summaries
|
||||||
.entry(pick.article.id)
|
.entry(pick.article.id)
|
||||||
.or_insert_with(|| excerpt_summary(pick));
|
.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 {
|
let front_page_html = match brief(llms, lineup, &summaries, temperature).await {
|
||||||
Ok(text) => text_to_paragraphs(&text),
|
Ok(text) => text_to_paragraphs(&text),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -340,10 +361,17 @@ pub async fn run(
|
|||||||
fallback_front_page_html(lineup)
|
fallback_front_page_html(lineup)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Editorial {
|
let brief_ms = started.elapsed().as_millis() as i64;
|
||||||
front_page_html,
|
(
|
||||||
summaries,
|
Editorial {
|
||||||
}
|
front_page_html,
|
||||||
|
summaries,
|
||||||
|
},
|
||||||
|
EditorialTimings {
|
||||||
|
summaries_ms,
|
||||||
|
brief_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn summary_to_html(summary: &str) -> String {
|
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.
|
/// Never fails the run: a budget trip or an API error degrades to excerpts.
|
||||||
pub async fn editorial(&self, lineup: &Lineup) -> anyhow::Result<Editorial> {
|
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() {
|
if self.llms.editor_or_bulk().is_none() {
|
||||||
tracing::info!("--skip-llm: using feed excerpts as summaries");
|
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 span = tracing::info_span!("llm_editorial", picks = lineup.picks.len());
|
||||||
let _guard = span.enter();
|
let _guard = span.enter();
|
||||||
Ok(editorial::run(
|
Ok(editorial::run_timed(
|
||||||
&self.llms,
|
&self.llms,
|
||||||
lineup,
|
lineup,
|
||||||
&self.config.editorial,
|
&self.config.editorial,
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ pub async fn load_or_build(
|
|||||||
),
|
),
|
||||||
version,
|
version,
|
||||||
built_at,
|
built_at,
|
||||||
|
verdicts: ratings.len().min(verdict_limit),
|
||||||
};
|
};
|
||||||
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -472,6 +473,7 @@ pub async fn rebuild(
|
|||||||
),
|
),
|
||||||
version: next_version,
|
version: next_version,
|
||||||
built_at,
|
built_at,
|
||||||
|
verdicts: current.len().min(verdict_limit),
|
||||||
};
|
};
|
||||||
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
+498
-1
@@ -15,7 +15,8 @@ use sqlx::Row as _;
|
|||||||
|
|
||||||
use crate::curate::signals::{Neighbour, Signals, TopInterest};
|
use crate::curate::signals::{Neighbour, Signals, TopInterest};
|
||||||
use crate::db::{Db, fmt_ts};
|
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.
|
/// The stage vocabulary of §7.4, in pipeline order.
|
||||||
pub const STAGES: [&str; 7] = [
|
pub const STAGES: [&str; 7] = [
|
||||||
@@ -230,6 +231,8 @@ pub struct ExplainRow {
|
|||||||
pub run_id: i64,
|
pub run_id: i64,
|
||||||
pub article_id: ArticleId,
|
pub article_id: ArticleId,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// The best entry's feed, for the paper's near-miss list.
|
||||||
|
pub feed_title: String,
|
||||||
pub stage: String,
|
pub stage: String,
|
||||||
pub excluded_reason: Option<String>,
|
pub excluded_reason: Option<String>,
|
||||||
pub admitted_by: Option<String>,
|
pub admitted_by: Option<String>,
|
||||||
@@ -247,6 +250,7 @@ impl ExplainRow {
|
|||||||
run_id: row.get("run_id"),
|
run_id: row.get("run_id"),
|
||||||
article_id: row.get("article_id"),
|
article_id: row.get("article_id"),
|
||||||
title: row.get("title"),
|
title: row.get("title"),
|
||||||
|
feed_title: row.get("feed_title"),
|
||||||
stage: row.get("stage"),
|
stage: row.get("stage"),
|
||||||
excluded_reason: row.get("excluded_reason"),
|
excluded_reason: row.get("excluded_reason"),
|
||||||
admitted_by: row.get("admitted_by"),
|
admitted_by: row.get("admitted_by"),
|
||||||
@@ -305,9 +309,11 @@ pub async fn explain_row(
|
|||||||
) -> Result<Option<ExplainRow>, sqlx::Error> {
|
) -> Result<Option<ExplainRow>, sqlx::Error> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
"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.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
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
|
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 = ?",
|
WHERE cr.run_id = ? AND cr.article_id = ?",
|
||||||
)
|
)
|
||||||
.bind(run_id)
|
.bind(run_id)
|
||||||
@@ -326,9 +332,11 @@ pub async fn near_misses(
|
|||||||
) -> Result<Vec<ExplainRow>, sqlx::Error> {
|
) -> Result<Vec<ExplainRow>, sqlx::Error> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
"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.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
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
|
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'",
|
WHERE cr.run_id = ? AND cr.stage != 'selected' AND cr.stage != 'excluded'",
|
||||||
)
|
)
|
||||||
.bind(run_id)
|
.bind(run_id)
|
||||||
@@ -579,6 +587,265 @@ pub async fn explain_near_misses(
|
|||||||
Ok(out)
|
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)
|
// `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]
|
#[tokio::test]
|
||||||
async fn prune_respects_rated_and_published() {
|
async fn prune_respects_rated_and_published() {
|
||||||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await;
|
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).
|
//! Chapter ordering and `epub-builder` assembly (spec §3.10).
|
||||||
//!
|
//!
|
||||||
//! Structure: cover → From the Editor → In This Issue → sections (title page,
|
//! Structure: cover → The Brief → In This Issue → sections (title page,
|
||||||
//! article chapters, discussion chapters) → World Briefing → colophon. The
|
//! article chapters, discussion chapters) → World Briefing → Behind the paper
|
||||||
|
//! → colophon. The
|
||||||
//! chapters themselves are rendered by [`super::chapters`] and the cover by
|
//! chapters themselves are rendered by [`super::chapters`] and the cover by
|
||||||
//! [`super::cover`]; this module decides the order and zips the result.
|
//! [`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::EpubError;
|
||||||
use super::chapters::{
|
use super::chapters::{
|
||||||
render_colophon, render_front_page, render_in_this_issue, render_section_page,
|
render_behind_the_paper, render_colophon, render_front_page, render_in_this_issue,
|
||||||
render_world_briefing, section_names,
|
render_section_page, render_world_briefing, section_names,
|
||||||
};
|
};
|
||||||
use super::cover::{CoverAsset, render_cover_page};
|
use super::cover::{CoverAsset, render_cover_page};
|
||||||
use super::x4;
|
use super::x4;
|
||||||
@@ -85,6 +86,7 @@ pub fn render_all(
|
|||||||
if let Some(world) = render_world_briefing(issue)? {
|
if let Some(world) = render_world_briefing(issue)? {
|
||||||
chapters.push(world);
|
chapters.push(world);
|
||||||
}
|
}
|
||||||
|
chapters.push(render_behind_the_paper(issue)?);
|
||||||
chapters.push(render_colophon(issue)?);
|
chapters.push(render_colophon(issue)?);
|
||||||
|
|
||||||
if edition == Edition::X4 {
|
if edition == Edition::X4 {
|
||||||
@@ -272,11 +274,12 @@ mod tests {
|
|||||||
"sec-Niche Corner",
|
"sec-Niche Corner",
|
||||||
"art-1002",
|
"art-1002",
|
||||||
"world",
|
"world",
|
||||||
|
"behind",
|
||||||
"colophon",
|
"colophon",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
let levels: Vec<u8> = chapters.iter().map(|c| c.toc_level).collect();
|
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 {
|
for chapter in &chapters {
|
||||||
assert_xml_ok(&chapter.xhtml);
|
assert_xml_ok(&chapter.xhtml);
|
||||||
}
|
}
|
||||||
|
|||||||
+203
-1
@@ -10,7 +10,10 @@ use askama::Template;
|
|||||||
use crate::comments;
|
use crate::comments;
|
||||||
use crate::html::{text_escape, to_xhtml};
|
use crate::html::{text_escape, to_xhtml};
|
||||||
use crate::images;
|
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 crate::world;
|
||||||
|
|
||||||
use super::EpubError;
|
use super::EpubError;
|
||||||
@@ -102,6 +105,17 @@ struct WorldBriefingChapter {
|
|||||||
body_html: String,
|
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)]
|
#[derive(Template)]
|
||||||
#[template(path = "colophon.xhtml", escape = "html")]
|
#[template(path = "colophon.xhtml", escape = "html")]
|
||||||
struct ColophonChapter {
|
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).
|
/// Colophon: generation timestamp, models used, token cost, feed counts (§3.10).
|
||||||
pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> {
|
pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> {
|
||||||
let colophon = &issue.colophon;
|
let colophon = &issue.colophon;
|
||||||
@@ -676,6 +815,69 @@ mod tests {
|
|||||||
assert!(render_discussion(&issue.lineup.picks[1]).unwrap().is_none());
|
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]
|
#[test]
|
||||||
fn world_and_colophon_chapters_render() {
|
fn world_and_colophon_chapters_render() {
|
||||||
let issue = issue();
|
let issue = issue();
|
||||||
|
|||||||
@@ -155,6 +155,53 @@ pub fn issue() -> Issue {
|
|||||||
cost_usd: 0.0731,
|
cost_usd: 0.0731,
|
||||||
generator_version: "daily-epub 0.1.0".into(),
|
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",
|
"in-this-issue",
|
||||||
"sections",
|
"sections",
|
||||||
"world-briefing",
|
"world-briefing",
|
||||||
|
"behind-the-paper",
|
||||||
"colophon",
|
"colophon",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ mod tests {
|
|||||||
"OEBPS/disc-1001.xhtml",
|
"OEBPS/disc-1001.xhtml",
|
||||||
"OEBPS/art-1002.xhtml",
|
"OEBPS/art-1002.xhtml",
|
||||||
"OEBPS/world.xhtml",
|
"OEBPS/world.xhtml",
|
||||||
|
"OEBPS/behind.xhtml",
|
||||||
"OEBPS/colophon.xhtml",
|
"OEBPS/colophon.xhtml",
|
||||||
] {
|
] {
|
||||||
assert!(
|
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 %}`) |
|
| `base.xhtml` | — | Shared XHTML skeleton (`{% block body_class %}`, `{% block content %}`) |
|
||||||
| `cover_page.xhtml` | `CoverPage` | Page that displays the rasterized cover image |
|
| `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 |
|
| `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 |
|
| `chapter.xhtml` | `ArticleChapter` | Article: header, body, rating/read-online footer |
|
||||||
| `discussion.xhtml` | `DiscussionChapter` | Comment chapter (§3.7); body from `comments::render_xhtml` |
|
| `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` |
|
| `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 |
|
| `colophon.xhtml` | `ColophonChapter` | Back matter: models, cost, counts |
|
||||||
| `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia |
|
| `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia |
|
||||||
| `style.css` | — | Standard-edition stylesheet, embedded as `stylesheet.css` |
|
| `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 html;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
|
pub mod lock;
|
||||||
pub mod miniflux;
|
pub mod miniflux;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod publish;
|
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::curate::telemetry;
|
||||||
use daily_epub::db::Db;
|
use daily_epub::db::Db;
|
||||||
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
|
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::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.
|
/// A personalized daily newspaper, delivered as an EPUB.
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -45,6 +45,8 @@ enum Command {
|
|||||||
Ratings(RatingsCommand),
|
Ratings(RatingsCommand),
|
||||||
/// Why an article was (not) in the paper, from persisted run telemetry.
|
/// Why an article was (not) in the paper, from persisted run telemetry.
|
||||||
Explain(ExplainArgs),
|
Explain(ExplainArgs),
|
||||||
|
/// The weekly numbers: issues, ratings, retriever yield, cost, timing.
|
||||||
|
Stats(StatsArgs),
|
||||||
/// Embedding cache and telemetry maintenance.
|
/// Embedding cache and telemetry maintenance.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Features(FeaturesCommand),
|
Features(FeaturesCommand),
|
||||||
@@ -199,6 +201,14 @@ struct ExplainArgs {
|
|||||||
near_misses: Option<usize>,
|
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)]
|
#[derive(Debug, Subcommand)]
|
||||||
enum FeaturesCommand {
|
enum FeaturesCommand {
|
||||||
/// Embed rated and published articles, then interests, into the cache.
|
/// 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")?;
|
let config = Config::load(cli.config.as_deref()).context("loading configuration")?;
|
||||||
tracing::debug!(?config.database_path, "configuration loaded");
|
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 {
|
match cli.command {
|
||||||
Command::Generate(args) => {
|
Command::Generate(args) => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
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?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
cmd_explain(&db, args).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) => {
|
Command::Features(command) => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
cmd_features(&config, &db, command).await?;
|
cmd_features(&config, &db, command).await?;
|
||||||
@@ -282,6 +305,24 @@ async fn main() -> Result<()> {
|
|||||||
Ok(())
|
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").
|
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
let filter = EnvFilter::try_from_default_env()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
@@ -343,44 +384,10 @@ fn print_report(report: &RunReport) {
|
|||||||
report.counts.duplicates_merged,
|
report.counts.duplicates_merged,
|
||||||
report.counts.entries_dropped,
|
report.counts.entries_dropped,
|
||||||
);
|
);
|
||||||
println!(
|
// The same four lines the run logged (§15.4).
|
||||||
"curation: {} considered → {} eligible → {} triaged → {} assessed → {} shortlisted → {} selected",
|
for line in report.info_block() {
|
||||||
report.counts.articles,
|
println!("{line}");
|
||||||
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),
|
|
||||||
);
|
|
||||||
println!(
|
println!(
|
||||||
"tokens: {} input · {} cache read · {} cache write · {} output · {} voyage = ${:.4}",
|
"tokens: {} input · {} cache read · {} cache write · {} output · {} voyage = ${:.4}",
|
||||||
report.usage.input_tokens,
|
report.usage.input_tokens,
|
||||||
@@ -391,6 +398,9 @@ fn print_report(report: &RunReport) {
|
|||||||
report.cost_usd,
|
report.cost_usd,
|
||||||
);
|
);
|
||||||
for (provider, usage) in &report.provider_costs {
|
for (provider, usage) in &report.provider_costs {
|
||||||
|
if provider == VOYAGE_PROVIDER {
|
||||||
|
continue; // embedding tokens are printed on their own line below
|
||||||
|
}
|
||||||
println!(
|
println!(
|
||||||
" {provider}: {} input · {} cache read · {} cache write · {} output = ${:.4}",
|
" {provider}: {} input · {} cache read · {} cache write · {} output = ${:.4}",
|
||||||
usage.usage.input_tokens,
|
usage.usage.input_tokens,
|
||||||
@@ -792,6 +802,56 @@ mod tests {
|
|||||||
assert_eq!(cli.config, Some(PathBuf::from("/tmp/x.toml")));
|
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]
|
#[test]
|
||||||
fn parses_explain_and_features() {
|
fn parses_explain_and_features() {
|
||||||
match Cli::try_parse_from([
|
match Cli::try_parse_from([
|
||||||
|
|||||||
+164
-45
@@ -2,9 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
|
//! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
|
||||||
//! ─▶ signals ─▶ triage ─▶ admission ─▶ LLM scoring ─▶ selection
|
//! ─▶ hygiene ─▶ embeddings + signals ─▶ triage ─▶ admission ─▶ deep assessment
|
||||||
//! ─▶ comments ─▶ editorial
|
//! ─▶ utility + shortlist ─▶ editor ─▶ comments ─▶ summaries + brief
|
||||||
//! ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
|
//! ─▶ world briefing ─▶ behind the paper ─▶ EPUB (standard + X4) ─▶ XTC
|
||||||
|
//! ─▶ publish ─▶ report
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Failure policy (notes §3):
|
//! Failure policy (notes §3):
|
||||||
@@ -40,10 +41,10 @@ use crate::db::Db;
|
|||||||
use crate::extract::Extractor;
|
use crate::extract::Extractor;
|
||||||
use crate::miniflux::MinifluxClient;
|
use crate::miniflux::MinifluxClient;
|
||||||
use crate::publish::Published;
|
use crate::publish::Published;
|
||||||
use crate::report::{ProviderUsage, RunReport, RunStatus};
|
use crate::report::{ProviderUsage, RunReport, RunStatus, VOYAGE_PROVIDER};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
Article, ArticleId, Artifact, Candidate, Colophon, Edition, Issue, IssueMeta, Lineup, Models,
|
Article, ArticleId, Artifact, BehindThePaper, Candidate, Colophon, Edition, Issue, IssueMeta,
|
||||||
reading_minutes,
|
Lineup, Models, TokenUsage, reading_minutes,
|
||||||
};
|
};
|
||||||
use crate::{comments, dedupe, epub, http, miniflux, publish, social, world};
|
use crate::{comments, dedupe, epub, http, miniflux, publish, social, world};
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ pub fn build_issue(
|
|||||||
editorial,
|
editorial,
|
||||||
world_briefing,
|
world_briefing,
|
||||||
colophon,
|
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 {
|
let stages = match run_stages(&ctx, window_start, window_end, &mut report).await {
|
||||||
Ok(stages) => {
|
Ok(stages) => {
|
||||||
report.finish(Timestamp::now());
|
report.finish(Timestamp::now());
|
||||||
|
// The once-per-run info block of §15.4.
|
||||||
|
for line in report.info_block() {
|
||||||
|
tracing::info!("{line}");
|
||||||
|
}
|
||||||
stages
|
stages
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -483,23 +489,7 @@ async fn run_stages(
|
|||||||
record_candidates(ctx, &personalized)
|
record_candidates(ctx, &personalized)
|
||||||
.await
|
.await
|
||||||
.context("recording admission telemetry")?;
|
.context("recording admission telemetry")?;
|
||||||
tracing::info!(
|
tracing::debug!(admitted_by = ?admission.admitted_by, "admission complete");
|
||||||
"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),
|
|
||||||
);
|
|
||||||
report.timings.record("admit", elapsed_ms(stage));
|
report.timings.record("admit", elapsed_ms(stage));
|
||||||
|
|
||||||
// --- Stage 9: deep assessment (§12.1) ---
|
// --- 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.counts.discussions = comments::fetch_all(&http, &mut lineup.picks).await as i64;
|
||||||
report.timings.record("comments", elapsed_ms(stage));
|
report.timings.record("comments", elapsed_ms(stage));
|
||||||
|
|
||||||
// --- Stage 9: editorial (§3.6 C) ---
|
// --- Stage 9: editorial — summaries and the Brief (§14) ---
|
||||||
let stage = Timestamp::now();
|
let editorial = match curator.editorial_timed(&lineup).await {
|
||||||
let editorial = match curator.editorial(&lineup).await {
|
Ok((editorial, timings)) => {
|
||||||
Ok(editorial) => editorial,
|
report.timings.record("summaries", timings.summaries_ms);
|
||||||
|
report.timings.record("brief", timings.brief_ms);
|
||||||
|
editorial
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
report.warn(format!(
|
report.warn(format!(
|
||||||
"editorial generation failed; using excerpts: {e:#}"
|
"editorial generation failed; using excerpts: {e:#}"
|
||||||
));
|
));
|
||||||
|
report.timings.record("summaries", 0);
|
||||||
|
report.timings.record("brief", 0);
|
||||||
editorial::fallback_editorial(&lineup)
|
editorial::fallback_editorial(&lineup)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
apply_summaries(&mut lineup, &editorial);
|
apply_summaries(&mut lineup, &editorial);
|
||||||
report.timings.record("editorial", elapsed_ms(stage));
|
|
||||||
|
|
||||||
// --- Stage 10: completed-day World Briefing (§3.8), best effort ---
|
// --- Stage 10: completed-day World Briefing (§3.8), best effort ---
|
||||||
// Editorial retains budget priority; only the remaining metered budget is
|
// Editorial retains budget priority; only the remaining metered budget is
|
||||||
@@ -656,6 +650,19 @@ async fn run_stages(
|
|||||||
cost_usd: editor_meter.cost_usd(),
|
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 {
|
let summary_model = match config.editorial.summary_model {
|
||||||
crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => {
|
crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => {
|
||||||
config.anthropic.model.clone()
|
config.anthropic.model.clone()
|
||||||
@@ -668,30 +675,32 @@ async fn run_stages(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(provider, usage)| (provider.clone(), usage.cost_usd))
|
.map(|(provider, usage)| (provider.clone(), usage.cost_usd))
|
||||||
.collect();
|
.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 {
|
let colophon = Colophon {
|
||||||
provider_costs,
|
provider_costs,
|
||||||
models: Models {
|
models: models.clone(),
|
||||||
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,
|
|
||||||
},
|
|
||||||
entries_fetched: report.counts.entries_fetched,
|
entries_fetched: report.counts.entries_fetched,
|
||||||
feeds_seen: report.counts.feeds_seen,
|
feeds_seen: report.counts.feeds_seen,
|
||||||
candidates: report.counts.candidates,
|
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),
|
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,
|
date,
|
||||||
issue_number,
|
issue_number,
|
||||||
Timestamp::now(),
|
Timestamp::now(),
|
||||||
@@ -700,6 +709,7 @@ async fn run_stages(
|
|||||||
world_briefing,
|
world_briefing,
|
||||||
colophon,
|
colophon,
|
||||||
);
|
);
|
||||||
|
issue.behind = behind;
|
||||||
|
|
||||||
// --- Stage 12: build both EPUB editions (§3.10) — fatal on failure ---
|
// --- Stage 12: build both EPUB editions (§3.10) — fatal on failure ---
|
||||||
let stage = Timestamp::now();
|
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
|
/// The embedding cache with a Voyage client behind it, or cache-only under
|
||||||
/// `--skip-embeddings`, `voyage.enabled = false` or a missing key (§16, §17).
|
/// `--skip-embeddings`, `voyage.enabled = false` or a missing key (§16, §17).
|
||||||
fn build_embedding_service(
|
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.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() {
|
for candidate in candidates.iter_mut() {
|
||||||
candidate.signals = computed
|
candidate.signals = computed
|
||||||
.remove(&candidate.article.id)
|
.remove(&candidate.article.id)
|
||||||
@@ -1013,6 +1070,7 @@ async fn build_llms(
|
|||||||
return Llms::default();
|
return Llms::default();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
report.counts.verdicts_in_prompt = profile.verdicts as i64;
|
||||||
if ctx.skip_llm {
|
if ctx.skip_llm {
|
||||||
tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made");
|
tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made");
|
||||||
return Llms::default();
|
return Llms::default();
|
||||||
@@ -1047,6 +1105,7 @@ async fn build_llms(
|
|||||||
version = rebuilt.version,
|
version = rebuilt.version,
|
||||||
"taste profile rebuilt with editor-or-bulk"
|
"taste profile rebuilt with editor-or-bulk"
|
||||||
);
|
);
|
||||||
|
report.counts.verdicts_in_prompt = rebuilt.verdicts as i64;
|
||||||
llms = make_clients(rebuilt.text);
|
llms = make_clients(rebuilt.text);
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
@@ -1663,6 +1722,66 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(misses.contains("not selected, by utility"), "{misses}");
|
assert!(misses.contains("not selected, by utility"), "{misses}");
|
||||||
assert!(misses.contains("shortlisted, not_selected"), "{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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -752,6 +752,7 @@ mod tests {
|
|||||||
editorial: Editorial::default(),
|
editorial: Editorial::default(),
|
||||||
world_briefing: None,
|
world_briefing: None,
|
||||||
colophon: Colophon::default(),
|
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).
|
/// 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 {
|
pub struct StageCounts {
|
||||||
/// Entries returned by Miniflux inside the lookback window (§3.1).
|
/// Entries returned by Miniflux inside the lookback window (§3.1).
|
||||||
pub entries_fetched: i64,
|
pub entries_fetched: i64,
|
||||||
@@ -73,6 +73,15 @@ pub struct StageCounts {
|
|||||||
pub embedded: i64,
|
pub embedded: i64,
|
||||||
/// Current rated articles with a valid embedding.
|
/// Current rated articles with a valid embedding.
|
||||||
pub rated_with_embeddings: i64,
|
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.
|
/// Articles with a reusable or newly produced triage assessment.
|
||||||
pub triaged: i64,
|
pub triaged: i64,
|
||||||
/// Articles admitted to close reading.
|
/// Articles admitted to close reading.
|
||||||
@@ -97,6 +106,10 @@ pub struct StageCounts {
|
|||||||
pub images_embedded: i64,
|
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.
|
/// Wall-clock milliseconds per pipeline stage.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct StageTimings(pub BTreeMap<String, i64>);
|
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
|
/// Stamp the end time, total provider costs (LLM providers plus Voyage) and
|
||||||
/// settle the status.
|
/// 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) {
|
pub fn finish(&mut self, finished_at: Timestamp) {
|
||||||
self.finished_at = Some(finished_at);
|
self.finished_at = Some(finished_at);
|
||||||
self.usage = TokenUsage::default();
|
self.usage = TokenUsage::default();
|
||||||
self.cost_usd = self.voyage_cost_usd;
|
self.cost_usd = 0.0;
|
||||||
for provider in self.provider_costs.values() {
|
for (provider, usage) in &self.provider_costs {
|
||||||
self.usage.add(provider.usage);
|
self.cost_usd += usage.cost_usd;
|
||||||
self.cost_usd += provider.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 {
|
if self.status == RunStatus::Running {
|
||||||
self.status = if self.warnings.is_empty() {
|
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.
|
/// Feeds ordered by entry count, descending — the M1 dry-run breakdown.
|
||||||
pub fn top_feeds(&self, limit: usize) -> Vec<(&str, i64)> {
|
pub fn top_feeds(&self, limit: usize) -> Vec<(&str, i64)> {
|
||||||
let mut v: Vec<(&str, i64)> = self
|
let mut v: Vec<(&str, i64)> = self
|
||||||
@@ -289,6 +372,69 @@ mod tests {
|
|||||||
// The legacy aggregate columns are the sum across providers.
|
// 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.usage, usage(1_000_100, 1_003_000, 2_000, 1_000_800));
|
||||||
assert_eq!(r.duration_secs(), Some(360));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ pub struct TasteProfile {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
pub version: i64,
|
pub version: i64,
|
||||||
pub built_at: Timestamp,
|
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>,
|
pub world_briefing: Option<WorldBriefing>,
|
||||||
/// Colophon facts: models used, token cost, feed counts (§3.10).
|
/// Colophon facts: models used, token cost, feed counts (§3.10).
|
||||||
pub colophon: Colophon,
|
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).
|
/// Resolved model names printed in the colophon (§15.1).
|
||||||
@@ -584,6 +590,49 @@ pub struct Colophon {
|
|||||||
pub generator_version: String,
|
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).
|
/// A downloaded, re-encoded image embedded in an edition (§3.10 images).
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ImageAsset {
|
pub struct ImageAsset {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ fn standard_edition_is_a_well_formed_epub3_archive() {
|
|||||||
"OEBPS/sec-niche-corner.xhtml",
|
"OEBPS/sec-niche-corner.xhtml",
|
||||||
"OEBPS/art-1002.xhtml",
|
"OEBPS/art-1002.xhtml",
|
||||||
"OEBPS/world.xhtml",
|
"OEBPS/world.xhtml",
|
||||||
|
"OEBPS/behind.xhtml",
|
||||||
"OEBPS/colophon.xhtml",
|
"OEBPS/colophon.xhtml",
|
||||||
] {
|
] {
|
||||||
assert!(contains_entry(&zip, entry), "missing {entry}");
|
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),
|
("art-1002".into(), "art-1002.xhtml".into(), 2),
|
||||||
("world".into(), "world.xhtml".into(), 1),
|
("world".into(), "world.xhtml".into(), 1),
|
||||||
|
("behind".into(), "behind.xhtml".into(), 1),
|
||||||
("colophon".into(), "colophon.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]
|
#[test]
|
||||||
fn every_chapter_is_parseable_xhtml() {
|
fn every_chapter_is_parseable_xhtml() {
|
||||||
let issue = fixtures::issue();
|
let issue = fixtures::issue();
|
||||||
|
|||||||
Reference in New Issue
Block a user