//! Editor-first summaries and The Brief, with per-call bulk fallback (§14). use std::collections::BTreeMap; use std::fmt::Write as _; use futures::{StreamExt, stream}; use serde::Deserialize; use super::llm::{LlmClient, LlmError, Llms}; use super::{escape_html, prompt_text, text_to_paragraphs, truncate_tokens, truncate_words}; use crate::config::{EditorialConfig, SummaryModel}; use crate::extract::EXCERPT_NOTE; use crate::types::{Article, ArticleId, Editorial, Lineup, Pick}; pub const FALLBACK_SUMMARY_WORDS: usize = 45; pub const SUMMARY_INSTRUCTIONS: &str = "\ TASK: write the newspaper abstract for one article in today's issue. Two or three sentences, 40–70 words, present tense, third person. It runs under \ the headline in the \"In This Issue\" page, so the reader decides from it alone \ whether to open the piece. DO - Say what the article actually argues, reports or builds — the specific claim, \ number, method or story, not the topic. - Add the one detail that makes it worth his time: the surprising result, the \ scale, the person involved, the unusual method. - Match the piece's register: a technical post-mortem gets a technical abstract, \ an essay gets an essayistic one. - Stay strictly inside the supplied text. DO NOT - Tease (\"you won't believe what happens next\"), moralize, or address the \ reader as \"you\". - Open with \"This article…\", \"The author…\", \"In this post…\", or repeat the \ headline's words. - Invent facts, names, numbers or conclusions that are not in the text. - Recommend, rate or editorialize — that is the front page's job. - Label the abstract. Never append \"(Excerpt.)\", \"(Summary.)\" or any note \ about the text you were given. PARTIAL TEXT - Text marked \"truncated for length\" is the opening of a longer piece that the \ reader gets in full. Write the abstract from what is there, with no caveat. - Text marked \"opening excerpt only\" is all the paper has of a piece that \ lives behind the link. Summarize what the opening establishes, and end with one \ plain clause that only the opening was available — not a parenthetical. Return JSON exactly: {\"summary\": \"\"}"; /// The Brief instructions (§14.2). pub const BRIEF_INSTRUCTIONS: &str = r#"TASK: write "The Brief" for today's issue — the note at the top of the paper. 120-200 words, one or two paragraphs. It must earn its place: if a reader skipped it, what would he miss? Name at least three of today's picks by title and say the specific thing that makes each worth his time (the result, the argument, the scale, the person). If there is a thread connecting several pieces, say it in one sentence; if there is not, do not invent one. If the issue is short, say why in one clause. Do not: welcome the reader, describe the weather, summarize every section, use "delve", "dive", "explore", "a mix of", "something for everyone", or any sentence that could introduce any other issue. No headings. No bullet points. Return JSON exactly: {"brief": ""}"#; #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BriefResponse { #[serde(default)] pub brief: String, } #[derive(Debug, Clone, Default, Deserialize)] struct SummaryResponse { #[serde(default)] summary: String, } /// How much of the piece the summarizer is looking at. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SummarySource { /// The extracted article body (possibly cut to the token budget). FullText, /// Only the feed's teaser was available (paywall, stub, fetch failure). ExcerptOnly, } impl SummarySource { pub fn for_article(article: &Article) -> Self { if article.excerpt_only { Self::ExcerptOnly } else { Self::FullText } } } fn summary_prompt( title: &str, body_html: &str, source: SummarySource, input_tokens: usize, ) -> String { // The extractor's reader-facing note must not reach the model: it mirrors it. let text = prompt_text(body_html).replace(EXCERPT_NOTE, ""); let body = truncate_tokens(text.trim(), input_tokens); let label = match (source, body.ends_with('…')) { (SummarySource::ExcerptOnly, _) => " (opening excerpt only)", (SummarySource::FullText, true) => " (truncated for length)", (SummarySource::FullText, false) => "", }; let mut prompt = String::with_capacity(body.len() + SUMMARY_INSTRUCTIONS.len() + 256); prompt.push_str(SUMMARY_INSTRUCTIONS); let _ = write!( prompt, "\n\nHEADLINE: {}\n\nARTICLE TEXT{}:\n{}\n", title.trim(), label, if body.is_empty() { "(no body text was extracted; summarize from the headline alone and say the full text was unavailable)" } else { &body } ); prompt } /// Drop a trailing label such as `(Excerpt.)` or `(Excerpt only.)` that a /// model appends despite the instructions. Only a short parenthetical that /// starts with "excerpt" or "summary" is removed; real sentences stay. pub fn strip_trailing_label(summary: &str) -> &str { let trimmed = summary.trim_end(); let Some(open) = trimmed.rfind('(') else { return trimmed; }; let tail = &trimmed[open..]; let inner = tail .trim_start_matches('(') .trim_end_matches(')') .trim() .to_ascii_lowercase(); let is_label = tail.ends_with(')') && inner.len() <= 24 && (inner.starts_with("excerpt") || inner.starts_with("summary")); if is_label { trimmed[..open].trim_end() } else { trimmed } } pub async fn summarize_article( llm: &LlmClient, title: &str, body_html: &str, source: SummarySource, input_tokens: usize, temperature: f32, ) -> Result { let prompt = summary_prompt(title, body_html, source, input_tokens); let response: SummaryResponse = llm.complete_json(&prompt, temperature).await?; let summary = strip_trailing_label(&response.summary).to_string(); if summary.is_empty() { return Err(LlmError::empty_response(llm.provider())); } Ok(summary) } /// `(primary, fallback)` for the summaries per `editorial.summary_model` (§14.1). fn summary_clients(llms: &Llms, model: SummaryModel) -> (Option<&LlmClient>, Option<&LlmClient>) { match model { SummaryModel::Bulk => (llms.bulk.as_ref(), None), SummaryModel::Editor => { let primary = llms.editor_or_bulk(); let fallback = primary.and_then(|client| { llms.bulk .as_ref() .filter(|bulk| bulk.provider != client.provider) }); (primary, fallback) } } } async fn summarize_pick( pick: &Pick, primary: Option<&LlmClient>, fallback: Option<&LlmClient>, config: &EditorialConfig, temperature: f32, ) -> Option { let primary = primary?; match summarize_article( primary, &pick.article.title, &pick.article.content_html, SummarySource::for_article(&pick.article), config.summary_input_tokens, temperature, ) .await { Ok(summary) => Some(summary), Err(error) => { let Some(fallback) = fallback else { tracing::warn!(article_id = pick.article.id, %error, "summary failed; using excerpt"); return None; }; tracing::warn!(article_id = pick.article.id, %error, "editor summary failed; retrying on bulk"); summarize_article( fallback, &pick.article.title, &pick.article.content_html, SummarySource::for_article(&pick.article), config.summary_input_tokens, temperature, ) .await .map_err(|fallback_error| { tracing::warn!(article_id = pick.article.id, %fallback_error, "bulk summary failed; using excerpt"); }) .ok() } } } pub async fn summarize_all( llms: &Llms, lineup: &Lineup, config: &EditorialConfig, temperature: f32, ) -> BTreeMap { let (primary, fallback) = summary_clients(llms, config.summary_model); // The summary provider's own `max_concurrent_requests` bounds the fan-out. let concurrency = primary .map(|client| client.max_concurrent_requests) .unwrap_or(1) .max(1); stream::iter(lineup.picks.iter()) .map(|pick| async move { let summary = summarize_pick(pick, primary, fallback, config, temperature).await; (pick.article.id, summary) }) .buffer_unordered(concurrency) .filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) }) .collect() .await } pub fn build_brief_prompt(lineup: &Lineup, summaries: &BTreeMap) -> String { let mut prompt = String::with_capacity(4096); prompt.push_str(BRIEF_INSTRUCTIONS); let _ = write!( prompt, "\n\nISSUE: {} · {} articles\n\nLINEUP\n", lineup.date, lineup.picks.len() ); for section in &lineup.section_order { let _ = writeln!(prompt, "\n## {section}"); for pick in lineup.section_picks(section) { let quality = pick .llm .as_ref() .map(|assessment| format!("{:.1}", assessment.quality)) .unwrap_or_else(|| "unassessed".into()); let fit = pick .llm .as_ref() .map(|assessment| format!("{:.1}", assessment.fit)) .unwrap_or_else(|| "unassessed".into()); let summary = summaries .get(&pick.article.id) .cloned() .unwrap_or_else(|| excerpt_summary(pick)); let _ = writeln!( prompt, "- {}\n feed: {}\n quality: {}\n fit: {}\n summary: {}", pick.article.title.trim(), pick.article.feed_title.trim(), quality, fit, summary ); } } prompt } pub async fn brief( llms: &Llms, lineup: &Lineup, summaries: &BTreeMap, temperature: f32, ) -> Result { let prompt = build_brief_prompt(lineup, summaries); let Some(primary) = llms.editor_or_bulk() else { return Err(LlmError::api("editorial", "no provider configured")); }; let response = match primary .complete_json::(&prompt, temperature) .await { Ok(response) => response, Err(error) => { let Some(fallback) = llms .bulk .as_ref() .filter(|bulk| bulk.provider != primary.provider) else { return Err(error); }; tracing::warn!(%error, "brief failed on editor; retrying on bulk"); fallback .complete_json::(&prompt, temperature) .await? } }; let brief = response.brief.trim().to_string(); if brief.is_empty() { return Err(LlmError::empty_response(primary.provider())); } Ok(brief) } pub fn excerpt_summary(pick: &Pick) -> String { let text = truncate_words( &prompt_text(&pick.article.content_html), FALLBACK_SUMMARY_WORDS, ); if text.is_empty() { format!( "From {}. (No preview text was available; open the article to read it.)", if pick.article.feed_title.is_empty() { "an unknown feed" } else { pick.article.feed_title.trim() } ) } else { text } } pub fn fallback_front_page_html(lineup: &Lineup) -> String { let minutes: i64 = lineup .picks .iter() .map(|pick| pick.article.reading_minutes()) .sum(); let mut text = format!( "Today's issue collects {} articles across {} sections — about {} minutes of reading. Editorial notes are unavailable for this issue, so the lineup speaks for itself.", lineup.picks.len(), lineup.section_order.len(), minutes ); if let Some(lead) = lineup.lead() { let _ = write!( text, "\n\nLeading today: “{}” ({}).", lead.article.title.trim(), lead.article.feed_title.trim() ); } text_to_paragraphs(&text) } pub fn fallback_editorial(lineup: &Lineup) -> Editorial { Editorial { front_page_html: fallback_front_page_html(lineup), summaries: lineup .picks .iter() .map(|pick| (pick.article.id, excerpt_summary(pick))) .collect(), } } /// Wall-clock milliseconds of the two editorial calls, for the run report's /// `summaries` and `brief` stage timings (§15.4). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct EditorialTimings { pub summaries_ms: i64, pub brief_ms: i64, } pub async fn run( llms: &Llms, lineup: &Lineup, config: &EditorialConfig, temperature: f32, ) -> Editorial { run_timed(llms, lineup, config, temperature).await.0 } /// [`run`], also reporting how long the summaries and the brief took. pub async fn run_timed( llms: &Llms, lineup: &Lineup, config: &EditorialConfig, temperature: f32, ) -> (Editorial, EditorialTimings) { if lineup.picks.is_empty() { return (fallback_editorial(lineup), EditorialTimings::default()); } let started = std::time::Instant::now(); let mut summaries = summarize_all(llms, lineup, config, temperature).await; for pick in &lineup.picks { summaries .entry(pick.article.id) .or_insert_with(|| excerpt_summary(pick)); } let summaries_ms = started.elapsed().as_millis() as i64; let started = std::time::Instant::now(); let front_page_html = match brief(llms, lineup, &summaries, temperature).await { Ok(text) => text_to_paragraphs(&text), Err(error) => { tracing::warn!(%error, "brief failed; using fallback front page"); fallback_front_page_html(lineup) } }; let brief_ms = started.elapsed().as_millis() as i64; ( Editorial { front_page_html, summaries, }, EditorialTimings { summaries_ms, brief_ms, }, ) } pub fn summary_to_html(summary: &str) -> String { format!("

{}

", escape_html(summary.trim())) } #[cfg(test)] mod tests { use super::*; use crate::config::ProviderConfig; use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, UsageMeter}; use crate::curate::prefilter::tests::article; use crate::types::TokenUsage; use std::sync::Arc; const BRIEF_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/claude_brief.json" )); fn pick(id: i64, title: &str, section: &str, is_lead: bool) -> Pick { let mut a = article(id, title, 900); a.content_html = format!("

{title} opens with a specific, concrete claim.

"); Pick { article: a, section: section.into(), position: 1, is_lead, summary: None, llm: None, top_interests: Vec::new(), discussion: None, } } fn lineup() -> Lineup { Lineup { date: "2026-08-15".parse().expect("date"), picks: vec![ pick(1, "Migrating 40TB off Postgres", "Top Stories", true), pick(2, "The MBTA slow-zone dataset", "Boston & Local", false), ], section_order: vec!["Top Stories".into(), "Boston & Local".into()], } } fn mock(provider: &'static str, backend: Arc, limit: f64) -> LlmClient { let prices = if provider == "anthropic" { PriceTable::from(&ProviderConfig::anthropic()) } else { PriceTable::from(&ProviderConfig::deepseek()) }; LlmClient::with_backend_options( provider, "model", "SYSTEM".into(), None, UsageMeter::with_prices(prices, limit), backend as Arc, ) } fn bulk_only(backend: Arc, limit: f64) -> Llms { Llms { bulk: Some(mock("deepseek", backend, limit)), editor: None, } } fn editor_and_bulk(editor: Arc, bulk: Arc) -> Llms { Llms { bulk: Some(mock("deepseek", bulk, 2.0)), editor: Some(mock("anthropic", editor, 3.0)), } } fn config() -> EditorialConfig { EditorialConfig::default() } #[tokio::test] async fn summary_prompt_carries_headline_and_truncated_body() { let backend = Arc::new(MockBackend::new()); backend.push( r#"{"summary": "A team moves 40TB of relational data off Postgres and documents every rollback."}"#, TokenUsage::default(), ); let llm = mock("deepseek", Arc::clone(&backend), 2.0); let body = format!("

{}

", "word ".repeat(20_000)); let summary = summarize_article( &llm, "Migrating 40TB", &body, SummarySource::FullText, 3_000, 0.8, ) .await .expect("summary"); assert!(summary.starts_with("A team moves 40TB")); let prompt = &backend.prompts()[0].user; assert!(prompt.starts_with(SUMMARY_INSTRUCTIONS)); assert!(prompt.contains("HEADLINE: Migrating 40TB")); assert!(prompt.contains("(truncated for length)")); // 3k tokens ≈ 12k characters of body, not the full 100k. assert!(prompt.len() < 16_000, "prompt was {} bytes", prompt.len()); } #[tokio::test] async fn excerpt_only_bodies_are_labelled_and_the_reader_note_is_dropped() { let backend = Arc::new(MockBackend::new()); backend.push( r#"{"summary": "The opening sets up a failover that went wrong; only the opening was available."}"#, TokenUsage::default(), ); let llm = mock("deepseek", Arc::clone(&backend), 2.0); let body = format!("

A teaser paragraph.

{EXCERPT_NOTE}

"); summarize_article( &llm, "Failover", &body, SummarySource::ExcerptOnly, 3_000, 0.8, ) .await .expect("summary"); let prompt = &backend.prompts()[0].user; assert!(prompt.contains("ARTICLE TEXT (opening excerpt only):\nA teaser paragraph.")); assert!(!prompt.contains("(truncated for length)")); assert!( !prompt.contains(EXCERPT_NOTE), "the reader-facing note leaked into the prompt" ); } #[test] fn instructions_forbid_labelling_and_distinguish_truncation_from_excerpts() { assert!(!SUMMARY_INSTRUCTIONS.contains("say it is an excerpt")); assert!(SUMMARY_INSTRUCTIONS.contains("Never append \"(Excerpt.)\"")); assert!(SUMMARY_INSTRUCTIONS.contains("truncated for length")); assert!(SUMMARY_INSTRUCTIONS.contains("opening excerpt only")); } #[tokio::test] async fn a_trailing_excerpt_label_is_scrubbed_from_the_summary() { let backend = Arc::new(MockBackend::new()); backend.push( r#"{"summary": "A team moves 40TB off Postgres. (Excerpt.)"}"#, TokenUsage::default(), ); let llm = mock("deepseek", Arc::clone(&backend), 2.0); let summary = summarize_article( &llm, "Migrating 40TB", "

body

", SummarySource::FullText, 3_000, 0.8, ) .await .expect("summary"); assert_eq!(summary, "A team moves 40TB off Postgres."); } #[test] fn strip_trailing_label_only_removes_short_labels() { assert_eq!(strip_trailing_label("Done. (Excerpt.)"), "Done."); assert_eq!(strip_trailing_label("Done. (Excerpt only.) "), "Done."); assert_eq!(strip_trailing_label("Done.(summary)"), "Done."); assert_eq!( strip_trailing_label("A 40TB (roughly) migration."), "A 40TB (roughly) migration." ); assert_eq!( strip_trailing_label("Ships the tool (a Rust CLI)."), "Ships the tool (a Rust CLI)." ); assert_eq!(strip_trailing_label("No label here."), "No label here."); } #[tokio::test] async fn the_brief_is_parsed_and_rendered() { let backend = Arc::new(MockBackend::new()); backend.push(BRIEF_FIXTURE, TokenUsage::default()); let llms = bulk_only(Arc::clone(&backend), 2.0); let lineup = lineup(); let summaries = BTreeMap::from([ (1, "A migration story with numbers.".to_string()), (2, "Transit data, charted.".to_string()), ]); let text = brief(&llms, &lineup, &summaries, 0.8).await.expect("brief"); assert!(text.split_whitespace().count() > 100); assert!(text.contains("Migrating 40TB off Postgres")); let prompt = &backend.prompts()[0].user; assert!(prompt.starts_with(BRIEF_INSTRUCTIONS)); assert!(prompt.contains("## Top Stories")); assert!(prompt.contains("## Boston & Local")); assert!(prompt.contains("- Migrating 40TB off Postgres")); assert!(prompt.contains("summary: A migration story with numbers.")); assert!(prompt.contains("quality: unassessed")); assert!(prompt.contains("fit: unassessed")); assert!(prompt.contains("2026-08-15")); assert!( !prompt.contains("section_intros"), "section intros are gone" ); } #[tokio::test] async fn full_stage_c_produces_summaries_and_the_brief() { let backend = Arc::new(MockBackend::new()); backend.push(r#"{"summary": "First abstract."}"#, TokenUsage::default()); backend.push(r#"{"summary": "Second abstract."}"#, TokenUsage::default()); backend.push(BRIEF_FIXTURE, TokenUsage::default()); let llms = bulk_only(Arc::clone(&backend), 2.0); let editorial = run(&llms, &lineup(), &config(), 0.8).await; assert_eq!(backend.calls(), 3, "one call per article plus the brief"); assert_eq!(editorial.summaries.len(), 2); assert_eq!(editorial.summaries[&1], "First abstract."); assert!(editorial.front_page_html.starts_with("

")); assert!(editorial.front_page_html.contains("

")); assert!( editorial .front_page_html .contains("Migrating 40TB off Postgres") ); assert!(!editorial.front_page_html.contains("")); // An empty lineup is still a valid editorial. let empty = Lineup { date: "2026-08-15".parse().expect("date"), picks: vec![], section_order: vec![], }; let editorial = fallback_editorial(&empty); assert!(editorial.summaries.is_empty()); assert!(editorial.front_page_html.contains("0 articles")); } #[test] fn excerpt_summary_handles_empty_bodies() { let mut p = pick(9, "No body here", "Top Stories", false); p.article.content_html = String::new(); assert!(excerpt_summary(&p).contains("No preview text")); assert_eq!(summary_to_html("a c"), "

a <b> c

"); } }