//! Thin async HTTP client for Apollo's `/api/places/*` and //! `/api/location/*` 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 also owns the location track itself — cleaned of ghost devices //! and coarse cell fixes, and current (HA siphon, Garmin), unlike the //! frozen 2019 Takeout import in our `location_history` table. The //! insight path's location context (`/api/location/at`) and the //! `get_location_history` tool (`/api/location/day`) read it from Apollo //! when configured (ImageApi #177, Apollo #51), falling back to the local //! table when Apollo is unset or a call fails. //! //! 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, } /// A kept location sample from Apollo, `offset_s` = sample ts − asked ts. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct LocationSample { pub ts: i64, pub lat: f64, pub lon: f64, #[serde(default)] pub accuracy: Option, #[serde(default)] pub activity: Option, pub offset_s: i64, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct LocationPlace { pub id: i32, pub name: String, #[serde(default)] pub category: Option, pub radius_m: i32, } /// The Place stay (run of samples inside one Place) covering an instant. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct LocationStay { pub place_id: i32, #[serde(default)] pub name: Option, pub t0: i64, pub t1: i64, } /// `/api/location/at`: where the owner was at an instant. `sample` is /// `None` when no kept sample lies within `max_gap` — Apollo says "no /// data" rather than handing back a position from hours away. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct LocationAt { pub ts: i64, #[serde(default)] pub sample: Option, #[serde(default)] pub nearest_offset_s: Option, #[serde(default)] pub bracketed: bool, #[serde(default)] pub place: Option, #[serde(default)] pub stay: Option, } /// `/api/location/day`: the owner's timeline for one local day. `text` is /// Apollo's compact rendering (one "HH:MM–HH:MM at Home" line per stay / /// dwell / travel / gap), meant to go into a tool result as-is; the /// structured `items` aren't needed here. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct LocationDay { pub tz: String, pub points: i64, pub km: f64, pub text: String, } #[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() } } } /// Where the owner was at `ts` (real UTC seconds). `None` when the /// integration is disabled or the call fails — callers fall back to /// the local table; `Some` with `sample: None` means Apollo answered /// "no data within `max_gap`", which callers should respect. pub async fn location_at(&self, ts: i64, max_gap: i64) -> Option { let base = self.base_url.as_deref()?; let url = format!("{}/api/location/at", base.trim_end_matches('/')); let result: Result = async { Ok(self .client .get(&url) .query(&[("ts", ts), ("max_gap", max_gap)]) .send() .await? .error_for_status()? .json() .await?) } .await; result .map_err(|err| log::warn!("apollo_client: location_at({ts}) failed: {err}")) .ok() } /// The owner's timeline for `date` (YYYY-MM-DD) as a local day in /// `tz` (IANA). `None` when disabled or the call fails. pub async fn location_day(&self, date: &str, tz: &str) -> Option { let base = self.base_url.as_deref()?; let url = format!("{}/api/location/day", base.trim_end_matches('/')); let result: Result = async { Ok(self .client .get(&url) .query(&[("date", date), ("tz", tz)]) .send() .await? .error_for_status()? .json() .await?) } .await; result .map_err(|err| log::warn!("apollo_client: location_day({date}, {tz}) failed: {err}")) .ok() } 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) } } #[cfg(test)] mod tests { use super::*; #[test] fn location_at_parses_hit_and_miss() { let hit: LocationAt = serde_json::from_str( r#"{"ts": 100, "max_gap": 1800, "sample": {"ts": 120, "lat": 41.46, "lon": -81.93, "accuracy": 19, "activity": "STILL", "offset_s": 20}, "nearest_offset_s": 20, "bracketed": true, "place": {"id": 7, "name": "Work", "category": null, "radius_m": 250}, "stay": {"place_id": 7, "name": "Work", "t0": 50, "t1": 900}}"#, ) .unwrap(); assert_eq!(hit.sample.unwrap().offset_s, 20); assert_eq!(hit.place.unwrap().name, "Work"); assert_eq!(hit.stay.unwrap().t1, 900); let miss: LocationAt = serde_json::from_str( r#"{"ts": 100, "max_gap": 1800, "sample": null, "nearest_offset_s": -7200, "bracketed": false, "place": null, "stay": null}"#, ) .unwrap(); assert!(miss.sample.is_none()); assert_eq!(miss.nearest_offset_s, Some(-7200)); } #[test] fn location_day_ignores_items() { let day: LocationDay = serde_json::from_str( r#"{"start": 0, "end": 86400, "tz": "America/New_York", "items": [{"kind": "gap", "t0": 0, "t1": 1}], "points": 3, "km": 1.5, "text": "00:00–23:59 at Home"}"#, ) .unwrap(); assert_eq!(day.text, "00:00–23:59 at Home"); assert_eq!(day.points, 3); } #[tokio::test] async fn disabled_client_returns_none() { let c = ApolloClient::new(None); assert!(c.location_at(0, 1800).await.is_none()); assert!(c.location_day("2016-01-01", "UTC").await.is_none()); } }