diff --git a/README.md b/README.md index 46498ff..1921798 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/runbooks/curation-v2-migration.md b/docs/runbooks/curation-v2-migration.md index 4fb1d6f..76069f7 100644 --- a/docs/runbooks/curation-v2-migration.md +++ b/docs/runbooks/curation-v2-migration.md @@ -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`. diff --git a/examples/image_audit.rs b/examples/image_audit.rs index 088d1a8..72db100 100644 --- a/examples/image_audit.rs +++ b/examples/image_audit.rs @@ -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(), diff --git a/examples/seed_dev_db.rs b/examples/seed_dev_db.rs index 35953ac..42f778c 100644 --- a/examples/seed_dev_db.rs +++ b/examples/seed_dev_db.rs @@ -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?; diff --git a/src/curate/editor.rs b/src/curate/editor.rs index 477b249..3107c7c 100644 --- a/src/curate/editor.rs +++ b/src/curate/editor.rs @@ -19,8 +19,6 @@ pub struct SelectionItem { pub position: i64, #[serde(default)] pub lead_story: bool, - #[serde(default)] - pub why: Option, } 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 { }) }) .unwrap_or(false), - why: object - .get("why") - .and_then(serde_json::Value::as_str) - .map(|why| { - why.split_whitespace() - .take(14) - .collect::>() - .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 = (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::>() - .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), §ions(), 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), - §ions(), - 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()); } diff --git a/src/curate/editorial.rs b/src/curate/editorial.rs index 340060b..18367cd 100644 --- a/src/curate/editorial.rs +++ b/src/curate/editorial.rs @@ -275,10 +275,9 @@ pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap { pub rank_utility: Option, pub cluster_id: Option, pub cluster_rank: Option, - 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, pub cluster_id: Option, pub cluster_rank: Option, - pub editor_why: Option, } 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, understanding: Understanding, } @@ -82,7 +81,6 @@ struct ArticleChapter { meta_line: String, social_line: Option, understanding: Understanding, - why: Option, summary: Option, excerpt_only: bool, body_html: String, @@ -411,7 +409,6 @@ pub fn render_in_this_issue(issue: &Issue) -> Result { 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 { 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_), diff --git a/src/epub/fixtures.rs b/src/epub/fixtures.rs index 217679a..bbd48d2 100644 --- a/src/epub/fixtures.rs +++ b/src/epub/fixtures.rs @@ -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(), diff --git a/src/epub/templates/chapter.xhtml b/src/epub/templates/chapter.xhtml index 89b4169..abf8971 100644 --- a/src/epub/templates/chapter.xhtml +++ b/src/epub/templates/chapter.xhtml @@ -10,11 +10,8 @@ {% if understanding.kicker.is_some() || understanding.topics.is_some() %}

{% if let Some(kicker) = understanding.kicker %}{{ kicker }}{% if let Some(topics) = understanding.topics %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %} -{% if why.is_some() || !understanding.interests.is_empty() %} +{% if !understanding.interests.is_empty() %}
-{% if let Some(text) = why %} -

Why it's here: {{ text }}

-{% endif %} {% if !understanding.interests.is_empty() %}

Matches: {{ understanding.interests_line() }}

{% endif %} diff --git a/src/epub/templates/in_this_issue.xhtml b/src/epub/templates/in_this_issue.xhtml index 37fdfa7..19294b2 100644 --- a/src/epub/templates/in_this_issue.xhtml +++ b/src/epub/templates/in_this_issue.xhtml @@ -13,11 +13,8 @@ {% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %}

{% if let Some(kicker) = entry.understanding.kicker %}{{ kicker }}{% if let Some(topics) = entry.understanding.topics %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %} -{% if entry.why.is_some() || !entry.understanding.interests.is_empty() %} +{% if !entry.understanding.interests.is_empty() %}
-{% if let Some(text) = entry.why %} -

Why it's here: {{ text }}

-{% endif %} {% if !entry.understanding.interests.is_empty() %}

Matches: {{ entry.understanding.interests_line() }}

{% endif %} diff --git a/src/epub/templates/style-x4.css b/src/epub/templates/style-x4.css index 1322f4e..3dd3c06 100644 --- a/src/epub/templates/style-x4.css +++ b/src/epub/templates/style-x4.css @@ -101,10 +101,6 @@ img { font-size: 0.9em; } -.why-line { - margin: 0; -} - .why-matches { margin: 0; font-size: 0.85em; diff --git a/src/epub/templates/style.css b/src/epub/templates/style.css index e464022..14dbfeb 100644 --- a/src/epub/templates/style.css +++ b/src/epub/templates/style.css @@ -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; diff --git a/src/pipeline.rs b/src/pipeline.rs index dda761d..bac9050 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -648,27 +648,19 @@ async fn run_stages( .copied() .filter(|id| !selected_set.contains(id)) .collect::>(); - // 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::>(); set_candidate_stage( &mut personalized, ¬_selected, "shortlisted", Some("not_selected"), ); - let why = selected_with_why.into_iter().collect::>(); 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>, -) -> 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 diff --git a/src/types.rs b/src/types.rs index 4f539d0..087ed3b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -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, /// Newspaper-abstract summary from stage C; `None` until editorial runs. pub summary: Option, pub llm: Option, diff --git a/src/web/dashboard/articles.rs b/src/web/dashboard/articles.rs index 6920586..c452c5b 100644 --- a/src/web/dashboard/articles.rs +++ b/src/web/dashboard/articles.rs @@ -472,7 +472,6 @@ pub struct HistoryRow { pub utility: String, pub rank: String, pub cluster: String, - pub editor_why: Option, pub signals: SignalsView, } @@ -640,8 +639,7 @@ pub async fn assessments( pub async fn run_history(db: &Db, article_id: ArticleId) -> Result, 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 id.to_string(), _ => "—".into(), }, - editor_why: row.get("editor_why"), signals: SignalsView::from_json(&row.get::("signals_json")), } }) diff --git a/src/web/dashboard/mod.rs b/src/web/dashboard/mod.rs index 3ba9b1e..60a2ba8 100644 --- a/src/web/dashboard/mod.rs +++ b/src/web/dashboard/mod.rs @@ -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()) diff --git a/src/web/dashboard/runs.rs b/src/web/dashboard/runs.rs index 6ea0817..c4c7806 100644 --- a/src/web/dashboard/runs.rs +++ b/src/web/dashboard/runs.rs @@ -506,7 +506,6 @@ pub struct CandidateView { pub fit: String, pub exploration: bool, pub auto_include: bool, - pub editor_why: Option, 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!( diff --git a/src/web/issue.rs b/src/web/issue.rs index 4c617f9..0f83b61 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -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, understanding: chapters::Understanding, rating: Option, } @@ -950,7 +948,6 @@ struct ArticleTemplate { byline: Option, source: Source, meta_line: String, - why: Option, social_line: Option, understanding: chapters::Understanding, summary: Option, @@ -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'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\"")); diff --git a/src/web/public.rs b/src/web/public.rs index 8dfe9ed..38fe6da 100644 --- a/src/web/public.rs +++ b/src/web/public.rs @@ -53,7 +53,6 @@ pub struct PublicEntry { pub reading_minutes: i64, pub word_count: i64, pub summary: Option, - pub why: Option, pub understanding: crate::epub::chapters::Understanding, pub comment_links: Vec, 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", diff --git a/src/web/templates/_candidate_row.html b/src/web/templates/_candidate_row.html index 612eee7..fe15115 100644 --- a/src/web/templates/_candidate_row.html +++ b/src/web/templates/_candidate_row.html @@ -12,5 +12,4 @@ {{ candidate.quality }} {{ candidate.fit }} {% if candidate.exploration %}exploration{% endif %}{% if candidate.auto_include %}auto{% endif %} -{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %} diff --git a/src/web/templates/_understanding.html b/src/web/templates/_understanding.html index d9af543..d2e21fa 100644 --- a/src/web/templates/_understanding.html +++ b/src/web/templates/_understanding.html @@ -1 +1 @@ -{% if understanding.kicker.is_some() || understanding.topics.is_some() %}

{% if let Some(kicker) = understanding.kicker %}{{ kicker }}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %}{% if why.is_some() || !understanding.interests.is_empty() %}
{% if let Some(why) = why %}

Why it's here: {{ why }}

{% endif %}{% if !understanding.interests.is_empty() %}

Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}{{ interest.name }}{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}

{% endif %}
{% endif %} +{% if understanding.kicker.is_some() || understanding.topics.is_some() %}

{% if let Some(kicker) = understanding.kicker %}{{ kicker }}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %}{% if !understanding.interests.is_empty() %}
{% if !understanding.interests.is_empty() %}

Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}{{ interest.name }}{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}

{% endif %}
{% endif %} diff --git a/src/web/templates/dashboard/article.html b/src/web/templates/dashboard/article.html index 7d1388b..e525d61 100644 --- a/src/web/templates/dashboard/article.html +++ b/src/web/templates/dashboard/article.html @@ -43,7 +43,7 @@

Run history

{% if history.is_empty() %}

Never considered by a run.

{% else %}
- +{% for row in history %} @@ -53,7 +53,6 @@ -{% endfor %}
rundatestagereasonadmitted byutilityrankclustereditor
rundatestagereasonadmitted byutilityrankcluster
run {{ row.run_id }} {{ row.status }}{% let signals = row.signals %}{% include "_signals_table.html" %}
{{ row.date }}{{ row.utility }} {{ row.rank }} {{ row.cluster }}{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}
{% endif %}

Neighbours and interests

diff --git a/src/web/templates/dashboard/run.html b/src/web/templates/dashboard/run.html index a1db6eb..a86dd5d 100644 --- a/src/web/templates/dashboard/run.html +++ b/src/web/templates/dashboard/run.html @@ -59,7 +59,7 @@ {% include "dashboard/_pager.html" %} {% if candidates.len() > 1 %}{% endif %}
- -{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}{% endif %}
titlefeedwordsstagereasonadmitted byutilityrankclustertriagequalityfitflagseditor
No candidates match this filter.
+titlefeedwordsstagereasonadmitted byutilityrankclustertriagequalityfitflags +{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}No candidates match this filter.{% endif %}
{% include "dashboard/_pager.html" %} {% endblock %} diff --git a/src/web/templates/feed_entry.html b/src/web/templates/feed_entry.html index b909c5b..a1c241f 100644 --- a/src/web/templates/feed_entry.html +++ b/src/web/templates/feed_entry.html @@ -1 +1 @@ -{% for section in issue.sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }} — {% 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) %}

    {{ summary }}

    {% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}

    Why it's here: {{ why }}

    {% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}{{ link.label }}{% endfor %}{% endif %}
  • {% endfor %}
{% endfor %} +{% for section in issue.sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }} — {% 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) %}

    {{ summary }}

    {% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}{{ link.label }}{% endfor %}{% endif %}
  • {% endfor %}
{% endfor %} diff --git a/src/web/templates/issue_full.html b/src/web/templates/issue_full.html index 3bd3a09..1463f24 100644 --- a/src/web/templates/issue_full.html +++ b/src/web/templates/issue_full.html @@ -5,7 +5,7 @@

The Brief

{{ front_page_html|safe }}
{% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}
{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}Read in BookOrbit{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}Download {{ downloads.primary.label }}{% else %}{% endif %}{% when None %}{% endmatch %}
{% endif %}

In This Issue

- {% for section in sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }}

    {% 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() %} · dashboard{% endif %}

    {% let understanding = entry.understanding %}{% let why = entry.why %}{% include "_understanding.html" %}{% if !entry.summary.is_empty() %}

    {{ entry.summary }}

    {% endif %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
  • {% endfor %}
{% endfor %} + {% for section in sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }}

    {% 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() %} · dashboard{% endif %}

    {% let understanding = entry.understanding %}{% include "_understanding.html" %}{% if !entry.summary.is_empty() %}

    {{ entry.summary }}

    {% endif %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
  • {% endfor %}
{% endfor %}
{% if has_world || has_behind %}{% endif %}

Colophon

The Daily EPUB is assembled every morning from a personal feed reader.

Generated
{{ colophon.generated_at }}
Bulk model
{{ colophon.bulk_model }}
Editor model
{{ colophon.editor_model }}
Summaries model
{{ colophon.summaries_model }}
Entries considered
{% 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 %}
Candidates scored
{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}
Articles selected
{{ colophon.article_count }} across {{ colophon.section_count }} sections
Words
{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read
{% for cost in colophon.provider_costs %}
{{ cost.provider }} cost
{{ cost.cost }}
{% endfor %}
Total token cost
{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}
Generator
{{ colophon.generator_version }}
diff --git a/src/web/templates/issue_public.html b/src/web/templates/issue_public.html index bb8281f..339d941 100644 --- a/src/web/templates/issue_public.html +++ b/src/web/templates/issue_public.html @@ -4,6 +4,6 @@

{{ issue.display_date }} · No. {{ issue.issue_number }}

{{ issue.stats_line }}

A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.

Sign in to read every article's full text and download the editions, or request access.

{% if !downloads.is_empty() %}
{% for download in downloads %}{{ download.label }} {{ download.size }}{% endfor %}
{% endif %} - {% for section in issue.sections %}

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% 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

{% let understanding = entry.understanding %}{% let why = entry.why %}{% include "_understanding.html" %}{% match entry.summary %}{% when Some with (summary) %}

{{ summary }}

{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}

{% for link in entry.comment_links %}{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}{% endfor %}

{% endif %}
{% endfor %}
{% endfor %} + {% for section in issue.sections %}

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% 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

{% let understanding = entry.understanding %}{% include "_understanding.html" %}{% match entry.summary %}{% when Some with (summary) %}

{{ summary }}

{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}

{% for link in entry.comment_links %}{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}{% endfor %}

{% endif %}
{% endfor %}
{% endfor %}

Browse the archive →

{% endif %}{% endblock %} diff --git a/tests/e2e_pipeline.rs b/tests/e2e_pipeline.rs index 84370ec..da79034 100644 --- a/tests/e2e_pipeline.rs +++ b/tests/e2e_pipeline.rs @@ -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(); diff --git a/tests/fixtures/deepseek_lineup.json b/tests/fixtures/deepseek_lineup.json index 48d4220..648ff0a 100644 --- a/tests/fixtures/deepseek_lineup.json +++ b/tests/fixtures/deepseek_lineup.json @@ -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"