Apollo Places: enrich insights with personal place name + notes

Optional integration with the sibling Apollo project's user-defined
Places (name + lat/lon + radius_m + description + category). When
APOLLO_API_BASE_URL is set, the per-photo location resolver folds the
most-specific containing Place into the LLM prompt's location string —
"Home (My house in Cambridge) — near Cambridge, MA" rather than the
city name alone. Smallest-radius wins; Apollo sorts server-side via
/api/places/contains, so the carousel badge in Apollo and the prompt
string here always agree.

Adds an agentic tool `get_personal_place_at(latitude, longitude)` that
the LLM can call during chat continuation. Tool description tells the
model the call returns the user's free-text notes, not just a name.
Deliberately narrow — no enumerate-all variant, lat/lon required.

Unset APOLLO_API_BASE_URL = legacy Nominatim-only path, tool is not
registered. 5 s timeout; all errors degrade silently.

Tests: 5 unit tests for compose_location_string (Apollo only, Nominatim
only, both, both-with-description, neither).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-04-28 19:11:12 +00:00
parent 9d58547ce3
commit 4ae7be35e9
7 changed files with 339 additions and 20 deletions

112
src/ai/apollo_client.rs Normal file
View File

@@ -0,0 +1,112 @@
//! 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)
}
}