Build insight title from generated summary

This commit is contained in:
Cameron
2026-02-24 16:08:25 -05:00
parent 1fb3441a38
commit 7a0da1ab4a
6 changed files with 63 additions and 150 deletions

7
.idea/sqldialects.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/2021-09-02-000740_create_tags/up.sql" dialect="GenericSQL" />
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

View File

@@ -1011,18 +1011,7 @@ impl InsightGenerator {
None None
}; };
// 10. Generate title and summary with Ollama (using multi-source context + image if supported) // 10. Generate summary first, then derive title from the summary
let title = ollama_client
.generate_photo_title(
date_taken,
location.as_deref(),
contact.as_deref(),
Some(&combined_context),
custom_system_prompt.as_deref(),
image_base64.clone(),
)
.await?;
let summary = ollama_client let summary = ollama_client
.generate_photo_summary( .generate_photo_summary(
date_taken, date_taken,
@@ -1034,6 +1023,10 @@ impl InsightGenerator {
) )
.await?; .await?;
let title = ollama_client
.generate_photo_title(&summary, custom_system_prompt.as_deref())
.await?;
log::info!("Generated title: {}", title); log::info!("Generated title: {}", title);
log::info!("Generated summary: {}", summary); log::info!("Generated summary: {}", summary);

View File

@@ -373,94 +373,25 @@ impl OllamaClient {
Ok(cleaned) Ok(cleaned)
} }
/// Generate a title for a single photo based on its context /// Generate a title for a single photo based on its generated summary
pub async fn generate_photo_title( pub async fn generate_photo_title(
&self, &self,
date: NaiveDate, summary: &str,
location: Option<&str>,
contact: Option<&str>,
sms_summary: Option<&str>,
custom_system: Option<&str>, custom_system: Option<&str>,
image_base64: Option<String>,
) -> Result<String> { ) -> Result<String> {
let location_str = location.unwrap_or("Unknown location"); let prompt = format!(
let sms_str = sms_summary.unwrap_or("No messages"); r#"Create a short title (maximum 8 words) for the following journal entry:
let prompt = if image_base64.is_some() { {}
if let Some(contact_name) = contact {
format!(
r#"Create a short title (maximum 8 words) about this moment by analyzing the image and context:
Date: {} Capture the key moment or theme. Return ONLY the title, nothing else."#,
Location: {} summary
Person/Contact: {} );
Messages: {}
Analyze the image and use specific details from both the visual content and the context above. The photo is from a folder for {}, so they are likely in or related to this photo. If limited information is available, use a simple descriptive title based on what you see.
Return ONLY the title, nothing else."#,
date.format("%B %d, %Y"),
location_str,
contact_name,
sms_str,
contact_name
)
} else {
format!(
r#"Create a short title (maximum 8 words) about this moment by analyzing the image and context:
Date: {}
Location: {}
Messages: {}
Analyze the image and use specific details from both the visual content and the context above. If limited information is available, use a simple descriptive title based on what you see.
Return ONLY the title, nothing else."#,
date.format("%B %d, %Y"),
location_str,
sms_str
)
}
} else if let Some(contact_name) = contact {
format!(
r#"Create a short title (maximum 8 words) about this moment:
Date: {}
Location: {}
Person/Contact: {}
Messages: {}
Use specific details from the context above. The photo is from a folder for {}, so they are likely related to this moment. If no specific details are available, use a simple descriptive title.
Return ONLY the title, nothing else."#,
date.format("%B %d, %Y"),
location_str,
contact_name,
sms_str,
contact_name
)
} else {
format!(
r#"Create a short title (maximum 8 words) about this moment:
Date: {}
Location: {}
Messages: {}
Use specific details from the context above. If no specific details are available, use a simple descriptive title.
Return ONLY the title, nothing else."#,
date.format("%B %d, %Y"),
location_str,
sms_str
)
};
let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details."); let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details.");
let images = image_base64.map(|img| vec![img]);
let title = self let title = self
.generate_with_images(&prompt, Some(system), images) .generate_with_images(&prompt, Some(system), None)
.await?; .await?;
Ok(title.trim().trim_matches('"').to_string()) Ok(title.trim().trim_matches('"').to_string())
} }

View File

@@ -1471,9 +1471,7 @@ mod tests {
Data::new(AppState::test_state()), Data::new(AppState::test_state()),
Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())), Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())),
Data::new(Mutex::new(SqliteTagDao::default())), Data::new(Mutex::new(SqliteTagDao::default())),
Data::new(Mutex::new( Data::new(Mutex::new(Box::new(MockExifDao) as Box<dyn ExifDao>)),
Box::new(MockExifDao) as Box<dyn ExifDao>
)),
) )
.await; .await;
@@ -1518,9 +1516,7 @@ mod tests {
Data::new(AppState::test_state()), Data::new(AppState::test_state()),
Data::new(FakeFileSystem::new(HashMap::new())), Data::new(FakeFileSystem::new(HashMap::new())),
Data::new(Mutex::new(tag_dao)), Data::new(Mutex::new(tag_dao)),
Data::new(Mutex::new( Data::new(Mutex::new(Box::new(MockExifDao) as Box<dyn ExifDao>)),
Box::new(MockExifDao) as Box<dyn ExifDao>
)),
) )
.await; .await;
@@ -1581,9 +1577,7 @@ mod tests {
Data::new(AppState::test_state()), Data::new(AppState::test_state()),
Data::new(FakeFileSystem::new(HashMap::new())), Data::new(FakeFileSystem::new(HashMap::new())),
Data::new(Mutex::new(tag_dao)), Data::new(Mutex::new(tag_dao)),
Data::new(Mutex::new( Data::new(Mutex::new(Box::new(MockExifDao) as Box<dyn ExifDao>)),
Box::new(MockExifDao) as Box<dyn ExifDao>
)),
) )
.await; .await;

View File

@@ -1044,10 +1044,12 @@ fn cleanup_orphaned_playlists() {
.filter(|e| e.file_type().is_file()) .filter(|e| e.file_type().is_file())
{ {
if let Some(entry_stem) = entry.path().file_stem() if let Some(entry_stem) = entry.path().file_stem()
&& entry_stem == filename && is_video_file(entry.path()) { && entry_stem == filename
video_exists = true; && is_video_file(entry.path())
break; {
} video_exists = true;
break;
}
} }
if !video_exists { if !video_exists {
@@ -1078,27 +1080,27 @@ fn cleanup_orphaned_playlists() {
{ {
let entry_path = entry.path(); let entry_path = entry.path();
if let Some(ext) = entry_path.extension() if let Some(ext) = entry_path.extension()
&& ext.eq_ignore_ascii_case("ts") { && ext.eq_ignore_ascii_case("ts")
// Check if this .ts file belongs to our playlist {
if let Some(ts_stem) = entry_path.file_stem() { // Check if this .ts file belongs to our playlist
let ts_name = ts_stem.to_string_lossy(); if let Some(ts_stem) = entry_path.file_stem() {
if ts_name.starts_with(&*video_filename) { let ts_name = ts_stem.to_string_lossy();
if let Err(e) = std::fs::remove_file(entry_path) if ts_name.starts_with(&*video_filename) {
{ if let Err(e) = std::fs::remove_file(entry_path) {
debug!( debug!(
"Failed to delete segment {}: {}", "Failed to delete segment {}: {}",
entry_path.display(), entry_path.display(),
e e
); );
} else { } else {
debug!( debug!(
"Deleted segment: {}", "Deleted segment: {}",
entry_path.display() entry_path.display()
); );
}
} }
} }
} }
}
} }
} }
} }
@@ -1206,12 +1208,11 @@ fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> bool {
if let (Ok(video_meta), Ok(playlist_meta)) = ( if let (Ok(video_meta), Ok(playlist_meta)) = (
std::fs::metadata(video_path), std::fs::metadata(video_path),
std::fs::metadata(playlist_path), std::fs::metadata(playlist_path),
) ) && let (Ok(video_modified), Ok(playlist_modified)) =
&& let (Ok(video_modified), Ok(playlist_modified)) = (video_meta.modified(), playlist_meta.modified())
(video_meta.modified(), playlist_meta.modified()) {
{ return video_modified > playlist_modified;
return video_modified > playlist_modified; }
}
// If we can't determine, assume it needs generation // If we can't determine, assume it needs generation
true true

View File

@@ -156,20 +156,20 @@ async fn get_video_rotation(video_path: &str) -> i32 {
.output() .output()
.await; .await;
if let Ok(output) = output { if let Ok(output) = output
if output.status.success() { && output.status.success() {
let rotation_str = String::from_utf8_lossy(&output.stdout); let rotation_str = String::from_utf8_lossy(&output.stdout);
let rotation_str = rotation_str.trim(); let rotation_str = rotation_str.trim();
if !rotation_str.is_empty() { if !rotation_str.is_empty()
if let Ok(rotation) = rotation_str.parse::<i32>() { && let Ok(rotation) = rotation_str.parse::<i32>()
if rotation != 0 { && rotation != 0 {
debug!("Detected rotation {}° from stream tag for {}", rotation, video_path); debug!(
"Detected rotation {}° from stream tag for {}",
rotation, video_path
);
return rotation; return rotation;
} }
}
}
} }
}
// Check display matrix side data (modern videos, e.g. iPhone) // Check display matrix side data (modern videos, e.g. iPhone)
let output = tokio::process::Command::new("ffprobe") let output = tokio::process::Command::new("ffprobe")
@@ -185,21 +185,22 @@ async fn get_video_rotation(video_path: &str) -> i32 {
.output() .output()
.await; .await;
if let Ok(output) = output { if let Ok(output) = output
if output.status.success() { && output.status.success() {
let rotation_str = String::from_utf8_lossy(&output.stdout); let rotation_str = String::from_utf8_lossy(&output.stdout);
let rotation_str = rotation_str.trim(); let rotation_str = rotation_str.trim();
if !rotation_str.is_empty() { if !rotation_str.is_empty()
if let Ok(rotation) = rotation_str.parse::<f64>() { && let Ok(rotation) = rotation_str.parse::<f64>() {
let rotation = rotation.abs() as i32; let rotation = rotation.abs() as i32;
if rotation != 0 { if rotation != 0 {
debug!("Detected rotation {}° from display matrix for {}", rotation, video_path); debug!(
"Detected rotation {}° from display matrix for {}",
rotation, video_path
);
return rotation; return rotation;
} }
} }
}
} }
}
0 0
} }