diff --git a/CLAUDE.md b/CLAUDE.md index 8a8dea4..86515d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,6 +280,12 @@ OLLAMA_REQUEST_TIMEOUT_SECONDS=120 # Per-request generation timeout SMS_API_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000) SMS_API_TOKEN=your-api-token # SMS API authentication token (optional) +# Apollo Places integration (optional). When set, photo-insight enrichment +# folds the user's personal place name (Home, Work, Cabin, ...) into the +# location string fed to the LLM, and the agentic loop gains a +# `get_personal_place_at` tool. Unset = legacy Nominatim-only path. +APOLLO_API_BASE_URL=http://apollo.lan:8000 # Base URL of the sibling Apollo backend + # OpenRouter (Hybrid Backend) - keeps embeddings + vision local, routes chat to OpenRouter OPENROUTER_API_KEY=sk-or-... # Required to enable hybrid backend OPENROUTER_DEFAULT_MODEL=anthropic/claude-sonnet-4 # Used when client doesn't pick a model @@ -371,6 +377,31 @@ Configurable env: - `AGENTIC_CHAT_MAX_ITERATIONS` — cap on tool-calling iterations per turn (default 6). Per-request `max_iterations` is clamped to this cap. +**Apollo Places integration (optional):** + +The sibling Apollo project (personal location-history viewer) owns +user-defined Places: `name + lat/lon + radius_m + description (+ optional +category)`. When `APOLLO_API_BASE_URL` is set, ImageApi queries +`/api/places/contains?lat=&lon=` to enrich the LLM prompt's location +string. See `src/ai/apollo_client.rs` and `src/ai/insight_generator.rs`: + +- **Auto-enrichment** (always on when configured): the per-photo location + resolver folds the most-specific containing Place ("Home — near + Cambridge, MA" or "Home (My house in Cambridge) — near Cambridge, MA" + when a description is set) into the location field of `combine_contexts`. + Smallest-radius wins — Apollo sorts server-side, this code takes `[0]`. +- **Agentic tool** `get_personal_place_at(latitude, longitude)`: registered + alongside `reverse_geocode` only when `apollo_enabled()` returns true. + Returns "- Name [category]: description (radius N m)" lines, smallest + radius first. The tool is **deliberately narrow** — no enumerate-all + variant; auto-enrichment covers the photo-context path and the agentic + tool covers ad-hoc lat/lon questions in chat continuation. + +Failure modes degrade silently to the legacy Nominatim path: 5 s timeout, +errors logged at `warn`, empty results returned. Apollo's routes are +unauthenticated (single-user, LAN-trust); add JWT auth here + on Apollo's +side if exposing beyond a trusted network. + ## Dependencies of Note ### Rust crates diff --git a/src/ai/apollo_client.rs b/src/ai/apollo_client.rs new file mode 100644 index 0000000..71a18eb --- /dev/null +++ b/src/ai/apollo_client.rs @@ -0,0 +1,110 @@ +//! Thin async HTTP client for Apollo's `/api/places/*` endpoints. +//! +//! Apollo (the personal location-history viewer at the sibling repo) owns +//! user-defined Places: `name + lat/lon + radius_m + description (+ optional +//! category)`. We consume them in two places: +//! +//! 1. Automatic enrichment in [`crate::ai::insight_generator`] — the always-on +//! path that folds the most-specific containing Place into the location +//! string fed to the LLM. +//! 2. The agentic `get_personal_place_at` tool — lets the LLM ask "what +//! user-defined place contains this lat/lon" during chat continuation. +//! +//! Apollo does the haversine. This client is plumbing only — no geometry, +//! no caching at the moment. If insight throughput ever makes per-photo +//! HTTP latency a problem, swap to a small `Mutex` TTL cache here. +//! +//! Configured via `APOLLO_API_BASE_URL`. When unset, the client constructs +//! to a no-op shell: every method returns empty / `None`, the enrichment +//! path silently falls through to the legacy Nominatim-only output, and the +//! tool registration in `insight_generator` reports "integration disabled." + +use anyhow::Result; +use reqwest::Client; +use serde::Deserialize; +use std::time::Duration; + +// Public fields — `id`, `lat`, `lon` aren't read from the current tool +// output but are part of the wire model and useful for future tool +// extensions / debugging. +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +pub struct ApolloPlace { + pub id: i32, + pub name: String, + #[serde(default)] + pub description: String, + pub lat: f64, + pub lon: f64, + pub radius_m: i32, + #[serde(default)] + pub category: Option, +} + +#[derive(Deserialize)] +struct PlacesResponse { + places: Vec, +} + +#[derive(Clone)] +pub struct ApolloClient { + client: Client, + /// `None` means the integration is disabled — every method returns + /// empty so the rest of insight generation runs unchanged. + base_url: Option, +} + +impl ApolloClient { + pub fn new(base_url: Option) -> Self { + // 5 s timeout: Apollo runs on the LAN. If it doesn't answer in + // five seconds, treat the call as failed and fall back to the + // legacy Nominatim path rather than block the whole insight. + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client build"); + Self { client, base_url } + } + + /// Convenience for callers that need to know whether to register the + /// `get_personal_place_at` tool (or to short-circuit enrichment). + pub fn is_enabled(&self) -> bool { + self.base_url.is_some() + } + + /// Server-side haversine: returns places whose radius contains + /// (lat, lon), already sorted smallest-radius-first by Apollo. The + /// caller can take `[0]` for the most-specific match (matches + /// Apollo's `primaryPlaceFor` rule on the frontend, so the carousel + /// badge and the LLM prompt always agree). + pub async fn places_containing(&self, lat: f64, lon: f64) -> Vec { + let Some(base) = self.base_url.as_deref() else { + return Vec::new(); + }; + match self.fetch_places_containing(base, lat, lon).await { + Ok(places) => places, + Err(err) => { + log::warn!("apollo_client: places_containing({lat:.4}, {lon:.4}) failed: {err}"); + Vec::new() + } + } + } + + async fn fetch_places_containing( + &self, + base: &str, + lat: f64, + lon: f64, + ) -> Result> { + let url = format!("{}/api/places/contains", base.trim_end_matches('/')); + let resp = self + .client + .get(&url) + .query(&[("lat", lat), ("lon", lon)]) + .send() + .await? + .error_for_status()?; + let body: PlacesResponse = resp.json().await?; + Ok(body.places) + } +} diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 1c20b45..05de11c 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -359,7 +359,10 @@ impl InsightChatService { .map(|imgs| !imgs.is_empty()) .unwrap_or(false); let offer_describe_tool = !is_hybrid && local_first_user_has_image; - let tools = InsightGenerator::build_tool_definitions(offer_describe_tool); + let tools = InsightGenerator::build_tool_definitions( + offer_describe_tool, + self.generator.apollo_enabled(), + ); // Image base64 only needed when describe_photo is on the menu. Load // lazily to avoid disk IO when the loop never invokes it. @@ -786,7 +789,10 @@ impl InsightChatService { .map(|imgs| !imgs.is_empty()) .unwrap_or(false); let offer_describe_tool = !is_hybrid && local_first_user_has_image; - let tools = InsightGenerator::build_tool_definitions(offer_describe_tool); + let tools = InsightGenerator::build_tool_definitions( + offer_describe_tool, + self.generator.apollo_enabled(), + ); let image_base64: Option = if offer_describe_tool { self.generator.load_image_as_base64(&normalized).ok() diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 44a2a4e..f31bfac 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -9,6 +9,7 @@ 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; @@ -25,6 +26,28 @@ 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, + nominatim: Option, + 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, @@ -46,6 +69,10 @@ pub struct InsightGenerator { /// `None` when `OPENROUTER_API_KEY` is not configured. openrouter: Option>, 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>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, @@ -67,6 +94,7 @@ impl InsightGenerator { ollama: OllamaClient, openrouter: Option>, sms_client: SmsApiClient, + apollo_client: ApolloClient, insight_dao: Arc>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, @@ -81,6 +109,7 @@ impl InsightGenerator { ollama, openrouter, sms_client, + apollo_client, insight_dao, exif_dao, daily_summary_dao, @@ -93,6 +122,14 @@ impl InsightGenerator { } } + /// 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() + } + /// 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 @@ -809,25 +846,27 @@ impl InsightGenerator { tag_names ); - // 4. Get location name from GPS coordinates (needed for RAG query) + // 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 loc = self.reverse_geocode(lat as f64, lon as f64).await; - if let Some(ref l) = loc { - insight_cx - .span() - .set_attribute(KeyValue::new("location", l.clone())); - Some(l.clone()) - } else { - // Fallback: If reverse geocoding fails, use coordinates - log::warn!( - "Reverse geocoding failed for {}, {}, using coordinates as fallback", - lat, - lon - ); - Some(format!("{:.4}, {:.4}", lat, lon)) - } + 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 } @@ -1436,6 +1475,7 @@ Return ONLY the summary, nothing else."#, "get_file_tags" => self.tool_get_file_tags(arguments, cx).await, "describe_photo" => self.tool_describe_photo(ollama, image_base64).await, "reverse_geocode" => self.tool_reverse_geocode(arguments).await, + "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, @@ -2047,6 +2087,48 @@ Return ONLY the summary, nothing else."#, } } + /// 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::>() + .join("\n") + } + /// Tool: recall_entities — search the knowledge memory for known entities async fn tool_recall_entities( &self, @@ -2400,7 +2482,7 @@ Return ONLY the summary, nothing else."#, // ── Agentic insight generation ────────────────────────────────────── /// Build the list of tool definitions for the agentic loop - pub(crate) fn build_tool_definitions(has_vision: bool) -> Vec { + pub(crate) fn build_tool_definitions(has_vision: bool, apollo_enabled: bool) -> Vec { let mut tools = vec![ Tool::function( "search_rag", @@ -2552,6 +2634,23 @@ Return ONLY the summary, nothing else."#, }), )); + // Personal place lookup. Only registered when the integration is + // enabled — otherwise the LLM gets a tool that always errors. + if apollo_enabled { + tools.push(Tool::function( + "get_personal_place_at", + "Get the user's personal, named place (e.g. Home, Work, Cabin) at a GPS coordinate, if any. Returns the place name, category, free-text description (the user's own notes about the location), and radius. More specific than reverse_geocode — prefer this when both apply.", + serde_json::json!({ + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { "type": "number", "description": "GPS latitude in decimal degrees" }, + "longitude": { "type": "number", "description": "GPS longitude in decimal degrees" } + } + }), + )); + } + // Knowledge memory tools tools.push(Tool::function( "recall_entities", @@ -2813,6 +2912,15 @@ Return ONLY the summary, nothing else."#, 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" { @@ -3262,7 +3370,8 @@ Return ONLY the summary, nothing else."#, // 10. Define tools. Hybrid mode omits `describe_photo` since the // chat model receives the visual description inline. let offer_describe_tool = has_vision && !is_hybrid; - let tools = Self::build_tool_definitions(offer_describe_tool); + let tools = + Self::build_tool_definitions(offer_describe_tool, self.apollo_client.is_enabled()); // 11. Build initial messages. In hybrid mode images are never // attached to the wire message — the description is part of @@ -3542,6 +3651,58 @@ mod tests { use super::*; use crate::ai::ollama::{ToolCall, ToolCallFunction}; + 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( diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 94e8541..a9d55bf 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -1,3 +1,4 @@ +pub mod apollo_client; pub mod daily_summary_job; pub mod handlers; pub mod insight_chat; diff --git a/src/bin/populate_knowledge.rs b/src/bin/populate_knowledge.rs index 2cae014..9c55e60 100644 --- a/src/bin/populate_knowledge.rs +++ b/src/bin/populate_knowledge.rs @@ -5,6 +5,7 @@ use clap::Parser; use log::warn; use walkdir::WalkDir; +use image_api::ai::apollo_client::ApolloClient; use image_api::ai::{InsightGenerator, OllamaClient, SmsApiClient}; use image_api::bin_progress; use image_api::database::{ @@ -163,6 +164,7 @@ async fn main() -> anyhow::Result<()> { std::env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); let sms_api_token = std::env::var("SMS_API_TOKEN").ok(); let sms_client = SmsApiClient::new(sms_api_url, sms_api_token); + let apollo_client = ApolloClient::new(std::env::var("APOLLO_API_BASE_URL").ok()); let insight_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteInsightDao::new()))); @@ -188,6 +190,7 @@ async fn main() -> anyhow::Result<()> { ollama, None, sms_client, + apollo_client, insight_dao.clone(), exif_dao, daily_summary_dao, diff --git a/src/exif.rs b/src/exif.rs index b7f7112..4470f50 100644 --- a/src/exif.rs +++ b/src/exif.rs @@ -160,6 +160,52 @@ pub fn extract_embedded_jpeg_preview(path: &Path) -> Option> { .max_by_key(|v| v.len()) } +/// Write GPS lat/lon into the file's EXIF in place via exiftool. Touches +/// nothing else — camera, dates, MakerNote, etc. all stay as-is. Uses +/// `-overwrite_original` so no `.orig` sidecar is left behind (the +/// caller's responsibility to back up the file system if they want +/// rollback). Returns Err if exiftool isn't on PATH, the file format +/// doesn't support EXIF, lat/lon are out of range, or exiftool prints +/// to stderr. +/// +/// We pass lat/lon as positive decimal numbers and let the *Ref tags +/// carry the sign (N/S, E/W). exiftool happily accepts signed decimals +/// too, but the explicit ref form is unambiguous across exiftool +/// versions and matches what cameras write. +pub fn write_gps(path: &Path, lat: f64, lon: f64) -> Result<()> { + if !supports_exif(path) { + return Err(anyhow!( + "Format does not support EXIF GPS write: {}", + path.display() + )); + } + if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) { + return Err(anyhow!("GPS coordinates out of range: {}, {}", lat, lon)); + } + let lat_ref = if lat >= 0.0 { "N" } else { "S" }; + let lon_ref = if lon >= 0.0 { "E" } else { "W" }; + let lat_abs = lat.abs(); + let lon_abs = lon.abs(); + let output = Command::new("exiftool") + .arg("-overwrite_original") + .arg(format!("-GPSLatitude={}", lat_abs)) + .arg(format!("-GPSLatitudeRef={}", lat_ref)) + .arg(format!("-GPSLongitude={}", lon_abs)) + .arg(format!("-GPSLongitudeRef={}", lon_ref)) + .arg(path) + .output() + .map_err(|e| anyhow!("exiftool spawn failed (is it on PATH?): {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "exiftool failed (exit {}): {}", + output.status.code().unwrap_or(-1), + stderr.trim() + )); + } + Ok(()) +} + pub fn supports_exif(path: &Path) -> bool { if let Some(ext) = path.extension() { let ext_lower = ext.to_string_lossy().to_lowercase(); diff --git a/src/main.rs b/src/main.rs index 12a4003..ccdb14b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -394,6 +394,168 @@ async fn get_file_metadata( } } +/// Body for `POST /image/exif/gps` — write GPS coordinates into a file's +/// EXIF in place. Only `path` + `latitude` + `longitude` are required. +/// `library` is optional (falls back to the primary library) and matches +/// the convention of the other path-keyed routes. +#[derive(serde::Deserialize)] +struct SetGpsRequest { + path: String, + library: Option, + latitude: f64, + longitude: f64, +} + +#[post("/image/exif/gps")] +async fn set_image_gps( + _: Claims, + request: HttpRequest, + body: web::Json, + app_state: Data, + exif_dao: Data>>, +) -> impl Responder { + let tracer = global_tracer(); + let context = extract_context_from_request(&request); + let mut span = tracer.start_with_context("set_image_gps", &context); + let span_context = + opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); + + let library = libraries::resolve_library_param(&app_state, body.library.as_deref()) + .ok() + .flatten() + .unwrap_or_else(|| app_state.primary_library()); + + // Same fallback as get_file_metadata: union-mode means a file may + // resolve under a sibling library. + let resolved = is_valid_full_path(&library.root_path, &body.path, false) + .filter(|p| p.exists()) + .map(|p| (library, p)) + .or_else(|| { + app_state.libraries.iter().find_map(|lib| { + if lib.id == library.id { + return None; + } + is_valid_full_path(&lib.root_path, &body.path, false) + .filter(|p| p.exists()) + .map(|p| (lib, p)) + }) + }); + + let (resolved_library, full_path) = match resolved { + Some(v) => v, + None => { + span.set_status(Status::error("file not found")); + return HttpResponse::NotFound().body("File not found"); + } + }; + + if !exif::supports_exif(&full_path) { + return HttpResponse::BadRequest().body("File format does not support EXIF GPS write"); + } + + if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) { + let msg = format!("exiftool write failed: {}", e); + error!("{}", msg); + span.set_status(Status::error(msg.clone())); + return HttpResponse::InternalServerError().body(msg); + } + + // Re-read EXIF from disk (the write path doesn't tell us the rest of + // the parsed fields back, and we want the DB row to match what + // extract_exif_from_path would now produce). Update the existing row + // rather than insert — this endpoint is invoked on already-indexed + // files only. + let extracted = match exif::extract_exif_from_path(&full_path) { + Ok(d) => d, + Err(e) => { + // GPS was written successfully but re-extraction failed; surface + // a 500 because the DB will now disagree with disk until the + // next file scan rewrites it. + let msg = format!("EXIF re-read failed after write: {}", e); + error!("{}", msg); + return HttpResponse::InternalServerError().body(msg); + } + }; + let now = Utc::now().timestamp(); + let normalized_path = body.path.replace('\\', "/"); + let insert_exif = InsertImageExif { + library_id: resolved_library.id, + file_path: normalized_path.clone(), + camera_make: extracted.camera_make, + camera_model: extracted.camera_model, + lens_model: extracted.lens_model, + width: extracted.width, + height: extracted.height, + orientation: extracted.orientation, + gps_latitude: extracted.gps_latitude.map(|v| v as f32), + gps_longitude: extracted.gps_longitude.map(|v| v as f32), + gps_altitude: extracted.gps_altitude.map(|v| v as f32), + focal_length: extracted.focal_length.map(|v| v as f32), + aperture: extracted.aperture.map(|v| v as f32), + shutter_speed: extracted.shutter_speed, + iso: extracted.iso, + date_taken: extracted.date_taken, + // Created_time is preserved by update_exif (it doesn't touch the + // column); pass any int — it's ignored in the UPDATE statement. + created_time: now, + last_modified: now, + // Hash + size aren't touched in update_exif either, but the file + // bytes did change — best-effort recompute so the new hash lands + // on the next call to get_exif. Failure here just leaves the old + // values in place. + content_hash: content_hash::compute(&full_path) + .ok() + .map(|c| c.content_hash), + size_bytes: content_hash::compute(&full_path).ok().map(|c| c.size_bytes), + }; + + let updated = { + let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); + // If the row doesn't exist yet (file isn't indexed for some reason), + // insert instead so the GPS write is at least visible the moment + // the watcher catches up. + match dao.get_exif(&span_context, &normalized_path) { + Ok(Some(_)) => dao.update_exif(&span_context, insert_exif), + Ok(None) => dao.store_exif(&span_context, insert_exif), + Err(_) => dao.update_exif(&span_context, insert_exif), + } + }; + + match updated { + Ok(row) => { + // Mirror the file metadata so the client gets the new size / + // mtime in the same response and can refresh its cached + // metadata block in one round-trip. + let fs_meta = std::fs::metadata(&full_path).ok(); + let mut response: MetadataResponse = match fs_meta { + Some(m) => m.into(), + None => MetadataResponse { + created: None, + modified: None, + size: 0, + exif: None, + filename_date: None, + library_id: None, + library_name: None, + }, + }; + response.exif = Some(row.into()); + response.library_id = Some(resolved_library.id); + response.library_name = Some(resolved_library.name.clone()); + response.filename_date = + memories::extract_date_from_filename(&body.path).map(|dt| dt.timestamp()); + span.set_status(Status::Ok); + HttpResponse::Ok().json(response) + } + Err(e) => { + let msg = format!("EXIF DB update failed: {:?}", e); + error!("{}", msg); + span.set_status(Status::error(msg.clone())); + HttpResponse::InternalServerError().body(msg) + } + } +} + #[derive(serde::Deserialize)] struct UploadQuery { library: Option, @@ -1415,6 +1577,7 @@ fn main() -> std::io::Result<()> { .service(put_add_favorite) .service(delete_favorite) .service(get_file_metadata) + .service(set_image_gps) .service(memories::list_memories) .service(ai::generate_insight_handler) .service(ai::generate_agentic_insight_handler) diff --git a/src/state.rs b/src/state.rs index 8e13d28..5682d43 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,4 @@ +use crate::ai::apollo_client::ApolloClient; use crate::ai::insight_chat::{ChatLockMap, InsightChatService}; use crate::ai::openrouter::OpenRouterClient; use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; @@ -155,6 +156,11 @@ impl Default for AppState { let sms_api_token = env::var("SMS_API_TOKEN").ok(); let sms_client = SmsApiClient::new(sms_api_url, sms_api_token); + // Apollo Places integration. Optional — when APOLLO_API_BASE_URL is + // unset, ApolloClient is constructed disabled and the insight + // generator silently falls through to the legacy Nominatim path. + let apollo_client = ApolloClient::new(env::var("APOLLO_API_BASE_URL").ok()); + // Initialize DAOs let insight_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteInsightDao::new()))); @@ -193,6 +199,7 @@ impl Default for AppState { ollama.clone(), openrouter.clone(), sms_client.clone(), + apollo_client.clone(), insight_dao.clone(), exif_dao.clone(), daily_summary_dao.clone(), @@ -295,6 +302,7 @@ impl AppState { None, ); let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None); + let apollo_client = ApolloClient::new(None); // Initialize test DAOs let insight_dao: Arc>> = @@ -327,6 +335,7 @@ impl AppState { ollama.clone(), None, sms_client.clone(), + apollo_client.clone(), insight_dao.clone(), exif_dao.clone(), daily_summary_dao.clone(),