diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 645caa2..3673c43 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -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" 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: \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."); diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 518c7ec..5316208 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -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