Stop summaries ending in "(Excerpt.)"
The summary prompt told the model "if the text is a truncated excerpt, summarize only what is there and say it is an excerpt". Any article cut to the 3k-token budget tripped that clause, and the model complied with a terse "(Excerpt.)" label on the abstract. Bodies the extractor could only excerpt also carried the reader-facing "(excerpt only — read online)" note into the prompt, which the model mirrored. Separate the two cases: text truncated for length gets no caveat, and genuine feed-teaser bodies (the article's excerpt_only flag, now passed in as SummarySource) are labelled "opening excerpt only" with a request for one plain closing clause. Strip the extractor's note from the prompt text, forbid labelling the abstract outright, and scrub a short trailing "(Excerpt…)" parenthetical from the response as a guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KnYokPekNmU7LNSf6rdYAk
This commit is contained in:
+157
-14
@@ -9,7 +9,8 @@ use serde::Deserialize;
|
|||||||
use super::llm::{LlmClient, LlmError, Llms};
|
use super::llm::{LlmClient, LlmError, Llms};
|
||||||
use super::{escape_html, prompt_text, text_to_paragraphs, truncate_tokens, truncate_words};
|
use super::{escape_html, prompt_text, text_to_paragraphs, truncate_tokens, truncate_words};
|
||||||
use crate::config::{EditorialConfig, SummaryModel};
|
use crate::config::{EditorialConfig, SummaryModel};
|
||||||
use crate::types::{ArticleId, Editorial, Lineup, Pick};
|
use crate::extract::EXCERPT_NOTE;
|
||||||
|
use crate::types::{Article, ArticleId, Editorial, Lineup, Pick};
|
||||||
|
|
||||||
pub const FALLBACK_SUMMARY_WORDS: usize = 45;
|
pub const FALLBACK_SUMMARY_WORDS: usize = 45;
|
||||||
|
|
||||||
@@ -34,10 +35,17 @@ DO NOT
|
|||||||
reader as \"you\".
|
reader as \"you\".
|
||||||
- Open with \"This article…\", \"The author…\", \"In this post…\", or repeat the \
|
- Open with \"This article…\", \"The author…\", \"In this post…\", or repeat the \
|
||||||
headline's words.
|
headline's words.
|
||||||
- Invent facts, names, numbers or conclusions that are not in the text. If the \
|
- Invent facts, names, numbers or conclusions that are not in the text.
|
||||||
text is a truncated excerpt, summarize only what is there and say it is an \
|
|
||||||
excerpt.
|
|
||||||
- Recommend, rate or editorialize — that is the front page's job.
|
- 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\": \"<two or three sentences>\"}";
|
Return JSON exactly: {\"summary\": \"<two or three sentences>\"}";
|
||||||
|
|
||||||
@@ -68,19 +76,46 @@ struct SummaryResponse {
|
|||||||
summary: String,
|
summary: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summary_prompt(title: &str, body_html: &str, input_tokens: usize) -> String {
|
/// How much of the piece the summarizer is looking at.
|
||||||
let body = truncate_tokens(&prompt_text(body_html), input_tokens);
|
#[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);
|
let mut prompt = String::with_capacity(body.len() + SUMMARY_INSTRUCTIONS.len() + 256);
|
||||||
prompt.push_str(SUMMARY_INSTRUCTIONS);
|
prompt.push_str(SUMMARY_INSTRUCTIONS);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
prompt,
|
prompt,
|
||||||
"\n\nHEADLINE: {}\n\nARTICLE TEXT{}:\n{}\n",
|
"\n\nHEADLINE: {}\n\nARTICLE TEXT{}:\n{}\n",
|
||||||
title.trim(),
|
title.trim(),
|
||||||
if body.ends_with('…') {
|
label,
|
||||||
" (truncated for length)"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
},
|
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
"(no body text was extracted; summarize from the headline alone and say the full text was unavailable)"
|
"(no body text was extracted; summarize from the headline alone and say the full text was unavailable)"
|
||||||
} else {
|
} else {
|
||||||
@@ -90,16 +125,41 @@ fn summary_prompt(title: &str, body_html: &str, input_tokens: usize) -> String {
|
|||||||
prompt
|
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(
|
pub async fn summarize_article(
|
||||||
llm: &LlmClient,
|
llm: &LlmClient,
|
||||||
title: &str,
|
title: &str,
|
||||||
body_html: &str,
|
body_html: &str,
|
||||||
|
source: SummarySource,
|
||||||
input_tokens: usize,
|
input_tokens: usize,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
) -> Result<String, LlmError> {
|
) -> Result<String, LlmError> {
|
||||||
let prompt = summary_prompt(title, body_html, input_tokens);
|
let prompt = summary_prompt(title, body_html, source, input_tokens);
|
||||||
let response: SummaryResponse = llm.complete_json(&prompt, temperature).await?;
|
let response: SummaryResponse = llm.complete_json(&prompt, temperature).await?;
|
||||||
let summary = response.summary.trim().to_string();
|
let summary = strip_trailing_label(&response.summary).to_string();
|
||||||
if summary.is_empty() {
|
if summary.is_empty() {
|
||||||
return Err(LlmError::empty_response(llm.provider()));
|
return Err(LlmError::empty_response(llm.provider()));
|
||||||
}
|
}
|
||||||
@@ -134,6 +194,7 @@ async fn summarize_pick(
|
|||||||
primary,
|
primary,
|
||||||
&pick.article.title,
|
&pick.article.title,
|
||||||
&pick.article.content_html,
|
&pick.article.content_html,
|
||||||
|
SummarySource::for_article(&pick.article),
|
||||||
config.summary_input_tokens,
|
config.summary_input_tokens,
|
||||||
temperature,
|
temperature,
|
||||||
)
|
)
|
||||||
@@ -150,6 +211,7 @@ async fn summarize_pick(
|
|||||||
fallback,
|
fallback,
|
||||||
&pick.article.title,
|
&pick.article.title,
|
||||||
&pick.article.content_html,
|
&pick.article.content_html,
|
||||||
|
SummarySource::for_article(&pick.article),
|
||||||
config.summary_input_tokens,
|
config.summary_input_tokens,
|
||||||
temperature,
|
temperature,
|
||||||
)
|
)
|
||||||
@@ -459,7 +521,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let llm = mock("deepseek", Arc::clone(&backend), 2.0);
|
let llm = mock("deepseek", Arc::clone(&backend), 2.0);
|
||||||
let body = format!("<p>{}</p>", "word ".repeat(20_000));
|
let body = format!("<p>{}</p>", "word ".repeat(20_000));
|
||||||
let summary = summarize_article(&llm, "Migrating 40TB", &body, 3_000, 0.8)
|
let summary = summarize_article(
|
||||||
|
&llm,
|
||||||
|
"Migrating 40TB",
|
||||||
|
&body,
|
||||||
|
SummarySource::FullText,
|
||||||
|
3_000,
|
||||||
|
0.8,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("summary");
|
.expect("summary");
|
||||||
assert!(summary.starts_with("A team moves 40TB"));
|
assert!(summary.starts_with("A team moves 40TB"));
|
||||||
@@ -472,6 +541,80 @@ mod tests {
|
|||||||
assert!(prompt.len() < 16_000, "prompt was {} bytes", prompt.len());
|
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!("<p>A teaser paragraph.</p><p>{EXCERPT_NOTE}</p>");
|
||||||
|
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",
|
||||||
|
"<p>body</p>",
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn the_brief_is_parsed_and_rendered() {
|
async fn the_brief_is_parsed_and_rendered() {
|
||||||
let backend = Arc::new(MockBackend::new());
|
let backend = Arc::new(MockBackend::new());
|
||||||
|
|||||||
Reference in New Issue
Block a user