Files
ImageApi/src/ai/apollo_client.rs
T
Cameron CordesandClaude Opus 5.5 e104f452f3 feat(insights): read location context from Apollo (#177)
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>
2026-10-17 04:11:37 +00:00

280 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}