use anyhow::Result; use base64::Engine as _; use chrono::{DateTime, NaiveDate, Utc}; use image::ImageFormat; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::Deserialize; use std::fs::File; use std::io::Cursor; use std::sync::{Arc, Mutex}; use crate::ai::ollama::{ChatMessage, OllamaClient, Tool}; use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; use crate::database::{ CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao, SearchHistoryDao, }; use crate::memories::extract_date_from_filename; use crate::otel::global_tracer; use crate::tags::TagDao; use crate::utils::normalize_path; #[derive(Deserialize)] struct NominatimResponse { display_name: Option, address: Option, } #[derive(Deserialize)] struct NominatimAddress { city: Option, town: Option, village: Option, state: Option, } #[derive(Clone)] pub struct InsightGenerator { ollama: OllamaClient, sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, // Google Takeout data sources calendar_dao: Arc>>, location_dao: Arc>>, search_dao: Arc>>, tag_dao: Arc>>, // Knowledge memory knowledge_dao: Arc>>, base_path: String, } impl InsightGenerator { pub fn new( ollama: OllamaClient, sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, calendar_dao: Arc>>, location_dao: Arc>>, search_dao: Arc>>, tag_dao: Arc>>, knowledge_dao: Arc>>, base_path: String, ) -> Self { Self { ollama, sms_client, insight_dao, exif_dao, daily_summary_dao, calendar_dao, location_dao, search_dao, tag_dao, knowledge_dao, base_path, } } /// Extract contact name from file path /// e.g., "Sarah/img.jpeg" -> Some("Sarah") /// e.g., "img.jpeg" -> None fn extract_contact_from_path(file_path: &str) -> Option { use std::path::Path; let path = Path::new(file_path); let components: Vec<_> = path.components().collect(); // If path has at least 2 components (directory + file), extract first directory if components.len() >= 2 && let Some(component) = components.first() && let Some(os_str) = component.as_os_str().to_str() { return Some(os_str.to_string()); } None } /// Load image file, resize it, and encode as base64 for vision models /// Resizes to max 1024px on longest edge to reduce context usage fn load_image_as_base64(&self, file_path: &str) -> Result { use image::imageops::FilterType; use std::path::Path; let full_path = Path::new(&self.base_path).join(file_path); log::debug!("Loading image for vision model: {:?}", full_path); // Open and decode the image let img = image::open(&full_path) .map_err(|e| anyhow::anyhow!("Failed to open image file: {}", e))?; let (original_width, original_height) = (img.width(), img.height()); // Resize to max 1024px on longest edge let resized = img.resize(1024, 1024, FilterType::Lanczos3); log::debug!( "Resized image from {}x{} to {}x{}", original_width, original_height, resized.width(), resized.height() ); // Encode as JPEG at 85% quality let mut buffer = Vec::new(); let mut cursor = Cursor::new(&mut buffer); resized .write_to(&mut cursor, ImageFormat::Jpeg) .map_err(|e| anyhow::anyhow!("Failed to encode image as JPEG: {}", e))?; let base64_string = base64::engine::general_purpose::STANDARD.encode(&buffer); log::debug!( "Encoded image as base64 ({} bytes -> {} chars)", buffer.len(), base64_string.len() ); Ok(base64_string) } /// Find relevant messages using RAG, excluding recent messages (>30 days ago) /// This prevents RAG from returning messages already in the immediate time window async fn find_relevant_messages_rag_historical( &self, parent_cx: &opentelemetry::Context, date: chrono::NaiveDate, location: Option<&str>, contact: Option<&str>, topics: Option<&[String]>, limit: usize, extra_context: Option<&str>, ) -> Result> { let tracer = global_tracer(); let span = tracer.start_with_context("ai.rag.filter_historical", parent_cx); let filter_cx = parent_cx.with_span(span); filter_cx .span() .set_attribute(KeyValue::new("date", date.to_string())); filter_cx .span() .set_attribute(KeyValue::new("limit", limit as i64)); filter_cx .span() .set_attribute(KeyValue::new("exclusion_window_days", 30)); if let Some(t) = topics { filter_cx .span() .set_attribute(KeyValue::new("topics", t.join(", "))); } let query_results = self .find_relevant_messages_rag(date, location, contact, topics, limit * 2, extra_context) .await?; filter_cx.span().set_attribute(KeyValue::new( "rag_results_count", query_results.len() as i64, )); // Filter out messages from within 30 days of the photo date let photo_timestamp = date .and_hms_opt(12, 0, 0) .ok_or_else(|| anyhow::anyhow!("Invalid date"))? .and_utc() .timestamp(); let exclusion_window = 30 * 86400; // 30 days in seconds let historical_only: Vec = query_results .into_iter() .filter(|msg| { // Extract date from formatted daily summary "[2024-08-15] Contact ..." if let Some(bracket_end) = msg.find(']') && let Some(date_str) = msg.get(1..bracket_end) { // Parse just the date (daily summaries don't have time) if let Ok(msg_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { let msg_timestamp = msg_date .and_hms_opt(12, 0, 0) .unwrap() .and_utc() .timestamp(); let time_diff = (photo_timestamp - msg_timestamp).abs(); return time_diff > exclusion_window; } } false }) .take(limit) .collect(); log::info!( "Found {} historical messages (>30 days from photo date)", historical_only.len() ); filter_cx.span().set_attribute(KeyValue::new( "historical_results_count", historical_only.len() as i64, )); filter_cx.span().set_status(Status::Ok); Ok(historical_only) } /// Find relevant daily summaries using RAG (semantic search) /// Returns formatted daily summary strings for LLM context async fn find_relevant_messages_rag( &self, date: chrono::NaiveDate, location: Option<&str>, contact: Option<&str>, topics: Option<&[String]>, limit: usize, extra_context: Option<&str>, ) -> Result> { let tracer = global_tracer(); let current_cx = opentelemetry::Context::current(); let mut span = tracer.start_with_context("ai.rag.search_daily_summaries", ¤t_cx); span.set_attribute(KeyValue::new("date", date.to_string())); span.set_attribute(KeyValue::new("limit", limit as i64)); if let Some(loc) = location { span.set_attribute(KeyValue::new("location", loc.to_string())); } if let Some(c) = contact { span.set_attribute(KeyValue::new("contact", c.to_string())); } // Build query string - prioritize topics if available (semantically meaningful) let base_query = if let Some(topics) = topics { if !topics.is_empty() { // Use topics for semantic search - these are actual content keywords let topic_str = topics.join(", "); if let Some(c) = contact { format!("Conversations about {} with {}", topic_str, c) } else { format!("Conversations about {}", topic_str) } } else { // Fallback to metadata-based query Self::build_metadata_query(date, location, contact) } } else { // Fallback to metadata-based query Self::build_metadata_query(date, location, contact) }; let query = if let Some(extra) = extra_context { format!("{}. {}", base_query, extra) } else { base_query }; span.set_attribute(KeyValue::new("query", query.clone())); // Create context with this span for child operations let search_cx = current_cx.with_span(span); log::info!("========================================"); log::info!("RAG QUERY: {}", query); log::info!("========================================"); // Generate embedding for the query let query_embedding = self.ollama.generate_embedding(&query).await?; // Search for similar daily summaries with time-based weighting // This prioritizes summaries temporally close to the query date let mut summary_dao = self .daily_summary_dao .lock() .expect("Unable to lock DailySummaryDao"); let date_str = date.format("%Y-%m-%d").to_string(); let similar_summaries = summary_dao .find_similar_summaries_with_time_weight(&search_cx, &query_embedding, &date_str, limit) .map_err(|e| anyhow::anyhow!("Failed to find similar summaries: {:?}", e))?; log::info!( "Found {} relevant daily summaries via RAG", similar_summaries.len() ); search_cx.span().set_attribute(KeyValue::new( "results_count", similar_summaries.len() as i64, )); // Format daily summaries for LLM context let formatted = similar_summaries .into_iter() .map(|s| { format!( "[{}] {} ({} messages):\n{}", s.date, s.contact, s.message_count, s.summary ) }) .collect(); search_cx.span().set_status(Status::Ok); Ok(formatted) } /// Build a metadata-based query (fallback when no topics available) fn build_metadata_query( date: chrono::NaiveDate, location: Option<&str>, contact: Option<&str>, ) -> String { let mut query_parts = Vec::new(); // Add temporal context query_parts.push(format!("On {}", date.format("%B %d, %Y"))); // Add location if available if let Some(loc) = location { query_parts.push(format!("at {}", loc)); } // Add contact context if available if let Some(c) = contact { query_parts.push(format!("conversation with {}", c)); } // Add day of week for temporal context let weekday = date.format("%A"); query_parts.push(format!("it was a {}", weekday)); query_parts.join(", ") } /// Haversine distance calculation for GPS proximity (in kilometers) fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { const R: f64 = 6371.0; // Earth radius in km let d_lat = (lat2 - lat1).to_radians(); let d_lon = (lon2 - lon1).to_radians(); let a = (d_lat / 2.0).sin().powi(2) + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lon / 2.0).sin().powi(2); R * 2.0 * a.sqrt().atan2((1.0 - a).sqrt()) } /// Gather calendar context for photo timestamp /// Uses hybrid time + semantic search (±7 days, ranked by relevance) async fn gather_calendar_context( &self, parent_cx: &opentelemetry::Context, timestamp: i64, location: Option<&str>, ) -> Result> { let tracer = global_tracer(); let span = tracer.start_with_context("ai.context.calendar", parent_cx); let calendar_cx = parent_cx.with_span(span); let query_embedding = if let Some(loc) = location { match self.ollama.generate_embedding(loc).await { Ok(emb) => Some(emb), Err(e) => { log::warn!("Failed to generate embedding for location '{}': {}", loc, e); None } } } else { None }; let events = { let mut dao = self .calendar_dao .lock() .expect("Unable to lock CalendarEventDao"); dao.find_relevant_events_hybrid( &calendar_cx, timestamp, 7, // ±7 days window query_embedding.as_deref(), 5, // Top 5 events ) .ok() }; calendar_cx.span().set_status(Status::Ok); if let Some(events) = events { if events.is_empty() { return Ok(None); } let formatted = events .iter() .map(|e| { let date = DateTime::from_timestamp(e.start_time, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown".to_string()); let attendees = e .attendees .as_ref() .and_then(|a| serde_json::from_str::>(a).ok()) .map(|list| format!(" (with {})", list.join(", "))) .unwrap_or_default(); format!("[{}] {}{}", date, e.summary, attendees) }) .collect::>() .join("\n"); Ok(Some(format!("Calendar events:\n{}", formatted))) } else { Ok(None) } } /// Gather location context for photo timestamp /// Finds nearest location record (±30 minutes) async fn gather_location_context( &self, parent_cx: &opentelemetry::Context, timestamp: i64, exif_gps: Option<(f64, f64)>, ) -> Result> { let tracer = global_tracer(); let span = tracer.start_with_context("ai.context.location", parent_cx); let location_cx = parent_cx.with_span(span); let nearest = { let mut dao = self .location_dao .lock() .expect("Unable to lock LocationHistoryDao"); dao.find_nearest_location( &location_cx, timestamp, 10800, // ±3 hours (more realistic for photo timing) ) .ok() .flatten() }; location_cx.span().set_status(Status::Ok); if let Some(loc) = nearest { // Check if this adds NEW information compared to EXIF if let Some((exif_lat, exif_lon)) = exif_gps { let distance = Self::haversine_distance(exif_lat, exif_lon, loc.latitude, loc.longitude); // Skip only if very close AND no useful activity/place info // Allow activity context even if coordinates match if distance < 0.5 && loc.activity.is_none() && loc.place_name.is_none() { log::debug!( "Location history matches EXIF GPS ({}m) with no extra context, skipping", (distance * 1000.0) as i32 ); return Ok(None); } else if distance < 0.5 { log::debug!( "Location history close to EXIF ({}m) but has activity/place info", (distance * 1000.0) as i32 ); } } let activity = loc .activity .as_ref() .map(|a| format!(" ({})", a)) .unwrap_or_default(); let place = loc .place_name .as_ref() .map(|p| format!(" at {}", p)) .unwrap_or_default(); Ok(Some(format!( "Location history: You were{}{}{}", activity, place, if activity.is_empty() && place.is_empty() { format!(" near {:.4}, {:.4}", loc.latitude, loc.longitude) } else { String::new() } ))) } else { Ok(None) } } /// Gather search context for photo date /// Uses semantic search on queries (±30 days, top 5 relevant) async fn gather_search_context( &self, parent_cx: &opentelemetry::Context, timestamp: i64, location: Option<&str>, contact: Option<&str>, enrichment: Option<&str>, ) -> Result> { let tracer = global_tracer(); let span = tracer.start_with_context("ai.context.search", parent_cx); let search_cx = parent_cx.with_span(span); // Use enrichment (topics + photo description + tags) if available; // fall back to generic temporal query. let query_text = if let Some(enriched) = enrichment { enriched.to_string() } else { // Fallback: generic temporal query format!( "searches about {} {} {}", DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.format("%B %Y").to_string()) .unwrap_or_default(), location.unwrap_or(""), contact .map(|c| format!("involving {}", c)) .unwrap_or_default() ) }; let query_embedding = match self.ollama.generate_embedding(&query_text).await { Ok(emb) => emb, Err(e) => { log::warn!("Failed to generate search embedding: {}", e); search_cx.span().set_status(Status::Error { description: e.to_string().into(), }); return Ok(None); } }; let searches = { let mut dao = self .search_dao .lock() .expect("Unable to lock SearchHistoryDao"); dao.find_relevant_searches_hybrid( &search_cx, timestamp, 30, // ±30 days (wider window than calendar) Some(&query_embedding), 5, // Top 5 searches ) .ok() }; search_cx.span().set_status(Status::Ok); if let Some(searches) = searches { if searches.is_empty() { log::warn!( "No relevant searches found for photo timestamp {}", timestamp ); return Ok(None); } let formatted = searches .iter() .map(|s| { let date = DateTime::from_timestamp(s.timestamp, 0) .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| "unknown".to_string()); format!("[{}] \"{}\"", date, s.query) }) .collect::>() .join("\n"); Ok(Some(format!("Search history:\n{}", formatted))) } else { Ok(None) } } /// Combine all context sources with equal weight fn combine_contexts( sms: Option, calendar: Option, location: Option, search: Option, tags: Option, ) -> String { let mut parts = Vec::new(); if let Some(s) = sms { parts.push(format!("## Messages\n{}", s)); } if let Some(c) = calendar { parts.push(format!("## Calendar\n{}", c)); } if let Some(l) = location { parts.push(format!("## Location\n{}", l)); } if let Some(s) = search { parts.push(format!("## Searches\n{}", s)); } if let Some(t) = tags { parts.push(format!("## Tags\n{}", t)); } if parts.is_empty() { "No additional context available".to_string() } else { parts.join("\n\n") } } /// Generate AI insight for a single photo with custom configuration pub async fn generate_insight_for_photo_with_config( &self, file_path: &str, custom_model: Option, custom_system_prompt: Option, num_ctx: Option, ) -> Result<()> { let tracer = global_tracer(); let current_cx = opentelemetry::Context::current(); let mut span = tracer.start_with_context("ai.insight.generate", ¤t_cx); // Normalize path to ensure consistent forward slashes in database let file_path = normalize_path(file_path); log::info!("Generating insight for photo: {}", file_path); span.set_attribute(KeyValue::new("file_path", file_path.clone())); // Create custom Ollama client if model is specified let mut ollama_client = if let Some(model) = custom_model { log::info!("Using custom model: {}", model); span.set_attribute(KeyValue::new("custom_model", model.clone())); OllamaClient::new( self.ollama.primary_url.clone(), self.ollama.fallback_url.clone(), model.clone(), Some(model), // Use the same custom model for fallback server ) } else { span.set_attribute(KeyValue::new("model", self.ollama.primary_model.clone())); self.ollama.clone() }; // Set context size if specified if let Some(ctx) = num_ctx { log::info!("Using custom context size: {}", ctx); span.set_attribute(KeyValue::new("num_ctx", ctx as i64)); ollama_client.set_num_ctx(Some(ctx)); } // Create context with this span for child operations let insight_cx = current_cx.with_span(span); // 1. Get EXIF data for the photo let exif = { let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); exif_dao .get_exif(&insight_cx, &file_path) .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; // Get full timestamp for proximity-based message filtering let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { ts } else { log::warn!("No date_taken in EXIF for {}, trying filename", file_path); extract_date_from_filename(&file_path) .map(|dt| dt.timestamp()) .or_else(|| { // Combine base_path with file_path to get full path let full_path = std::path::Path::new(&self.base_path).join(&file_path); File::open(&full_path) .and_then(|f| f.metadata()) .and_then(|m| m.created().or(m.modified())) .map(|t| DateTime::::from(t).timestamp()) .inspect_err(|e| { log::warn!( "Failed to get file timestamp for insight {}: {}", file_path, e ) }) .ok() }) .unwrap_or_else(|| Utc::now().timestamp()) }; let date_taken = DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.date_naive()) .unwrap_or_else(|| Utc::now().date_naive()); // 3. Extract contact name from file path let contact = Self::extract_contact_from_path(&file_path); log::info!("Extracted contact from path: {:?}", contact); insight_cx .span() .set_attribute(KeyValue::new("date_taken", date_taken.to_string())); if let Some(ref c) = contact { insight_cx .span() .set_attribute(KeyValue::new("contact", c.clone())); } // Fetch file tags (used to enrich RAG and final context) let tag_names: Vec = { let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao"); dao.get_tags_for_path(&insight_cx, &file_path) .unwrap_or_else(|e| { log::warn!("Failed to fetch tags for insight {}: {}", file_path, e); Vec::new() }) .into_iter() .map(|t| t.name) .collect() }; log::info!( "Fetched {} tags for photo: {:?}", tag_names.len(), tag_names ); // 4. Get location name from GPS coordinates (needed for RAG query) let location = match exif { Some(ref exif) => { if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) { let loc = self.reverse_geocode(lat as f64, lon as f64).await; if let Some(ref l) = loc { insight_cx .span() .set_attribute(KeyValue::new("location", l.clone())); Some(l.clone()) } else { // Fallback: If reverse geocoding fails, use coordinates log::warn!( "Reverse geocoding failed for {}, {}, using coordinates as fallback", lat, lon ); Some(format!("{:.4}, {:.4}", lat, lon)) } } else { None } } None => None, }; // Check if the model has vision capabilities let model_to_check = ollama_client.primary_model.clone(); let has_vision = match OllamaClient::check_model_capabilities( &ollama_client.primary_url, &model_to_check, ) .await { Ok(capabilities) => { log::info!( "Model '{}' vision capability: {}", model_to_check, capabilities.has_vision ); capabilities.has_vision } Err(e) => { log::warn!( "Failed to check vision capabilities for model '{}', assuming no vision support: {}", model_to_check, e ); false } }; insight_cx .span() .set_attribute(KeyValue::new("model_has_vision", has_vision)); // Load image and encode as base64 only if model supports vision let image_base64 = if has_vision { match self.load_image_as_base64(&file_path) { Ok(b64) => { log::info!( "Successfully loaded image for vision-capable model '{}'", model_to_check ); Some(b64) } Err(e) => { log::warn!("Failed to load image for vision model: {}", e); None } } } else { log::info!( "Model '{}' does not support vision, skipping image processing", model_to_check ); None }; // Generate brief photo description for RAG enrichment (vision models only) let photo_description: Option = if let Some(ref img_b64) = image_base64 { match ollama_client.generate_photo_description(img_b64).await { Ok(desc) => { log::info!("Photo description for RAG enrichment: {}", desc); Some(desc) } Err(e) => { log::warn!( "Failed to generate photo description for RAG enrichment: {}", e ); None } } } else { None }; // Build enriched context string for RAG: photo description + tags // (SMS topics are passed separately to RAG functions) let enriched_query: Option = { let mut parts: Vec = Vec::new(); if let Some(ref desc) = photo_description { parts.push(desc.clone()); } if !tag_names.is_empty() { parts.push(format!("tags: {}", tag_names.join(", "))); } if parts.is_empty() { None } else { Some(parts.join(". ")) } }; let mut search_enrichment: Option = enriched_query.clone(); // 5. Intelligent retrieval: Hybrid approach for better context let mut sms_summary = None; let mut used_rag = false; // TEMPORARY: Set to true to disable RAG and use only time-based retrieval for testing let disable_rag_for_testing = false; if disable_rag_for_testing { log::warn!("RAG DISABLED FOR TESTING - Using only time-based retrieval (±4 days)"); // Skip directly to fallback } else { // ALWAYS use Strategy B: Expanded immediate context + historical RAG // This is more reliable than pure semantic search which can match irrelevant messages log::info!("Using expanded immediate context + historical RAG approach"); // Step 1: Get FULL immediate temporal context (±4 days, ALL messages) let immediate_messages = self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp) .await .unwrap_or_else(|e| { log::error!("Failed to fetch immediate messages: {}", e); Vec::new() }); log::info!( "Fetched {} messages from ±4 days window (using ALL for immediate context)", immediate_messages.len() ); if !immediate_messages.is_empty() { // Step 2: Extract topics from immediate messages to enrich RAG query let topics = self .extract_topics_from_messages(&immediate_messages, &ollama_client) .await; log::info!("Extracted topics for query enrichment: {:?}", topics); // Build full search enrichment: SMS topics + photo description + tag names search_enrichment = { let mut parts: Vec = Vec::new(); if !topics.is_empty() { parts.push(topics.join(", ")); } if let Some(ref desc) = photo_description { parts.push(desc.clone()); } if !tag_names.is_empty() { parts.push(format!("tags: {}", tag_names.join(", "))); } if parts.is_empty() { None } else { Some(parts.join(". ")) } }; // Step 3: Try historical RAG (>30 days ago) using extracted topics let topics_slice = if topics.is_empty() { None } else { Some(topics.as_slice()) }; match self .find_relevant_messages_rag_historical( &insight_cx, date_taken, None, contact.as_deref(), topics_slice, 10, // Top 10 historical matches enriched_query.as_deref(), ) .await { Ok(historical_messages) if !historical_messages.is_empty() => { log::info!( "Two-context approach: {} immediate (full conversation) + {} historical (similar past moments)", immediate_messages.len(), historical_messages.len() ); used_rag = true; // Step 4: Summarize contexts separately, then combine let immediate_summary = self .summarize_context_from_messages( &immediate_messages, &ollama_client, custom_system_prompt.as_deref(), ) .await .unwrap_or_else(|| String::from("No immediate context")); let historical_summary = self .summarize_messages( &historical_messages, &ollama_client, custom_system_prompt.as_deref(), ) .await .unwrap_or_else(|| String::from("No historical context")); // Combine summaries sms_summary = Some(format!( "Immediate context (±4 days): {}\n\nSimilar moments from the past: {}", immediate_summary, historical_summary )); } Ok(_) => { // RAG found no historical matches, just use immediate context log::info!("No historical RAG matches, using immediate context only"); sms_summary = self .summarize_context_from_messages( &immediate_messages, &ollama_client, custom_system_prompt.as_deref(), ) .await; } Err(e) => { log::warn!("Historical RAG failed, using immediate context only: {}", e); sms_summary = self .summarize_context_from_messages( &immediate_messages, &ollama_client, custom_system_prompt.as_deref(), ) .await; } } } else { log::info!("No immediate messages found, trying basic RAG as fallback"); // Fallback to basic RAG even without strong query match self .find_relevant_messages_rag( date_taken, None, contact.as_deref(), None, 20, enriched_query.as_deref(), ) .await { Ok(rag_messages) if !rag_messages.is_empty() => { used_rag = true; sms_summary = self .summarize_messages( &rag_messages, &ollama_client, custom_system_prompt.as_deref(), ) .await; } _ => {} } } } // 6. Fallback to traditional time-based message retrieval if RAG didn't work if !used_rag { log::info!("Using traditional time-based message retrieval (±4 days)"); let sms_messages = self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp) .await .unwrap_or_else(|e| { log::error!("Failed to fetch SMS messages: {}", e); Vec::new() }); log::info!( "Fetched {} SMS messages closest to {}", sms_messages.len(), DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "unknown time".to_string()) ); // Summarize time-based messages if !sms_messages.is_empty() { match self .sms_client .summarize_context(&sms_messages, &ollama_client) .await { Ok(summary) => { sms_summary = Some(summary); } Err(e) => { log::warn!("Failed to summarize SMS context: {}", e); } } } } let retrieval_method = if used_rag { "RAG" } else { "time-based" }; insight_cx .span() .set_attribute(KeyValue::new("retrieval_method", retrieval_method)); insight_cx .span() .set_attribute(KeyValue::new("has_sms_context", sms_summary.is_some())); log::info!( "Photo context: date={}, location={:?}, retrieval_method={}", date_taken, location, retrieval_method ); // 6. Gather Google Takeout context from all sources let calendar_context = self .gather_calendar_context(&insight_cx, timestamp, location.as_deref()) .await .ok() .flatten(); let exif_gps = exif.as_ref().and_then(|e| { if let (Some(lat), Some(lon)) = (e.gps_latitude, e.gps_longitude) { Some((lat as f64, lon as f64)) } else { None } }); let location_context = self .gather_location_context(&insight_cx, timestamp, exif_gps) .await .ok() .flatten(); let search_context = self .gather_search_context( &insight_cx, timestamp, location.as_deref(), contact.as_deref(), search_enrichment.as_deref(), ) .await .ok() .flatten(); // 7. Combine all context sources with equal weight let tags_context = if tag_names.is_empty() { None } else { Some(tag_names.join(", ")) }; let combined_context = Self::combine_contexts( sms_summary, calendar_context, location_context, search_context, tags_context, ); log::info!( "Combined context from all sources ({} chars)", combined_context.len() ); // 10. Generate summary first, then derive title from the summary let summary = ollama_client .generate_photo_summary( date_taken, location.as_deref(), contact.as_deref(), Some(&combined_context), custom_system_prompt.as_deref(), image_base64.clone(), ) .await?; let title = ollama_client .generate_photo_title(&summary, custom_system_prompt.as_deref()) .await?; log::info!("Generated title: {}", title); log::info!("Generated summary: {}", summary); insight_cx .span() .set_attribute(KeyValue::new("title_length", title.len() as i64)); insight_cx .span() .set_attribute(KeyValue::new("summary_length", summary.len() as i64)); // 11. Store in database let insight = InsertPhotoInsight { file_path: file_path.to_string(), title, summary, generated_at: Utc::now().timestamp(), model_version: ollama_client.primary_model.clone(), is_current: true, }; let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let result = dao .store_insight(&insight_cx, insight) .map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e)); match &result { Ok(_) => { log::info!("Successfully stored insight for {}", file_path); insight_cx.span().set_status(Status::Ok); } Err(e) => { log::error!("Failed to store insight: {:?}", e); insight_cx.span().set_status(Status::error(e.to_string())); } } result?; Ok(()) } /// Extract key topics/entities from messages using LLM for query enrichment async fn extract_topics_from_messages( &self, messages: &[crate::ai::SmsMessage], ollama: &OllamaClient, ) -> Vec { if messages.is_empty() { return Vec::new(); } // Format a sample of messages for topic extraction let sample_size = messages.len().min(20); let sample_text: Vec = messages .iter() .take(sample_size) .map(|m| format!("{}: {}", if m.is_sent { "Me" } else { &m.contact }, m.body)) .collect(); let prompt = format!( r#"Extract important entities from these messages that provide context about what was happening. Focus on: 1. **People**: Names of specific people mentioned (first names, nicknames) 2. **Places**: Locations, cities, buildings, workplaces, parks, restaurants, venues 3. **Activities**: Specific events, hobbies, groups, organizations (e.g., "drum corps", "auditions") 4. **Unique terms**: Domain-specific words or phrases that might need explanation (e.g., "Hyland", "Vanguard", "DCI") Messages: {} Return a comma-separated list of 3-7 specific entities (people, places, activities, unique terms). Focus on proper nouns and specific terms that provide context. Return ONLY the comma-separated list, nothing else."#, sample_text.join("\n") ); match ollama .generate(&prompt, Some("You are an entity extraction assistant. Extract proper nouns, people, places, and domain-specific terms that provide context.")) .await { Ok(response) => { log::debug!("Topic extraction raw response: {}", response); // Parse comma-separated topics let topics: Vec = response .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty() && s.len() > 1) // Filter out single chars .take(7) // Increased from 5 to 7 .collect(); if topics.is_empty() { log::warn!("Topic extraction returned empty list from {} messages", messages.len()); } else { log::info!("Extracted {} topics from {} messages: {}", topics.len(), messages.len(), topics.join(", ")); } topics } Err(e) => { log::warn!("Failed to extract topics from messages: {}", e); Vec::new() } } } /// Summarize pre-formatted message strings using LLM (concise version for historical context) async fn summarize_messages( &self, messages: &[String], ollama: &OllamaClient, custom_system: Option<&str>, ) -> Option { if messages.is_empty() { return None; } let messages_text = messages.join("\n"); let prompt = format!( r#"Summarize the context from these messages in 2-3 sentences. Focus on activities, locations, events, and relationships mentioned. Messages: {} Return ONLY the summary, nothing else."#, messages_text ); let system = custom_system .unwrap_or("You are a context summarization assistant. Be concise and factual."); match ollama.generate(&prompt, Some(system)).await { Ok(summary) => Some(summary), Err(e) => { log::warn!("Failed to summarize messages: {}", e); None } } } /// Convert SmsMessage objects to formatted strings and summarize with more detail /// This is used for immediate context (±2 days) to preserve conversation details async fn summarize_context_from_messages( &self, messages: &[crate::ai::SmsMessage], ollama: &OllamaClient, custom_system: Option<&str>, ) -> Option { if messages.is_empty() { return None; } // Format messages let formatted: Vec = messages .iter() .map(|m| { let sender = if m.is_sent { "Me" } else { &m.contact }; let timestamp = chrono::DateTime::from_timestamp(m.timestamp, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown time".to_string()); format!("[{}] {}: {}", timestamp, sender, m.body) }) .collect(); let messages_text = formatted.join("\n"); // Use a more detailed prompt for immediate context let prompt = format!( r#"Provide a detailed summary of the conversation context from these messages. Include: - Key activities, events, and plans discussed - Important locations or places mentioned - Emotional tone and relationship dynamics - Any significant details that provide context about what was happening Be thorough but organized. Use 1-2 paragraphs. Messages: {} Return ONLY the summary, nothing else."#, messages_text ); let system = custom_system.unwrap_or( "You are a context summarization assistant. Be detailed and factual, preserving important context.", ); match ollama.generate(&prompt, Some(system)).await { Ok(summary) => Some(summary), Err(e) => { log::warn!("Failed to summarize immediate context: {}", e); None } } } // ── Tool executors for agentic loop ──────────────────────────────── /// Dispatch a tool call to the appropriate executor async fn execute_tool( &self, tool_name: &str, arguments: &serde_json::Value, ollama: &OllamaClient, image_base64: &Option, file_path: &str, cx: &opentelemetry::Context, ) -> String { let result = match tool_name { "search_rag" => self.tool_search_rag(arguments, cx).await, "get_sms_messages" => self.tool_get_sms_messages(arguments, cx).await, "get_calendar_events" => self.tool_get_calendar_events(arguments, cx).await, "get_location_history" => self.tool_get_location_history(arguments, cx).await, "get_file_tags" => self.tool_get_file_tags(arguments, cx).await, "describe_photo" => self.tool_describe_photo(ollama, image_base64).await, "reverse_geocode" => self.tool_reverse_geocode(arguments).await, "recall_entities" => self.tool_recall_entities(arguments, cx).await, "recall_facts_for_photo" => self.tool_recall_facts_for_photo(arguments, cx).await, "store_entity" => self.tool_store_entity(arguments, ollama, cx).await, "store_fact" => self.tool_store_fact(arguments, file_path, cx).await, unknown => format!("Unknown tool: {}", unknown), }; if result.starts_with("Error") || result.starts_with("No ") { log::warn!("Tool '{}' result: {}", tool_name, result); } else { log::info!("Tool '{}' result: {} chars", tool_name, result.len()); } result } /// Tool: search_rag — semantic search over daily summaries async fn tool_search_rag( &self, args: &serde_json::Value, _cx: &opentelemetry::Context, ) -> String { let query = match args.get("query").and_then(|v| v.as_str()) { Some(q) => q.to_string(), None => return "Error: missing required parameter 'query'".to_string(), }; let date_str = match args.get("date").and_then(|v| v.as_str()) { Some(d) => d, None => return "Error: missing required parameter 'date'".to_string(), }; let date = match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { Ok(d) => d, Err(e) => return format!("Error: failed to parse date '{}': {}", date_str, e), }; let contact = args .get("contact") .and_then(|v| v.as_str()) .map(|s| s.to_string()); log::info!( "tool_search_rag: query='{}', date={}, contact={:?}", query, date, contact ); match self .find_relevant_messages_rag(date, None, contact.as_deref(), None, 5, Some(&query)) .await { Ok(results) if !results.is_empty() => results.join("\n\n"), Ok(_) => "No relevant messages found.".to_string(), Err(e) => format!("Error searching RAG: {}", e), } } /// Tool: get_sms_messages — fetch SMS messages near a date for a contact async fn tool_get_sms_messages( &self, args: &serde_json::Value, _cx: &opentelemetry::Context, ) -> String { let date_str = match args.get("date").and_then(|v| v.as_str()) { Some(d) => d, None => return "Error: missing required parameter 'date'".to_string(), }; let contact = args .get("contact") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let days_radius = args .get("days_radius") .and_then(|v| v.as_i64()) .unwrap_or(4); let date = match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { Ok(d) => d, Err(e) => return format!("Error: failed to parse date '{}': {}", date_str, e), }; let timestamp = date.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp(); log::info!( "tool_get_sms_messages: date={}, contact={:?}, days_radius={}", date, contact, days_radius ); match self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp) .await { Ok(messages) if !messages.is_empty() => { let formatted: Vec = messages .iter() .take(30) .map(|m| { let sender = if m.is_sent { "Me" } else { &m.contact }; let ts = DateTime::from_timestamp(m.timestamp, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown".to_string()); format!("[{}] {}: {}", ts, sender, m.body) }) .collect(); format!( "Found {} messages:\n{}", messages.len(), formatted.join("\n") ) } Ok(_) => "No messages found.".to_string(), Err(e) => { log::warn!("tool_get_sms_messages failed: {}", e); format!("Error fetching SMS messages: {}", e) } } } /// Tool: get_calendar_events — fetch calendar events near a date async fn tool_get_calendar_events( &self, args: &serde_json::Value, cx: &opentelemetry::Context, ) -> String { let date_str = match args.get("date").and_then(|v| v.as_str()) { Some(d) => d, None => return "Error: missing required parameter 'date'".to_string(), }; let days_radius = args .get("days_radius") .and_then(|v| v.as_i64()) .unwrap_or(7); let date = match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { Ok(d) => d, Err(e) => return format!("Error: failed to parse date '{}': {}", date_str, e), }; let timestamp = date.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp(); log::info!( "tool_get_calendar_events: date={}, days_radius={}", date, days_radius ); let events = { let mut dao = self .calendar_dao .lock() .expect("Unable to lock CalendarEventDao"); dao.find_relevant_events_hybrid(cx, timestamp, days_radius, None, 10) .ok() }; match events { Some(evts) if !evts.is_empty() => { let formatted: Vec = evts .iter() .map(|e| { let dt = DateTime::from_timestamp(e.start_time, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown".to_string()); let loc = e .location .as_ref() .map(|l| format!(" at {}", l)) .unwrap_or_default(); let attendees = e .attendees .as_ref() .and_then(|a| serde_json::from_str::>(a).ok()) .map(|list| format!(" (with {})", list.join(", "))) .unwrap_or_default(); format!("[{}] {}{}{}", dt, e.summary, loc, attendees) }) .collect(); format!( "Found {} calendar events:\n{}", evts.len(), formatted.join("\n") ) } Some(_) => "No calendar events found.".to_string(), None => "No calendar events found.".to_string(), } } /// Tool: get_location_history — fetch location records near a date async fn tool_get_location_history( &self, args: &serde_json::Value, cx: &opentelemetry::Context, ) -> String { let date_str = match args.get("date").and_then(|v| v.as_str()) { Some(d) => d, None => return "Error: missing required parameter 'date'".to_string(), }; let days_radius = args .get("days_radius") .and_then(|v| v.as_i64()) .unwrap_or(14); let date = match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { Ok(d) => d, Err(e) => return format!("Error: failed to parse date '{}': {}", date_str, e), }; let timestamp = date.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp(); log::info!( "tool_get_location_history: date={}, days_radius={}", date, days_radius ); let start_ts = timestamp - (days_radius * 86400); let end_ts = timestamp + (days_radius * 86400); let locations = { let mut dao = self .location_dao .lock() .expect("Unable to lock LocationHistoryDao"); dao.find_locations_in_range(cx, start_ts, end_ts).ok() }; match locations { Some(locs) if !locs.is_empty() => { let formatted: Vec = locs .iter() .take(20) .map(|loc| { let dt = DateTime::from_timestamp(loc.timestamp, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown".to_string()); let activity = loc .activity .as_ref() .map(|a| format!(" ({})", a)) .unwrap_or_default(); let place = loc .place_name .as_ref() .map(|p| format!(" at {}", p)) .unwrap_or_default(); format!( "[{}] {:.4}, {:.4}{}{}", dt, loc.latitude, loc.longitude, place, activity ) }) .collect(); format!( "Found {} location records:\n{}", locs.len(), formatted.join("\n") ) } Some(_) => "No location history found.".to_string(), None => "No location history found.".to_string(), } } /// Tool: get_file_tags — fetch tags for a file path async fn tool_get_file_tags( &self, args: &serde_json::Value, cx: &opentelemetry::Context, ) -> String { let file_path = match args.get("file_path").and_then(|v| v.as_str()) { Some(p) => p.to_string(), None => return "Error: missing required parameter 'file_path'".to_string(), }; log::info!("tool_get_file_tags: file_path='{}'", file_path); let tags = { let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao"); dao.get_tags_for_path(cx, &file_path).ok() }; match tags { Some(t) if !t.is_empty() => { let names: Vec = t.into_iter().map(|tag| tag.name).collect(); names.join(", ") } Some(_) => "No tags found.".to_string(), None => "No tags found.".to_string(), } } /// Tool: describe_photo — generate a visual description of the photo async fn tool_describe_photo( &self, ollama: &OllamaClient, image_base64: &Option, ) -> String { log::info!("tool_describe_photo: generating visual description"); match image_base64 { Some(img) => match ollama.generate_photo_description(img).await { Ok(desc) => desc, Err(e) => format!("Error describing photo: {}", e), }, None => "No image available for description.".to_string(), } } /// Tool: reverse_geocode — convert GPS coordinates to a human-readable place name async fn tool_reverse_geocode(&self, args: &serde_json::Value) -> String { let lat = match args.get("latitude").and_then(|v| v.as_f64()) { Some(v) => v, None => return "Error: missing required parameter 'latitude'".to_string(), }; let lon = match args.get("longitude").and_then(|v| v.as_f64()) { Some(v) => v, None => return "Error: missing required parameter 'longitude'".to_string(), }; log::info!("tool_reverse_geocode: lat={}, lon={}", lat, lon); match self.reverse_geocode(lat, lon).await { Some(place) => place, None => "Could not resolve coordinates to a place name.".to_string(), } } /// Tool: recall_entities — search the knowledge memory for known entities async fn tool_recall_entities( &self, args: &serde_json::Value, cx: &opentelemetry::Context, ) -> String { use crate::database::EntityFilter; let name_search = args .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let entity_type = args .get("entity_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10); log::info!( "tool_recall_entities: name={:?}, type={:?}, limit={}", name_search, entity_type, limit ); let filter = EntityFilter { entity_type, status: Some("active".to_string()), search: name_search, limit, offset: 0, }; let mut kdao = self .knowledge_dao .lock() .expect("Unable to lock KnowledgeDao"); match kdao.list_entities(cx, filter) { Ok((entities, _total)) if entities.is_empty() => { "No known entities found matching the query.".to_string() } Ok((entities, _total)) => { let lines: Vec = entities .iter() .map(|e| { format!( "ID:{} | {} | {} | {} | confidence:{:.2}", e.id, e.entity_type, e.name, e.description, e.confidence ) }) .collect(); format!("Known entities:\n{}", lines.join("\n")) } Err(e) => format!("Error recalling entities: {:?}", e), } } /// Tool: recall_facts_for_photo — retrieve facts linked to a specific photo async fn tool_recall_facts_for_photo( &self, args: &serde_json::Value, cx: &opentelemetry::Context, ) -> String { let file_path = match args.get("file_path").and_then(|v| v.as_str()) { Some(p) => p.to_string(), None => return "Error: missing required parameter 'file_path'".to_string(), }; log::info!("tool_recall_facts_for_photo: file_path={}", file_path); let mut kdao = self .knowledge_dao .lock() .expect("Unable to lock KnowledgeDao"); // Fetch photo links to find which entities appear in this photo let links = match kdao.get_links_for_photo(cx, &file_path) { Ok(l) => l, Err(e) => return format!("Error fetching photo links: {:?}", e), }; if links.is_empty() { return "No knowledge facts found for this photo.".to_string(); } let mut output_lines = Vec::new(); let entity_ids: Vec = links.iter().map(|l| l.entity_id).collect(); // For each linked entity, fetch its facts for entity_id in entity_ids { if let Ok(entity) = kdao.get_entity_by_id(cx, entity_id) { if let Some(e) = entity { let role = links .iter() .find(|l| l.entity_id == entity_id) .map(|l| l.role.as_str()) .unwrap_or("subject"); output_lines.push(format!( "Entity: {} ({}, role: {})", e.name, e.entity_type, role )); if let Ok(facts) = kdao.get_facts_for_entity(cx, entity_id) { for f in facts.iter().filter(|f| f.status == "active") { let obj = if let Some(ref v) = f.object_value { v.clone() } else if let Some(oid) = f.object_entity_id { kdao.get_entity_by_id(cx, oid) .ok() .flatten() .map(|e| format!("{} (entity ID: {})", e.name, e.id)) .unwrap_or_else(|| format!("entity:{}", oid)) } else { "(unknown)".to_string() }; output_lines.push(format!(" - {} {}", f.predicate, obj)); } } } } } if output_lines.is_empty() { "No active knowledge facts found for this photo.".to_string() } else { format!("Knowledge for this photo:\n{}", output_lines.join("\n")) } } /// Tool: store_entity — upsert an entity into the knowledge memory async fn tool_store_entity( &self, args: &serde_json::Value, ollama: &OllamaClient, cx: &opentelemetry::Context, ) -> String { use crate::database::models::InsertEntity; let name = match args.get("name").and_then(|v| v.as_str()) { Some(n) => n.to_string(), None => return "Error: missing required parameter 'name'".to_string(), }; let entity_type = match args.get("entity_type").and_then(|v| v.as_str()) { Some(t) => t.to_string(), None => return "Error: missing required parameter 'entity_type'".to_string(), }; let description = args .get("description") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); log::info!( "tool_store_entity: name='{}', type='{}', description='{}'", name, entity_type, description ); // Generate embedding for name + description (best-effort) let embed_text = format!("{} {}", name, description); let embedding: Option> = match ollama.generate_embedding(&embed_text).await { Ok(vec) => { let bytes: Vec = vec.iter().flat_map(|f| f.to_le_bytes()).collect(); Some(bytes) } Err(e) => { log::warn!("Embedding generation failed for entity '{}': {}", name, e); None } }; let now = chrono::Utc::now().timestamp(); let insert = InsertEntity { name, entity_type, description, embedding, confidence: 0.6, status: "active".to_string(), created_at: now, updated_at: now, }; let mut kdao = self .knowledge_dao .lock() .expect("Unable to lock KnowledgeDao"); match kdao.upsert_entity(cx, insert) { Ok(entity) => format!( "Entity stored: ID:{} | {} | {} | confidence:{:.2}", entity.id, entity.entity_type, entity.name, entity.confidence ), Err(e) => format!("Error storing entity: {:?}", e), } } /// Tool: store_fact — record a fact about an entity, linked to the current photo async fn tool_store_fact( &self, args: &serde_json::Value, file_path: &str, cx: &opentelemetry::Context, ) -> String { use crate::database::models::{InsertEntityFact, InsertEntityPhotoLink}; let subject_entity_id = match args.get("subject_entity_id").and_then(|v| v.as_i64()) { Some(id) => id as i32, None => return "Error: missing required parameter 'subject_entity_id'".to_string(), }; let predicate = match args.get("predicate").and_then(|v| v.as_str()) { Some(p) => p.to_string(), None => return "Error: missing required parameter 'predicate'".to_string(), }; let object_entity_id = args .get("object_entity_id") .and_then(|v| v.as_i64()) .map(|id| id as i32); let object_value = args .get("object_value") .and_then(|v| v.as_str()) .map(|s| s.to_string()); if object_entity_id.is_none() && object_value.is_none() { return "Error: provide either object_entity_id or object_value".to_string(); } let photo_role = args .get("photo_role") .and_then(|v| v.as_str()) .unwrap_or("subject") .to_string(); log::info!( "tool_store_fact: entity_id={}, predicate='{}', object_entity_id={:?}, object_value={:?}, photo='{}'", subject_entity_id, predicate, object_entity_id, object_value, file_path ); let fact = InsertEntityFact { subject_entity_id, predicate, object_entity_id, object_value, source_photo: Some(file_path.to_string()), source_insight_id: None, // will be backfilled after store_insight confidence: 0.6, status: "active".to_string(), created_at: chrono::Utc::now().timestamp(), }; let mut kdao = self .knowledge_dao .lock() .expect("Unable to lock KnowledgeDao"); // Upsert the fact (corroboration bumps confidence if duplicate) let (stored_fact, is_new) = match kdao.upsert_fact(cx, fact) { Ok(r) => r, Err(e) => return format!("Error storing fact: {:?}", e), }; // Upsert a photo link so this entity is associated with this photo let link = InsertEntityPhotoLink { entity_id: subject_entity_id, file_path: file_path.to_string(), role: photo_role, }; if let Err(e) = kdao.upsert_photo_link(cx, link) { log::warn!( "Failed to upsert photo link for entity {}: {:?}", subject_entity_id, e ); } let action = if is_new { "Stored new fact" } else { "Corroborated existing fact" }; format!( "{}: ID:{} | confidence:{:.2}", action, stored_fact.id, stored_fact.confidence ) } // ── Agentic insight generation ────────────────────────────────────── /// Build the list of tool definitions for the agentic loop fn build_tool_definitions(has_vision: bool) -> Vec { let mut tools = vec![ Tool::function( "search_rag", "Search conversation history using semantic search. Use this to find relevant past conversations about specific topics, people, or events.", serde_json::json!({ "type": "object", "required": ["query", "date"], "properties": { "query": { "type": "string", "description": "The search query to find relevant conversations" }, "date": { "type": "string", "description": "The reference date in YYYY-MM-DD format" }, "contact": { "type": "string", "description": "Optional contact name to filter results" } } }), ), Tool::function( "get_sms_messages", "Fetch SMS/text messages near a specific date. Returns the actual message conversation. Omit contact to search across all conversations.", serde_json::json!({ "type": "object", "required": ["date"], "properties": { "date": { "type": "string", "description": "The center date in YYYY-MM-DD format" }, "contact": { "type": "string", "description": "Optional contact name to filter messages. If omitted, searches all conversations." }, "days_radius": { "type": "integer", "description": "Number of days before and after the date to search (default: 4)" } } }), ), Tool::function( "get_calendar_events", "Fetch calendar events near a specific date. Shows scheduled events, meetings, and activities.", serde_json::json!({ "type": "object", "required": ["date"], "properties": { "date": { "type": "string", "description": "The center date in YYYY-MM-DD format" }, "days_radius": { "type": "integer", "description": "Number of days before and after the date to search (default: 7)" } } }), ), Tool::function( "get_location_history", "Fetch location history records near a specific date. Shows places visited and activities.", serde_json::json!({ "type": "object", "required": ["date"], "properties": { "date": { "type": "string", "description": "The center date in YYYY-MM-DD format" }, "days_radius": { "type": "integer", "description": "Number of days before and after the date to search (default: 14)" } } }), ), Tool::function( "get_file_tags", "Get tags/labels that have been applied to a specific photo file.", serde_json::json!({ "type": "object", "required": ["file_path"], "properties": { "file_path": { "type": "string", "description": "The file path of the photo to get tags for" } } }), ), ]; tools.push(Tool::function( "reverse_geocode", "Convert GPS latitude/longitude coordinates to a human-readable place name (city, state). Use this when GPS coordinates are available in the photo metadata, or to resolve coordinates returned by get_location_history.", serde_json::json!({ "type": "object", "required": ["latitude", "longitude"], "properties": { "latitude": { "type": "number", "description": "GPS latitude in decimal degrees" }, "longitude": { "type": "number", "description": "GPS longitude in decimal degrees" } } }), )); // Knowledge memory tools tools.push(Tool::function( "recall_entities", "Search the knowledge memory for people, places, events, or things previously learned from other photos. Use this to retrieve context about subjects appearing in this photo.", serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Name or partial name to search for (case-insensitive substring match)" }, "entity_type": { "type": "string", "enum": ["person", "place", "event", "thing"], "description": "Filter by entity type (optional)" }, "limit": { "type": "integer", "description": "Maximum number of results to return (default: 10)" } } }), )); tools.push(Tool::function( "recall_facts_for_photo", "Retrieve all known facts linked to a specific photo from the knowledge memory. Use this at the start of insight generation to load any previously stored knowledge about subjects in this photo.", serde_json::json!({ "type": "object", "required": ["file_path"], "properties": { "file_path": { "type": "string", "description": "The file path of the photo to retrieve facts for" } } }), )); tools.push(Tool::function( "store_entity", "Store or update a person, place, event, or thing in the knowledge memory. Call this when you identify a subject in this photo that should be remembered for future insights.", serde_json::json!({ "type": "object", "required": ["name", "entity_type"], "properties": { "name": { "type": "string", "description": "The canonical name of the entity (e.g. 'John Smith', 'Banff National Park')" }, "entity_type": { "type": "string", "enum": ["person", "place", "event", "thing"], "description": "The type of entity" }, "description": { "type": "string", "description": "A brief description of the entity" } } }), )); tools.push(Tool::function( "store_fact", "Record a fact about an entity in the knowledge memory. Provide EITHER object_entity_id (when the object is a known entity whose ID you have) OR object_value (for free-text attributes). The fact will be linked to the current photo automatically.", serde_json::json!({ "type": "object", "required": ["subject_entity_id", "predicate"], "properties": { "subject_entity_id": { "type": "integer", "description": "The ID of the entity this fact is about (returned by store_entity or recall_entities)" }, "predicate": { "type": "string", "description": "The relationship or attribute (e.g. 'is_friend_of', 'located_in', 'attended_event', 'is_sibling_of')" }, "object_entity_id": { "type": "integer", "description": "Use when the object is a known entity (e.g. Cameron's entity ID for 'is_friend_of Cameron'). Takes precedence over object_value." }, "object_value": { "type": "string", "description": "Use for free-text attributes where the object is not a stored entity (e.g. 'Portland, Oregon', 'software engineer')" }, "photo_role": { "type": "string", "description": "How this entity appears in the photo (e.g. 'subject', 'background', 'location'). Defaults to 'subject'." } } }), )); if has_vision { tools.push(Tool::function( "describe_photo", "Generate a visual description of the photo. Describes people, location, and activity visible in the image.", serde_json::json!({ "type": "object", "properties": {} }), )); } tools } /// Generate an AI insight for a photo using an agentic tool-calling loop. /// The model decides which tools to call to gather context before writing the final insight. pub async fn generate_agentic_insight_for_photo( &self, file_path: &str, custom_model: Option, custom_system_prompt: Option, num_ctx: Option, max_iterations: usize, ) -> Result<(Option, Option)> { let tracer = global_tracer(); let current_cx = opentelemetry::Context::current(); let mut span = tracer.start_with_context("ai.insight.generate_agentic", ¤t_cx); let file_path = normalize_path(file_path); log::info!("Generating agentic insight for photo: {}", file_path); span.set_attribute(KeyValue::new("file_path", file_path.clone())); span.set_attribute(KeyValue::new("max_iterations", max_iterations as i64)); // 1. Create OllamaClient let mut ollama_client = if let Some(ref model) = custom_model { log::info!("Using custom model for agentic: {}", model); span.set_attribute(KeyValue::new("custom_model", model.clone())); OllamaClient::new( self.ollama.primary_url.clone(), self.ollama.fallback_url.clone(), model.clone(), Some(model.clone()), ) } else { span.set_attribute(KeyValue::new("model", self.ollama.primary_model.clone())); self.ollama.clone() }; if let Some(ctx) = num_ctx { log::info!("Using custom context size: {}", ctx); span.set_attribute(KeyValue::new("num_ctx", ctx as i64)); ollama_client.set_num_ctx(Some(ctx)); } let insight_cx = current_cx.with_span(span); // 2a. Verify the model exists on at least one server before checking capabilities if let Some(ref model_name) = custom_model { let available_on_primary = OllamaClient::is_model_available(&ollama_client.primary_url, model_name) .await .unwrap_or(false); let available_on_fallback = if let Some(ref fallback_url) = ollama_client.fallback_url { OllamaClient::is_model_available(fallback_url, model_name) .await .unwrap_or(false) } else { false }; if !available_on_primary && !available_on_fallback { anyhow::bail!( "model not available: '{}' not found on any configured server", model_name ); } } // 2b. Check tool calling capability — try primary, fall back to fallback URL let model_name_for_caps = &ollama_client.primary_model; let capabilities = match OllamaClient::check_model_capabilities( &ollama_client.primary_url, model_name_for_caps, ) .await { Ok(caps) => caps, Err(_) => { // Model may only be on the fallback server let fallback_url = ollama_client.fallback_url.as_deref().ok_or_else(|| { anyhow::anyhow!( "Failed to check model capabilities for '{}': model not found on primary server and no fallback configured", model_name_for_caps ) })?; OllamaClient::check_model_capabilities(fallback_url, model_name_for_caps) .await .map_err(|e| { anyhow::anyhow!( "Failed to check model capabilities for '{}': {}", model_name_for_caps, e ) })? } }; if !capabilities.has_tool_calling { return Err(anyhow::anyhow!( "tool calling not supported by model '{}'", ollama_client.primary_model )); } let has_vision = capabilities.has_vision; insight_cx .span() .set_attribute(KeyValue::new("model_has_vision", has_vision)); insight_cx .span() .set_attribute(KeyValue::new("model_has_tool_calling", true)); // 3. Fetch EXIF let exif = { let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); exif_dao .get_exif(&insight_cx, &file_path) .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; // 4. Extract timestamp and contact let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { ts } else { log::warn!("No date_taken in EXIF for {}, trying filename", file_path); extract_date_from_filename(&file_path) .map(|dt| dt.timestamp()) .or_else(|| { let full_path = std::path::Path::new(&self.base_path).join(&file_path); File::open(&full_path) .and_then(|f| f.metadata()) .and_then(|m| m.created().or(m.modified())) .map(|t| DateTime::::from(t).timestamp()) .inspect_err(|e| { log::warn!( "Failed to get file timestamp for agentic insight {}: {}", file_path, e ) }) .ok() }) .unwrap_or_else(|| Utc::now().timestamp()) }; let date_taken = DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.date_naive()) .unwrap_or_else(|| Utc::now().date_naive()); let contact = Self::extract_contact_from_path(&file_path); log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact); // 5. Fetch tags let tag_names: Vec = { let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao"); dao.get_tags_for_path(&insight_cx, &file_path) .unwrap_or_else(|e| { log::warn!("Failed to fetch tags for agentic {}: {}", file_path, e); Vec::new() }) .into_iter() .map(|t| t.name) .collect() }; // 6. Clear existing entity-photo links for this file so the run starts fresh, // and ensure the owner entity (Cameron) exists so the agent can reference it. let cameron_entity_id: Option = { let mut kdao = self .knowledge_dao .lock() .expect("Unable to lock KnowledgeDao"); if let Err(e) = kdao.delete_photo_links_for_file(&insight_cx, &file_path) { log::warn!( "Failed to clear entity_photo_links for {}: {:?}", file_path, e ); } // Upsert the owner entity so the agent always has a stable entity ID to reference. let owner = crate::database::models::InsertEntity { name: "Cameron".to_string(), entity_type: "person".to_string(), description: "The owner of this photo collection. All memories are written from Cameron's perspective.".to_string(), embedding: None, confidence: 1.0, status: "active".to_string(), created_at: Utc::now().timestamp(), updated_at: Utc::now().timestamp(), }; match kdao.upsert_entity(&insight_cx, owner) { Ok(e) => { log::info!("Cameron entity ID: {}", e.id); Some(e.id) } Err(e) => { log::warn!("Failed to upsert Cameron entity: {:?}", e); None } } }; // 7. Load image if vision capable let image_base64 = if has_vision { match self.load_image_as_base64(&file_path) { Ok(b64) => { log::info!("Loaded image for vision-capable agentic model"); Some(b64) } Err(e) => { log::warn!("Failed to load image for agentic vision: {}", e); None } } } else { None }; // 8. Build system message let cameron_id_note = match cameron_entity_id { Some(id) => format!( "\n\nYour identity in the knowledge store: Cameron (entity ID: {}). \ When storing facts where you (Cameron) are the object — for example, someone is your friend, \ sibling, or colleague — use subject_entity_id for the other person and set object_value to \ \"Cameron\" (or use store_fact with the other person as subject). When storing facts about \ Cameron directly, use {} as the subject_entity_id.", id, id ), None => String::new(), }; let base_system = format!( "You are a personal photo memory assistant helping to reconstruct a memory from a photo. \ You are writing from the perspective of Cameron, the owner of this photo collection.{cameron_id_note}\n\n\ IMPORTANT INSTRUCTIONS:\n\ 1. You MUST call multiple tools to gather context BEFORE writing any final insight. Do not produce a final answer after only one or two tool calls.\n\ 2. Always call ALL of the following tools that are relevant: search_rag (search conversation summaries), get_sms_messages (fetch nearby messages), get_calendar_events (check what was happening that day), get_location_history (find where this was taken), get_file_tags (retrieve tags).\n\ 3. Use recall_facts_for_photo to load any previously stored knowledge about subjects in this photo.\n\ 4. Use recall_entities to look up known people, places, or things that appear in this photo.\n\ 5. When you identify people, places, events, or notable things in this photo: use store_entity to record them and store_fact to record key facts (relationships, roles, attributes). This builds a persistent memory for future insights.\n\ 6. Only produce your final insight AFTER you have gathered context from at least 3-4 tools.\n\ 7. If a tool returns no results, that is useful information — continue calling the remaining tools anyway.\n\ 8. Your final insight must be written in first person as Cameron, in a journal/memoir style.", cameron_id_note = cameron_id_note ); let system_content = if let Some(ref custom) = custom_system_prompt { format!("{}\n\n{}", custom, base_system) } else { base_system.to_string() }; // 9. Build user message let gps_info = exif .as_ref() .and_then(|e| { if let (Some(lat), Some(lon)) = (e.gps_latitude, e.gps_longitude) { Some(format!("GPS: {:.4}, {:.4}", lat, lon)) } else { None } }) .unwrap_or_else(|| "GPS: unknown".to_string()); let tags_info = if tag_names.is_empty() { "Tags: none".to_string() } else { format!("Tags: {}", tag_names.join(", ")) }; let contact_info = contact .as_ref() .map(|c| format!("Contact/Person: {}", c)) .unwrap_or_else(|| "Contact/Person: unknown".to_string()); let user_content = format!( "Please analyze this photo and gather context to write a personal journal-style insight.\n\n\ Photo file path: {}\n\ Date taken: {}\n\ {}\n\ {}\n\ {}\n\n\ Use the available tools to gather more context about this moment (messages, calendar events, location history, etc.), \ then write a detailed personal insight with a title and summary. Write in first person as Cameron.", file_path, date_taken.format("%B %d, %Y"), contact_info, gps_info, tags_info, ); // 10. Define tools let tools = Self::build_tool_definitions(has_vision); // 11. Build initial messages let system_msg = ChatMessage::system(system_content); let mut user_msg = ChatMessage::user(user_content); if let Some(ref img) = image_base64 { user_msg.images = Some(vec![img.clone()]); } let mut messages = vec![system_msg, user_msg]; // 12. Agentic loop let loop_span = tracer.start_with_context("ai.agentic.loop", &insight_cx); let loop_cx = insight_cx.with_span(loop_span); let mut final_content = String::new(); let mut iterations_used = 0usize; let mut last_prompt_eval_count: Option = None; let mut last_eval_count: Option = None; for iteration in 0..max_iterations { iterations_used = iteration + 1; log::info!("Agentic iteration {}/{}", iteration + 1, max_iterations); let (response, prompt_tokens, eval_tokens) = ollama_client .chat_with_tools(messages.clone(), tools.clone()) .await?; last_prompt_eval_count = prompt_tokens; last_eval_count = eval_tokens; // Sanitize tool call arguments before pushing back into history. // Some models occasionally return non-object arguments (bool, string, null) // which Ollama rejects when they are re-sent in a subsequent request. let mut response = response; if let Some(ref mut tool_calls) = response.tool_calls { for tc in tool_calls.iter_mut() { if !tc.function.arguments.is_object() { log::warn!( "Tool '{}' returned non-object arguments ({:?}), normalising to {{}}", tc.function.name, tc.function.arguments ); tc.function.arguments = serde_json::Value::Object(Default::default()); } } } messages.push(response.clone()); if let Some(ref tool_calls) = response.tool_calls && !tool_calls.is_empty() { for tool_call in tool_calls { log::info!( "Agentic tool call [{}]: {} {:?}", iteration, tool_call.function.name, tool_call.function.arguments ); let result = self .execute_tool( &tool_call.function.name, &tool_call.function.arguments, &ollama_client, &image_base64, &file_path, &loop_cx, ) .await; messages.push(ChatMessage::tool_result(result)); } continue; } // No tool calls — this is the final answer final_content = response.content; break; } // If loop exhausted without final answer, ask for one if final_content.is_empty() { log::info!( "Agentic loop exhausted after {} iterations, requesting final answer", iterations_used ); messages.push(ChatMessage::user( "Based on the context gathered, please write the final photo insight: a title and a detailed personal summary. Write in first person as Cameron.", )); let (final_response, prompt_tokens, eval_tokens) = ollama_client.chat_with_tools(messages, vec![]).await?; last_prompt_eval_count = prompt_tokens; last_eval_count = eval_tokens; final_content = final_response.content; } loop_cx .span() .set_attribute(KeyValue::new("iterations_used", iterations_used as i64)); loop_cx.span().set_status(Status::Ok); // 13. Generate title let title = ollama_client .generate_photo_title(&final_content, custom_system_prompt.as_deref()) .await?; log::info!("Agentic generated title: {}", title); log::info!( "Agentic generated summary ({} chars): {}", final_content.len(), &final_content[..final_content.len().min(200)] ); // 14. Store insight (returns the persisted row including its new id) let insight = InsertPhotoInsight { file_path: file_path.to_string(), title, summary: final_content, generated_at: Utc::now().timestamp(), model_version: ollama_client.primary_model.clone(), is_current: true, }; let stored = { let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); dao.store_insight(&insight_cx, insight) .map_err(|e| anyhow::anyhow!("Failed to store agentic insight: {:?}", e)) }; match &stored { Ok(_) => { log::info!("Successfully stored agentic insight for {}", file_path); insight_cx.span().set_status(Status::Ok); } Err(e) => { log::error!("Failed to store agentic insight: {:?}", e); insight_cx.span().set_status(Status::error(e.to_string())); } } let stored_insight = stored?; // 15. Backfill source_insight_id on all facts recorded for this photo during the loop { let mut kdao = self .knowledge_dao .lock() .expect("Unable to lock KnowledgeDao"); if let Err(e) = kdao.update_facts_insight_id(&insight_cx, &file_path, stored_insight.id) { log::warn!( "Failed to backfill source_insight_id for {}: {:?}", file_path, e ); } } Ok((last_prompt_eval_count, last_eval_count)) } /// Reverse geocode GPS coordinates to human-readable place names async fn reverse_geocode(&self, lat: f64, lon: f64) -> Option { let url = format!( "https://nominatim.openstreetmap.org/reverse?format=json&lat={}&lon={}", lat, lon ); log::debug!("Reverse geocoding {}, {} via Nominatim", lat, lon); let client = reqwest::Client::new(); let response = match client .get(&url) .header("User-Agent", "ImageAPI/1.0") // Nominatim requires User-Agent .send() .await { Ok(resp) => resp, Err(e) => { log::warn!("Geocoding network error for {}, {}: {}", lat, lon, e); return None; } }; if !response.status().is_success() { log::warn!( "Geocoding HTTP error for {}, {}: {}", lat, lon, response.status() ); return None; } let data: NominatimResponse = match response.json().await { Ok(d) => d, Err(e) => { log::warn!("Geocoding JSON parse error for {}, {}: {}", lat, lon, e); return None; } }; // Try to build a concise location name if let Some(addr) = data.address { let mut parts = Vec::new(); // Prefer city/town/village if let Some(city) = addr.city.or(addr.town).or(addr.village) { parts.push(city); } // Add state if available if let Some(state) = addr.state { parts.push(state); } if !parts.is_empty() { log::info!("Reverse geocoded {}, {} -> {}", lat, lon, parts.join(", ")); return Some(parts.join(", ")); } } // Fallback to display_name if structured address not available if let Some(ref display_name) = data.display_name { log::info!( "Reverse geocoded {}, {} -> {} (display_name)", lat, lon, display_name ); } else { log::warn!("Geocoding returned no address data for {}, {}", lat, lon); } data.display_name } } #[cfg(test)] mod tests { use super::*; #[test] fn combine_contexts_includes_tags_section_when_tags_present() { let result = InsightGenerator::combine_contexts( None, None, None, None, Some("vacation, hiking, mountains".to_string()), ); assert!(result.contains("## Tags"), "Should include Tags section"); assert!( result.contains("vacation, hiking, mountains"), "Should include tag names" ); } #[test] fn combine_contexts_omits_tags_section_when_no_tags() { let result = InsightGenerator::combine_contexts( Some("some messages".to_string()), None, None, None, None, // no tags ); assert!( !result.contains("## Tags"), "Should not include Tags section when None" ); assert!( result.contains("## Messages"), "Should still include Messages" ); } #[test] fn combine_contexts_returns_no_context_message_when_all_none() { let result = InsightGenerator::combine_contexts(None, None, None, None, None); assert_eq!(result, "No additional context available"); } }