Show what the pipeline understood about each article

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
2026-09-08 19:44:00 +00:00
co-authored by Claude Fable 5.1
parent 7ce8c65939
commit bedbcb218f
17 changed files with 285 additions and 12 deletions
+143
View File
@@ -43,6 +43,7 @@ struct IndexEntry {
reading_minutes: i64,
summary: String,
why: Option<String>,
understanding: Option<String>,
}
struct IndexSection {
@@ -79,6 +80,7 @@ struct ArticleChapter {
byline: Option<String>,
meta_line: String,
social_line: Option<String>,
understanding: Option<String>,
why: Option<String>,
summary: Option<String>,
excerpt_only: bool,
@@ -191,6 +193,84 @@ pub fn social_line(social: &[SocialRef]) -> Option<String> {
}
}
fn facet_label(token: &str) -> Option<String> {
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<String> {
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::<Vec<_>>();
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<Chapter, EpubError> {
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<Chapter, EpubError> {
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 &#38; 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
+20 -1
View File
@@ -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();
+3
View File
@@ -26,6 +26,9 @@
</div>
<hr class="rule"/>
<div class="article-footer">
{% if let Some(line) = understanding %}
<p class="understood">{{ line }}</p>
{% endif %}
{% if let Some(links) = rating %}
<p class="rating">Was this a good pick? &#160; <a href="{{ links.loved_url }}">[ Loved it ]</a> &#160; <a href="{{ links.good_url }}">[ Good ]</a> &#160; <a href="{{ links.not_for_me_url }}">[ Not for me ]</a> &#160;&#160;&#160; <a href="{{ read_online_url }}">Read online &#8599;</a></p>
{% else %}
+3
View File
@@ -15,6 +15,9 @@
{% endif %}
{% if let Some(text) = entry.why %}
<p class="index-why"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
{% if let Some(line) = entry.understanding %}
<p class="index-understood">{{ line }}</p>
{% endif %}
</li>
{% endfor %}
+8 -1
View File
@@ -78,6 +78,8 @@ img {
.stats,
.meta,
.social,
.understood,
.index-understood,
.index-meta,
.discussion-note,
.attribution,
@@ -85,6 +87,10 @@ img {
font-size: 0.85em;
}
.understood {
margin: 0 0 0.5em 0;
}
.dateline,
.stats {
text-align: center;
@@ -122,7 +128,8 @@ ul.index-list {
.index-title,
.index-meta,
.index-summary {
.index-summary,
.index-understood {
margin: 0;
}
+10
View File
@@ -154,6 +154,16 @@ hr.rule {
font-variant: small-caps;
}
.understood, .index-understood {
margin: 0 0 0.75em 0;
font-size: 0.8em;
color: #444444;
}
.index-understood {
margin: 0.2em 0 0 0;
}
.summary {
margin: 0.5em 0 0 0;
font-style: italic;