feat/apollo-places-tool and Geo Tagging Exif #60
31
CLAUDE.md
31
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_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000)
|
||||||
SMS_API_TOKEN=your-api-token # SMS API authentication token (optional)
|
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 (Hybrid Backend) - keeps embeddings + vision local, routes chat to OpenRouter
|
||||||
OPENROUTER_API_KEY=sk-or-... # Required to enable hybrid backend
|
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
|
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
|
- `AGENTIC_CHAT_MAX_ITERATIONS` — cap on tool-calling iterations per turn
|
||||||
(default 6). Per-request `max_iterations` is clamped to this cap.
|
(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
|
## Dependencies of Note
|
||||||
|
|
||||||
### Rust crates
|
### Rust crates
|
||||||
|
|||||||
110
src/ai/apollo_client.rs
Normal file
110
src/ai/apollo_client.rs
Normal file
@@ -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<HashMap>` 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PlacesResponse {
|
||||||
|
places: Vec<ApolloPlace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApolloClient {
|
||||||
|
pub fn new(base_url: Option<String>) -> 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<ApolloPlace> {
|
||||||
|
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<Vec<ApolloPlace>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,7 +359,10 @@ impl InsightChatService {
|
|||||||
.map(|imgs| !imgs.is_empty())
|
.map(|imgs| !imgs.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let offer_describe_tool = !is_hybrid && local_first_user_has_image;
|
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
|
// Image base64 only needed when describe_photo is on the menu. Load
|
||||||
// lazily to avoid disk IO when the loop never invokes it.
|
// lazily to avoid disk IO when the loop never invokes it.
|
||||||
@@ -786,7 +789,10 @@ impl InsightChatService {
|
|||||||
.map(|imgs| !imgs.is_empty())
|
.map(|imgs| !imgs.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let offer_describe_tool = !is_hybrid && local_first_user_has_image;
|
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<String> = if offer_describe_tool {
|
let image_base64: Option<String> = if offer_describe_tool {
|
||||||
self.generator.load_image_as_base64(&normalized).ok()
|
self.generator.load_image_as_base64(&normalized).ok()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use std::fs::File;
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::ai::apollo_client::{ApolloClient, ApolloPlace};
|
||||||
use crate::ai::llm_client::LlmClient;
|
use crate::ai::llm_client::LlmClient;
|
||||||
use crate::ai::ollama::{ChatMessage, OllamaClient, Tool};
|
use crate::ai::ollama::{ChatMessage, OllamaClient, Tool};
|
||||||
use crate::ai::openrouter::OpenRouterClient;
|
use crate::ai::openrouter::OpenRouterClient;
|
||||||
@@ -25,6 +26,28 @@ use crate::otel::global_tracer;
|
|||||||
use crate::tags::TagDao;
|
use crate::tags::TagDao;
|
||||||
use crate::utils::{earliest_fs_time, normalize_path};
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct NominatimResponse {
|
struct NominatimResponse {
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
@@ -46,6 +69,10 @@ pub struct InsightGenerator {
|
|||||||
/// `None` when `OPENROUTER_API_KEY` is not configured.
|
/// `None` when `OPENROUTER_API_KEY` is not configured.
|
||||||
openrouter: Option<Arc<OpenRouterClient>>,
|
openrouter: Option<Arc<OpenRouterClient>>,
|
||||||
sms_client: SmsApiClient,
|
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>>>,
|
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
||||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||||
daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
|
daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
|
||||||
@@ -67,6 +94,7 @@ impl InsightGenerator {
|
|||||||
ollama: OllamaClient,
|
ollama: OllamaClient,
|
||||||
openrouter: Option<Arc<OpenRouterClient>>,
|
openrouter: Option<Arc<OpenRouterClient>>,
|
||||||
sms_client: SmsApiClient,
|
sms_client: SmsApiClient,
|
||||||
|
apollo_client: ApolloClient,
|
||||||
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
||||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||||
daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
|
daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
|
||||||
@@ -81,6 +109,7 @@ impl InsightGenerator {
|
|||||||
ollama,
|
ollama,
|
||||||
openrouter,
|
openrouter,
|
||||||
sms_client,
|
sms_client,
|
||||||
|
apollo_client,
|
||||||
insight_dao,
|
insight_dao,
|
||||||
exif_dao,
|
exif_dao,
|
||||||
daily_summary_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
|
/// Resolve `rel_path` against the configured libraries, returning the
|
||||||
/// first root under which the file exists. Insights may be generated
|
/// first root under which the file exists. Insights may be generated
|
||||||
/// for any library — the generator itself doesn't know which — so we
|
/// for any library — the generator itself doesn't know which — so we
|
||||||
@@ -809,25 +846,27 @@ impl InsightGenerator {
|
|||||||
tag_names
|
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 {
|
let location = match exif {
|
||||||
Some(ref exif) => {
|
Some(ref exif) => {
|
||||||
if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) {
|
if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) {
|
||||||
let loc = self.reverse_geocode(lat as f64, lon as f64).await;
|
let lat = lat as f64;
|
||||||
if let Some(ref l) = loc {
|
let lon = lon as f64;
|
||||||
insight_cx
|
let nominatim = self.reverse_geocode(lat, lon).await;
|
||||||
.span()
|
let apollo_primary = self
|
||||||
.set_attribute(KeyValue::new("location", l.clone()));
|
.apollo_client
|
||||||
Some(l.clone())
|
.places_containing(lat, lon)
|
||||||
} else {
|
.await
|
||||||
// Fallback: If reverse geocoding fails, use coordinates
|
.into_iter()
|
||||||
log::warn!(
|
.next();
|
||||||
"Reverse geocoding failed for {}, {}, using coordinates as fallback",
|
let combined = compose_location_string(apollo_primary, nominatim, lat, lon);
|
||||||
lat,
|
insight_cx
|
||||||
lon
|
.span()
|
||||||
);
|
.set_attribute(KeyValue::new("location", combined.clone()));
|
||||||
Some(format!("{:.4}, {:.4}", lat, lon))
|
Some(combined)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -1436,6 +1475,7 @@ Return ONLY the summary, nothing else."#,
|
|||||||
"get_file_tags" => self.tool_get_file_tags(arguments, cx).await,
|
"get_file_tags" => self.tool_get_file_tags(arguments, cx).await,
|
||||||
"describe_photo" => self.tool_describe_photo(ollama, image_base64).await,
|
"describe_photo" => self.tool_describe_photo(ollama, image_base64).await,
|
||||||
"reverse_geocode" => self.tool_reverse_geocode(arguments).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_entities" => self.tool_recall_entities(arguments, cx).await,
|
||||||
"recall_facts_for_photo" => self.tool_recall_facts_for_photo(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_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::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool: recall_entities — search the knowledge memory for known entities
|
/// Tool: recall_entities — search the knowledge memory for known entities
|
||||||
async fn tool_recall_entities(
|
async fn tool_recall_entities(
|
||||||
&self,
|
&self,
|
||||||
@@ -2400,7 +2482,7 @@ Return ONLY the summary, nothing else."#,
|
|||||||
// ── Agentic insight generation ──────────────────────────────────────
|
// ── Agentic insight generation ──────────────────────────────────────
|
||||||
|
|
||||||
/// Build the list of tool definitions for the agentic loop
|
/// Build the list of tool definitions for the agentic loop
|
||||||
pub(crate) fn build_tool_definitions(has_vision: bool) -> Vec<Tool> {
|
pub(crate) fn build_tool_definitions(has_vision: bool, apollo_enabled: bool) -> Vec<Tool> {
|
||||||
let mut tools = vec![
|
let mut tools = vec![
|
||||||
Tool::function(
|
Tool::function(
|
||||||
"search_rag",
|
"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
|
// Knowledge memory tools
|
||||||
tools.push(Tool::function(
|
tools.push(Tool::function(
|
||||||
"recall_entities",
|
"recall_entities",
|
||||||
@@ -2813,6 +2912,15 @@ Return ONLY the summary, nothing else."#,
|
|||||||
let short: String = raw.chars().take(60).collect();
|
let short: String = raw.chars().take(60).collect();
|
||||||
format!("place: {}", short)
|
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" => {
|
"recall_entities" | "recall_facts_for_photo" => {
|
||||||
let n = raw.lines().skip(1).filter(|l| !l.trim().is_empty()).count();
|
let n = raw.lines().skip(1).filter(|l| !l.trim().is_empty()).count();
|
||||||
let kind = if tool_name == "recall_entities" {
|
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
|
// 10. Define tools. Hybrid mode omits `describe_photo` since the
|
||||||
// chat model receives the visual description inline.
|
// chat model receives the visual description inline.
|
||||||
let offer_describe_tool = has_vision && !is_hybrid;
|
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
|
// 11. Build initial messages. In hybrid mode images are never
|
||||||
// attached to the wire message — the description is part of
|
// attached to the wire message — the description is part of
|
||||||
@@ -3542,6 +3651,58 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::ai::ollama::{ToolCall, ToolCallFunction};
|
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]
|
#[test]
|
||||||
fn combine_contexts_includes_tags_section_when_tags_present() {
|
fn combine_contexts_includes_tags_section_when_tags_present() {
|
||||||
let result = InsightGenerator::combine_contexts(
|
let result = InsightGenerator::combine_contexts(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod apollo_client;
|
||||||
pub mod daily_summary_job;
|
pub mod daily_summary_job;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod insight_chat;
|
pub mod insight_chat;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use clap::Parser;
|
|||||||
use log::warn;
|
use log::warn;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use image_api::ai::apollo_client::ApolloClient;
|
||||||
use image_api::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
use image_api::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
||||||
use image_api::bin_progress;
|
use image_api::bin_progress;
|
||||||
use image_api::database::{
|
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());
|
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_api_token = std::env::var("SMS_API_TOKEN").ok();
|
||||||
let sms_client = SmsApiClient::new(sms_api_url, sms_api_token);
|
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<Mutex<Box<dyn InsightDao>>> =
|
let insight_dao: Arc<Mutex<Box<dyn InsightDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(SqliteInsightDao::new())));
|
Arc::new(Mutex::new(Box::new(SqliteInsightDao::new())));
|
||||||
@@ -188,6 +190,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
ollama,
|
ollama,
|
||||||
None,
|
None,
|
||||||
sms_client,
|
sms_client,
|
||||||
|
apollo_client,
|
||||||
insight_dao.clone(),
|
insight_dao.clone(),
|
||||||
exif_dao,
|
exif_dao,
|
||||||
daily_summary_dao,
|
daily_summary_dao,
|
||||||
|
|||||||
46
src/exif.rs
46
src/exif.rs
@@ -160,6 +160,52 @@ pub fn extract_embedded_jpeg_preview(path: &Path) -> Option<Vec<u8>> {
|
|||||||
.max_by_key(|v| v.len())
|
.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 {
|
pub fn supports_exif(path: &Path) -> bool {
|
||||||
if let Some(ext) = path.extension() {
|
if let Some(ext) = path.extension() {
|
||||||
let ext_lower = ext.to_string_lossy().to_lowercase();
|
let ext_lower = ext.to_string_lossy().to_lowercase();
|
||||||
|
|||||||
163
src/main.rs
163
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<String>,
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/image/exif/gps")]
|
||||||
|
async fn set_image_gps(
|
||||||
|
_: Claims,
|
||||||
|
request: HttpRequest,
|
||||||
|
body: web::Json<SetGpsRequest>,
|
||||||
|
app_state: Data<AppState>,
|
||||||
|
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||||
|
) -> 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)]
|
#[derive(serde::Deserialize)]
|
||||||
struct UploadQuery {
|
struct UploadQuery {
|
||||||
library: Option<String>,
|
library: Option<String>,
|
||||||
@@ -1415,6 +1577,7 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(put_add_favorite)
|
.service(put_add_favorite)
|
||||||
.service(delete_favorite)
|
.service(delete_favorite)
|
||||||
.service(get_file_metadata)
|
.service(get_file_metadata)
|
||||||
|
.service(set_image_gps)
|
||||||
.service(memories::list_memories)
|
.service(memories::list_memories)
|
||||||
.service(ai::generate_insight_handler)
|
.service(ai::generate_insight_handler)
|
||||||
.service(ai::generate_agentic_insight_handler)
|
.service(ai::generate_agentic_insight_handler)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::ai::apollo_client::ApolloClient;
|
||||||
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
||||||
use crate::ai::openrouter::OpenRouterClient;
|
use crate::ai::openrouter::OpenRouterClient;
|
||||||
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
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_api_token = env::var("SMS_API_TOKEN").ok();
|
||||||
let sms_client = SmsApiClient::new(sms_api_url, sms_api_token);
|
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
|
// Initialize DAOs
|
||||||
let insight_dao: Arc<Mutex<Box<dyn InsightDao>>> =
|
let insight_dao: Arc<Mutex<Box<dyn InsightDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(SqliteInsightDao::new())));
|
Arc::new(Mutex::new(Box::new(SqliteInsightDao::new())));
|
||||||
@@ -193,6 +199,7 @@ impl Default for AppState {
|
|||||||
ollama.clone(),
|
ollama.clone(),
|
||||||
openrouter.clone(),
|
openrouter.clone(),
|
||||||
sms_client.clone(),
|
sms_client.clone(),
|
||||||
|
apollo_client.clone(),
|
||||||
insight_dao.clone(),
|
insight_dao.clone(),
|
||||||
exif_dao.clone(),
|
exif_dao.clone(),
|
||||||
daily_summary_dao.clone(),
|
daily_summary_dao.clone(),
|
||||||
@@ -295,6 +302,7 @@ impl AppState {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None);
|
let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None);
|
||||||
|
let apollo_client = ApolloClient::new(None);
|
||||||
|
|
||||||
// Initialize test DAOs
|
// Initialize test DAOs
|
||||||
let insight_dao: Arc<Mutex<Box<dyn InsightDao>>> =
|
let insight_dao: Arc<Mutex<Box<dyn InsightDao>>> =
|
||||||
@@ -327,6 +335,7 @@ impl AppState {
|
|||||||
ollama.clone(),
|
ollama.clone(),
|
||||||
None,
|
None,
|
||||||
sms_client.clone(),
|
sms_client.clone(),
|
||||||
|
apollo_client.clone(),
|
||||||
insight_dao.clone(),
|
insight_dao.clone(),
|
||||||
exif_dao.clone(),
|
exif_dao.clone(),
|
||||||
daily_summary_dao.clone(),
|
daily_summary_dao.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user