Files
ImageApi/src/ai/sms_client.rs
Cameron 6831f50993 feat(ai): USER_NAME env + shared summary prompt + test-bin knobs
Introduces USER_NAME (default "Me") as the single source for the message
sender label and the first-person persona across daily summaries, SMS
context, insight generation, and chat. Eliminates the "Me:" transcript /
"what I did" ambiguity that confused smaller models, and unhardcodes
"Cameron" from prompt text + the knowledge-graph owner entity. Set
USER_NAME=Cameron in .env to preserve the existing owner entity row
(keyed on UNIQUE(name, entity_type)) — otherwise the next run creates
a fresh owner entity and orphans the existing facts/photo-links.

Also:
- search_messages redirect: when the model calls it with date/contact
  but no query, return a hint pointing at get_sms_messages instead of
  a bare missing-parameter error (prevents same-turn retry loops)
- sharpen search_messages vs get_sms_messages tool descriptions so
  content-vs-time-based intent is unambiguous
- extract build_daily_summary_prompt (+ DAILY_SUMMARY_MESSAGE_LIMIT,
  DAILY_SUMMARY_SYSTEM_PROMPT) shared by daily_summary_job and
  test_daily_summary binary — prompt tweaks now land in both
- EMBEDDING_MODEL const; fixes both insert sites that stored
  "mxbai-embed-large:335m" while generate_embeddings actually runs
  "nomic-embed-text:v1.5"
- test_daily_summary: add --num-ctx / --temperature / --top-p /
  --top-k / --min-p flags wired into OllamaClient setters, and print
  the configured knobs at the top of each run
- OllamaClient::generate now logs prompt/gen token counts and tok/s
  via log_chat_metrics (symmetric with chat_with_tools)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:39:37 -04:00

382 lines
12 KiB
Rust

use anyhow::Result;
use reqwest::Client;
use serde::Deserialize;
use super::ollama::OllamaClient;
#[derive(Clone)]
pub struct SmsApiClient {
client: Client,
base_url: String,
token: Option<String>,
}
impl SmsApiClient {
pub fn new(base_url: String, token: Option<String>) -> Self {
Self {
client: Client::new(),
base_url,
token,
}
}
/// Fetch messages for a specific contact within ±4 days of the given timestamp
/// Falls back to all contacts if no messages found for the specific contact
/// Messages are sorted by proximity to the center timestamp
pub async fn fetch_messages_for_contact(
&self,
contact: Option<&str>,
center_timestamp: i64,
) -> Result<Vec<SmsMessage>> {
use chrono::Duration;
// Calculate ±4 days range around the center timestamp
let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0)
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?;
let start_dt = center_dt - Duration::days(4);
let end_dt = center_dt + Duration::days(4);
let start_ts = start_dt.timestamp();
let end_ts = end_dt.timestamp();
// If contact specified, try fetching for that contact first
if let Some(contact_name) = contact {
log::info!(
"Fetching SMS for contact: {} (±4 days from {})",
contact_name,
center_dt.format("%Y-%m-%d %H:%M:%S")
);
let messages = self
.fetch_messages(start_ts, end_ts, Some(contact_name), Some(center_timestamp))
.await?;
if !messages.is_empty() {
log::info!(
"Found {} messages for contact {}",
messages.len(),
contact_name
);
return Ok(messages);
}
log::info!(
"No messages found for contact {}, falling back to all contacts",
contact_name
);
}
// Fallback to all contacts
log::info!(
"Fetching all SMS messages (±4 days from {})",
center_dt.format("%Y-%m-%d %H:%M:%S")
);
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
.await
}
/// Fetch all messages for a specific contact across all time
/// Used for embedding generation - retrieves complete message history
/// Handles pagination automatically if the API returns a limited number of results
pub async fn fetch_all_messages_for_contact(&self, contact: &str) -> Result<Vec<SmsMessage>> {
let start_ts = chrono::DateTime::parse_from_rfc3339("2000-01-01T00:00:00Z")
.unwrap()
.timestamp();
let end_ts = chrono::Utc::now().timestamp();
log::info!("Fetching all historical messages for contact: {}", contact);
let mut all_messages = Vec::new();
let mut offset = 0;
let limit = 1000; // Fetch in batches of 1000
loop {
log::debug!(
"Fetching batch at offset {} for contact {}",
offset,
contact
);
let batch = self
.fetch_messages_paginated(start_ts, end_ts, Some(contact), None, limit, offset)
.await?;
let batch_size = batch.len();
all_messages.extend(batch);
log::debug!(
"Fetched {} messages (total so far: {})",
batch_size,
all_messages.len()
);
// If we got fewer messages than the limit, we've reached the end
if batch_size < limit {
break;
}
offset += limit;
}
log::info!(
"Fetched {} total messages for contact {}",
all_messages.len(),
contact
);
Ok(all_messages)
}
/// Internal method to fetch messages with pagination support
async fn fetch_messages_paginated(
&self,
start_ts: i64,
end_ts: i64,
contact: Option<&str>,
center_timestamp: Option<i64>,
limit: usize,
offset: usize,
) -> Result<Vec<SmsMessage>> {
let mut url = format!(
"{}/api/messages/by-date-range/?start_date={}&end_date={}&limit={}&offset={}",
self.base_url, start_ts, end_ts, limit, offset
);
if let Some(contact_name) = contact {
url.push_str(&format!("&contact={}", urlencoding::encode(contact_name)));
}
if let Some(ts) = center_timestamp {
url.push_str(&format!("&timestamp={}", ts));
}
log::debug!("Fetching SMS messages from: {}", url);
let mut request = self.client.get(&url);
if let Some(token) = &self.token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request.send().await?;
log::debug!("SMS API response status: {}", response.status());
if !response.status().is_success() {
let status = response.status();
let error_body = response.text().await.unwrap_or_default();
log::error!("SMS API request failed: {} - {}", status, error_body);
return Err(anyhow::anyhow!(
"SMS API request failed: {} - {}",
status,
error_body
));
}
let data: SmsApiResponse = response.json().await?;
Ok(data
.messages
.into_iter()
.map(|m| SmsMessage {
contact: m.contact_name,
body: m.body,
timestamp: m.date,
is_sent: m.type_ == 2,
})
.collect())
}
/// Internal method to fetch messages with optional contact filter and timestamp sorting
async fn fetch_messages(
&self,
start_ts: i64,
end_ts: i64,
contact: Option<&str>,
center_timestamp: Option<i64>,
) -> Result<Vec<SmsMessage>> {
// Call Django endpoint
let mut url = format!(
"{}/api/messages/by-date-range/?start_date={}&end_date={}",
self.base_url, start_ts, end_ts
);
// Add contact filter if provided
if let Some(contact_name) = contact {
url.push_str(&format!("&contact={}", urlencoding::encode(contact_name)));
}
// Add timestamp for proximity sorting if provided
if let Some(ts) = center_timestamp {
url.push_str(&format!("&timestamp={}", ts));
}
log::debug!("Fetching SMS messages from: {}", url);
let mut request = self.client.get(&url);
// Add authorization header if token exists
if let Some(token) = &self.token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request.send().await?;
log::debug!("SMS API response status: {}", response.status());
if !response.status().is_success() {
let status = response.status();
let error_body = response.text().await.unwrap_or_default();
log::error!("SMS API request failed: {} - {}", status, error_body);
return Err(anyhow::anyhow!(
"SMS API request failed: {} - {}",
status,
error_body
));
}
let data: SmsApiResponse = response.json().await?;
// Convert to internal format
Ok(data
.messages
.into_iter()
.map(|m| SmsMessage {
contact: m.contact_name,
body: m.body,
timestamp: m.date,
is_sent: m.type_ == 2, // type 2 = sent
})
.collect())
}
/// Search message bodies via the Django side's FTS5 / semantic / hybrid
/// endpoint. `mode` selects the ranking strategy:
/// - "fts5" keyword-only, supports phrase / prefix / boolean / NEAR
/// - "semantic" embedding similarity
/// - "hybrid" both merged via reciprocal rank fusion (recommended)
pub async fn search_messages(
&self,
query: &str,
mode: &str,
limit: usize,
) -> Result<Vec<SmsSearchHit>> {
let url = format!(
"{}/api/messages/search/?q={}&mode={}&limit={}",
self.base_url,
urlencoding::encode(query),
urlencoding::encode(mode),
limit
);
let mut request = self.client.get(&url);
if let Some(token) = &self.token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request.send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"SMS search request failed: {} - {}",
status,
body
));
}
let data: SmsSearchResponse = response.json().await?;
Ok(data.results)
}
pub async fn summarize_context(
&self,
messages: &[SmsMessage],
ollama: &OllamaClient,
) -> Result<String> {
if messages.is_empty() {
return Ok(String::from("No messages on this day"));
}
// Create prompt for Ollama with sender/receiver distinction
let user_name = crate::ai::user_display_name();
let messages_text: String = messages
.iter()
.take(60) // Limit to avoid token overflow
.map(|m| {
if m.is_sent {
format!("{}: {}", user_name, m.body)
} else {
format!("{}: {}", m.contact, m.body)
}
})
.collect::<Vec<_>>()
.join("\n");
let prompt = format!(
r#"Summarize these messages in up to 4-5 sentences. Focus on key topics, places, people mentioned, and the overall context of the conversations.
Messages:
{}
Summary:"#,
messages_text
);
ollama
.generate(
&prompt,
// Some("You are a summarizer for the purposes of jogging my memory and highlighting events and situations."),
Some("You are the keeper of memories, ingest the context and give me a casual summary of the moment."),
)
.await
}
}
#[derive(Debug, Clone)]
pub struct SmsMessage {
pub contact: String,
pub body: String,
pub timestamp: i64,
pub is_sent: bool,
}
#[derive(Deserialize)]
struct SmsApiResponse {
messages: Vec<SmsApiMessage>,
}
#[derive(Deserialize)]
struct SmsApiMessage {
contact_name: String,
body: String,
date: i64,
#[serde(rename = "type")]
type_: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SmsSearchHit {
#[allow(dead_code)]
pub message_id: i64,
pub contact_name: String,
#[allow(dead_code)]
pub contact_address: String,
pub body: String,
pub date: i64,
/// Message direction code: 1 = received, 2 = sent.
#[serde(rename = "type")]
pub type_: i32,
/// Present for semantic / hybrid modes; absent for fts5.
#[serde(default)]
pub similarity_score: Option<f32>,
}
#[derive(Deserialize)]
struct SmsSearchResponse {
results: Vec<SmsSearchHit>,
#[allow(dead_code)]
#[serde(default)]
search_method: String,
}