feature/insight-jobs #102

Merged
cameron merged 13 commits from feature/insight-jobs into master 2026-06-02 23:41:37 +00:00
13 changed files with 1046 additions and 174 deletions
Showing only changes of commit b87eb4e690 - Show all commits
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_insight_gen_jobs_status_cleanup;
DROP INDEX IF EXISTS idx_insight_gen_jobs_file;
DROP TABLE IF EXISTS insight_generation_jobs;
@@ -0,0 +1,25 @@
-- Track async insight generation jobs so the client can poll for
-- completion after the server returns 202 Accepted. The UNIQUE
-- constraint on (library_id, file_path, generation_type) ensures
-- idempotent inserts: if a running job already exists, the caller
-- should return that job_id instead of creating a duplicate.
CREATE TABLE insight_generation_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
generation_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
started_at INTEGER NOT NULL,
completed_at INTEGER,
result_insight_id INTEGER,
error_message TEXT,
UNIQUE(library_id, file_path, generation_type)
);
-- For the status endpoint: fast lookup by (library_id, file_path)
CREATE INDEX idx_insight_gen_jobs_file
ON insight_generation_jobs(library_id, file_path);
-- For startup cleanup (future): prune old completed/failed jobs
CREATE INDEX idx_insight_gen_jobs_status_cleanup
ON insight_generation_jobs(status, started_at);
+308 -136
View File
@@ -5,8 +5,9 @@ use serde::{Deserialize, Serialize};
use crate::ai::insight_chat::{ChatStreamEvent, ChatTurnRequest}; use crate::ai::insight_chat::{ChatStreamEvent, ChatTurnRequest};
use crate::ai::ollama::ChatMessage; use crate::ai::ollama::ChatMessage;
use crate::ai::{InsightGenerator, ModelCapabilities, OllamaClient}; use crate::ai::{ModelCapabilities, OllamaClient};
use crate::data::Claims; use crate::data::Claims;
use crate::database::models::{InsightGenerationType, InsightJobStatus};
use crate::database::{ExifDao, InsightDao}; use crate::database::{ExifDao, InsightDao};
use crate::libraries; use crate::libraries;
use crate::otel::{extract_context_from_request, global_tracer}; use crate::otel::{extract_context_from_request, global_tracer};
@@ -64,6 +65,101 @@ pub struct GetPhotoInsightQuery {
pub library: Option<String>, pub library: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct GenerationStatusQuery {
/// If provided, look up the job by id.
#[serde(default)]
pub job_id: Option<i32>,
/// If provided with `library`, look up the latest running job for this
/// file. Used when the client doesn't have a persisted job_id.
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub library: Option<String>,
}
/// GET /insights/generation/status - Check status of a generation job.
/// Accepts either `?job_id=<id>` or `?file_path=<path>&library=<name>`.
#[get("/insights/generation/status")]
pub async fn generation_status_handler(
_claims: Claims,
query: web::Query<GenerationStatusQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let ctx = opentelemetry::Context::new();
if let Some(jid) = query.job_id {
let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.get_job_by_id(&ctx, jid) {
Ok(Some(job)) => {
return HttpResponse::Ok().json(GenerationStatusResponse {
job_id: job.id,
status: InsightJobStatus::from_str(&job.status),
started_at: job.started_at,
completed_at: job.completed_at,
result_insight_id: job.result_insight_id,
error_message: job.error_message,
});
}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Job {} not found", jid)
}));
}
Err(e) => {
log::error!("Failed to look up job {}: {:?}", jid, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to look up job"
}));
}
}
}
if let Some(ref fp) = query.file_path {
let library = libraries::resolve_library_param(&app_state, query.library.as_deref())
.ok()
.flatten()
.unwrap_or_else(|| app_state.primary_library());
let normalized = normalize_path(fp);
let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.get_active_job(&ctx, library.id, &normalized) {
Ok(Some(job)) => {
return HttpResponse::Ok().json(GenerationStatusResponse {
job_id: job.id,
status: InsightJobStatus::from_str(&job.status),
started_at: job.started_at,
completed_at: job.completed_at,
result_insight_id: job.result_insight_id,
error_message: job.error_message,
});
}
Ok(None) => {
return HttpResponse::Ok().json(serde_json::json!({
"status": "idle",
"message": "No running generation job for this file"
}));
}
Err(e) => {
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to look up active job"
}));
}
}
}
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Provide either job_id or file_path query parameter"
}))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RateInsightRequest { pub struct RateInsightRequest {
pub file_path: String, pub file_path: String,
@@ -76,6 +172,24 @@ pub struct ExportTrainingDataQuery {
pub approved_only: Option<bool>, pub approved_only: Option<bool>,
} }
#[derive(Debug, Serialize)]
pub struct JobIdResponse {
pub job_id: i32,
}
#[derive(Debug, Serialize)]
pub struct GenerationStatusResponse {
pub job_id: i32,
pub status: InsightJobStatus,
pub started_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result_insight_id: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct PhotoInsightResponse { pub struct PhotoInsightResponse {
pub id: i32, pub id: i32,
@@ -110,44 +224,75 @@ pub struct ServerModels {
pub default_model: String, pub default_model: String,
} }
/// POST /insights/generate - Generate insight for a specific photo /// POST /insights/generate - Generate insight for a specific photo (async)
#[post("/insights/generate")] #[post("/insights/generate")]
pub async fn generate_insight_handler( pub async fn generate_insight_handler(
http_request: HttpRequest, _http_request: HttpRequest,
_claims: Claims, _claims: Claims,
request: web::Json<GeneratePhotoInsightRequest>, request: web::Json<GeneratePhotoInsightRequest>,
insight_generator: web::Data<InsightGenerator>, app_state: web::Data<AppState>,
) -> impl Responder { ) -> 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", &parent_context);
let normalized_path = normalize_path(&request.file_path); let normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library();
span.set_attribute(KeyValue::new("file_path", normalized_path.clone())); let gen_type = InsightGenerationType::Standard;
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));
}
log::info!( log::info!(
"Manual insight generation triggered for photo: {} with model: {:?}, custom_prompt: {}, num_ctx: {:?}", "Manual insight generation triggered for photo: {} with model: {:?}",
normalized_path, normalized_path,
request.model, request.model
request.system_prompt.is_some(),
request.num_ctx
); );
// Generate insight with optional custom model, system prompt, and context size // Cancel any running job for this file, then create a fresh one
let result = insight_generator {
.generate_insight_for_photo_with_config( let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
let _ = dao.cancel_active_job(
&opentelemetry::Context::new(),
library.id,
&normalized_path, &normalized_path,
gen_type,
);
}
let job_id = {
let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.create_or_get_active_job(
&opentelemetry::Context::new(),
library.id,
&normalized_path,
gen_type,
) {
Ok(id) => id,
Err(e) => {
log::error!("Failed to create generation job: {:?}", e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create generation job"
}));
}
}
};
// Spawn background task with timeout
let generator = app_state.insight_generator.clone();
let job_dao = app_state.insight_job_dao.clone();
let lib_id = library.id;
let path = normalized_path.clone();
tokio::spawn(async move {
let timeout_secs: u64 = std::env::var("INSIGHT_GENERATION_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(120);
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
generator.generate_insight_for_photo_with_config(
&path,
request.model.clone(), request.model.clone(),
request.system_prompt.clone(), request.system_prompt.clone(),
request.num_ctx, request.num_ctx,
@@ -155,25 +300,47 @@ pub async fn generate_insight_handler(
request.top_p, request.top_p,
request.top_k, request.top_k,
request.min_p, request.min_p,
),
) )
.await; .await;
let ctx = opentelemetry::Context::new();
let mut dao = job_dao.lock().expect("Unable to lock InsightJobDao");
match result { match result {
Ok(()) => { Ok(Ok(())) => {
span.set_status(Status::Ok); // Look up the stored insight id to record on the job
HttpResponse::Ok().json(serde_json::json!({ let mut insight_dao = generator
"success": true, .insight_dao()
"message": "Insight generated successfully" .lock()
})) .expect("Unable to lock InsightDao");
} let insight_id = insight_dao
Err(e) => { .get_insight(&ctx, &path)
log::error!("Failed to generate insight: {:?}", e); .ok()
span.set_status(Status::error(e.to_string())); .flatten()
HttpResponse::InternalServerError().json(serde_json::json!({ .map(|i| i.id);
"error": format!("Failed to generate insight: {:?}", e) if let Some(id) = insight_id {
})) let _ = dao.complete_job(&ctx, job_id, id);
} else {
let _ = dao.fail_job(&ctx, job_id, "generation returned no insight");
} }
} }
Ok(Err(e)) => {
log::error!("Insight generation failed for {}: {:?}", path, e);
let _ = dao.fail_job(&ctx, job_id, &format!("{:?}", e));
}
Err(_) => {
log::error!(
"Insight generation timed out for {} after {}s",
path,
timeout_secs
);
let _ = dao.fail_job(&ctx, job_id, &format!("timeout after {}s", timeout_secs));
}
}
});
HttpResponse::Ok().json(JobIdResponse { job_id })
} }
/// GET /insights?path=/path/to/photo.jpg - Fetch insight for specific photo /// GET /insights?path=/path/to/photo.jpg - Fetch insight for specific photo
@@ -301,56 +468,60 @@ pub async fn get_all_insights_handler(
} }
} }
/// POST /insights/generate/agentic - Generate insight using agentic tool-calling loop /// POST /insights/generate/agentic - Generate insight using agentic tool-calling loop (async)
#[post("/insights/generate/agentic")] #[post("/insights/generate/agentic")]
pub async fn generate_agentic_insight_handler( pub async fn generate_agentic_insight_handler(
http_request: HttpRequest, _http_request: HttpRequest,
claims: Claims, claims: Claims,
request: web::Json<GeneratePhotoInsightRequest>, request: web::Json<GeneratePhotoInsightRequest>,
insight_generator: web::Data<InsightGenerator>, app_state: web::Data<AppState>,
insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>,
) -> impl Responder { ) -> impl Responder {
// Service tokens (sub: "service:apollo") fall through to user_id=1
// — the operator convention. Mobile/web clients have a numeric sub.
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
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); let normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library();
span.set_attribute(KeyValue::new("file_path", normalized_path.clone())); let gen_type = InsightGenerationType::Agentic;
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(12);
span.set_attribute(KeyValue::new("max_iterations", max_iterations as i64));
log::info!( log::info!(
"Agentic insight generation triggered for photo: {} with model: {:?}, max_iterations: {}", "Agentic insight generation triggered for photo: {} with model: {:?}",
normalized_path, normalized_path,
request.model, request.model
max_iterations
); );
if let Some(ref b) = request.backend { // Cancel any running job for this file, then create a fresh one
span.set_attribute(KeyValue::new("backend", b.clone())); {
let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
let _ = dao.cancel_active_job(
&opentelemetry::Context::new(),
library.id,
&normalized_path,
gen_type,
);
} }
// Resolve few-shot ids: request-provided ids take precedence when let job_id = {
// non-empty; otherwise fall back to the hardcoded defaults. let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.create_or_get_active_job(
&opentelemetry::Context::new(),
library.id,
&normalized_path,
gen_type,
) {
Ok(id) => id,
Err(e) => {
log::error!("Failed to create agentic generation job: {:?}", e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create generation job"
}));
}
}
};
// Resolve few-shot ids for the background task
let fewshot_ids: Vec<i32> = match request.fewshot_insight_ids.as_deref() { let fewshot_ids: Vec<i32> = match request.fewshot_insight_ids.as_deref() {
Some(ids) if !ids.is_empty() => ids.iter().take(2).copied().collect(), Some(ids) if !ids.is_empty() => ids.iter().take(2).copied().collect(),
_ => DEFAULT_FEWSHOT_INSIGHT_IDS _ => DEFAULT_FEWSHOT_INSIGHT_IDS
@@ -359,11 +530,14 @@ pub async fn generate_agentic_insight_handler(
.copied() .copied()
.collect(), .collect(),
}; };
span.set_attribute(KeyValue::new("fewshot_count", fewshot_ids.len() as i64));
let fewshot_examples: Vec<Vec<ChatMessage>> = { let fewshot_examples: Vec<Vec<ChatMessage>> = {
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 = app_state
.insight_chat
.insight_dao()
.lock()
.expect("Unable to lock InsightDao");
fewshot_ids fewshot_ids
.iter() .iter()
.filter_map(|id| { .filter_map(|id| {
@@ -384,16 +558,34 @@ pub async fn generate_agentic_insight_handler(
.collect() .collect()
}; };
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
let persona_id = request let persona_id = request
.persona_id .persona_id
.clone() .clone()
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "default".to_string()); .unwrap_or_else(|| "default".to_string());
span.set_attribute(KeyValue::new("persona_id", persona_id.clone()));
let result = insight_generator let max_iterations: usize = std::env::var("AGENTIC_MAX_ITERATIONS")
.generate_agentic_insight_for_photo( .ok()
&normalized_path, .and_then(|v| v.parse().ok())
.unwrap_or(12);
// Spawn background task with timeout
let generator = app_state.insight_generator.clone();
let job_dao = app_state.insight_job_dao.clone();
let lib_id = library.id;
let path = normalized_path.clone();
tokio::spawn(async move {
let timeout_secs: u64 = std::env::var("INSIGHT_GENERATION_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(180);
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
generator.generate_agentic_insight_for_photo(
&path,
request.model.clone(), request.model.clone(),
request.system_prompt.clone(), request.system_prompt.clone(),
request.num_ctx, request.num_ctx,
@@ -407,67 +599,47 @@ pub async fn generate_agentic_insight_handler(
fewshot_ids, fewshot_ids,
user_id, user_id,
persona_id, persona_id,
),
) )
.await; .await;
match result { let ctx = opentelemetry::Context::new();
Ok((prompt_eval_count, eval_count)) => { let mut dao = job_dao.lock().expect("Unable to lock InsightJobDao");
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,
prompt_eval_count,
eval_count,
approved: insight.approved,
has_training_messages: insight.training_messages.is_some(),
backend: insight.backend,
};
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") match result {
|| error_msg.contains("model not available") Ok(Ok(_)) => {
{ // Fetch the stored insight id to record on the job
HttpResponse::BadRequest().json(serde_json::json!({ let mut insight_dao = generator
"error": format!("Failed to generate agentic insight: {}", error_msg) .insight_dao()
})) .lock()
} else if error_msg.contains("error parsing tool call") { .expect("Unable to lock InsightDao");
HttpResponse::BadRequest().json(serde_json::json!({ let insight_id = insight_dao
"error": "Model is not compatible with Ollama's tool calling protocol. Try a model known to support native tool calling (e.g. llama3.1, llama3.2, qwen2.5, mistral-nemo)." .get_insight(&ctx, &path)
})) .ok()
.flatten()
.map(|i| i.id);
if let Some(id) = insight_id {
let _ = dao.complete_job(&ctx, job_id, id);
} else { } else {
HttpResponse::InternalServerError().json(serde_json::json!({ let _ = dao.fail_job(&ctx, job_id, "generation returned no insight");
"error": format!("Failed to generate agentic insight: {}", error_msg)
}))
} }
} }
Ok(Err(e)) => {
log::error!("Agentic insight generation failed for {}: {:?}", path, e);
let _ = dao.fail_job(&ctx, job_id, &format!("{:?}", e));
} }
Err(_) => {
log::error!(
"Agentic insight generation timed out for {} after {}s",
path,
timeout_secs
);
let _ = dao.fail_job(&ctx, job_id, &format!("timeout after {}s", timeout_secs));
}
}
});
HttpResponse::Ok().json(JobIdResponse { job_id })
} }
/// GET /insights/models - Local-backend models with capabilities. Returns /// GET /insights/models - Local-backend models with capabilities. Returns
+35 -3
View File
@@ -107,6 +107,11 @@ impl InsightChatService {
} }
} }
/// Accessor for the insight DAO (used by async job completion).
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
&self.insight_dao
}
/// Load the rendered transcript for chat-UI display. Filters internal /// Load the rendered transcript for chat-UI display. Filters internal
/// scaffolding (system message, tool turns, tool-dispatch-only assistant /// scaffolding (system message, tool turns, tool-dispatch-only assistant
/// messages) and drops base64 images from user turns to keep payloads /// messages) and drops base64 images from user turns to keep payloads
@@ -522,8 +527,17 @@ impl InsightChatService {
} else { } else {
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.update_training_messages(&cx, req.library_id, &normalized, &json) let rows = dao
.update_training_messages(&cx, req.library_id, &normalized, &json)
.map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?; .map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?;
if rows == 0 {
log::warn!(
"update_training_messages updated 0 rows for {} (lib {}), \
concurrent regenerate likely flipped is_current",
normalized,
req.library_id
);
}
} }
Ok(ChatTurnResult { Ok(ChatTurnResult {
@@ -590,8 +604,17 @@ impl InsightChatService {
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.update_training_messages(&cx, library_id, &normalized, &json) let rows = dao
.update_training_messages(&cx, library_id, &normalized, &json)
.map_err(|e| anyhow!("failed to persist truncated history: {:?}", e))?; .map_err(|e| anyhow!("failed to persist truncated history: {:?}", e))?;
if rows == 0 {
log::warn!(
"update_training_messages (rewind) updated 0 rows for {} (lib {}), \
concurrent regenerate likely flipped is_current",
normalized,
library_id
);
}
Ok(()) Ok(())
} }
@@ -851,8 +874,17 @@ impl InsightChatService {
} else { } else {
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.update_training_messages(&cx, req.library_id, &normalized, &json) let rows = dao
.update_training_messages(&cx, req.library_id, &normalized, &json)
.map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?; .map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?;
if rows == 0 {
log::warn!(
"update_training_messages (stream) updated 0 rows for {} (lib {}), \
concurrent regenerate likely flipped is_current",
normalized,
req.library_id
);
}
} }
let _ = tx let _ = tx
+6
View File
@@ -196,6 +196,12 @@ impl InsightGenerator {
} }
} }
/// Accessor for the insight DAO (used by async job completion to
/// look up the stored insight id).
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
&self.insight_dao
}
/// Whether the optional Apollo Places integration is wired up. Drives /// Whether the optional Apollo Places integration is wired up. Drives
/// tool-definition gating (no point offering `get_personal_place_at` /// tool-definition gating (no point offering `get_personal_place_at`
/// when Apollo is unreachable) — exposed publicly so `insight_chat` /// when Apollo is unreachable) — exposed publicly so `insight_chat`
+3 -2
View File
@@ -21,8 +21,9 @@ pub use daily_summary_job::{
pub use handlers::{ pub use handlers::{
chat_history_handler, chat_rewind_handler, chat_stream_handler, chat_turn_handler, chat_history_handler, chat_rewind_handler, chat_stream_handler, chat_turn_handler,
delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler, delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler,
generate_insight_handler, get_all_insights_handler, get_available_models_handler, generate_insight_handler, generation_status_handler, get_all_insights_handler,
get_insight_handler, get_openrouter_models_handler, rate_insight_handler, get_available_models_handler, get_insight_handler, get_openrouter_models_handler,
rate_insight_handler,
}; };
pub use insight_generator::InsightGenerator; pub use insight_generator::InsightGenerator;
pub use llamacpp::LlamaCppClient; pub use llamacpp::LlamaCppClient;
+503
View File
@@ -0,0 +1,503 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{
InsertInsightGenerationJob, InsightGenerationJob, InsightGenerationType, InsightJobStatus,
};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// Tracks async insight generation jobs. The idempotent insert ensures
/// concurrent callers for the same (library_id, file_path, generation_type)
/// get the same job_id rather than creating duplicates.
pub trait InsightGenerationJobDao: Sync + Send {
/// Insert a new job or return the existing running job for the same key.
/// Returns the job_id either way.
fn create_or_get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<i32, DbError>;
/// Mark a job as completed with the resulting insight id.
fn complete_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
insight_id: i32,
) -> Result<(), DbError>;
/// Mark a job as failed with an error message.
fn fail_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
error_message: &str,
) -> Result<(), DbError>;
/// Mark the active running job for a file as "cancelled". Returns true if
/// a job was found and cancelled, false if no running job existed.
fn cancel_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<bool, DbError>;
/// Find the latest running job for a given file. Returns None if no
/// running job exists.
fn get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<InsightGenerationJob>, DbError>;
/// Find any job by id regardless of status.
fn get_job_by_id(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<Option<InsightGenerationJob>, DbError>;
}
pub struct SqliteInsightGenerationJobDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqliteInsightGenerationJobDao {
fn default() -> Self {
Self::new()
}
}
impl SqliteInsightGenerationJobDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
#[cfg(test)]
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
fn create_or_get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<i32, DbError> {
trace_db_call(context, "insert", "create_or_get_active_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
// Check for existing running job
let existing = dsl::insight_generation_jobs
.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::generation_type.eq(generation_type.as_str()))
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
)
.select(dsl::id)
.first::<i32>(connection.deref_mut())
.optional();
match existing {
Ok(Some(job_id)) => return Ok(job_id),
Ok(None) => {}
Err(e) => return Err(anyhow::anyhow!("Failed to check existing job: {}", e)),
}
// No running job exists, insert new one (upsert on conflict)
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let new_job = InsertInsightGenerationJob {
library_id,
path: file_path.to_string(),
gen_type: generation_type.to_string(),
status: InsightJobStatus::Running.to_string(),
started_at: now,
};
diesel::insert_into(dsl::insight_generation_jobs)
.values(&new_job)
.on_conflict((dsl::library_id, dsl::file_path, dsl::generation_type))
.do_update()
.set((
dsl::status.eq(InsightJobStatus::Running.as_str()),
dsl::started_at.eq(now),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to insert job: {}", e))?;
// Get the job id
dsl::insight_generation_jobs
.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::generation_type.eq(generation_type.as_str())),
)
.select(dsl::id)
.order(dsl::id.desc())
.first::<i32>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to get job id: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn complete_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
insight_id: i32,
) -> Result<(), DbError> {
trace_db_call(context, "update", "complete_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
diesel::update(dsl::insight_generation_jobs.filter(dsl::id.eq(job_id)))
.set((
dsl::status.eq(InsightJobStatus::Completed.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::result_insight_id.eq(Some(insight_id)),
))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Failed to complete job: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
}
fn fail_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
error_message: &str,
) -> Result<(), DbError> {
trace_db_call(context, "update", "fail_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
diesel::update(dsl::insight_generation_jobs.filter(dsl::id.eq(job_id)))
.set((
dsl::status.eq(InsightJobStatus::Failed.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::error_message.eq(Some(error_message.to_string())),
))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Failed to fail job: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
}
fn cancel_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<bool, DbError> {
trace_db_call(context, "update", "cancel_active_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let rows = diesel::update(
dsl::insight_generation_jobs.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::generation_type.eq(generation_type.as_str()))
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
),
)
.set((
dsl::status.eq(InsightJobStatus::Cancelled.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::error_message.eq(Some("cancelled by newer request".to_string())),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to cancel job: {}", e))?;
Ok(rows > 0)
})
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
}
fn get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call(context, "query", "get_active_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
dsl::insight_generation_jobs
.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
)
.order(dsl::id.desc())
.first::<InsightGenerationJob>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get active job: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_job_by_id(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call(context, "query", "get_job_by_id", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
dsl::insight_generation_jobs
.filter(dsl::id.eq(job_id))
.first::<InsightGenerationJob>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get job: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn setup_dao() -> SqliteInsightGenerationJobDao {
let mut conn = SqliteConnection::establish(":memory:")
.expect("Unable to create in-memory db connection");
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("Failure running DB migrations");
SqliteInsightGenerationJobDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn create_job_idempotent() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id_1 = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert_eq!(
job_id_1, job_id_2,
"idempotent insert should return same job_id"
);
}
#[test]
fn complete_job_sets_result() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
dao.complete_job(&ctx, job_id, 42).unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
assert_eq!(job.result_insight_id, Some(42));
assert!(job.completed_at.is_some());
}
#[test]
fn fail_job_sets_error() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
.unwrap();
dao.fail_job(&ctx, job_id, "model timeout").unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Failed.as_str());
assert_eq!(job.error_message.as_deref(), Some("model timeout"));
assert!(job.completed_at.is_some());
}
#[test]
fn get_active_job_returns_none_when_completed() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
// Job is running
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active.is_some());
assert_eq!(active.unwrap().id, job_id);
// Complete it
dao.complete_job(&ctx, job_id, 1).unwrap();
// No longer active
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active.is_none());
}
#[test]
fn cancel_active_job() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let cancelled = dao
.cancel_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert!(cancelled, "should cancel existing running job");
// Job is no longer active
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active.is_none());
// Job exists with cancelled status
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
// Cancelling again returns false (nothing to cancel)
let cancelled2 = dao
.cancel_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert!(!cancelled2, "should return false when no running job");
}
#[test]
fn get_active_job_scoped_by_library() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id_1 = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_or_get_active_job(&ctx, 2, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert_ne!(
job_id_1, job_id_2,
"different libraries should have separate jobs"
);
// Complete lib1's job
dao.complete_job(&ctx, job_id_1, 1).unwrap();
// lib1 has no active job
let active1 = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active1.is_none());
// lib2 still has active job
let active2 = dao.get_active_job(&ctx, 2, "photos/test.jpg").unwrap();
assert!(active2.is_some());
assert_eq!(active2.unwrap().id, job_id_2);
}
#[test]
fn get_job_by_id_finds_any_status() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_or_get_active_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
// Find while running
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Running.as_str());
// Complete it
dao.complete_job(&ctx, job_id, 99).unwrap();
// Still findable
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
assert_eq!(job.result_insight_id, Some(99));
}
}
+4 -3
View File
@@ -90,13 +90,15 @@ pub trait InsightDao: Sync + Send {
/// Replace the `training_messages` JSON blob on the current row for /// Replace the `training_messages` JSON blob on the current row for
/// `(library_id, rel_path)`. Used by chat-turn append mode to persist /// `(library_id, rel_path)`. Used by chat-turn append mode to persist
/// the extended conversation without inserting a new insight version. /// the extended conversation without inserting a new insight version.
/// Returns the number of rows affected (0 if no current row matched,
/// indicating a concurrent regenerate/reconcile flipped `is_current`).
fn update_training_messages( fn update_training_messages(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
library_id: i32, library_id: i32,
file_path: &str, file_path: &str,
training_messages_json: &str, training_messages_json: &str,
) -> Result<(), DbError>; ) -> Result<usize, DbError>;
} }
pub struct SqliteInsightDao { pub struct SqliteInsightDao {
@@ -372,7 +374,7 @@ impl InsightDao for SqliteInsightDao {
lib_id: i32, lib_id: i32,
path: &str, path: &str,
training_messages_json: &str, training_messages_json: &str,
) -> Result<(), DbError> { ) -> Result<usize, DbError> {
trace_db_call(context, "update", "update_training_messages", |_span| { trace_db_call(context, "update", "update_training_messages", |_span| {
use schema::photo_insights::dsl::*; use schema::photo_insights::dsl::*;
@@ -386,7 +388,6 @@ impl InsightDao for SqliteInsightDao {
) )
.set(training_messages.eq(Some(training_messages_json.to_string()))) .set(training_messages.eq(Some(training_messages_json.to_string())))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map(|_| ())
.map_err(|_| anyhow::anyhow!("Update error")) .map_err(|_| anyhow::anyhow!("Update error"))
}) })
.map_err(|_| DbError::new(DbErrorKind::UpdateError)) .map_err(|_| DbError::new(DbErrorKind::UpdateError))
+2
View File
@@ -45,6 +45,7 @@ pub struct DuplicateRow {
pub mod calendar_dao; pub mod calendar_dao;
pub mod daily_summary_dao; pub mod daily_summary_dao;
pub mod insight_generation_job_dao;
pub mod insights_dao; pub mod insights_dao;
pub mod knowledge_dao; pub mod knowledge_dao;
pub mod location_dao; pub mod location_dao;
@@ -57,6 +58,7 @@ pub mod search_dao;
pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao}; pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
pub use insight_generation_job_dao::{InsightGenerationJobDao, SqliteInsightGenerationJobDao};
pub use insights_dao::{InsightDao, SqliteInsightDao}; pub use insights_dao::{InsightDao, SqliteInsightDao};
pub use knowledge_dao::{ pub use knowledge_dao::{
ConsolidationGroup, EntityFilter, EntityGraph, EntityPatch, EntitySort, FactFilter, FactPatch, ConsolidationGroup, EntityFilter, EntityGraph, EntityPatch, EntitySort, FactFilter, FactPatch,
+101 -2
View File
@@ -1,9 +1,81 @@
use crate::database::schema::{ use crate::database::schema::{
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, personas, entities, entity_facts, entity_photo_links, favorites, image_exif, insight_generation_jobs,
photo_insights, users, video_preview_clips, libraries, personas, photo_insights, users, video_preview_clips,
}; };
use serde::Serialize; use serde::Serialize;
/// Possible statuses for an insight generation job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, FromSqlRow)]
#[serde(rename_all = "snake_case")]
pub enum InsightJobStatus {
Running,
Completed,
Failed,
Cancelled,
}
impl InsightJobStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
}
impl ToString for InsightJobStatus {
fn to_string(&self) -> String {
self.as_str().to_string()
}
}
impl InsightJobStatus {
pub fn from_str(s: &str) -> Self {
match s {
"running" => Self::Running,
"completed" => Self::Completed,
"failed" => Self::Failed,
"cancelled" => Self::Cancelled,
_ => Self::Failed,
}
}
}
/// Type of insight generation (standard vs agentic).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InsightGenerationType {
Standard,
Agentic,
}
impl InsightGenerationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Agentic => "agentic",
}
}
}
impl ToString for InsightGenerationType {
fn to_string(&self) -> String {
self.as_str().to_string()
}
}
impl InsightGenerationType {
pub fn from_str(s: &str) -> Self {
match s {
"standard" => Self::Standard,
"agentic" => Self::Agentic,
_ => Self::Standard,
}
}
}
#[derive(Insertable)] #[derive(Insertable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
pub struct InsertUser<'a> { pub struct InsertUser<'a> {
@@ -394,3 +466,30 @@ pub struct VideoPreviewClip {
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
#[derive(Insertable)]
#[diesel(table_name = insight_generation_jobs)]
pub struct InsertInsightGenerationJob {
pub library_id: i32,
#[diesel(column_name = file_path)]
pub path: String,
#[diesel(column_name = generation_type)]
pub gen_type: String,
pub status: String,
pub started_at: i64,
}
#[derive(Queryable, Serialize, Clone, Debug)]
pub struct InsightGenerationJob {
pub id: i32,
pub library_id: i32,
#[diesel(column_name = file_path)]
pub path: String,
#[diesel(column_name = generation_type)]
pub gen_type: String,
pub status: String,
pub started_at: i64,
pub completed_at: Option<i64>,
pub result_insight_id: Option<i32>,
pub error_message: Option<String>,
}
+16
View File
@@ -271,12 +271,27 @@ diesel::table! {
} }
} }
diesel::table! {
insight_generation_jobs (id) {
id -> Integer,
library_id -> Integer,
file_path -> Text,
generation_type -> Text,
status -> Text,
started_at -> BigInt,
completed_at -> Nullable<BigInt>,
result_insight_id -> Nullable<Integer>,
error_message -> Nullable<Text>,
}
}
diesel::joinable!(entity_facts -> photo_insights (source_insight_id)); diesel::joinable!(entity_facts -> photo_insights (source_insight_id));
diesel::joinable!(entity_photo_links -> entities (entity_id)); diesel::joinable!(entity_photo_links -> entities (entity_id));
diesel::joinable!(entity_photo_links -> libraries (library_id)); diesel::joinable!(entity_photo_links -> libraries (library_id));
diesel::joinable!(face_detections -> libraries (library_id)); diesel::joinable!(face_detections -> libraries (library_id));
diesel::joinable!(face_detections -> persons (person_id)); diesel::joinable!(face_detections -> persons (person_id));
diesel::joinable!(image_exif -> libraries (library_id)); diesel::joinable!(image_exif -> libraries (library_id));
diesel::joinable!(insight_generation_jobs -> libraries (library_id));
diesel::joinable!(personas -> users (user_id)); diesel::joinable!(personas -> users (user_id));
diesel::joinable!(persons -> entities (entity_id)); diesel::joinable!(persons -> entities (entity_id));
diesel::joinable!(photo_insights -> libraries (library_id)); diesel::joinable!(photo_insights -> libraries (library_id));
@@ -292,6 +307,7 @@ diesel::allow_tables_to_appear_in_same_query!(
face_detections, face_detections,
favorites, favorites,
image_exif, image_exif,
insight_generation_jobs,
libraries, libraries,
location_history, location_history,
personas, personas,
+1
View File
@@ -308,6 +308,7 @@ fn main() -> std::io::Result<()> {
.service(memories::list_memories) .service(memories::list_memories)
.service(ai::generate_insight_handler) .service(ai::generate_insight_handler)
.service(ai::generate_agentic_insight_handler) .service(ai::generate_agentic_insight_handler)
.service(ai::generation_status_handler)
.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)
+15 -4
View File
@@ -6,10 +6,10 @@ use crate::ai::llamacpp::LlamaCppClient;
use crate::ai::openrouter::OpenRouterClient; use crate::ai::openrouter::OpenRouterClient;
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
use crate::database::{ use crate::database::{
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao, CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, InsightGenerationJobDao, KnowledgeDao,
SearchHistoryDao, SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, LocationHistoryDao, SearchHistoryDao, SqliteCalendarEventDao, SqliteDailySummaryDao,
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao, SqliteExifDao, SqliteInsightDao, SqliteInsightGenerationJobDao, SqliteKnowledgeDao,
connect, SqliteLocationHistoryDao, SqliteSearchHistoryDao, connect,
}; };
use crate::database::{PreviewDao, SqlitePreviewDao}; use crate::database::{PreviewDao, SqlitePreviewDao};
use crate::faces; use crate::faces;
@@ -86,6 +86,8 @@ pub struct AppState {
/// Same disabled semantics as `face_client`: unset env → no-op /// Same disabled semantics as `face_client`: unset env → no-op
/// backlog drain, /photos/search returns an empty result. /// backlog drain, /photos/search returns an empty result.
pub clip_client: ClipClient, pub clip_client: ClipClient,
/// Tracks async insight generation jobs (spawned by generate endpoints).
pub insight_job_dao: Arc<Mutex<Box<dyn InsightGenerationJobDao>>>,
} }
impl AppState { impl AppState {
@@ -124,6 +126,7 @@ impl AppState {
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>, preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
face_client: FaceClient, face_client: FaceClient,
clip_client: ClipClient, clip_client: ClipClient,
insight_job_dao: Arc<Mutex<Box<dyn InsightGenerationJobDao>>>,
) -> Self { ) -> Self {
assert!( assert!(
!libraries_vec.is_empty(), !libraries_vec.is_empty(),
@@ -165,6 +168,7 @@ impl AppState {
insight_chat, insight_chat,
face_client, face_client,
clip_client, clip_client,
insight_job_dao,
} }
} }
@@ -253,6 +257,10 @@ impl Default for AppState {
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> = let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new()))); Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
// Initialize insight generation job DAO (async generation tracking)
let insight_job_dao: Arc<Mutex<Box<dyn InsightGenerationJobDao>>> =
Arc::new(Mutex::new(Box::new(SqliteInsightGenerationJobDao::new())));
// Load base path and ensure the primary library row reflects it. // Load base path and ensure the primary library row reflects it.
let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env"); let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
let mut seed_conn = connect(); let mut seed_conn = connect();
@@ -319,6 +327,7 @@ impl Default for AppState {
preview_dao, preview_dao,
face_client, face_client,
clip_client, clip_client,
insight_job_dao,
) )
} }
} }
@@ -389,6 +398,7 @@ fn parse_llamacpp_allowed_models() -> Vec<String> {
impl AppState { impl AppState {
/// Creates an AppState instance for testing with temporary directories /// Creates an AppState instance for testing with temporary directories
pub fn test_state() -> Self { pub fn test_state() -> Self {
use crate::database::insight_generation_job_dao::SqliteInsightGenerationJobDao;
use actix::Actor; use actix::Actor;
// Create a base temporary directory // Create a base temporary directory
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
@@ -502,6 +512,7 @@ impl AppState {
preview_dao, preview_dao,
FaceClient::new(None), // disabled in test FaceClient::new(None), // disabled in test
ClipClient::new(None), // disabled in test ClipClient::new(None), // disabled in test
Arc::new(Mutex::new(Box::new(SqliteInsightGenerationJobDao::new()))), // placeholder for test
) )
} }
} }