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