use anyhow::Result; use chrono::{DateTime, Utc}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::Deserialize; use std::fs::File; use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; use crate::database::{DailySummaryDao, ExifDao, InsightDao}; use crate::memories::extract_date_from_filename; use crate::otel::global_tracer; 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>>, base_path: String, } impl InsightGenerator { pub fn new( ollama: OllamaClient, sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, base_path: String, ) -> Self { Self { ollama, sms_client, insight_dao, exif_dao, daily_summary_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 { if let Some(component) = components.first() { if let Some(os_str) = component.as_os_str().to_str() { return Some(os_str.to_string()); } } } None } /// 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>, limit: usize, ) -> 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)); let query_results = self .find_relevant_messages_rag(date, location, contact, limit * 2) .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(']') { if 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>, limit: usize, ) -> 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 more detailed query string from photo context 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)); let query = query_parts.join(", "); 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 let mut summary_dao = self .daily_summary_dao .lock() .expect("Unable to lock DailySummaryDao"); let similar_summaries = summary_dao .find_similar_summaries(&search_cx, &query_embedding, 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) } /// Generate AI insight for a single photo with optional custom model pub async fn generate_insight_for_photo_with_model( &self, file_path: &str, custom_model: 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 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() }; // 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())); } // 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, lon).await; if let Some(ref l) = loc { insight_cx .span() .set_attribute(KeyValue::new("location", l.clone())); } loc } else { None } } None => None, }; // 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; // Decide strategy based on available metadata let has_strong_query = location.is_some(); if disable_rag_for_testing { log::warn!("RAG DISABLED FOR TESTING - Using only time-based retrieval (±1 day)"); // Skip directly to fallback } else if has_strong_query { // Strategy A: Pure RAG (we have location for good semantic matching) log::info!("Using RAG with location-based query"); match self .find_relevant_messages_rag(date_taken, location.as_deref(), contact.as_deref(), 20) .await { Ok(rag_messages) if !rag_messages.is_empty() => { used_rag = true; sms_summary = self.summarize_messages(&rag_messages, &ollama_client).await; } Ok(_) => log::info!("RAG returned no messages"), Err(e) => log::warn!("RAG failed: {}", e), } } else { // Strategy B: Expanded immediate context + historical RAG log::info!("Using expanded immediate context + historical RAG approach"); // Step 1: Get FULL immediate temporal context (±1 day, 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 ±1 day 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); // Step 3: Try historical RAG (>30 days ago) match self .find_relevant_messages_rag_historical( &insight_cx, date_taken, None, contact.as_deref(), 10, // Top 10 historical matches ) .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) .await .unwrap_or_else(|| String::from("No immediate context")); let historical_summary = self .summarize_messages(&historical_messages, &ollama_client) .await .unwrap_or_else(|| String::from("No historical context")); // Combine summaries sms_summary = Some(format!( "Immediate context (±1 day): {}\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) .await; } Err(e) => { log::warn!("Historical RAG failed, using immediate context only: {}", e); sms_summary = self .summarize_context_from_messages(&immediate_messages, &ollama_client) .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(), 20) .await { Ok(rag_messages) if !rag_messages.is_empty() => { used_rag = true; sms_summary = self.summarize_messages(&rag_messages, &ollama_client).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 (±1 day)"); 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(), chrono::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 ); // 7. Generate title and summary with Ollama let title = ollama_client .generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref()) .await?; let summary = ollama_client .generate_photo_summary(date_taken, location.as_deref(), sms_summary.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)); // 8. 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(), }; 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) => { // Parse comma-separated topics 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() } Err(e) => { log::warn!("Failed to extract topics from messages: {}", e); Vec::new() } } } /// Find relevant messages using RAG with topic-enriched query async fn find_relevant_messages_rag_enriched( &self, date: chrono::NaiveDate, contact: Option<&str>, topics: &[String], limit: usize, ) -> Result> { // Build enriched query from date + topics let mut query_parts = Vec::new(); query_parts.push(format!("On {}", date.format("%B %d, %Y"))); if !topics.is_empty() { query_parts.push(format!("about {}", topics.join(", "))); } if let Some(c) = contact { query_parts.push(format!("conversation with {}", c)); } // Add day of week let weekday = date.format("%A"); query_parts.push(format!("it was a {}", weekday)); let query = query_parts.join(", "); log::info!("========================================"); log::info!("ENRICHED RAG QUERY: {}", query); log::info!("Extracted topics: {:?}", topics); log::info!("========================================"); // Use existing RAG method with enriched query self.find_relevant_messages_rag(date, None, contact, limit) .await } /// Summarize pre-formatted message strings using LLM (concise version for historical context) async fn summarize_messages( &self, messages: &[String], ollama: &OllamaClient, ) -> 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 ); match ollama .generate( &prompt, Some("You are a context summarization assistant. Be concise and factual."), ) .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 (±1 day) to preserve conversation details async fn summarize_context_from_messages( &self, messages: &[crate::ai::SmsMessage], ollama: &OllamaClient, ) -> 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 ); match ollama .generate( &prompt, Some("You are a context summarization assistant. Be detailed and factual, preserving important context."), ) .await { Ok(summary) => Some(summary), Err(e) => { log::warn!("Failed to summarize immediate context: {}", e); None } } } /// 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 ); let client = reqwest::Client::new(); let response = client .get(&url) .header("User-Agent", "ImageAPI/1.0") // Nominatim requires User-Agent .send() .await .ok()?; if !response.status().is_success() { log::warn!( "Geocoding failed for {}, {}: {}", lat, lon, response.status() ); return None; } let data: NominatimResponse = response.json().await.ok()?; // 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() { return Some(parts.join(", ")); } } // Fallback to display_name if structured address not available data.display_name } }