Drop the editor's "Why it's here" line

After a rewrite of the prompt rule and a few more live issues the line still
read as a second abstract, so it goes entirely: the rule and the "why" key in
the editor's JSON, the parsing and 14-word cap, the Pick field, the
issue_articles and candidate_runs writes, the Brief prompt's per-pick why,
the explain output, the dashboard "editor" columns, and the rendering on the
web index, article and public pages, the Atom feed and both EPUB pages.

The "Matches: …" interests line stays (restyled in the next commit). The
issue_articles.why and candidate_runs.editor_why columns remain in the
schema unused; stored issue snapshots that still carry "why" deserialize
because Pick ignores unknown fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESJbbzQ1b8fs5AA65tKw2q
This commit is contained in:
2026-09-15 00:59:13 +00:00
co-authored by Claude Fable 5.1
parent cd2bbe8840
commit 36424784af
30 changed files with 57 additions and 245 deletions
+5 -5
View File
@@ -8,8 +8,8 @@ the articles, enriches them with HackerNews/Lobsters/Reddit social proof, has
DeepSeek triage every eligible opening and closely assess a 120-article union,
then utility-ranks and diversity-caps a 60-item shortlist for Claude Opus 5 — the editor —
which assembles the issue
(no minimum size, a hard ceiling), writes a one-line *why* under every headline,
the summaries and *The Brief*. It assembles two EPUB editions (a standard one
(no minimum size, a hard ceiling), then writes the summaries and *The Brief*.
It assembles two EPUB editions (a standard one
and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and
publishes the lot over its own OPDS catalog — which doubles as a
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
@@ -207,8 +207,8 @@ the database or takes the lock, so it is safe to run next to a live `generate`.
## Web site and dashboard
The server is both the public newspaper index and the private operator UI. An
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
lines and outbound comment links; article bodies, the Brief, the World Briefing
anonymous visitor sees titles, authors, sources, metadata, AI summaries and
outbound comment links; article bodies, the Brief, the World Briefing
and comments stay private. A signed-in `user`
sees complete issues and article chapters and can download available formats
from a single download menu. An
@@ -855,7 +855,7 @@ epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
# 4. Now with the bulk and editor providers, still not publishing
daily-epub generate --dry-run --out ./out --max-articles 6
# → check the lineup is sane (at most 6 picks, each with a "why" line) and the
# → check the lineup is sane (at most 6 picks) and the
# printed per-provider cost is well under $1
# 5. Full live run
+2 -2
View File
@@ -212,7 +212,7 @@ de generate --dry-run --out /var/lib/daily-epub/out-check
The dry run makes real DeepSeek, Claude and Voyage calls but publishes nothing and writes no
`issues` row. Read the printed lineup and the four report lines (`curation:`, `admission:`,
`preference:`, `providers:`); the cost should be well under $1. Then read the EPUB it wrote
(Calibre or KOReader): The Brief, the `Why it's here` line under each headline, and the new
(Calibre or KOReader): The Brief and the new
Behind-the-paper chapter before the colophon. Finally:
```sh
@@ -261,7 +261,7 @@ content filter (those are bisected, retried once on the editor provider, and the
`provider_rejected` so they are not retried daily). The `triage:` and `assess:` log lines say how
many were reused versus requested. The bulk-side cost of the second run is therefore small; the
editor, summaries and the Brief are the real spend. Compare the two
lineups, the `why` lines and the Brief side by side, and the `providers:` cost line. To switch
lineups and the Brief side by side, and the `providers:` cost line. To switch
for good, set `editor = "gemini"` in `[llm]` (and `summary_model` stays `editor`, so summaries
move with it). The same trick works for the bulk role: `DAILY_EPUB_LLM__BULK=gemini`.
-1
View File
@@ -370,7 +370,6 @@ fn pick_for(target: &Target, content_html: String) -> Pick {
section: target.issue.clone(),
position: 0,
is_lead: false,
why: None,
summary: None,
llm: None,
top_interests: Vec::new(),
+5 -16
View File
@@ -29,14 +29,13 @@ use jiff::civil::Date;
const ADMIN_PASSWORD: &str = "adminpassword123";
const READER_PASSWORD: &str = "readerpassword123";
/// (section, title, feed, summary, why, words)
const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
/// (section, title, feed, summary, words)
const STORIES: &[(&str, &str, &str, &str, i64)] = &[
(
"Top Stories",
"How a Forty-Year-Old Filesystem Quietly Rewrote Its Write Path",
"Systems Weekly",
"A long, careful account of the redesign, with the benchmarks that justified it and the two regressions that nearly sank it.",
"The systems story with enough operational detail to matter to you",
3100,
),
(
@@ -44,7 +43,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"The Case Against Feature Flags",
"Alice on Software",
"Argues that flags outlive their purpose and proposes a retirement discipline, with examples from three codebases.",
"A contrarian take on tooling you use every day",
1850,
),
(
@@ -52,7 +50,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"What the Latest Battery Chemistry Actually Changes",
"Ars Technica",
"Separates the press-release claims from the measurable improvements in energy density and cycle life.",
"You keep an eye on energy storage; this one is unusually sober",
2200,
),
(
@@ -60,7 +57,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"A Field Guide to Distributed Consensus, Told Through One Outage",
"The Morning Paper",
"Walks through a real incident to explain leader election, log replication and why the fix was a config change.",
"Distributed systems explained with an actual outage rather than diagrams",
4200,
),
(
@@ -68,7 +64,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"Why Every Map Is a Lie & How Cartographers Choose Which One to Tell",
"Longreads",
"A history of projections and the politics behind them, from Mercator to the maps in your phone.",
"Long-form nonfiction outside the technical orbit you asked for",
5100,
),
(
@@ -76,7 +71,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"Notes on Writing a Rust Linter That People Actually Enable",
"Rust Blog",
"Design notes on false-positive budgets, fix suggestions and the social side of shipping a lint.",
"Rust tooling with a practical bent",
1600,
),
(
@@ -84,7 +78,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"The Terminal Is the Best UI We Have and It Is Getting Better",
"Julia's Notebook",
"A tour of modern terminal features (hyperlinks, images, synchronized output) and which tools use them.",
"Terminal ergonomics, one of your recurring interests",
1300,
),
(
@@ -92,7 +85,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"A Small-Town Bakery's Sourdough Starter Turns One Hundred",
"Saveur",
"A charming profile of a starter kept alive across four generations and the bread it still makes.",
"A small-scene delight outside the usual technical orbit",
900,
),
(
@@ -100,15 +92,14 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
"Inside the Community Keeping 1990s Synthesizers Alive",
"Sound on Sound",
"Repair collectives, replacement parts and the odd economics of vintage gear.",
"Music hardware, for the weekend",
1700,
),
];
fn story_article(index: usize, story: &(&str, &str, &str, &str, &str, i64)) -> Article {
fn story_article(index: usize, story: &(&str, &str, &str, &str, i64)) -> Article {
let id = index as i64 + 1;
let entry_id = 1000 + id;
let (_, title, feed, _, _, words) = *story;
let (_, title, feed, _, words) = *story;
let mut article = fixtures::article(id, entry_id, title);
article.feed_title = feed.to_string();
article.feed_id = 7 + index as i64;
@@ -167,7 +158,7 @@ fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue {
let mut picks = Vec::new();
let mut summaries = BTreeMap::new();
for (index, story) in STORIES.iter().enumerate() {
let (section, _, _, summary, why, _) = *story;
let (section, _, _, summary, _) = *story;
if !section_order.iter().any(|s| s == section) {
section_order.push(section.to_string());
}
@@ -237,7 +228,6 @@ fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue {
section: section.to_string(),
position,
is_lead: index == 0,
why: Some(why.to_string()),
summary: Some(summary.to_string()),
llm,
top_interests,
@@ -418,7 +408,6 @@ async fn seed_near_misses(db: &Db, run_id: i64) -> anyhow::Result<()> {
rank_utility: Some(index + 10),
cluster_id: Some(index),
cluster_rank: Some(1),
editor_why: None,
},
)
.await?;
+2 -76
View File
@@ -19,8 +19,6 @@ pub struct SelectionItem {
pub position: i64,
#[serde(default)]
pub lead_story: bool,
#[serde(default)]
pub why: Option<String>,
}
pub const EDITOR_INSTRUCTIONS: &str = r#"TASK: assemble today's issue of The Daily EPUB from the shortlist below.
@@ -39,16 +37,6 @@ RULES
6. Never select two articles that tell the same story.
7. SIZE: aim for about {soft_target}; never more than {hard_max}; there is NO minimum.
If only nine pieces deserve the reader's morning, publish nine. Never pad.
8. For every pick write "why": at most 14 words. It is printed under the headline
as "Why it's here", above the summary, with the matched interests listed right
beneath it. So it must NOT describe the piece (the summary does that) and must
NOT just list interests (already shown). Say why THIS reader gets THIS piece
today: the itch it scratches, the rated piece it rhymes with, the argument he
will want to pick, the gap in today's paper it fills, or the exploration bet it
is. Second person is fine.
Bad: "A candid Rust query engine post-mortem showing why io_uring lost to mmap."
Good: "The io_uring-versus-mmap verdict you'd want before touching your own engine."
EDITORIAL JUDGEMENT
- Depth over coverage. Drop anything you would not defend to him in person.
- Diversity is a feature: do not let one subject, one format, or one feed dominate,
@@ -62,7 +50,7 @@ EDITORIAL JUDGEMENT
- Scores are evidence, not instructions. Overrule them when the paper reads better.
Return JSON exactly:
{"picks": [{"id": 123, "section": "Top Stories", "position": 1, "lead_story": true, "why": "…"}]}"#;
{"picks": [{"id": 123, "section": "Top Stories", "position": 1, "lead_story": true}]}"#;
pub fn build_prompt(
shortlist: &[Candidate],
@@ -398,16 +386,6 @@ pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
})
})
.unwrap_or(false),
why: object
.get("why")
.and_then(serde_json::Value::as_str)
.map(|why| {
why.split_whitespace()
.take(14)
.collect::<Vec<_>>()
.join(" ")
})
.filter(|why| !why.is_empty()),
})
})
.collect()
@@ -483,7 +461,6 @@ pub async fn select(
section: "From the Blogroll".into(),
position: i64::MAX,
lead_story: false,
why: Some("A standing source you always want represented".into()),
},
candidate.clone(),
));
@@ -592,7 +569,6 @@ fn assemble(
section: item.section,
position: *position,
is_lead: Some(item.id) == lead_id,
why: item.why,
summary: None,
llm: candidate.assessment.deep,
top_interests: candidate
@@ -639,7 +615,6 @@ pub fn select_without_llm(
section: heuristic_section(&candidate, sections),
position: chosen.len() as i64 + 1,
lead_story: false,
why: None,
},
candidate,
));
@@ -742,7 +717,7 @@ mod tests {
let picks: Vec<String> = (1..=n)
.map(|i| {
format!(
r#"{{"id":{i},"section":"Top Stories","position":{i},"lead_story":{},"why":"pick {i} because"}}"#,
r#"{{"id":{i},"section":"Top Stories","position":{i},"lead_story":{}}}"#,
i == 1
)
})
@@ -809,34 +784,10 @@ mod tests {
assert!(items[0].lead_story);
assert_eq!(items[0].section, "Top Stories");
assert_eq!(items.iter().filter(|i| i.lead_story).count(), 1);
assert!(items[0].why.as_deref().is_some_and(|w| !w.is_empty()));
// Junk entries in the fixture are dropped, not fatal.
assert!(items.iter().all(|i| i.id != 0));
}
#[test]
fn why_lines_are_optional_and_capped_at_fourteen_words() {
let long = (1..=30)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" ");
let items = parse_selection_response(&format!(
r#"{{"picks":[{{"id":1,"section":"Top Stories","why":"{long}"}},
{{"id":2,"section":"Top Stories","why":" "}},
{{"id":3,"section":"Top Stories"}}]}}"#
));
assert_eq!(items.len(), 3);
assert_eq!(
items[0]
.why
.as_deref()
.map(|w| w.split_whitespace().count()),
Some(14)
);
assert!(items[1].why.is_none());
assert!(items[2].why.is_none());
}
#[test]
fn the_prompt_substitutes_the_size_targets_and_renders_the_shortlist() {
let prompt = build_prompt(&candidates(3), &sections(), 6, 11);
@@ -963,29 +914,6 @@ mod tests {
assert!(prompt.contains("aim for about 6; never more than 11"));
}
#[tokio::test]
async fn why_lines_land_on_picks() {
let backend = Arc::new(MockBackend::new());
backend.push(picks_json(3), TokenUsage::default());
let lineup = select(
&bulk_only(backend),
candidates(5),
&sections(),
3,
5,
date(),
)
.await
.expect("selection");
assert_eq!(lineup.picks.len(), 3);
for pick in &lineup.picks {
assert_eq!(
pick.why.as_deref(),
Some(format!("pick {} because", pick.article.id).as_str())
);
}
}
#[tokio::test]
async fn hallucinated_ids_and_missing_leads_are_repaired() {
let backend = Arc::new(MockBackend::new());
@@ -1086,7 +1014,6 @@ mod tests {
.find(|p| p.article.id == 30)
.expect("reinserted");
assert_eq!(reinserted.section, "From the Blogroll");
assert!(reinserted.why.is_some());
}
#[tokio::test]
@@ -1195,7 +1122,6 @@ mod tests {
for pick in &lineup.picks {
assert!(sections().contains(&pick.section));
assert!(pick.summary.is_none());
assert!(pick.why.is_none());
}
assert!(!lineup.section_order.is_empty());
}
+1 -4
View File
@@ -275,10 +275,9 @@ pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap<ArticleId, Strin
.unwrap_or_else(|| excerpt_summary(pick));
let _ = writeln!(
prompt,
"- {}\n feed: {}\n why: {}\n quality: {}\n fit: {}\n summary: {}",
"- {}\n feed: {}\n quality: {}\n fit: {}\n summary: {}",
pick.article.title.trim(),
pick.article.feed_title.trim(),
pick.why.as_deref().unwrap_or("not supplied"),
quality,
fit,
summary
@@ -459,7 +458,6 @@ mod tests {
section: section.into(),
position: 1,
is_lead,
why: Some(format!("the {title} piece you'd argue with")),
summary: None,
llm: None,
top_interests: Vec::new(),
@@ -635,7 +633,6 @@ mod tests {
assert!(prompt.contains("## Top Stories"));
assert!(prompt.contains("## Boston & Local"));
assert!(prompt.contains("- Migrating 40TB off Postgres"));
assert!(prompt.contains("why: the Migrating 40TB off Postgres piece you'd argue with"));
assert!(prompt.contains("summary: A migration story with numbers."));
assert!(prompt.contains("quality: unassessed"));
assert!(prompt.contains("fit: unassessed"));
+8 -26
View File
@@ -2,7 +2,7 @@
//! the `explain` command and feature retention (plan §7.4–7.5, §15.2, §16).
//!
//! One row per considered article per run says where it stopped and why. Rows
//! are upserted on every stage transition with every column set (never
//! are upserted on every stage transition with every active field set (never
//! `COALESCE`), so the last write for a run is the whole truth.
use std::collections::BTreeMap;
@@ -45,16 +45,15 @@ pub struct CandidateRun<'a> {
pub rank_utility: Option<i64>,
pub cluster_id: Option<i64>,
pub cluster_rank: Option<i64>,
pub editor_why: Option<&'a str>,
}
/// Upsert one row, setting every column (§7.4).
/// Upsert one row, setting every active telemetry field (§7.4).
pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO candidate_runs
(run_id, article_id, stage, excluded_reason, admitted_by, signals_json,
utility, rank_utility, cluster_id, cluster_rank, editor_why)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
utility, rank_utility, cluster_id, cluster_rank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id, article_id) DO UPDATE SET
stage = excluded.stage,
excluded_reason = excluded.excluded_reason,
@@ -63,8 +62,7 @@ pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
utility = excluded.utility,
rank_utility = excluded.rank_utility,
cluster_id = excluded.cluster_id,
cluster_rank = excluded.cluster_rank,
editor_why = excluded.editor_why",
cluster_rank = excluded.cluster_rank",
)
.bind(row.run_id)
.bind(row.article_id)
@@ -76,7 +74,6 @@ pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
.bind(row.rank_utility)
.bind(row.cluster_id)
.bind(row.cluster_rank)
.bind(row.editor_why)
.execute(db.pool())
.await?;
Ok(())
@@ -103,7 +100,6 @@ pub async fn thin_excluded(
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -236,7 +232,6 @@ pub struct ExplainRow {
pub rank_utility: Option<i64>,
pub cluster_id: Option<i64>,
pub cluster_rank: Option<i64>,
pub editor_why: Option<String>,
}
impl ExplainRow {
@@ -254,7 +249,6 @@ impl ExplainRow {
rank_utility: row.get("rank_utility"),
cluster_id: row.get("cluster_id"),
cluster_rank: row.get("cluster_rank"),
editor_why: row.get("editor_why"),
}
}
@@ -306,7 +300,7 @@ pub async fn explain_row(
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title,
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank
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 = ?",
@@ -329,7 +323,7 @@ pub async fn near_misses(
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title,
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank
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'",
@@ -490,9 +484,6 @@ pub async fn render_explain(db: &Db, row: &ExplainRow) -> Result<String, sqlx::E
if let Some(admitted_by) = &row.admitted_by {
let _ = writeln!(out, "admitted by: {admitted_by}");
}
if let Some(why) = &row.editor_why {
let _ = writeln!(out, "editor: {why}");
}
Ok(out)
}
@@ -1255,7 +1246,6 @@ mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -1289,7 +1279,7 @@ mod tests {
}
#[tokio::test]
async fn rows_are_upserted_with_every_column_replaced() {
async fn rows_are_upserted_with_every_active_field_replaced() {
let (_dir, db) = db_with_articles(&[1]).await;
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
write(
@@ -1305,7 +1295,6 @@ mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -1323,7 +1312,6 @@ mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: Some("because"),
},
)
.await
@@ -1333,7 +1321,6 @@ mod tests {
assert_eq!(row.excluded_reason, None, "no COALESCE");
assert_eq!(row.utility, None);
assert_eq!(row.admitted_by.as_deref(), Some("[\"prefilter\"]"));
assert_eq!(row.editor_why.as_deref(), Some("because"));
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM candidate_runs")
.fetch_one(db.pool())
.await
@@ -1360,7 +1347,6 @@ mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -1493,7 +1479,6 @@ mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -1582,7 +1567,6 @@ mod tests {
rank_utility: Some(3),
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -1600,7 +1584,6 @@ mod tests {
rank_utility: Some(1),
cluster_id: None,
cluster_rank: None,
editor_why: Some("because"),
},
)
.await
@@ -1697,7 +1680,6 @@ mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
+2 -3
View File
@@ -606,8 +606,8 @@ impl Db {
.await?;
for pick in picks {
sqlx::query(
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary, why)
VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(date.to_string())
.bind(pick.article.id)
@@ -615,7 +615,6 @@ impl Db {
.bind(pick.position)
.bind(pick.is_lead)
.bind(pick.summary.as_deref())
.bind(pick.why.as_deref())
.execute(&mut *tx)
.await?;
}
-5
View File
@@ -42,7 +42,6 @@ struct IndexEntry {
source: String,
reading_minutes: i64,
summary: String,
why: Option<String>,
understanding: Understanding,
}
@@ -82,7 +81,6 @@ struct ArticleChapter {
meta_line: String,
social_line: Option<String>,
understanding: Understanding,
why: Option<String>,
summary: Option<String>,
excerpt_only: bool,
body_html: String,
@@ -411,7 +409,6 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
source: index_source_line(&pick.article),
reading_minutes: pick.article.reading_minutes(),
summary: summary_for(issue, pick).unwrap_or_default().to_string(),
why: pick.why.clone(),
understanding: understanding(pick),
})
.collect();
@@ -426,7 +423,6 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
source: "Wikipedia Current Events".into(),
reading_minutes: 3,
summary: "The day's events, as recorded by the Current Events portal.".into(),
why: None,
understanding: Understanding::default(),
}],
});
@@ -504,7 +500,6 @@ pub fn render_article(
meta_line: meta_parts.join(" \u{00b7} "),
social_line: social_line(&article.social),
understanding: understanding(pick),
why: pick.why.clone(),
summary: summary_for(issue, pick).map(str::to_string),
excerpt_only: article.excerpt_only,
body_html: prepare_body(&article.content_html, images_),
-2
View File
@@ -87,7 +87,6 @@ pub fn issue() -> Issue {
section: "Top Stories".into(),
position: 0,
is_lead: true,
why: Some("The systems story with enough operational detail to matter".into()),
summary: Some("What it argues, and why it is worth the time.".into()),
llm: Some(Deep {
quality: 9.0,
@@ -115,7 +114,6 @@ pub fn issue() -> Issue {
section: "Niche Corner".into(),
position: 0,
is_lead: false,
why: Some("A small-scene delight outside the usual technical orbit".into()),
summary: None,
llm: None,
top_interests: Vec::new(),
+1 -4
View File
@@ -10,11 +10,8 @@
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}
<p class="rubric">{% if let Some(kicker) = understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
{% endif %}
{% if why.is_some() || !understanding.interests.is_empty() %}
{% if !understanding.interests.is_empty() %}
<div class="why">
{% if let Some(text) = why %}
<p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
{% if !understanding.interests.is_empty() %}
<p class="why-matches">Matches: {{ understanding.interests_line() }}</p>
{% endif %}
+1 -4
View File
@@ -13,11 +13,8 @@
{% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %}
<p class="rubric">{% if let Some(kicker) = entry.understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = entry.understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
{% endif %}
{% if entry.why.is_some() || !entry.understanding.interests.is_empty() %}
{% if !entry.understanding.interests.is_empty() %}
<div class="index-why">
{% if let Some(text) = entry.why %}
<p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
{% if !entry.understanding.interests.is_empty() %}
<p class="why-matches">Matches: {{ entry.understanding.interests_line() }}</p>
{% endif %}
-4
View File
@@ -101,10 +101,6 @@ img {
font-size: 0.9em;
}
.why-line {
margin: 0;
}
.why-matches {
margin: 0;
font-size: 0.85em;
-4
View File
@@ -166,10 +166,6 @@ hr.rule {
font-size: 0.9em;
}
.why-line {
margin: 0;
}
.why-matches {
margin: 0.1em 0 0 0;
font-size: 0.85em;
+1 -18
View File
@@ -648,27 +648,19 @@ async fn run_stages(
.copied()
.filter(|id| !selected_set.contains(id))
.collect::<Vec<_>>();
// The editor's one-line `why` (§13) lands in `candidate_runs.editor_why` so
// `explain` can quote it; heuristic picks leave it NULL.
let selected_with_why = lineup
.picks
.iter()
.map(|pick| (pick.article.id, pick.why.as_deref()))
.collect::<Vec<_>>();
set_candidate_stage(
&mut personalized,
&not_selected,
"shortlisted",
Some("not_selected"),
);
let why = selected_with_why.into_iter().collect::<HashMap<_, _>>();
for candidate in &mut personalized {
if selected_set.contains(&candidate.article.id) {
candidate.stage = "selected".into();
candidate.excluded_reason = None;
}
}
record_candidates_with_why(ctx, &personalized, &why)
record_candidates(ctx, &personalized)
.await
.context("recording selection telemetry")?;
report.counts.exploration_selected = personalized
@@ -1039,14 +1031,6 @@ async fn prepare_features(
}
async fn record_candidates(ctx: &StageContext<'_>, candidates: &[Candidate]) -> Result<()> {
record_candidates_with_why(ctx, candidates, &HashMap::new()).await
}
async fn record_candidates_with_why(
ctx: &StageContext<'_>,
candidates: &[Candidate],
editor_why: &HashMap<ArticleId, Option<&str>>,
) -> Result<()> {
for candidate in candidates {
let json = telemetry::serialize_candidate(candidate);
let admitted_by = if candidate.admitted_by.is_empty() {
@@ -1067,7 +1051,6 @@ async fn record_candidates_with_why(
rank_utility: candidate.rank_utility,
cluster_id: candidate.cluster,
cluster_rank: candidate.cluster_rank,
editor_why: editor_why.get(&candidate.article.id).copied().flatten(),
},
)
.await
-2
View File
@@ -372,8 +372,6 @@ pub struct Pick {
/// Order within the section, ascending.
pub position: i64,
pub is_lead: bool,
/// Editor-written reason, at most 14 words (§13).
pub why: Option<String>,
/// Newspaper-abstract summary from stage C; `None` until editorial runs.
pub summary: Option<String>,
pub llm: Option<Deep>,
+1 -4
View File
@@ -472,7 +472,6 @@ pub struct HistoryRow {
pub utility: String,
pub rank: String,
pub cluster: String,
pub editor_why: Option<String>,
pub signals: SignalsView,
}
@@ -640,8 +639,7 @@ pub async fn assessments(
pub async fn run_history(db: &Db, article_id: ArticleId) -> Result<Vec<HistoryRow>, sqlx::Error> {
let rows = sqlx::query(
"SELECT cr.run_id, r.date, r.status, 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.signals_json, cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank
FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
WHERE cr.article_id = ? ORDER BY cr.run_id DESC",
)
@@ -673,7 +671,6 @@ pub async fn run_history(db: &Db, article_id: ArticleId) -> Result<Vec<HistoryRo
(Some(id), None) => id.to_string(),
_ => "—".into(),
},
editor_why: row.get("editor_why"),
signals: SignalsView::from_json(&row.get::<String, _>("signals_json")),
}
})
+5 -19
View File
@@ -865,17 +865,8 @@ pub(crate) mod tests {
Some("[\"triage\",\"interest\"]"),
88.0,
1,
Some("Why one"),
),
(
2,
"selected",
None,
Some("[\"blend\"]"),
80.0,
2,
Some("Why two"),
),
(2, "selected", None, Some("[\"blend\"]"), 80.0, 2),
(
3,
"shortlisted",
@@ -883,7 +874,6 @@ pub(crate) mod tests {
Some("[\"triage\"]"),
75.0,
3,
None,
),
(
4,
@@ -892,10 +882,9 @@ pub(crate) mod tests {
Some("[\"triage\"]"),
60.0,
4,
None,
),
];
for (article_id, stage, reason, admitted_by, utility, rank, why) in rows {
for (article_id, stage, reason, admitted_by, utility, rank) in rows {
let json = signals(utility / 10.0, article_id == 2);
telemetry::write(
&db,
@@ -910,7 +899,6 @@ pub(crate) mod tests {
rank_utility: Some(rank),
cluster_id: Some(1),
cluster_rank: Some(rank),
editor_why: why,
},
)
.await
@@ -931,7 +919,6 @@ pub(crate) mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -958,7 +945,6 @@ pub(crate) mod tests {
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
@@ -1008,9 +994,9 @@ pub(crate) mod tests {
.unwrap();
sqlx::query(
"INSERT INTO issue_articles
(issue_date, article_id, section, position, is_lead, summary, why)
VALUES (?, 1, 'Top Stories', 0, 1, 'Summary one', 'Why one'),
(?, 2, 'Top Stories', 1, 0, 'Summary two', 'Why two')",
(issue_date, article_id, section, position, is_lead, summary)
VALUES (?, 1, 'Top Stories', 0, 1, 'Summary one'),
(?, 2, 'Top Stories', 1, 0, 'Summary two')",
)
.bind(date.to_string())
.bind(date.to_string())
+1 -6
View File
@@ -506,7 +506,6 @@ pub struct CandidateView {
pub fit: String,
pub exploration: bool,
pub auto_include: bool,
pub editor_why: Option<String>,
pub signals: SignalsView,
}
@@ -541,7 +540,6 @@ impl CandidateView {
fit: fmt_opt(row.get("fit"), 1),
exploration: signals.exploration,
auto_include: signals.auto_include,
editor_why: row.get("editor_why"),
signals,
}
}
@@ -575,7 +573,7 @@ pub async fn candidates(
"SELECT cr.article_id, COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title, a.word_count,
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.rank_utility, cr.cluster_id, cr.cluster_rank,
t.score AS triage, d.score AS quality, d.fit AS fit
{CANDIDATE_FROM}{clauses}
ORDER BY {}
@@ -964,8 +962,6 @@ mod tests {
assert_eq!(rows[0].article_id, 1);
assert_eq!(rows[0].admitted_first.as_deref(), Some("triage"));
assert_eq!(rows[0].admitted_rest, "interest");
assert_eq!(rows[0].editor_why.as_deref(), Some("Why one"));
let by_rank = CandidateFilters::from_query(&query(None, Some("rank")));
let (rows, _) = candidates(db, seed.run_id, &by_rank, 1).await.unwrap();
assert_eq!(rows[0].rank, "1");
@@ -1099,7 +1095,6 @@ mod tests {
"near miss: {detail}"
);
assert!(detail.contains("Gaussian Splatting"), "signals: {detail}");
assert!(detail.contains("Why one"), "{detail}");
assert!(detail.contains("Alpha Blog"), "{detail}");
assert!(detail.contains("class=\"funnel\""), "{detail}");
assert!(
+4 -13
View File
@@ -119,7 +119,7 @@ pub async fn load(
(issue, true)
} else {
let rows = sqlx::query(
"SELECT article_id, section, position, is_lead, summary, why
"SELECT article_id, section, position, is_lead, summary
FROM issue_articles WHERE issue_date = ? ORDER BY section, position",
)
.bind(date.to_string())
@@ -148,7 +148,6 @@ pub async fn load(
section,
position: pick_row.get("position"),
is_lead: pick_row.get("is_lead"),
why: pick_row.get("why"),
summary,
llm: None,
top_interests: Vec::new(),
@@ -881,7 +880,6 @@ struct FullEntry {
reading_minutes: i64,
is_lead: bool,
summary: String,
why: Option<String>,
understanding: chapters::Understanding,
rating: Option<RatingWidget>,
}
@@ -950,7 +948,6 @@ struct ArticleTemplate {
byline: Option<String>,
source: Source,
meta_line: String,
why: Option<String>,
social_line: Option<String>,
understanding: chapters::Understanding,
summary: Option<String>,
@@ -1029,7 +1026,6 @@ pub async fn render_full(
summary: summary_for(&view.issue, pick)
.unwrap_or_default()
.to_string(),
why: pick.why.clone(),
understanding: chapters::understanding(pick),
rating: is_admin.then(|| {
RatingWidget::for_issue(
@@ -1132,7 +1128,6 @@ pub async fn article(
thousands(article.word_count),
article.reading_minutes()
),
why: pick.why.clone(),
social_line: chapters::social_line(&article.social),
understanding: chapters::understanding(pick),
summary: summary_for(&view.issue, pick).map(str::to_string),
@@ -1636,7 +1631,6 @@ mod tests {
rank_utility: Some(3),
cluster_id: Some(1),
cluster_rank: Some(2),
editor_why: None,
},
)
.await
@@ -1844,7 +1838,6 @@ mod tests {
assert!(html.contains("The Lead Story"));
assert!(html.contains("Hacker News"));
assert!(html.contains("What it argues, and why it is worth the time."));
assert!(html.contains("Why it's here"));
assert!(!html.contains("Two stories today"));
assert!(!html.contains("Something happened"));
assert!(!html.contains("Body of"));
@@ -1903,7 +1896,6 @@ mod tests {
1
);
assert!(feed.contains("What it argues, and why it is worth the time."));
assert!(feed.contains("Why it&apos;s here"));
assert!(!feed.contains("Something happened"));
assert!(!feed.contains("Two stories today"));
assert!(!feed.contains("Body of"));
@@ -2000,7 +1992,6 @@ mod tests {
assert!(issue.contains("Two stories today"));
assert!(issue.contains("What it argues"));
assert!(issue.contains("A short abstract for the second piece"));
assert!(issue.contains("Why it"));
assert!(issue.contains("Software engineering · Analysis"));
assert!(issue.contains("copy-on-write · ZFS"));
assert!(issue.contains("Matches: Filesystems · Rust"));
@@ -2036,13 +2027,13 @@ mod tests {
assert!(article.contains("loading=\"lazy\""));
assert!(article.contains("referrerpolicy=\"no-referrer\""));
let rubric_position = article.find("Software engineering · Analysis").unwrap();
let why_position = article.find("Why it's here").unwrap();
let matches_position = article.find("Matches: Filesystems · Rust").unwrap();
let summary_position = article
.find("What it argues, and why it is worth the time.")
.unwrap();
let social_position = article.find("342 on HN").unwrap();
assert!(rubric_position < why_position);
assert!(why_position < summary_position);
assert!(rubric_position < matches_position);
assert!(matches_position < summary_position);
assert!(summary_position < social_position);
assert!(article.contains("A Niche Delight"));
assert!(article.contains("rel=\"next\""));
+6 -8
View File
@@ -53,7 +53,6 @@ pub struct PublicEntry {
pub reading_minutes: i64,
pub word_count: i64,
pub summary: Option<String>,
pub why: Option<String>,
pub understanding: crate::epub::chapters::Understanding,
pub comment_links: Vec<CommentLink>,
pub is_lead: bool,
@@ -132,7 +131,6 @@ impl From<&Issue> for PublicIssue {
.map(str::trim)
.filter(|summary| !summary.is_empty())
.map(str::to_string),
why: pick.why.clone(),
understanding: crate::epub::chapters::understanding(pick),
comment_links,
is_lead: pick.is_lead,
@@ -423,7 +421,7 @@ mod tests {
use super::*;
#[test]
fn public_issue_shows_summaries_and_why_but_no_bodies() {
fn public_issue_shows_summaries_but_no_bodies() {
let source = crate::epub::fixtures::issue();
let public = PublicIssue::from(&source);
assert_eq!(
@@ -442,19 +440,19 @@ mod tests {
assert!(html.contains("Hacker News"));
assert!(html.contains("What it argues, and why it is worth the time."));
assert!(html.contains("A short abstract for the second piece."));
assert!(html.contains("The systems story with enough operational detail to matter"));
assert!(html.contains("A small-scene delight outside the usual technical orbit"));
assert!(!html.contains("The systems story with enough operational detail to matter"));
assert!(!html.contains("A small-scene delight outside the usual technical orbit"));
assert!(html.contains("Software engineering · Analysis"));
assert!(html.contains("copy-on-write · ZFS"));
assert!(html.contains("Matches: Filesystems · Rust"));
let rubric_position = html.find("Software engineering · Analysis").unwrap();
let why_position = html.find("Why it's here").unwrap();
let matches_position = html.find("Matches: Filesystems · Rust").unwrap();
let summary_position = html
.find("What it argues, and why it is worth the time.")
.unwrap();
let comments_position = html.find(">Hacker News:").unwrap();
assert!(rubric_position < why_position);
assert!(why_position < summary_position);
assert!(rubric_position < matches_position);
assert!(matches_position < summary_position);
assert!(summary_position < comments_position);
for private in [
"Two stories today",
-1
View File
@@ -12,5 +12,4 @@
<td class="num">{{ candidate.quality }}</td>
<td class="num">{{ candidate.fit }}</td>
<td class="cell-tight">{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
<td class="cell-wrap text-muted">{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
</tr>
+1 -1
View File
@@ -1 +1 @@
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}<p class="mt-3 font-sans text-sm text-muted">{% if let Some(kicker) = understanding.kicker %}<span class="text-[0.72rem] uppercase tracking-[0.12em]">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}<span class="ml-3">{{ topics }}</span>{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}<span>{{ topics }}</span>{% endif %}{% endif %}</p>{% endif %}{% if why.is_some() || !understanding.interests.is_empty() %}<div class="mt-4 border-l-2 border-accent pl-3">{% if let Some(why) = why %}<p class="italic text-ink-2">Why it's here: {{ why }}</p>{% endif %}{% if !understanding.interests.is_empty() %}<p class="mt-1 font-sans text-sm text-muted">Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}<a href="{{ interest.href }}">{{ interest.name }}</a>{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}</div>{% endif %}
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}<p class="mt-3 font-sans text-sm text-muted">{% if let Some(kicker) = understanding.kicker %}<span class="text-[0.72rem] uppercase tracking-[0.12em]">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}<span class="ml-3">{{ topics }}</span>{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}<span>{{ topics }}</span>{% endif %}{% endif %}</p>{% endif %}{% if !understanding.interests.is_empty() %}<div class="mt-4 border-l-2 border-accent pl-3">{% if !understanding.interests.is_empty() %}<p class="mt-1 font-sans text-sm text-muted">Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}<a href="{{ interest.href }}">{{ interest.name }}</a>{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}</div>{% endif %}
+1 -2
View File
@@ -43,7 +43,7 @@
<h2>Run history</h2>
{% if history.is_empty() %}<p class="muted text-sm">Never considered by a run.</p>{% else %}<div class="scroll-x"><table class="history">
<thead><tr><th>run</th><th>date</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th>editor</th></tr></thead>
<thead><tr><th>run</th><th>date</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th></tr></thead>
<tbody>{% for row in history %}<tr>
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
<td class="cell-tight text-muted">{{ row.date }}</td>
@@ -53,7 +53,6 @@
<td class="num">{{ row.utility }}</td>
<td class="num">{{ row.rank }}</td>
<td class="num">{{ row.cluster }}</td>
<td class="cell-wrap text-muted">{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>{% endif %}
<h2>Neighbours and interests</h2>
+2 -2
View File
@@ -59,7 +59,7 @@
</form>
{% include "dashboard/_pager.html" %}
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="candidates" data-filter>
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th><th>editor</th></tr></thead>
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="14" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th></tr></thead>
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="13" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %}
</section>{% endblock %}
+1 -1
View File
@@ -1 +1 @@
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}{% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}{% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
+1 -1
View File
@@ -5,7 +5,7 @@
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
{% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %}
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{% let source = entry.source %}{% include "_source.html" %} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% let understanding = entry.understanding %}{% let why = entry.why %}{% include "_understanding.html" %}{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{% let source = entry.source %}{% include "_source.html" %} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% let understanding = entry.understanding %}{% include "_understanding.html" %}{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
</section>
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
+1 -1
View File
@@ -4,6 +4,6 @@
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
<p class="notice mb-8"><a href="/login">Sign in</a> to read every article's full text and download the editions, or <a href="/request-access">request access</a>.</p>
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
{% for section in issue.sections %}<section class="mt-14"><h2 class="reader-section-heading">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %} · {{ entry.reading_minutes }} min</p>{% let understanding = entry.understanding %}{% let why = entry.why %}{% include "_understanding.html" %}{% match entry.summary %}{% when Some with (summary) %}<p class="index-summary mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
{% for section in issue.sections %}<section class="mt-14"><h2 class="reader-section-heading">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %} · {{ entry.reading_minutes }} min</p>{% let understanding = entry.understanding %}{% include "_understanding.html" %}{% match entry.summary %}{% when Some with (summary) %}<p class="index-summary mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
{% endif %}</article>{% endblock %}
-5
View File
@@ -619,11 +619,6 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
"the model's summaries were used, not excerpts"
);
assert!(editorial_doc.front_page_html.contains("storage internals"));
assert!(
lineup.picks.iter().all(|p| p.why.is_none()),
"the scripted editor gave no why lines"
);
// Every scripted response was consumed, and the meter priced them (§3.6).
assert_eq!(backend.calls(), 1 + 1 + 5 + 1);
let total = meter.total();
+5 -5
View File
@@ -1,10 +1,10 @@
{
"picks": [
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true, "why": "The migration post-mortem with the invoices still attached" },
{ "id": 102, "section": "Tech & Engineering", "position": 1, "lead_story": false, "why": "A failover story you'd argue with over coffee" },
{ "id": 103, "section": "Tech & Engineering", "position": 2, "lead_story": false, "why": "Rare first-hand detail on a tool you use daily" },
{ "id": 104, "section": "AI & Machine Learning", "position": 1, "lead_story": false, "why": "The one benchmark piece this week that shows its work" },
{ "id": 105, "section": "Boston & Local", "position": 1, "lead_story": false, "why": "MBTA slow zones charted by a rider, not a press office" },
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true },
{ "id": 102, "section": "Tech & Engineering", "position": 1, "lead_story": false },
{ "id": 103, "section": "Tech & Engineering", "position": 2, "lead_story": false },
{ "id": 104, "section": "AI & Machine Learning", "position": 1, "lead_story": false },
{ "id": 105, "section": "Boston & Local", "position": 1, "lead_story": false },
{ "id": 106, "section": "Culture & Essays", "position": 1 },
{ "section": "Niche Corner", "position": 2, "lead_story": false },
"the model sometimes trails off like this"