feat: progressive multi-round face matching + pending person API

- Identity agent: per-face max matching, multi-round with derived
  seeds from high-confidence faces, angle diversity filter (cosine sim < 0.90)
- Pending person API: POST /file/:file_uuid/pending-person
  + GET /file/:file_uuid/pending-persons with status=pending, source=manual
- Update API docs (07_identity.md)
This commit is contained in:
Accusys
2026-06-24 03:42:04 +08:00
parent 766a1d9a6d
commit 14e886cc08
31 changed files with 5882 additions and 742 deletions
+447 -62
View File
@@ -93,15 +93,38 @@ pub async fn bind_identity(
)
})?;
// Capture old identity_id before bind
let old_identity_id: Option<i32> = sqlx::query_scalar(&format!(
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&req.face_id)
.fetch_optional(state.db.pool())
.await
let face_identifier = match (&req.face_id, req.id) {
(Some(fid), _) => fid.clone(),
(None, Some(id)) => id.to_string(),
(None, None) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Either face_id or id is required"})),
));
}
};
let use_id_field = req.id.is_some();
let old_identity_id: Option<i32> = if use_id_field {
sqlx::query_scalar(&format!(
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND id = $2",
table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.fetch_optional(state.db.pool())
.await
} else {
sqlx::query_scalar(&format!(
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.fetch_optional(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -110,16 +133,27 @@ pub async fn bind_identity(
})?
.flatten();
// Direct UPDATE face_detections.identity_id
let result = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
table
))
.bind(identity_id)
.bind(&req.file_uuid)
.bind(&req.face_id)
.execute(state.db.pool())
.await
let result = if use_id_field {
sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND id = $3",
table
))
.bind(identity_id)
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.execute(state.db.pool())
.await
} else {
sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
table
))
.bind(identity_id)
.bind(&req.file_uuid)
.bind(&face_identifier)
.execute(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -127,6 +161,67 @@ pub async fn bind_identity(
)
})?;
let trace_id: Option<i32> = if use_id_field {
sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2 LIMIT 1",
table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.fetch_optional(state.db.pool())
.await
} else {
sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2 LIMIT 1",
table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.fetch_optional(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?
.flatten();
// Update Qdrant + TKG if trace_id exists
if let Some(tid) = trace_id {
// 1. Update Qdrant payload
let face_db = crate::core::db::FaceEmbeddingDb::new();
if let Err(e) = face_db
.update_identity_by_trace(&req.file_uuid, tid, &uuid_clean)
.await
{
tracing::warn!(
"[bind] Failed to update Qdrant identity_uuid for trace {}: {}",
tid, e
);
}
// 2. Update TKG face_track node (dual-field design)
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
let ext_id = format!("face_track_{}", tid);
let identity_ref = format!("{}:identity_{}", req.file_uuid, identity_id);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = properties || $1::jsonb - 'stranger_ref' \
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
tkg_table
))
.bind(serde_json::json!({
"identity_uuid": uuid_clean,
"identity_ref": identity_ref
}))
.bind(&req.file_uuid)
.bind(&ext_id)
.execute(state.db.pool())
.await;
}
// Clear bind redo stack
let _ = sqlx::query(&format!(
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
@@ -144,10 +239,10 @@ pub async fn bind_identity(
crate::api::middleware::AuthSource::ApiKey => "api_key",
};
let before = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_before": old_identity_id
});
let after = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_after": identity_id
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": identity_id
});
let _ = sqlx::query(&format!(
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'bind', $2, $3, false, $4, $5)",
@@ -161,7 +256,6 @@ pub async fn bind_identity(
.execute(state.db.pool())
.await;
// Sync identity JSON file
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean)
.await
@@ -177,7 +271,7 @@ pub async fn bind_identity(
success: true,
message: format!(
"Bound face {} of {} to {}",
req.face_id, req.file_uuid, name
face_identifier, req.file_uuid, name
),
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
}))
@@ -193,15 +287,38 @@ pub async fn unbind_identity(
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
// Capture old identity_id before unbind
let old_identity_id: Option<i32> = sqlx::query_scalar(&format!(
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&req.face_id)
.fetch_optional(state.db.pool())
.await
let face_identifier = match (&req.face_id, req.id) {
(Some(fid), _) => fid.clone(),
(None, Some(id)) => id.to_string(),
(None, None) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Either face_id or id is required"})),
));
}
};
let use_id_field = req.id.is_some();
let old_identity_id: Option<i32> = if use_id_field {
sqlx::query_scalar(&format!(
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND id = $2",
table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.fetch_optional(state.db.pool())
.await
} else {
sqlx::query_scalar(&format!(
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.fetch_optional(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -210,14 +327,25 @@ pub async fn unbind_identity(
})?
.flatten();
let result = sqlx::query(&format!(
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&req.face_id)
.execute(state.db.pool())
.await
let result = if use_id_field {
sqlx::query(&format!(
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND id = $2",
table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.execute(state.db.pool())
.await
} else {
sqlx::query(&format!(
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.execute(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -225,15 +353,85 @@ pub async fn unbind_identity(
)
})?;
// Phase 2.3: Also update TKG node (find face_track_id first)
let trace_id_opt: Option<i32> = sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&req.face_id)
.fetch_optional(state.db.pool())
.await
let trace_id: Option<i32> = if use_id_field {
sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2 LIMIT 1",
table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.fetch_optional(state.db.pool())
.await
} else {
sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2 LIMIT 1",
table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.fetch_optional(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?
.flatten();
// Clear Qdrant + TKG if trace_id exists
if let Some(tid) = trace_id {
// 1. Clear Qdrant payload
let face_db = crate::core::db::FaceEmbeddingDb::new();
if let Err(e) = face_db
.clear_identity_by_trace(&req.file_uuid, tid)
.await
{
tracing::warn!(
"[unbind] Failed to clear Qdrant identity_uuid for trace {}: {}",
tid, e
);
}
// 2. Update TKG face_track node (restore stranger_ref)
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
let ext_id = format!("face_track_{}", tid);
let stranger_ref = format!("{}:stranger_trace_{}", req.file_uuid, tid);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = properties || $1::jsonb - 'identity_uuid' - 'identity_ref' \
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
tkg_table
))
.bind(serde_json::json!({
"stranger_ref": stranger_ref
}))
.bind(&req.file_uuid)
.bind(&ext_id)
.execute(state.db.pool())
.await;
}
let trace_id_opt: Option<i32> = if use_id_field {
sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2",
table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.fetch_optional(state.db.pool())
.await
} else {
sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.fetch_optional(state.db.pool())
.await
}
.ok()
.flatten();
@@ -251,9 +449,7 @@ pub async fn unbind_identity(
.await;
}
// Record history if there was a binding
if let Some(identity_id) = old_identity_id {
// Clear bind redo stack
let _ = sqlx::query(&format!(
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
history_table
@@ -262,7 +458,6 @@ pub async fn unbind_identity(
.execute(state.db.pool())
.await;
// Insert history record
let uid = auth.user_id.to_string();
let usrc = match auth.source {
crate::api::middleware::AuthSource::Jwt => "jwt",
@@ -270,10 +465,10 @@ pub async fn unbind_identity(
crate::api::middleware::AuthSource::ApiKey => "api_key",
};
let before = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_before": old_identity_id
});
let after = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_after": null
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": null
});
let _ = sqlx::query(&format!(
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'unbind', $2, $3, false, $4, $5)",
@@ -315,7 +510,7 @@ pub async fn unbind_identity(
Ok(Json(ApiResponse {
success: true,
message: format!("Unbound face {} from {}", req.face_id, req.file_uuid),
message: format!("Unbound face {} from {}", face_identifier, req.file_uuid),
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
}))
}
@@ -933,14 +1128,14 @@ pub async fn get_identity_traces(
COUNT(*)::bigint AS frame_count,
MIN(fd.frame_number)::int AS first_frame,
MAX(fd.frame_number)::int AS last_frame,
ROUND(MIN(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1)::float8 AS first_sec,
ROUND(MAX(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1)::float8 AS last_sec,
COALESCE(ROUND(MIN(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1), 0)::float8 AS first_sec,
COALESCE(ROUND(MAX(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1), 0)::float8 AS last_sec,
ROUND(AVG(fd.confidence)::numeric, 4)::float8 AS avg_confidence
FROM {} fd
LEFT JOIN dev.videos v ON fd.file_uuid = v.file_uuid
WHERE fd.identity_id = $1
GROUP BY trace_id, v.fps
ORDER BY trace_id
LEFT JOIN videos v ON fd.file_uuid = v.file_uuid
WHERE fd.identity_id = $1 AND fd.trace_id IS NOT NULL
GROUP BY fd.file_uuid, fd.trace_id, v.fps
ORDER BY fd.trace_id
LIMIT $2 OFFSET $3"#,
fd_table
))
@@ -953,7 +1148,7 @@ pub async fn get_identity_traces(
// Get total count for pagination
let total: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE trace_id) sub",
"SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE fd.identity_id = $1 AND fd.trace_id IS NOT NULL GROUP BY fd.trace_id) sub",
fd_table
))
.bind(identity_id)
@@ -1864,6 +2059,188 @@ pub async fn bind_history(
}))
}
// ============================================================================
// Pending Person API (file-scoped)
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePendingPersonRequest {
#[serde(default)]
pub trace_ids: Vec<i32>,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingPersonItem {
pub identity_uuid: String,
pub identity_id: i32,
pub name: String,
pub created_at: String,
pub trace_count: i64,
pub bound_traces: Option<Vec<i32>>,
}
/// Create a pending person under a file, optionally binding traces.
pub async fn create_pending_person(
State(state): State<crate::api::types::AppState>,
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
Path(file_uuid): Path<String>,
Json(req): Json<CreatePendingPersonRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let fd_table = crate::core::db::schema::table_name("face_detections");
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
// Auto-generate name if not provided
let name = if let Some(n) = &req.name {
n.clone()
} else {
let count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND status = 'pending'",
id_table
))
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
format!("Person {}", count + 1)
};
// Create identity with pending status
let identity_row: (i32, String) = sqlx::query_as(&format!(
"INSERT INTO {} (name, identity_type, source, status, file_uuid) VALUES ($1, 'people', 'manual', 'pending', $2) RETURNING id, uuid::text",
id_table
))
.bind(&name)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to create identity: {}", e)})),
)
})?;
let (identity_id, identity_uuid): (i32, String) = identity_row;
// Bind traces if provided
let bound_traces = if !req.trace_ids.is_empty() {
// Update face_detections
let _ = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = ANY($3)",
fd_table
))
.bind(identity_id)
.bind(&file_uuid)
.bind(&req.trace_ids)
.execute(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to bind traces: {}", e)})),
)
})?;
// Update TKG nodes
for &tid in &req.trace_ids {
let external_id = format!("face_track_{}", tid);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = jsonb_set(\
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
'{{identity_name}}', $2::jsonb, false)\
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
nodes_table
))
.bind(identity_id)
.bind(&name)
.bind(&file_uuid)
.bind(&external_id)
.execute(state.db.pool())
.await;
}
Some(req.trace_ids.clone())
} else {
None
};
// Sync identity file
let _ = crate::core::identity::storage::save_identity_file_by_pool(
state.db.pool(),
&identity_uuid,
)
.await;
Ok(Json(ApiResponse {
success: true,
message: format!("Created pending person: {} (uuid: {})", name, identity_uuid),
data: Some(serde_json::json!({
"identity_uuid": identity_uuid,
"identity_id": identity_id,
"name": name,
"bound_traces": bound_traces.map(|v| v.len()).unwrap_or(0),
})),
}))
}
/// List pending persons for a file.
pub async fn list_pending_persons(
State(state): State<crate::api::types::AppState>,
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
Path(file_uuid): Path<String>,
) -> Result<Json<ApiResponse<Vec<PendingPersonItem>>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let fd_table = crate::core::db::schema::table_name("face_detections");
let rows: Vec<(i32, String, String, chrono::NaiveDateTime)> = sqlx::query_as(&format!(
"SELECT id, uuid::text, name, created_at FROM {} WHERE file_uuid = $1 AND status = 'pending' ORDER BY created_at DESC",
id_table
))
.bind(&file_uuid)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let mut items = Vec::new();
for (id, uuid, name, created_at) in rows {
let trace_count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id = $1 AND file_uuid = $2",
fd_table
))
.bind(id)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.unwrap_or(0);
items.push(PendingPersonItem {
identity_uuid: uuid,
identity_id: id,
name,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
trace_count,
bound_traces: None,
});
}
Ok(Json(ApiResponse {
success: true,
message: format!("Found {} pending persons for {}", items.len(), file_uuid),
data: Some(items),
}))
}
pub fn identity_binding_routes() -> Router<crate::api::types::AppState> {
Router::new()
.route("/api/v1/identity/:identity_uuid/bind", post(bind_identity))
@@ -1892,4 +2269,12 @@ pub fn identity_binding_routes() -> Router<crate::api::types::AppState> {
.route("/api/v1/identity/merge/:merge_id/undo", post(undo_merge))
.route("/api/v1/identity/merge/:merge_id/redo", post(redo_merge))
.route("/api/v1/identity/merge/history", get(get_merge_history))
.route(
"/api/v1/file/:file_uuid/pending-person",
post(create_pending_person),
)
.route(
"/api/v1/file/:file_uuid/pending-persons",
get(list_pending_persons),
)
}