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:
+373
-145
@@ -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)
|
||||
|
||||
Vendored
+4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -476,6 +476,7 @@ impl RedisClient {
|
||||
let _: i32 = conn.del(&key).await?;
|
||||
|
||||
let processor_types = [
|
||||
"appearance",
|
||||
"asr",
|
||||
"cut",
|
||||
"yolo",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,4 +82,4 @@ pub async fn process_hand(
|
||||
tracing::info!("[HAND] Result: {} frames", result.frames.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user