//! 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) } }