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:
@@ -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,
|
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 —
|
then utility-ranks and diversity-caps a 60-item shortlist for Claude Opus 5 — the editor —
|
||||||
which assembles the issue
|
which assembles the issue
|
||||||
(no minimum size, a hard ceiling), writes a one-line *why* under every headline,
|
(no minimum size, a hard ceiling), then writes the summaries and *The Brief*.
|
||||||
the summaries and *The Brief*. It assembles two EPUB editions (a standard one
|
It assembles two EPUB editions (a standard one
|
||||||
and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and
|
and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and
|
||||||
publishes the lot over its own OPDS catalog — which doubles as a
|
publishes the lot over its own OPDS catalog — which doubles as a
|
||||||
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
|
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
|
||||||
@@ -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
|
## Web site and dashboard
|
||||||
|
|
||||||
The server is both the public newspaper index and the private operator UI. An
|
The server is both the public newspaper index and the private operator UI. An
|
||||||
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
|
anonymous visitor sees titles, authors, sources, metadata, AI summaries and
|
||||||
lines and outbound comment links; article bodies, the Brief, the World Briefing
|
outbound comment links; article bodies, the Brief, the World Briefing
|
||||||
and comments stay private. A signed-in `user`
|
and comments stay private. A signed-in `user`
|
||||||
sees complete issues and article chapters and can download available formats
|
sees complete issues and article chapters and can download available formats
|
||||||
from a single download menu. An
|
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
|
# 4. Now with the bulk and editor providers, still not publishing
|
||||||
daily-epub generate --dry-run --out ./out --max-articles 6
|
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
|
# printed per-provider cost is well under $1
|
||||||
|
|
||||||
# 5. Full live run
|
# 5. Full live run
|
||||||
|
|||||||
@@ -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
|
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:`,
|
`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
|
`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:
|
Behind-the-paper chapter before the colophon. Finally:
|
||||||
|
|
||||||
```sh
|
```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
|
`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
|
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
|
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
|
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`.
|
move with it). The same trick works for the bulk role: `DAILY_EPUB_LLM__BULK=gemini`.
|
||||||
|
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ fn pick_for(target: &Target, content_html: String) -> Pick {
|
|||||||
section: target.issue.clone(),
|
section: target.issue.clone(),
|
||||||
position: 0,
|
position: 0,
|
||||||
is_lead: false,
|
is_lead: false,
|
||||||
why: None,
|
|
||||||
summary: None,
|
summary: None,
|
||||||
llm: None,
|
llm: None,
|
||||||
top_interests: Vec::new(),
|
top_interests: Vec::new(),
|
||||||
|
|||||||
+5
-16
@@ -29,14 +29,13 @@ use jiff::civil::Date;
|
|||||||
const ADMIN_PASSWORD: &str = "adminpassword123";
|
const ADMIN_PASSWORD: &str = "adminpassword123";
|
||||||
const READER_PASSWORD: &str = "readerpassword123";
|
const READER_PASSWORD: &str = "readerpassword123";
|
||||||
|
|
||||||
/// (section, title, feed, summary, why, words)
|
/// (section, title, feed, summary, words)
|
||||||
const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
const STORIES: &[(&str, &str, &str, &str, i64)] = &[
|
||||||
(
|
(
|
||||||
"Top Stories",
|
"Top Stories",
|
||||||
"How a Forty-Year-Old Filesystem Quietly Rewrote Its Write Path",
|
"How a Forty-Year-Old Filesystem Quietly Rewrote Its Write Path",
|
||||||
"Systems Weekly",
|
"Systems Weekly",
|
||||||
"A long, careful account of the redesign, with the benchmarks that justified it and the two regressions that nearly sank it.",
|
"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,
|
3100,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -44,7 +43,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
|||||||
"The Case Against Feature Flags",
|
"The Case Against Feature Flags",
|
||||||
"Alice on Software",
|
"Alice on Software",
|
||||||
"Argues that flags outlive their purpose and proposes a retirement discipline, with examples from three codebases.",
|
"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,
|
1850,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -52,7 +50,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
|||||||
"What the Latest Battery Chemistry Actually Changes",
|
"What the Latest Battery Chemistry Actually Changes",
|
||||||
"Ars Technica",
|
"Ars Technica",
|
||||||
"Separates the press-release claims from the measurable improvements in energy density and cycle life.",
|
"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,
|
2200,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -60,7 +57,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
|||||||
"A Field Guide to Distributed Consensus, Told Through One Outage",
|
"A Field Guide to Distributed Consensus, Told Through One Outage",
|
||||||
"The Morning Paper",
|
"The Morning Paper",
|
||||||
"Walks through a real incident to explain leader election, log replication and why the fix was a config change.",
|
"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,
|
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",
|
"Why Every Map Is a Lie & How Cartographers Choose Which One to Tell",
|
||||||
"Longreads",
|
"Longreads",
|
||||||
"A history of projections and the politics behind them, from Mercator to the maps in your phone.",
|
"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,
|
5100,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -76,7 +71,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
|||||||
"Notes on Writing a Rust Linter That People Actually Enable",
|
"Notes on Writing a Rust Linter That People Actually Enable",
|
||||||
"Rust Blog",
|
"Rust Blog",
|
||||||
"Design notes on false-positive budgets, fix suggestions and the social side of shipping a lint.",
|
"Design notes on false-positive budgets, fix suggestions and the social side of shipping a lint.",
|
||||||
"Rust tooling with a practical bent",
|
|
||||||
1600,
|
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",
|
"The Terminal Is the Best UI We Have and It Is Getting Better",
|
||||||
"Julia's Notebook",
|
"Julia's Notebook",
|
||||||
"A tour of modern terminal features (hyperlinks, images, synchronized output) and which tools use them.",
|
"A tour of modern terminal features (hyperlinks, images, synchronized output) and which tools use them.",
|
||||||
"Terminal ergonomics, one of your recurring interests",
|
|
||||||
1300,
|
1300,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -92,7 +85,6 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
|||||||
"A Small-Town Bakery's Sourdough Starter Turns One Hundred",
|
"A Small-Town Bakery's Sourdough Starter Turns One Hundred",
|
||||||
"Saveur",
|
"Saveur",
|
||||||
"A charming profile of a starter kept alive across four generations and the bread it still makes.",
|
"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,
|
900,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -100,15 +92,14 @@ const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
|||||||
"Inside the Community Keeping 1990s Synthesizers Alive",
|
"Inside the Community Keeping 1990s Synthesizers Alive",
|
||||||
"Sound on Sound",
|
"Sound on Sound",
|
||||||
"Repair collectives, replacement parts and the odd economics of vintage gear.",
|
"Repair collectives, replacement parts and the odd economics of vintage gear.",
|
||||||
"Music hardware, for the weekend",
|
|
||||||
1700,
|
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 id = index as i64 + 1;
|
||||||
let entry_id = 1000 + id;
|
let entry_id = 1000 + id;
|
||||||
let (_, title, feed, _, _, words) = *story;
|
let (_, title, feed, _, words) = *story;
|
||||||
let mut article = fixtures::article(id, entry_id, title);
|
let mut article = fixtures::article(id, entry_id, title);
|
||||||
article.feed_title = feed.to_string();
|
article.feed_title = feed.to_string();
|
||||||
article.feed_id = 7 + index as i64;
|
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 picks = Vec::new();
|
||||||
let mut summaries = BTreeMap::new();
|
let mut summaries = BTreeMap::new();
|
||||||
for (index, story) in STORIES.iter().enumerate() {
|
for (index, story) in STORIES.iter().enumerate() {
|
||||||
let (section, _, _, summary, why, _) = *story;
|
let (section, _, _, summary, _) = *story;
|
||||||
if !section_order.iter().any(|s| s == section) {
|
if !section_order.iter().any(|s| s == section) {
|
||||||
section_order.push(section.to_string());
|
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(),
|
section: section.to_string(),
|
||||||
position,
|
position,
|
||||||
is_lead: index == 0,
|
is_lead: index == 0,
|
||||||
why: Some(why.to_string()),
|
|
||||||
summary: Some(summary.to_string()),
|
summary: Some(summary.to_string()),
|
||||||
llm,
|
llm,
|
||||||
top_interests,
|
top_interests,
|
||||||
@@ -418,7 +408,6 @@ async fn seed_near_misses(db: &Db, run_id: i64) -> anyhow::Result<()> {
|
|||||||
rank_utility: Some(index + 10),
|
rank_utility: Some(index + 10),
|
||||||
cluster_id: Some(index),
|
cluster_id: Some(index),
|
||||||
cluster_rank: Some(1),
|
cluster_rank: Some(1),
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+2
-76
@@ -19,8 +19,6 @@ pub struct SelectionItem {
|
|||||||
pub position: i64,
|
pub position: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lead_story: bool,
|
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.
|
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.
|
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.
|
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.
|
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
|
EDITORIAL JUDGEMENT
|
||||||
- Depth over coverage. Drop anything you would not defend to him in person.
|
- 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,
|
- 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.
|
- Scores are evidence, not instructions. Overrule them when the paper reads better.
|
||||||
|
|
||||||
Return JSON exactly:
|
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(
|
pub fn build_prompt(
|
||||||
shortlist: &[Candidate],
|
shortlist: &[Candidate],
|
||||||
@@ -398,16 +386,6 @@ pub fn parse_selection_response(raw: &str) -> Vec<SelectionItem> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or(false),
|
.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()
|
.collect()
|
||||||
@@ -483,7 +461,6 @@ pub async fn select(
|
|||||||
section: "From the Blogroll".into(),
|
section: "From the Blogroll".into(),
|
||||||
position: i64::MAX,
|
position: i64::MAX,
|
||||||
lead_story: false,
|
lead_story: false,
|
||||||
why: Some("A standing source you always want represented".into()),
|
|
||||||
},
|
},
|
||||||
candidate.clone(),
|
candidate.clone(),
|
||||||
));
|
));
|
||||||
@@ -592,7 +569,6 @@ fn assemble(
|
|||||||
section: item.section,
|
section: item.section,
|
||||||
position: *position,
|
position: *position,
|
||||||
is_lead: Some(item.id) == lead_id,
|
is_lead: Some(item.id) == lead_id,
|
||||||
why: item.why,
|
|
||||||
summary: None,
|
summary: None,
|
||||||
llm: candidate.assessment.deep,
|
llm: candidate.assessment.deep,
|
||||||
top_interests: candidate
|
top_interests: candidate
|
||||||
@@ -639,7 +615,6 @@ pub fn select_without_llm(
|
|||||||
section: heuristic_section(&candidate, sections),
|
section: heuristic_section(&candidate, sections),
|
||||||
position: chosen.len() as i64 + 1,
|
position: chosen.len() as i64 + 1,
|
||||||
lead_story: false,
|
lead_story: false,
|
||||||
why: None,
|
|
||||||
},
|
},
|
||||||
candidate,
|
candidate,
|
||||||
));
|
));
|
||||||
@@ -742,7 +717,7 @@ mod tests {
|
|||||||
let picks: Vec<String> = (1..=n)
|
let picks: Vec<String> = (1..=n)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
format!(
|
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
|
i == 1
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -809,34 +784,10 @@ mod tests {
|
|||||||
assert!(items[0].lead_story);
|
assert!(items[0].lead_story);
|
||||||
assert_eq!(items[0].section, "Top Stories");
|
assert_eq!(items[0].section, "Top Stories");
|
||||||
assert_eq!(items.iter().filter(|i| i.lead_story).count(), 1);
|
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.
|
// Junk entries in the fixture are dropped, not fatal.
|
||||||
assert!(items.iter().all(|i| i.id != 0));
|
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]
|
#[test]
|
||||||
fn the_prompt_substitutes_the_size_targets_and_renders_the_shortlist() {
|
fn the_prompt_substitutes_the_size_targets_and_renders_the_shortlist() {
|
||||||
let prompt = build_prompt(&candidates(3), §ions(), 6, 11);
|
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"));
|
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]
|
#[tokio::test]
|
||||||
async fn hallucinated_ids_and_missing_leads_are_repaired() {
|
async fn hallucinated_ids_and_missing_leads_are_repaired() {
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
@@ -1086,7 +1014,6 @@ mod tests {
|
|||||||
.find(|p| p.article.id == 30)
|
.find(|p| p.article.id == 30)
|
||||||
.expect("reinserted");
|
.expect("reinserted");
|
||||||
assert_eq!(reinserted.section, "From the Blogroll");
|
assert_eq!(reinserted.section, "From the Blogroll");
|
||||||
assert!(reinserted.why.is_some());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1195,7 +1122,6 @@ mod tests {
|
|||||||
for pick in &lineup.picks {
|
for pick in &lineup.picks {
|
||||||
assert!(sections().contains(&pick.section));
|
assert!(sections().contains(&pick.section));
|
||||||
assert!(pick.summary.is_none());
|
assert!(pick.summary.is_none());
|
||||||
assert!(pick.why.is_none());
|
|
||||||
}
|
}
|
||||||
assert!(!lineup.section_order.is_empty());
|
assert!(!lineup.section_order.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,10 +275,9 @@ pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap<ArticleId, Strin
|
|||||||
.unwrap_or_else(|| excerpt_summary(pick));
|
.unwrap_or_else(|| excerpt_summary(pick));
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
prompt,
|
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.title.trim(),
|
||||||
pick.article.feed_title.trim(),
|
pick.article.feed_title.trim(),
|
||||||
pick.why.as_deref().unwrap_or("not supplied"),
|
|
||||||
quality,
|
quality,
|
||||||
fit,
|
fit,
|
||||||
summary
|
summary
|
||||||
@@ -459,7 +458,6 @@ mod tests {
|
|||||||
section: section.into(),
|
section: section.into(),
|
||||||
position: 1,
|
position: 1,
|
||||||
is_lead,
|
is_lead,
|
||||||
why: Some(format!("the {title} piece you'd argue with")),
|
|
||||||
summary: None,
|
summary: None,
|
||||||
llm: None,
|
llm: None,
|
||||||
top_interests: Vec::new(),
|
top_interests: Vec::new(),
|
||||||
@@ -635,7 +633,6 @@ mod tests {
|
|||||||
assert!(prompt.contains("## Top Stories"));
|
assert!(prompt.contains("## Top Stories"));
|
||||||
assert!(prompt.contains("## Boston & Local"));
|
assert!(prompt.contains("## Boston & Local"));
|
||||||
assert!(prompt.contains("- Migrating 40TB off Postgres"));
|
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("summary: A migration story with numbers."));
|
||||||
assert!(prompt.contains("quality: unassessed"));
|
assert!(prompt.contains("quality: unassessed"));
|
||||||
assert!(prompt.contains("fit: unassessed"));
|
assert!(prompt.contains("fit: unassessed"));
|
||||||
|
|||||||
+8
-26
@@ -2,7 +2,7 @@
|
|||||||
//! the `explain` command and feature retention (plan §7.4–7.5, §15.2, §16).
|
//! 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
|
//! 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.
|
//! `COALESCE`), so the last write for a run is the whole truth.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -45,16 +45,15 @@ pub struct CandidateRun<'a> {
|
|||||||
pub rank_utility: Option<i64>,
|
pub rank_utility: Option<i64>,
|
||||||
pub cluster_id: Option<i64>,
|
pub cluster_id: Option<i64>,
|
||||||
pub cluster_rank: 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> {
|
pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO candidate_runs
|
"INSERT INTO candidate_runs
|
||||||
(run_id, article_id, stage, excluded_reason, admitted_by, signals_json,
|
(run_id, article_id, stage, excluded_reason, admitted_by, signals_json,
|
||||||
utility, rank_utility, cluster_id, cluster_rank, editor_why)
|
utility, rank_utility, cluster_id, cluster_rank)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(run_id, article_id) DO UPDATE SET
|
ON CONFLICT(run_id, article_id) DO UPDATE SET
|
||||||
stage = excluded.stage,
|
stage = excluded.stage,
|
||||||
excluded_reason = excluded.excluded_reason,
|
excluded_reason = excluded.excluded_reason,
|
||||||
@@ -63,8 +62,7 @@ pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
|
|||||||
utility = excluded.utility,
|
utility = excluded.utility,
|
||||||
rank_utility = excluded.rank_utility,
|
rank_utility = excluded.rank_utility,
|
||||||
cluster_id = excluded.cluster_id,
|
cluster_id = excluded.cluster_id,
|
||||||
cluster_rank = excluded.cluster_rank,
|
cluster_rank = excluded.cluster_rank",
|
||||||
editor_why = excluded.editor_why",
|
|
||||||
)
|
)
|
||||||
.bind(row.run_id)
|
.bind(row.run_id)
|
||||||
.bind(row.article_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.rank_utility)
|
||||||
.bind(row.cluster_id)
|
.bind(row.cluster_id)
|
||||||
.bind(row.cluster_rank)
|
.bind(row.cluster_rank)
|
||||||
.bind(row.editor_why)
|
|
||||||
.execute(db.pool())
|
.execute(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -103,7 +100,6 @@ pub async fn thin_excluded(
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -236,7 +232,6 @@ pub struct ExplainRow {
|
|||||||
pub rank_utility: Option<i64>,
|
pub rank_utility: Option<i64>,
|
||||||
pub cluster_id: Option<i64>,
|
pub cluster_id: Option<i64>,
|
||||||
pub cluster_rank: Option<i64>,
|
pub cluster_rank: Option<i64>,
|
||||||
pub editor_why: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExplainRow {
|
impl ExplainRow {
|
||||||
@@ -254,7 +249,6 @@ impl ExplainRow {
|
|||||||
rank_utility: row.get("rank_utility"),
|
rank_utility: row.get("rank_utility"),
|
||||||
cluster_id: row.get("cluster_id"),
|
cluster_id: row.get("cluster_id"),
|
||||||
cluster_rank: row.get("cluster_rank"),
|
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,
|
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||||||
COALESCE(e.feed_title, '') AS feed_title,
|
COALESCE(e.feed_title, '') AS feed_title,
|
||||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank
|
||||||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||||||
LEFT JOIN entries e ON e.id = a.best_entry_id
|
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||||
WHERE cr.run_id = ? AND cr.article_id = ?",
|
WHERE cr.run_id = ? AND cr.article_id = ?",
|
||||||
@@ -329,7 +323,7 @@ pub async fn near_misses(
|
|||||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||||||
COALESCE(e.feed_title, '') AS feed_title,
|
COALESCE(e.feed_title, '') AS feed_title,
|
||||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank
|
||||||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||||||
LEFT JOIN entries e ON e.id = a.best_entry_id
|
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||||
WHERE cr.run_id = ? AND cr.stage != 'selected' AND cr.stage != 'excluded'",
|
WHERE cr.run_id = ? AND cr.stage != 'selected' AND cr.stage != 'excluded'",
|
||||||
@@ -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 {
|
if let Some(admitted_by) = &row.admitted_by {
|
||||||
let _ = writeln!(out, "admitted by: {admitted_by}");
|
let _ = writeln!(out, "admitted by: {admitted_by}");
|
||||||
}
|
}
|
||||||
if let Some(why) = &row.editor_why {
|
|
||||||
let _ = writeln!(out, "editor: {why}");
|
|
||||||
}
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,7 +1246,6 @@ mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1289,7 +1279,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 (_dir, db) = db_with_articles(&[1]).await;
|
||||||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||||||
write(
|
write(
|
||||||
@@ -1305,7 +1295,6 @@ mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1323,7 +1312,6 @@ mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: Some("because"),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1333,7 +1321,6 @@ mod tests {
|
|||||||
assert_eq!(row.excluded_reason, None, "no COALESCE");
|
assert_eq!(row.excluded_reason, None, "no COALESCE");
|
||||||
assert_eq!(row.utility, None);
|
assert_eq!(row.utility, None);
|
||||||
assert_eq!(row.admitted_by.as_deref(), Some("[\"prefilter\"]"));
|
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")
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM candidate_runs")
|
||||||
.fetch_one(db.pool())
|
.fetch_one(db.pool())
|
||||||
.await
|
.await
|
||||||
@@ -1360,7 +1347,6 @@ mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1493,7 +1479,6 @@ mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1582,7 +1567,6 @@ mod tests {
|
|||||||
rank_utility: Some(3),
|
rank_utility: Some(3),
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1600,7 +1584,6 @@ mod tests {
|
|||||||
rank_utility: Some(1),
|
rank_utility: Some(1),
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: Some("because"),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1697,7 +1680,6 @@ mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -606,8 +606,8 @@ impl Db {
|
|||||||
.await?;
|
.await?;
|
||||||
for pick in picks {
|
for pick in picks {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary, why)
|
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(date.to_string())
|
.bind(date.to_string())
|
||||||
.bind(pick.article.id)
|
.bind(pick.article.id)
|
||||||
@@ -615,7 +615,6 @@ impl Db {
|
|||||||
.bind(pick.position)
|
.bind(pick.position)
|
||||||
.bind(pick.is_lead)
|
.bind(pick.is_lead)
|
||||||
.bind(pick.summary.as_deref())
|
.bind(pick.summary.as_deref())
|
||||||
.bind(pick.why.as_deref())
|
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ struct IndexEntry {
|
|||||||
source: String,
|
source: String,
|
||||||
reading_minutes: i64,
|
reading_minutes: i64,
|
||||||
summary: String,
|
summary: String,
|
||||||
why: Option<String>,
|
|
||||||
understanding: Understanding,
|
understanding: Understanding,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ struct ArticleChapter {
|
|||||||
meta_line: String,
|
meta_line: String,
|
||||||
social_line: Option<String>,
|
social_line: Option<String>,
|
||||||
understanding: Understanding,
|
understanding: Understanding,
|
||||||
why: Option<String>,
|
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
excerpt_only: bool,
|
excerpt_only: bool,
|
||||||
body_html: String,
|
body_html: String,
|
||||||
@@ -411,7 +409,6 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
|
|||||||
source: index_source_line(&pick.article),
|
source: index_source_line(&pick.article),
|
||||||
reading_minutes: pick.article.reading_minutes(),
|
reading_minutes: pick.article.reading_minutes(),
|
||||||
summary: summary_for(issue, pick).unwrap_or_default().to_string(),
|
summary: summary_for(issue, pick).unwrap_or_default().to_string(),
|
||||||
why: pick.why.clone(),
|
|
||||||
understanding: understanding(pick),
|
understanding: understanding(pick),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -426,7 +423,6 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
|
|||||||
source: "Wikipedia Current Events".into(),
|
source: "Wikipedia Current Events".into(),
|
||||||
reading_minutes: 3,
|
reading_minutes: 3,
|
||||||
summary: "The day's events, as recorded by the Current Events portal.".into(),
|
summary: "The day's events, as recorded by the Current Events portal.".into(),
|
||||||
why: None,
|
|
||||||
understanding: Understanding::default(),
|
understanding: Understanding::default(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -504,7 +500,6 @@ pub fn render_article(
|
|||||||
meta_line: meta_parts.join(" \u{00b7} "),
|
meta_line: meta_parts.join(" \u{00b7} "),
|
||||||
social_line: social_line(&article.social),
|
social_line: social_line(&article.social),
|
||||||
understanding: understanding(pick),
|
understanding: understanding(pick),
|
||||||
why: pick.why.clone(),
|
|
||||||
summary: summary_for(issue, pick).map(str::to_string),
|
summary: summary_for(issue, pick).map(str::to_string),
|
||||||
excerpt_only: article.excerpt_only,
|
excerpt_only: article.excerpt_only,
|
||||||
body_html: prepare_body(&article.content_html, images_),
|
body_html: prepare_body(&article.content_html, images_),
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ pub fn issue() -> Issue {
|
|||||||
section: "Top Stories".into(),
|
section: "Top Stories".into(),
|
||||||
position: 0,
|
position: 0,
|
||||||
is_lead: true,
|
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()),
|
summary: Some("What it argues, and why it is worth the time.".into()),
|
||||||
llm: Some(Deep {
|
llm: Some(Deep {
|
||||||
quality: 9.0,
|
quality: 9.0,
|
||||||
@@ -115,7 +114,6 @@ pub fn issue() -> Issue {
|
|||||||
section: "Niche Corner".into(),
|
section: "Niche Corner".into(),
|
||||||
position: 0,
|
position: 0,
|
||||||
is_lead: false,
|
is_lead: false,
|
||||||
why: Some("A small-scene delight outside the usual technical orbit".into()),
|
|
||||||
summary: None,
|
summary: None,
|
||||||
llm: None,
|
llm: None,
|
||||||
top_interests: Vec::new(),
|
top_interests: Vec::new(),
|
||||||
|
|||||||
@@ -10,11 +10,8 @@
|
|||||||
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}
|
{% 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 %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
|
<p class="rubric">{% if let Some(kicker) = understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if why.is_some() || !understanding.interests.is_empty() %}
|
{% if !understanding.interests.is_empty() %}
|
||||||
<div class="why">
|
<div class="why">
|
||||||
{% if let Some(text) = why %}
|
|
||||||
<p class="why-line"><em>Why it's here: {{ text }}</em></p>
|
|
||||||
{% endif %}
|
|
||||||
{% if !understanding.interests.is_empty() %}
|
{% if !understanding.interests.is_empty() %}
|
||||||
<p class="why-matches">Matches: {{ understanding.interests_line() }}</p>
|
<p class="why-matches">Matches: {{ understanding.interests_line() }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -13,11 +13,8 @@
|
|||||||
{% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %}
|
{% 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 %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
|
<p class="rubric">{% if let Some(kicker) = entry.understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = entry.understanding.topics %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.why.is_some() || !entry.understanding.interests.is_empty() %}
|
{% if !entry.understanding.interests.is_empty() %}
|
||||||
<div class="index-why">
|
<div class="index-why">
|
||||||
{% if let Some(text) = entry.why %}
|
|
||||||
<p class="why-line"><em>Why it's here: {{ text }}</em></p>
|
|
||||||
{% endif %}
|
|
||||||
{% if !entry.understanding.interests.is_empty() %}
|
{% if !entry.understanding.interests.is_empty() %}
|
||||||
<p class="why-matches">Matches: {{ entry.understanding.interests_line() }}</p>
|
<p class="why-matches">Matches: {{ entry.understanding.interests_line() }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ img {
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.why-line {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.why-matches {
|
.why-matches {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
@@ -166,10 +166,6 @@ hr.rule {
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.why-line {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.why-matches {
|
.why-matches {
|
||||||
margin: 0.1em 0 0 0;
|
margin: 0.1em 0 0 0;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
+1
-18
@@ -648,27 +648,19 @@ async fn run_stages(
|
|||||||
.copied()
|
.copied()
|
||||||
.filter(|id| !selected_set.contains(id))
|
.filter(|id| !selected_set.contains(id))
|
||||||
.collect::<Vec<_>>();
|
.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(
|
set_candidate_stage(
|
||||||
&mut personalized,
|
&mut personalized,
|
||||||
¬_selected,
|
¬_selected,
|
||||||
"shortlisted",
|
"shortlisted",
|
||||||
Some("not_selected"),
|
Some("not_selected"),
|
||||||
);
|
);
|
||||||
let why = selected_with_why.into_iter().collect::<HashMap<_, _>>();
|
|
||||||
for candidate in &mut personalized {
|
for candidate in &mut personalized {
|
||||||
if selected_set.contains(&candidate.article.id) {
|
if selected_set.contains(&candidate.article.id) {
|
||||||
candidate.stage = "selected".into();
|
candidate.stage = "selected".into();
|
||||||
candidate.excluded_reason = None;
|
candidate.excluded_reason = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
record_candidates_with_why(ctx, &personalized, &why)
|
record_candidates(ctx, &personalized)
|
||||||
.await
|
.await
|
||||||
.context("recording selection telemetry")?;
|
.context("recording selection telemetry")?;
|
||||||
report.counts.exploration_selected = personalized
|
report.counts.exploration_selected = personalized
|
||||||
@@ -1039,14 +1031,6 @@ async fn prepare_features(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn record_candidates(ctx: &StageContext<'_>, candidates: &[Candidate]) -> Result<()> {
|
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 {
|
for candidate in candidates {
|
||||||
let json = telemetry::serialize_candidate(candidate);
|
let json = telemetry::serialize_candidate(candidate);
|
||||||
let admitted_by = if candidate.admitted_by.is_empty() {
|
let admitted_by = if candidate.admitted_by.is_empty() {
|
||||||
@@ -1067,7 +1051,6 @@ async fn record_candidates_with_why(
|
|||||||
rank_utility: candidate.rank_utility,
|
rank_utility: candidate.rank_utility,
|
||||||
cluster_id: candidate.cluster,
|
cluster_id: candidate.cluster,
|
||||||
cluster_rank: candidate.cluster_rank,
|
cluster_rank: candidate.cluster_rank,
|
||||||
editor_why: editor_why.get(&candidate.article.id).copied().flatten(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -372,8 +372,6 @@ pub struct Pick {
|
|||||||
/// Order within the section, ascending.
|
/// Order within the section, ascending.
|
||||||
pub position: i64,
|
pub position: i64,
|
||||||
pub is_lead: bool,
|
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.
|
/// Newspaper-abstract summary from stage C; `None` until editorial runs.
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
pub llm: Option<Deep>,
|
pub llm: Option<Deep>,
|
||||||
|
|||||||
@@ -472,7 +472,6 @@ pub struct HistoryRow {
|
|||||||
pub utility: String,
|
pub utility: String,
|
||||||
pub rank: String,
|
pub rank: String,
|
||||||
pub cluster: String,
|
pub cluster: String,
|
||||||
pub editor_why: Option<String>,
|
|
||||||
pub signals: SignalsView,
|
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> {
|
pub async fn run_history(db: &Db, article_id: ArticleId) -> Result<Vec<HistoryRow>, sqlx::Error> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT cr.run_id, r.date, r.status, cr.stage, cr.excluded_reason, cr.admitted_by,
|
"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.signals_json, cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank
|
||||||
cr.editor_why
|
|
||||||
FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
|
FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
|
||||||
WHERE cr.article_id = ? ORDER BY cr.run_id DESC",
|
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(),
|
(Some(id), None) => id.to_string(),
|
||||||
_ => "—".into(),
|
_ => "—".into(),
|
||||||
},
|
},
|
||||||
editor_why: row.get("editor_why"),
|
|
||||||
signals: SignalsView::from_json(&row.get::<String, _>("signals_json")),
|
signals: SignalsView::from_json(&row.get::<String, _>("signals_json")),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -865,17 +865,8 @@ pub(crate) mod tests {
|
|||||||
Some("[\"triage\",\"interest\"]"),
|
Some("[\"triage\",\"interest\"]"),
|
||||||
88.0,
|
88.0,
|
||||||
1,
|
1,
|
||||||
Some("Why one"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
2,
|
|
||||||
"selected",
|
|
||||||
None,
|
|
||||||
Some("[\"blend\"]"),
|
|
||||||
80.0,
|
|
||||||
2,
|
|
||||||
Some("Why two"),
|
|
||||||
),
|
),
|
||||||
|
(2, "selected", None, Some("[\"blend\"]"), 80.0, 2),
|
||||||
(
|
(
|
||||||
3,
|
3,
|
||||||
"shortlisted",
|
"shortlisted",
|
||||||
@@ -883,7 +874,6 @@ pub(crate) mod tests {
|
|||||||
Some("[\"triage\"]"),
|
Some("[\"triage\"]"),
|
||||||
75.0,
|
75.0,
|
||||||
3,
|
3,
|
||||||
None,
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
4,
|
4,
|
||||||
@@ -892,10 +882,9 @@ pub(crate) mod tests {
|
|||||||
Some("[\"triage\"]"),
|
Some("[\"triage\"]"),
|
||||||
60.0,
|
60.0,
|
||||||
4,
|
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);
|
let json = signals(utility / 10.0, article_id == 2);
|
||||||
telemetry::write(
|
telemetry::write(
|
||||||
&db,
|
&db,
|
||||||
@@ -910,7 +899,6 @@ pub(crate) mod tests {
|
|||||||
rank_utility: Some(rank),
|
rank_utility: Some(rank),
|
||||||
cluster_id: Some(1),
|
cluster_id: Some(1),
|
||||||
cluster_rank: Some(rank),
|
cluster_rank: Some(rank),
|
||||||
editor_why: why,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -931,7 +919,6 @@ pub(crate) mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -958,7 +945,6 @@ pub(crate) mod tests {
|
|||||||
rank_utility: None,
|
rank_utility: None,
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
cluster_rank: None,
|
cluster_rank: None,
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1008,9 +994,9 @@ pub(crate) mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO issue_articles
|
"INSERT INTO issue_articles
|
||||||
(issue_date, article_id, section, position, is_lead, summary, why)
|
(issue_date, article_id, section, position, is_lead, summary)
|
||||||
VALUES (?, 1, 'Top Stories', 0, 1, 'Summary one', 'Why one'),
|
VALUES (?, 1, 'Top Stories', 0, 1, 'Summary one'),
|
||||||
(?, 2, 'Top Stories', 1, 0, 'Summary two', 'Why two')",
|
(?, 2, 'Top Stories', 1, 0, 'Summary two')",
|
||||||
)
|
)
|
||||||
.bind(date.to_string())
|
.bind(date.to_string())
|
||||||
.bind(date.to_string())
|
.bind(date.to_string())
|
||||||
|
|||||||
@@ -506,7 +506,6 @@ pub struct CandidateView {
|
|||||||
pub fit: String,
|
pub fit: String,
|
||||||
pub exploration: bool,
|
pub exploration: bool,
|
||||||
pub auto_include: bool,
|
pub auto_include: bool,
|
||||||
pub editor_why: Option<String>,
|
|
||||||
pub signals: SignalsView,
|
pub signals: SignalsView,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +540,6 @@ impl CandidateView {
|
|||||||
fit: fmt_opt(row.get("fit"), 1),
|
fit: fmt_opt(row.get("fit"), 1),
|
||||||
exploration: signals.exploration,
|
exploration: signals.exploration,
|
||||||
auto_include: signals.auto_include,
|
auto_include: signals.auto_include,
|
||||||
editor_why: row.get("editor_why"),
|
|
||||||
signals,
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,7 +573,7 @@ pub async fn candidates(
|
|||||||
"SELECT cr.article_id, COALESCE(a.title, '') AS title,
|
"SELECT cr.article_id, COALESCE(a.title, '') AS title,
|
||||||
COALESCE(e.feed_title, '') AS feed_title, a.word_count,
|
COALESCE(e.feed_title, '') AS feed_title, a.word_count,
|
||||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json, cr.utility,
|
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
|
t.score AS triage, d.score AS quality, d.fit AS fit
|
||||||
{CANDIDATE_FROM}{clauses}
|
{CANDIDATE_FROM}{clauses}
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
@@ -964,8 +962,6 @@ mod tests {
|
|||||||
assert_eq!(rows[0].article_id, 1);
|
assert_eq!(rows[0].article_id, 1);
|
||||||
assert_eq!(rows[0].admitted_first.as_deref(), Some("triage"));
|
assert_eq!(rows[0].admitted_first.as_deref(), Some("triage"));
|
||||||
assert_eq!(rows[0].admitted_rest, "interest");
|
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 by_rank = CandidateFilters::from_query(&query(None, Some("rank")));
|
||||||
let (rows, _) = candidates(db, seed.run_id, &by_rank, 1).await.unwrap();
|
let (rows, _) = candidates(db, seed.run_id, &by_rank, 1).await.unwrap();
|
||||||
assert_eq!(rows[0].rank, "1");
|
assert_eq!(rows[0].rank, "1");
|
||||||
@@ -1099,7 +1095,6 @@ mod tests {
|
|||||||
"near miss: {detail}"
|
"near miss: {detail}"
|
||||||
);
|
);
|
||||||
assert!(detail.contains("Gaussian Splatting"), "signals: {detail}");
|
assert!(detail.contains("Gaussian Splatting"), "signals: {detail}");
|
||||||
assert!(detail.contains("Why one"), "{detail}");
|
|
||||||
assert!(detail.contains("Alpha Blog"), "{detail}");
|
assert!(detail.contains("Alpha Blog"), "{detail}");
|
||||||
assert!(detail.contains("class=\"funnel\""), "{detail}");
|
assert!(detail.contains("class=\"funnel\""), "{detail}");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+4
-13
@@ -119,7 +119,7 @@ pub async fn load(
|
|||||||
(issue, true)
|
(issue, true)
|
||||||
} else {
|
} else {
|
||||||
let rows = sqlx::query(
|
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",
|
FROM issue_articles WHERE issue_date = ? ORDER BY section, position",
|
||||||
)
|
)
|
||||||
.bind(date.to_string())
|
.bind(date.to_string())
|
||||||
@@ -148,7 +148,6 @@ pub async fn load(
|
|||||||
section,
|
section,
|
||||||
position: pick_row.get("position"),
|
position: pick_row.get("position"),
|
||||||
is_lead: pick_row.get("is_lead"),
|
is_lead: pick_row.get("is_lead"),
|
||||||
why: pick_row.get("why"),
|
|
||||||
summary,
|
summary,
|
||||||
llm: None,
|
llm: None,
|
||||||
top_interests: Vec::new(),
|
top_interests: Vec::new(),
|
||||||
@@ -881,7 +880,6 @@ struct FullEntry {
|
|||||||
reading_minutes: i64,
|
reading_minutes: i64,
|
||||||
is_lead: bool,
|
is_lead: bool,
|
||||||
summary: String,
|
summary: String,
|
||||||
why: Option<String>,
|
|
||||||
understanding: chapters::Understanding,
|
understanding: chapters::Understanding,
|
||||||
rating: Option<RatingWidget>,
|
rating: Option<RatingWidget>,
|
||||||
}
|
}
|
||||||
@@ -950,7 +948,6 @@ struct ArticleTemplate {
|
|||||||
byline: Option<String>,
|
byline: Option<String>,
|
||||||
source: Source,
|
source: Source,
|
||||||
meta_line: String,
|
meta_line: String,
|
||||||
why: Option<String>,
|
|
||||||
social_line: Option<String>,
|
social_line: Option<String>,
|
||||||
understanding: chapters::Understanding,
|
understanding: chapters::Understanding,
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
@@ -1029,7 +1026,6 @@ pub async fn render_full(
|
|||||||
summary: summary_for(&view.issue, pick)
|
summary: summary_for(&view.issue, pick)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
why: pick.why.clone(),
|
|
||||||
understanding: chapters::understanding(pick),
|
understanding: chapters::understanding(pick),
|
||||||
rating: is_admin.then(|| {
|
rating: is_admin.then(|| {
|
||||||
RatingWidget::for_issue(
|
RatingWidget::for_issue(
|
||||||
@@ -1132,7 +1128,6 @@ pub async fn article(
|
|||||||
thousands(article.word_count),
|
thousands(article.word_count),
|
||||||
article.reading_minutes()
|
article.reading_minutes()
|
||||||
),
|
),
|
||||||
why: pick.why.clone(),
|
|
||||||
social_line: chapters::social_line(&article.social),
|
social_line: chapters::social_line(&article.social),
|
||||||
understanding: chapters::understanding(pick),
|
understanding: chapters::understanding(pick),
|
||||||
summary: summary_for(&view.issue, pick).map(str::to_string),
|
summary: summary_for(&view.issue, pick).map(str::to_string),
|
||||||
@@ -1636,7 +1631,6 @@ mod tests {
|
|||||||
rank_utility: Some(3),
|
rank_utility: Some(3),
|
||||||
cluster_id: Some(1),
|
cluster_id: Some(1),
|
||||||
cluster_rank: Some(2),
|
cluster_rank: Some(2),
|
||||||
editor_why: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1844,7 +1838,6 @@ mod tests {
|
|||||||
assert!(html.contains("The Lead Story"));
|
assert!(html.contains("The Lead Story"));
|
||||||
assert!(html.contains("Hacker News"));
|
assert!(html.contains("Hacker News"));
|
||||||
assert!(html.contains("What it argues, and why it is worth the time."));
|
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("Two stories today"));
|
||||||
assert!(!html.contains("Something happened"));
|
assert!(!html.contains("Something happened"));
|
||||||
assert!(!html.contains("Body of"));
|
assert!(!html.contains("Body of"));
|
||||||
@@ -1903,7 +1896,6 @@ mod tests {
|
|||||||
1
|
1
|
||||||
);
|
);
|
||||||
assert!(feed.contains("What it argues, and why it is worth the time."));
|
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("Something happened"));
|
||||||
assert!(!feed.contains("Two stories today"));
|
assert!(!feed.contains("Two stories today"));
|
||||||
assert!(!feed.contains("Body of"));
|
assert!(!feed.contains("Body of"));
|
||||||
@@ -2000,7 +1992,6 @@ mod tests {
|
|||||||
assert!(issue.contains("Two stories today"));
|
assert!(issue.contains("Two stories today"));
|
||||||
assert!(issue.contains("What it argues"));
|
assert!(issue.contains("What it argues"));
|
||||||
assert!(issue.contains("A short abstract for the second piece"));
|
assert!(issue.contains("A short abstract for the second piece"));
|
||||||
assert!(issue.contains("Why it"));
|
|
||||||
assert!(issue.contains("Software engineering · Analysis"));
|
assert!(issue.contains("Software engineering · Analysis"));
|
||||||
assert!(issue.contains("copy-on-write · ZFS"));
|
assert!(issue.contains("copy-on-write · ZFS"));
|
||||||
assert!(issue.contains("Matches: Filesystems · Rust"));
|
assert!(issue.contains("Matches: Filesystems · Rust"));
|
||||||
@@ -2036,13 +2027,13 @@ mod tests {
|
|||||||
assert!(article.contains("loading=\"lazy\""));
|
assert!(article.contains("loading=\"lazy\""));
|
||||||
assert!(article.contains("referrerpolicy=\"no-referrer\""));
|
assert!(article.contains("referrerpolicy=\"no-referrer\""));
|
||||||
let rubric_position = article.find("Software engineering · Analysis").unwrap();
|
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
|
let summary_position = article
|
||||||
.find("What it argues, and why it is worth the time.")
|
.find("What it argues, and why it is worth the time.")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let social_position = article.find("342 on HN").unwrap();
|
let social_position = article.find("342 on HN").unwrap();
|
||||||
assert!(rubric_position < why_position);
|
assert!(rubric_position < matches_position);
|
||||||
assert!(why_position < summary_position);
|
assert!(matches_position < summary_position);
|
||||||
assert!(summary_position < social_position);
|
assert!(summary_position < social_position);
|
||||||
assert!(article.contains("A Niche Delight"));
|
assert!(article.contains("A Niche Delight"));
|
||||||
assert!(article.contains("rel=\"next\""));
|
assert!(article.contains("rel=\"next\""));
|
||||||
|
|||||||
+6
-8
@@ -53,7 +53,6 @@ pub struct PublicEntry {
|
|||||||
pub reading_minutes: i64,
|
pub reading_minutes: i64,
|
||||||
pub word_count: i64,
|
pub word_count: i64,
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
pub why: Option<String>,
|
|
||||||
pub understanding: crate::epub::chapters::Understanding,
|
pub understanding: crate::epub::chapters::Understanding,
|
||||||
pub comment_links: Vec<CommentLink>,
|
pub comment_links: Vec<CommentLink>,
|
||||||
pub is_lead: bool,
|
pub is_lead: bool,
|
||||||
@@ -132,7 +131,6 @@ impl From<&Issue> for PublicIssue {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|summary| !summary.is_empty())
|
.filter(|summary| !summary.is_empty())
|
||||||
.map(str::to_string),
|
.map(str::to_string),
|
||||||
why: pick.why.clone(),
|
|
||||||
understanding: crate::epub::chapters::understanding(pick),
|
understanding: crate::epub::chapters::understanding(pick),
|
||||||
comment_links,
|
comment_links,
|
||||||
is_lead: pick.is_lead,
|
is_lead: pick.is_lead,
|
||||||
@@ -423,7 +421,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[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 source = crate::epub::fixtures::issue();
|
||||||
let public = PublicIssue::from(&source);
|
let public = PublicIssue::from(&source);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -442,19 +440,19 @@ mod tests {
|
|||||||
assert!(html.contains("Hacker News"));
|
assert!(html.contains("Hacker News"));
|
||||||
assert!(html.contains("What it argues, and why it is worth the time."));
|
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("A short abstract for the second piece."));
|
||||||
assert!(html.contains("The systems story with enough operational detail to matter"));
|
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("A small-scene delight outside the usual technical orbit"));
|
||||||
assert!(html.contains("Software engineering · Analysis"));
|
assert!(html.contains("Software engineering · Analysis"));
|
||||||
assert!(html.contains("copy-on-write · ZFS"));
|
assert!(html.contains("copy-on-write · ZFS"));
|
||||||
assert!(html.contains("Matches: Filesystems · Rust"));
|
assert!(html.contains("Matches: Filesystems · Rust"));
|
||||||
let rubric_position = html.find("Software engineering · Analysis").unwrap();
|
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
|
let summary_position = html
|
||||||
.find("What it argues, and why it is worth the time.")
|
.find("What it argues, and why it is worth the time.")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let comments_position = html.find(">Hacker News:").unwrap();
|
let comments_position = html.find(">Hacker News:").unwrap();
|
||||||
assert!(rubric_position < why_position);
|
assert!(rubric_position < matches_position);
|
||||||
assert!(why_position < summary_position);
|
assert!(matches_position < summary_position);
|
||||||
assert!(summary_position < comments_position);
|
assert!(summary_position < comments_position);
|
||||||
for private in [
|
for private in [
|
||||||
"Two stories today",
|
"Two stories today",
|
||||||
|
|||||||
@@ -12,5 +12,4 @@
|
|||||||
<td class="num">{{ candidate.quality }}</td>
|
<td class="num">{{ candidate.quality }}</td>
|
||||||
<td class="num">{{ candidate.fit }}</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-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>
|
</tr>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<h2>Run history</h2>
|
<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">
|
{% 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>
|
<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-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>
|
<td class="cell-tight text-muted">{{ row.date }}</td>
|
||||||
@@ -53,7 +53,6 @@
|
|||||||
<td class="num">{{ row.utility }}</td>
|
<td class="num">{{ row.utility }}</td>
|
||||||
<td class="num">{{ row.rank }}</td>
|
<td class="num">{{ row.rank }}</td>
|
||||||
<td class="num">{{ row.cluster }}</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 %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<h2>Neighbours and interests</h2>
|
<h2>Neighbours and interests</h2>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% 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>
|
{% 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>
|
<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="14" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
<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" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
<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 %}
|
{% 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>
|
<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>
|
</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 %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<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 %}
|
{% 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>
|
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
|
||||||
{% endif %}</article>{% endblock %}
|
{% endif %}</article>{% endblock %}
|
||||||
|
|||||||
@@ -619,11 +619,6 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
|||||||
"the model's summaries were used, not excerpts"
|
"the model's summaries were used, not excerpts"
|
||||||
);
|
);
|
||||||
assert!(editorial_doc.front_page_html.contains("storage internals"));
|
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).
|
// Every scripted response was consumed, and the meter priced them (§3.6).
|
||||||
assert_eq!(backend.calls(), 1 + 1 + 5 + 1);
|
assert_eq!(backend.calls(), 1 + 1 + 5 + 1);
|
||||||
let total = meter.total();
|
let total = meter.total();
|
||||||
|
|||||||
Vendored
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"picks": [
|
"picks": [
|
||||||
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true, "why": "The migration post-mortem with the invoices still attached" },
|
{ "id": 101, "section": "Top Stories", "position": 1, "lead_story": true },
|
||||||
{ "id": 102, "section": "Tech & Engineering", "position": 1, "lead_story": false, "why": "A failover story you'd argue with over coffee" },
|
{ "id": 102, "section": "Tech & Engineering", "position": 1, "lead_story": false },
|
||||||
{ "id": 103, "section": "Tech & Engineering", "position": 2, "lead_story": false, "why": "Rare first-hand detail on a tool you use daily" },
|
{ "id": 103, "section": "Tech & Engineering", "position": 2, "lead_story": false },
|
||||||
{ "id": 104, "section": "AI & Machine Learning", "position": 1, "lead_story": false, "why": "The one benchmark piece this week that shows its work" },
|
{ "id": 104, "section": "AI & Machine Learning", "position": 1, "lead_story": false },
|
||||||
{ "id": 105, "section": "Boston & Local", "position": 1, "lead_story": false, "why": "MBTA slow zones charted by a rider, not a press office" },
|
{ "id": 105, "section": "Boston & Local", "position": 1, "lead_story": false },
|
||||||
{ "id": 106, "section": "Culture & Essays", "position": 1 },
|
{ "id": 106, "section": "Culture & Essays", "position": 1 },
|
||||||
{ "section": "Niche Corner", "position": 2, "lead_story": false },
|
{ "section": "Niche Corner", "position": 2, "lead_story": false },
|
||||||
"the model sometimes trails off like this"
|
"the model sometimes trails off like this"
|
||||||
|
|||||||
Reference in New Issue
Block a user