Add Insights Model Discovery and Fallback Handling

This commit is contained in:
Cameron
2026-01-03 20:27:34 -05:00
parent 1171f19845
commit cf52d4ab76
10 changed files with 419 additions and 80 deletions

View File

@@ -250,8 +250,31 @@ Optional:
WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval
WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval
OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds) OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds)
# AI Insights Configuration
OLLAMA_PRIMARY_URL=http://desktop:11434 # Primary Ollama server (e.g., desktop)
OLLAMA_FALLBACK_URL=http://server:11434 # Fallback Ollama server (optional, always-on)
OLLAMA_PRIMARY_MODEL=nemotron-3-nano:30b # Model for primary server (default: nemotron-3-nano:30b)
OLLAMA_FALLBACK_MODEL=llama3.2:3b # Model for fallback server (optional, uses primary if not set)
SMS_API_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000)
SMS_API_TOKEN=your-api-token # SMS API authentication token (optional)
``` ```
**AI Insights Fallback Behavior:**
- Primary server is tried first with its configured model (5-second connection timeout)
- On connection failure, automatically falls back to secondary server with its model (if configured)
- If `OLLAMA_FALLBACK_MODEL` not set, uses same model as primary server on fallback
- Total request timeout is 120 seconds to accommodate slow LLM inference
- Logs indicate which server and model was used (info level) and failover attempts (warn level)
- Backwards compatible: `OLLAMA_URL` and `OLLAMA_MODEL` still supported as fallbacks
**Model Discovery:**
The `OllamaClient` provides methods to query available models:
- `OllamaClient::list_models(url)` - Returns list of all models on a server
- `OllamaClient::is_model_available(url, model_name)` - Checks if a specific model exists
This allows runtime verification of model availability before generating insights.
## Dependencies of Note ## Dependencies of Note
- **actix-web**: HTTP framework - **actix-web**: HTTP framework

View File

@@ -1,13 +1,16 @@
use actix_web::{HttpResponse, Responder, delete, get, post, web}; use actix_web::{HttpResponse, Responder, delete, get, post, web};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ai::InsightGenerator; use crate::ai::{InsightGenerator, OllamaClient};
use crate::data::Claims; use crate::data::Claims;
use crate::database::InsightDao; use crate::database::InsightDao;
use crate::utils::normalize_path;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct GeneratePhotoInsightRequest { pub struct GeneratePhotoInsightRequest {
pub file_path: String, pub file_path: String,
#[serde(default)]
pub model: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -25,6 +28,20 @@ pub struct PhotoInsightResponse {
pub model_version: String, pub model_version: String,
} }
#[derive(Debug, Serialize)]
pub struct AvailableModelsResponse {
pub primary: ServerModels,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback: Option<ServerModels>,
}
#[derive(Debug, Serialize)]
pub struct ServerModels {
pub url: String,
pub models: Vec<String>,
pub default_model: String,
}
/// POST /insights/generate - Generate insight for a specific photo /// POST /insights/generate - Generate insight for a specific photo
#[post("/insights/generate")] #[post("/insights/generate")]
pub async fn generate_insight_handler( pub async fn generate_insight_handler(
@@ -32,14 +49,17 @@ pub async fn generate_insight_handler(
request: web::Json<GeneratePhotoInsightRequest>, request: web::Json<GeneratePhotoInsightRequest>,
insight_generator: web::Data<InsightGenerator>, insight_generator: web::Data<InsightGenerator>,
) -> impl Responder { ) -> impl Responder {
let normalized_path = normalize_path(&request.file_path);
log::info!( log::info!(
"Manual insight generation triggered for photo: {}", "Manual insight generation triggered for photo: {} with model: {:?}",
request.file_path normalized_path,
request.model
); );
// Generate insight // Generate insight with optional custom model
match insight_generator match insight_generator
.generate_insight_for_photo(&request.file_path) .generate_insight_for_photo_with_model(&normalized_path, request.model.clone())
.await .await
{ {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ Ok(()) => HttpResponse::Ok().json(serde_json::json!({
@@ -62,12 +82,13 @@ pub async fn get_insight_handler(
query: web::Query<GetPhotoInsightQuery>, query: web::Query<GetPhotoInsightQuery>,
insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>, insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>,
) -> impl Responder { ) -> impl Responder {
log::debug!("Fetching insight for {}", query.path); let normalized_path = normalize_path(&query.path);
log::debug!("Fetching insight for {}", normalized_path);
let otel_context = opentelemetry::Context::new(); let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
match dao.get_insight(&otel_context, &query.path) { match dao.get_insight(&otel_context, &normalized_path) {
Ok(Some(insight)) => { Ok(Some(insight)) => {
let response = PhotoInsightResponse { let response = PhotoInsightResponse {
id: insight.id, id: insight.id,
@@ -98,12 +119,13 @@ pub async fn delete_insight_handler(
query: web::Query<GetPhotoInsightQuery>, query: web::Query<GetPhotoInsightQuery>,
insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>, insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>,
) -> impl Responder { ) -> impl Responder {
log::info!("Deleting insight for {}", query.path); let normalized_path = normalize_path(&query.path);
log::info!("Deleting insight for {}", normalized_path);
let otel_context = opentelemetry::Context::new(); let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
match dao.delete_insight(&otel_context, &query.path) { match dao.delete_insight(&otel_context, &normalized_path) {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ Ok(()) => HttpResponse::Ok().json(serde_json::json!({
"success": true, "success": true,
"message": "Insight deleted successfully" "message": "Insight deleted successfully"
@@ -152,3 +174,53 @@ pub async fn get_all_insights_handler(
} }
} }
} }
/// GET /insights/models - List available models from both servers
#[get("/insights/models")]
pub async fn get_available_models_handler(
_claims: Claims,
app_state: web::Data<crate::state::AppState>,
) -> impl Responder {
log::debug!("Fetching available models");
let ollama_client = &app_state.ollama;
// Fetch models from primary server
let primary_models = match OllamaClient::list_models(&ollama_client.primary_url).await {
Ok(models) => models,
Err(e) => {
log::warn!("Failed to fetch models from primary server: {:?}", e);
vec![]
}
};
let primary = ServerModels {
url: ollama_client.primary_url.clone(),
models: primary_models,
default_model: ollama_client.primary_model.clone(),
};
// Fetch models from fallback server if configured
let fallback = if let Some(fallback_url) = &ollama_client.fallback_url {
match OllamaClient::list_models(fallback_url).await {
Ok(models) => Some(ServerModels {
url: fallback_url.clone(),
models,
default_model: ollama_client
.fallback_model
.clone()
.unwrap_or_else(|| ollama_client.primary_model.clone()),
}),
Err(e) => {
log::warn!("Failed to fetch models from fallback server: {:?}", e);
None
}
}
} else {
None
};
let response = AvailableModelsResponse { primary, fallback };
HttpResponse::Ok().json(response)
}

View File

@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use chrono::Utc; use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
use std::fs::File;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::ai::ollama::OllamaClient; use crate::ai::ollama::OllamaClient;
@@ -8,6 +9,7 @@ use crate::ai::sms_client::SmsApiClient;
use crate::database::models::InsertPhotoInsight; use crate::database::models::InsertPhotoInsight;
use crate::database::{ExifDao, InsightDao}; use crate::database::{ExifDao, InsightDao};
use crate::memories::extract_date_from_filename; use crate::memories::extract_date_from_filename;
use crate::utils::normalize_path;
#[derive(Deserialize)] #[derive(Deserialize)]
struct NominatimResponse { struct NominatimResponse {
@@ -20,9 +22,7 @@ struct NominatimAddress {
city: Option<String>, city: Option<String>,
town: Option<String>, town: Option<String>,
village: Option<String>, village: Option<String>,
county: Option<String>,
state: Option<String>, state: Option<String>,
country: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -31,6 +31,7 @@ pub struct InsightGenerator {
sms_client: SmsApiClient, sms_client: SmsApiClient,
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>, insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>, exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
base_path: String,
} }
impl InsightGenerator { impl InsightGenerator {
@@ -39,12 +40,14 @@ impl InsightGenerator {
sms_client: SmsApiClient, sms_client: SmsApiClient,
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>, insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>, exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
base_path: String,
) -> Self { ) -> Self {
Self { Self {
ollama, ollama,
sms_client, sms_client,
insight_dao, insight_dao,
exif_dao, exif_dao,
base_path,
} }
} }
@@ -69,16 +72,35 @@ impl InsightGenerator {
None None
} }
/// Generate AI insight for a single photo /// Generate AI insight for a single photo with optional custom model
pub async fn generate_insight_for_photo(&self, file_path: &str) -> Result<()> { pub async fn generate_insight_for_photo_with_model(
&self,
file_path: &str,
custom_model: Option<String>,
) -> Result<()> {
// Normalize path to ensure consistent forward slashes in database
let file_path = normalize_path(file_path);
log::info!("Generating insight for photo: {}", file_path); log::info!("Generating insight for photo: {}", file_path);
// Create custom Ollama client if model is specified
let ollama_client = if let Some(model) = custom_model {
log::info!("Using custom model: {}", model);
OllamaClient::new(
self.ollama.primary_url.clone(),
self.ollama.fallback_url.clone(),
model.clone(),
Some(model), // Use the same custom model for fallback server
)
} else {
self.ollama.clone()
};
// 1. Get EXIF data for the photo // 1. Get EXIF data for the photo
let otel_context = opentelemetry::Context::new(); let otel_context = opentelemetry::Context::new();
let exif = { let exif = {
let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
exif_dao exif_dao
.get_exif(&otel_context, file_path) .get_exif(&otel_context, &file_path)
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
}; };
@@ -88,17 +110,33 @@ impl InsightGenerator {
} else { } else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path); log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(file_path) extract_date_from_filename(&file_path)
.map(|dt| dt.timestamp()) .map(|dt| dt.timestamp())
.or_else(|| {
// Combine base_path with file_path to get full path
let full_path = std::path::Path::new(&self.base_path).join(&file_path);
File::open(&full_path)
.and_then(|f| f.metadata())
.and_then(|m| m.created().or(m.modified()))
.map(|t| DateTime::<Utc>::from(t).timestamp())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for insight {}: {}",
file_path,
e
)
})
.ok()
})
.unwrap_or_else(|| Utc::now().timestamp()) .unwrap_or_else(|| Utc::now().timestamp())
}; };
let date_taken = chrono::DateTime::from_timestamp(timestamp, 0) let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive()) .map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive()); .unwrap_or_else(|| Utc::now().date_naive());
// 3. Extract contact name from file path // 3. Extract contact name from file path
let contact = Self::extract_contact_from_path(file_path); let contact = Self::extract_contact_from_path(&file_path);
log::info!("Extracted contact from path: {:?}", contact); log::info!("Extracted contact from path: {:?}", contact);
// 4. Fetch SMS messages for the contact (±1 day) // 4. Fetch SMS messages for the contact (±1 day)
@@ -124,7 +162,7 @@ impl InsightGenerator {
let sms_summary = if !sms_messages.is_empty() { let sms_summary = if !sms_messages.is_empty() {
match self match self
.sms_client .sms_client
.summarize_context(&sms_messages, &self.ollama) .summarize_context(&sms_messages, &ollama_client)
.await .await
{ {
Ok(summary) => Some(summary), Ok(summary) => Some(summary),
@@ -157,13 +195,11 @@ impl InsightGenerator {
); );
// 7. Generate title and summary with Ollama // 7. Generate title and summary with Ollama
let title = self let title = ollama_client
.ollama
.generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref()) .generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref())
.await?; .await?;
let summary = self let summary = ollama_client
.ollama
.generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref()) .generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref())
.await?; .await?;
@@ -176,7 +212,7 @@ impl InsightGenerator {
title, title,
summary, summary,
generated_at: Utc::now().timestamp(), generated_at: Utc::now().timestamp(),
model_version: self.ollama.model.clone(), model_version: ollama_client.primary_model.clone(),
}; };
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");

View File

@@ -4,7 +4,8 @@ pub mod ollama;
pub mod sms_client; pub mod sms_client;
pub use handlers::{ pub use handlers::{
delete_insight_handler, generate_insight_handler, get_all_insights_handler, get_insight_handler, delete_insight_handler, generate_insight_handler, get_all_insights_handler,
get_available_models_handler, get_insight_handler,
}; };
pub use insight_generator::InsightGenerator; pub use insight_generator::InsightGenerator;
pub use ollama::OllamaClient; pub use ollama::OllamaClient;

View File

@@ -2,25 +2,60 @@ use anyhow::Result;
use chrono::NaiveDate; use chrono::NaiveDate;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::memories::MemoryItem;
#[derive(Clone)] #[derive(Clone)]
pub struct OllamaClient { pub struct OllamaClient {
client: Client, client: Client,
pub base_url: String, pub primary_url: String,
pub model: String, pub fallback_url: Option<String>,
pub primary_model: String,
pub fallback_model: Option<String>,
} }
impl OllamaClient { impl OllamaClient {
pub fn new(base_url: String, model: String) -> Self { pub fn new(
primary_url: String,
fallback_url: Option<String>,
primary_model: String,
fallback_model: Option<String>,
) -> Self {
Self { Self {
client: Client::new(), client: Client::builder()
base_url, .connect_timeout(Duration::from_secs(5)) // Quick connection timeout
model, .timeout(Duration::from_secs(120)) // Total request timeout for generation
.build()
.unwrap_or_else(|_| Client::new()),
primary_url,
fallback_url,
primary_model,
fallback_model,
} }
} }
/// List available models on an Ollama server
pub async fn list_models(url: &str) -> Result<Vec<String>> {
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.build()?;
let response = client.get(&format!("{}/api/tags", url)).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to list models from {}", url));
}
let tags_response: OllamaTagsResponse = response.json().await?;
Ok(tags_response.models.into_iter().map(|m| m.name).collect())
}
/// Check if a model is available on a server
pub async fn is_model_available(url: &str, model_name: &str) -> Result<bool> {
let models = Self::list_models(url).await?;
Ok(models.iter().any(|m| m == model_name))
}
/// Extract final answer from thinking model output /// Extract final answer from thinking model output
/// Handles <think>...</think> tags and takes everything after /// Handles <think>...</think> tags and takes everything after
fn extract_final_answer(&self, response: &str) -> String { fn extract_final_answer(&self, response: &str) -> String {
@@ -38,17 +73,15 @@ impl OllamaClient {
response.to_string() response.to_string()
} }
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> { async fn try_generate(
log::debug!("=== Ollama Request ==="); &self,
log::debug!("Model: {}", self.model); url: &str,
if let Some(sys) = system { model: &str,
log::debug!("System: {}", sys); prompt: &str,
} system: Option<&str>,
log::debug!("Prompt:\n{}", prompt); ) -> Result<String> {
log::debug!("=====================");
let request = OllamaRequest { let request = OllamaRequest {
model: self.model.clone(), model: model.to_string(),
prompt: prompt.to_string(), prompt: prompt.to_string(),
stream: false, stream: false,
system: system.map(|s| s.to_string()), system: system.map(|s| s.to_string()),
@@ -56,7 +89,7 @@ impl OllamaClient {
let response = self let response = self
.client .client
.post(&format!("{}/api/generate", self.base_url)) .post(&format!("{}/api/generate", url))
.json(&request) .json(&request)
.send() .send()
.await?; .await?;
@@ -64,7 +97,6 @@ impl OllamaClient {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let error_body = response.text().await.unwrap_or_default(); let error_body = response.text().await.unwrap_or_default();
log::error!("Ollama request failed: {} - {}", status, error_body);
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Ollama request failed: {} - {}", "Ollama request failed: {} - {}",
status, status,
@@ -73,13 +105,77 @@ impl OllamaClient {
} }
let result: OllamaResponse = response.json().await?; let result: OllamaResponse = response.json().await?;
Ok(result.response)
}
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
log::debug!("=== Ollama Request ===");
log::debug!("Primary model: {}", self.primary_model);
if let Some(sys) = system {
log::debug!("System: {}", sys);
}
log::debug!("Prompt:\n{}", prompt);
log::debug!("=====================");
// Try primary server first with primary model
log::info!(
"Attempting to generate with primary server: {} (model: {})",
self.primary_url,
self.primary_model
);
let primary_result = self
.try_generate(&self.primary_url, &self.primary_model, prompt, system)
.await;
let raw_response = match primary_result {
Ok(response) => {
log::info!("Successfully generated response from primary server");
response
}
Err(e) => {
log::warn!("Primary server failed: {}", e);
// Try fallback server if available
if let Some(fallback_url) = &self.fallback_url {
// Use fallback model if specified, otherwise use primary model
let fallback_model =
self.fallback_model.as_ref().unwrap_or(&self.primary_model);
log::info!(
"Attempting to generate with fallback server: {} (model: {})",
fallback_url,
fallback_model
);
match self
.try_generate(fallback_url, fallback_model, prompt, system)
.await
{
Ok(response) => {
log::info!("Successfully generated response from fallback server");
response
}
Err(fallback_e) => {
log::error!("Fallback server also failed: {}", fallback_e);
return Err(anyhow::anyhow!(
"Both primary and fallback servers failed. Primary: {}, Fallback: {}",
e,
fallback_e
));
}
}
} else {
log::error!("No fallback server configured");
return Err(e);
}
}
};
log::debug!("=== Ollama Response ==="); log::debug!("=== Ollama Response ===");
log::debug!("Raw response: {}", result.response.trim()); log::debug!("Raw response: {}", raw_response.trim());
log::debug!("======================="); log::debug!("=======================");
// Extract final answer from thinking model output // Extract final answer from thinking model output
let cleaned = self.extract_final_answer(&result.response); let cleaned = self.extract_final_answer(&raw_response);
log::debug!("=== Cleaned Response ==="); log::debug!("=== Cleaned Response ===");
log::debug!("Final answer: {}", cleaned); log::debug!("Final answer: {}", cleaned);
@@ -99,7 +195,7 @@ impl OllamaClient {
let sms_str = sms_summary.unwrap_or("No messages"); let sms_str = sms_summary.unwrap_or("No messages");
let prompt = format!( let prompt = format!(
r#"Create a short title (maximum 8 words) for this photo: r#"Create a short title (maximum 8 words) about this moment:
Date: {} Date: {}
Location: {} Location: {}
@@ -113,8 +209,7 @@ Return ONLY the title, nothing else."#,
sms_str sms_str
); );
let system = let system = "You are my long term memory assistant. Use only the information provided. Do not invent details.";
"You are a memory assistant. Use only the information provided. Do not invent details.";
let title = self.generate(&prompt, Some(system)).await?; let title = self.generate(&prompt, Some(system)).await?;
Ok(title.trim().trim_matches('"').to_string()) Ok(title.trim().trim_matches('"').to_string())
@@ -127,7 +222,7 @@ Return ONLY the title, nothing else."#,
location: Option<&str>, location: Option<&str>,
sms_summary: Option<&str>, sms_summary: Option<&str>,
) -> Result<String> { ) -> Result<String> {
let location_str = location.unwrap_or("somewhere"); let location_str = location.unwrap_or("Unknown");
let sms_str = sms_summary.unwrap_or("No messages"); let sms_str = sms_summary.unwrap_or("No messages");
let prompt = format!( let prompt = format!(
@@ -137,7 +232,7 @@ Date: {}
Location: {} Location: {}
Messages: {} Messages: {}
Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cam in a casual but fluent tone. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#,
date.format("%B %d, %Y"), date.format("%B %d, %Y"),
location_str, location_str,
sms_str sms_str
@@ -147,15 +242,6 @@ Use only the specific details provided above. Mention people's names, places, or
self.generate(&prompt, Some(system)).await self.generate(&prompt, Some(system)).await
} }
}
pub struct MemoryContext {
pub date: NaiveDate,
pub photos: Vec<MemoryItem>,
pub sms_summary: Option<String>,
pub locations: Vec<String>,
pub cameras: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -171,3 +257,13 @@ struct OllamaRequest {
struct OllamaResponse { struct OllamaResponse {
response: String, response: String,
} }
#[derive(Deserialize)]
struct OllamaTagsResponse {
models: Vec<OllamaModel>,
}
#[derive(Deserialize)]
struct OllamaModel {
name: String,
}

View File

@@ -18,6 +18,7 @@ pub mod state;
pub mod tags; pub mod tags;
#[cfg(test)] #[cfg(test)]
pub mod testhelpers; pub mod testhelpers;
pub mod utils;
pub mod video; pub mod video;
// Re-export commonly used types // Re-export commonly used types

View File

@@ -46,7 +46,6 @@ use crate::tags::*;
use crate::video::actors::{ use crate::video::actors::{
ProcessMessage, ScanDirectoryMessage, create_playlist, generate_video_thumbnail, ProcessMessage, ScanDirectoryMessage, create_playlist, generate_video_thumbnail,
}; };
use crate::video::generate_video_gifs;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::{KeyValue, global}; use opentelemetry::{KeyValue, global};
@@ -62,6 +61,7 @@ mod files;
mod geo; mod geo;
mod state; mod state;
mod tags; mod tags;
mod utils;
mod video; mod video;
mod memories; mod memories;
@@ -802,6 +802,7 @@ fn main() -> std::io::Result<()> {
.service(ai::get_insight_handler) .service(ai::get_insight_handler)
.service(ai::delete_insight_handler) .service(ai::delete_insight_handler)
.service(ai::get_all_insights_handler) .service(ai::get_all_insights_handler)
.service(ai::get_available_models_handler)
.add_feature(add_tag_services::<_, SqliteTagDao>) .add_feature(add_tag_services::<_, SqliteTagDao>)
.app_data(app_data.clone()) .app_data(app_data.clone())
.app_data::<Data<RealFileSystem>>(Data::new(RealFileSystem::new( .app_data::<Data<RealFileSystem>>(Data::new(RealFileSystem::new(

View File

@@ -65,10 +65,21 @@ impl AppState {
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
// Initialize AI clients // Initialize AI clients
let ollama_url = let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| {
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string())
let ollama_model = env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.2".to_string()); });
let ollama = OllamaClient::new(ollama_url, ollama_model); let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok();
let ollama_primary_model = env::var("OLLAMA_PRIMARY_MODEL")
.or_else(|_| env::var("OLLAMA_MODEL"))
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string());
let ollama_fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok();
let ollama = OllamaClient::new(
ollama_primary_url,
ollama_fallback_url,
ollama_primary_model,
ollama_fallback_model,
);
let sms_api_url = let sms_api_url =
env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
@@ -81,17 +92,21 @@ impl Default for AppState {
let exif_dao: Arc<Mutex<Box<dyn ExifDao>>> = let exif_dao: Arc<Mutex<Box<dyn ExifDao>>> =
Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
// Load base path
let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
// Initialize InsightGenerator // Initialize InsightGenerator
let insight_generator = InsightGenerator::new( let insight_generator = InsightGenerator::new(
ollama.clone(), ollama.clone(),
sms_client.clone(), sms_client.clone(),
insight_dao.clone(), insight_dao.clone(),
exif_dao.clone(), exif_dao.clone(),
base_path.clone(),
); );
Self::new( Self::new(
Arc::new(StreamActor {}.start()), Arc::new(StreamActor {}.start()),
env::var("BASE_PATH").expect("BASE_PATH was not set in the env"), base_path,
env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"), env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"),
env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"), env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"),
env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"), env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"),
@@ -119,8 +134,12 @@ impl AppState {
let gif_path = create_test_subdir(&base_path, "gifs"); let gif_path = create_test_subdir(&base_path, "gifs");
// Initialize test AI clients // Initialize test AI clients
let ollama = let ollama = OllamaClient::new(
OllamaClient::new("http://localhost:11434".to_string(), "llama3.2".to_string()); "http://localhost:11434".to_string(),
None,
"llama3.2".to_string(),
None,
);
let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None); let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None);
// Initialize test DAOs // Initialize test DAOs
@@ -130,17 +149,19 @@ impl AppState {
Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
// Initialize test InsightGenerator // Initialize test InsightGenerator
let base_path_str = base_path.to_string_lossy().to_string();
let insight_generator = InsightGenerator::new( let insight_generator = InsightGenerator::new(
ollama.clone(), ollama.clone(),
sms_client.clone(), sms_client.clone(),
insight_dao.clone(), insight_dao.clone(),
exif_dao.clone(), exif_dao.clone(),
base_path_str.clone(),
); );
// Create the AppState with the temporary paths // Create the AppState with the temporary paths
AppState::new( AppState::new(
Arc::new(StreamActor {}.start()), Arc::new(StreamActor {}.start()),
base_path.to_string_lossy().to_string(), base_path_str,
thumbnail_path.to_string_lossy().to_string(), thumbnail_path.to_string_lossy().to_string(),
video_path.to_string_lossy().to_string(), video_path.to_string_lossy().to_string(),
gif_path.to_string_lossy().to_string(), gif_path.to_string_lossy().to_string(),

View File

@@ -1,5 +1,6 @@
use crate::data::GetTagsRequest; use crate::data::GetTagsRequest;
use crate::otel::{extract_context_from_request, global_tracer, trace_db_call}; use crate::otel::{extract_context_from_request, global_tracer, trace_db_call};
use crate::utils::normalize_path;
use crate::{Claims, ThumbnailRequest, connect, data::AddTagRequest, error::IntoHttpError, schema}; use crate::{Claims, ThumbnailRequest, connect, data::AddTagRequest, error::IntoHttpError, schema};
use actix_web::dev::{ServiceFactory, ServiceRequest}; use actix_web::dev::{ServiceFactory, ServiceRequest};
use actix_web::{App, HttpRequest, HttpResponse, Responder, web}; use actix_web::{App, HttpRequest, HttpResponse, Responder, web};
@@ -41,6 +42,7 @@ async fn add_tag<D: TagDao>(
let span = tracer.start_with_context("add_tag", &context); let span = tracer.start_with_context("add_tag", &context);
let span_context = opentelemetry::Context::current_with_span(span); let span_context = opentelemetry::Context::current_with_span(span);
let tag_name = body.tag_name.clone(); let tag_name = body.tag_name.clone();
let normalized_path = normalize_path(&body.file_name);
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
@@ -52,12 +54,12 @@ async fn add_tag<D: TagDao>(
} else { } else {
info!( info!(
"Creating missing tag: '{:?}' for file: '{}'", "Creating missing tag: '{:?}' for file: '{}'",
tag_name, &body.file_name tag_name, &normalized_path
); );
tag_dao.create_tag(&span_context, tag_name.trim()) tag_dao.create_tag(&span_context, tag_name.trim())
} }
}) })
.and_then(|tag| tag_dao.tag_file(&span_context, &body.file_name, tag.id)) .and_then(|tag| tag_dao.tag_file(&span_context, &normalized_path, tag.id))
.map(|_| { .map(|_| {
span_context.span().set_status(Status::Ok); span_context.span().set_status(Status::Ok);
HttpResponse::Ok() HttpResponse::Ok()
@@ -74,9 +76,10 @@ async fn get_tags<D: TagDao>(
let context = extract_context_from_request(&http_request); let context = extract_context_from_request(&http_request);
let span = global_tracer().start_with_context("get_tags", &context); let span = global_tracer().start_with_context("get_tags", &context);
let span_context = opentelemetry::Context::current_with_span(span); let span_context = opentelemetry::Context::current_with_span(span);
let normalized_path = normalize_path(&request.path);
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
tag_dao tag_dao
.get_tags_for_path(&span_context, &request.path) .get_tags_for_path(&span_context, &normalized_path)
.map(|tags| { .map(|tags| {
span_context.span().set_status(Status::Ok); span_context.span().set_status(Status::Ok);
HttpResponse::Ok().json(tags) HttpResponse::Ok().json(tags)
@@ -139,10 +142,11 @@ async fn remove_tagged_photo<D: TagDao>(
let context = extract_context_from_request(&http_request); let context = extract_context_from_request(&http_request);
let span = global_tracer().start_with_context("remove_tagged_photo", &context); let span = global_tracer().start_with_context("remove_tagged_photo", &context);
let span_context = opentelemetry::Context::current_with_span(span); let span_context = opentelemetry::Context::current_with_span(span);
let normalized_path = normalize_path(&request.file_name);
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
tag_dao tag_dao
.remove_tag(&span_context, &request.tag_name, &request.file_name) .remove_tag(&span_context, &request.tag_name, &normalized_path)
.map(|result| { .map(|result| {
span_context.span().set_status(Status::Ok); span_context.span().set_status(Status::Ok);
@@ -165,8 +169,9 @@ async fn update_tags<D: TagDao>(
let context = extract_context_from_request(&http_request); let context = extract_context_from_request(&http_request);
let span = global_tracer().start_with_context("update_tags", &context); let span = global_tracer().start_with_context("update_tags", &context);
let span_context = opentelemetry::Context::current_with_span(span); let span_context = opentelemetry::Context::current_with_span(span);
let normalized_path = normalize_path(&request.file_name);
dao.get_tags_for_path(&span_context, &request.file_name) dao.get_tags_for_path(&span_context, &normalized_path)
.and_then(|existing_tags| { .and_then(|existing_tags| {
dao.get_all_tags(&span_context, None) dao.get_all_tags(&span_context, None)
.map(|all| (existing_tags, all)) .map(|all| (existing_tags, all))
@@ -180,9 +185,9 @@ async fn update_tags<D: TagDao>(
for tag in tags_to_remove { for tag in tags_to_remove {
info!( info!(
"Removing tag {:?} from file: {:?}", "Removing tag {:?} from file: {:?}",
tag.name, request.file_name tag.name, normalized_path
); );
dao.remove_tag(&span_context, &tag.name, &request.file_name) dao.remove_tag(&span_context, &tag.name, &normalized_path)
.unwrap_or_else(|err| panic!("{:?} Unable to remove tag {:?}", err, &tag.name)); .unwrap_or_else(|err| panic!("{:?} Unable to remove tag {:?}", err, &tag.name));
} }
@@ -194,14 +199,14 @@ async fn update_tags<D: TagDao>(
for (_, new_tag) in new_tags { for (_, new_tag) in new_tags {
info!( info!(
"Adding tag {:?} to file: {:?}", "Adding tag {:?} to file: {:?}",
new_tag.name, request.file_name new_tag.name, normalized_path
); );
dao.tag_file(&span_context, &request.file_name, new_tag.id) dao.tag_file(&span_context, &normalized_path, new_tag.id)
.with_context(|| { .with_context(|| {
format!( format!(
"Unable to tag file {:?} with tag: {:?}", "Unable to tag file {:?} with tag: {:?}",
request.file_name, new_tag.name normalized_path, new_tag.name
) )
}) })
.unwrap(); .unwrap();

83
src/utils.rs Normal file
View File

@@ -0,0 +1,83 @@
/// Normalize a file path to use forward slashes for cross-platform consistency
/// This ensures paths stored in the database always use `/` regardless of OS
///
/// # Examples
/// ```
/// use image_api::utils::normalize_path;
///
/// assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg");
/// assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg");
/// ```
pub fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path_with_backslashes() {
assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg");
}
#[test]
fn test_normalize_path_with_forward_slashes() {
assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg");
}
#[test]
fn test_normalize_path_mixed() {
assert_eq!(
normalize_path("foo\\bar/baz\\qux.jpg"),
"foo/bar/baz/qux.jpg"
);
}
#[test]
fn test_normalize_path_empty() {
assert_eq!(normalize_path(""), "");
}
#[test]
fn test_normalize_path_absolute_windows() {
assert_eq!(
normalize_path("C:\\Users\\Photos\\image.jpg"),
"C:/Users/Photos/image.jpg"
);
}
#[test]
fn test_normalize_path_unc_path() {
assert_eq!(
normalize_path("\\\\server\\share\\folder\\file.jpg"),
"//server/share/folder/file.jpg"
);
}
#[test]
fn test_normalize_path_single_filename() {
assert_eq!(normalize_path("image.jpg"), "image.jpg");
}
#[test]
fn test_normalize_path_trailing_slash() {
assert_eq!(normalize_path("foo\\bar\\"), "foo/bar/");
}
#[test]
fn test_normalize_path_multiple_consecutive_backslashes() {
assert_eq!(
normalize_path("foo\\\\bar\\\\\\baz.jpg"),
"foo//bar///baz.jpg"
);
}
#[test]
fn test_normalize_path_deep_nesting() {
assert_eq!(
normalize_path("a\\b\\c\\d\\e\\f\\g\\file.jpg"),
"a/b/c/d/e/f/g/file.jpg"
);
}
}