fix: API endpoints for file_uuid filtering of pending identities

- get_file_identities: UNION face_detections + file_identities
- list_identities: add file_bindings from file_identities table
- Add back /api/v1/traces/unassigned route
- Total count query now includes file_identities

Frontend can now:
- Filter pending identities by file_uuid
- Filter pending faces (unassigned traces) by file_uuid
This commit is contained in:
Accusys
2026-06-26 14:26:36 +08:00
parent bd7d8c77bf
commit d791d138f2
3 changed files with 245 additions and 18 deletions

View File

@@ -34,6 +34,7 @@ pub fn identity_routes() -> Router<crate::api::types::AppState> {
.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<serde_json::Value>, Option<String>, Option<bool>)> = match sqlx::query_as(&sql)
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>, Option<String>, Option<bool>, 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<IdentityResponse> = rows
.into_iter()
.map(|r| {
let file_bindings: Vec<FileBinding> = 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<String> = 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<String>,
}
#[derive(Debug, Serialize)]
pub struct IdentityResponse {
pub id: i32,
@@ -289,6 +315,7 @@ pub struct IdentityResponse {
pub status: Option<String>,
pub starred: bool,
pub file_uuids: Vec<String>,
pub file_bindings: Option<Vec<FileBinding>>,
}
#[derive(Debug, Serialize)]
@@ -458,3 +485,173 @@ async fn list_face_candidates(
page_size,
}))
}
#[derive(Debug, Deserialize)]
pub struct UnassignedTracesQuery {
pub file_uuid: Option<String>,
pub page: Option<usize>,
pub page_size: Option<usize>,
}
#[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<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct UnassignedTracesResponse {
pub traces: Vec<UnassignedTrace>,
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<UnassignedTracesQuery>,
) -> Result<Json<UnassignedTracesResponse>, (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<serde_json::Value>)> =
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<UnassignedTrace> = 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,
}))
}

View File

@@ -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)

View File

@@ -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 {