From 091327e5d9caa329c513171b36cdd0b4e21ec1d5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 18 Mar 2026 23:01:25 -0400 Subject: [PATCH] feat: add POST /insights/generate/agentic handler and route Register the agentic insight endpoint that validates tool-calling capability, runs the agentic loop, and returns the stored PhotoInsightResponse. Returns 400 for unsupported models, 500 for other errors. Max iterations configurable via AGENTIC_MAX_ITERATIONS env var (default 10). Co-Authored-By: Claude Sonnet 4.6 --- src/ai/handlers.rs | 102 +++++++++++++++++++++++++++++++++++++++++++++ src/ai/mod.rs | 4 +- src/main.rs | 1 + 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index 2fcfa06..5bf3178 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -211,6 +211,108 @@ pub async fn get_all_insights_handler( } } +/// POST /insights/generate/agentic - Generate insight using agentic tool-calling loop +#[post("/insights/generate/agentic")] +pub async fn generate_agentic_insight_handler( + http_request: HttpRequest, + _claims: Claims, + request: web::Json, + insight_generator: web::Data, + insight_dao: web::Data>>, +) -> impl Responder { + let parent_context = extract_context_from_request(&http_request); + let tracer = global_tracer(); + let mut span = tracer.start_with_context("http.insights.generate_agentic", &parent_context); + + let normalized_path = normalize_path(&request.file_path); + + span.set_attribute(KeyValue::new("file_path", normalized_path.clone())); + if let Some(ref model) = request.model { + span.set_attribute(KeyValue::new("model", model.clone())); + } + if let Some(ref prompt) = request.system_prompt { + span.set_attribute(KeyValue::new("has_custom_prompt", true)); + span.set_attribute(KeyValue::new("prompt_length", prompt.len() as i64)); + } + if let Some(ctx) = request.num_ctx { + span.set_attribute(KeyValue::new("num_ctx", ctx as i64)); + } + + let max_iterations: usize = std::env::var("AGENTIC_MAX_ITERATIONS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10); + + span.set_attribute(KeyValue::new("max_iterations", max_iterations as i64)); + + log::info!( + "Agentic insight generation triggered for photo: {} with model: {:?}, max_iterations: {}", + normalized_path, + request.model, + max_iterations + ); + + let result = insight_generator + .generate_agentic_insight_for_photo( + &normalized_path, + request.model.clone(), + request.system_prompt.clone(), + request.num_ctx, + max_iterations, + ) + .await; + + match result { + Ok(()) => { + span.set_status(Status::Ok); + // Fetch the stored insight to return it + let otel_context = opentelemetry::Context::new(); + let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); + match dao.get_insight(&otel_context, &normalized_path) { + Ok(Some(insight)) => { + let response = PhotoInsightResponse { + id: insight.id, + file_path: insight.file_path, + title: insight.title, + summary: insight.summary, + generated_at: insight.generated_at, + model_version: insight.model_version, + }; + HttpResponse::Ok().json(response) + } + Ok(None) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Agentic insight generated successfully" + })), + Err(e) => { + log::warn!("Insight stored but failed to retrieve: {:?}", e); + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Agentic insight generated successfully" + })) + } + } + } + Err(e) => { + let error_msg = format!("{:?}", e); + log::error!("Failed to generate agentic insight: {}", error_msg); + span.set_status(Status::error(error_msg.clone())); + + if error_msg.contains("tool calling not supported") + || error_msg.contains("model not available") + { + HttpResponse::BadRequest().json(serde_json::json!({ + "error": format!("Failed to generate agentic insight: {}", error_msg) + })) + } else { + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to generate agentic insight: {}", error_msg) + })) + } + } + } +} + /// GET /insights/models - List available models from both servers with capabilities #[get("/insights/models")] pub async fn get_available_models_handler( diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 57425e1..49c6651 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -8,8 +8,8 @@ pub mod sms_client; #[allow(unused_imports)] pub use daily_summary_job::{generate_daily_summaries, strip_summary_boilerplate}; pub use handlers::{ - delete_insight_handler, generate_insight_handler, get_all_insights_handler, - get_available_models_handler, get_insight_handler, + delete_insight_handler, generate_agentic_insight_handler, generate_insight_handler, + get_all_insights_handler, get_available_models_handler, get_insight_handler, }; pub use insight_generator::InsightGenerator; pub use ollama::{ModelCapabilities, OllamaClient}; diff --git a/src/main.rs b/src/main.rs index e56ecdf..14ca74c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1189,6 +1189,7 @@ fn main() -> std::io::Result<()> { .service(get_file_metadata) .service(memories::list_memories) .service(ai::generate_insight_handler) + .service(ai::generate_agentic_insight_handler) .service(ai::get_insight_handler) .service(ai::delete_insight_handler) .service(ai::get_all_insights_handler)