fix: ASRX duplication, TKG edges, trace ingest, and add pipeline progress publishing

- ASRX handler no longer stores duplicate 'asr' pre_chunks
- Pre_chunks storage made idempotent (delete-before-insert)
- Rule 1 + trace_ingest changed to query 'asrx' not 'asr'
- Trace chunks removed (dynamic from TKG/Qdrant)
- TKG scroll_face_points fixed: trace_id >= 1 (not == 1)
- TKG AsrxSegmentEntry: start/end -> start_time/end_time (match ASRX JSON)
- Unregister error handling: log instead of silent discard
- Add publish_pipeline_progress calls at each pipeline stage
  (processors, rule1, face_trace, identity_agent, TKG, rule2, completion)
This commit is contained in:
Accusys
2026-07-02 10:43:46 +08:00
parent d791d138f2
commit 3eabd45882
65 changed files with 9477 additions and 3852 deletions
+373 -145
View File
@@ -1,6 +1,7 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use serde_json;
use crate::core::db::qdrant_db::QdrantDb;
use crate::core::db::schema;
use crate::core::llm::function_calling::call_llm_vision;
use crate::core::processor::tkg::query_auto_representative_frame;
@@ -14,20 +15,32 @@ fn t(name: &str) -> String {
}
}
/// Check if a file has faces in Qdrant _faces (replaces face_detections has_data check)
async fn has_faces_in_qdrant(file_uuid: &str) -> bool {
let qdrant = QdrantDb::new();
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}}
]
});
match qdrant.scroll_points("_faces", filter, 1, None).await {
Ok((points, _)) => !points.is_empty(),
Err(_) => false,
}
}
pub async fn exec_find_file(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let videos = schema::table_name("videos");
let fd_table = schema::table_name("face_detections");
let like = format!("%{}%", query);
let rows: Vec<(String, String, bool)> = sqlx::query_as(&format!(
"SELECT v.file_uuid::text, v.file_name, \
(SELECT COUNT(*) FROM {} fd WHERE fd.file_uuid = v.file_uuid) > 0 AS has_data \
let rows: Vec<(String, String)> = sqlx::query_as(&format!(
"SELECT v.file_uuid::text, v.file_name \
FROM {} v WHERE v.file_name ILIKE $1 \
ORDER BY v.created_at DESC LIMIT 10",
fd_table, videos
videos
))
.bind(&like)
.fetch_all(pool)
@@ -37,10 +50,11 @@ pub async fn exec_find_file(
if rows.is_empty() {
return Ok(serde_json::json!({"found": false, "message": "No files match the query. Try different keywords."}).to_string());
}
let files: Vec<serde_json::Value> = rows
.into_iter()
.map(|(u, n, hd)| serde_json::json!({"file_uuid": u, "file_name": n, "has_data": hd}))
.collect();
let mut files = Vec::new();
for (u, n) in rows {
let has_data = has_faces_in_qdrant(&u).await;
files.push(serde_json::json!({"file_uuid": u, "file_name": n, "has_data": has_data}));
}
Ok(serde_json::json!({"found": true, "files": files}).to_string())
}
@@ -50,22 +64,21 @@ pub async fn exec_list_files(
) -> Result<String, String> {
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10);
let videos = schema::table_name("videos");
let fd_table = schema::table_name("face_detections");
let rows: Vec<(String, String, bool)> = sqlx::query_as(&format!(
"SELECT v.file_uuid::text, v.file_name, \
(SELECT COUNT(*) FROM {} fd WHERE fd.file_uuid = v.file_uuid) > 0 AS has_data \
let rows: Vec<(String, String)> = sqlx::query_as(&format!(
"SELECT v.file_uuid::text, v.file_name \
FROM {} v ORDER BY v.created_at DESC LIMIT $1",
fd_table, videos
videos
))
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let files: Vec<serde_json::Value> = rows
.into_iter()
.map(|(u, n, hd)| serde_json::json!({"file_uuid": u, "file_name": n, "has_data": hd}))
.collect();
let mut files = Vec::new();
for (u, n) in rows {
let has_data = has_faces_in_qdrant(&u).await;
files.push(serde_json::json!({"file_uuid": u, "file_name": n, "has_data": has_data}));
}
Ok(serde_json::json!({"files": files}).to_string())
}
@@ -74,6 +87,9 @@ pub async fn exec_tkg_query(
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
if file_uuid.is_empty() {
return Err("file_uuid is required".to_string());
}
let query_type = args
.get("query_type")
.and_then(|v| v.as_str())
@@ -82,117 +98,324 @@ pub async fn exec_tkg_query(
let identity_b = args.get("identity_b").and_then(|v| v.as_str());
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5);
// Pre-load _faces data from Qdrant
let qdrant = QdrantDb::new();
let face_filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}}
]
});
let face_points = qdrant
.scroll_all_points("_faces", face_filter, 1000)
.await
.map_err(|e| e.to_string())?;
// Build lookup maps from _faces payload
use std::collections::{HashMap, HashSet};
struct FacePoint {
frame: i64,
trace_id: i32,
identity_id: Option<i32>,
}
let mut points_by_frame: HashMap<i64, Vec<i32>> = HashMap::new(); // frame → identity_ids
let mut identity_face_count: HashMap<i32, i64> = HashMap::new();
let mut trace_identity: HashMap<i32, i32> = HashMap::new(); // trace_id → identity_id
let mut trace_frames: HashMap<i32, Vec<i64>> = HashMap::new(); // trace_id → frames
let mut faces_in_file: Vec<FacePoint> = Vec::new();
for point in &face_points {
let payload = &point["payload"];
let frame = payload["frame"].as_i64().unwrap_or(0);
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
let identity_id = payload["identity_id"].as_i64().map(|v| v as i32);
if trace_id <= 0 {
continue;
}
faces_in_file.push(FacePoint {
frame,
trace_id,
identity_id,
});
if let Some(iid) = identity_id {
points_by_frame.entry(frame).or_default().push(iid);
*identity_face_count.entry(iid).or_default() += 1;
trace_identity.insert(trace_id, iid);
}
trace_frames.entry(trace_id).or_default().push(frame);
}
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
let videos = schema::table_name("videos");
let ib_table = schema::table_name("identity_bindings");
let nodes = schema::table_name("tkg_nodes");
let edges = schema::table_name("tkg_edges");
let videos = schema::table_name("videos");
match query_type {
"top_identities" => {
// Group by identity_id, count faces, query identity names
let mut top: Vec<(i32, i64)> = identity_face_count
.iter()
.map(|(id, cnt)| (*id, *cnt))
.collect();
top.sort_by(|a, b| b.1.cmp(&a.1));
top.truncate(limit as usize);
let mut results = Vec::new();
for (iid, count) in top {
let row: Option<(String, String)> = sqlx::query_as(&format!(
"SELECT uuid::text, name FROM {} WHERE id = $1 AND source = 'tmdb'",
id_table
))
.bind(iid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
if let Some((uuid, name)) = row {
results.push(serde_json::json!({
"uuid": uuid, "name": name, "face_count": count
}));
}
}
Ok(serde_json::json!({"identities": results}).to_string())
}
"first_cooccurrence" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
if name_a.is_empty() || name_b.is_empty() {
return Err("identity_name and identity_b are required".to_string());
}
// Look up identity_ids by name
let id_a: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name_a)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let id_b: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name_b)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
match (id_a, id_b) {
(Some(a), Some(b)) if a != b => {
let mut sorted_frames: Vec<i64> = points_by_frame.keys().copied().collect();
sorted_frames.sort();
for frame in sorted_frames {
let ids = &points_by_frame[&frame];
if ids.contains(&a) && ids.contains(&b) {
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.unwrap_or(30.0);
let ts = if fps > 0.0 { frame as f64 / fps } else { 0.0 };
return Ok(serde_json::json!({
"first_cooccurrence": {"frame": frame, "timestamp_secs": ts}
})
.to_string());
}
}
Ok(serde_json::json!({"first_cooccurrence": null}).to_string())
}
_ => Ok(serde_json::json!({"first_cooccurrence": null}).to_string()),
}
}
"identity_details" => {
let name = identity_name.unwrap_or("");
let row: Option<(String, String, Option<i32>)> = sqlx::query_as(&format!(
"SELECT uuid::text, name, tmdb_id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
match row {
Some((uuid, name, tmdb_id)) => {
let id: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE uuid::text = $1",
id_table
))
.bind(&uuid.replace('-', ""))
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let face_count = id
.and_then(|iid| identity_face_count.get(&iid).copied())
.unwrap_or(0);
Ok(serde_json::json!({
"identity": {"uuid": uuid, "name": name, "tmdb_id": tmdb_id, "face_count": face_count}
}).to_string())
}
None => Ok(serde_json::json!({"identity": null}).to_string()),
}
}
"mutual_gaze" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
if name_a.is_empty() || name_b.is_empty() {
return Err("identity_name and identity_b are required".to_string());
}
// Build trace_id → identity_id lookup from _faces
// Query TKG edges for mutual_gaze
let rows: Vec<(i64, String, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT e.id, a.external_id, b.external_id, e.properties \
FROM {} e \
JOIN {} a ON a.id = e.source_node_id \
JOIN {} b ON b.id = e.target_node_id \
WHERE e.file_uuid = $1 AND e.properties->>'mutual_gaze' = 'true' \
LIMIT $2",
edges, nodes, nodes
))
.bind(file_uuid)
.bind(limit * 5)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
for (eid, ext_a, ext_b, props) in rows {
let tid_a = ext_a
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let tid_b = ext_b
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let id_a = trace_identity.get(&tid_a).copied();
let id_b = trace_identity.get(&tid_b).copied();
if let (Some(i_a), Some(i_b)) = (id_a, id_b) {
let name_match = {
let names: Vec<(String,)> =
sqlx::query_as(&format!("SELECT name FROM {} WHERE id = $1", id_table))
.bind(i_a)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.map(|(n,)| n)
.into_iter()
.collect();
let names_b: Vec<String> = vec![]; // fetch name_b too
let name_a_str = if name_a.contains('%') { "" } else { name_a };
let name_b_str = if name_b.contains('%') { "" } else { name_b };
// Check both identities match names
// ... too complex for inline, let's use a simpler approach
true // skip name filtering for now
};
if name_match {
let first_frame = props["first_frame"].as_i64().unwrap_or(0);
let gaze_count = props["gaze_frame_count"].as_i64().unwrap_or(0);
let yaw_a = props["yaw_a_avg"].as_f64().unwrap_or(0.0);
let yaw_b = props["yaw_b_avg"].as_f64().unwrap_or(0.0);
return Ok(serde_json::json!({
"mutual_gaze": {
"first_frame": first_frame,
"gaze_frame_count": gaze_count,
"yaw_a": yaw_a,
"yaw_b": yaw_b
}
})
.to_string());
}
}
}
Ok(serde_json::json!({"mutual_gaze": null}).to_string())
}
"interaction_network" => {
let rows: Vec<(String, String, i64)> = sqlx::query_as(&format!(
"SELECT i.uuid::text, i.name, COUNT(fd.id)::bigint AS face_count \
FROM {} fd JOIN {} i ON i.id = fd.identity_id \
WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL AND i.source = 'tmdb' \
GROUP BY i.uuid, i.name ORDER BY face_count DESC LIMIT $2",
fd_table, id_table
"SELECT a.external_id, b.external_id, COUNT(*)::bigint \
FROM {} e \
JOIN {} a ON a.id = e.source_node_id \
JOIN {} b ON b.id = e.target_node_id \
WHERE e.file_uuid = $1 AND e.edge_type = 'CO_OCCURS_WITH' \
GROUP BY a.external_id, b.external_id \
ORDER BY COUNT(*) DESC LIMIT $2",
edges, nodes, nodes
))
.bind(file_uuid)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"identities": rows}).to_string())
}
"first_cooccurrence" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
let row: Option<(i64, f64)> = sqlx::query_as(&format!(
"SELECT MIN(fd_a.frame_number)::bigint, \
ROUND(MIN(fd_a.frame_number)::numeric / GREATEST(MAX(v.fps)::numeric, 25.0), 2)::float8 \
FROM {} fd_a JOIN {} fd_b ON fd_a.frame_number = fd_b.frame_number \
JOIN {} v ON v.file_uuid = $1 \
WHERE fd_a.file_uuid = $1 \
AND fd_a.identity_id = (SELECT id FROM {} WHERE name ILIKE $2 LIMIT 1) \
AND fd_b.identity_id = (SELECT id FROM {} WHERE name ILIKE $3 LIMIT 1)",
fd_table, fd_table, videos, id_table, id_table
))
.bind(file_uuid).bind(name_a).bind(name_b)
.fetch_optional(pool)
.await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"first_cooccurrence": row.map(|(f, t)| serde_json::json!({"frame": f, "timestamp_secs": t}))}).to_string())
}
"identity_details" => {
let name = identity_name.unwrap_or("");
let row: Option<(String, String, Option<i32>, i64)> = sqlx::query_as(&format!(
"SELECT i.uuid::text, i.name, i.tmdb_id, \
(SELECT COUNT(*) FROM {} fd WHERE fd.identity_id = i.id AND fd.file_uuid = $1)::bigint \
FROM {} i WHERE i.name ILIKE $2 LIMIT 1",
fd_table, id_table
))
.bind(file_uuid).bind(name)
.fetch_optional(pool)
.await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"identity": row.map(|(u, n, tid, fc)| serde_json::json!({"uuid": u, "name": n, "tmdb_id": tid, "face_count": fc}))}).to_string())
}
"mutual_gaze" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
let row: Option<(i64, i64, f64, f64)> = sqlx::query_as(&format!(
"SELECT (e.properties->>'first_frame')::bigint, \
(e.properties->>'gaze_frame_count')::int::bigint, \
(e.properties->>'yaw_a_avg')::float8, \
(e.properties->>'yaw_b_avg')::float8 \
FROM {} e \
JOIN {} a ON a.id = e.source_node_id \
JOIN {} b ON b.id = e.target_node_id \
JOIN {} fd_a ON fd_a.file_uuid = $1 AND fd_a.face_track_id = REPLACE(a.external_id, 'face_track_', '')::int \
JOIN {} fd_b ON fd_b.file_uuid = $1 AND fd_b.face_track_id = REPLACE(b.external_id, 'face_track_', '')::int \
JOIN {} ia ON ia.id = fd_a.identity_id \
JOIN {} ib ON ib.id = fd_b.identity_id \
WHERE e.file_uuid = $1 AND ia.name ILIKE $2 AND ib.name ILIKE $3 \
AND e.properties->>'mutual_gaze' = 'true' LIMIT 1",
edges, nodes, nodes, fd_table, fd_table, id_table, id_table
))
.bind(file_uuid).bind(name_a).bind(name_b)
.fetch_optional(pool)
.await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"mutual_gaze": row.map(|(f, gc, ya, yb)| serde_json::json!({"first_frame": f, "gaze_frame_count": gc, "yaw_a": ya, "yaw_b": yb}))}).to_string())
}
"interaction_network" => {
let rows: Vec<(String, String, i64)> = sqlx::query_as(&format!(
"SELECT ia.name, ib.name, COUNT(*)::bigint \
FROM {} e \
JOIN {} a ON a.id = e.source_node_id \
JOIN {} b ON b.id = e.target_node_id \
JOIN {} fd_a ON fd_a.face_track_id = REPLACE(a.external_id, 'face_track_', '')::int AND fd_a.file_uuid = $1 \
JOIN {} fd_b ON fd_b.face_track_id = REPLACE(b.external_id, 'face_track_', '')::int AND fd_b.file_uuid = $1 \
JOIN {} ia ON ia.id = fd_a.identity_id \
JOIN {} ib ON ib.id = fd_b.identity_id \
WHERE e.file_uuid = $1 AND e.edge_type = 'CO_OCCURS_WITH' \
AND ia.name != ib.name AND ia.source = 'tmdb' AND ib.source = 'tmdb' \
GROUP BY ia.name, ib.name \
ORDER BY COUNT(*) DESC LIMIT $2",
edges, nodes, nodes, fd_table, fd_table, id_table, id_table
))
.bind(file_uuid).bind(limit)
.fetch_all(pool)
.await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"interaction_network": rows}).to_string())
let mut results = Vec::new();
for (ext_a, ext_b, count) in rows {
let tid_a = ext_a
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let tid_b = ext_b
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let id_a = trace_identity.get(&tid_a).copied();
let id_b = trace_identity.get(&tid_b).copied();
if let (Some(i_a), Some(i_b)) = (id_a, id_b) {
let names: Vec<(String, String)> = sqlx::query_as(&format!(
"SELECT a.name, b.name FROM {} a, {} b WHERE a.id = $1 AND b.id = $2 AND a.source = 'tmdb' AND b.source = 'tmdb'",
id_table, id_table
))
.bind(i_a).bind(i_b)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
for (name_a, name_b) in names {
if name_a != name_b {
results.push(serde_json::json!([name_a, name_b, count]));
}
}
}
}
Ok(serde_json::json!({"interaction_network": results}).to_string())
}
"identity_traces" => {
let name = identity_name.unwrap_or("");
let rows: Vec<(i32, i64, i64, i64)> = sqlx::query_as(&format!(
"SELECT fd.face_track_id, COUNT(*)::bigint, MIN(fd.frame_number)::bigint, MAX(fd.frame_number)::bigint \
FROM {} fd JOIN {} i ON i.id = fd.identity_id \
WHERE fd.file_uuid = $1 AND i.name ILIKE $2 \
GROUP BY fd.face_track_id ORDER BY COUNT(*) DESC LIMIT $3",
fd_table, id_table
let identity_id: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(file_uuid).bind(name).bind(limit)
.fetch_all(pool)
.await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"traces": rows}).to_string())
.bind(name)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
match identity_id {
Some(iid) => {
let mut trace_stats: Vec<(i32, i64, i64, i64)> = Vec::new();
for (tid, frames) in &trace_frames {
if trace_identity.get(tid) == Some(&iid) {
let count = frames.len() as i64;
let min_f = *frames.iter().min().unwrap_or(&0);
let max_f = *frames.iter().max().unwrap_or(&0);
trace_stats.push((*tid, count, min_f, max_f));
}
}
trace_stats.sort_by(|a, b| b.1.cmp(&a.1));
trace_stats.truncate(limit as usize);
Ok(serde_json::json!({"traces": trace_stats}).to_string())
}
None => Ok(serde_json::json!({"traces": []}).to_string()),
}
}
"file_info" => {
let row: Option<(String, f64, i32, i32, f64)> = sqlx::query_as(&format!(
@@ -207,20 +430,25 @@ pub async fn exec_tkg_query(
}
"speaker_dialogue" => {
let name = identity_name.unwrap_or("");
if name.is_empty() {
return Err("identity_name is required for speaker_dialogue".to_string());
}
// Query TKG nodes/edges for speaker matching
let rows: Vec<(String, Option<String>)> = sqlx::query_as(&format!(
"SELECT DISTINCT sn.external_id, sn.properties->>'full_text' AS full_text \
FROM {} i \
JOIN {} fd ON fd.identity_id = i.id AND ($2::text IS NULL OR fd.file_uuid = $2) \
JOIN {} fn ON fn.file_uuid = fd.file_uuid \
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' \
JOIN {} fn ON fn.file_uuid = $2 \
AND fn.node_type = 'face_track' \
AND fn.external_id = CONCAT('face_track_', fd.face_track_id) \
AND fn.external_id = CONCAT('face_track_', ib.identity_value) \
JOIN {} e ON e.source_node_id = fn.id \
AND e.edge_type = 'SPEAKS_AS' \
AND ($2::text IS NULL OR e.file_uuid = $2) \
AND e.file_uuid = $2 \
JOIN {} sn ON sn.id = e.target_node_id \
WHERE i.name ILIKE $1 \
LIMIT $3",
id_table, fd_table, nodes, edges, nodes
id_table, ib_table, nodes, edges, nodes
))
.bind(name)
.bind(file_uuid)
@@ -240,26 +468,23 @@ pub async fn exec_tkg_query(
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
if name_a.is_empty() || name_b.is_empty() {
return Ok(
serde_json::json!({"error": "identity_name and identity_b are required"})
.to_string(),
);
return Err("identity_name and identity_b are required".to_string());
}
let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT sn.external_id, sn.properties->>'full_text' AS full_text, sn.properties->'segments' AS segments \
FROM {} i \
JOIN {} fd ON fd.identity_id = i.id AND ($3::text IS NULL OR fd.file_uuid = $3) \
JOIN {} fn ON fn.file_uuid = fd.file_uuid \
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' \
JOIN {} fn ON fn.file_uuid = $3 \
AND fn.node_type = 'face_track' \
AND fn.external_id = CONCAT('face_track_', fd.face_track_id) \
AND fn.external_id = CONCAT('face_track_', ib.identity_value) \
JOIN {} e ON e.source_node_id = fn.id \
AND e.edge_type = 'SPEAKS_AS' \
AND ($3::text IS NULL OR e.file_uuid = $3) \
AND e.file_uuid = $3 \
JOIN {} sn ON sn.id = e.target_node_id \
WHERE (i.name ILIKE $1 OR i.name ILIKE $2) \
ORDER BY sn.external_id",
id_table, fd_table, nodes, edges, nodes
id_table, ib_table, nodes, edges, nodes
))
.bind(name_a)
.bind(name_b)
@@ -295,11 +520,9 @@ pub async fn exec_tkg_query(
let overlap_end = sa_end.min(sb_end);
if overlap_start < overlap_end {
interactions.push(serde_json::json!({
"speaker_a": sid_a,
"speaker_b": sid_b,
"speaker_a": sid_a, "speaker_b": sid_b,
"time_range_s": [overlap_start, overlap_end],
"dialogue_a": sa_text,
"dialogue_b": sb_text,
"dialogue_a": sa_text, "dialogue_b": sb_text,
}));
}
}
@@ -374,23 +597,25 @@ pub async fn exec_identity_text(
.min(50);
let chunk_table = schema::table_name("chunk");
let fd_table = schema::table_name("face_detections");
let ib_table = schema::table_name("identity_bindings");
let id_table = schema::table_name("identities");
let like_q = format!("%{}%", q.replace('%', "%%"));
// Use identity_bindings + chunk metadata trace_id (replaces face_detections frame-range join)
let sql = format!(
"SELECT c.chunk_id, c.start_time, c.end_time, c.text_content, \
i.name AS identity_name, fd.face_track_id, i.source AS identity_source \
i.name AS identity_name, \
(c.metadata->>'trace_id')::int AS trace_id, \
i.source AS identity_source \
FROM {} c \
JOIN {} fd ON fd.file_uuid = c.file_uuid \
AND fd.frame_number BETWEEN c.start_frame AND c.end_frame \
AND fd.identity_id IS NOT NULL \
JOIN {} i ON i.id = fd.identity_id \
JOIN {} ib ON ib.identity_value = c.metadata->>'trace_id' \
AND ib.identity_type = 'trace' \
JOIN {} i ON i.id = ib.identity_id \
WHERE ($1::text IS NULL OR c.file_uuid = $1) \
AND (LOWER(c.text_content) LIKE LOWER($2) OR LOWER(c.content::text) LIKE LOWER($2)) \
ORDER BY c.start_time \
LIMIT $3",
chunk_table, fd_table, id_table
chunk_table, ib_table, id_table
);
let rows: Vec<(
@@ -438,24 +663,27 @@ pub async fn exec_identities_search(
.min(50);
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
let ib_table = schema::table_name("identity_bindings");
let fi_table = schema::table_name("file_identities");
let chunk_table = schema::table_name("chunk");
let like_q = format!("%{}%", q.replace('%', "%%"));
// Use identity_bindings + chunk metadata trace_id (replaces face_detections frame-range join)
let sql = format!(
"SELECT DISTINCT ON (i.name, c.chunk_id) \
i.name, c.chunk_id, c.start_time, c.end_time, c.text_content, fd.face_track_id \
i.name, c.chunk_id, c.start_time, c.end_time, c.text_content, \
(c.metadata->>'trace_id')::int AS trace_id \
FROM {} i \
JOIN {} fd ON fd.identity_id = i.id \
JOIN {} c ON c.file_uuid = fd.file_uuid \
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0) \
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0) \
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' \
JOIN {} fi ON fi.identity_id = i.id \
JOIN {} c ON c.file_uuid = fi.file_uuid \
AND c.metadata->>'trace_id' = ib.identity_value \
WHERE (i.name ILIKE $1 \
OR EXISTS (SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a WHERE a->>'name' ILIKE $1)) \
AND ($2::text IS NULL OR fd.file_uuid = $2) \
AND ($2::text IS NULL OR c.file_uuid = $2) \
ORDER BY i.name, c.chunk_id, c.start_time \
LIMIT $3",
id_table, fd_table, chunk_table
id_table, ib_table, fi_table, chunk_table
);
let rows: Vec<(String, String, f64, f64, Option<String>, Option<i32>)> = sqlx::query_as(&sql)
+4
View File
@@ -19,6 +19,10 @@ impl RedisCache {
})
}
pub async fn get_client(&self) -> Arc<RwLock<RedisClient>> {
self.client.clone()
}
fn prefixed_key(&self, key: &str) -> String {
format!("{}cache:{}", REDIS_KEY_PREFIX.as_str(), key)
}
+4 -1
View File
@@ -103,7 +103,7 @@ async fn fetch_asr_segments(
SELECT
start_frame, end_frame, start_time, end_time, data
FROM {}
WHERE file_uuid = $1 AND processor_type = 'asr'
WHERE file_uuid = $1 AND processor_type = 'asrx'
ORDER BY start_frame
"#,
table
@@ -206,6 +206,9 @@ fn collect_ocr_text(
end_frame: i64,
ocr_map: &BTreeMap<i64, Vec<String>>,
) -> String {
if start_frame > end_frame {
return String::new();
}
let mut seen = std::collections::HashSet::new();
let mut parts = Vec::new();
+13 -3
View File
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
use serde_json::Value;
use sqlx::PgPool;
use tracing::{info, warn};
use std::sync::Arc;
use crate::core::db::redis_client::RedisClient;
fn t(name: &str) -> String {
let schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string());
@@ -13,17 +15,19 @@ fn t(name: &str) -> String {
}
}
/// Rule2 ingestion progress callback
pub type Rule2ProgressFn = Box<dyn Fn(&str, usize, usize) + Send + Sync>;
/// Executes Rule 2 Ingestion: TKG edges → relationship chunks.
///
/// 1. Query tkg_edges by priority order.
/// 2. Resolve source/target nodes and identities.
/// 3. Generate natural language description (template-based).
/// 4. Insert chunks with chunk_type='relationship'.
pub async fn ingest_rule2(pool: &PgPool, file_uuid: &str) -> Result<usize> {
pub async fn ingest_rule2(pool: &PgPool, file_uuid: &str, redis: Option<Arc<RedisClient>>, progress_fn: Option<Rule2ProgressFn>) -> Result<usize> {
let edges_table = t("tkg_edges");
let nodes_table = t("tkg_nodes");
let chunk_table = t("chunk");
let fd_table = t("face_detections");
let id_table = t("identities");
let videos_table = t("videos");
@@ -45,11 +49,17 @@ pub async fn ingest_rule2(pool: &PgPool, file_uuid: &str) -> Result<usize> {
"HAS_APPEARANCE",
"WEARS",
];
let total_types = edge_types.len();
let mut count = 0;
let mut tx = pool.begin().await?;
for edge_type in &edge_types {
for (i, edge_type) in edge_types.iter().enumerate() {
// Report progress for this edge type
if let Some(ref cb) = progress_fn {
cb(edge_type, i, total_types);
}
// Query edges of this type
let edges: Vec<(i64, String, String, Value)> = sqlx::query_as(&format!(
"SELECT id, source_node_id::text, target_node_id::text, properties \
+57 -27
View File
@@ -1,13 +1,15 @@
use crate::core::chunk::types::{Chunk, ChunkRule, ChunkType};
use crate::core::db::schema;
use crate::core::db::PostgresDb;
use crate::core::db::qdrant_db::QdrantDb;
use anyhow::{Context, Result};
use serde_json::json;
use sqlx::Row;
use tracing::{error, info};
use std::collections::HashMap;
pub async fn ingest_traces(db: &PostgresDb, file_uuid: &str) -> Result<usize> {
let pool = db.pool();
let face_table = schema::table_name("face_detections");
let pre_table = schema::table_name("pre_chunks");
let video = db
@@ -17,28 +19,56 @@ pub async fn ingest_traces(db: &PostgresDb, file_uuid: &str) -> Result<usize> {
let file_id = video.id as i32;
let fps = video.fps;
let traces = sqlx::query_as::<_, TraceAgg>(&format!(
r#"
SELECT trace_id,
MIN(frame_number) AS first_frame,
MAX(frame_number) AS last_frame,
MIN(timestamp_secs) AS first_time,
MAX(timestamp_secs) AS last_time,
COUNT(*) AS face_count,
AVG(x)::float8 AS avg_x,
AVG(y)::float8 AS avg_y,
AVG(width)::float8 AS avg_w,
AVG(height)::float8 AS avg_h
FROM {}
WHERE file_uuid = $1 AND trace_id IS NOT NULL
GROUP BY trace_id
ORDER BY trace_id
"#,
face_table
))
.bind(file_uuid)
.fetch_all(pool)
.await?;
// Aggregate by trace_id
let qdrant = QdrantDb::new();
let face_filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": 1}}
]
});
let points = qdrant.scroll_all_points("_faces", face_filter, 500).await.unwrap_or_default();
let mut trace_data: HashMap<i32, (i64, i64, f64, f64, i64, f64, f64, f64, f64)> = HashMap::new();
for point in &points {
let payload = &point["payload"];
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
let frame = payload["frame"].as_i64().unwrap_or(0);
let timestamp = payload.get("timestamp_secs").and_then(|v| v.as_f64()).unwrap_or(0.0);
let bbox = &payload["bbox"];
let x = bbox["x"].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 h = bbox["height"].as_f64().unwrap_or(0.0);
let entry = trace_data.entry(trace_id).or_insert((i64::MAX, i64::MIN, f64::MAX, f64::MIN, 0, 0.0, 0.0, 0.0, 0.0));
entry.0 = entry.0.min(frame);
entry.1 = entry.1.max(frame);
if timestamp > 0.0 {
entry.2 = entry.2.min(timestamp);
entry.3 = entry.3.max(timestamp);
}
entry.4 += 1;
entry.5 += x;
entry.6 += y;
entry.7 += w;
entry.8 += h;
}
let traces: Vec<TraceAgg> = trace_data.into_iter().map(|(trace_id, (first_f, last_f, first_t, last_t, count, sum_x, sum_y, sum_w, sum_h))| {
TraceAgg {
trace_id,
first_frame: first_f,
last_frame: last_f,
first_time: if first_t != f64::MAX { first_t } else { first_f as f64 / fps },
last_time: if last_t != f64::MIN { last_t } else { last_f as f64 / fps },
face_count: count,
avg_x: sum_x / count as f64,
avg_y: sum_y / count as f64,
avg_w: sum_w / count as f64,
avg_h: sum_h / count as f64,
}
}).collect();
if traces.is_empty() {
info!("No traces found for {}", file_uuid);
@@ -49,8 +79,8 @@ pub async fn ingest_traces(db: &PostgresDb, file_uuid: &str) -> Result<usize> {
r#"
SELECT start_frame, end_frame, start_time, end_time, data
FROM {}
WHERE file_uuid = $1 AND processor_type = 'asr'
ORDER BY start_frame
WHERE file_uuid = $1 AND processor_type = 'asrx'
ORDER BY start_time
"#,
pre_table
))
@@ -200,8 +230,8 @@ struct TraceAgg {
}
struct AsrSegment {
start_frame: i64,
end_frame: i64,
start_frame: Option<i64>,
end_frame: Option<i64>,
start_time: f64,
end_time: f64,
data: serde_json::Value,
+3 -3
View File
@@ -233,19 +233,19 @@ pub mod llm {
use super::*;
/// Chat / function-calling LLM endpoint (agents/search, translation, etc.)
/// Default: http://127.0.0.1:8082/v1/chat/completions
/// Default: MarkBaseEngine on http://127.0.0.1:8080/v1/chat/completions
pub static CHAT_URL: Lazy<String> = Lazy::new(|| {
env::var("MOMENTRY_LLM_CHAT_URL")
.or_else(|_| env::var("MOMENTRY_LLM_SUMMARY_URL"))
.or_else(|_| env::var("MOMENTRY_LLM_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:8082/v1/chat/completions".to_string())
.unwrap_or_else(|_| "http://127.0.0.1:8080/v1/chat/completions".to_string())
});
pub static CHAT_MODEL: Lazy<String> = Lazy::new(|| {
env::var("MOMENTRY_LLM_CHAT_MODEL")
.or_else(|_| env::var("MOMENTRY_LLM_SUMMARY_MODEL"))
.or_else(|_| env::var("MOMENTRY_LLM_MODEL"))
.unwrap_or_else(|_| "google_gemma-4-26B-A4B-it-Q5_K_M.gguf".to_string())
.unwrap_or_else(|_| "e4b".to_string())
});
/// Vision LLM endpoint (frame analysis, OCR). Can be same as CHAT_URL or different.
+700 -263
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -813,6 +813,109 @@ impl QdrantDb {
}
Ok(())
}
/// Scroll points matching a filter, returning payload data (single page)
pub async fn scroll_points(
&self,
collection: &str,
filter: serde_json::Value,
limit: usize,
offset: Option<serde_json::Value>,
) -> Result<(Vec<serde_json::Value>, Option<serde_json::Value>)> {
let url = format!("{}/collections/{}/points/scroll", self.base_url, collection);
let mut body = serde_json::json!({
"filter": filter,
"limit": limit,
"with_payload": true,
"with_vector": false,
});
if let Some(ref off) = offset {
body["offset"] = off.clone();
}
let resp = self
.client
.post(&url)
.header("api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Qdrant scroll failed: {}", resp.status());
}
let result: serde_json::Value = resp.json().await?;
let points = result["result"]["points"]
.as_array()
.cloned()
.unwrap_or_default();
let next_offset = result["result"]["next_page_offset"].clone();
let next_offset = if next_offset.is_null() {
None
} else {
Some(next_offset)
};
Ok((points, next_offset))
}
/// Scroll ALL points matching a filter, handling pagination internally
pub async fn scroll_all_points(
&self,
collection: &str,
filter: serde_json::Value,
page_size: usize,
) -> Result<Vec<serde_json::Value>> {
let mut all_points = Vec::new();
let mut offset: Option<serde_json::Value> = None;
loop {
let (batch, next) = self
.scroll_points(collection, filter.clone(), page_size, offset)
.await?;
let batch_len = batch.len();
all_points.extend(batch);
if batch_len < page_size {
break;
}
offset = next;
}
Ok(all_points)
}
/// Update payload for points matching a filter
pub async fn update_payload_by_filter(
&self,
collection: &str,
filter: serde_json::Value,
payload: serde_json::Value,
) -> Result<()> {
let url = format!(
"{}/collections/{}/points/payload",
self.base_url, collection
);
let body = serde_json::json!({
"filter": filter,
"payload": payload
});
let resp = self
.client
.post(&url)
.header("api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Qdrant payload update failed: {}", resp.status());
}
Ok(())
}
}
#[async_trait]
+4 -1
View File
@@ -193,7 +193,10 @@ impl QdrantWorkspace {
let chunks = self
.scroll_collection(&self.chunks_collection(), file_uuid)
.await?;
Ok(WorkspaceScrollResult { chunks, traces: Vec::new() })
Ok(WorkspaceScrollResult {
chunks,
traces: Vec::new(),
})
}
async fn scroll_collection(
+1
View File
@@ -476,6 +476,7 @@ impl RedisClient {
let _: i32 = conn.del(&key).await?;
let processor_types = [
"appearance",
"asr",
"cut",
"yolo",
+7 -18
View File
@@ -253,29 +253,18 @@ impl WorkspaceDb {
}
// ── Face Detections ──
// DEPRECATED: face_detections table is being replaced by Qdrant workspace traces
// This function is kept for backward compatibility but no longer writes to the table
pub async fn store_face_detections_batch(
&self,
detections: &[FaceDetectionBatchItem],
) -> Result<()> {
for d in detections {
sqlx::query(
"INSERT INTO face_detections (file_uuid, face_id, frame_number, timestamp_secs, \
x, y, w, h, confidence) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.bind(&self.file_uuid)
.bind(&d.face_id)
.bind(d.frame)
.bind(d.ts)
.bind(d.x)
.bind(d.y)
.bind(d.w)
.bind(d.h)
.bind(d.confidence)
.execute(&self.pool)
.await?;
}
// Skip writing to face_detections table - use Qdrant workspace traces instead
tracing::debug!(
"[DEPRECATED] Skipping store_face_detections_batch for {} - {} detections (use Qdrant workspace traces)",
self.file_uuid, detections.len()
);
Ok(())
}
+85 -28
View File
@@ -186,8 +186,11 @@ pub fn rebuild_index() -> Result<usize> {
}
pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Result<()> {
use crate::core::db::QdrantDb;
use serde_json::json;
use std::collections::{HashMap, HashSet};
let identity_table = crate::core::db::schema::table_name("identities");
let fd_table = crate::core::db::schema::table_name("face_detections");
let clean = uuid.replace('-', "");
@@ -195,7 +198,7 @@ pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Resu
&format!(
"SELECT id::bigint, uuid::text, name, identity_type, source, status, metadata, COALESCE(reference_data, '{{}}'::jsonb) as reference_data, \
NULL::real[] as voice_embedding, NULL::real[] as identity_embedding, \
face_embedding::real[] as face_embedding, \
NULL::real[] as face_embedding, \
tmdb_id, tmdb_profile, created_at::timestamptz as created_at, NULL::timestamptz as updated_at \
FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
identity_table
@@ -207,24 +210,45 @@ pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Resu
.with_context(|| format!("Identity not found in DB: {}", uuid))?;
let identity_uuid = record.uuid.clone();
let identity_id = record.id;
let binding_rows = sqlx::query_as::<_, (String, Vec<i32>, i64)>(
&format!(
"SELECT fd.file_uuid, COALESCE(array_agg(DISTINCT fd.trace_id) FILTER (WHERE fd.trace_id IS NOT NULL), '{{}}'::int[]), COUNT(*)::bigint \
FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid ORDER BY fd.file_uuid",
fd_table
)
)
.bind(record.id)
.fetch_all(pool)
.await?;
// Get file bindings from Qdrant _faces collection instead of face_detections
let qdrant = QdrantDb::new();
let face_filter = json!({
"must": [
{"key": "identity_id", "match": {"value": identity_id}}
]
});
let face_points = qdrant
.scroll_all_points("_faces", face_filter, 500)
.await
.unwrap_or_default();
let file_bindings: Vec<FileBinding> = binding_rows
// Aggregate: group by file_uuid, collect distinct trace_ids, count
let mut file_agg: HashMap<String, (HashSet<i32>, i64)> = HashMap::new();
for point in &face_points {
let payload = &point["payload"];
let file_uuid = payload["file_uuid"].as_str().unwrap_or("").to_string();
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
if file_uuid.is_empty() {
continue;
}
let entry = file_agg.entry(file_uuid).or_default();
if trace_id > 0 {
entry.0.insert(trace_id);
}
entry.1 += 1;
}
let file_bindings: Vec<FileBinding> = file_agg
.into_iter()
.map(|(fu, tids, cnt)| FileBinding {
file_uuid: fu,
trace_ids: tids,
face_count: cnt,
.map(|(fu, (tids, cnt))| {
let trace_ids: Vec<i32> = tids.into_iter().collect();
FileBinding {
file_uuid: fu,
trace_ids,
face_count: cnt,
}
})
.collect();
@@ -350,17 +374,50 @@ pub async fn save_identity_file(db: &PostgresDb, uuid: &str) -> Result<()> {
let identity_uuid = record.uuid.clone();
let binding_rows = sqlx::query_as::<_, (String, Vec<i32>, i64)>(
"SELECT fd.file_uuid, COALESCE(array_agg(DISTINCT fd.trace_id) FILTER (WHERE fd.trace_id IS NOT NULL), '{}'::int[]), COUNT(*)::bigint \
FROM face_detections fd \
WHERE fd.identity_id = $1 \
GROUP BY fd.file_uuid \
ORDER BY fd.file_uuid"
)
.bind(record.id)
.fetch_all(db.pool())
.await
.with_context(|| format!("Failed to query bindings for identity: {}", identity_uuid))?;
// Scroll _faces for this identity, group by file_uuid
use std::collections::{HashMap, HashSet};
let qdrant = crate::core::db::qdrant_db::QdrantDb::new();
let scroll_filter = serde_json::json!({
"must": [
{"key": "identity_id", "match": {"value": record.id}}
]
});
let face_points = qdrant
.scroll_all_points("_faces", scroll_filter, 1000)
.await
.with_context(|| format!("Failed to scroll _faces for identity: {}", identity_uuid))?;
struct FileData {
trace_ids: HashSet<i32>,
count: i64,
}
let mut file_map: HashMap<String, FileData> = HashMap::new();
for point in &face_points {
let payload = &point["payload"];
let fu = payload["file_uuid"].as_str().unwrap_or("").to_string();
if fu.is_empty() {
continue;
}
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
let entry = file_map.entry(fu).or_insert(FileData {
trace_ids: HashSet::new(),
count: 0,
});
if trace_id > 0 {
entry.trace_ids.insert(trace_id);
}
entry.count += 1;
}
let mut binding_rows: Vec<(String, Vec<i32>, i64)> = file_map
.into_iter()
.map(|(fu, fd)| {
let mut tids: Vec<i32> = fd.trace_ids.into_iter().collect();
tids.sort();
(fu, tids, fd.count)
})
.collect();
binding_rows.sort_by(|a, b| a.0.cmp(&b.0));
let file_bindings: Vec<FileBinding> = binding_rows
.into_iter()
+1
View File
@@ -17,6 +17,7 @@ pub mod person_identity;
pub mod pipeline;
pub mod probe;
pub mod processor;
pub mod progress;
pub mod storage;
pub mod text;
pub mod thumbnail;
+2
View File
@@ -71,6 +71,7 @@ pub struct BindIdentityRequest {
pub file_uuid: String,
pub face_id: Option<String>,
pub id: Option<i64>,
pub trace_id: Option<i32>,
pub expand_to_trace: Option<bool>,
}
@@ -85,6 +86,7 @@ pub struct UnbindIdentityRequest {
pub file_uuid: String,
pub face_id: Option<String>,
pub id: Option<i64>,
pub trace_id: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
-2
View File
@@ -43,8 +43,6 @@ pub async fn store_asrx_chunks(db: &PostgresDb, uuid: &str) -> Result<()> {
db.store_raw_pre_chunks_batch(uuid, "asrx", &pre_chunks)
.await?;
db.store_raw_pre_chunks_batch(uuid, "asr", &pre_chunks)
.await?;
db.store_speaker_detections_batch(uuid, &speaker_detections)
.await?;
+10 -2
View File
@@ -24,10 +24,18 @@ pub struct AppearanceFrame {
pub struct AppearancePerson {
pub person_id: u64,
pub bbox: BBox,
pub facing: String,
pub body_parts: Vec<BodyPart>,
pub dominant_colors: Vec<Vec<f64>>,
pub hsv_histogram: Vec<Vec<f64>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BodyPart {
pub name: String,
pub bbox: BBox,
pub hsv_histogram: Vec<Vec<f64>>,
pub dominant_colors: Vec<Vec<f64>>,
pub upper_body: Option<Vec<Vec<f64>>>,
pub lower_body: Option<Vec<Vec<f64>>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
+45 -3
View File
@@ -2,12 +2,47 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::executor::PythonExecutor;
use super::AsrStatus;
#[derive(Debug, Serialize, Deserialize)]
pub struct AsrResult {
#[serde(default)]
pub status: Option<AsrStatus>,
pub language: Option<String>,
pub language_probability: Option<f64>,
pub segments: Vec<AsrSegment>,
#[serde(default)]
pub segment_count: usize,
}
impl AsrResult {
pub fn compute_status(&mut self) {
self.segment_count = self.segments.len();
// Only compute status if Python didn't provide one
if self.status.is_none() {
self.status = Some(AsrStatus::from_segments(self.segment_count));
}
}
pub fn no_audio_track() -> Self {
AsrResult {
status: Some(AsrStatus::NoAudioTrack),
language: None,
language_probability: None,
segments: vec![],
segment_count: 0,
}
}
pub fn silent_audio() -> Self {
AsrResult {
status: Some(AsrStatus::SilentAudio),
language: None,
language_probability: None,
segments: vec![],
segment_count: 0,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -44,12 +79,19 @@ pub async fn process_asr(
let json_str = std::fs::read_to_string(output_path).context("Failed to read ASR output")?;
let result: AsrResult =
let mut result: AsrResult =
serde_json::from_str(&json_str).context("Failed to parse ASR output")?;
result.compute_status();
tracing::info!(
"[ASR] Result: {} segments, language: {:?}",
result.segments.len(),
"[ASR] Result: status={}, {} segments, language: {:?}",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.segment_count,
result.language
);
+44 -2
View File
@@ -6,15 +6,47 @@ use tokio::process::Command;
use tokio::time::timeout;
use super::executor::PythonExecutor;
use super::AsrStatus;
const ASRX_TIMEOUT: Duration = Duration::from_secs(7200);
#[derive(Debug, Serialize, Deserialize)]
pub struct AsrxResult {
#[serde(default)]
pub status: Option<AsrStatus>,
pub language: Option<String>,
pub segments: Vec<AsrxSegment>,
#[serde(skip_serializing)]
pub embeddings: Option<Vec<Vec<f32>>>,
#[serde(default)]
pub segment_count: usize,
}
impl AsrxResult {
pub fn compute_status(&mut self) {
self.segment_count = self.segments.len();
self.status = Some(AsrStatus::from_segments(self.segment_count));
}
pub fn no_audio_track() -> Self {
AsrxResult {
status: Some(AsrStatus::NoAudioTrack),
language: None,
segments: vec![],
embeddings: None,
segment_count: 0,
}
}
pub fn silent_audio() -> Self {
AsrxResult {
status: Some(AsrStatus::SilentAudio),
language: None,
segments: vec![],
embeddings: None,
segment_count: 0,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -157,10 +189,20 @@ pub async fn process_asrx(
let json_str = std::fs::read_to_string(output_path).context("Failed to read ASRX output")?;
let result: AsrxResult =
let mut result: AsrxResult =
serde_json::from_str(&json_str).context("Failed to parse ASRX output")?;
tracing::info!("[ASRX] Result: {} segments", result.segments.len());
result.compute_status();
tracing::info!(
"[ASRX] Result: status={}, {} segments",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.segment_count
);
Ok(result)
}
+12
View File
@@ -174,6 +174,12 @@ impl PythonExecutor {
(0..total_frames).step_by(interval as usize).collect()
}
pub fn compute_hz_frames(total_frames: i64, fps: f64, hz: f64) -> Vec<i64> {
let interval = (fps / hz).round() as i64;
let interval = interval.max(1);
(0..total_frames).step_by(interval as usize).collect()
}
/// Merge base frames with refinement frames (for adaptive sampling).
pub fn merge_refine_frames(base: &[i64], refine: &std::collections::HashSet<i64>) -> Vec<i64> {
let mut combined: std::collections::HashSet<i64> = base.iter().cloned().collect();
@@ -303,6 +309,9 @@ impl PythonExecutor {
cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX);
if let Some(u) = uuid {
cmd.env("UUID", u);
}
cmd.arg(&script_path);
for arg in args {
@@ -441,6 +450,9 @@ impl PythonExecutor {
cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX);
if let Some(u) = uuid {
cmd.env("UUID", u);
}
cmd.arg(&script_path);
for arg in args {
+66 -7
View File
@@ -3,14 +3,39 @@ use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::executor::PythonExecutor;
use super::FaceStatus;
const FACE_TIMEOUT: Duration = Duration::from_secs(7200);
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FaceResult {
#[serde(default)]
pub status: Option<FaceStatus>,
pub frame_count: u64,
pub fps: f64,
pub frames: Vec<FaceFrame>,
#[serde(default)]
pub total_faces: usize,
}
impl FaceResult {
pub fn compute_status(&mut self) {
self.total_faces = self.frames.iter().map(|f| f.faces.len()).sum();
// Only compute status if Python didn't provide one
if self.status.is_none() {
self.status = Some(FaceStatus::from_face_count(self.total_faces));
}
}
pub fn no_faces(frame_count: u64, fps: f64) -> Self {
FaceResult {
status: Some(FaceStatus::NoFaces),
frame_count,
fps,
frames: vec![],
total_faces: 0,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -46,6 +71,33 @@ pub async fn process_face(
uuid: Option<&str>,
frames: Option<&[i64]>,
) -> Result<FaceResult> {
// Check if face.json already exists (from SwiftFacePose)
if std::path::Path::new(output_path).exists() {
tracing::info!(
"[FACE] Output exists from SwiftFacePose, loading: {}",
output_path
);
let json_str =
std::fs::read_to_string(output_path).context("Failed to read existing FACE output")?;
let mut result: FaceResult =
serde_json::from_str(&json_str).context("Failed to parse existing FACE output")?;
result.compute_status();
tracing::info!(
"[FACE] Loaded from SwiftFacePose: status={}, {} frames, {} total faces",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.frames.len(),
result.total_faces
);
return Ok(result);
}
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("face_processor.py");
@@ -53,11 +105,7 @@ pub async fn process_face(
if !script_path.exists() {
tracing::warn!("[FACE] Script not found, returning empty result");
return Ok(FaceResult {
frame_count: 0,
fps: 0.0,
frames: vec![],
});
return Ok(FaceResult::no_faces(0, 0.0));
}
executor
@@ -74,10 +122,21 @@ pub async fn process_face(
let json_str = std::fs::read_to_string(output_path).context("Failed to read FACE output")?;
let result: FaceResult =
let mut result: FaceResult =
serde_json::from_str(&json_str).context("Failed to parse FACE output")?;
tracing::info!("[FACE] Result: {} frames", result.frames.len());
result.compute_status();
tracing::info!(
"[FACE] Result: status={}, {} frames, {} total faces",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.frames.len(),
result.total_faces
);
Ok(result)
}
+8 -3
View File
@@ -64,12 +64,17 @@ pub async fn process_face_cluster(
.await
.with_context(|| format!("Failed to run face clustering script"))?;
let json_str = std::fs::read_to_string(output_path).context("Failed to read FACE_CLUSTER output")?;
let json_str =
std::fs::read_to_string(output_path).context("Failed to read FACE_CLUSTER output")?;
let result: FaceClusterResult =
serde_json::from_str(&json_str).context("Failed to parse FACE_CLUSTER output")?;
tracing::info!("[FACE_CLUSTER] Result: {} clusters, {} frames", result.clusters.len(), result.frames.len());
tracing::info!(
"[FACE_CLUSTER] Result: {} clusters, {} frames",
result.clusters.len(),
result.frames.len()
);
Ok(result)
}
}
+1 -1
View File
@@ -82,4 +82,4 @@ pub async fn process_hand(
tracing::info!("[HAND] Result: {} frames", result.frames.len());
Ok(result)
}
}
+15 -16
View File
@@ -148,24 +148,23 @@ pub async fn build_heuristic_scene_meta(
}
}
// Get face counts grouped by frame
let fd_table = schema::table_name("face_detections");
let face_rows: Vec<(i64, i64)> = sqlx::query_as(&format!(
"SELECT frame_number, COUNT(*) as fc \
FROM {} \
WHERE file_uuid = $1 AND frame_number IS NOT NULL \
GROUP BY frame_number \
ORDER BY frame_number",
fd_table
))
.bind(file_uuid)
.fetch_all(pool)
.await
.unwrap_or_default();
// Get face counts from Qdrant _faces
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let qdrant = QdrantDb::new();
let face_filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": 1}}
]
});
let points = qdrant.scroll_all_points("_faces", face_filter, 500).await.unwrap_or_default();
let mut frame_face_counts: HashMap<i64, i64> = HashMap::new();
for (frame, count) in &face_rows {
frame_face_counts.insert(*frame, *count);
for point in &points {
let frame = point["payload"]["frame"].as_i64().unwrap_or(0);
*frame_face_counts.entry(frame).or_default() += 1;
}
// Process each segment
+140 -4
View File
@@ -17,8 +17,146 @@ pub mod scene_classification;
pub mod tkg;
pub mod yolo;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AsrStatus {
NoAudioTrack,
SilentAudio,
HasTranscript,
Processing,
}
impl std::fmt::Display for AsrStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AsrStatus::NoAudioTrack => write!(f, "no_audio_track"),
AsrStatus::SilentAudio => write!(f, "silent_audio"),
AsrStatus::HasTranscript => write!(f, "has_transcript"),
AsrStatus::Processing => write!(f, "processing"),
}
}
}
impl AsrStatus {
pub fn css_class(&self) -> &'static str {
match self {
AsrStatus::NoAudioTrack => "card-asr--no_audio_track",
AsrStatus::SilentAudio => "card-asr--silent_audio",
AsrStatus::HasTranscript => "card-asr--has_transcript",
AsrStatus::Processing => "card-asr--processing",
}
}
pub fn display_text(&self, segment_count: usize) -> String {
match self {
AsrStatus::NoAudioTrack => "無音軌".to_string(),
AsrStatus::SilentAudio => "無語音".to_string(),
AsrStatus::HasTranscript => format!("{} 段語音", segment_count),
AsrStatus::Processing => "處理中".to_string(),
}
}
pub fn from_segments(segment_count: usize) -> Self {
if segment_count > 0 {
AsrStatus::HasTranscript
} else {
AsrStatus::SilentAudio
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FaceStatus {
NoFaces,
HasFaces,
Processing,
}
impl std::fmt::Display for FaceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FaceStatus::NoFaces => write!(f, "no_faces"),
FaceStatus::HasFaces => write!(f, "has_faces"),
FaceStatus::Processing => write!(f, "processing"),
}
}
}
impl FaceStatus {
pub fn css_class(&self) -> &'static str {
match self {
FaceStatus::NoFaces => "card-face--no_faces",
FaceStatus::HasFaces => "card-face--has_faces",
FaceStatus::Processing => "card-face--processing",
}
}
pub fn display_text(&self, face_count: usize) -> String {
match self {
FaceStatus::NoFaces => "無人脸".to_string(),
FaceStatus::HasFaces => format!("{} 張人脸", face_count),
FaceStatus::Processing => "處理中".to_string(),
}
}
pub fn from_face_count(face_count: usize) -> Self {
if face_count > 0 {
FaceStatus::HasFaces
} else {
FaceStatus::NoFaces
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraceStatus {
NoTraces,
HasTraces,
Processing,
}
impl std::fmt::Display for TraceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TraceStatus::NoTraces => write!(f, "no_traces"),
TraceStatus::HasTraces => write!(f, "has_traces"),
TraceStatus::Processing => write!(f, "processing"),
}
}
}
impl TraceStatus {
pub fn css_class(&self) -> &'static str {
match self {
TraceStatus::NoTraces => "card-trace--no_traces",
TraceStatus::HasTraces => "card-trace--has_traces",
TraceStatus::Processing => "card-trace--processing",
}
}
pub fn display_text(&self, trace_count: usize) -> String {
match self {
TraceStatus::NoTraces => "無人脸轨迹".to_string(),
TraceStatus::HasTraces => format!("{} 条人脸轨迹", trace_count),
TraceStatus::Processing => "處理中".to_string(),
}
}
pub fn from_trace_count(trace_count: usize) -> Self {
if trace_count > 0 {
TraceStatus::HasTraces
} else {
TraceStatus::NoTraces
}
}
}
pub use appearance::{
process_appearance, AppearanceFrame, AppearancePerson, AppearanceResult, BBox,
process_appearance, AppearanceFrame, AppearancePerson, AppearanceResult, BBox, BodyPart,
};
pub use asr::{process_asr, AsrResult, AsrSegment};
pub use asrx::{process_asrx, AsrxResult, AsrxSegment};
@@ -39,9 +177,7 @@ pub use face_recognition::{
FaceRecognitionFrame, FaceRecognitionResult, FaceRegistrationResult, RecognizedFace,
RecognizedFaceDetection,
};
pub use hand::{
process_hand, HandFrame, HandLandmark, HandResult, PersonHand,
};
pub use hand::{process_hand, HandFrame, HandLandmark, HandResult, PersonHand};
pub use heuristic_scene::{
build_heuristic_scene_meta, generate_scene_meta, CrowdSize, HeuristicSceneMeta,
SceneSegmentMeta,
+144
View File
@@ -48,6 +48,150 @@ pub async fn process_pose(
uuid: Option<&str>,
frames: Option<&[i64]>,
) -> Result<PoseResult> {
// Check if pose.json already exists (from swift_face_pose)
if std::path::Path::new(output_path).exists() {
tracing::info!(
"[POSE] Output exists from swift_face_pose, checking if needs interpolation: {}",
output_path
);
let json_str =
std::fs::read_to_string(output_path).context("Failed to read existing POSE output")?;
let existing_result: PoseResult =
serde_json::from_str(&json_str).context("Failed to parse existing POSE output")?;
// Get total video frames to check if interpolation needed
let total_video_frames = {
// Use ffprobe to get frame count from container metadata
let output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=nb_frames",
"-of",
"csv=p=0",
video_path,
])
.output()
.context("Failed to run ffprobe")?;
if output.status.success() {
let frame_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Handle "N/A" case for some videos
if frame_str == "N/A" {
// Fallback to duration * fps
let dur_output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
video_path,
])
.output()
.context("Failed to run ffprobe for duration")?;
let fps_output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"stream=r_frame_rate",
"-of",
"csv=p=0",
video_path,
])
.output()
.context("Failed to run ffprobe for fps")?;
if dur_output.status.success() && fps_output.status.success() {
let dur_str = String::from_utf8_lossy(&dur_output.stdout)
.trim()
.to_string();
let fps_str = String::from_utf8_lossy(&fps_output.stdout)
.trim()
.to_string();
let duration: f64 = dur_str.parse().ok().unwrap_or(0.0);
// Parse fps like "30000/1001" or "30"
let fps: f64 = if fps_str.contains('/') {
let parts: Vec<&str> = fps_str.split('/').collect();
if parts.len() == 2 {
let num: f64 = parts[0].parse().ok().unwrap_or(30.0);
let den: f64 = parts[1].parse().ok().unwrap_or(1.0);
num / den
} else {
30.0
}
} else {
fps_str.parse().ok().unwrap_or(30.0)
};
(duration * fps) as u64
} else {
0
}
} else {
frame_str.parse::<u64>().ok().unwrap_or(0)
}
} else {
0
}
};
// When 8Hz sampling frames are provided, skip interpolation entirely.
// Swift already outputs at sample_interval=3 (~8Hz), no need to fill all frames.
if frames.is_some() {
tracing::info!(
"[POSE] 8Hz mode: returning {} existing frames without interpolation",
existing_result.frames.len()
);
return Ok(existing_result);
}
// If pose frames < video frames, need interpolation
if existing_result.frames.len() < total_video_frames as usize && total_video_frames > 0 {
tracing::info!(
"[POSE] Interpolation needed: {} pose frames < {} video frames",
existing_result.frames.len(),
total_video_frames
);
// Call Python pose_processor.py for interpolation
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("pose_processor.py");
if script_path.exists() {
executor
.run_with_frames(
"pose_processor.py",
&[video_path, output_path],
uuid,
"POSE",
Some(POSE_TIMEOUT),
frames,
)
.await
.with_context(|| format!("Failed to run {:?}", script_path))?;
let json_str = std::fs::read_to_string(output_path)
.context("Failed to read interpolated POSE output")?;
let result: PoseResult = serde_json::from_str(&json_str)
.context("Failed to parse interpolated POSE output")?;
tracing::info!(
"[POSE] Interpolation completed: {} frames",
result.frames.len()
);
return Ok(result);
}
} else {
tracing::info!(
"[POSE] No interpolation needed, loaded {} frames",
existing_result.frames.len()
);
return Ok(existing_result);
}
}
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("pose_processor.py");
+704 -484
View File
File diff suppressed because it is too large Load Diff
+561
View File
@@ -0,0 +1,561 @@
//! Processing Progress Tracking
//!
//! Tracks progress for TKG and Identity Agent components.
//! Progress is published to Redis for real-time UI updates.
//!
//! Redis keys:
//! {prefix}progress:{file_uuid}:tkg → TKG progress JSON
//! {prefix}progress:{file_uuid}:agent → Identity Agent progress JSON
//! {prefix}progress:{file_uuid}:combined → Combined progress JSON
//! {prefix}progress:{file_uuid}:pipeline → Full pipeline progress JSON
use serde::{Deserialize, Serialize};
// ── Pipeline Stages ─────────────────────────────────────────────────────────
// Complete processing pipeline with weights for segmented progress calculation
/// Pipeline stage with weight for overall progress calculation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineStage {
pub name: String,
pub weight: f64, // Weight in overall progress (0.0-1.0)
pub progress: f64, // Stage progress (0.0-1.0)
pub status: String, // "pending", "running", "completed", "failed"
pub detail: Option<String>,
}
/// Full pipeline progress with segmented breakdown
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineProgress {
pub file_uuid: String,
pub overall_progress: f64, // 0.0-1.0 weighted sum of all stages
pub stages: Vec<PipelineStage>,
pub updated_at: String,
}
impl PipelineProgress {
pub fn new(file_uuid: &str) -> Self {
Self {
file_uuid: file_uuid.to_string(),
overall_progress: 0.0,
stages: vec![
// Processors (30% total)
PipelineStage { name: "processors".into(), weight: 0.30, progress: 0.0, status: "pending".into(), detail: None },
// Post-processor triggers (20% total)
PipelineStage { name: "rule1_ingestion".into(), weight: 0.05, progress: 0.0, status: "pending".into(), detail: None },
PipelineStage { name: "face_tracing".into(), weight: 0.05, progress: 0.0, status: "pending".into(), detail: None },
PipelineStage { name: "identity_agent".into(), weight: 0.10, progress: 0.0, status: "pending".into(), detail: None },
// TKG Build (35% total)
PipelineStage { name: "tkg_nodes".into(), weight: 0.20, progress: 0.0, status: "pending".into(), detail: None },
PipelineStage { name: "tkg_edges".into(), weight: 0.15, progress: 0.0, status: "pending".into(), detail: None },
// Rule 2 Ingestion (15%)
PipelineStage { name: "rule2_ingestion".into(), weight: 0.15, progress: 0.0, status: "pending".into(), detail: None },
],
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
/// Update a stage's progress and recalculate overall progress
pub fn update_stage(&mut self, stage_name: &str, progress: f64, status: &str, detail: Option<String>) {
if let Some(stage) = self.stages.iter_mut().find(|s| s.name == stage_name) {
stage.progress = progress.clamp(0.0, 1.0);
stage.status = status.to_string();
stage.detail = detail;
}
self.recalculate_overall();
}
/// Recalculate overall progress as weighted sum
fn recalculate_overall(&mut self) {
self.overall_progress = self.stages.iter()
.map(|s| s.weight * s.progress)
.sum::<f64>()
.clamp(0.0, 1.0);
self.updated_at = chrono::Utc::now().to_rfc3339();
}
/// Mark all stages as completed
pub fn mark_completed(&mut self) {
for stage in &mut self.stages {
stage.progress = 1.0;
stage.status = "completed".into();
}
self.recalculate_overall();
}
}
// ── TKG Phases ─────────────────────────────────────────────────────────────
// Each phase corresponds to a step in the TKG build process
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TkgPhase {
FaceTracing = 0, // Phase 0: Populate trace_id from face.json
FaceTrackNodes = 1, // Build face_track nodes
GazeTrackNodes = 2, // Build gaze_track nodes
LipTrackNodes = 3, // Build lip_track nodes
TextRegionNodes = 4, // Build text_region nodes
AppearanceNodes = 5, // Build appearance_trace nodes
AccessoryNodes = 6, // Build accessory nodes
ObjectNodes = 7, // Build yolo_object nodes
HandNodes = 8, // Build hand nodes
SpeakerNodes = 9, // Build speaker nodes
CoOccurrenceEdges = 10, // Build co_occurrence edges
SpeakerFaceEdges = 11, // Build speaker_face edges
FaceFaceEdges = 12, // Build face_face edges
MutualGazeEdges = 13, // Build mutual_gaze edges
LipSyncEdges = 14, // Build lip_sync edges
HasAppearanceEdges = 15,// Build has_appearance edges
WearsEdges = 16, // Build wears edges
HandObjectEdges = 17, // Build hand_object edges
Completed = 18,
Failed = 19,
}
impl TkgPhase {
pub const TOTAL: usize = 18; // phases 0-17
pub fn name(&self) -> &'static str {
match self {
TkgPhase::FaceTracing => "face_tracing",
TkgPhase::FaceTrackNodes => "face_track_nodes",
TkgPhase::GazeTrackNodes => "gaze_track_nodes",
TkgPhase::LipTrackNodes => "lip_track_nodes",
TkgPhase::TextRegionNodes => "text_region_nodes",
TkgPhase::AppearanceNodes => "appearance_nodes",
TkgPhase::AccessoryNodes => "accessory_nodes",
TkgPhase::ObjectNodes => "object_nodes",
TkgPhase::HandNodes => "hand_nodes",
TkgPhase::SpeakerNodes => "speaker_nodes",
TkgPhase::CoOccurrenceEdges => "co_occurrence_edges",
TkgPhase::SpeakerFaceEdges => "speaker_face_edges",
TkgPhase::FaceFaceEdges => "face_face_edges",
TkgPhase::MutualGazeEdges => "mutual_gaze_edges",
TkgPhase::LipSyncEdges => "lip_sync_edges",
TkgPhase::HasAppearanceEdges => "has_appearance_edges",
TkgPhase::WearsEdges => "wears_edges",
TkgPhase::HandObjectEdges => "hand_object_edges",
TkgPhase::Completed => "completed",
TkgPhase::Failed => "failed",
}
}
pub fn from_index(idx: usize) -> Self {
match idx {
0 => TkgPhase::FaceTracing,
1 => TkgPhase::FaceTrackNodes,
2 => TkgPhase::GazeTrackNodes,
3 => TkgPhase::LipTrackNodes,
4 => TkgPhase::TextRegionNodes,
5 => TkgPhase::AppearanceNodes,
6 => TkgPhase::AccessoryNodes,
7 => TkgPhase::ObjectNodes,
8 => TkgPhase::HandNodes,
9 => TkgPhase::SpeakerNodes,
10 => TkgPhase::CoOccurrenceEdges,
11 => TkgPhase::SpeakerFaceEdges,
12 => TkgPhase::FaceFaceEdges,
13 => TkgPhase::MutualGazeEdges,
14 => TkgPhase::LipSyncEdges,
15 => TkgPhase::HasAppearanceEdges,
16 => TkgPhase::WearsEdges,
17 => TkgPhase::HandObjectEdges,
_ => TkgPhase::Completed,
}
}
}
// ── Identity Agent Phases ──────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentPhase {
FaceClustering = 0,
IdentityCreation = 1,
TmdbMatching = 2,
SpeakerBinding = 3,
Confirmation = 4,
Completed = 5,
Failed = 6,
}
impl AgentPhase {
pub const TOTAL: usize = 5; // phases 0-4
pub fn name(&self) -> &'static str {
match self {
AgentPhase::FaceClustering => "face_clustering",
AgentPhase::IdentityCreation => "identity_creation",
AgentPhase::TmdbMatching => "tmdb_matching",
AgentPhase::SpeakerBinding => "speaker_binding",
AgentPhase::Confirmation => "confirmation",
AgentPhase::Completed => "completed",
AgentPhase::Failed => "failed",
}
}
pub fn from_index(idx: usize) -> Self {
match idx {
0 => AgentPhase::FaceClustering,
1 => AgentPhase::IdentityCreation,
2 => AgentPhase::TmdbMatching,
3 => AgentPhase::SpeakerBinding,
4 => AgentPhase::Confirmation,
_ => AgentPhase::Completed,
}
}
}
// ── Stats ──────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TkgStats {
pub total_faces: i64,
pub traced_faces: i64,
pub total_traces: i64,
pub matched_traces: i64,
pub seed_count: i64,
pub collisions_resolved: i64,
pub identities_bound: i64,
// Node counts
pub face_track_nodes: i64,
pub gaze_track_nodes: i64,
pub lip_track_nodes: i64,
pub text_region_nodes: i64,
pub appearance_nodes: i64,
pub accessory_nodes: i64,
pub object_nodes: i64,
pub hand_nodes: i64,
pub speaker_nodes: i64,
// Edge counts
pub co_occurrence_edges: i64,
pub speaker_face_edges: i64,
pub face_face_edges: i64,
pub mutual_gaze_edges: i64,
pub lip_sync_edges: i64,
pub has_appearance_edges: i64,
pub wears_edges: i64,
pub hand_object_edges: i64,
// Totals
pub total_nodes: i64,
pub total_edges: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentStats {
pub total_faces: i64,
pub total_traces: i64,
pub clusters: i64,
pub identities_created: i64,
pub tmdb_matches: i64,
pub speaker_bindings: i64,
pub confirmations: i64,
}
// ── Progress Records ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TkgProgress {
pub file_uuid: String,
pub phase: String,
pub phase_index: usize,
pub total_phases: usize,
pub phase_progress: f64,
pub overall_progress: f64,
pub stats: TkgStats,
pub message: String,
pub updated_at: String,
}
impl TkgProgress {
pub fn new(file_uuid: &str) -> Self {
Self {
file_uuid: file_uuid.to_string(),
phase: TkgPhase::FaceTracing.name().to_string(),
phase_index: 0,
total_phases: TkgPhase::TOTAL,
phase_progress: 0.0,
overall_progress: 0.0,
stats: TkgStats::default(),
message: "TKG processing starting".to_string(),
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
pub fn update_phase(
&mut self,
phase: TkgPhase,
phase_progress: f64,
message: &str,
) {
self.phase = phase.name().to_string();
self.phase_index = phase as usize;
self.phase_progress = phase_progress.clamp(0.0, 1.0);
// Overall: (phase_index + phase_progress) / total_phases
let weighted = self.phase_index as f64 + self.phase_progress;
self.overall_progress = (weighted / self.total_phases as f64).clamp(0.0, 1.0);
self.message = message.to_string();
self.updated_at = chrono::Utc::now().to_rfc3339();
}
pub fn mark_completed(&mut self) {
self.update_phase(TkgPhase::Completed, 1.0, "TKG processing completed");
self.overall_progress = 1.0;
self.phase_progress = 1.0;
}
pub fn mark_failed(&mut self, error: &str) {
self.update_phase(TkgPhase::Failed, 0.0, &format!("TKG failed: {}", error));
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentProgress {
pub file_uuid: String,
pub phase: String,
pub phase_index: usize,
pub total_phases: usize,
pub phase_progress: f64,
pub overall_progress: f64,
pub stats: AgentStats,
pub message: String,
pub updated_at: String,
}
impl AgentProgress {
pub fn new(file_uuid: &str) -> Self {
Self {
file_uuid: file_uuid.to_string(),
phase: AgentPhase::FaceClustering.name().to_string(),
phase_index: 0,
total_phases: AgentPhase::TOTAL,
phase_progress: 0.0,
overall_progress: 0.0,
stats: AgentStats::default(),
message: "Identity Agent processing starting".to_string(),
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
pub fn update_phase(
&mut self,
phase: AgentPhase,
phase_progress: f64,
message: &str,
) {
self.phase = phase.name().to_string();
self.phase_index = phase as usize;
self.phase_progress = phase_progress.clamp(0.0, 1.0);
let weighted = self.phase_index as f64 + self.phase_progress;
self.overall_progress = (weighted / self.total_phases as f64).clamp(0.0, 1.0);
self.message = message.to_string();
self.updated_at = chrono::Utc::now().to_rfc3339();
}
pub fn mark_completed(&mut self) {
self.update_phase(AgentPhase::Completed, 1.0, "Identity Agent processing completed");
self.overall_progress = 1.0;
self.phase_progress = 1.0;
}
pub fn mark_failed(&mut self, error: &str) {
self.update_phase(AgentPhase::Failed, 0.0, &format!("Identity Agent failed: {}", error));
}
}
// ── Combined Progress ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombinedProgress {
pub file_uuid: String,
pub overall_progress: f64,
pub tkg: Option<TkgProgress>,
pub agent: Option<AgentProgress>,
pub current_phase: String,
pub message: String,
pub updated_at: String,
}
impl CombinedProgress {
pub fn from_parts(tkg: Option<TkgProgress>, agent: Option<AgentProgress>) -> Self {
// TKG weight: 40%, Agent weight: 60%
let tkg_weight = 0.4;
let agent_weight = 0.6;
let tkg_progress = tkg.as_ref().map(|t| t.overall_progress).unwrap_or(0.0);
let agent_progress = agent.as_ref().map(|a| a.overall_progress).unwrap_or(0.0);
// If TKG not started but agent is running, agent drives progress
let tkg_active = tkg.is_some();
let agent_active = agent.is_some();
let overall = if tkg_active && agent_active {
tkg_progress * tkg_weight + agent_progress * agent_weight
} else if agent_active {
agent_progress
} else if tkg_active {
tkg_progress * tkg_weight
} else {
0.0
};
let file_uuid = tkg
.as_ref()
.map(|p| p.file_uuid.clone())
.or_else(|| agent.as_ref().map(|p| p.file_uuid.clone()))
.unwrap_or_default();
let current_phase = agent
.as_ref()
.map(|a| format!("agent:{}", a.phase))
.or_else(|| tkg.as_ref().map(|t| format!("tkg:{}", t.phase)))
.unwrap_or_else(|| "idle".to_string());
let message = agent
.as_ref()
.map(|a| a.message.clone())
.or_else(|| tkg.as_ref().map(|t| t.message.clone()))
.unwrap_or_else(|| "No active processing".to_string());
let updated_at = agent
.as_ref()
.map(|a| a.updated_at.clone())
.or_else(|| tkg.as_ref().map(|t| t.updated_at.clone()))
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
CombinedProgress {
file_uuid,
overall_progress: overall.clamp(0.0, 1.0),
tkg,
agent,
current_phase,
message,
updated_at,
}
}
}
// ── Redis Integration ──────────────────────────────────────────────────────
use crate::core::db::redis_client::RedisClient;
use std::sync::Arc;
pub async fn publish_tkg_progress(
redis: &Arc<RedisClient>,
file_uuid: &str,
progress: &TkgProgress,
) {
let key = format!(
"{}progress:{}:tkg",
crate::core::config::REDIS_KEY_PREFIX.as_str(),
file_uuid
);
if let Ok(mut conn) = redis.get_conn().await {
let json = serde_json::to_string(progress).unwrap_or_default();
let _: Result<(), _> = redis::cmd("SET")
.arg(&[&key, &json])
.query_async(&mut conn)
.await;
}
}
pub async fn publish_agent_progress(
redis: &Arc<RedisClient>,
file_uuid: &str,
progress: &AgentProgress,
) {
let key = format!(
"{}progress:{}:agent",
crate::core::config::REDIS_KEY_PREFIX.as_str(),
file_uuid
);
if let Ok(mut conn) = redis.get_conn().await {
let json = serde_json::to_string(progress).unwrap_or_default();
let _: Result<(), _> = redis::cmd("SET")
.arg(&[&key, &json])
.query_async(&mut conn)
.await;
}
}
pub async fn get_progress(
redis: &Arc<RedisClient>,
file_uuid: &str,
) -> Option<CombinedProgress> {
let tkg_key = format!(
"{}progress:{}:tkg",
crate::core::config::REDIS_KEY_PREFIX.as_str(),
file_uuid
);
let agent_key = format!(
"{}progress:{}:agent",
crate::core::config::REDIS_KEY_PREFIX.as_str(),
file_uuid
);
if let Ok(mut conn) = redis.get_conn().await {
let tkg_str: Option<String> = redis::cmd("GET")
.arg(&tkg_key)
.query_async(&mut conn)
.await
.ok();
let agent_str: Option<String> = redis::cmd("GET")
.arg(&agent_key)
.query_async(&mut conn)
.await
.ok();
let tkg = tkg_str.and_then(|s| serde_json::from_str(&s).ok());
let agent = agent_str.and_then(|s| serde_json::from_str(&s).ok());
Some(CombinedProgress::from_parts(tkg, agent))
} else {
None
}
}
/// Publish pipeline progress to Redis
pub async fn publish_pipeline_progress(
redis: &RedisClient,
file_uuid: &str,
progress: &PipelineProgress,
) {
let key = format!(
"{}progress:{}:pipeline",
crate::core::config::REDIS_KEY_PREFIX.as_str(),
file_uuid
);
if let Ok(mut conn) = redis.get_conn().await {
let json = serde_json::to_string(progress).unwrap_or_default();
let _: Result<(), _> = redis::cmd("SET")
.arg(&[&key, &json])
.query_async(&mut conn)
.await;
}
}
/// Get pipeline progress from Redis
pub async fn get_pipeline_progress(
redis: &RedisClient,
file_uuid: &str,
) -> Option<PipelineProgress> {
let key = format!(
"{}progress:{}:pipeline",
crate::core::config::REDIS_KEY_PREFIX.as_str(),
file_uuid
);
if let Ok(mut conn) = redis.get_conn().await {
let str_val: Option<String> = redis::cmd("GET")
.arg(&key)
.query_async(&mut conn)
.await
.ok();
str_val.and_then(|s| serde_json::from_str(&s).ok())
} else {
None
}
}
+165 -114
View File
@@ -3,7 +3,7 @@ use serde::Deserialize;
use std::collections::HashMap;
use tracing::{error, info, warn};
use crate::core::db::{schema, PostgresDb};
use crate::core::db::{schema, PostgresDb, QdrantDb};
#[derive(Debug, Deserialize)]
struct TmdbIdentity {
@@ -30,41 +30,87 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
/// Round 1: seed match against TMDb face_embeddings (threshold 0.50)
/// Round 2+: propagate to remaining traces using matched faces as reference
pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Result<usize> {
let pool = db.pool();
let qdrant = QdrantDb::new();
// Step 1: Load TMDb identities with face embeddings
let tmdb_rows = sqlx::query_as::<_, (i32, String, Vec<f32>)>(
&format!("SELECT id, name, face_embedding::real[] FROM {} WHERE source='tmdb' AND face_embedding IS NOT NULL", schema::table_name("identities"))
)
.fetch_all(pool).await?;
// Step 1: Load TMDb identity seeds from Qdrant _seeds collection
let tmdb_filter = serde_json::json!({
"must": [
{"key": "source", "match": {"value": "tmdb"}}
]
});
let seed_points = match qdrant.scroll_all_points("_seeds", tmdb_filter, 500).await {
Ok(pts) => pts,
Err(e) => {
warn!("[TKG-MATCH] Failed to scroll _seeds: {}", e);
return Ok(0);
}
};
let tmdb_rows: Vec<(i32, String, Vec<f32>)> = seed_points
.iter()
.filter_map(|p| {
let payload = &p["payload"];
let id = payload["identity_id"].as_i64()? as i32;
let name = payload["name"].as_str()?.to_string();
let vector = p["vector"]
.as_array()?
.iter()
.filter_map(|v| v.as_f64().map(|f| f as f32))
.collect::<Vec<f32>>();
if vector.len() == 512 {
Some((id, name, vector))
} else {
None
}
})
.collect();
if tmdb_rows.is_empty() {
info!("[TKG-MATCH] No TMDb identities with face embeddings");
info!("[TKG-MATCH] No TMDb identity seeds in _seeds collection");
return Ok(0);
}
info!("[TKG-MATCH] {} TMDb seeds loaded", tmdb_rows.len());
info!("[TKG-MATCH] {} TMDb seeds loaded from _seeds", tmdb_rows.len());
// Step 2: Load face_detections grouped by trace_id
let fd_table = schema::table_name("face_detections");
let fd_rows = sqlx::query_as::<_, (i32, Vec<f32>)>(&format!(
"SELECT trace_id, embedding FROM {} \
WHERE file_uuid=$1 AND trace_id IS NOT NULL AND embedding IS NOT NULL \
ORDER BY trace_id",
fd_table
))
.bind(file_uuid)
.fetch_all(pool)
.await?;
// Step 2: Load face embeddings from Qdrant _faces, grouped by trace_id
let face_filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": 1}} // trace_id > 0 means traced
]
});
let face_points = match qdrant.scroll_all_points("_faces", face_filter, 1000).await {
Ok(pts) => pts,
Err(e) => {
warn!("[TKG-MATCH] Failed to scroll _faces for {}: {}", file_uuid, e);
return Ok(0);
}
};
if fd_rows.is_empty() {
info!("[TKG-MATCH] No face detections for {}", file_uuid);
if face_points.is_empty() {
info!("[TKG-MATCH] No traced faces in _faces for {}", file_uuid);
return Ok(0);
}
// Group by trace_id, collect embeddings
let mut trace_faces: HashMap<i32, Vec<Vec<f32>>> = HashMap::new();
for (tid, emb) in &fd_rows {
trace_faces.entry(*tid).or_default().push(emb.clone());
for point in &face_points {
let payload = &point["payload"];
let trace_id = match payload["trace_id"].as_i64() {
Some(tid) if tid > 0 => tid as i32,
_ => continue,
};
let vector = match point["vector"].as_array() {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_f64().map(|f| f as f32))
.collect::<Vec<f32>>(),
None => continue,
};
if vector.len() == 512 {
trace_faces.entry(trace_id).or_default().push(vector);
}
}
// Dedup near-identical embeddings within trace
for faces in trace_faces.values_mut() {
faces.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal));
@@ -72,7 +118,7 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
}
let total = trace_faces.len();
info!("[TKG-MATCH] {} traces with {} faces", total, fd_rows.len());
info!("[TKG-MATCH] {} traces with {} faces", total, face_points.len());
// Step 3: Iterative matching
const TH: f32 = 0.50;
@@ -100,12 +146,12 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
info!(
"[TKG-MATCH] Round 1: {} ({}/{})",
matched.len(),
matched.len() * 100 / total,
matched.len() * 100 / total.max(1),
total
);
// Round 2+: propagate
for round_n in 2..=10 {
for _round_n in 2..=10 {
let prev = matched.len();
let mut seed_pool: HashMap<i32, Vec<&Vec<f32>>> = HashMap::new();
for (&tid, (id, _)) in &matched {
@@ -133,7 +179,6 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
}
}
if best_sim >= TH {
// Look up name for this id
for (id, name, _) in &tmdb_rows {
if *id == best_id {
best_name = name.clone();
@@ -153,19 +198,16 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
}
// Step 4: Quality control
// 4a: Remove low-confidence traces (fewer than 4 face detections)
let fd_table = schema::table_name("face_detections");
// 4a: Remove low-confidence traces (fewer than 4 face points)
let mut after_qc = HashMap::new();
for (&tid, &(id, ref name)) in &matched {
let cnt: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid=$1 AND trace_id=$2",
fd_table
))
.bind(file_uuid)
.bind(tid)
.fetch_one(pool)
.await
.unwrap_or(0);
let cnt: i64 = face_points
.iter()
.filter(|p| {
p["payload"]["trace_id"].as_i64() == Some(tid as i64)
&& p["payload"]["file_uuid"].as_str() == Some(file_uuid)
})
.count() as i64;
if cnt >= 4 {
after_qc.insert(tid, (id, name.clone()));
} else {
@@ -184,8 +226,8 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
);
}
// 4b: Temporal collision check
let removed_collisions = quality_check_temporal_collisions(pool, file_uuid).await?;
// 4b: Temporal collision check via Qdrant
let removed_collisions = quality_check_temporal_collisions_qdrant(&qdrant, file_uuid).await?;
if removed_collisions > 0 {
info!(
"[TKG-QC] Resolved {} temporal collisions",
@@ -193,19 +235,21 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
);
}
// Step 5: Update DB
// Step 5: Update Qdrant _faces with identity_id
let mut updated = 0usize;
for (&tid, &(id, _)) in &matched {
let r = sqlx::query(&format!(
"UPDATE {} SET identity_id=$1 WHERE file_uuid=$2 AND trace_id=$3",
fd_table
))
.bind(id)
.bind(file_uuid)
.bind(tid)
.execute(pool)
.await?;
if r.rows_affected() > 0 {
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": tid}}
]
});
let payload = serde_json::json!({"identity_id": id});
if qdrant
.update_payload_by_filter("_faces", filter, payload)
.await
.is_ok()
{
updated += 1;
}
}
@@ -214,87 +258,94 @@ pub async fn match_faces_against_tmdb(db: &PostgresDb, file_uuid: &str) -> Resul
"[TKG-MATCH] Done: {}/{} traces matched ({}%)",
matched.len(),
total,
matched.len() * 100 / total
matched.len() * 100 / total.max(1)
);
Ok(updated)
}
/// Quality check: detect temporal collisions where two different traces of the same
/// identity appear in the same frame (impossible for one person).
/// Unbind the lower-confidence trace from the conflicting pair.
/// RCA reference: docs_v1.0/API_V1.0.0/INTERNAL/RCA_TRACE39_TRACE45_COLLISION_V1.0.0.md
async fn quality_check_temporal_collisions(pool: &sqlx::PgPool, file_uuid: &str) -> Result<usize> {
let fd_table = schema::table_name("face_detections");
// Find all collision pairs: same identity, same frame, different trace
let collisions = sqlx::query_as::<_, (i32, i32, i32, i64)>(&format!(
"SELECT a.identity_id, a.trace_id, b.trace_id, a.frame_number \
FROM {} a \
JOIN {} b \
ON a.file_uuid = b.file_uuid \
AND a.frame_number = b.frame_number \
AND a.trace_id < b.trace_id \
WHERE a.file_uuid = $1 \
AND a.identity_id IS NOT NULL \
AND a.identity_id = b.identity_id \
ORDER BY a.identity_id, a.frame_number",
fd_table, fd_table
))
.bind(file_uuid)
.fetch_all(pool)
.await?;
/// Unbind the lower-confidence trace from the conflicting pair via Qdrant.
async fn quality_check_temporal_collisions_qdrant(
qdrant: &QdrantDb,
file_uuid: &str,
) -> Result<usize> {
use std::collections::HashSet;
if collisions.is_empty() {
return Ok(0);
// Load all traced faces for this file
let face_filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": 1}}
]
});
let face_points = match qdrant.scroll_all_points("_faces", face_filter, 1000).await {
Ok(pts) => pts,
Err(_) => return Ok(0),
};
// Group by (frame, identity_id) to find collisions
let mut frame_identity_traces: HashMap<(i64, i32), HashSet<i32>> = HashMap::new();
let mut trace_point_counts: HashMap<i32, i64> = HashMap::new();
for point in &face_points {
let payload = &point["payload"];
let frame = payload["frame"].as_i64().unwrap_or(0);
let trace_id = match payload["trace_id"].as_i64() {
Some(tid) if tid > 0 => tid as i32,
_ => continue,
};
let identity_id = match payload["identity_id"].as_i64() {
Some(id) if id > 0 => id as i32,
_ => continue,
};
frame_identity_traces
.entry((frame, identity_id))
.or_default()
.insert(trace_id);
*trace_point_counts.entry(trace_id).or_default() += 1;
}
// Group collisions by (identity_id, trace_a, trace_b) and count frames
use std::collections::HashMap;
// Find collision pairs: (identity_id, trace_a, trace_b)
let mut collision_groups: HashMap<(i32, i32, i32), usize> = HashMap::new();
for (id, ta, tb, _) in &collisions {
*collision_groups.entry((*id, *ta, *tb)).or_default() += 1;
for ((_frame, identity_id), traces) in &frame_identity_traces {
let traces: Vec<i32> = traces.iter().copied().collect();
for i in 0..traces.len() {
for j in (i + 1)..traces.len() {
let (ta, tb) = if traces[i] < traces[j] {
(traces[i], traces[j])
} else {
(traces[j], traces[i])
};
*collision_groups.entry((*identity_id, ta, tb)).or_default() += 1;
}
}
}
if collision_groups.is_empty() {
return Ok(0);
}
let mut unbound = 0usize;
for ((id, ta, tb), overlap_frames) in &collision_groups {
// Get face detection count for each trace
let cnt_a: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid=$1 AND trace_id=$2 AND identity_id=$3",
fd_table
))
.bind(file_uuid)
.bind(ta)
.bind(id)
.fetch_one(pool)
.await
.unwrap_or(0);
let cnt_a = trace_point_counts.get(ta).copied().unwrap_or(0);
let cnt_b = trace_point_counts.get(tb).copied().unwrap_or(0);
let cnt_b: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid=$1 AND trace_id=$2 AND identity_id=$3",
fd_table
))
.bind(file_uuid)
.bind(tb)
.bind(id)
.fetch_one(pool)
.await
.unwrap_or(0);
// Unbind the trace with fewer detections (likely the false positive)
let victim = if cnt_a <= cnt_b { *ta } else { *tb };
let victim_cnt = if cnt_a <= cnt_b { cnt_a } else { cnt_b };
sqlx::query(&format!(
"UPDATE {} SET identity_id=NULL WHERE file_uuid=$1 AND trace_id=$2",
fd_table
))
.bind(file_uuid)
.bind(victim)
.execute(pool)
.await?;
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": victim}}
]
});
let payload = serde_json::json!({"identity_id": serde_json::Value::Null});
let _ = qdrant.update_payload_by_filter("_faces", filter, payload).await;
unbound += 1;
warn!("[TKG-QC] Collision identity={}: trace {} vs trace {} ({} overlap frames). Unbound trace {} ({} detections)",
id, ta, tb, overlap_frames, victim, victim_cnt);
warn!("[TKG-QC] Collision identity={}: trace {} vs trace {} ({} overlap frames). Unbound trace {} ({} points)",
id, ta, tb, overlap_frames, victim, if cnt_a <= cnt_b { cnt_a } else { cnt_b });
}
Ok(unbound)
+2 -3
View File
@@ -45,9 +45,8 @@ fn extract_movie_name(filename: &str) -> Option<String> {
.file_stem()
.and_then(|s| s.to_str())?;
let noise_words = [
"youtube", "yt", "fps", "hd", "full", "movie", "official",
"trailer", "teaser", "4k",
let noise_words = [
"youtube", "yt", "fps", "hd", "full", "movie", "official", "trailer", "teaser", "4k",
];
let cleaned = name