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:
112
src/ai/apollo_client.rs
Normal file
112
src/ai/apollo_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user