Strip markdown decoration from model-emitted insight titles

Models wrap the title line despite the prompt — "**Title: A Day in the
Woods**", "## Title: ...", bold around just the label — which made
parse_title_body's bare "Title:" prefix match fall through to the
fallbacks and leak asterisks into the stored title.

strip_title_markdown trims bold/italic markers, heading hashes,
backticks, and quotes from both ends; applied to the label line, the
extracted title, both fallback paths, and generate_photo_title (which
previously stripped only quotes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-06-11 22:18:43 -04:00
parent efd05db523
commit 3fa4fa8501
2 changed files with 54 additions and 13 deletions
+44 -12
View File
@@ -33,30 +33,40 @@ use crate::utils::{earliest_fs_time, normalize_path};
/// and labels the truncation via `found_header`.
const LOCATION_HISTORY_DISPLAY_LIMIT: usize = 20;
/// Strip common markdown decoration (bold/italic markers, heading hashes,
/// backticks, quotes) from both ends of a model-emitted title. Models wrap
/// the line despite the prompt: `**Title: A Day in the Woods**`,
/// `## Title: ...`, `"..."`.
pub(crate) fn strip_title_markdown(s: &str) -> &str {
s.trim_matches(|c: char| matches!(c, '*' | '_' | '`' | '#' | '"') || c.is_whitespace())
}
/// Parse a "Title: ...\n\n<body>" response into (title, body).
/// Falls back to the first sentence as the title if the model didn't
/// follow the format.
pub(crate) fn parse_title_body(raw: &str) -> (String, String) {
let trimmed = raw.trim();
// Try "Title: <title>\n\n<body>" or "Title: <title>\n<body>"
if let Some(rest) = trimmed
// Try "Title: <title>\n<body>", tolerating markdown decoration around
// the title line.
let (first_line, rest) = match trimmed.find('\n') {
Some(pos) => (&trimmed[..pos], trimmed[pos..].trim()),
None => (trimmed, ""),
};
let first_line = strip_title_markdown(first_line);
if let Some(t) = first_line
.strip_prefix("Title:")
.or_else(|| trimmed.strip_prefix("title:"))
.or_else(|| first_line.strip_prefix("title:"))
{
let rest = rest.trim_start();
if let Some(split_pos) = rest.find("\n\n").or_else(|| rest.find('\n')) {
let title = rest[..split_pos].trim();
let body = rest[split_pos..].trim();
if !title.is_empty() && !body.is_empty() {
return (title.to_string(), body.to_string());
}
let title = strip_title_markdown(t);
if !title.is_empty() && !rest.is_empty() {
return (title.to_string(), rest.to_string());
}
}
// Fallback: first sentence (up to first `. ` or `.\n`) becomes the title
if let Some(pos) = trimmed.find(". ").or_else(|| trimmed.find(".\n")) {
let title = &trimmed[..pos];
let title = strip_title_markdown(&trimmed[..pos]);
let body = trimmed[pos + 1..].trim();
if title.len() <= 100 && !body.is_empty() {
return (title.to_string(), body.to_string());
@@ -65,7 +75,7 @@ pub(crate) fn parse_title_body(raw: &str) -> (String, String) {
// Last resort: truncate to 60 chars for title, full text as body
let title: String = trimmed.chars().take(60).collect();
let title = title.trim_end().to_string();
let title = strip_title_markdown(title.trim_end()).to_string();
(title, trimmed.to_string())
}
@@ -5126,6 +5136,28 @@ mod tests {
assert_eq!(b, "Everyone gathered...");
}
#[test]
fn parse_title_body_strips_bold_wrapper() {
let (t, b) = parse_title_body("**Title: A Day in the Woods**\n\nWe hiked the ridge trail.");
assert_eq!(t, "A Day in the Woods");
assert_eq!(b, "We hiked the ridge trail.");
}
#[test]
fn parse_title_body_strips_bold_label_only() {
// Bold around just the label: "**Title:** X"
let (t, b) = parse_title_body("**Title:** Garden Party\n\nEveryone gathered...");
assert_eq!(t, "Garden Party");
assert_eq!(b, "Everyone gathered...");
}
#[test]
fn parse_title_body_strips_heading_hashes() {
let (t, b) = parse_title_body("## Title: Morning Walk\nThe sun was rising...");
assert_eq!(t, "Morning Walk");
assert_eq!(b, "The sun was rising...");
}
#[test]
fn parse_title_body_fallback_first_sentence() {
let (t, b) = parse_title_body("A warm summer day. We gathered at the park for a picnic.");
+10 -1
View File
@@ -548,7 +548,16 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
let title = self
.generate_with_images(&prompt, Some(system), None)
.await?;
Ok(title.trim().trim_matches('"').to_string())
// Models decorate despite "Return ONLY the title": quotes, bold
// markers, sometimes a "Title:" label.
use crate::ai::insight_generator::strip_title_markdown;
let cleaned = strip_title_markdown(title.trim());
let cleaned = cleaned
.strip_prefix("Title:")
.or_else(|| cleaned.strip_prefix("title:"))
.map(strip_title_markdown)
.unwrap_or(cleaned);
Ok(cleaned.to_string())
}
/// Generate a summary for a single photo based on its context