Create Insight Generation Feature
Added integration with Messages API and Ollama
This commit is contained in:
239
src/ai/insight_generator.rs
Normal file
239
src/ai/insight_generator.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::ai::ollama::OllamaClient;
|
||||
use crate::ai::sms_client::SmsApiClient;
|
||||
use crate::database::models::InsertPhotoInsight;
|
||||
use crate::database::{ExifDao, InsightDao};
|
||||
use crate::memories::extract_date_from_filename;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NominatimResponse {
|
||||
display_name: Option<String>,
|
||||
address: Option<NominatimAddress>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NominatimAddress {
|
||||
city: Option<String>,
|
||||
town: Option<String>,
|
||||
village: Option<String>,
|
||||
county: Option<String>,
|
||||
state: Option<String>,
|
||||
country: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InsightGenerator {
|
||||
ollama: OllamaClient,
|
||||
sms_client: SmsApiClient,
|
||||
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
}
|
||||
|
||||
impl InsightGenerator {
|
||||
pub fn new(
|
||||
ollama: OllamaClient,
|
||||
sms_client: SmsApiClient,
|
||||
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ollama,
|
||||
sms_client,
|
||||
insight_dao,
|
||||
exif_dao,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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
|
||||
}
|
||||
|
||||
/// Generate AI insight for a single photo
|
||||
pub async fn generate_insight_for_photo(&self, file_path: &str) -> Result<()> {
|
||||
log::info!("Generating insight for photo: {}", file_path);
|
||||
|
||||
// 1. Get EXIF data for the photo
|
||||
let otel_context = opentelemetry::Context::new();
|
||||
let exif = {
|
||||
let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
exif_dao
|
||||
.get_exif(&otel_context, 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())
|
||||
.unwrap_or_else(|| Utc::now().timestamp())
|
||||
};
|
||||
|
||||
let date_taken = chrono::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);
|
||||
|
||||
// 4. Fetch SMS messages for the contact (±1 day)
|
||||
// Pass the full timestamp for proximity sorting
|
||||
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())
|
||||
);
|
||||
|
||||
// 5. Summarize SMS context
|
||||
let sms_summary = if !sms_messages.is_empty() {
|
||||
match self
|
||||
.sms_client
|
||||
.summarize_context(&sms_messages, &self.ollama)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => Some(summary),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to summarize SMS context: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 6. Get location name from GPS coordinates
|
||||
let location = match exif {
|
||||
Some(exif) => {
|
||||
if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) {
|
||||
self.reverse_geocode(lat, lon).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Photo context: date={}, location={:?}, sms_messages={}",
|
||||
date_taken,
|
||||
location,
|
||||
sms_messages.len()
|
||||
);
|
||||
|
||||
// 7. Generate title and summary with Ollama
|
||||
let title = self
|
||||
.ollama
|
||||
.generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref())
|
||||
.await?;
|
||||
|
||||
let summary = self
|
||||
.ollama
|
||||
.generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref())
|
||||
.await?;
|
||||
|
||||
log::info!("Generated title: {}", title);
|
||||
log::info!("Generated summary: {}", summary);
|
||||
|
||||
// 8. Store in database
|
||||
let insight = InsertPhotoInsight {
|
||||
file_path: file_path.to_string(),
|
||||
title,
|
||||
summary,
|
||||
generated_at: Utc::now().timestamp(),
|
||||
model_version: self.ollama.model.clone(),
|
||||
};
|
||||
|
||||
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
dao.store_insight(&otel_context, insight)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e))?;
|
||||
|
||||
log::info!("Successfully stored insight for {}", file_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reverse geocode GPS coordinates to human-readable place names
|
||||
async fn reverse_geocode(&self, lat: f64, lon: f64) -> Option<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user