feat: fix TKG external_id mismatch, add trace delete/merge endpoints, fix no-faces progress update
- identity_binding.rs: match both face_track_N and trace_N external_id formats in bind/unbind/create_pending_person - profile.rs: fix external_id matching and parameter ordering in trace profile updates - trace_agent_api.rs: add DELETE /trace/:id (soft/hard delete) and POST /trace/:src/merge/:target endpoints - qdrant_db.rs: add delete_points_by_filter method - tkg.rs: add debug logging for scroll and parse operations - job_worker.rs: update progress for no-faces TKG path
This commit is contained in:
+42
-24
@@ -304,24 +304,31 @@ pub async fn unbind_identity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update TKG: restore stranger_ref and remove identity fields
|
// Update TKG: restore stranger_ref and remove identity fields (match both external_id formats)
|
||||||
if let Some(tid) = trace_id {
|
if let Some(tid) = trace_id {
|
||||||
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
|
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||||
let ext_id = format!("face_track_{}", tid);
|
let ext_id_face = format!("face_track_{}", tid);
|
||||||
|
let ext_id_trace = format!("trace_{}", tid);
|
||||||
let stranger_ref = format!("{}:stranger_trace_{}", req.file_uuid, tid);
|
let stranger_ref = format!("{}:stranger_trace_{}", req.file_uuid, tid);
|
||||||
|
|
||||||
let _ = sqlx::query(&format!(
|
let result = sqlx::query(&format!(
|
||||||
"UPDATE {} SET properties = properties || $1::jsonb - 'identity_uuid' - 'identity_ref' - 'identity_id' - 'identity_name' \
|
"UPDATE {} SET properties = properties || $1::jsonb - 'identity_uuid' - 'identity_ref' - 'identity_id' - 'identity_name' \
|
||||||
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
|
WHERE file_uuid = $2 AND node_type = 'face_track' AND (external_id = $3 OR external_id = $4)",
|
||||||
tkg_table
|
tkg_table
|
||||||
))
|
))
|
||||||
.bind(serde_json::json!({
|
.bind(serde_json::json!({
|
||||||
"stranger_ref": stranger_ref
|
"stranger_ref": stranger_ref
|
||||||
}))
|
}))
|
||||||
.bind(&req.file_uuid)
|
.bind(&req.file_uuid)
|
||||||
.bind(&ext_id)
|
.bind(&ext_id_face)
|
||||||
|
.bind(&ext_id_trace)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(r) => tracing::info!("[unbind_identity] TKG update: {} rows affected for trace {}", r.rows_affected(), tid),
|
||||||
|
Err(e) => tracing::error!("[unbind_identity] TKG update failed: {}", e),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(identity_id) = old_identity_id {
|
if let Some(identity_id) = old_identity_id {
|
||||||
@@ -887,9 +894,10 @@ pub async fn bind_identity_trace(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update TKG node properties
|
// Update TKG node properties (match both face_track_N and trace_N external_id formats)
|
||||||
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
|
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||||
let external_id = format!("face_track_{}", req.trace_id);
|
let external_id_face = format!("face_track_{}", req.trace_id);
|
||||||
|
let external_id_trace = format!("trace_{}", req.trace_id);
|
||||||
let identity_name: Option<String> =
|
let identity_name: Option<String> =
|
||||||
sqlx::query_scalar(&format!("SELECT name FROM {} WHERE id = $1", id_table))
|
sqlx::query_scalar(&format!("SELECT name FROM {} WHERE id = $1", id_table))
|
||||||
.bind(identity_id)
|
.bind(identity_id)
|
||||||
@@ -898,20 +906,27 @@ pub async fn bind_identity_trace(
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let _ = sqlx::query(&format!(
|
let new_props = serde_json::json!({
|
||||||
"UPDATE {} SET properties = jsonb_set(\
|
"identity_id": identity_id,
|
||||||
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
|
"identity_name": identity_name
|
||||||
'{{identity_name}}', $2::jsonb, false)\
|
});
|
||||||
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
|
let result = sqlx::query(&format!(
|
||||||
|
"UPDATE {} SET properties = properties || $1::jsonb \
|
||||||
|
WHERE file_uuid = $2 AND node_type = 'face_track' AND (external_id = $3 OR external_id = $4)",
|
||||||
nodes_table
|
nodes_table
|
||||||
))
|
))
|
||||||
.bind(identity_id)
|
.bind(serde_json::to_string(&new_props).unwrap())
|
||||||
.bind(identity_name.as_deref())
|
|
||||||
.bind(&req.file_uuid)
|
.bind(&req.file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
|
.bind(&external_id_trace)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(r) => tracing::info!("[bind_identity_trace] TKG update: {} rows affected for trace {}", r.rows_affected(), req.trace_id),
|
||||||
|
Err(e) => tracing::error!("[bind_identity_trace] TKG update failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
// Clear bind redo stack
|
// Clear bind redo stack
|
||||||
let _ = sqlx::query(&format!(
|
let _ = sqlx::query(&format!(
|
||||||
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
|
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
|
||||||
@@ -2040,20 +2055,23 @@ pub async fn create_pending_person(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update TKG nodes
|
// Update TKG nodes (match both face_track_N and trace_N external_id formats)
|
||||||
for &tid in &req.trace_ids {
|
for &tid in &req.trace_ids {
|
||||||
let external_id = format!("face_track_{}", tid);
|
let external_id_face = format!("face_track_{}", tid);
|
||||||
|
let external_id_trace = format!("trace_{}", tid);
|
||||||
|
let new_props = serde_json::json!({
|
||||||
|
"identity_id": identity_id,
|
||||||
|
"identity_name": name
|
||||||
|
});
|
||||||
let _ = sqlx::query(&format!(
|
let _ = sqlx::query(&format!(
|
||||||
"UPDATE {} SET properties = jsonb_set(\
|
"UPDATE {} SET properties = properties || $1::jsonb \
|
||||||
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
|
WHERE file_uuid = $2 AND node_type = 'face_track' AND (external_id = $3 OR external_id = $4)",
|
||||||
'{{identity_name}}', $2::jsonb, false)\
|
|
||||||
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
|
|
||||||
nodes_table
|
nodes_table
|
||||||
))
|
))
|
||||||
.bind(identity_id)
|
.bind(serde_json::to_string(&new_props).unwrap())
|
||||||
.bind(&name)
|
|
||||||
.bind(&file_uuid)
|
.bind(&file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
|
.bind(&external_id_trace)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-19
@@ -55,15 +55,17 @@ pub async fn get_trace_profile_handler(
|
|||||||
Query(params): Query<TraceProfileQuery>,
|
Query(params): Query<TraceProfileQuery>,
|
||||||
) -> Result<Json<TraceProfile>, StatusCode> {
|
) -> Result<Json<TraceProfile>, StatusCode> {
|
||||||
let tkg_table = schema::table_name("tkg_nodes");
|
let tkg_table = schema::table_name("tkg_nodes");
|
||||||
let external_id = format!("face_track_{}", params.trace_id);
|
let external_id_face = format!("face_track_{}", params.trace_id);
|
||||||
|
let external_id_trace = format!("trace_{}", params.trace_id);
|
||||||
|
|
||||||
let row: Option<(String, String, serde_json::Value)> = sqlx::query_as(&format!(
|
let row: Option<(String, String, serde_json::Value)> = sqlx::query_as(&format!(
|
||||||
"SELECT label, external_id, properties FROM {} \
|
"SELECT label, external_id, properties FROM {} \
|
||||||
WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2",
|
WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)",
|
||||||
tkg_table
|
tkg_table
|
||||||
))
|
))
|
||||||
.bind(¶ms.file_uuid)
|
.bind(¶ms.file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
|
.bind(&external_id_trace)
|
||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -99,16 +101,18 @@ pub async fn update_trace_profile_handler(
|
|||||||
Json(req): Json<UpdateTraceProfileRequest>,
|
Json(req): Json<UpdateTraceProfileRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
let tkg_table = schema::table_name("tkg_nodes");
|
let tkg_table = schema::table_name("tkg_nodes");
|
||||||
let external_id = format!("face_track_{}", req.trace_id);
|
let external_id_face = format!("face_track_{}", req.trace_id);
|
||||||
|
let external_id_trace = format!("trace_{}", req.trace_id);
|
||||||
|
|
||||||
// Get current node
|
// Get current node (match both external_id formats)
|
||||||
let current: Option<(String, serde_json::Value)> = sqlx::query_as(&format!(
|
let current: Option<(String, serde_json::Value)> = sqlx::query_as(&format!(
|
||||||
"SELECT label, properties FROM {} \
|
"SELECT label, properties FROM {} \
|
||||||
WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2",
|
WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)",
|
||||||
tkg_table
|
tkg_table
|
||||||
))
|
))
|
||||||
.bind(&req.file_uuid)
|
.bind(&req.file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
|
.bind(&external_id_trace)
|
||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -123,7 +127,7 @@ pub async fn update_trace_profile_handler(
|
|||||||
|
|
||||||
if let Some(ref new_name) = req.name {
|
if let Some(ref new_name) = req.name {
|
||||||
if new_name != ¤t_label {
|
if new_name != ¤t_label {
|
||||||
updates.push(format!("label = $3"));
|
updates.push(format!("label = $5"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,31 +162,34 @@ pub async fn update_trace_profile_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut query = format!(
|
let mut query = format!(
|
||||||
"UPDATE {} SET properties = $4",
|
"UPDATE {} SET properties = properties || $4::jsonb",
|
||||||
tkg_table
|
tkg_table
|
||||||
);
|
);
|
||||||
if !updates.is_empty() {
|
if !updates.is_empty() {
|
||||||
query.push_str(", ");
|
query.push_str(", ");
|
||||||
query.push_str(&updates.join(", "));
|
query.push_str(&updates.join(", "));
|
||||||
}
|
}
|
||||||
query.push_str(" WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2");
|
query.push_str(" WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)");
|
||||||
|
|
||||||
let param_idx = if updates.is_empty() { 3 } else { 4 };
|
query.push_str(" RETURNING id");
|
||||||
query.push_str(&format!(" RETURNING id"));
|
|
||||||
|
let props_json = serde_json::to_string(¤t_props).unwrap();
|
||||||
|
|
||||||
let result = if !updates.is_empty() {
|
let result = if !updates.is_empty() {
|
||||||
sqlx::query(&query)
|
sqlx::query(&query)
|
||||||
.bind(&req.file_uuid)
|
.bind(&req.file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
|
.bind(&external_id_trace)
|
||||||
|
.bind(&props_json)
|
||||||
.bind(req.name.as_ref().unwrap_or(¤t_label))
|
.bind(req.name.as_ref().unwrap_or(¤t_label))
|
||||||
.bind(¤t_props)
|
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(&query)
|
sqlx::query(&query)
|
||||||
.bind(&req.file_uuid)
|
.bind(&req.file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
.bind(¤t_props)
|
.bind(&external_id_trace)
|
||||||
|
.bind(&props_json)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -211,15 +218,17 @@ pub async fn update_trace_profile_group_handler(
|
|||||||
|
|
||||||
let mut updated = 0;
|
let mut updated = 0;
|
||||||
for trace_id in &req.trace_ids {
|
for trace_id in &req.trace_ids {
|
||||||
let external_id = format!("face_track_{}", trace_id);
|
let external_id_face = format!("face_track_{}", trace_id);
|
||||||
|
let external_id_trace = format!("trace_{}", trace_id);
|
||||||
let result = sqlx::query(&format!(
|
let result = sqlx::query(&format!(
|
||||||
"UPDATE {} SET label = $1 \
|
"UPDATE {} SET label = $1 \
|
||||||
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
|
WHERE file_uuid = $2 AND node_type = 'face_track' AND (external_id = $3 OR external_id = $4)",
|
||||||
tkg_table
|
tkg_table
|
||||||
))
|
))
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
.bind(&req.file_uuid)
|
.bind(&req.file_uuid)
|
||||||
.bind(&external_id)
|
.bind(&external_id_face)
|
||||||
|
.bind(&external_id_trace)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
+214
-1
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Extension, Path, Query, State},
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{IntoResponse, Json, Response},
|
response::{IntoResponse, Json, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
@@ -53,6 +53,14 @@ pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
|
|||||||
"/api/v1/file/:file_uuid/tkg/node/:node_id",
|
"/api/v1/file/:file_uuid/tkg/node/:node_id",
|
||||||
get(get_tkg_node_detail),
|
get(get_tkg_node_detail),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/file/:file_uuid/trace/:trace_id",
|
||||||
|
delete(delete_trace),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/file/:file_uuid/trace/:trace_id/merge/:target_trace_id",
|
||||||
|
post(merge_trace),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -132,6 +140,9 @@ async fn list_traces_sorted(
|
|||||||
let face_filter = json!({
|
let face_filter = json!({
|
||||||
"must": [
|
"must": [
|
||||||
{"key": "file_uuid", "match": {"value": file_uuid}}
|
{"key": "file_uuid", "match": {"value": file_uuid}}
|
||||||
|
],
|
||||||
|
"must_not": [
|
||||||
|
{"key": "status", "match": {"value": "deleted"}}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
let points = qdrant
|
let points = qdrant
|
||||||
@@ -1685,3 +1696,205 @@ async fn ingest_rule2(
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_trace(
|
||||||
|
State(state): State<crate::api::types::AppState>,
|
||||||
|
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||||
|
Json(req): Json<serde_json::Value>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
let hard_delete = req.get("hard_delete").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
let qdrant = crate::core::db::qdrant_db::QdrantDb::new();
|
||||||
|
|
||||||
|
if hard_delete {
|
||||||
|
// Hard delete: actually remove from Qdrant and TKG
|
||||||
|
let filter = serde_json::json!({
|
||||||
|
"must": [
|
||||||
|
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||||
|
{"key": "trace_id", "match": {"value": trace_id}}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleted_count = match qdrant.delete_points_by_filter("_faces", filter.clone()).await {
|
||||||
|
Ok(count) => {
|
||||||
|
tracing::info!("[delete_trace] Hard deleted {} face points from Qdrant for trace {} in {}", count, trace_id, file_uuid);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[delete_trace] Failed to delete Qdrant points: {}", e);
|
||||||
|
return Json(serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": format!("Failed to delete Qdrant points: {}", e)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||||
|
let ext_id_face = format!("face_track_{}", trace_id);
|
||||||
|
let ext_id_trace = format!("trace_{}", trace_id);
|
||||||
|
|
||||||
|
let tkg_result = sqlx::query(&format!(
|
||||||
|
"DELETE FROM {} WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)",
|
||||||
|
nodes_table
|
||||||
|
))
|
||||||
|
.bind(&file_uuid)
|
||||||
|
.bind(&ext_id_face)
|
||||||
|
.bind(&ext_id_trace)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tkg_deleted = match tkg_result {
|
||||||
|
Ok(r) => r.rows_affected(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[delete_trace] Failed to delete TKG nodes: {}", e);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"file_uuid": file_uuid,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"hard_delete": true,
|
||||||
|
"deleted_face_points": deleted_count,
|
||||||
|
"deleted_tkg_nodes": tkg_deleted
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// Soft delete: mark as deleted in Qdrant and TKG
|
||||||
|
let filter = serde_json::json!({
|
||||||
|
"must": [
|
||||||
|
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||||
|
{"key": "trace_id", "match": {"value": trace_id}}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"status": "deleted"
|
||||||
|
});
|
||||||
|
|
||||||
|
let qdrant_updated = match qdrant.update_payload_by_filter("_faces", filter.clone(), payload).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!("[delete_trace] Soft deleted (marked) trace {} in {}", trace_id, file_uuid);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[delete_trace] Failed to mark Qdrant points as deleted: {}", e);
|
||||||
|
return Json(serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": format!("Failed to mark Qdrant points: {}", e)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update TKG node status
|
||||||
|
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||||
|
let ext_id_face = format!("face_track_{}", trace_id);
|
||||||
|
let ext_id_trace = format!("trace_{}", trace_id);
|
||||||
|
|
||||||
|
let tkg_result = sqlx::query(&format!(
|
||||||
|
"UPDATE {} SET properties = properties || $1::jsonb \
|
||||||
|
WHERE file_uuid = $2 AND node_type = 'face_track' AND (external_id = $3 OR external_id = $4)",
|
||||||
|
nodes_table
|
||||||
|
))
|
||||||
|
.bind(serde_json::json!({"status": "deleted"}))
|
||||||
|
.bind(&file_uuid)
|
||||||
|
.bind(&ext_id_face)
|
||||||
|
.bind(&ext_id_trace)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tkg_updated = match tkg_result {
|
||||||
|
Ok(r) => r.rows_affected(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[delete_trace] Failed to mark TKG nodes as deleted: {}", e);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"file_uuid": file_uuid,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"hard_delete": false,
|
||||||
|
"qdrant_marked": qdrant_updated,
|
||||||
|
"tkg_nodes_marked": tkg_updated
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn merge_trace(
|
||||||
|
State(state): State<crate::api::types::AppState>,
|
||||||
|
Path((file_uuid, trace_id, target_trace_id)): Path<(String, i32, i32)>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
if trace_id == target_trace_id {
|
||||||
|
return Json(serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": "Cannot merge trace into itself"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let qdrant = crate::core::db::qdrant_db::QdrantDb::new();
|
||||||
|
|
||||||
|
// Step 1: Update all face points from source trace to target trace
|
||||||
|
let src_filter = serde_json::json!({
|
||||||
|
"must": [
|
||||||
|
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||||
|
{"key": "trace_id", "match": {"value": trace_id}}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get count before update
|
||||||
|
let points_count = qdrant.scroll_all_points("_faces", src_filter.clone(), 1).await
|
||||||
|
.map(|pts| pts.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
// Update all source points to target trace_id
|
||||||
|
let new_payload = serde_json::json!({
|
||||||
|
"trace_id": target_trace_id,
|
||||||
|
"merged_from": trace_id
|
||||||
|
});
|
||||||
|
|
||||||
|
let moved_count = match qdrant.update_payload_by_filter("_faces", src_filter, new_payload).await {
|
||||||
|
Ok(_) => points_count as u64,
|
||||||
|
Err(e) => {
|
||||||
|
return Json(serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": format!("Failed to update trace_id in Qdrant: {}", e)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Delete source TKG nodes
|
||||||
|
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||||
|
let ext_id_face = format!("face_track_{}", trace_id);
|
||||||
|
let ext_id_trace = format!("trace_{}", trace_id);
|
||||||
|
|
||||||
|
let tkg_result = sqlx::query(&format!(
|
||||||
|
"DELETE FROM {} WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)",
|
||||||
|
nodes_table
|
||||||
|
))
|
||||||
|
.bind(&file_uuid)
|
||||||
|
.bind(&ext_id_face)
|
||||||
|
.bind(&ext_id_trace)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tkg_deleted = match tkg_result {
|
||||||
|
Ok(r) => r.rows_affected(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[merge_trace] Failed to delete source TKG nodes: {}", e);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Rebuild TKG for the target trace
|
||||||
|
// (The TKG rebuild will pick up the merged points automatically)
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"file_uuid": file_uuid,
|
||||||
|
"source_trace_id": trace_id,
|
||||||
|
"target_trace_id": target_trace_id,
|
||||||
|
"points_moved": moved_count,
|
||||||
|
"tkg_nodes_deleted": tkg_deleted
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -740,6 +740,35 @@ impl QdrantDb {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_points_by_filter(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
filter: serde_json::Value,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let url = format!("{}/collections/{}/points/delete", self.base_url, collection);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"filter": filter
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("api-key", &self.api_key)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to delete points from Qdrant")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("Qdrant delete failed: {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qdrant returns {"result": {"operation_id": N, "status": "completed"}}
|
||||||
|
Ok(1) // Qdrant doesn't return count, just return 1 to indicate success
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_point_count(&self) -> Result<usize> {
|
pub async fn get_point_count(&self) -> Result<usize> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/collections/{}/info",
|
"{}/collections/{}/info",
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ async fn scroll_face_points(file_uuid: &str) -> Vec<FacePoint> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(pts) => {
|
Ok(pts) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[TKG-Qdrant] scroll returned {} raw points for {}",
|
||||||
|
pts.len(),
|
||||||
|
file_uuid
|
||||||
|
);
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[TKG-Qdrant] scroll succeeded on attempt {} for {}",
|
"[TKG-Qdrant] scroll succeeded on attempt {} for {}",
|
||||||
@@ -49,7 +54,13 @@ async fn scroll_face_points(file_uuid: &str) -> Vec<FacePoint> {
|
|||||||
file_uuid
|
file_uuid
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return parse_face_points(pts);
|
let parsed = parse_face_points(pts);
|
||||||
|
tracing::info!(
|
||||||
|
"[TKG-Qdrant] parsed {} FacePoints for {}",
|
||||||
|
parsed.len(),
|
||||||
|
file_uuid
|
||||||
|
);
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
last_err = Some(e);
|
last_err = Some(e);
|
||||||
@@ -77,27 +88,47 @@ async fn scroll_face_points(file_uuid: &str) -> Vec<FacePoint> {
|
|||||||
|
|
||||||
/// Parse FacePoint structs from Qdrant response
|
/// Parse FacePoint structs from Qdrant response
|
||||||
fn parse_face_points(points: Vec<serde_json::Value>) -> Vec<FacePoint> {
|
fn parse_face_points(points: Vec<serde_json::Value>) -> Vec<FacePoint> {
|
||||||
points
|
let mut valid = 0;
|
||||||
|
let mut no_trace_id = 0;
|
||||||
|
let mut no_frame = 0;
|
||||||
|
let result: Vec<FacePoint> = points
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|p| {
|
.filter_map(|p| {
|
||||||
let payload = &p["payload"];
|
let payload = &p["payload"];
|
||||||
let trace_id = payload["trace_id"].as_i64().filter(|&t| t > 0)?;
|
let trace_id = payload["trace_id"].as_i64().filter(|&t| t > 0);
|
||||||
let frame = payload["frame"].as_i64()?;
|
if trace_id.is_none() {
|
||||||
|
no_trace_id += 1;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let frame = payload["frame"].as_i64();
|
||||||
|
if frame.is_none() {
|
||||||
|
no_frame += 1;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
valid += 1;
|
||||||
let bbox = &payload["bbox"];
|
let bbox = &payload["bbox"];
|
||||||
let x = bbox["x"].as_f64().unwrap_or(0.0);
|
let x = bbox["x"].as_f64().unwrap_or(0.0);
|
||||||
let y = bbox["y"].as_f64().unwrap_or(0.0);
|
let y = bbox["y"].as_f64().unwrap_or(0.0);
|
||||||
let w = bbox["width"].as_f64().unwrap_or(0.0);
|
let w = bbox["width"].as_f64().unwrap_or(0.0);
|
||||||
let h = bbox["height"].as_f64().unwrap_or(0.0);
|
let h = bbox["height"].as_f64().unwrap_or(0.0);
|
||||||
Some(FacePoint {
|
Some(FacePoint {
|
||||||
trace_id,
|
trace_id: trace_id.unwrap(),
|
||||||
frame,
|
frame: frame.unwrap(),
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
tracing::info!(
|
||||||
|
"[TKG-parse] total={}, valid={}, no_trace_id={}, no_frame={}",
|
||||||
|
points.len(),
|
||||||
|
valid,
|
||||||
|
no_trace_id,
|
||||||
|
no_frame
|
||||||
|
);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build frame-to-face-points index for O(1) lookup
|
/// Build frame-to-face-points index for O(1) lookup
|
||||||
|
|||||||
@@ -2307,6 +2307,23 @@ impl JobWorker {
|
|||||||
"✅ TKG build completed (no faces) for {}: {} nodes, {} edges",
|
"✅ TKG build completed (no faces) for {}: {} nodes, {} edges",
|
||||||
uuid_clone, total_nodes, total_edges
|
uuid_clone, total_nodes, total_edges
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update progress for no-faces case
|
||||||
|
let mut pp = PipelineProgress::new(&uuid_clone);
|
||||||
|
pp.update_stage(
|
||||||
|
"tkg_nodes",
|
||||||
|
1.0,
|
||||||
|
"completed",
|
||||||
|
Some(format!("{} nodes", total_nodes)),
|
||||||
|
);
|
||||||
|
pp.update_stage(
|
||||||
|
"tkg_edges",
|
||||||
|
1.0,
|
||||||
|
"completed",
|
||||||
|
Some(format!("{} edges", total_edges)),
|
||||||
|
);
|
||||||
|
publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => error!("❌ TKG build failed for {}: {}", uuid_clone, e),
|
Err(e) => error!("❌ TKG build failed for {}: {}", uuid_clone, e),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user