From bedbcb218f048719b40216e8b793aa2792c9b798 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Tue, 8 Sep 2026 19:44:00 +0000 Subject: [PATCH] Show what the pipeline understood about each article MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A muted one-line note now follows every article: the deep assessment's topic group, format, depth and technicality, the specific topics it extracted, and the reader interests the article matched best, e.g. Software engineering · analysis essay · in depth · highly technical · Topics: copy-on-write, ZFS · Interests: Filesystems, Rust It renders on the web article page footer, on the public and private issue-page entries, in the EPUB article footer and in the EPUB "In This Issue" index (both editions), always styled like the existing meta lines so it stays secondary to the content. `Pick` gains `top_interests` (serde default, no migration), filled from the signals stage when the editor assembles the lineup; the facets were already on the issue snapshot via `Pick.llm`, so earlier issues show the facet and topic parts and only future issues carry interests. One shared builder, `understanding_line` in epub::chapters, feeds every surface. The dev seed gives four picks facets and interests so the line can be eyeballed locally. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4 --- README.md | 11 +- examples/image_audit.rs | 1 + examples/seed_dev_db.rs | 60 ++++++++++- src/curate/editor.rs | 6 ++ src/curate/editorial.rs | 1 + src/epub/chapters.rs | 143 +++++++++++++++++++++++++ src/epub/fixtures.rs | 21 +++- src/epub/templates/chapter.xhtml | 3 + src/epub/templates/in_this_issue.xhtml | 3 + src/epub/templates/style-x4.css | 9 +- src/epub/templates/style.css | 10 ++ src/types.rs | 5 + src/web/issue.rs | 6 ++ src/web/public.rs | 12 ++- src/web/templates/article.html | 2 +- src/web/templates/issue_full.html | 2 +- src/web/templates/issue_public.html | 2 +- 17 files changed, 285 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4becf4e..836ad08 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ the summaries and *The Brief*. It assembles two EPUB editions (a standard one and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and publishes the lot over its own OPDS catalog — which doubles as a [BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one. -Each article chapter ends with Loved it / Good / Not for me links that feed back -into tomorrow's curation, and a short *Behind the paper* chapter before the -colophon says what the run considered, how the deep set was admitted, whether -the learned signals were active, the ten highest-utility near misses, and what -it all cost. +Each article chapter ends with a one-line note of what the pipeline made of the +piece (topic, format, depth, and the reader interests it matched) and Loved it / +Good / Not for me links that feed back into tomorrow's curation; a short *Behind +the paper* chapter before the colophon says what the run considered, how the +deep set was admitted, whether the learned signals were active, the ten +highest-utility near misses, and what it all cost. Steady-state cost is roughly **$1/day**: $0.05–0.30 in DeepSeek tokens plus ~$0.50–0.80 for the Claude editor and a few cents of Voyage AI embeddings, each diff --git a/examples/image_audit.rs b/examples/image_audit.rs index 729c2d7..8aa11cf 100644 --- a/examples/image_audit.rs +++ b/examples/image_audit.rs @@ -372,6 +372,7 @@ fn pick_for(target: &Target, content_html: String) -> Pick { why: None, summary: None, llm: None, + top_interests: Vec::new(), discussion: None, } } diff --git a/examples/seed_dev_db.rs b/examples/seed_dev_db.rs index 9a4782c..35953ac 100644 --- a/examples/seed_dev_db.rs +++ b/examples/seed_dev_db.rs @@ -154,6 +154,10 @@ fn body_html(title: &str, words: i64) -> String { fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue { let mut issue = fixtures::issue(); + let deep = issue.lineup.picks[0] + .llm + .clone() + .expect("the fixture lead carries a deep assessment"); issue.meta.date = date; issue.meta.issue_number = issue_number; issue.meta.generated_at = generated_at; @@ -173,6 +177,59 @@ fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue { .count() as i64; let article = story_article(index, story); summaries.insert(article.id, summary.to_string()); + // A few picks carry facets and matched interests so the + // "what the pipeline understood" line shows up; the rest exercise the + // nothing-to-say path. + let understood = match index { + 0 => Some(( + ("software_engineering", "analysis_essay", "deep", "advanced"), + vec!["copy-on-write", "ZFS", "filesystem design"], + vec!["Filesystems", "Rust", "Systems programming"], + )), + 1 => Some(( + ( + "software_engineering", + "analysis_essay", + "standard", + "intermediate", + ), + vec!["feature flags", "technical debt"], + vec!["Software craft"], + )), + 3 => Some(( + ( + "software_engineering", + "first_hand_account", + "deep", + "advanced", + ), + vec!["Raft", "leader election", "incident review"], + vec![], + )), + 4 => Some(( + ("history", "analysis_essay", "deep", "nontechnical"), + vec!["map projections", "cartography"], + vec!["Maps", "History of science"], + )), + _ => None, + }; + let (llm, top_interests) = match understood { + Some(((topic_group, format, depth, technicality), topics, interests)) => ( + Some(daily_epub::types::Deep { + facets: daily_epub::types::Facets { + format: Some(format.into()), + depth: Some(depth.into()), + topic_group: Some(topic_group.into()), + technicality: Some(technicality.into()), + specific_topics: Some(topics.into_iter().map(String::from).collect()), + ..Default::default() + }, + ..deep.clone() + }), + interests.into_iter().map(String::from).collect(), + ), + None => (None, Vec::new()), + }; picks.push(Pick { discussion: (index == 0) .then(|| fixtures::discussion(article.id, article.best_entry_id)), @@ -182,7 +239,8 @@ fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue { is_lead: index == 0, why: Some(why.to_string()), summary: Some(summary.to_string()), - llm: None, + llm, + top_interests, }); } issue.meta.article_count = picks.len() as i64; diff --git a/src/curate/editor.rs b/src/curate/editor.rs index b02094b..112fe37 100644 --- a/src/curate/editor.rs +++ b/src/curate/editor.rs @@ -588,6 +588,12 @@ fn assemble( why: item.why, summary: None, llm: candidate.assessment.deep, + top_interests: candidate + .signals + .top_interests + .iter() + .map(|interest| interest.name.clone()) + .collect(), discussion: None, } }) diff --git a/src/curate/editorial.rs b/src/curate/editorial.rs index b457eef..00855e1 100644 --- a/src/curate/editorial.rs +++ b/src/curate/editorial.rs @@ -400,6 +400,7 @@ mod tests { why: Some(format!("the {title} piece you'd argue with")), summary: None, llm: None, + top_interests: Vec::new(), discussion: None, } } diff --git a/src/epub/chapters.rs b/src/epub/chapters.rs index 9eebf6e..1c58ca8 100644 --- a/src/epub/chapters.rs +++ b/src/epub/chapters.rs @@ -43,6 +43,7 @@ struct IndexEntry { reading_minutes: i64, summary: String, why: Option, + understanding: Option, } struct IndexSection { @@ -79,6 +80,7 @@ struct ArticleChapter { byline: Option, meta_line: String, social_line: Option, + understanding: Option, why: Option, summary: Option, excerpt_only: bool, @@ -191,6 +193,84 @@ pub fn social_line(social: &[SocialRef]) -> Option { } } +fn facet_label(token: &str) -> Option { + let label = match token { + "software_engineering" => "Software engineering", + "ai_ml" => "AI & ML", + "science_space" => "Science & space", + "culture_arts" => "Culture & arts", + "books_writing" => "Books & writing", + "games" => "Games", + "hardware" => "Hardware", + "internet_web" => "Internet & web", + "business_economics" => "Business & economics", + "politics_policy" => "Politics & policy", + "boston_new_england" => "Boston & New England", + "outdoors_lifestyle" => "Outdoors & lifestyle", + "history" => "History", + "reported_news" => "reported news", + "analysis_essay" => "analysis essay", + "how_to_technical" => "how-to", + "first_hand_account" => "first-hand account", + "announcement_roundup" => "announcement", + "code_repository" => "code repository", + "documentation_reference" => "documentation", + "tool_or_product_page" => "product page", + "discussion_thread" => "discussion thread", + "paper_or_report" => "paper or report", + "interview_or_transcript" => "interview", + "video_or_podcast" => "video or podcast", + "fiction_or_humor" => "fiction or humor", + "brief" => "brief", + "standard" => "standard depth", + "deep" => "in depth", + "nontechnical" => "non-technical", + "light" => "lightly technical", + "intermediate" => "moderately technical", + "advanced" => "highly technical", + "other" => return None, + unknown => return Some(unknown.replace('_', " ")), + }; + Some(label.to_string()) +} + +/// One muted line saying what the pipeline understood about an article: the +/// deep-assessment facets, the extracted topics and the best-matching reader +/// interests. `None` when there is nothing to say (no deep read, no interests). +pub fn understanding_line(pick: &Pick) -> Option { + let mut parts = Vec::new(); + if let Some(deep) = &pick.llm { + let facets = &deep.facets; + for token in [ + facets.topic_group.as_deref(), + facets.format.as_deref(), + facets.depth.as_deref(), + facets.technicality.as_deref(), + ] + .into_iter() + .flatten() + { + if let Some(label) = facet_label(token).filter(|label| !label.is_empty()) { + parts.push(label); + } + } + if let Some(topics) = &facets.specific_topics { + let topics = topics + .iter() + .map(|topic| topic.trim()) + .filter(|topic| !topic.is_empty()) + .collect::>(); + if !topics.is_empty() { + parts.push(format!("Topics: {}", topics.join(", "))); + } + } + } + if !pick.top_interests.is_empty() { + parts.push(format!("Interests: {}", pick.top_interests.join(", "))); + } + (!parts.is_empty()).then(|| parts.join(" \u{00b7} ")) +} + fn article_href(pick: &Pick) -> String { format!("{}.xhtml", pick.article.chapter_id()) } @@ -298,6 +378,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result { reading_minutes: pick.article.reading_minutes(), summary: summary_for(issue, pick).unwrap_or_default().to_string(), why: pick.why.clone(), + understanding: understanding_line(pick), }) .collect(); sections.push(IndexSection { name, entries }); @@ -312,6 +393,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result { reading_minutes: 3, summary: "The day's events, as recorded by the Current Events portal.".into(), why: None, + understanding: None, }], }); } @@ -386,6 +468,7 @@ pub fn render_article( byline: article.author.as_ref().map(|a| format!("By {a}")), meta_line: meta_parts.join(" \u{00b7} "), social_line: social_line(&article.social), + understanding: understanding_line(pick), why: pick.why.clone(), summary: summary_for(issue, pick).map(str::to_string), excerpt_only: article.excerpt_only, @@ -709,6 +792,46 @@ mod tests { assert!(social_line(&[]).is_none()); } + #[test] + fn understanding_line_includes_facets_topics_and_interests() { + let issue = issue(); + assert_eq!( + understanding_line(&issue.lineup.picks[0]).as_deref(), + Some( + "Software engineering · analysis essay · in depth · highly technical · Topics: copy-on-write, ZFS · Interests: Filesystems, Rust" + ) + ); + } + + #[test] + fn understanding_line_is_none_without_a_deep_read_or_interests() { + let issue = issue(); + assert!(understanding_line(&issue.lineup.picks[1]).is_none()); + } + + #[test] + fn understanding_line_can_contain_only_interests() { + let issue = issue(); + let mut pick = issue.lineup.picks[1].clone(); + pick.top_interests = vec!["Rust".into()]; + assert_eq!( + understanding_line(&pick).as_deref(), + Some("Interests: Rust") + ); + } + + #[test] + fn understanding_line_skips_other_topic_group() { + let issue = issue(); + let mut pick = issue.lineup.picks[0].clone(); + pick.llm.as_mut().unwrap().facets = crate::types::Facets { + topic_group: Some("other".into()), + ..crate::types::Facets::default() + }; + pick.top_interests.clear(); + assert!(understanding_line(&pick).is_none()); + } + #[test] fn hrefs_are_deterministic() { let issue = issue(); @@ -743,6 +866,12 @@ mod tests { assert!(chapter.xhtml.contains("6 min read")); assert!(chapter.xhtml.contains("What it argues")); assert!(chapter.xhtml.contains("A short abstract")); + // The lead's understanding line is on the index; the second pick has none. + assert_eq!( + chapter.xhtml.matches("class=\"index-understood\"").count(), + 1 + ); + assert!(chapter.xhtml.contains("Interests: Filesystems, Rust")); // Titles are escaped (askama emits numeric references), never injected raw. assert!(chapter.xhtml.contains("A Niche Delight & Other Tales")); assert_xml_ok(&chapter.xhtml); @@ -772,6 +901,20 @@ mod tests { assert!(chapter.xhtml.contains("/r/2026-08-15/1/down?t=")); assert!(chapter.xhtml.contains("Read online")); assert!(chapter.xhtml.contains("href=\"disc-1001.xhtml\"")); + assert!(chapter.xhtml.contains("class=\"understood\"")); + assert!(chapter.xhtml.contains( + "Software engineering · analysis essay · in depth · highly technical · Topics: copy-on-write, ZFS · Interests: Filesystems, Rust" + )); + let second = render_article( + &issue, + &issue.lineup.picks[1], + &[], + Edition::Standard, + "https://daily.hallada.net", + Some("s3cret"), + ) + .unwrap(); + assert!(!second.xhtml.contains("class=\"understood\"")); // The un-downloaded image degrades to a placeholder. assert!( chapter diff --git a/src/epub/fixtures.rs b/src/epub/fixtures.rs index f70d780..36f3cbc 100644 --- a/src/epub/fixtures.rs +++ b/src/epub/fixtures.rs @@ -88,7 +88,25 @@ pub fn issue() -> Issue { is_lead: true, why: Some("The systems story with enough operational detail to matter".into()), summary: Some("What it argues, and why it is worth the time.".into()), - llm: None, + llm: Some(Deep { + quality: 9.0, + fit: 9.0, + category: Some("Tech & Engineering".into()), + rationale: "Detailed systems analysis".into(), + paywalled_guess: false, + facets: Facets { + format: Some("analysis_essay".into()), + depth: Some("deep".into()), + topic_group: Some("software_engineering".into()), + technicality: Some("advanced".into()), + specific_topics: Some(vec!["copy-on-write".into(), "ZFS".into()]), + ..Facets::default() + }, + model: "fixture-model".into(), + prompt_version: 2, + assessed_at: timestamp(), + }), + top_interests: vec!["Filesystems".into(), "Rust".into()], discussion: Some(discussion(1, 1001)), }; let second = Pick { @@ -99,6 +117,7 @@ pub fn issue() -> Issue { why: Some("A small-scene delight outside the usual technical orbit".into()), summary: None, llm: None, + top_interests: Vec::new(), discussion: None, }; let mut summaries = BTreeMap::new(); diff --git a/src/epub/templates/chapter.xhtml b/src/epub/templates/chapter.xhtml index 5777156..35e85af 100644 --- a/src/epub/templates/chapter.xhtml +++ b/src/epub/templates/chapter.xhtml @@ -26,6 +26,9 @@
{% endblock %} diff --git a/src/web/templates/issue_full.html b/src/web/templates/issue_full.html index a9b5239..9a0d4fc 100644 --- a/src/web/templates/issue_full.html +++ b/src/web/templates/issue_full.html @@ -5,7 +5,7 @@

The Brief

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

In This Issue

- {% for section in sections %}

{{ section.name }}

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

    {{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · dashboard{% endif %}

    {% if !entry.summary.is_empty() %}

    {{ entry.summary }}

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

    Why it's here: {{ why }}

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

{{ section.name }}

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

    {{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · dashboard{% endif %}

    {% if !entry.summary.is_empty() %}

    {{ entry.summary }}

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

    Why it's here: {{ why }}

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

    {{ line }}

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

Colophon

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

Generated
{{ colophon.generated_at }}
Bulk model
{{ colophon.bulk_model }}
Editor model
{{ colophon.editor_model }}
Summaries model
{{ colophon.summaries_model }}
Entries considered
{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}
Candidates scored
{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}
Articles selected
{{ colophon.article_count }} across {{ colophon.section_count }} sections
Words
{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read
{% for cost in colophon.provider_costs %}
{{ cost.provider }} cost
{{ cost.cost }}
{% endfor %}
Total token cost
{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}
Generator
{{ colophon.generator_version }}
diff --git a/src/web/templates/issue_public.html b/src/web/templates/issue_public.html index e2aad84..82d02b6 100644 --- a/src/web/templates/issue_public.html +++ b/src/web/templates/issue_public.html @@ -4,6 +4,6 @@

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

{{ issue.stats_line }}

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

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

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

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min

{% match entry.summary %}{% when Some with (summary) %}

{{ summary }}

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

Why it's here: {{ why }}

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

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

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

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min

{% match entry.summary %}{% when Some with (summary) %}

{{ summary }}

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

Why it's here: {{ why }}

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

{{ line }}

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

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

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

Browse the archive →

{% endif %}{% endblock %}