The local location_history table is a one-time Takeout import: it ends in 2019, still has the ghost-device and coarse cell-fix samples Apollo has since excluded, and was read loosely. The per-photo line took the nearest sample within +/-3 h at any distance (17% of photo moments got a position >2 km away), and get_location_history listed the first 20 rows of a +/-14-day window sorted ascending (always ~14 days before the date). With APOLLO_API_BASE_URL set: - per-photo context comes from /api/location/at (30 min max gap; no sample -> no line). A Place stay renders as "at Home (14:02-18:30)" in the display zone; an unnamed sample is reverse-geocoded. - get_location_history returns Apollo's /api/location/day timeline for the date (+/-1 day at most), and is offered even with an empty local table. Both fall back to the local table when Apollo is unset or a call fails; the local tool path now covers the requested local day(s) and samples evenly instead of listing a window's first rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
9.7 KiB
Rust
280 lines
9.7 KiB
Rust
//! 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<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>,
|
||
}
|
||
|
||
/// 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<i32>,
|
||
#[serde(default)]
|
||
pub activity: Option<String>,
|
||
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<String>,
|
||
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<String>,
|
||
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<LocationSample>,
|
||
#[serde(default)]
|
||
pub nearest_offset_s: Option<i64>,
|
||
#[serde(default)]
|
||
pub bracketed: bool,
|
||
#[serde(default)]
|
||
pub place: Option<LocationPlace>,
|
||
#[serde(default)]
|
||
pub stay: Option<LocationStay>,
|
||
}
|
||
|
||
/// `/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<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()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<LocationAt> {
|
||
let base = self.base_url.as_deref()?;
|
||
let url = format!("{}/api/location/at", base.trim_end_matches('/'));
|
||
let result: Result<LocationAt> = 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<LocationDay> {
|
||
let base = self.base_url.as_deref()?;
|
||
let url = format!("{}/api/location/day", base.trim_end_matches('/'));
|
||
let result: Result<LocationDay> = 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<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)
|
||
}
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|