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