Files
ImageApi/src/ai/insight_generator.rs
Cameron Cordes 6f0c15d0c5 insight-chat: code-review polish on get_faces_in_photo
- Drop redundant `use anyhow::Context` inside has_any_faces (already
  imported at the module level).
- Drop dead `.unwrap_or("?")` on bound faces — the vec is filtered to
  is_some() so the fallback can never fire.
- Reorder the face_dao constructor param + initializer to match the
  struct declaration (between tag_dao and knowledge_dao). Update both
  state.rs call sites and populate_knowledge.rs to match.
- Hold face_dao lock once across the library-resolver loop instead of
  reacquiring per iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:48:22 -04:00

4255 lines
163 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::Result;
use base64::Engine as _;
use chrono::{DateTime, Local, 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::apollo_client::{ApolloClient, ApolloPlace};
use crate::ai::llm_client::LlmClient;
use crate::ai::ollama::{ChatMessage, OllamaClient, Tool};
use crate::ai::openrouter::OpenRouterClient;
use crate::ai::sms_client::SmsApiClient;
use crate::ai::user_display_name;
use crate::database::models::InsertPhotoInsight;
use crate::database::{
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao,
SearchHistoryDao,
};
use crate::libraries::Library;
use crate::memories::extract_date_from_filename;
use crate::otel::global_tracer;
use crate::tags::TagDao;
use crate::utils::{earliest_fs_time, normalize_path};
/// Combine an optional personal Apollo Place with an optional Nominatim
/// reverse-geocoded city, falling back to bare coordinates when neither
/// resolves. Free function so we can test it cheaply without spinning up
/// the whole InsightGenerator.
fn compose_location_string(
apollo: Option<ApolloPlace>,
nominatim: Option<String>,
lat: f64,
lon: f64,
) -> String {
match (apollo, nominatim) {
(Some(p), Some(n)) if !p.description.is_empty() => {
format!("{} ({}) — near {}", p.name, p.description, n)
}
(Some(p), Some(n)) => format!("{} — near {}", p.name, n),
(Some(p), None) if !p.description.is_empty() => format!("{} ({})", p.name, p.description),
(Some(p), None) => p.name,
(None, Some(n)) => n,
(None, None) => format!("{lat:.4}, {lon:.4}"),
}
}
#[derive(Deserialize)]
struct NominatimResponse {
display_name: Option<String>,
address: Option<NominatimAddress>,
}
#[derive(Deserialize)]
struct NominatimAddress {
city: Option<String>,
town: Option<String>,
village: Option<String>,
state: Option<String>,
}
#[derive(Clone)]
pub struct InsightGenerator {
ollama: OllamaClient,
/// Optional OpenRouter client, used when `backend=hybrid` is requested.
/// `None` when `OPENROUTER_API_KEY` is not configured.
openrouter: Option<Arc<OpenRouterClient>>,
sms_client: SmsApiClient,
/// Optional integration with Apollo's user-defined Places. When the
/// integration is disabled (`APOLLO_API_BASE_URL` unset), every
/// query returns empty and the legacy Nominatim path is used as-is.
apollo_client: ApolloClient,
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
// Google Takeout data sources
calendar_dao: Arc<Mutex<Box<dyn CalendarEventDao>>>,
location_dao: Arc<Mutex<Box<dyn LocationHistoryDao>>>,
search_dao: Arc<Mutex<Box<dyn SearchHistoryDao>>>,
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
// Face detections (used by the get_faces_in_photo agentic tool)
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
// Knowledge memory
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
libraries: Vec<Library>,
}
/// Per-call gating flags for `build_tool_definitions`. Tools whose backing
/// data is empty (or whose env-var guard is unset) are dropped from the
/// catalog so the LLM doesn't reach for a tool that always returns "No
/// results found." — that wastes iteration budget.
#[derive(Debug, Clone, Copy, Default)]
pub struct ToolGateOpts {
pub has_vision: bool,
pub apollo_enabled: bool,
pub daily_summaries_present: bool,
pub calendar_present: bool,
pub location_history_present: bool,
pub faces_present: bool,
}
impl InsightGenerator {
pub fn new(
ollama: OllamaClient,
openrouter: Option<Arc<OpenRouterClient>>,
sms_client: SmsApiClient,
apollo_client: ApolloClient,
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
calendar_dao: Arc<Mutex<Box<dyn CalendarEventDao>>>,
location_dao: Arc<Mutex<Box<dyn LocationHistoryDao>>>,
search_dao: Arc<Mutex<Box<dyn SearchHistoryDao>>>,
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
libraries: Vec<Library>,
) -> Self {
Self {
ollama,
openrouter,
sms_client,
apollo_client,
insight_dao,
exif_dao,
daily_summary_dao,
calendar_dao,
location_dao,
search_dao,
tag_dao,
face_dao,
knowledge_dao,
libraries,
}
}
/// Whether the optional Apollo Places integration is wired up. Drives
/// tool-definition gating (no point offering `get_personal_place_at`
/// when Apollo is unreachable) — exposed publicly so `insight_chat`
/// can call `build_tool_definitions` with the same flag.
pub fn apollo_enabled(&self) -> bool {
self.apollo_client.is_enabled()
}
/// Compute the per-call tool gate options by probing each backing
/// table for presence. `daily_summaries_present` uses a `LIMIT 1`
/// existence probe; `calendar_present` and `location_history_present`
/// use the existing `get_event_count` / `get_location_count`
/// methods (small enough that a full `COUNT(*)` is fine). Meant to
/// be called once per chat turn / generation. `has_vision` is
/// supplied by the caller because it depends on the model selected
/// for this turn, not on persistent state.
pub fn current_gate_opts(&self, has_vision: bool) -> ToolGateOpts {
let cx = opentelemetry::Context::new();
let calendar_present = {
let mut dao = self
.calendar_dao
.lock()
.expect("Unable to lock CalendarEventDao");
dao.get_event_count(&cx).map(|n| n > 0).unwrap_or(false)
};
let location_history_present = {
let mut dao = self
.location_dao
.lock()
.expect("Unable to lock LocationHistoryDao");
dao.get_location_count(&cx).map(|n| n > 0).unwrap_or(false)
};
let daily_summaries_present = {
let mut dao = self
.daily_summary_dao
.lock()
.expect("Unable to lock DailySummaryDao");
dao.has_any_summaries(&cx).unwrap_or(false)
};
let faces_present = {
let mut dao = self
.face_dao
.lock()
.expect("Unable to lock FaceDao");
dao.has_any_faces(&cx).unwrap_or(false)
};
ToolGateOpts {
has_vision,
apollo_enabled: self.apollo_enabled(),
daily_summaries_present,
calendar_present,
location_history_present,
faces_present,
}
}
/// Resolve `rel_path` against the configured libraries, returning the
/// first root under which the file exists. Insights may be generated
/// for any library — the generator itself doesn't know which — so we
/// probe each root rather than trust a single `base_path`.
pub(crate) fn resolve_full_path(&self, rel_path: &str) -> Option<std::path::PathBuf> {
use std::path::Path;
for lib in &self.libraries {
let candidate = Path::new(&lib.root_path).join(rel_path);
if candidate.exists() {
return Some(candidate);
}
}
None
}
/// 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
&& 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
pub(crate) fn load_image_as_base64(&self, file_path: &str) -> Result<String> {
use image::imageops::FilterType;
let full_path = self.resolve_full_path(file_path).ok_or_else(|| {
anyhow::anyhow!(
"File '{}' not found under any configured library",
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<Vec<String>> {
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<String> = 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<Vec<String>> {
let tracer = global_tracer();
let current_cx = opentelemetry::Context::current();
let mut span = tracer.start_with_context("ai.rag.search_daily_summaries", &current_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<Option<String>> {
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.with_timezone(&Local)
.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::<Vec<String>>(a).ok())
.map(|list| format!(" (with {})", list.join(", ")))
.unwrap_or_default();
format!("[{}] {}{}", date, e.summary, attendees)
})
.collect::<Vec<_>>()
.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<Option<String>> {
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<Option<String>> {
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::<Vec<_>>()
.join("\n");
Ok(Some(format!("Search history:\n{}", formatted)))
} else {
Ok(None)
}
}
/// Combine all context sources with equal weight
fn combine_contexts(
sms: Option<String>,
calendar: Option<String>,
location: Option<String>,
search: Option<String>,
tags: Option<String>,
) -> 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<String>,
custom_system_prompt: Option<String>,
num_ctx: Option<i32>,
temperature: Option<f32>,
top_p: Option<f32>,
top_k: Option<i32>,
min_p: Option<f32>,
) -> Result<()> {
let tracer = global_tracer();
let current_cx = opentelemetry::Context::current();
let mut span = tracer.start_with_context("ai.insight.generate", &current_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));
}
// Apply sampling parameters if any were provided
if temperature.is_some() || top_p.is_some() || top_k.is_some() || min_p.is_some() {
log::info!(
"Using sampling params — temperature: {:?}, top_p: {:?}, top_k: {:?}, min_p: {:?}",
temperature,
top_p,
top_k,
min_p
);
if let Some(t) = temperature {
span.set_attribute(KeyValue::new("temperature", t as f64));
}
if let Some(p) = top_p {
span.set_attribute(KeyValue::new("top_p", p as f64));
}
if let Some(k) = top_k {
span.set_attribute(KeyValue::new("top_k", k as i64));
}
if let Some(m) = min_p {
span.set_attribute(KeyValue::new("min_p", m as f64));
}
ollama_client.set_sampling_params(temperature, top_p, top_k, min_p);
}
// 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(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.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<String> = {
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).
// Personal Apollo Place wins over Nominatim when both apply —
// "Home (My house in Cambridge) — near Cambridge, MA" is more
// grounding for the LLM than the city name alone.
let location = match exif {
Some(ref exif) => {
if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) {
let lat = lat as f64;
let lon = lon as f64;
let nominatim = self.reverse_geocode(lat, lon).await;
let apollo_primary = self
.apollo_client
.places_containing(lat, lon)
.await
.into_iter()
.next();
let combined = compose_location_string(apollo_primary, nominatim, lat, lon);
insight_cx
.span()
.set_attribute(KeyValue::new("location", combined.clone()));
Some(combined)
} 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<String> = 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<String> = {
let mut parts: Vec<String> = 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<String> = 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, 4)
.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<String> = 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, 4)
.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. content_hash is None here — store_insight
// looks it up from image_exif before persisting; reconciliation
// backfills if the hash isn't known yet.
let insight = InsertPhotoInsight {
library_id: crate::libraries::PRIMARY_LIBRARY_ID,
file_path: file_path.to_string(),
title,
summary,
generated_at: Utc::now().timestamp(),
model_version: ollama_client.primary_model.clone(),
is_current: true,
training_messages: None,
backend: "local".to_string(),
fewshot_source_ids: None,
content_hash: None,
};
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<String> {
if messages.is_empty() {
return Vec::new();
}
// Format a sample of messages for topic extraction
let sample_size = messages.len().min(20);
let user_name = user_display_name();
let sample_text: Vec<String> = messages
.iter()
.take(sample_size)
.map(|m| {
let sender: &str = if m.is_sent { &user_name } else { &m.contact };
format!("{}: {}", sender, 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<String> = 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<String> {
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<String> {
if messages.is_empty() {
return None;
}
// Format messages
let user_name = user_display_name();
let formatted: Vec<String> = messages
.iter()
.map(|m| {
let sender: &str = if m.is_sent { &user_name } else { &m.contact };
let timestamp = chrono::DateTime::from_timestamp(m.timestamp, 0)
.map(|dt| {
dt.with_timezone(&Local)
.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.
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
pub(crate) async fn execute_tool(
&self,
tool_name: &str,
arguments: &serde_json::Value,
ollama: &OllamaClient,
image_base64: &Option<String>,
file_path: &str,
cx: &opentelemetry::Context,
) -> String {
let result = match tool_name {
"search_rag" => self.tool_search_rag(arguments, ollama, cx).await,
"search_messages" => self.tool_search_messages(arguments).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,
"get_faces_in_photo" => self.tool_get_faces_in_photo(arguments, cx).await,
"describe_photo" => self.tool_describe_photo(ollama, image_base64).await,
"reverse_geocode" => self.tool_reverse_geocode(arguments).await,
"get_personal_place_at" => self.tool_get_personal_place_at(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,
"get_current_datetime" => Self::tool_get_current_datetime(),
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,
ollama: &OllamaClient,
_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());
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(10)
.clamp(1, 25) as usize;
log::info!(
"tool_search_rag: query='{}', date={}, contact={:?}, limit={}",
query,
date,
contact,
limit
);
// Pull a wider candidate pool than the final limit so the LLM
// reranker has room to promote less-obvious hits. Candidates_factor
// is capped so a big `limit` doesn't blow past what the reranker
// can sensibly judge in one prompt.
let rerank_enabled = std::env::var("SEARCH_RAG_RERANK")
.ok()
.map(|v| v.to_lowercase() != "off" && v != "0")
.unwrap_or(true);
let candidate_limit = if rerank_enabled {
(limit * 3).min(40)
} else {
limit
};
let results = match self
.find_relevant_messages_rag(
date,
None,
contact.as_deref(),
None,
candidate_limit,
Some(&query),
)
.await
{
Ok(results) if !results.is_empty() => results,
Ok(_) => return "No relevant messages found.".to_string(),
Err(e) => return format!("Error searching RAG: {}", e),
};
let final_results = if rerank_enabled && results.len() > limit {
match self.rerank_with_llm(&query, &results, limit, ollama).await {
Ok(reordered) => reordered,
Err(e) => {
log::warn!("rerank failed, using vector order: {}", e);
results.into_iter().take(limit).collect()
}
}
} else {
results.into_iter().take(limit).collect::<Vec<_>>()
};
final_results.join("\n\n")
}
/// LLM-based reranker: ask the local model to pick the top-`limit`
/// passages from `candidates` that are most relevant to `query`.
/// Returns the reordered subset.
///
/// Cheap-ish because the reranker prompt and output live outside the
/// agent's visible context — only the final selection lands in the
/// tool_result. On parse failure we fall back to the input order.
async fn rerank_with_llm(
&self,
query: &str,
candidates: &[String],
limit: usize,
ollama: &OllamaClient,
) -> Result<Vec<String>> {
let query_preview: String = query.chars().take(60).collect();
log::info!(
"rerank: {} candidates -> top {} (query=\"{}\")",
candidates.len(),
limit,
query_preview
);
// Build numbered list (1-based for readability). Cap each passage
// at ~1000 chars so very long summaries don't eat the prompt.
let numbered: String = candidates
.iter()
.enumerate()
.map(|(i, c)| {
let trimmed = if c.len() > 1000 {
format!("{}", &c[..1000])
} else {
c.clone()
};
format!("[{}] {}", i + 1, trimmed)
})
.collect::<Vec<_>>()
.join("\n\n");
let prompt = format!(
"You are ranking search results. From the numbered passages below, \
select the {} most relevant to the query. Respond with ONLY a \
comma-separated list of passage numbers in order from most to \
least relevant. No explanation, no other text.\n\n\
Query: {}\n\n\
Passages:\n{}\n\n\
Top {} passage numbers:",
limit, query, numbered, limit
);
let started = std::time::Instant::now();
let response = ollama
.generate_no_think(
&prompt,
Some(
"You are a terse relevance ranker. You output only numbers separated by commas.",
),
)
.await?;
log::info!(
"rerank: finished in {} ms (prompt={} chars)",
started.elapsed().as_millis(),
prompt.len()
);
// Extract indices from the response. Accept "3, 1, 7" and also
// tolerate "[3, 1, 7]" or "3,1,7,..." with trailing junk.
let picks: Vec<usize> = response
.split(|c: char| !c.is_ascii_digit())
.filter_map(|s| s.parse::<usize>().ok())
.filter(|&n| n >= 1 && n <= candidates.len())
.collect();
if picks.is_empty() {
return Err(anyhow::anyhow!(
"reranker returned no usable indices (raw: {})",
response.chars().take(120).collect::<String>()
));
}
let mut seen = std::collections::HashSet::new();
let mut reordered: Vec<String> = Vec::with_capacity(limit);
let mut final_indices: Vec<usize> = Vec::with_capacity(limit);
for n in picks {
if seen.insert(n) {
reordered.push(candidates[n - 1].clone());
final_indices.push(n);
if reordered.len() >= limit {
break;
}
}
}
// Top-up from original order if the reranker returned fewer than
// `limit` distinct entries.
if reordered.len() < limit {
for (i, c) in candidates.iter().enumerate() {
if !seen.contains(&(i + 1)) {
reordered.push(c.clone());
final_indices.push(i + 1);
if reordered.len() >= limit {
break;
}
}
}
}
// Debug snapshot: show what the reranker changed. Position p holds
// the 1-based index of the candidate that now sits at position p.
// A value that equals its position means "no change at that slot".
let swapped = final_indices
.iter()
.enumerate()
.filter(|(pos, idx)| **idx != pos + 1)
.count();
log::info!(
"rerank: final indices (1-based): {:?} — {} of top {} swapped from vector order",
final_indices,
swapped,
final_indices.len()
);
let show = final_indices.len().min(5);
log::debug!("rerank: vector-order top {}:", show);
for (i, c) in candidates.iter().enumerate().take(show) {
let preview: String = c.chars().take(100).collect();
log::debug!("rerank: [{}] {}", i + 1, preview);
}
log::debug!("rerank: reranked top {}:", show);
for (pos, idx) in final_indices.iter().enumerate().take(show) {
let preview: String = candidates[*idx - 1].chars().take(100).collect();
log::debug!("rerank: [{}] (orig #{}) {}", pos + 1, idx, preview);
}
Ok(reordered)
}
/// Tool: search_messages — keyword / semantic / hybrid search over all
/// SMS message bodies via the Django FTS5 + embeddings pipeline. Now
/// supports optional `contact_id`, `start_ts`, `end_ts` filters.
async fn tool_search_messages(&self, args: &serde_json::Value) -> String {
let query = match args.get("query").and_then(|v| v.as_str()) {
Some(q) if !q.trim().is_empty() => q.trim(),
_ => {
let has_date = args.get("date").is_some()
|| args.get("start_ts").is_some()
|| args.get("end_ts").is_some();
let has_contact = args.get("contact").is_some() || args.get("contact_id").is_some();
if has_date || has_contact {
return "Error: search_messages needs a 'query' (keywords/phrase). \
To fetch messages around a date or from a contact without keywords, \
call get_sms_messages with { date, contact? } instead."
.to_string();
}
return "Error: missing required parameter 'query'".to_string();
}
};
if query.len() < 3 {
return "Error: query must be at least 3 characters".to_string();
}
let mode = args
.get("mode")
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "hybrid".to_string());
if !matches!(mode.as_str(), "fts5" | "semantic" | "hybrid") {
return format!(
"Error: unknown mode '{}'; expected one of: fts5, semantic, hybrid",
mode
);
}
let user_limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(20)
.clamp(1, 50) as usize;
let contact_id = args.get("contact_id").and_then(|v| v.as_i64());
let start_ts = args.get("start_ts").and_then(|v| v.as_i64());
let end_ts = args.get("end_ts").and_then(|v| v.as_i64());
let has_date_filter = start_ts.is_some() || end_ts.is_some();
// When a date filter is supplied, fetch a larger pool from SMS-API
// so in-window matches that ranked lower than out-of-window ones
// aren't lost.
let fetch_limit = if has_date_filter { 100 } else { user_limit };
log::info!(
"tool_search_messages: query='{}', mode={}, contact_id={:?}, range=[{:?}, {:?}], user_limit={}, fetch_limit={}",
query,
mode,
contact_id,
start_ts,
end_ts,
user_limit,
fetch_limit
);
let hits = match self
.sms_client
.search_messages_with_contact(query, &mode, fetch_limit, contact_id)
.await
{
Ok(h) => h,
Err(e) => return format!("Error searching messages: {}", e),
};
// Date-range post-filter on the client side. SMS-API's /search/
// doesn't accept date params; mirroring Apollo's pattern here.
let filtered: Vec<_> = hits
.into_iter()
.filter(|h| {
if let Some(s) = start_ts
&& h.date < s
{
return false;
}
if let Some(e) = end_ts
&& h.date > e
{
return false;
}
true
})
.take(user_limit)
.collect();
if filtered.is_empty() {
return "No messages matched.".to_string();
}
let user_name = user_display_name();
let mut out = String::new();
out.push_str(&format!(
"Found {} messages (mode: {}{}):\n\n",
filtered.len(),
mode,
if has_date_filter {
", date-filtered"
} else {
""
}
));
for h in filtered {
let date = chrono::DateTime::from_timestamp(h.date, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| h.date.to_string());
let direction: &str = if h.type_ == 2 {
&user_name
} else {
&h.contact_name
};
let score = h
.similarity_score
.map(|s| format!(" [score {:.2}]", s))
.unwrap_or_default();
out.push_str(&format!(
"[{}]{} {}{}\n\n",
date, score, direction, h.body
));
}
out
}
/// 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)
.clamp(1, 30);
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(60)
.clamp(1, 150) as usize;
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={}, limit={}",
date,
contact,
days_radius,
limit
);
match self
.sms_client
.fetch_messages_for_contact(contact.as_deref(), timestamp, days_radius)
.await
{
Ok(messages) if !messages.is_empty() => {
let user_name = user_display_name();
let formatted: Vec<String> = messages
.iter()
.take(limit)
.map(|m| {
let sender: &str = if m.is_sent { &user_name } else { &m.contact };
let ts = DateTime::from_timestamp(m.timestamp, 0)
.map(|dt| {
dt.with_timezone(&Local)
.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 limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(20)
.clamp(1, 50) as usize;
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={}, limit={}",
date,
days_radius,
limit
);
let events = {
let mut dao = self
.calendar_dao
.lock()
.expect("Unable to lock CalendarEventDao");
dao.find_relevant_events_hybrid(cx, timestamp, days_radius, None, limit)
.ok()
};
match events {
Some(evts) if !evts.is_empty() => {
let formatted: Vec<String> = evts
.iter()
.map(|e| {
let dt = DateTime::from_timestamp(e.start_time, 0)
.map(|dt| {
dt.with_timezone(&Local)
.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::<Vec<String>>(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<String> = locs
.iter()
.take(20)
.map(|loc| {
let dt = DateTime::from_timestamp(loc.timestamp, 0)
.map(|dt| {
dt.with_timezone(&Local)
.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<String> = t.into_iter().map(|tag| tag.name).collect();
names.join(", ")
}
Some(_) => "No tags found.".to_string(),
None => "No tags found.".to_string(),
}
}
/// Tool: get_faces_in_photo — list face detections + person names for
/// the given file path. Resolves rel_path → content_hash via FaceDao,
/// then queries face_detections joined with persons (status='detected'
/// only). Returns a compact bullet list keyed for human-LLM readability.
async fn tool_get_faces_in_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) if !p.trim().is_empty() => p.trim().to_string(),
_ => return "Error: missing required parameter 'file_path'".to_string(),
};
log::info!("tool_get_faces_in_photo: file_path='{}'", file_path);
// Resolve content_hash from any library that has this rel_path.
// Hold the FaceDao lock once across all libraries — resolve_content_hash
// is synchronous and there's no await in the loop body.
let content_hash: Option<String> = {
let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao");
self.libraries.iter().find_map(|lib| {
dao.resolve_content_hash(cx, lib.id, &file_path)
.ok()
.flatten()
})
};
let Some(content_hash) = content_hash else {
return "No content_hash found for that file path (the photo may not be indexed yet, \
or the path doesn't match any library)."
.to_string();
};
let faces = {
let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao");
match dao.list_for_content_hash(cx, &content_hash) {
Ok(rows) => rows,
Err(e) => return format!("Error querying faces: {}", e),
}
};
if faces.is_empty() {
return "No faces detected in this photo.".to_string();
}
// Render: bound faces grouped by person first, then unbound. The
// model uses the bound names directly; the unbound count + bbox
// helps it count people without naming them.
let bound: Vec<&_> = faces.iter().filter(|f| f.person_name.is_some()).collect();
let unbound: Vec<&_> = faces.iter().filter(|f| f.person_name.is_none()).collect();
let mut out = format!("Found {} face(s) in this photo:\n", faces.len());
for f in &bound {
// Invariant: `bound` is filtered on `person_name.is_some()` above.
let name = f.person_name.as_deref().expect("bound face must have a name");
out.push_str(&format!(
"- {} (confidence {:.2}, bbox x={:.2} y={:.2} w={:.2} h={:.2}, source: {})\n",
name,
f.confidence,
f.bbox_x,
f.bbox_y,
f.bbox_w,
f.bbox_h,
f.source,
));
}
for f in &unbound {
out.push_str(&format!(
"- (unidentified) confidence {:.2}, bbox x={:.2} y={:.2} w={:.2} h={:.2}, source: {}\n",
f.confidence,
f.bbox_x,
f.bbox_y,
f.bbox_w,
f.bbox_h,
f.source,
));
}
out
}
/// Tool: describe_photo — generate a visual description of the photo
async fn tool_describe_photo(
&self,
ollama: &OllamaClient,
image_base64: &Option<String>,
) -> 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: get_personal_place_at — look up the user's named places (Home,
/// Work, Cabin) at a coordinate. Server-side filter; results sorted
/// smallest-radius first.
async fn tool_get_personal_place_at(&self, args: &serde_json::Value) -> String {
if !self.apollo_client.is_enabled() {
return "Personal place lookup is disabled.".to_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_get_personal_place_at: lat={}, lon={}", lat, lon);
let places = self.apollo_client.places_containing(lat, lon).await;
if places.is_empty() {
return "No personal place contains this coordinate.".to_string();
}
places
.iter()
.map(|p| {
let category = p
.category
.as_deref()
.map(|c| format!(" [{c}]"))
.unwrap_or_default();
let desc = if p.description.is_empty() {
"(no description)".to_string()
} else {
p.description.clone()
};
format!(
"- {}{}: {} (radius {} m)",
p.name, category, desc, p.radius_m
)
})
.collect::<Vec<_>>()
.join("\n")
}
/// 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(20)
.clamp(1, 50);
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<String> = 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<i32> = 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)
&& 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
);
// Pre-flight similarity check — surface near-duplicates to the model
// before it commits to a new entity. Uses the first name token as the
// search term so "Sarah" matches when storing "Sarah Johnson" and vice
// versa. Exact-name matches are excluded (upsert_entity deduplicates
// those already). Results are appended to the tool response so the
// model can choose to use an existing entity's ID instead.
let similar_entities: Vec<String> = {
use crate::database::EntityFilter;
use crate::database::knowledge_dao::normalize_entity_type;
let normalised_type = normalize_entity_type(&entity_type);
let first_token = name.split_whitespace().next().unwrap_or(&name).to_string();
let filter = EntityFilter {
entity_type: None, // search all types, filter client-side to avoid case issues
status: Some("active".to_string()),
search: Some(first_token),
limit: 10,
offset: 0,
};
let mut kdao = self
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.list_entities(cx, filter)
.unwrap_or_default()
.0
.into_iter()
.filter(|e| {
normalize_entity_type(&e.entity_type) == normalised_type
&& e.name.to_lowercase() != name.to_lowercase()
})
.map(|e| format!(" ID:{} | {} | {}", e.id, e.name, e.description))
.collect()
};
// Generate embedding for name + description (best-effort)
let embed_text = format!("{} {}", name, description);
let embedding: Option<Vec<u8>> = match ollama.generate_embedding(&embed_text).await {
Ok(vec) => {
let bytes: Vec<u8> = 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) => {
let mut response = format!(
"Entity stored: ID:{} | {} | {} | confidence:{:.2}",
entity.id, entity.entity_type, entity.name, entity.confidence
);
if !similar_entities.is_empty() {
response.push_str(
"\nSimilar existing entities found — verify this is not a duplicate:\n",
);
response.push_str(&similar_entities.join("\n"));
response.push_str(
"\nIf one of these is the same entity, use their existing ID in store_fact instead of the newly created one.",
);
}
response
}
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,
library_id: crate::libraries::PRIMARY_LIBRARY_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
)
}
/// Tool: get_current_datetime — returns the current local date and time
fn tool_get_current_datetime() -> String {
let now = Local::now();
format!(
"Current date/time: {} ({})",
now.format("%Y-%m-%d %H:%M:%S %Z"),
now.format("%A")
)
}
// ── Agentic insight generation ──────────────────────────────────────
/// Build the list of tool definitions for the agentic loop, gated by
/// `opts`. Always-on tools: `search_messages`, `get_sms_messages`,
/// `get_file_tags`, `reverse_geocode`, `get_current_datetime`, the
/// four knowledge-memory tools. Conditional: `describe_photo` (vision
/// model), `get_personal_place_at` (Apollo configured), `search_rag`
/// (daily_summaries populated), `get_calendar_events` (calendar
/// populated), `get_location_history` (location history populated).
pub(crate) fn build_tool_definitions(opts: ToolGateOpts) -> Vec<Tool> {
let mut tools: Vec<Tool> = Vec::new();
if opts.daily_summaries_present {
tools.push(Tool::function(
"search_rag",
"Date-anchored semantic search over the user's daily-summary corpus. \
Returns up to `limit` summaries most semantically similar to `query`, \
weighted toward summaries near `date`. For raw message text across all \
time, prefer `search_messages`. \
Examples: `{query: \"family dinner\", date: \"2018-12-24\"}` — what \
daily summaries near Christmas Eve mention family / dinner / gathering. \
`{query: \"work travel\", date: \"2019-06-15\", contact: \"Alice\"}` — \
narrowed to summaries that involve Alice.",
serde_json::json!({
"type": "object",
"required": ["query", "date"],
"properties": {
"query": { "type": "string", "description": "Free-text query, semantically matched." },
"date": { "type": "string", "description": "Anchor date, YYYY-MM-DD. Summaries near this date rank higher." },
"contact": { "type": "string", "description": "Optional contact name to bias toward conversations with that person." },
"limit": { "type": "integer", "description": "Max summaries to return (default 10, max 25)." }
}
}),
));
}
tools.push(Tool::function(
"search_messages",
"Search SMS/MMS message bodies. Modes: `fts5` (keyword + phrase + prefix + AND/OR/NOT + NEAR proximity), \
`semantic` (embedding similarity, requires generated embeddings), `hybrid` (RRF merge, recommended; \
degrades to fts5 when embeddings absent). Optional `start_ts` / `end_ts` (real-UTC unix seconds) and \
`contact_id` filters. For pure date / contact browsing without keywords, prefer `get_sms_messages`. \
Examples: `{query: \"trader joe's\"}` — phrase across all time. \
`{query: \"dinner\", contact_id: 42, start_ts: 1700000000, end_ts: 1700604800}` — keyword within a contact and a week. \
`{query: \"NEAR(meeting work, 5)\"}` — proximity search.",
serde_json::json!({
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string", "description": "Search query. Min 3 chars. fts5 supports phrase (\"\"), prefix (*), AND/OR/NOT, and NEAR proximity." },
"mode": { "type": "string", "enum": ["fts5", "semantic", "hybrid"], "description": "Search strategy. Default: hybrid." },
"limit": { "type": "integer", "description": "Max results (default 20, max 50)." },
"contact_id": { "type": "integer", "description": "Optional numeric contact id to scope the search." },
"start_ts": { "type": "integer", "description": "Optional inclusive lower bound, real-UTC unix seconds." },
"end_ts": { "type": "integer", "description": "Optional inclusive upper bound, real-UTC unix seconds." }
}
}),
));
tools.push(Tool::function(
"get_sms_messages",
"Fetch SMS/MMS messages near a date (and optionally from a specific contact). Use when you know the date \
or want context around a photo's timestamp. For keyword search without a date, use `search_messages`. \
Returns up to `limit` messages within `±days_radius` of `date`, sorted by proximity. \
Example: `{date: \"2018-08-12\", contact: \"Mom\", days_radius: 2}` — messages from Mom within ±2 days of Aug 12 2018.",
serde_json::json!({
"type": "object",
"required": ["date"],
"properties": {
"date": { "type": "string", "description": "Center date, YYYY-MM-DD." },
"contact": { "type": "string", "description": "Optional contact name (case-insensitive). Falls back to all contacts on no match." },
"days_radius": { "type": "integer", "description": "Days before and after to include (default 4)." },
"limit": { "type": "integer", "description": "Max messages to return (default 60, max 150)." }
}
}),
));
if opts.calendar_present {
tools.push(Tool::function(
"get_calendar_events",
"Fetch calendar events near a date — meetings, scheduled activities, all-day events. \
Returns events within `±days_radius` of `date`. \
Example: `{date: \"2019-03-22\", days_radius: 3}` — events within a week of March 22 2019.",
serde_json::json!({
"type": "object",
"required": ["date"],
"properties": {
"date": { "type": "string", "description": "Center date, YYYY-MM-DD." },
"days_radius": { "type": "integer", "description": "Days before and after to include (default 7)." },
"limit": { "type": "integer", "description": "Max events to return (default 20, max 50)." }
}
}),
));
}
if opts.location_history_present {
tools.push(Tool::function(
"get_location_history",
"Fetch raw location records (lat/lon/timestamp/activity) near a date. The default 14-day radius is \
wide because location density varies; tighten to ±1 day for a single-trip query. For a coordinate's \
named place, use `reverse_geocode` (or `get_personal_place_at` when Apollo is enabled).",
serde_json::json!({
"type": "object",
"required": ["date"],
"properties": {
"date": { "type": "string", "description": "Center date, YYYY-MM-DD." },
"days_radius": { "type": "integer", "description": "Days before and after to include (default 14)." }
}
}),
));
}
tools.push(Tool::function(
"get_file_tags",
"Get user-applied tags for a specific photo file path. Tags are user-curated, not auto-detected.",
serde_json::json!({
"type": "object",
"required": ["file_path"],
"properties": {
"file_path": { "type": "string", "description": "File path of the photo." }
}
}),
));
tools.push(Tool::function(
"reverse_geocode",
"Convert GPS lat/lon to a human-readable place name (city, state). Use for any coordinate the LLM has \
obtained from EXIF or `get_location_history`. When Apollo is configured, prefer `get_personal_place_at` \
— it returns the user's named places (Home / Work / etc.) which are more specific.",
serde_json::json!({
"type": "object",
"required": ["latitude", "longitude"],
"properties": {
"latitude": { "type": "number", "description": "Decimal degrees." },
"longitude": { "type": "number", "description": "Decimal degrees." }
}
}),
));
if opts.apollo_enabled {
tools.push(Tool::function(
"get_personal_place_at",
"Return any of the user's named Places (e.g. Home, Work, Cabin) whose radius contains (latitude, longitude). \
Smallest radius first — most specific match wins. More specific than `reverse_geocode`; prefer this when \
both apply. Returns place name, category, free-text description, and radius.",
serde_json::json!({
"type": "object",
"required": ["latitude", "longitude"],
"properties": {
"latitude": { "type": "number", "description": "Decimal degrees." },
"longitude": { "type": "number", "description": "Decimal degrees." }
}
}),
));
}
if opts.faces_present {
tools.push(Tool::function(
"get_faces_in_photo",
"Return the faces detected in this photo with their bounding boxes and assigned person names \
(when bound). Each face carries `person_name` (string or null), `bbox` ({x, y, w, h} normalized 01), \
`confidence` (01), and `source` ('auto' from detector or 'manual' from a user-drawn bbox). \
More authoritative than `get_file_tags` for counting people in a photo or naming who is present, \
since it returns detected-but-unbound faces too. \
Example: `{file_path: \"2019/06/IMG_4242.jpg\"}`.",
serde_json::json!({
"type": "object",
"required": ["file_path"],
"properties": {
"file_path": { "type": "string", "description": "File path of the photo." }
}
}),
));
}
tools.push(Tool::function(
"recall_entities",
"Search the persistent knowledge memory for previously learned people, places, events, or things. \
Use BEFORE writing the insight to ground the model on what's already known.",
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Name or partial name (case-insensitive substring match)." },
"entity_type": { "type": "string", "enum": ["person", "place", "event", "thing"] },
"limit": { "type": "integer", "description": "Max results (default 20, max 50)." }
}
}),
));
tools.push(Tool::function(
"recall_facts_for_photo",
"Retrieve all stored facts linked to a specific photo. Call at the start of insight generation to load \
prior knowledge about subjects in this photo without scanning the whole knowledge base.",
serde_json::json!({
"type": "object",
"required": ["file_path"],
"properties": {
"file_path": { "type": "string", "description": "File path of the photo." }
}
}),
));
tools.push(Tool::function(
"store_entity",
"Upsert a person / place / event / thing into the knowledge memory. Returns the entity id (use it as \
`subject_entity_id` or `object_entity_id` in `store_fact`). Idempotent on canonical name.",
serde_json::json!({
"type": "object",
"required": ["name", "entity_type"],
"properties": {
"name": { "type": "string", "description": "Canonical name (e.g. \"John Smith\", \"Banff National Park\")." },
"entity_type": { "type": "string", "enum": ["person", "place", "event", "thing"] },
"description": { "type": "string", "description": "Brief description." }
}
}),
));
tools.push(Tool::function(
"store_fact",
"Record a fact about an entity in the knowledge memory. Always linked to the current photo. \
You must provide EITHER `object_entity_id` (when the object is itself a stored entity — e.g. \
person A is_friend_of person B) OR `object_value` (free-text attribute — e.g. role=\"software engineer\"). \
`object_entity_id` takes precedence when both are present. \
Examples: \
`{subject_entity_id: 7, predicate: \"is_friend_of\", object_entity_id: 12}` — links two known entities. \
`{subject_entity_id: 7, predicate: \"lives_in\", object_value: \"Portland, Oregon\"}` — free-text attribute.",
serde_json::json!({
"type": "object",
"required": ["subject_entity_id", "predicate"],
"properties": {
"subject_entity_id": { "type": "integer", "description": "Entity id this fact is about." },
"predicate": { "type": "string", "description": "Relationship or attribute (e.g. is_friend_of, located_in, attended_event)." },
"object_entity_id": { "type": "integer", "description": "Use when the object is itself a stored entity. Takes precedence over object_value." },
"object_value": { "type": "string", "description": "Use for free-text attributes where the object is not a stored entity." },
"photo_role": { "type": "string", "description": "How this entity appears in the photo (default \"subject\")." }
}
}),
));
tools.push(Tool::function(
"get_current_datetime",
"Get the current date and time. Useful when reasoning about how long ago a photo was taken.",
serde_json::json!({
"type": "object",
"properties": {}
}),
));
if opts.has_vision {
tools.push(Tool::function(
"describe_photo",
"Generate a visual description of the current photo — people, location, objects, activity visible \
in the image. Only available with vision-capable models.",
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.
///
/// `backend` selects the chat provider: `"local"` (default) routes the
/// agentic loop through the configured Ollama server with the image
/// attached to the first user message; `"hybrid"` asks the local Ollama
/// vision model to describe the image once, inlines the description as
/// text, and runs the loop through OpenRouter (chat only — embeddings
/// and describe calls stay local in either mode).
#[allow(clippy::too_many_arguments)]
/// Render a set of prior-conversation transcripts into a compact
/// trajectory block for inclusion in the system prompt. Tool results
/// are summarised to one line each so the prompt stays small.
fn render_fewshot_examples(examples: &[Vec<ChatMessage>]) -> String {
if examples.is_empty() {
return String::new();
}
let mut out = String::from("## Examples of strong context-gathering\n\n");
out.push_str(
"The following are compressed trajectories from prior high-quality insights. \
They show the *pattern* of tool use, not answers to copy.\n\n",
);
for (i, msgs) in examples.iter().enumerate() {
out.push_str(&format!("### Example {}\n\n", i + 1));
out.push_str(&Self::render_single_trajectory(msgs));
out.push('\n');
}
out.push_str("---\n\n");
out
}
fn render_single_trajectory(msgs: &[ChatMessage]) -> String {
let mut out = String::new();
if let Some(first_user) = msgs.iter().find(|m| m.role == "user") {
let trimmed = first_user
.content
.lines()
.filter(|l| !l.trim().is_empty())
.take(8)
.collect::<Vec<_>>()
.join("\n");
out.push_str(&format!("Input:\n{}\n\n", trimmed));
}
out.push_str("Trajectory:\n");
let mut step = 1;
let mut final_content: Option<String> = None;
for (i, m) in msgs.iter().enumerate() {
if m.role != "assistant" {
continue;
}
if let Some(ref calls) = m.tool_calls {
for call in calls {
let args_brief = Self::brief_json_args(&call.function.arguments);
let result_summary = msgs
.get(i + 1)
.filter(|r| r.role == "tool")
.map(|r| Self::summarize_tool_result(&call.function.name, &r.content))
.unwrap_or_else(|| "(no result)".to_string());
out.push_str(&format!(
"{}. {}({}) -> {}\n",
step, call.function.name, args_brief, result_summary
));
step += 1;
}
} else if !m.content.is_empty() {
final_content = Some(m.content.clone());
}
}
if let Some(content) = final_content {
let short: String = content.chars().take(240).collect();
out.push_str(&format!("\nFinal insight: {}...\n", short));
}
out
}
fn brief_json_args(v: &serde_json::Value) -> String {
let Some(obj) = v.as_object() else {
return v.to_string();
};
obj.iter()
.map(|(k, v)| {
let rendered = match v {
serde_json::Value::String(s) if s.len() > 40 => {
format!("\"{}...\"", &s[..40])
}
_ => v.to_string(),
};
format!("{}={}", k, rendered)
})
.collect::<Vec<_>>()
.join(", ")
}
/// Collapse a raw tool-result string (the text the model saw) into a
/// short phrase suitable for a few-shot trajectory. Detects the
/// "Found N ...", "No ...", and "Error ..." idioms used by the tool
/// implementations in this file. Unknown shapes fall back to a char
/// count, which is deliberately visible so drift shows up in output.
fn summarize_tool_result(tool_name: &str, raw: &str) -> String {
if raw.starts_with("Error ") {
return "error".to_string();
}
if raw.starts_with("No ") || raw.starts_with("Could not ") {
return "empty (pivoted)".to_string();
}
if let Some(rest) = raw.strip_prefix("Found ")
&& let Some(n_str) = rest.split_whitespace().next()
&& let Ok(n) = n_str.parse::<usize>()
{
let kind = match tool_name {
"search_messages" | "get_sms_messages" => "messages",
"get_calendar_events" => "events",
"get_location_history" => "location records",
_ => "results",
};
return format!("{} {}", n, kind);
}
match tool_name {
"search_rag" => {
let n = raw.split("\n\n").filter(|s| !s.trim().is_empty()).count();
format!("{} rag hits", n)
}
"get_file_tags" => {
let n = raw.split(',').filter(|s| !s.trim().is_empty()).count();
format!("{} tags", n)
}
"describe_photo" => {
let short: String = raw.chars().take(80).collect();
format!("described: \"{}...\"", short)
}
"reverse_geocode" => {
let short: String = raw.chars().take(60).collect();
format!("place: {}", short)
}
"get_personal_place_at" => {
if raw.starts_with("No personal place") || raw.starts_with("Personal place lookup")
{
"no personal place".to_string()
} else {
let n = raw.lines().filter(|l| l.starts_with("- ")).count().max(1);
format!("{} personal place(s)", n)
}
}
"recall_entities" | "recall_facts_for_photo" => {
let n = raw.lines().skip(1).filter(|l| !l.trim().is_empty()).count();
let kind = if tool_name == "recall_entities" {
"entities"
} else {
"facts"
};
format!("{} {}", n, kind)
}
"store_entity" | "store_fact" => raw
.split_whitespace()
.find_map(|tok| tok.strip_prefix("ID:"))
.map(|id| format!("stored id={}", id.trim_end_matches(',')))
.unwrap_or_else(|| "stored".to_string()),
"get_current_datetime" => "time noted".to_string(),
_ => format!("{} chars", raw.len()),
}
}
/// Assemble the chat system prompt from two named blocks:
///
/// 1. **Identity / voice / format** — `custom_system_prompt` verbatim
/// when supplied, or a neutral default that doesn't fight a future
/// persona. The framework never asserts an identity that could
/// contradict the persona.
/// 2. **Procedural scaffolding** — tool-use guidance, iteration budget,
/// contact-filter rule. Identity-free; never asserts voice or shape.
///
/// `owner_id_note` and `fewshot_block` are pre-rendered strings (they
/// already encode their own headers / blank lines). Pass empty / None
/// to skip.
pub(crate) fn build_system_content(
custom_system_prompt: Option<&str>,
owner_id_note: Option<&str>,
fewshot_block: &str,
max_iterations: usize,
) -> String {
let identity = match custom_system_prompt {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => String::from(
"You are reconstructing a memory from a photo. Use the gathered \
context to write a thoughtful summary; you decide voice, length, and shape.",
),
};
let owner = owner_id_note.unwrap_or("");
let procedural = format!(
"Tool-use guidance:\n\
- You have a budget of {max_iterations} tool-calling iterations.\n\
- Call tools to gather context BEFORE writing your final answer; don't answer after one or two calls.\n\
- When calling get_sms_messages or search_rag, make at least one call WITHOUT a contact filter \
— surrounding events matter even when a contact is known.\n\
- Use recall_facts_for_photo + recall_entities to load any prior knowledge about subjects in the photo.\n\
- When you identify people / places / events / things, use store_entity + store_fact to grow the persistent memory.\n\
- A tool returning no results is informative; continue with the others.",
);
let mut out = identity;
if !owner.is_empty() {
out.push_str(owner);
}
out.push_str("\n\n");
if !fewshot_block.is_empty() {
out.push_str(fewshot_block);
}
out.push_str(&procedural);
out
}
pub async fn generate_agentic_insight_for_photo(
&self,
file_path: &str,
custom_model: Option<String>,
custom_system_prompt: Option<String>,
num_ctx: Option<i32>,
temperature: Option<f32>,
top_p: Option<f32>,
top_k: Option<i32>,
min_p: Option<f32>,
max_iterations: usize,
backend: Option<String>,
fewshot_examples: Vec<Vec<ChatMessage>>,
fewshot_source_ids: Vec<i32>,
) -> Result<(Option<i32>, Option<i32>)> {
let tracer = global_tracer();
let current_cx = opentelemetry::Context::current();
let mut span = tracer.start_with_context("ai.insight.generate_agentic", &current_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));
// 1a. Resolve backend label (defaults to "local").
let backend_label = backend
.as_deref()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "local".to_string());
if !matches!(backend_label.as_str(), "local" | "hybrid") {
return Err(anyhow::anyhow!(
"unknown backend '{}'; expected 'local' or 'hybrid'",
backend_label
));
}
span.set_attribute(KeyValue::new("backend", backend_label.clone()));
let is_hybrid = backend_label == "hybrid";
// 1b. Always build an Ollama client. In local mode it owns the chat
// loop; in hybrid mode it still handles describe_image + any
// tool-local calls (e.g. if a future tool needs embeddings).
// Sampling overrides only apply in local mode — in hybrid the
// user's params belong to the OpenRouter chat client.
let apply_sampling_to_ollama = !is_hybrid;
let mut ollama_client = if let Some(ref model) = custom_model
&& !is_hybrid
{
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 {
if !is_hybrid {
span.set_attribute(KeyValue::new("model", self.ollama.primary_model.clone()));
}
self.ollama.clone()
};
if apply_sampling_to_ollama {
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));
}
if temperature.is_some() || top_p.is_some() || top_k.is_some() || min_p.is_some() {
log::info!(
"Using sampling params — temperature: {:?}, top_p: {:?}, top_k: {:?}, min_p: {:?}",
temperature,
top_p,
top_k,
min_p
);
if let Some(t) = temperature {
span.set_attribute(KeyValue::new("temperature", t as f64));
}
if let Some(p) = top_p {
span.set_attribute(KeyValue::new("top_p", p as f64));
}
if let Some(k) = top_k {
span.set_attribute(KeyValue::new("top_k", k as i64));
}
if let Some(m) = min_p {
span.set_attribute(KeyValue::new("min_p", m as f64));
}
ollama_client.set_sampling_params(temperature, top_p, top_k, min_p);
}
}
// 1c. In hybrid mode, clone the configured OpenRouter client and
// apply per-request overrides.
let openrouter_client: Option<OpenRouterClient> = if is_hybrid {
let arc = self.openrouter.as_ref().ok_or_else(|| {
anyhow::anyhow!("hybrid backend unavailable: OPENROUTER_API_KEY not configured")
})?;
let mut c: OpenRouterClient = (**arc).clone();
if let Some(ref m) = custom_model {
c.primary_model = m.clone();
span.set_attribute(KeyValue::new("custom_model", m.clone()));
}
span.set_attribute(KeyValue::new("openrouter_model", c.primary_model.clone()));
if temperature.is_some() || top_p.is_some() || top_k.is_some() || min_p.is_some() {
if let Some(t) = temperature {
span.set_attribute(KeyValue::new("temperature", t as f64));
}
if let Some(p) = top_p {
span.set_attribute(KeyValue::new("top_p", p as f64));
}
if let Some(k) = top_k {
span.set_attribute(KeyValue::new("top_k", k as i64));
}
if let Some(m) = min_p {
span.set_attribute(KeyValue::new("min_p", m as f64));
}
c.set_sampling_params(temperature, top_p, top_k, min_p);
}
if let Some(ctx) = num_ctx {
span.set_attribute(KeyValue::new("num_ctx", ctx as i64));
c.set_num_ctx(Some(ctx));
}
Some(c)
} else {
None
};
let insight_cx = current_cx.with_span(span);
// 2. Verify chat model supports tool calling.
// - local: existing Ollama model availability + capability check.
// - hybrid: trust the operator's curated allowlist
// (OPENROUTER_ALLOWED_MODELS) — no live precheck. A bad model id
// surfaces as a chat-call error on the next step.
let has_vision = if is_hybrid {
// In hybrid mode the chat model never sees images directly — we
// describe-then-inject, so `has_vision` drives only whether we
// bother loading the image to describe it, which we always do.
true
} else {
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
);
}
}
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(_) => {
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
));
}
insight_cx
.span()
.set_attribute(KeyValue::new("model_has_vision", capabilities.has_vision));
insight_cx
.span()
.set_attribute(KeyValue::new("model_has_tool_calling", true));
capabilities.has_vision
};
// 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 = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for agentic insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.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<String> = {
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. Ensure the owner entity exists so the agent can reference it.
// Prior entity_photo_links for this file are intentionally preserved
// across regenerations — clearing them made `recall_facts_for_photo`
// always return empty and discarded hard-won knowledge. Re-linking
// the same entity is a no-op (INSERT OR IGNORE).
let owner_name = user_display_name();
let owner_entity_id: Option<i32> = {
let mut kdao = self
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
// Upsert the owner entity so the agent always has a stable entity ID to reference.
let owner = crate::database::models::InsertEntity {
name: owner_name.clone(),
entity_type: "person".to_string(),
description: format!(
"The owner of this photo collection. All memories are written from {}'s perspective.",
owner_name
),
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!("Owner entity '{}' ID: {}", owner_name, e.id);
Some(e.id)
}
Err(e) => {
log::warn!("Failed to upsert owner entity '{}': {:?}", owner_name, e);
None
}
}
};
// 7. Load image if vision capable.
// In hybrid mode we ALSO describe it locally now so the
// description can be inlined as text — the OpenRouter chat model
// never receives the base64 image directly.
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
};
let hybrid_visual_description: Option<String> = if is_hybrid {
match image_base64.as_deref() {
Some(b64) => match self.ollama.describe_image(b64).await {
Ok(desc) => {
log::info!(
"Hybrid: local vision describe succeeded ({} chars)",
desc.len()
);
Some(desc)
}
Err(e) => {
log::warn!(
"Hybrid: local vision describe failed, continuing without: {}",
e
);
None
}
},
None => None,
}
} else {
None
};
// 8. Build system message via the two-block helper. Custom prompt
// (when supplied) is the authoritative identity — the framework
// never appends a competing "you are a personal photo memory
// assistant" line. The procedural block stays identity-free.
let owner_id_note = owner_entity_id.map(|id| {
format!(
"\n\nYour identity in the knowledge store: {name} (entity ID: {id}). \
When storing facts where you ({name}) 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 \
\"{name}\" (or use store_fact with the other person as subject). When storing facts about \
{name} directly, use {id} as the subject_entity_id.",
name = owner_name,
id = id
)
});
let fewshot_block = Self::render_fewshot_examples(&fewshot_examples);
let system_content = Self::build_system_content(
custom_system_prompt.as_deref(),
owner_id_note.as_deref(),
&fewshot_block,
max_iterations,
);
// 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 visual_block = hybrid_visual_description
.as_deref()
.map(|d| format!("Visual description (from local vision model):\n{}\n\n", d))
.unwrap_or_default();
// Context-only payload — no output-shape prescription. The persona /
// custom_system_prompt owns voice, length, and structure. The "title
// and summary" claim that used to live here was unused (the title is
// regenerated post-hoc from the summary by generate_photo_title).
let user_content = format!(
"{visual_block}Photo file path: {file_path}\n\
Date taken: {date}\n\
{contact_info}\n\
{gps_info}\n\
{tags_info}\n\n\
Gather context with the available tools, then respond.",
date = date_taken.format("%B %d, %Y"),
);
// 10. Define tools. Gate flags computed from current data presence;
// hybrid mode omits describe_photo since the chat model receives
// the visual description inline (so we pass `false` for has_vision
// in hybrid mode regardless of the model's actual capability).
let gate_opts = self.current_gate_opts(has_vision && !is_hybrid);
let tools = Self::build_tool_definitions(gate_opts);
// 11. Build initial messages. In hybrid mode images are never
// attached to the wire message — the description is part of
// `user_content`.
let system_msg = ChatMessage::system(system_content);
let mut user_msg = ChatMessage::user(user_content);
if !is_hybrid && let Some(ref img) = image_base64 {
user_msg.images = Some(vec![img.clone()]);
}
let mut messages = vec![system_msg, user_msg];
// 12. Agentic loop — dispatch through the selected backend.
let chat_backend: &dyn LlmClient = if let Some(ref or_c) = openrouter_client {
or_c
} else {
&ollama_client
};
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<i32> = None;
let mut last_eval_count: Option<i32> = 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) = chat_backend
.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(format!(
"Based on the context gathered, please write the final photo insight: a title and a detailed personal summary. Write in first person as {}.",
user_display_name()
)));
let (final_response, prompt_tokens, eval_tokens) = chat_backend
.chat_with_tools(messages.clone(), vec![])
.await?;
last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens;
final_content = final_response.content.clone();
messages.push(final_response);
}
loop_cx
.span()
.set_attribute(KeyValue::new("iterations_used", iterations_used as i64));
loop_cx.span().set_status(Status::Ok);
// 13. Generate title via the same backend so voice stays consistent.
let title_prompt = format!(
"Create a short title (maximum 8 words) for the following journal entry:\n\n{}\n\nCapture the key moment or theme. Return ONLY the title, nothing else.",
final_content
);
let title_system = custom_system_prompt.as_deref().unwrap_or(
"You are my long term memory assistant. Use only the information provided. Do not invent details.",
);
let title_raw = chat_backend
.generate(&title_prompt, Some(title_system), None)
.await?;
let title = title_raw.trim().trim_matches('"').to_string();
log::info!("Agentic generated title: {}", title);
log::info!(
"Agentic generated summary ({} chars): {}",
final_content.len(),
&final_content[..final_content.len().min(200)]
);
// 14. Serialize the full message history for training data
let training_messages = match serde_json::to_string(&messages) {
Ok(json) => Some(json),
Err(e) => {
log::warn!("Failed to serialize training messages: {}", e);
None
}
};
// 15. Store insight (returns the persisted row including its new id)
let model_version = chat_backend.primary_model().to_string();
let fewshot_source_ids_json = if fewshot_source_ids.is_empty() {
None
} else {
Some(serde_json::to_string(&fewshot_source_ids).unwrap_or_else(|_| "[]".to_string()))
};
let insight = InsertPhotoInsight {
library_id: crate::libraries::PRIMARY_LIBRARY_ID,
file_path: file_path.to_string(),
title,
summary: final_content,
generated_at: Utc::now().timestamp(),
model_version,
is_current: true,
training_messages,
backend: backend_label.clone(),
fewshot_source_ids: fewshot_source_ids_json,
content_hash: None,
};
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?;
// 16. 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<String> {
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::*;
use crate::ai::ollama::{ToolCall, ToolCallFunction};
#[test]
fn build_tool_definitions_drops_gated_tools() {
let opts = ToolGateOpts {
has_vision: false,
apollo_enabled: false,
daily_summaries_present: false,
calendar_present: false,
location_history_present: false,
faces_present: false,
};
let tools = InsightGenerator::build_tool_definitions(opts);
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
// Always-on tools survive.
assert!(names.contains(&"search_messages"));
assert!(names.contains(&"get_sms_messages"));
assert!(names.contains(&"get_file_tags"));
assert!(names.contains(&"reverse_geocode"));
assert!(names.contains(&"get_current_datetime"));
assert!(names.contains(&"recall_entities"));
assert!(names.contains(&"recall_facts_for_photo"));
assert!(names.contains(&"store_entity"));
assert!(names.contains(&"store_fact"));
// Gated tools are absent.
assert!(!names.contains(&"describe_photo"));
assert!(!names.contains(&"get_personal_place_at"));
assert!(!names.contains(&"search_rag"));
assert!(!names.contains(&"get_calendar_events"));
assert!(!names.contains(&"get_location_history"));
assert!(!names.contains(&"get_faces_in_photo"));
}
#[test]
fn build_tool_definitions_includes_gated_tools_when_present() {
let opts = ToolGateOpts {
has_vision: true,
apollo_enabled: true,
daily_summaries_present: true,
calendar_present: true,
location_history_present: true,
faces_present: true,
};
let tools = InsightGenerator::build_tool_definitions(opts);
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
assert!(names.contains(&"describe_photo"));
assert!(names.contains(&"get_personal_place_at"));
assert!(names.contains(&"search_rag"));
assert!(names.contains(&"get_calendar_events"));
assert!(names.contains(&"get_location_history"));
assert!(names.contains(&"get_faces_in_photo"));
}
fn place(name: &str, description: &str) -> ApolloPlace {
ApolloPlace {
id: 1,
name: name.to_string(),
description: description.to_string(),
lat: 0.0,
lon: 0.0,
radius_m: 200,
category: None,
}
}
#[test]
fn compose_location_with_apollo_and_nominatim_and_description() {
let s = compose_location_string(
Some(place("Home", "House in Cambridge")),
Some("Cambridge, MA".to_string()),
42.36,
-71.06,
);
assert_eq!(s, "Home (House in Cambridge) — near Cambridge, MA");
}
#[test]
fn compose_location_apollo_without_description_drops_parenthetical() {
let s = compose_location_string(
Some(place("Home", "")),
Some("Cambridge, MA".to_string()),
42.36,
-71.06,
);
assert_eq!(s, "Home — near Cambridge, MA");
}
#[test]
fn compose_location_nominatim_only() {
let s = compose_location_string(None, Some("Cambridge, MA".to_string()), 42.36, -71.06);
assert_eq!(s, "Cambridge, MA");
}
#[test]
fn compose_location_apollo_only_no_description() {
let s = compose_location_string(Some(place("Home", "")), None, 42.36, -71.06);
assert_eq!(s, "Home");
}
#[test]
fn compose_location_falls_back_to_coordinates() {
let s = compose_location_string(None, None, 42.3601, -71.0589);
assert_eq!(s, "42.3601, -71.0589");
}
#[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");
}
// These tests assert the shape of the strings returned by the tool
// implementations above. If a tool's output format changes, update the
// tool AND the corresponding arm of `summarize_tool_result` — these
// tests exist to make that coupling loud.
#[test]
fn summarize_errors_uniformly() {
assert_eq!(
InsightGenerator::summarize_tool_result("search_rag", "Error searching RAG: boom"),
"error"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"get_sms_messages",
"Error fetching SMS messages: timeout"
),
"error"
);
}
#[test]
fn summarize_empty_results_uniformly() {
assert_eq!(
InsightGenerator::summarize_tool_result("search_rag", "No relevant messages found."),
"empty (pivoted)"
);
assert_eq!(
InsightGenerator::summarize_tool_result("get_sms_messages", "No messages found."),
"empty (pivoted)"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"reverse_geocode",
"Could not resolve coordinates to a place name."
),
"empty (pivoted)"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"recall_facts_for_photo",
"No knowledge facts found for this photo."
),
"empty (pivoted)"
);
}
#[test]
fn summarize_found_count_per_tool() {
assert_eq!(
InsightGenerator::summarize_tool_result(
"get_sms_messages",
"Found 7 messages:\n[2023-08-15 10:00] Sarah: hi"
),
"7 messages"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"search_messages",
"Found 3 messages (mode: hybrid):\n\n[2023-08-15] Sarah — hi"
),
"3 messages"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"get_calendar_events",
"Found 2 calendar events:\n[2023-08-15 10:00] Wedding"
),
"2 events"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"get_location_history",
"Found 5 location records:\n[2023-08-15 10:00] 39.0, -120.0"
),
"5 location records"
);
}
#[test]
fn summarize_search_rag_counts_hits() {
let raw = "[2023-08-15] Sarah: venue confirmed\n\n[2023-08-14] Mom: travel plans\n\n[2023-08-13] Dad: weather";
assert_eq!(
InsightGenerator::summarize_tool_result("search_rag", raw),
"3 rag hits"
);
}
#[test]
fn summarize_get_file_tags() {
assert_eq!(
InsightGenerator::summarize_tool_result("get_file_tags", "wedding, tahoe, 2023"),
"3 tags"
);
}
#[test]
fn summarize_describe_photo_truncates() {
let raw = "A wedding ceremony at Lake Tahoe with about 40 guests seated in rows facing a lakeside arch decorated with white flowers.";
let out = InsightGenerator::summarize_tool_result("describe_photo", raw);
assert!(out.starts_with("described: \""));
assert!(out.contains("A wedding ceremony at Lake Tahoe"));
assert!(out.ends_with("...\""));
}
#[test]
fn summarize_reverse_geocode_returns_place() {
let out =
InsightGenerator::summarize_tool_result("reverse_geocode", "South Lake Tahoe, CA, USA");
assert_eq!(out, "place: South Lake Tahoe, CA, USA");
}
#[test]
fn summarize_recall_entities_counts_lines() {
let raw = "Known entities:\n- Sarah (person)\n- Tahoe (place)\n- Wedding 2023 (event)";
assert_eq!(
InsightGenerator::summarize_tool_result("recall_entities", raw),
"3 entities"
);
}
#[test]
fn summarize_recall_facts_counts_lines() {
let raw = "Knowledge for this photo:\n- Sarah: college friend\n- Tahoe: vacation spot";
assert_eq!(
InsightGenerator::summarize_tool_result("recall_facts_for_photo", raw),
"2 facts"
);
}
#[test]
fn summarize_store_entity_extracts_id() {
assert_eq!(
InsightGenerator::summarize_tool_result(
"store_entity",
"Entity stored: ID:42 | person | Sarah | confidence:0.80"
),
"stored id=42"
);
}
#[test]
fn summarize_store_fact_extracts_id() {
assert_eq!(
InsightGenerator::summarize_tool_result(
"store_fact",
"Stored new fact: ID:17 | confidence:0.60"
),
"stored id=17"
);
assert_eq!(
InsightGenerator::summarize_tool_result(
"store_fact",
"Corroborated existing fact: ID:17 | confidence:0.85"
),
"stored id=17"
);
}
#[test]
fn summarize_current_datetime() {
assert_eq!(
InsightGenerator::summarize_tool_result(
"get_current_datetime",
"Current date/time: 2024-01-15 12:00:00 PST (Monday)"
),
"time noted"
);
}
#[test]
fn summarize_unknown_tool_falls_back_to_char_count() {
let out = InsightGenerator::summarize_tool_result("never_heard_of_it", "some output");
assert_eq!(out, "11 chars");
}
#[test]
fn build_system_content_uses_custom_prompt_verbatim_for_identity() {
let out = InsightGenerator::build_system_content(
Some("You are a journal writer in first person, warm and reflective."),
None,
"",
6,
);
assert!(
out.starts_with("You are a journal writer in first person, warm and reflective."),
"custom prompt must lead the system content; got: {}",
&out[..out.len().min(200)],
);
assert!(
!out.contains("personal photo memory assistant"),
"framework identity must not leak when custom prompt is supplied"
);
assert!(out.contains("Tool-use guidance"));
assert!(out.contains("budget of 6"));
}
#[test]
fn build_system_content_uses_neutral_default_when_no_custom() {
let out = InsightGenerator::build_system_content(None, None, "", 6);
assert!(out.contains("reconstructing a memory from a photo"));
assert!(!out.contains("personal photo memory assistant"));
assert!(out.contains("Tool-use guidance"));
}
#[test]
fn build_system_content_includes_fewshot_and_owner_id() {
let owner = "\n\nYour identity in the knowledge store: Alice (entity ID: 7).";
let fewshot = "## Examples\n\n### Example 1\n...\n\n---\n\n";
let out = InsightGenerator::build_system_content(None, Some(owner), fewshot, 6);
assert!(out.contains("Alice (entity ID: 7)"));
assert!(out.contains("## Examples"));
}
#[test]
fn render_fewshot_empty_returns_empty_string() {
assert!(InsightGenerator::render_fewshot_examples(&[]).is_empty());
}
#[test]
fn render_single_trajectory_walks_tool_calls_in_order() {
let arguments = serde_json::json!({ "query": "wedding", "date": "2023-08-15" });
let msgs = vec![
ChatMessage::system("ignored"),
ChatMessage::user("Photo file path: /photos/img.jpg\nDate taken: August 15, 2023"),
ChatMessage {
role: "assistant".to_string(),
content: String::new(),
tool_calls: Some(vec![ToolCall {
function: ToolCallFunction {
name: "search_rag".to_string(),
arguments,
},
id: None,
}]),
images: None,
},
ChatMessage::tool_result("No relevant messages found."),
ChatMessage {
role: "assistant".to_string(),
content: "Final title\n\nFinal body.".to_string(),
tool_calls: None,
images: None,
},
];
let out = InsightGenerator::render_single_trajectory(&msgs);
assert!(out.contains("Input:"));
assert!(out.contains("/photos/img.jpg"));
assert!(out.contains("1. search_rag("));
assert!(out.contains("query=\"wedding\""));
assert!(out.contains("-> empty (pivoted)"));
assert!(out.contains("Final insight: Final title"));
}
}