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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 16:21:06 +00:00
co-authored by Claude Fable 5.1
parent 05a74a0dcf
commit d261cd485d
22 changed files with 1583 additions and 113 deletions
+8 -5
View File
@@ -1,7 +1,8 @@
//! Chapter ordering and `epub-builder` assembly (spec §3.10).
//!
//! Structure: cover → From the Editor → In This Issue → sections (title page,
//! article chapters, discussion chapters) → World Briefing → colophon. The
//! Structure: cover → The Brief → In This Issue → sections (title page,
//! article chapters, discussion chapters) → World Briefing → Behind the paper
//! → colophon. The
//! chapters themselves are rendered by [`super::chapters`] and the cover by
//! [`super::cover`]; this module decides the order and zips the result.
@@ -14,8 +15,8 @@ use crate::types::{Edition, ImageAsset, Issue};
use super::EpubError;
use super::chapters::{
render_colophon, render_front_page, render_in_this_issue, render_section_page,
render_world_briefing, section_names,
render_behind_the_paper, render_colophon, render_front_page, render_in_this_issue,
render_section_page, render_world_briefing, section_names,
};
use super::cover::{CoverAsset, render_cover_page};
use super::x4;
@@ -85,6 +86,7 @@ pub fn render_all(
if let Some(world) = render_world_briefing(issue)? {
chapters.push(world);
}
chapters.push(render_behind_the_paper(issue)?);
chapters.push(render_colophon(issue)?);
if edition == Edition::X4 {
@@ -272,11 +274,12 @@ mod tests {
"sec-Niche Corner",
"art-1002",
"world",
"behind",
"colophon",
]
);
let levels: Vec<u8> = chapters.iter().map(|c| c.toc_level).collect();
assert_eq!(levels, vec![1, 1, 1, 1, 2, 3, 1, 2, 1, 1]);
assert_eq!(levels, vec![1, 1, 1, 1, 2, 3, 1, 2, 1, 1, 1]);
for chapter in &chapters {
assert_xml_ok(&chapter.xhtml);
}
+203 -1
View File
@@ -10,7 +10,10 @@ use askama::Template;
use crate::comments;
use crate::html::{text_escape, to_xhtml};
use crate::images;
use crate::types::{Edition, ImageAsset, Issue, Pick, SocialRef, Vote, WORLD_BRIEFING_SECTION};
use crate::types::{
BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote,
WORLD_BRIEFING_SECTION,
};
use crate::world;
use super::EpubError;
@@ -102,6 +105,17 @@ struct WorldBriefingChapter {
body_html: String,
}
#[derive(Template)]
#[template(path = "behind.xhtml", escape = "html")]
struct BehindChapter {
title: String,
summary_line: String,
admitted_line: String,
learned_line: String,
near_misses: Vec<String>,
models_line: String,
}
#[derive(Template)]
#[template(path = "colophon.xhtml", escape = "html")]
struct ColophonChapter {
@@ -435,6 +449,131 @@ pub fn render_world_briefing(issue: &Issue) -> Result<Option<Chapter>, EpubError
}))
}
/// "1,465" — thousands separators for the counts in Behind the paper.
fn thousands(n: i64) -> String {
let digits = n.abs().to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
if n < 0 { format!("-{out}") } else { out }
}
/// `Considered 412 articles from 1,465 feeds · 398 eligible · …` (§15.1).
pub fn behind_summary_line(b: &BehindThePaper) -> String {
format!(
"Considered {} articles from {} feeds \u{00b7} {} eligible \u{00b7} {} triaged \u{00b7} {} read closely \u{00b7} {} shortlisted \u{00b7} {} selected.",
thousands(b.considered),
thousands(b.feeds_seen),
thousands(b.eligible),
thousands(b.triaged),
thousands(b.read_closely),
thousands(b.shortlisted),
thousands(b.selected),
)
}
/// `Admitted via: triage 60 · interests 20 · your ratings 12 · exploration 5 · blend 23.`
pub fn behind_admitted_line(b: &BehindThePaper) -> String {
let count = |name: &str| b.admitted_by.get(name).copied().unwrap_or(0);
let mut parts = vec![
format!("triage {}", count("triage")),
format!("interests {}", count("interest")),
format!("your ratings {}", count("knn")),
format!("exploration {}", count("exploration")),
format!("blend {}", count("blend")),
];
if count("auto_include") > 0 {
parts.push(format!("always-include {}", count("auto_include")));
}
format!("Admitted via: {}.", parts.join(" \u{00b7} "))
}
/// `Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off.`
pub fn behind_learned_line(b: &BehindThePaper) -> String {
let percent = |gate: f64| (gate.clamp(0.0, 1.0) * 100.0).round() as i64;
let neighbour = if b.knn_gate > 0.0 {
format!("neighbour signal at {}%", percent(b.knn_gate))
} else {
"neighbour signal off".to_string()
};
let feed = if b.feed_gate > 0.0 {
format!("feed affinity at {}%", percent(b.feed_gate))
} else {
"feed affinity off".to_string()
};
format!(
"Learned signals: {} rated articles with embeddings ({neighbour}); {feed}.",
thousands(b.rated_with_embeddings)
)
}
/// `<title> — <feed> · quality 8.0 · fit 6.5 · shortlisted, not selected`.
pub fn behind_near_miss_line(miss: &NearMiss) -> String {
let mut parts = vec![format!("{} \u{2014} {}", miss.title, miss.feed_title)];
if let Some(quality) = miss.quality {
parts.push(format!("quality {quality:.1}"));
}
if let Some(fit) = miss.fit {
parts.push(format!("fit {fit:.1}"));
}
parts.push(match &miss.reason {
Some(reason) => format!("{}, {}", miss.stage, reason.replace('_', " ")),
None => miss.stage.clone(),
});
parts.join(" \u{00b7} ")
}
/// `Models: triage and assessment … · editor and summaries … · embeddings ….
/// Cost $0.81. Generation 23 min.`
pub fn behind_models_line(b: &BehindThePaper) -> String {
let editorial = if b.models.summaries == b.models.editor {
format!("editor and summaries {}", b.models.editor)
} else {
format!(
"editor {} \u{00b7} summaries {}",
b.models.editor, b.models.summaries
)
};
let generation = if b.generation_secs < 60 {
format!("{} s", b.generation_secs.max(0))
} else {
format!("{} min", (b.generation_secs as f64 / 60.0).round() as i64)
};
format!(
"Models: triage and assessment {} \u{00b7} {editorial} \u{00b7} embeddings {}. Cost ${:.2}. Generation {generation}.",
b.models.bulk, b.embedding_model, b.cost_usd
)
}
/// "Behind the paper": the run's counts, admission mix, learned-signal state,
/// near misses and models (§15.1). Same text in both editions; no links.
pub fn render_behind_the_paper(issue: &Issue) -> Result<Chapter, EpubError> {
let behind = &issue.behind;
let tpl = BehindChapter {
title: "Behind the paper".into(),
summary_line: behind_summary_line(behind),
admitted_line: behind_admitted_line(behind),
learned_line: behind_learned_line(behind),
near_misses: behind
.near_misses
.iter()
.map(behind_near_miss_line)
.collect(),
models_line: behind_models_line(behind),
};
Ok(Chapter {
id: "behind".into(),
href: "behind.xhtml".into(),
title: "Behind the paper".into(),
xhtml: tpl.render()?,
toc_level: 1,
})
}
/// Colophon: generation timestamp, models used, token cost, feed counts (§3.10).
pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> {
let colophon = &issue.colophon;
@@ -676,6 +815,69 @@ mod tests {
assert!(render_discussion(&issue.lineup.picks[1]).unwrap().is_none());
}
#[test]
fn behind_the_paper_lines_follow_the_plan_shape() {
let issue = issue();
let behind = &issue.behind;
assert_eq!(
behind_summary_line(behind),
"Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected."
);
assert_eq!(
behind_admitted_line(behind),
"Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23."
);
assert_eq!(
behind_learned_line(behind),
"Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off."
);
assert_eq!(
behind_near_miss_line(&behind.near_misses[0]),
"The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected"
);
assert_eq!(
behind_near_miss_line(&behind.near_misses[1]),
"Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted"
);
assert_eq!(
behind_models_line(behind),
"Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min."
);
let chapter = render_behind_the_paper(&issue).unwrap();
assert_eq!(chapter.id, "behind");
assert_eq!(chapter.href, "behind.xhtml");
assert!(chapter.xhtml.contains("Behind the paper"));
assert!(chapter.xhtml.contains("1,465 feeds"));
assert!(chapter.xhtml.contains("The One That Got Away"));
assert!(!chapter.xhtml.contains("<a "), "no links in the chapter");
assert_xml_ok(&chapter.xhtml);
// Auto-includes are named only when there were any; a short run is in
// seconds; a differing summaries model is listed separately.
let mut short = behind.clone();
short.admitted_by.insert("auto_include".into(), 2);
short.generation_secs = 48;
short.feed_gate = 0.6;
short.models.summaries = "deepseek-v4-flash".into();
short.near_misses.clear();
assert!(behind_admitted_line(&short).ends_with("blend 23 \u{00b7} always-include 2."));
assert!(behind_learned_line(&short).ends_with("feed affinity at 60%."));
assert!(
behind_models_line(&short)
.contains("editor claude-opus-5 \u{00b7} summaries deepseek-v4-flash")
);
assert!(behind_models_line(&short).ends_with("Generation 48 s."));
let mut issue = issue;
issue.behind = short;
let chapter = render_behind_the_paper(&issue).unwrap();
assert!(chapter.xhtml.contains("None recorded"));
assert_xml_ok(&chapter.xhtml);
assert_eq!(thousands(0), "0");
assert_eq!(thousands(999), "999");
assert_eq!(thousands(1_000), "1,000");
assert_eq!(thousands(1_234_567), "1,234,567");
}
#[test]
fn world_and_colophon_chapters_render() {
let issue = issue();
+47
View File
@@ -155,6 +155,53 @@ pub fn issue() -> Issue {
cost_usd: 0.0731,
generator_version: "daily-epub 0.1.0".into(),
},
behind: BehindThePaper {
considered: 412,
feeds_seen: 1465,
eligible: 398,
triaged: 398,
read_closely: 120,
shortlisted: 60,
selected: 2,
admitted_by: BTreeMap::from([
("triage".to_string(), 60),
("interest".to_string(), 20),
("knn".to_string(), 12),
("exploration".to_string(), 5),
("blend".to_string(), 23),
]),
rated_with_embeddings: 14,
knn_gate: 0.35,
feed_gate: 0.0,
near_misses: vec![
NearMiss {
article_id: 3,
title: "The One That Got Away".into(),
feed_title: "Example Feed".into(),
quality: Some(8.0),
fit: Some(6.5),
stage: "shortlisted".into(),
reason: Some("not_selected".into()),
},
NearMiss {
article_id: 4,
title: "Never Read Closely".into(),
feed_title: "Other Feed".into(),
quality: None,
fit: None,
stage: "triaged".into(),
reason: Some("not_admitted".into()),
},
],
models: Models {
bulk: "deepseek-v4-flash".into(),
editor: "claude-opus-5".into(),
summaries: "claude-opus-5".into(),
},
embedding_model: "voyage-4-lite".into(),
cost_usd: 0.81,
generation_secs: 23 * 60 + 12,
},
}
}
+2
View File
@@ -23,6 +23,7 @@ pub const CHAPTER_ORDER: &[&str] = &[
"in-this-issue",
"sections",
"world-briefing",
"behind-the-paper",
"colophon",
];
@@ -192,6 +193,7 @@ mod tests {
"OEBPS/disc-1001.xhtml",
"OEBPS/art-1002.xhtml",
"OEBPS/world.xhtml",
"OEBPS/behind.xhtml",
"OEBPS/colophon.xhtml",
] {
assert!(
+3 -2
View File
@@ -8,12 +8,13 @@ the crate root points askama here (`dirs = ["src/epub/templates"]`).
|---|---|---|
| `base.xhtml` | — | Shared XHTML skeleton (`{% block body_class %}`, `{% block content %}`) |
| `cover_page.xhtml` | `CoverPage` | Page that displays the rasterized cover image |
| `front_page.xhtml` | `FrontPage` | "From the Editor" + issue stats line |
| `front_page.xhtml` | `FrontPage` | "The Brief" + issue stats line |
| `in_this_issue.xhtml` | `InThisIssue` | Introduction chapter: per-section linked index |
| `section.xhtml` | `SectionPage` | Section title page + LLM intro |
| `section.xhtml` | `SectionPage` | Section title page (name only) |
| `chapter.xhtml` | `ArticleChapter` | Article: header, body, rating/read-online footer |
| `discussion.xhtml` | `DiscussionChapter` | Comment chapter (§3.7); body from `comments::render_xhtml` |
| `world_briefing.xhtml` | `WorldBriefingChapter` | Wikipedia Current Events (§3.8), body from `world::render_xhtml` |
| `behind.xhtml` | `BehindChapter` | "Behind the paper": run counts, admission mix, near misses, models (§15.1) |
| `colophon.xhtml` | `ColophonChapter` | Back matter: models, cost, counts |
| `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia |
| `style.css` | — | Standard-edition stylesheet, embedded as `stylesheet.css` |
+20
View File
@@ -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 %}