diff --git a/src/api/identities.rs b/src/api/identities.rs index 2cd2e78..e0fe39e 100644 --- a/src/api/identities.rs +++ b/src/api/identities.rs @@ -34,6 +34,7 @@ pub fn identity_routes() -> Router { .route("/api/v1/identities", get(list_identities)) .route("/api/v1/identity", post(create_identity)) .route("/api/v1/faces/candidates", get(list_face_candidates)) + .route("/api/v1/traces/unassigned", get(list_unassigned_traces)) } /// Register a Global Identity from face.json with multi-angle reference vectors. @@ -180,11 +181,24 @@ async fn list_identities( })?; let sql = format!( - "SELECT id::int, uuid, name, metadata, status, starred FROM {} WHERE status IS NULL OR status != 'merged' ORDER BY id DESC LIMIT $1 OFFSET $2", - id_table + r#"SELECT i.id::int, i.uuid, i.name, i.metadata, i.status, i.starred, + COALESCE( + jsonb_agg(jsonb_build_object( + 'file_uuid', fi.file_uuid, + 'confidence', fi.confidence, + 'source', fi.metadata->>'source' + ) ORDER BY fi.created_at DESC), + '[]'::jsonb + ) as file_bindings + FROM {} i + LEFT JOIN {} fi ON i.id = fi.identity_id + WHERE i.status IS NULL OR i.status != 'merged' + GROUP BY i.id, i.uuid, i.name, i.metadata, i.status, i.starred + ORDER BY i.id DESC LIMIT $1 OFFSET $2"#, + id_table, crate::core::db::schema::table_name("file_identities") ); - let rows: Vec<(i32, uuid::Uuid, String, Option, Option, Option)> = match sqlx::query_as(&sql) + let rows: Vec<(i32, uuid::Uuid, String, Option, Option, Option, serde_json::Value)> = match sqlx::query_as(&sql) .bind(page_size as i64) .bind(offset) .fetch_all(db.pool()) @@ -202,6 +216,10 @@ let sql = format!( let identities: Vec = rows .into_iter() .map(|r| { + let file_bindings: Vec = r.6.as_array() + .map(|arr| arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()) + .unwrap_or_default(); + let file_uuids: Vec = file_bindings.iter().map(|fb| fb.file_uuid.clone()).collect(); IdentityResponse { id: r.0, identity_uuid: r.1.to_string().replace('-', ""), @@ -209,7 +227,8 @@ let sql = format!( metadata: r.3, status: r.4, starred: r.5.unwrap_or(false), - file_uuids: vec![], // Removed N+1 query + file_uuids, + file_bindings: Some(file_bindings), } }) .collect(); @@ -280,6 +299,13 @@ pub struct FaceCandidatesResponse { pub page_size: usize, } +#[derive(Debug, Serialize, Deserialize)] +pub struct FileBinding { + pub file_uuid: String, + pub confidence: f64, + pub source: Option, +} + #[derive(Debug, Serialize)] pub struct IdentityResponse { pub id: i32, @@ -289,6 +315,7 @@ pub struct IdentityResponse { pub status: Option, pub starred: bool, pub file_uuids: Vec, + pub file_bindings: Option>, } #[derive(Debug, Serialize)] @@ -458,3 +485,173 @@ async fn list_face_candidates( page_size, })) } + +#[derive(Debug, Deserialize)] +pub struct UnassignedTracesQuery { + pub file_uuid: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct UnassignedTrace { + pub trace_id: i32, + pub file_uuid: String, + pub frame_count: i64, + pub start_frame: i64, + pub end_frame: i64, + pub best_face_id: i32, + pub best_face_frame: i64, + pub best_face_confidence: f64, + pub best_face_bbox: Option, +} + +#[derive(Debug, Serialize)] +pub struct UnassignedTracesResponse { + pub traces: Vec, + pub total: i64, + pub page: usize, + pub page_size: usize, +} + +/// List unassigned traces (identity_id IS NULL, grouped by trace_id) +async fn list_unassigned_traces( + Query(query): Query, +) -> Result, (StatusCode, String)> { + let db = match PostgresDb::init().await { + Ok(db) => db, + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to connect to database: {}", e), + )) + } + }; + + let page = query.page.unwrap_or(1); + let page_size = std::cmp::min(query.page_size.unwrap_or(20), 100); + let offset = (page - 1) * page_size; + + let table = crate::core::db::schema::table_name("face_detections"); + + let total: i64 = if let Some(file_uuid) = &query.file_uuid { + let count_sql = format!( + "SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id IS NULL AND trace_id IS NOT NULL AND file_uuid = $1", + table + ); + sqlx::query_scalar(&count_sql) + .bind(file_uuid) + .fetch_one(db.pool()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))? + } else { + let count_sql = format!( + "SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id IS NULL AND trace_id IS NOT NULL", + table + ); + sqlx::query_scalar(&count_sql) + .fetch_one(db.pool()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))? + }; + + let sql = if let Some(file_uuid) = &query.file_uuid { + format!( + "WITH trace_agg AS ( + SELECT trace_id, file_uuid, + COUNT(*) as frame_count, + MIN(frame_number) as start_frame, + MAX(frame_number) as end_frame + FROM {} + WHERE identity_id IS NULL AND trace_id IS NOT NULL AND file_uuid = $1 + GROUP BY trace_id, file_uuid + ), + best_face AS ( + SELECT DISTINCT ON (fd.trace_id, fd.file_uuid) + fd.trace_id, fd.file_uuid, fd.id as best_face_id, + fd.frame_number as best_face_frame, + fd.confidence as best_face_confidence, + jsonb_build_object('x', fd.x, 'y', fd.y, 'width', fd.width, 'height', fd.height) as best_face_bbox + FROM {} fd + WHERE fd.identity_id IS NULL AND fd.trace_id IS NOT NULL AND fd.file_uuid = $1 + ORDER BY fd.trace_id, fd.file_uuid, fd.confidence DESC + ) + SELECT ta.trace_id, ta.file_uuid, ta.frame_count, ta.start_frame, ta.end_frame, + bf.best_face_id, bf.best_face_frame, bf.best_face_confidence, bf.best_face_bbox + FROM trace_agg ta + JOIN best_face bf ON ta.trace_id = bf.trace_id AND ta.file_uuid = bf.file_uuid + ORDER BY ta.frame_count DESC + LIMIT $2 OFFSET $3", + table, table + ) + } else { + format!( + "WITH trace_agg AS ( + SELECT trace_id, file_uuid, + COUNT(*) as frame_count, + MIN(frame_number) as start_frame, + MAX(frame_number) as end_frame + FROM {} + WHERE identity_id IS NULL AND trace_id IS NOT NULL + GROUP BY trace_id, file_uuid + ), + best_face AS ( + SELECT DISTINCT ON (fd.trace_id, fd.file_uuid) + fd.trace_id, fd.file_uuid, fd.id as best_face_id, + fd.frame_number as best_face_frame, + fd.confidence as best_face_confidence, + jsonb_build_object('x', fd.x, 'y', fd.y, 'width', fd.width, 'height', fd.height) as best_face_bbox + FROM {} fd + WHERE fd.identity_id IS NULL AND fd.trace_id IS NOT NULL + ORDER BY fd.trace_id, fd.file_uuid, fd.confidence DESC + ) + SELECT ta.trace_id, ta.file_uuid, ta.frame_count, ta.start_frame, ta.end_frame, + bf.best_face_id, bf.best_face_frame, bf.best_face_confidence, bf.best_face_bbox + FROM trace_agg ta + JOIN best_face bf ON ta.trace_id = bf.trace_id AND ta.file_uuid = bf.file_uuid + ORDER BY ta.frame_count DESC + LIMIT $1 OFFSET $2", + table, table + ) + }; + + let rows: Vec<(i32, String, i64, i64, i64, i32, i64, f64, Option)> = + if let Some(file_uuid) = &query.file_uuid { + sqlx::query_as(&sql) + .bind(file_uuid) + .bind(page_size as i64) + .bind(offset as i64) + .fetch_all(db.pool()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {}", e)))? + } else { + sqlx::query_as(&sql) + .bind(page_size as i64) + .bind(offset as i64) + .fetch_all(db.pool()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {}", e)))? + }; + + let traces: Vec = rows + .into_iter() + .map(|r| UnassignedTrace { + trace_id: r.0, + file_uuid: r.1, + frame_count: r.2, + start_frame: r.3, + end_frame: r.4, + best_face_id: r.5, + best_face_frame: r.6, + best_face_confidence: r.7, + best_face_bbox: r.8, + }) + .collect(); + + Ok(Json(UnassignedTracesResponse { + traces, + total, + page, + page_size, + })) +} diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index 49b3d73..eaee500 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -266,10 +266,16 @@ async fn get_file_identities( }) .collect(); + let fi_table = crate::core::db::schema::table_name("file_identities"); let total = match sqlx::query_scalar::<_, i64>( &format!( - "SELECT COUNT(DISTINCT fd.identity_id) FROM {} fd WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL", - crate::core::db::schema::table_name("face_detections") + r#"SELECT COUNT(DISTINCT identity_id) FROM ( + SELECT identity_id FROM {} WHERE file_uuid = $1 AND identity_id IS NOT NULL + UNION + SELECT identity_id FROM {} WHERE file_uuid = $1 + ) combined"#, + crate::core::db::schema::table_name("face_detections"), + fi_table ) ) .bind(&file_uuid) diff --git a/src/core/db/postgres_db.rs b/src/core/db/postgres_db.rs index 58c2652..0ea5f95 100644 --- a/src/core/db/postgres_db.rs +++ b/src/core/db/postgres_db.rs @@ -3836,26 +3836,50 @@ sqlx::query( let id_table = schema::table_name("identities"); let fd_table = schema::table_name("face_detections"); let video_table = schema::table_name("videos"); + let fi_table = schema::table_name("file_identities"); use sqlx::Row; + let rows = sqlx::query(&format!( - "SELECT i.id, i.uuid::text, i.name, i.metadata, \ - COUNT(fd.id)::int4 as face_count, 0::int4 as speaker_count, \ - MIN(fd.frame_number)::int4 as start_frame, MAX(fd.frame_number)::int4 as end_frame, \ - MIN(fd.frame_number::float8 / NULLIF(v.fps, 0)) as start_time, \ - MAX(fd.frame_number::float8 / NULLIF(v.fps, 0)) as end_time, \ - AVG(fd.confidence)::float8 as confidence \ - FROM {} fd JOIN {} i ON i.id = fd.identity_id \ - JOIN {} v ON v.file_uuid = fd.file_uuid \ - WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL \ - GROUP BY i.id, i.uuid, i.name, i.metadata \ - ORDER BY face_count DESC LIMIT $2 OFFSET $3", - fd_table, id_table, video_table + r#"WITH face_matched AS ( + SELECT i.id, i.uuid::text, i.name, i.metadata, i.status, i.source, + COUNT(fd.id)::int4 as face_count, 0::int4 as speaker_count, + MIN(fd.frame_number)::int4 as start_frame, MAX(fd.frame_number)::int4 as end_frame, + MIN(fd.frame_number::float8 / NULLIF(v.fps, 0)) as start_time, + MAX(fd.frame_number::float8 / NULLIF(v.fps, 0)) as end_time, + AVG(fd.confidence)::float8 as confidence + FROM {} fd JOIN {} i ON i.id = fd.identity_id + JOIN {} v ON v.file_uuid = fd.file_uuid + WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL + GROUP BY i.id, i.uuid, i.name, i.metadata, i.status, i.source + ), + file_linked AS ( + SELECT i.id, i.uuid::text, i.name, i.metadata, i.status, i.source, + 0::int4 as face_count, 0::int4 as speaker_count, + NULL::int4 as start_frame, NULL::int4 as end_frame, + NULL::float8 as start_time, NULL::float8 as end_time, + fi.confidence::float8 as confidence + FROM {} fi JOIN {} i ON i.id = fi.identity_id + WHERE fi.file_uuid = $1 + ), + combined AS ( + SELECT * FROM face_matched + UNION + SELECT * FROM file_linked WHERE id NOT IN (SELECT id FROM face_matched) + ) + SELECT id, uuid, name, metadata, status, source, + face_count, speaker_count, start_frame, end_frame, + start_time, end_time, confidence + FROM combined + ORDER BY face_count DESC, name ASC + LIMIT $2 OFFSET $3"#, + fd_table, id_table, video_table, fi_table, id_table )) .bind(uuid) .bind(limit) .bind(offset) .fetch_all(&self.pool) .await?; + Ok(rows .into_iter() .map(|r| super::FileIdentityRecord {