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" or "Title: \n"
- if let Some(rest) = trimmed
+ // Try "Title: \n", 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