feat: Phase 2-3 TKG-only architecture

Phase 2.1: build_face_trace_nodes_from_qdrant()
- Read trace_id, frame, bbox directly from Qdrant payload
- No dependency on face_detections table

Phase 2.3: Rule2 queries TKG nodes
- identity resolution from tkg_nodes.properties.identity_id
- TKG-only architecture (Phase 2.3)

Phase 3: Identity Agent updates TKG nodes
- match_faces_iterative() updates tkg_nodes.properties
- bind_identity_trace() syncs identity_id to TKG
- unbind_identity() removes identity_id from TKG

Test results:
- 23 face_trace nodes from Qdrant (Phase 2.1)
- 75 relationship chunks (Rule2)
- TKG rebuild: Phase0 → Phase1 → Phase2
This commit is contained in:
Accusys
2026-06-21 01:30:04 +08:00
parent 2f2ccc94f7
commit 23c440104b
5 changed files with 305 additions and 23 deletions

View File

@@ -751,13 +751,26 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
round += 1;
}
// Update face_detections.identity_id
// Update face_detections.identity_id AND tkg_nodes.properties (Phase 3)
let fd_table = schema::table_name("face_detections");
let nodes_table = schema::table_name("tkg_nodes");
let id_table = schema::table_name("identities");
let identities_map: HashMap<String, i32> = tmdb_seeds
.iter()
.map(|(id, name, _)| (name.clone(), *id))
.collect();
// Batch query identity names
let identity_names: HashMap<i32, String> = sqlx::query_as::<_, (i32, String)>(&format!(
"SELECT id, name FROM {} WHERE id = ANY($1)",
id_table
))
.bind(identities_map.values().collect::<Vec<_>>())
.fetch_all(pool)
.await?
.into_iter()
.collect();
let mut updated = 0usize;
for (tid, name) in &matched {
let identity_id = identities_map.get(name);
@@ -773,6 +786,23 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
.await?
.rows_affected();
updated += rows as usize;
// Phase 3: Also update TKG node
let external_id = format!("trace_{}", tid);
let identity_name = identity_names.get(id);
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_trace' AND external_id = $4",
nodes_table
))
.bind(*id)
.bind(identity_name.as_deref())
.bind(file_uuid)
.bind(&external_id)
.execute(pool)
.await;
}
}
@@ -878,10 +908,11 @@ async fn match_faces_iterative_pg(pool: &sqlx::PgPool, file_uuid: &str) -> anyho
matched.len() * 100 / total_traces
);
// Step 5: 寫入 DB — Round 1 結果先存
// Step 5: 寫入 DB — Round 1 結果先存 (Phase 3: update both face_detections AND tkg_nodes)
let identities_table = schema::table_name("identities");
let strangers_table = schema::table_name("strangers");
let fd_table = schema::table_name("face_detections");
let nodes_table = schema::table_name("tkg_nodes");
let mut updated = 0usize;
for (tid, name) in &matched {
let id_opt = sqlx::query_scalar::<_, Option<i32>>(&format!(
@@ -901,6 +932,23 @@ async fn match_faces_iterative_pg(pool: &sqlx::PgPool, file_uuid: &str) -> anyho
.bind(tid)
.execute(pool)
.await;
// Phase 3: Also update TKG node
let external_id = format!("trace_{}", 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_trace' AND external_id = $4",
nodes_table
))
.bind(identity_id)
.bind(name.as_str())
.bind(file_uuid)
.bind(&external_id)
.execute(pool)
.await;
updated += 1;
}
}

View File

@@ -225,6 +225,32 @@ pub async fn unbind_identity(
)
})?;
// Phase 2.3: Also update TKG node (find trace_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
.ok()
.flatten();
if let Some(trace_id) = trace_id_opt {
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
let external_id = format!("trace_{}", trace_id);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = properties - 'identity_id' - 'identity_name' \
WHERE file_uuid = $1 AND node_type = 'face_trace' AND external_id = $2",
nodes_table
))
.bind(&req.file_uuid)
.bind(&external_id)
.execute(state.db.pool())
.await;
}
// Record history if there was a binding
if let Some(identity_id) = old_identity_id {
// Clear bind redo stack
@@ -794,6 +820,33 @@ pub async fn bind_identity_trace(
)
})?;
// Phase 2.3: Also update TKG node properties
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
let external_id = format!("trace_{}", req.trace_id);
let identity_name: Option<String> = sqlx::query_scalar(&format!(
"SELECT name FROM {} WHERE id = $1",
id_table
))
.bind(identity_id)
.fetch_optional(state.db.pool())
.await
.ok()
.flatten();
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_trace' AND external_id = $4",
nodes_table
))
.bind(identity_id)
.bind(identity_name.as_deref())
.bind(&req.file_uuid)
.bind(&external_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')",