Add per-file insight history endpoint and rate-by-id
Expose GET /insights/history?path=... returning every generated version of a photo's insight (current plus superseded), newest-first, backing the mobile per-file insight history view. - New get_insight_history_handler; reuses the existing get_insight_history DAO method (removed its dead_code allow). - impl From<PhotoInsight> for PhotoInsightResponse, collapsing the mapping that was duplicated across the single-get and all-insights handlers. - rate_insight_by_id DAO method + optional insight_id on RateInsightRequest so previously generated versions can be approved/rejected (the path-based rate only touches the current row). - DAO tests for history ordering/scoping and id-targeted rating. - cargo fmt normalized a multi-line assert in insight_chat.rs tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+76
-46
@@ -8,7 +8,7 @@ use crate::ai::insight_chat::{ChatStreamEvent, ChatTurnRequest};
|
||||
use crate::ai::ollama::ChatMessage;
|
||||
use crate::ai::{ModelCapabilities, OllamaClient};
|
||||
use crate::data::Claims;
|
||||
use crate::database::models::{InsightGenerationType, InsightJobStatus};
|
||||
use crate::database::models::{InsightGenerationType, InsightJobStatus, PhotoInsight};
|
||||
use crate::database::{ExifDao, InsightDao};
|
||||
use crate::libraries;
|
||||
use crate::otel::{extract_context_from_request, global_tracer};
|
||||
@@ -273,6 +273,11 @@ pub async fn cancel_generation_handler(
|
||||
pub struct RateInsightRequest {
|
||||
pub file_path: String,
|
||||
pub approved: bool,
|
||||
/// When set, rate this specific insight version by primary key
|
||||
/// (used by the per-file history view to rate superseded versions).
|
||||
/// When omitted, the current insight for `file_path` is rated.
|
||||
#[serde(default)]
|
||||
pub insight_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -333,6 +338,31 @@ pub struct PhotoInsightResponse {
|
||||
pub persona_id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<PhotoInsight> for PhotoInsightResponse {
|
||||
fn from(insight: PhotoInsight) -> Self {
|
||||
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: insight.prompt_eval_count,
|
||||
eval_count: insight.eval_count,
|
||||
approved: insight.approved,
|
||||
has_training_messages: insight.training_messages.is_some(),
|
||||
backend: insight.backend,
|
||||
num_ctx: insight.num_ctx,
|
||||
temperature: insight.temperature,
|
||||
top_p: insight.top_p,
|
||||
top_k: insight.top_k,
|
||||
min_p: insight.min_p,
|
||||
system_prompt: insight.system_prompt,
|
||||
persona_id: insight.persona_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AvailableModelsResponse {
|
||||
pub primary: ServerModels,
|
||||
@@ -554,29 +584,7 @@ pub async fn get_insight_handler(
|
||||
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
|
||||
match dao.get_insight_for_paths(&otel_context, &sibling_paths) {
|
||||
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: insight.prompt_eval_count,
|
||||
eval_count: insight.eval_count,
|
||||
approved: insight.approved,
|
||||
has_training_messages: insight.training_messages.is_some(),
|
||||
backend: insight.backend,
|
||||
num_ctx: insight.num_ctx,
|
||||
temperature: insight.temperature,
|
||||
top_p: insight.top_p,
|
||||
top_k: insight.top_k,
|
||||
min_p: insight.min_p,
|
||||
system_prompt: insight.system_prompt,
|
||||
persona_id: insight.persona_id,
|
||||
};
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "Insight not found"
|
||||
})),
|
||||
@@ -631,26 +639,7 @@ pub async fn get_all_insights_handler(
|
||||
Ok(insights) => {
|
||||
let responses: Vec<PhotoInsightResponse> = insights
|
||||
.into_iter()
|
||||
.map(|insight| 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: insight.prompt_eval_count,
|
||||
eval_count: insight.eval_count,
|
||||
approved: insight.approved,
|
||||
has_training_messages: insight.training_messages.is_some(),
|
||||
backend: insight.backend,
|
||||
num_ctx: insight.num_ctx,
|
||||
temperature: insight.temperature,
|
||||
top_p: insight.top_p,
|
||||
top_k: insight.top_k,
|
||||
min_p: insight.min_p,
|
||||
system_prompt: insight.system_prompt,
|
||||
persona_id: insight.persona_id,
|
||||
})
|
||||
.map(PhotoInsightResponse::from)
|
||||
.collect();
|
||||
|
||||
HttpResponse::Ok().json(responses)
|
||||
@@ -664,6 +653,39 @@ pub async fn get_all_insights_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /insights/history?path=/path/to/photo.jpg - Get all insight versions
|
||||
/// for a single photo (current plus previously generated/superseded ones),
|
||||
/// newest first. Backs the per-file insight history view.
|
||||
#[get("/insights/history")]
|
||||
pub async fn get_insight_history_handler(
|
||||
_claims: Claims,
|
||||
query: web::Query<GetPhotoInsightQuery>,
|
||||
insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>,
|
||||
) -> impl Responder {
|
||||
let normalized_path = normalize_path(&query.path);
|
||||
log::debug!("Fetching insight history for {}", normalized_path);
|
||||
|
||||
let otel_context = opentelemetry::Context::new();
|
||||
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
|
||||
match dao.get_insight_history(&otel_context, &normalized_path) {
|
||||
Ok(insights) => {
|
||||
let responses: Vec<PhotoInsightResponse> = insights
|
||||
.into_iter()
|
||||
.map(PhotoInsightResponse::from)
|
||||
.collect();
|
||||
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insight history: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /insights/generate/agentic - Generate insight using agentic tool-calling loop (async)
|
||||
#[post("/insights/generate/agentic")]
|
||||
pub async fn generate_agentic_insight_handler(
|
||||
@@ -1012,15 +1034,23 @@ pub async fn rate_insight_handler(
|
||||
) -> impl Responder {
|
||||
let normalized_path = normalize_path(&request.file_path);
|
||||
log::info!(
|
||||
"Rating insight for {}: approved={}",
|
||||
"Rating insight for {} (id={:?}): approved={}",
|
||||
normalized_path,
|
||||
request.insight_id,
|
||||
request.approved
|
||||
);
|
||||
|
||||
let otel_context = opentelemetry::Context::new();
|
||||
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
|
||||
match dao.rate_insight(&otel_context, &normalized_path, request.approved) {
|
||||
// Rate a specific version by id when provided (history view), otherwise
|
||||
// rate the current insight for the path.
|
||||
let result = match request.insight_id {
|
||||
Some(id) => dao.rate_insight_by_id(&otel_context, id, request.approved),
|
||||
None => dao.rate_insight(&otel_context, &normalized_path, request.approved),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "Insight rated successfully"
|
||||
|
||||
Reference in New Issue
Block a user