diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index 6cd163a..01e2fad 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -6,6 +6,7 @@ use axum::{ Router, }; use serde::{Deserialize, Serialize}; +use sqlx::Row; use uuid::Uuid; use crate::core::db::ResourceRecord; @@ -33,6 +34,9 @@ pub fn identity_routes() -> Router { .route("/api/v1/resource/register", post(register_resource)) .route("/api/v1/resource/heartbeat", post(heartbeat_resource)) .route("/api/v1/resources", get(list_resources)) + // Experiment: identity text search (non-polluting, separate endpoint) + .route("/api/v1/search/identity_text", get(search_identity_text)) + .route("/api/v1/identities/search", get(search_identities_by_text)) } // --- Files Endpoints --- @@ -632,3 +636,153 @@ async fn list_resources( data: None, })) } + +// ── Experiment: Identity Text Search ────────────────────────── +// Separate endpoints — do not modify existing API behavior. + +#[derive(Debug, Deserialize)] +struct IdentityTextQuery { + uuid: String, + q: String, + limit: Option, +} + +#[derive(Debug, Serialize)] +struct IdentityTextHit { + file_uuid: String, + chunk_id: String, + start_time: f64, + end_time: f64, + text_content: String, + identity_id: Option, + identity_name: Option, + identity_source: Option, + trace_id: Option, +} + +#[derive(Debug, Serialize)] +struct IdentityTextResponse { + success: bool, + total: i64, + results: Vec, +} + +/// Path A: Search chunk text → associated identities +async fn search_identity_text( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + use crate::core::db::schema; + let chunk_table = schema::table_name("chunk"); + let fd_table = schema::table_name("face_detections"); + let id_table = schema::table_name("identities"); + let like_q = format!("%{}%", params.q.replace('%', "%%")); + let limit = params.limit.unwrap_or(50).min(100); + + let query = format!( + r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content, + fd.identity_id, i.name AS identity_name, i.source AS identity_source, + fd.trace_id + FROM {} c + LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid + AND fd.frame_number BETWEEN c.start_frame AND c.end_frame + AND fd.identity_id IS NOT NULL + LEFT JOIN {} i ON i.id = fd.identity_id + WHERE c.file_uuid = $1 AND LOWER(c.text_content) LIKE LOWER($2) + ORDER BY c.start_time + LIMIT $3"#, + chunk_table, fd_table, id_table + ); + + let rows = sqlx::query_as::<_, (String, String, f64, f64, String, Option, Option, Option, Option)>(&query) + .bind(¶ms.uuid).bind(&like_q).bind(limit) + .fetch_all(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let results: Vec = rows + .into_iter() + .map(|(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit { + file_uuid: fu, chunk_id: cid, start_time: st, end_time: et, text_content: txt, + identity_id: iid, identity_name: iname, identity_source: isrc, trace_id: tid, + }) + .collect(); + + let total = results.len() as i64; + Ok(Json(IdentityTextResponse { success: true, total, results })) +} + +#[derive(Debug, Deserialize)] +struct IdentitySearchQuery { + q: String, + uuid: Option, + limit: Option, +} + +#[derive(Debug, Serialize)] +struct IdentitySearchHit { + identity_id: i32, + name: String, + source: Option, + tmdb_id: Option, + file_uuid: String, + trace_id: Option, + chunk_id: String, + start_time: f64, + text_content: String, +} + +#[derive(Debug, Serialize)] +struct IdentitySearchResponse { + success: bool, + total: i64, + results: Vec, +} + +/// Path B: Search identity name → associated chunk text +async fn search_identities_by_text( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + use crate::core::db::schema; + let id_table = schema::table_name("identities"); + let ib_table = schema::table_name("identity_bindings"); + let fd_table = schema::table_name("face_detections"); + let chunk_table = schema::table_name("chunk"); + let like_q = format!("%{}%", params.q.replace('%', "%%")); + let limit = params.limit.unwrap_or(50).min(100); + + let query = format!( + r#"SELECT i.id, i.name, i.source, i.tmdb_id, + fd.file_uuid, fd.trace_id, + c.chunk_id, c.start_time, c.text_content + FROM {} i + JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' + JOIN {} fd ON fd.trace_id = ib.identity_value::int + JOIN {} c ON c.file_uuid = fd.file_uuid + AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0) + AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0) + WHERE i.name ILIKE $1 + AND ($2::text IS NULL OR fd.file_uuid = $2) + ORDER BY i.name, c.start_time + LIMIT $3"#, + id_table, ib_table, fd_table, chunk_table + ); + + let rows = sqlx::query_as::<_, (i32, String, Option, Option, String, Option, String, f64, String)>(&query) + .bind(&like_q).bind(¶ms.uuid).bind(limit) + .fetch_all(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let results: Vec = rows + .into_iter() + .map(|(iid, name, src, tid, fu, trace_id, cid, st, txt)| IdentitySearchHit { + identity_id: iid, name, source: src, tmdb_id: tid, + file_uuid: fu, trace_id, chunk_id: cid, start_time: st, text_content: txt, + }) + .collect(); + + let total = results.len() as i64; + Ok(Json(IdentitySearchResponse { success: true, total, results })) +}