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:
529
src/api/files.rs
529
src/api/files.rs
@@ -22,6 +22,12 @@ struct RegisterFileRequest {
|
||||
user_id: Option<i64>,
|
||||
content_hash: Option<String>,
|
||||
pattern: Option<String>,
|
||||
#[serde(default = "default_force")]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
fn default_force() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -188,6 +194,7 @@ async fn register_single_file(
|
||||
file_path: &str,
|
||||
_user_id: Option<i64>,
|
||||
provided_hash: Option<String>,
|
||||
force: bool,
|
||||
) -> RegisterFileResponse {
|
||||
tracing::info!("[REGISTER] Starting registration for: {}", file_path);
|
||||
|
||||
@@ -325,41 +332,54 @@ async fn register_single_file(
|
||||
"[REGISTER] Content hash collision → already registered: {}",
|
||||
existing_uuid
|
||||
);
|
||||
let existing_info: Option<(String, String, f64, i32, i32, f64, i64, Option<String>)> = sqlx::query_as(
|
||||
&format!("SELECT file_name, file_path, duration, width, height, fps, total_frames, registration_time::text FROM {} WHERE file_uuid = $1", videos_table)
|
||||
).bind(&existing_uuid).fetch_optional(db.pool()).await.unwrap_or(None);
|
||||
if let Some((ename, epath, dur, w, h, f, tf, rt)) = existing_info {
|
||||
// If force=true, unregister asynchronously then continue
|
||||
if force {
|
||||
tracing::info!(
|
||||
"[REGISTER] Force mode: async unregistering existing file {}",
|
||||
existing_uuid
|
||||
);
|
||||
if let Err(e) = unregister_internal(&state, &existing_uuid).await {
|
||||
tracing::error!("[REGISTER] Force unregister failed for {}: {:?}", existing_uuid, e);
|
||||
} else {
|
||||
tracing::info!("[REGISTER] Force unregister completed for {}", existing_uuid);
|
||||
}
|
||||
} else {
|
||||
let existing_info: Option<(String, String, f64, i32, i32, f64, i64, Option<String>)> = sqlx::query_as(
|
||||
&format!("SELECT file_name, file_path, duration, width, height, fps, total_frames, registration_time::text FROM {} WHERE file_uuid = $1", videos_table)
|
||||
).bind(&existing_uuid).fetch_optional(db.pool()).await.unwrap_or(None);
|
||||
if let Some((ename, epath, dur, w, h, f, tf, rt)) = existing_info {
|
||||
return RegisterFileResponse {
|
||||
success: true,
|
||||
file_uuid: existing_uuid,
|
||||
file_name: ename,
|
||||
file_path: epath.clone(),
|
||||
file_type: None,
|
||||
duration: dur,
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
fps: f,
|
||||
total_frames: tf as u64,
|
||||
registration_time: rt,
|
||||
already_exists: true,
|
||||
message: format!("Content already registered: {}", epath),
|
||||
};
|
||||
}
|
||||
return RegisterFileResponse {
|
||||
success: true,
|
||||
file_uuid: existing_uuid,
|
||||
file_name: ename,
|
||||
file_path: epath.clone(),
|
||||
file_name: file_name.clone(),
|
||||
file_path: canonical_path.clone(),
|
||||
file_type: None,
|
||||
duration: dur,
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
fps: f,
|
||||
total_frames: tf as u64,
|
||||
registration_time: rt,
|
||||
duration: 0.0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: 0.0,
|
||||
total_frames: 0,
|
||||
registration_time: None,
|
||||
already_exists: true,
|
||||
message: format!("Content already registered: {}", epath),
|
||||
message: "Content already registered (identical file)".to_string(),
|
||||
};
|
||||
}
|
||||
return RegisterFileResponse {
|
||||
success: true,
|
||||
file_uuid: existing_uuid,
|
||||
file_name: file_name.clone(),
|
||||
file_path: canonical_path.clone(),
|
||||
file_type: None,
|
||||
duration: 0.0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: 0.0,
|
||||
total_frames: 0,
|
||||
registration_time: None,
|
||||
already_exists: true,
|
||||
message: "Content already registered (identical file)".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,12 +438,19 @@ async fn register_single_file(
|
||||
|
||||
let duration = temp_probe_json
|
||||
.get("format")
|
||||
.and_then(|f| {
|
||||
let src = if has_video { f.get("duration") } else { None };
|
||||
src.and_then(|v| v.as_str())
|
||||
.and_then(|f| f.get("duration"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or_else(|| {
|
||||
temp_probe_json
|
||||
.get("streams")
|
||||
.and_then(|s| s.as_array())
|
||||
.and_then(|streams| streams.iter().next())
|
||||
.and_then(|st| st.get("duration"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
.unwrap_or(0.0)
|
||||
});
|
||||
let mut width = 0u32;
|
||||
let mut height = 0u32;
|
||||
let mut fps = 0.0;
|
||||
@@ -454,7 +481,7 @@ async fn register_single_file(
|
||||
|
||||
let status = "registered";
|
||||
let _ = sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, status, content_hash, registration_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (file_uuid) DO UPDATE SET file_path = EXCLUDED.file_path, file_name = EXCLUDED.file_name, status = EXCLUDED.status, content_hash = EXCLUDED.content_hash",
|
||||
"INSERT INTO {} (file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, status, content_hash, registration_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (file_uuid) DO UPDATE SET file_path = EXCLUDED.file_path, file_name = EXCLUDED.file_name, status = EXCLUDED.status, content_hash = EXCLUDED.content_hash, duration = EXCLUDED.duration, width = EXCLUDED.width, height = EXCLUDED.height, fps = EXCLUDED.fps, probe_json = EXCLUDED.probe_json",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid).bind(&canonical_path).bind(&final_name).bind(&final_file_type)
|
||||
@@ -509,7 +536,6 @@ async fn register_single_file(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let audio_tracks: Vec<serde_json::Value> = temp_probe_json
|
||||
@@ -647,6 +673,7 @@ async fn register_file(
|
||||
&entry_path.to_string_lossy().to_string(),
|
||||
req.user_id,
|
||||
None,
|
||||
req.force,
|
||||
)
|
||||
.await;
|
||||
if result.success {
|
||||
@@ -682,7 +709,49 @@ async fn register_file(
|
||||
}));
|
||||
}
|
||||
|
||||
let resp = register_single_file(&state, &file_path, req.user_id, req.content_hash).await;
|
||||
// If force=true and file already exists, unregister first
|
||||
if req.force {
|
||||
let videos_table = schema::table_name("videos");
|
||||
// Check by file_path first
|
||||
if let Ok(Some(existing_uuid)) = sqlx::query_scalar::<_, String>(&format!(
|
||||
"SELECT file_uuid FROM {} WHERE file_path = $1 LIMIT 1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_path)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
{
|
||||
tracing::info!(
|
||||
"[REGISTER] Force mode: unregistering existing file {}",
|
||||
existing_uuid
|
||||
);
|
||||
if let Err(e) = unregister_internal(&state, &existing_uuid).await {
|
||||
tracing::error!("[REGISTER] Force unregister failed for {}: {:?}", existing_uuid, e);
|
||||
}
|
||||
}
|
||||
// Also check by content_hash if provided
|
||||
if let Some(ref content_hash) = req.content_hash {
|
||||
if let Ok(Some(existing_uuid)) = sqlx::query_scalar::<_, String>(&format!(
|
||||
"SELECT file_uuid FROM {} WHERE content_hash = $1 LIMIT 1",
|
||||
videos_table
|
||||
))
|
||||
.bind(content_hash)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
{
|
||||
tracing::info!(
|
||||
"[REGISTER] Force mode: unregistering by content_hash {}",
|
||||
existing_uuid
|
||||
);
|
||||
if let Err(e) = unregister_internal(&state, &existing_uuid).await {
|
||||
tracing::error!("[REGISTER] Force unregister failed for {}: {:?}", existing_uuid, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp =
|
||||
register_single_file(&state, &file_path, req.user_id, req.content_hash, req.force).await;
|
||||
|
||||
if resp.success
|
||||
&& !resp.already_exists
|
||||
@@ -706,7 +775,8 @@ async fn register_file(
|
||||
if let Some(ref vp) = video_path {
|
||||
if let Ok(job) = auto_state.db.create_monitor_job(&auto_uuid, Some(vp)).await {
|
||||
tracing::info!("[AUTO-PIPELINE] Job {} created for {}", job.id, auto_uuid);
|
||||
let all_procs: Vec<&str> = vec!["cut", "asr", "asrx", "yolo", "ocr", "face", "pose", "appearance"];
|
||||
let all_procs: Vec<&str> =
|
||||
vec!["cut", "asr", "asrx", "ocr", "face", "pose", "appearance"];
|
||||
let total = sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1",
|
||||
schema::table_name("videos")
|
||||
@@ -927,6 +997,7 @@ struct UnregisterResponse {
|
||||
deleted_characters: u64,
|
||||
deleted_chunks_rule1: u64,
|
||||
deleted_processor_alerts: u64,
|
||||
deleted_processor_versions: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -948,7 +1019,11 @@ fn delete_output_files(uuid: &str) -> u64 {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with(uuid) && name.ends_with(".json") {
|
||||
let is_uuid_file = name.starts_with(uuid) && !path.is_dir();
|
||||
let is_pipeline_log = name.starts_with("pipeline_")
|
||||
&& name.contains(uuid)
|
||||
&& name.ends_with(".log");
|
||||
if is_uuid_file || is_pipeline_log {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
deleted_count += 1;
|
||||
tracing::info!("[UNREGISTER] Deleted output file: {}", name);
|
||||
@@ -957,6 +1032,17 @@ fn delete_output_files(uuid: &str) -> u64 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let uuid_dir = std::path::Path::new(output_dir).join(uuid);
|
||||
if uuid_dir.is_dir() {
|
||||
if std::fs::remove_dir_all(&uuid_dir).is_ok() {
|
||||
deleted_count += 1;
|
||||
tracing::info!(
|
||||
"[UNREGISTER] Deleted output directory: {}",
|
||||
uuid_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_sqlite = format!("{}.workspace.sqlite", uuid);
|
||||
@@ -982,7 +1068,6 @@ async fn unregister(
|
||||
tracing::info!("[UNREGISTER] Unregistering file: {}", uuid);
|
||||
|
||||
let videos_table = schema::table_name("videos");
|
||||
let face_table = schema::table_name("face_detections");
|
||||
let processor_table = schema::table_name("processor_results");
|
||||
let chunks_table = schema::table_name("chunk");
|
||||
let parent_chunks_table = schema::table_name("parent_chunks");
|
||||
@@ -1020,7 +1105,7 @@ async fn unregister(
|
||||
}};
|
||||
}
|
||||
|
||||
let deleted_faces = delete_safe!(face_table, "file_uuid = $1", &uuid, "faces");
|
||||
let deleted_faces = 0i64; // Deprecated: face_detections table removed
|
||||
let deleted_processors = delete_safe!(processor_table, "file_uuid = $1", &uuid, "processors");
|
||||
let deleted_parent_chunks =
|
||||
delete_safe!(parent_chunks_table, "uuid = $1", &uuid, "parent chunks");
|
||||
@@ -1045,20 +1130,44 @@ async fn unregister(
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
|
||||
let deleted_file_identities =
|
||||
delete_safe!(file_identities_table, "file_uuid = $1", &uuid, "file identities");
|
||||
let deleted_speaker_detections =
|
||||
delete_safe!(speaker_detections_table, "file_uuid = $1", &uuid, "speaker detections");
|
||||
let deleted_face_clusters =
|
||||
delete_safe!(face_clusters_table, "file_uuid = $1", &uuid, "face clusters");
|
||||
let deleted_face_recognition =
|
||||
delete_safe!(face_recognition_results_table, "file_uuid = $1", &uuid, "face recognition results");
|
||||
let deleted_characters =
|
||||
delete_safe!(characters_table, "file_uuid = $1", &uuid, "characters");
|
||||
let deleted_chunks_rule1 =
|
||||
delete_safe!(chunks_rule1_table, "uuid = $1", &uuid, "chunks rule1");
|
||||
let deleted_processor_alerts =
|
||||
delete_safe!(processor_alerts_table, "file_uuid = $1", &uuid, "processor alerts");
|
||||
let deleted_file_identities = delete_safe!(
|
||||
file_identities_table,
|
||||
"file_uuid = $1",
|
||||
&uuid,
|
||||
"file identities"
|
||||
);
|
||||
let deleted_speaker_detections = delete_safe!(
|
||||
speaker_detections_table,
|
||||
"file_uuid = $1",
|
||||
&uuid,
|
||||
"speaker detections"
|
||||
);
|
||||
let deleted_face_clusters = delete_safe!(
|
||||
face_clusters_table,
|
||||
"file_uuid = $1",
|
||||
&uuid,
|
||||
"face clusters"
|
||||
);
|
||||
let deleted_face_recognition = delete_safe!(
|
||||
face_recognition_results_table,
|
||||
"file_uuid = $1",
|
||||
&uuid,
|
||||
"face recognition results"
|
||||
);
|
||||
let deleted_characters = delete_safe!(characters_table, "file_uuid = $1", &uuid, "characters");
|
||||
let deleted_chunks_rule1 = delete_safe!(chunks_rule1_table, "uuid = $1", &uuid, "chunks rule1");
|
||||
let deleted_processor_alerts = delete_safe!(
|
||||
processor_alerts_table,
|
||||
"file_uuid = $1",
|
||||
&uuid,
|
||||
"processor alerts"
|
||||
);
|
||||
let deleted_processor_versions = delete_safe!(
|
||||
"processor_versions",
|
||||
"file_uuid = $1",
|
||||
&uuid,
|
||||
"processor versions"
|
||||
);
|
||||
|
||||
sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE file_uuid = $1",
|
||||
@@ -1078,29 +1187,54 @@ async fn unregister(
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[UNREGISTER] Deleted: {} faces, {} processors, {} parent_chunks, {} chunks, {} pre_chunks, {} tkg_nodes, {} cuts, {} strangers, {} chunk_vectors, {} monitor_jobs, {} frames, {} file_identities, {} speaker_detections, {} face_clusters, {} face_recognition_results, {} characters, {} chunks_rule1, {} processor_alerts",
|
||||
"[UNREGISTER] Deleted: {} faces, {} processors, {} parent_chunks, {} chunks, {} pre_chunks, {} tkg_nodes, {} cuts, {} strangers, {} chunk_vectors, {} monitor_jobs, {} frames, {} file_identities, {} speaker_detections, {} face_clusters, {} face_recognition_results, {} characters, {} chunks_rule1, {} processor_alerts, {} processor_versions",
|
||||
deleted_faces, deleted_processors, deleted_parent_chunks, deleted_chunks,
|
||||
deleted_pre_chunks, deleted_tkg_nodes, deleted_cuts, deleted_strangers,
|
||||
deleted_chunk_vectors, deleted_monitor_jobs, deleted_frames,
|
||||
deleted_file_identities, deleted_speaker_detections, deleted_face_clusters,
|
||||
deleted_face_recognition, deleted_characters, deleted_chunks_rule1,
|
||||
deleted_processor_alerts
|
||||
deleted_processor_alerts, deleted_processor_versions
|
||||
);
|
||||
|
||||
let deleted_output_files = delete_output_files(&uuid);
|
||||
|
||||
let deleted_qdrant_vectors = {
|
||||
let qdrant = QdrantDb::new();
|
||||
match qdrant.delete_by_uuid(&uuid).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[UNREGISTER] Deleted Qdrant vectors for {}", uuid);
|
||||
Some(1)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[UNREGISTER] Failed to delete Qdrant vectors: {}", e);
|
||||
None
|
||||
let mut total = 0u64;
|
||||
|
||||
if qdrant.delete_by_uuid(&uuid).await.is_ok() {
|
||||
tracing::info!("[UNREGISTER] Deleted Qdrant vectors from main collection");
|
||||
total += 1;
|
||||
} else {
|
||||
tracing::warn!("[UNREGISTER] Failed to delete Qdrant vectors from main collection");
|
||||
}
|
||||
|
||||
let additional_collections = [
|
||||
"_faces", // Python store_traced_faces.py
|
||||
&format!("{}_voice", uuid), // Per-file voice embeddings
|
||||
];
|
||||
for coll in &additional_collections {
|
||||
if QdrantDb::delete_by_uuid_from_collection(
|
||||
&qdrant.client,
|
||||
&qdrant.base_url,
|
||||
&qdrant.api_key,
|
||||
coll,
|
||||
&uuid,
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
tracing::info!(
|
||||
"[UNREGISTER] Deleted Qdrant vectors from collection: {}",
|
||||
coll
|
||||
);
|
||||
total += 1;
|
||||
} else {
|
||||
tracing::debug!("[UNREGISTER] No vectors or collection not found: {}", coll);
|
||||
}
|
||||
}
|
||||
|
||||
Some(total)
|
||||
};
|
||||
|
||||
let deleted_redis_keys = {
|
||||
@@ -1130,7 +1264,10 @@ async fn unregister(
|
||||
Some(1)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[UNREGISTER] Failed to delete Qdrant workspace vectors: {}", e);
|
||||
tracing::warn!(
|
||||
"[UNREGISTER] Failed to delete Qdrant workspace vectors: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1155,13 +1292,275 @@ async fn unregister(
|
||||
deleted_characters: deleted_characters as u64,
|
||||
deleted_chunks_rule1: deleted_chunks_rule1 as u64,
|
||||
deleted_processor_alerts: deleted_processor_alerts as u64,
|
||||
deleted_processor_versions: deleted_processor_versions as u64,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Internal unregister function - can be called from both API and register
|
||||
async fn unregister_internal(state: &AppState, uuid: &str) -> Result<(), StatusCode> {
|
||||
let videos_table = schema::table_name("videos");
|
||||
let processor_table = schema::table_name("processor_results");
|
||||
let chunks_table = schema::table_name("chunk");
|
||||
let parent_chunks_table = schema::table_name("parent_chunks");
|
||||
let pre_chunks_table = schema::table_name("pre_chunks");
|
||||
let tkg_nodes_table = schema::table_name("tkg_nodes");
|
||||
let cuts_table = schema::table_name("cuts");
|
||||
let strangers_table = schema::table_name("strangers");
|
||||
let chunk_vectors_table = schema::table_name("chunk_vectors");
|
||||
let monitor_jobs_table = schema::table_name("monitor_jobs");
|
||||
let frames_table = schema::table_name("frames");
|
||||
let file_identities_table = schema::table_name("file_identities");
|
||||
let speaker_detections_table = schema::table_name("speaker_detections");
|
||||
let face_clusters_table = schema::table_name("face_clusters");
|
||||
let face_recognition_results_table = schema::table_name("face_recognition_results");
|
||||
let characters_table = schema::table_name("characters");
|
||||
let chunks_rule1_table = schema::table_name("chunks_rule1");
|
||||
let processor_alerts_table = schema::table_name("processor_alerts");
|
||||
|
||||
let mut tx = state.db.pool().begin().await.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to start transaction: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
macro_rules! delete_safe {
|
||||
($table:expr, $where:expr, $bind:expr, $label:expr) => {{
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE {}", $table, $where))
|
||||
.bind($bind)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete {}: {}", $label, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64
|
||||
}};
|
||||
}
|
||||
|
||||
let _deleted_faces: i64 = 0; // Deprecated: face_detections table removed
|
||||
let _deleted_processors = delete_safe!(processor_table, "file_uuid = $1", uuid, "processors");
|
||||
let _deleted_parent_chunks =
|
||||
delete_safe!(parent_chunks_table, "uuid = $1", uuid, "parent chunks");
|
||||
let _deleted_chunks = delete_safe!(chunks_table, "file_uuid = $1", uuid, "chunks");
|
||||
let _deleted_pre_chunks = delete_safe!(pre_chunks_table, "file_uuid = $1", uuid, "pre_chunks");
|
||||
let _deleted_tkg_nodes = delete_safe!(tkg_nodes_table, "file_uuid = $1", uuid, "TKG nodes");
|
||||
let _deleted_cuts = delete_safe!(cuts_table, "file_uuid = $1", uuid, "cuts");
|
||||
let _deleted_strangers = delete_safe!(strangers_table, "file_uuid = $1", uuid, "strangers");
|
||||
let _deleted_chunk_vectors =
|
||||
delete_safe!(chunk_vectors_table, "uuid = $1", uuid, "chunk vectors");
|
||||
let _deleted_monitor_jobs = delete_safe!(monitor_jobs_table, "uuid = $1", uuid, "monitor jobs");
|
||||
let _deleted_frames: i64 = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE file_id = (SELECT id FROM {} WHERE file_uuid = $1)",
|
||||
frames_table, videos_table
|
||||
))
|
||||
.bind(uuid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete frames: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
|
||||
let _deleted_file_identities = delete_safe!(
|
||||
file_identities_table,
|
||||
"file_uuid = $1",
|
||||
uuid,
|
||||
"file identities"
|
||||
);
|
||||
let _deleted_speaker_detections = delete_safe!(
|
||||
speaker_detections_table,
|
||||
"file_uuid = $1",
|
||||
uuid,
|
||||
"speaker detections"
|
||||
);
|
||||
let _deleted_face_clusters =
|
||||
delete_safe!(face_clusters_table, "file_uuid = $1", uuid, "face clusters");
|
||||
let _deleted_face_recognition = delete_safe!(
|
||||
face_recognition_results_table,
|
||||
"file_uuid = $1",
|
||||
uuid,
|
||||
"face recognition results"
|
||||
);
|
||||
let _deleted_characters = delete_safe!(characters_table, "file_uuid = $1", uuid, "characters");
|
||||
let _deleted_chunks_rule1 = delete_safe!(chunks_rule1_table, "uuid = $1", uuid, "chunks rule1");
|
||||
let _deleted_processor_alerts = delete_safe!(
|
||||
processor_alerts_table,
|
||||
"file_uuid = $1",
|
||||
uuid,
|
||||
"processor alerts"
|
||||
);
|
||||
let _deleted_processor_versions = delete_safe!(
|
||||
"processor_versions",
|
||||
"file_uuid = $1",
|
||||
uuid,
|
||||
"processor versions"
|
||||
);
|
||||
|
||||
sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(uuid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to commit transaction: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tracing::info!("[UNREGISTER] Deleted all data for {}", uuid);
|
||||
|
||||
// Delete output files
|
||||
delete_output_files(uuid);
|
||||
|
||||
// Delete Qdrant vectors
|
||||
let qdrant = QdrantDb::new();
|
||||
let _ = qdrant.delete_by_uuid(uuid).await;
|
||||
let _ = QdrantDb::delete_by_uuid_from_collection(
|
||||
&qdrant.client,
|
||||
&qdrant.base_url,
|
||||
&qdrant.api_key,
|
||||
"_faces",
|
||||
uuid,
|
||||
)
|
||||
.await;
|
||||
let _ = QdrantDb::delete_by_uuid_from_collection(
|
||||
&qdrant.client,
|
||||
&qdrant.base_url,
|
||||
&qdrant.api_key,
|
||||
&format!("{}_voice", uuid),
|
||||
uuid,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Delete Qdrant workspace
|
||||
let workspace = QdrantWorkspace::new();
|
||||
let _ = workspace.delete_by_file_uuid(uuid).await;
|
||||
|
||||
// Delete Redis keys
|
||||
if let Ok(redis) = RedisClient::new() {
|
||||
let _ = redis.delete_worker_job(uuid).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateMetadataRequest {
|
||||
duration: Option<f64>,
|
||||
status: Option<String>,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
fps: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateMetadataResponse {
|
||||
success: bool,
|
||||
file_uuid: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn update_file_metadata(
|
||||
Path(file_uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UpdateMetadataRequest>,
|
||||
) -> Result<Json<UpdateMetadataResponse>, StatusCode> {
|
||||
let videos_table = schema::table_name("videos");
|
||||
|
||||
let mut set_clauses: Vec<String> = Vec::new();
|
||||
let mut bind_idx = 2;
|
||||
|
||||
if let Some(_) = req.duration {
|
||||
set_clauses.push(format!("duration = ${}", bind_idx));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if let Some(_) = req.status {
|
||||
set_clauses.push(format!("status = ${}", bind_idx));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if let Some(_) = req.width {
|
||||
set_clauses.push(format!("width = ${}", bind_idx));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if let Some(_) = req.height {
|
||||
set_clauses.push(format!("height = ${}", bind_idx));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if let Some(_) = req.fps {
|
||||
set_clauses.push(format!("fps = ${}", bind_idx));
|
||||
bind_idx += 1;
|
||||
}
|
||||
|
||||
if set_clauses.is_empty() {
|
||||
return Ok(Json(UpdateMetadataResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
message: "No fields to update".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
set_clauses.push("updated_at = NOW()".to_string());
|
||||
let sql = format!(
|
||||
"UPDATE {} SET {} WHERE file_uuid = $1",
|
||||
videos_table,
|
||||
set_clauses.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql).bind(&file_uuid);
|
||||
if let Some(d) = req.duration {
|
||||
query = query.bind(d);
|
||||
}
|
||||
if let Some(s) = req.status {
|
||||
query = query.bind(s);
|
||||
}
|
||||
if let Some(w) = req.width {
|
||||
query = query.bind(w);
|
||||
}
|
||||
if let Some(h) = req.height {
|
||||
query = query.bind(h);
|
||||
}
|
||||
if let Some(f) = req.fps {
|
||||
query = query.bind(f);
|
||||
}
|
||||
|
||||
let result = query.execute(state.db.pool()).await;
|
||||
|
||||
match result {
|
||||
Ok(res) if res.rows_affected() > 0 => Ok(Json(UpdateMetadataResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
message: "Metadata updated successfully".to_string(),
|
||||
})),
|
||||
Ok(_) => Ok(Json(UpdateMetadataResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
message: "File not found".to_string(),
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("[METADATA] Update failed: {}", e);
|
||||
Ok(Json(UpdateMetadataResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
message: format!("Update failed: {}", e),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/files/register", post(register_file))
|
||||
.route("/api/v1/files/lookup", get(lookup_file_by_name))
|
||||
.route("/api/v1/unregister", post(unregister))
|
||||
.route("/api/v1/file/:file_uuid/probe", get(probe_by_uuid))
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/metadata",
|
||||
post(update_file_metadata),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ async fn list_identities(
|
||||
)
|
||||
})?;
|
||||
|
||||
let sql = format!(
|
||||
let sql = format!(
|
||||
r#"SELECT i.id::int, i.uuid, i.name, i.metadata, i.status, i.starred,
|
||||
COALESCE(
|
||||
jsonb_agg(jsonb_build_object(
|
||||
@@ -195,10 +195,19 @@ let sql = format!(
|
||||
WHERE i.status IS NULL OR i.status != 'merged'
|
||||
GROUP BY i.id, i.uuid, i.name, i.metadata, i.status, i.starred
|
||||
ORDER BY i.id DESC LIMIT $1 OFFSET $2"#,
|
||||
id_table, crate::core::db::schema::table_name("file_identities")
|
||||
id_table,
|
||||
crate::core::db::schema::table_name("file_identities")
|
||||
);
|
||||
|
||||
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>, Option<String>, Option<bool>, serde_json::Value)> = match sqlx::query_as(&sql)
|
||||
let rows: Vec<(
|
||||
i32,
|
||||
uuid::Uuid,
|
||||
String,
|
||||
Option<serde_json::Value>,
|
||||
Option<String>,
|
||||
Option<bool>,
|
||||
serde_json::Value,
|
||||
)> = match sqlx::query_as(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset)
|
||||
.fetch_all(db.pool())
|
||||
@@ -216,10 +225,18 @@ let sql = format!(
|
||||
let identities: Vec<IdentityResponse> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let file_bindings: Vec<FileBinding> = r.6.as_array()
|
||||
.map(|arr| arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let file_uuids: Vec<String> = file_bindings.iter().map(|fb| fb.file_uuid.clone()).collect();
|
||||
let file_bindings: Vec<FileBinding> =
|
||||
r.6.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| serde_json::from_value(v.clone()).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let file_uuids: Vec<String> = file_bindings
|
||||
.iter()
|
||||
.map(|fb| fb.file_uuid.clone())
|
||||
.collect();
|
||||
IdentityResponse {
|
||||
id: r.0,
|
||||
identity_uuid: r.1.to_string().replace('-', ""),
|
||||
@@ -332,149 +349,57 @@ pub struct IdentityListResponse {
|
||||
async fn list_face_candidates(
|
||||
Query(query): Query<FaceCandidatesQuery>,
|
||||
) -> Result<Json<FaceCandidatesResponse>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = std::cmp::min(query.page_size.unwrap_or(15), 100);
|
||||
let offset = (page - 1) * page_size;
|
||||
let min_confidence = query.min_confidence.unwrap_or(0.5);
|
||||
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
// Query Qdrant _faces for unbound faces (identity_id IS NULL)
|
||||
let qdrant = crate::core::db::qdrant_db::QdrantDb::new();
|
||||
let mut filter_must = vec![
|
||||
serde_json::json!({"is_null": {"key": "identity_id"}}),
|
||||
serde_json::json!({"key": "confidence", "range": {"gte": min_confidence}}),
|
||||
];
|
||||
if let Some(ref file_uuid) = query.file_uuid {
|
||||
filter_must.push(serde_json::json!({"key": "file_uuid", "match": {"value": file_uuid}}));
|
||||
}
|
||||
let scroll_filter = serde_json::json!({"must": filter_must});
|
||||
|
||||
let total: i64 = if let Some(file_uuid) = &query.file_uuid {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE identity_id IS NULL AND confidence >= $1 AND file_uuid = $2",
|
||||
table
|
||||
);
|
||||
match sqlx::query_scalar(&count_sql)
|
||||
.bind(min_confidence)
|
||||
.bind(file_uuid)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE identity_id IS NULL AND confidence >= $1",
|
||||
table
|
||||
);
|
||||
match sqlx::query_scalar(&count_sql)
|
||||
.bind(min_confidence)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let rows = if let Some(file_uuid) = &query.file_uuid {
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number::bigint, confidence::float4,
|
||||
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
|
||||
NULL::jsonb as attributes
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND confidence >= $1 AND file_uuid = $2
|
||||
ORDER BY confidence DESC
|
||||
LIMIT $3 OFFSET $4",
|
||||
table
|
||||
);
|
||||
match sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f32,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
>(&sql)
|
||||
.bind(min_confidence)
|
||||
.bind(file_uuid)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
let all_points = qdrant
|
||||
.scroll_all_points("_faces", scroll_filter, 1000)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number::bigint, confidence::float4,
|
||||
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
|
||||
NULL::jsonb as attributes
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND confidence >= $1
|
||||
ORDER BY confidence DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
table
|
||||
);
|
||||
match sqlx::query_as::<
|
||||
_,
|
||||
.map_err(|e| {
|
||||
(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f32,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
>(&sql)
|
||||
.bind(min_confidence)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Qdrant scroll failed: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let candidates: Vec<FaceCandidate> = rows
|
||||
let total = all_points.len() as i64;
|
||||
|
||||
// Sort by confidence DESC then paginate
|
||||
let mut sorted: Vec<&serde_json::Value> = all_points.iter().collect();
|
||||
sorted.sort_by(|a, b| {
|
||||
let ca = a["payload"]["confidence"].as_f64().unwrap_or(0.0);
|
||||
let cb = b["payload"]["confidence"].as_f64().unwrap_or(0.0);
|
||||
cb.partial_cmp(&ca).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let paginated: Vec<&&serde_json::Value> = sorted.iter().skip(offset).take(page_size).collect();
|
||||
|
||||
let candidates: Vec<FaceCandidate> = paginated
|
||||
.into_iter()
|
||||
.map(|r| FaceCandidate {
|
||||
id: r.0,
|
||||
face_id: r.1,
|
||||
file_uuid: r.2,
|
||||
frame_number: r.3,
|
||||
confidence: r.4,
|
||||
bbox: r.5,
|
||||
attributes: r.6,
|
||||
.map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let point_id = p["id"].as_u64().unwrap_or(0);
|
||||
FaceCandidate {
|
||||
id: point_id as i32,
|
||||
face_id: Some(format!("{:x}", point_id)),
|
||||
file_uuid: payload["file_uuid"].as_str().unwrap_or("").to_string(),
|
||||
frame_number: payload["frame"].as_i64().unwrap_or(0),
|
||||
confidence: payload["confidence"].as_f64().unwrap_or(0.0) as f32,
|
||||
bbox: payload.get("bbox").cloned(),
|
||||
attributes: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -518,133 +443,98 @@ pub struct UnassignedTracesResponse {
|
||||
async fn list_unassigned_traces(
|
||||
Query(query): Query<UnassignedTracesQuery>,
|
||||
) -> Result<Json<UnassignedTracesResponse>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = std::cmp::min(query.page_size.unwrap_or(20), 100);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
// Query Qdrant _faces for unbound traces (identity_id IS NULL, trace_id > 0)
|
||||
let qdrant = crate::core::db::qdrant_db::QdrantDb::new();
|
||||
let mut filter_must: Vec<serde_json::Value> = vec![
|
||||
serde_json::json!({"is_null": {"key": "identity_id"}}),
|
||||
serde_json::json!({"key": "trace_id", "range": {"gt": 0}}),
|
||||
];
|
||||
if let Some(ref file_uuid) = query.file_uuid {
|
||||
filter_must.push(serde_json::json!({"key": "file_uuid", "match": {"value": file_uuid}}));
|
||||
}
|
||||
let scroll_filter = serde_json::json!({"must": filter_must});
|
||||
|
||||
let total: i64 = if let Some(file_uuid) = &query.file_uuid {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id IS NULL AND trace_id IS NOT NULL AND file_uuid = $1",
|
||||
table
|
||||
);
|
||||
sqlx::query_scalar(&count_sql)
|
||||
.bind(file_uuid)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))?
|
||||
} else {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id IS NULL AND trace_id IS NOT NULL",
|
||||
table
|
||||
);
|
||||
sqlx::query_scalar(&count_sql)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))?
|
||||
};
|
||||
|
||||
let sql = if let Some(file_uuid) = &query.file_uuid {
|
||||
format!(
|
||||
"WITH trace_agg AS (
|
||||
SELECT trace_id, file_uuid,
|
||||
COUNT(*) as frame_count,
|
||||
MIN(frame_number) as start_frame,
|
||||
MAX(frame_number) as end_frame
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND trace_id IS NOT NULL AND file_uuid = $1
|
||||
GROUP BY trace_id, file_uuid
|
||||
),
|
||||
best_face AS (
|
||||
SELECT DISTINCT ON (fd.trace_id, fd.file_uuid)
|
||||
fd.trace_id, fd.file_uuid, fd.id as best_face_id,
|
||||
fd.frame_number as best_face_frame,
|
||||
fd.confidence as best_face_confidence,
|
||||
jsonb_build_object('x', fd.x, 'y', fd.y, 'width', fd.width, 'height', fd.height) as best_face_bbox
|
||||
FROM {} fd
|
||||
WHERE fd.identity_id IS NULL AND fd.trace_id IS NOT NULL AND fd.file_uuid = $1
|
||||
ORDER BY fd.trace_id, fd.file_uuid, fd.confidence DESC
|
||||
let all_points = qdrant
|
||||
.scroll_all_points("_faces", scroll_filter, 1000)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Qdrant scroll failed: {}", e),
|
||||
)
|
||||
SELECT ta.trace_id, ta.file_uuid, ta.frame_count, ta.start_frame, ta.end_frame,
|
||||
bf.best_face_id, bf.best_face_frame, bf.best_face_confidence, bf.best_face_bbox
|
||||
FROM trace_agg ta
|
||||
JOIN best_face bf ON ta.trace_id = bf.trace_id AND ta.file_uuid = bf.file_uuid
|
||||
ORDER BY ta.frame_count DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
table, table
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"WITH trace_agg AS (
|
||||
SELECT trace_id, file_uuid,
|
||||
COUNT(*) as frame_count,
|
||||
MIN(frame_number) as start_frame,
|
||||
MAX(frame_number) as end_frame
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND trace_id IS NOT NULL
|
||||
GROUP BY trace_id, file_uuid
|
||||
),
|
||||
best_face AS (
|
||||
SELECT DISTINCT ON (fd.trace_id, fd.file_uuid)
|
||||
fd.trace_id, fd.file_uuid, fd.id as best_face_id,
|
||||
fd.frame_number as best_face_frame,
|
||||
fd.confidence as best_face_confidence,
|
||||
jsonb_build_object('x', fd.x, 'y', fd.y, 'width', fd.width, 'height', fd.height) as best_face_bbox
|
||||
FROM {} fd
|
||||
WHERE fd.identity_id IS NULL AND fd.trace_id IS NOT NULL
|
||||
ORDER BY fd.trace_id, fd.file_uuid, fd.confidence DESC
|
||||
)
|
||||
SELECT ta.trace_id, ta.file_uuid, ta.frame_count, ta.start_frame, ta.end_frame,
|
||||
bf.best_face_id, bf.best_face_frame, bf.best_face_confidence, bf.best_face_bbox
|
||||
FROM trace_agg ta
|
||||
JOIN best_face bf ON ta.trace_id = bf.trace_id AND ta.file_uuid = bf.file_uuid
|
||||
ORDER BY ta.frame_count DESC
|
||||
LIMIT $1 OFFSET $2",
|
||||
table, table
|
||||
)
|
||||
};
|
||||
})?;
|
||||
|
||||
let rows: Vec<(i32, String, i64, i64, i64, i32, i64, f64, Option<serde_json::Value>)> =
|
||||
if let Some(file_uuid) = &query.file_uuid {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(file_uuid)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {}", e)))?
|
||||
} else {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {}", e)))?
|
||||
// Group by (file_uuid, trace_id) and aggregate
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Default)]
|
||||
struct TraceAgg {
|
||||
frame_count: i64,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
best_confidence: f64,
|
||||
best_point_id: i64,
|
||||
best_frame: i64,
|
||||
best_bbox: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
let mut trace_map: BTreeMap<(String, i32), TraceAgg> = BTreeMap::new();
|
||||
for point in &all_points {
|
||||
let payload = &point["payload"];
|
||||
let file_uuid = match payload["file_uuid"].as_str() {
|
||||
Some(f) => f.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
|
||||
if trace_id <= 0 {
|
||||
continue;
|
||||
}
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
let confidence = payload["confidence"].as_f64().unwrap_or(0.0);
|
||||
let point_id = point["id"].as_i64().unwrap_or(0);
|
||||
|
||||
let traces: Vec<UnassignedTrace> = rows
|
||||
let entry = trace_map.entry((file_uuid, trace_id)).or_default();
|
||||
entry.frame_count += 1;
|
||||
if frame < entry.start_frame || entry.start_frame == 0 {
|
||||
entry.start_frame = frame;
|
||||
}
|
||||
if frame > entry.end_frame {
|
||||
entry.end_frame = frame;
|
||||
}
|
||||
if confidence > entry.best_confidence {
|
||||
entry.best_confidence = confidence;
|
||||
entry.best_point_id = point_id;
|
||||
entry.best_frame = frame;
|
||||
entry.best_bbox = payload.get("bbox").cloned();
|
||||
}
|
||||
}
|
||||
|
||||
let total = trace_map.len() as i64;
|
||||
|
||||
// Sort by frame_count DESC, paginate
|
||||
let mut sorted_traces: Vec<((String, i32), TraceAgg)> = trace_map.into_iter().collect();
|
||||
sorted_traces.sort_by(|a, b| b.1.frame_count.cmp(&a.1.frame_count));
|
||||
let paginated: Vec<_> = sorted_traces
|
||||
.into_iter()
|
||||
.map(|r| UnassignedTrace {
|
||||
trace_id: r.0,
|
||||
file_uuid: r.1,
|
||||
frame_count: r.2,
|
||||
start_frame: r.3,
|
||||
end_frame: r.4,
|
||||
best_face_id: r.5,
|
||||
best_face_frame: r.6,
|
||||
best_face_confidence: r.7,
|
||||
best_face_bbox: r.8,
|
||||
.skip(offset)
|
||||
.take(page_size)
|
||||
.collect();
|
||||
|
||||
let traces: Vec<UnassignedTrace> = paginated
|
||||
.into_iter()
|
||||
.map(|((file_uuid, trace_id), agg)| UnassignedTrace {
|
||||
trace_id,
|
||||
file_uuid,
|
||||
frame_count: agg.frame_count,
|
||||
start_frame: agg.start_frame,
|
||||
end_frame: agg.end_frame,
|
||||
best_face_id: agg.best_point_id as i32,
|
||||
best_face_frame: agg.best_frame,
|
||||
best_face_confidence: agg.best_confidence,
|
||||
best_face_bbox: agg.best_bbox,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -8,10 +8,14 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::types::AppState;
|
||||
use crate::core::db::schema;
|
||||
use crate::core::db::PostgresDb;
|
||||
use crate::core::db::QdrantDb;
|
||||
use crate::core::progress::{AgentPhase, AgentProgress, AgentStats, publish_agent_progress};
|
||||
use crate::core::db::redis_client::RedisClient;
|
||||
|
||||
pub fn identity_agent_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -27,10 +31,7 @@ pub fn identity_agent_routes() -> Router<AppState> {
|
||||
"/api/v1/agents/identity/generate-seeds",
|
||||
post(generate_seeds_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/agents/identity/run",
|
||||
post(run_identity_handler),
|
||||
)
|
||||
.route("/api/v1/agents/identity/run", post(run_identity_handler))
|
||||
.route(
|
||||
"/api/v1/agents/identity/confirm",
|
||||
post(confirm_identity_handler),
|
||||
@@ -209,39 +210,42 @@ async fn match_from_photo(
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Find best matching trace (highest similarity, no threshold)
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let best_match: Option<(i32, i32, f64)> = sqlx::query_as(&format!(
|
||||
r#"SELECT id, trace_id,
|
||||
1 - (embedding::vector <=> $1::vector) as similarity
|
||||
FROM {}
|
||||
WHERE file_uuid = $2 AND embedding IS NOT NULL
|
||||
ORDER BY embedding::vector <=> $1::vector
|
||||
LIMIT 1"#,
|
||||
fd_table
|
||||
))
|
||||
.bind(&embedding_f32)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"message": format!("Search failed: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
// 4. Find best matching trace via Qdrant _faces search
|
||||
let qdrant = QdrantDb::new();
|
||||
|
||||
// 5. Update best match face_detection
|
||||
let best_match: Option<(i32, f64)> = match qdrant.search_face_collection(
|
||||
"_faces",
|
||||
&embedding_f32,
|
||||
1,
|
||||
"file_uuid",
|
||||
"",
|
||||
Some(&file_uuid),
|
||||
).await {
|
||||
Ok(hits) if !hits.is_empty() => {
|
||||
let (score, payload) = &hits[0];
|
||||
let trace_id = payload.get("trace_id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
Some((trace_id, *score))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// 5. Update best match in Qdrant _faces (trace-scoped)
|
||||
let mut traces_matched: Vec<i32> = Vec::new();
|
||||
if let Some((fb_id, fb_trace, fb_sim)) = best_match {
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(fb_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
if let Some((fb_trace, fb_sim)) = best_match {
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": fb_trace}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": identity_id});
|
||||
if let Err(e) = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[match_from_photo] Qdrant update failed: {}", e);
|
||||
}
|
||||
traces_matched.push(fb_trace);
|
||||
|
||||
// 6. Save identity file
|
||||
@@ -283,25 +287,26 @@ async fn match_from_trace(
|
||||
) -> Result<Json<MatchFromPhotoResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let uuid_clean = req.identity_uuid.replace('-', "");
|
||||
|
||||
// 1. Get 3 best face embeddings from this trace at different angles
|
||||
// Divide trace frame range into 3 segments, pick best face from each
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let all_faces: Vec<(Vec<f32>, i64)> = sqlx::query_as::<_, (Vec<f32>, i64)>(&format!(
|
||||
"SELECT embedding, frame_number FROM {} \
|
||||
WHERE file_uuid = $1 AND trace_id = $2 AND embedding IS NOT NULL \
|
||||
ORDER BY frame_number ASC",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.trace_id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"message": format!("DB error: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
// 1. Get face embeddings from Qdrant _faces for this trace
|
||||
let qdrant = QdrantDb::new();
|
||||
let trace_filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": req.file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": req.trace_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", trace_filter, 500).await.unwrap_or_default();
|
||||
|
||||
let all_faces: Vec<(Vec<f32>, i64)> = points.iter().filter_map(|p| {
|
||||
let vector = p.get("vector").and_then(|v| v.as_array())?;
|
||||
let embedding: Vec<f32> = vector.iter().filter_map(|v| v.as_f64().map(|f| f as f32)).collect();
|
||||
let frame = p["payload"]["frame"].as_i64()?;
|
||||
if embedding.len() == 512 {
|
||||
Some((embedding, frame))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect();
|
||||
|
||||
if all_faces.is_empty() {
|
||||
return Err((
|
||||
@@ -322,18 +327,14 @@ async fn match_from_trace(
|
||||
|
||||
let mut query_embeddings: Vec<Vec<f32>> = Vec::new();
|
||||
|
||||
// Get width*height info if available (not all pipelines store it)
|
||||
let face_sizes: Vec<(i64, i32)> = sqlx::query_as::<_, (i64, i32)>(&format!(
|
||||
"SELECT frame_number, COALESCE(width, 0) * COALESCE(height, 0) AS area \
|
||||
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND embedding IS NOT NULL \
|
||||
ORDER BY frame_number ASC",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.trace_id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Get bbox size info from Qdrant payload
|
||||
let face_sizes: Vec<(i64, i32)> = points.iter().filter_map(|p| {
|
||||
let frame = p["payload"]["frame"].as_i64()?;
|
||||
let bbox = &p["payload"]["bbox"];
|
||||
let w = bbox["width"].as_f64().unwrap_or(0.0) as i32;
|
||||
let h = bbox["height"].as_f64().unwrap_or(0.0) as i32;
|
||||
Some((frame, w * h))
|
||||
}).collect();
|
||||
|
||||
let face_sizes_map: std::collections::HashMap<i64, i32> = face_sizes.into_iter().collect();
|
||||
|
||||
@@ -358,37 +359,39 @@ async fn match_from_trace(
|
||||
query_embeddings.push(all_faces[total / 2].0.clone());
|
||||
}
|
||||
|
||||
// 2. Three angles each find their best match; union all results
|
||||
// 2. Three angles each find their best match via Qdrant; union all results
|
||||
let mut validated: Vec<(i32, i32, f64)> = Vec::new();
|
||||
let mut seen_trace_ids = std::collections::HashSet::new();
|
||||
|
||||
for qemb in &query_embeddings {
|
||||
let top = sqlx::query_as::<_, (i32, i32, f64)>(&format!(
|
||||
r#"SELECT id, trace_id,
|
||||
1 - (embedding::vector <=> $1::vector) as similarity
|
||||
FROM {}
|
||||
WHERE file_uuid = $2
|
||||
AND trace_id != $3
|
||||
AND embedding IS NOT NULL
|
||||
ORDER BY embedding::vector <=> $1::vector
|
||||
LIMIT 1"#,
|
||||
fd_table
|
||||
))
|
||||
.bind(qemb)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.trace_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"message": format!("Search failed: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": req.file_uuid}}
|
||||
],
|
||||
"must_not": [
|
||||
{"key": "trace_id", "match": {"value": req.trace_id}}
|
||||
]
|
||||
});
|
||||
|
||||
if let Some((cface_id, c_trace_id, c_sim)) = top {
|
||||
if seen_trace_ids.insert(c_trace_id) {
|
||||
validated.push((cface_id, c_trace_id, c_sim));
|
||||
let hits = match qdrant.search_face_collection(
|
||||
"_faces",
|
||||
qemb,
|
||||
1,
|
||||
"trace_id",
|
||||
&req.trace_id.to_string(),
|
||||
Some(&req.file_uuid),
|
||||
).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!("[match_from_trace] Qdrant search failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((score, payload)) = hits.first() {
|
||||
let trace_id = payload.get("trace_id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
if seen_trace_ids.insert(trace_id) {
|
||||
validated.push((0, trace_id, *score));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,41 +424,49 @@ async fn match_from_trace(
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Update matched face_detections
|
||||
// 4. Update matched traces in Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let mut traces_matched: Vec<i32> = Vec::new();
|
||||
for (id, trace_id, _similarity) in &validated {
|
||||
if let Err(e) = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
for (_id, trace_id, _similarity) in &validated {
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": req.file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": trace_id}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": identity_id});
|
||||
if let Err(e) = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[match-from-trace] Failed to update face_detection {}: {}",
|
||||
id,
|
||||
"[match-from-trace] Qdrant update failed for trace {}: {}",
|
||||
trace_id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
if !traces_matched.contains(trace_id) {
|
||||
traces_matched.push(*trace_id);
|
||||
}
|
||||
} else if !traces_matched.contains(trace_id) {
|
||||
traces_matched.push(*trace_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Also bind the source trace itself
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = $3",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.trace_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": req.file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": req.trace_id}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": identity_id});
|
||||
if let Err(e) = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[match-from-trace] Qdrant update failed for source trace {}: {}",
|
||||
req.trace_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
if !traces_matched.contains(&req.trace_id) {
|
||||
traces_matched.push(req.trace_id);
|
||||
}
|
||||
@@ -667,33 +678,34 @@ fn average_embeddings<'a>(embeddings: impl Iterator<Item = &'a Vec<f32>>) -> Vec
|
||||
async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result<usize> {
|
||||
use crate::core::processor::executor::PythonExecutor;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
let executor = PythonExecutor::new()?;
|
||||
|
||||
|
||||
let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string());
|
||||
|
||||
|
||||
let output_path = std::path::PathBuf::from(&output_dir)
|
||||
.join(file_uuid)
|
||||
.join(format!("{}.identity_match_round1.json", file_uuid));
|
||||
|
||||
|
||||
std::fs::create_dir_all(output_path.parent().unwrap()).ok();
|
||||
|
||||
|
||||
let scripts_dir = executor.script_dir();
|
||||
let python_path = executor.python_path();
|
||||
let script_path = scripts_dir.join("identity_matcher.py");
|
||||
|
||||
let qdrant_url = std::env::var("QDRANT_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:6333".to_string());
|
||||
let qdrant_api_key = std::env::var("QDRANT_API_KEY")
|
||||
.unwrap_or_else(|_| "Test3200Test3200Test3200".to_string());
|
||||
|
||||
let qdrant_url =
|
||||
std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string());
|
||||
let qdrant_api_key =
|
||||
std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string());
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgresql://accusys@localhost:5432/momentry".to_string());
|
||||
|
||||
|
||||
let db_schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "public".to_string());
|
||||
let mut cmd = tokio::process::Command::new(python_path);
|
||||
cmd.env("MOMENTRY_OUTPUT_DIR", &output_dir);
|
||||
cmd.env("DATABASE_SCHEMA", "public");
|
||||
cmd.env("MOMENTRY_DB_SCHEMA", "public");
|
||||
cmd.env("DATABASE_SCHEMA", &db_schema);
|
||||
cmd.env("MOMENTRY_DB_SCHEMA", &db_schema);
|
||||
cmd.env("DATABASE_URL", &db_url);
|
||||
cmd.env("QDRANT_URL", &qdrant_url);
|
||||
cmd.env("QDRANT_API_KEY", &qdrant_api_key);
|
||||
@@ -702,42 +714,50 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
|
||||
cmd.arg("--round").arg("1");
|
||||
cmd.arg("--mark-tkg");
|
||||
cmd.arg("--output").arg(&output_path);
|
||||
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
|
||||
tracing::info!("[FaceMatch] Starting identity_matcher for {}", file_uuid);
|
||||
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
|
||||
if !output.status.success() {
|
||||
tracing::error!("[FaceMatch] identity_matcher failed with exit code: {:?}", output.status.code());
|
||||
tracing::error!(
|
||||
"[FaceMatch] identity_matcher failed with exit code: {:?}",
|
||||
output.status.code()
|
||||
);
|
||||
tracing::error!("[FaceMatch] stderr: {}", stderr);
|
||||
tracing::error!("[FaceMatch] stdout: {}", stdout);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
|
||||
tracing::info!("[FaceMatch] stdout: {}", stdout);
|
||||
|
||||
|
||||
if !output_path.exists() {
|
||||
tracing::info!("[FaceMatch] No matches found for {}", file_uuid);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
|
||||
let content = std::fs::read_to_string(&output_path)?;
|
||||
let result: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
|
||||
let matched = result.get("matched").and_then(|v| v.as_i64()).unwrap_or(0) as usize;
|
||||
let tkg_updated = result.get("tkg_nodes_updated").and_then(|v| v.as_i64()).unwrap_or(0) as usize;
|
||||
|
||||
let tkg_updated = result
|
||||
.get("tkg_nodes_updated")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0) as usize;
|
||||
|
||||
tracing::info!(
|
||||
"[FaceMatch] Round 1 for {}: {} matches, {} TKG nodes updated",
|
||||
file_uuid, matched, tkg_updated
|
||||
file_uuid,
|
||||
matched,
|
||||
tkg_updated
|
||||
);
|
||||
|
||||
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
@@ -755,17 +775,33 @@ async fn match_faces_iterative_pg(pool: &sqlx::PgPool, file_uuid: &str) -> anyho
|
||||
/// segments (speaker_id, start_time, end_time), computes overlap,
|
||||
/// and stores bindings in identity_bindings table.
|
||||
pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result<usize> {
|
||||
// Load face traces with identity_id and frame numbers
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let traces = sqlx::query_as::<_, (i32, Vec<i32>)>(&format!(
|
||||
"SELECT trace_id, array_agg(frame_number ORDER BY frame_number) \
|
||||
FROM {} WHERE file_uuid=$1 AND trace_id IS NOT NULL AND identity_id IS NOT NULL \
|
||||
GROUP BY trace_id",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
// Load face traces with identity_id from Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let trace_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "identity_id", "exists": true},
|
||||
{"key": "trace_id", "match": {"value": 1}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", trace_filter, 500).await.unwrap_or_default();
|
||||
|
||||
// Group by trace_id, collect frames
|
||||
let mut traces: HashMap<i32, Vec<i64>> = 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);
|
||||
traces.entry(trace_id).or_default().push(frame);
|
||||
}
|
||||
|
||||
// Sort frames per trace
|
||||
for frames in traces.values_mut() {
|
||||
frames.sort();
|
||||
}
|
||||
|
||||
if traces.is_empty() {
|
||||
tracing::info!("[SpeakerBind] No face traces with identities");
|
||||
@@ -818,8 +854,23 @@ pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Resu
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Get fps for frame-to-time conversion
|
||||
let fps: f64 = 25.0; // default, could also read from DB
|
||||
// Compute fps from video table
|
||||
let fps: f64 = sqlx::query_scalar::<_, f64>(
|
||||
"SELECT COALESCE(fps, 25.0) FROM videos WHERE file_uuid=$1"
|
||||
)
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(25.0);
|
||||
|
||||
tracing::info!(
|
||||
"[SpeakerBind] Using fps={:.3} for {} ({} traces)",
|
||||
fps,
|
||||
file_uuid,
|
||||
traces.len()
|
||||
);
|
||||
|
||||
// For each trace, compute overlap with each speaker
|
||||
let mut bindings = 0usize;
|
||||
@@ -828,13 +879,15 @@ pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Resu
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get identity_id for this trace
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let identity_id: Option<i32> = sqlx::query_scalar(
|
||||
&format!("SELECT identity_id FROM {} WHERE file_uuid=$1 AND trace_id=$2 AND identity_id IS NOT NULL LIMIT 1", fd_table)
|
||||
)
|
||||
.bind(file_uuid).bind(trace_id)
|
||||
.fetch_optional(pool).await?.flatten();
|
||||
// Get identity_id for this trace from Qdrant payload
|
||||
let identity_id: Option<i32> = points.iter()
|
||||
.find(|p| {
|
||||
p["payload"]["trace_id"].as_i64() == Some(*trace_id as i64)
|
||||
&& p["payload"]["identity_id"].as_i64().is_some()
|
||||
&& p["payload"]["identity_id"].as_i64().unwrap() > 0
|
||||
})
|
||||
.and_then(|p| p["payload"]["identity_id"].as_i64())
|
||||
.map(|id| id as i32);
|
||||
|
||||
if identity_id.is_none() {
|
||||
continue;
|
||||
@@ -873,18 +926,20 @@ pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Resu
|
||||
});
|
||||
|
||||
let ib_table = schema::table_name("identity_bindings");
|
||||
let _ = sqlx::query(
|
||||
&format!("INSERT INTO {} (identity_id, identity_type, identity_value, file_uuid, confidence, metadata) \
|
||||
VALUES ($1, 'speaker', $2, $3, $4, $5::jsonb) \
|
||||
ON CONFLICT (identity_id, identity_type, identity_value, file_uuid) \
|
||||
if let Err(e) = sqlx::query(
|
||||
&format!("INSERT INTO {} (identity_id, identity_type, identity_value, confidence, metadata) \
|
||||
VALUES ($1, 'speaker', $2, $3, $4::jsonb) \
|
||||
ON CONFLICT (identity_id, identity_type, identity_value) \
|
||||
DO UPDATE SET confidence = EXCLUDED.confidence, metadata = EXCLUDED.metadata", ib_table)
|
||||
)
|
||||
.bind(identity_id)
|
||||
.bind(&best_speaker)
|
||||
.bind(file_uuid)
|
||||
.bind(overlap_ratio)
|
||||
.bind(&metadata)
|
||||
.execute(pool).await;
|
||||
.execute(pool).await
|
||||
{
|
||||
tracing::error!("[SpeakerBind] INSERT failed for trace_id={}, identity_id={}, speaker={}: {}", trace_id, identity_id, best_speaker, e);
|
||||
}
|
||||
|
||||
// Also update speaker_detections with the identity_id
|
||||
let sd_table = schema::table_name("speaker_detections");
|
||||
@@ -915,16 +970,40 @@ pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Resu
|
||||
/// Pipeline-triggered entry point: runs the full identity agent for a file.
|
||||
/// Reads face_clustered.json + asrx.json, extracts persons/speakers, creates identities,
|
||||
/// runs iterative face matching, and binds speakers.
|
||||
pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<()> {
|
||||
pub async fn run_identity_agent(
|
||||
db: &PostgresDb,
|
||||
file_uuid: &str,
|
||||
redis: Option<std::sync::Arc<RedisClient>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string());
|
||||
|
||||
let pool = db.pool();
|
||||
|
||||
// Step 1: 先跑 face matching(不需 face_clustered.json)
|
||||
let matched = match_faces_iterative(pool, file_uuid).await.unwrap_or(0);
|
||||
let mut progress = AgentProgress::new(file_uuid);
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
// Step 1: Face matching (iterative TMDb matching)
|
||||
progress.update_phase(AgentPhase::TmdbMatching, 0.3, "Running face matching...");
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
let matched = match_faces_iterative(pool, file_uuid).await.unwrap_or(0);
|
||||
progress.stats.tmdb_matches = matched as i64;
|
||||
progress.update_phase(AgentPhase::TmdbMatching, 1.0, &format!("Face matching: {} matches", matched));
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
// Step 2: Load face_clustered.json and create identities
|
||||
progress.update_phase(AgentPhase::FaceClustering, 0.5, "Loading face clusters...");
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
// Step 2: 試著載入 face_clustered.json 建立新 identities
|
||||
let video_dir = PathBuf::from(&output_dir).join(file_uuid);
|
||||
let face_clustered_path = video_dir.join(format!("{}.face_clustered.json", file_uuid));
|
||||
let face_clustered_path = if face_clustered_path.exists() {
|
||||
@@ -947,6 +1026,8 @@ pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Res
|
||||
let speakers = extract_speakers_from_asrx_data(&asrx_data);
|
||||
let identities = analyze_person_speaker_overlap(&persons, &speakers);
|
||||
|
||||
progress.stats.clusters = identities.len() as i64;
|
||||
|
||||
let _ = identities.len();
|
||||
if !identities.is_empty() {
|
||||
let metadata = serde_json::json!({
|
||||
@@ -969,6 +1050,13 @@ pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Res
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
progress.stats.identities_created = identities.len() as i64;
|
||||
progress.update_phase(AgentPhase::IdentityCreation, 1.0, &format!(
|
||||
"Created {} identities from clusters", identities.len()
|
||||
));
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
tracing::info!(
|
||||
"[IdentityAgent] Analyzed {} face clusters from face_clustered for {}",
|
||||
identities.len(),
|
||||
@@ -979,9 +1067,29 @@ pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Res
|
||||
"[IdentityAgent] face_clustered.json not found for {}, skipping identity creation",
|
||||
file_uuid
|
||||
);
|
||||
progress.update_phase(AgentPhase::IdentityCreation, 0.0, "No face_clustered.json");
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Speaker binding
|
||||
progress.update_phase(AgentPhase::SpeakerBinding, 0.5, "Binding speakers...");
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
let bound = bind_speakers(pool, file_uuid).await.unwrap_or(0);
|
||||
progress.stats.speaker_bindings = bound as i64;
|
||||
progress.update_phase(AgentPhase::SpeakerBinding, 1.0, &format!("Speaker binding: {} bound", bound));
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
progress.mark_completed();
|
||||
if let Some(r) = redis.as_ref() {
|
||||
publish_agent_progress(&r, file_uuid, &progress).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[IdentityAgent] Done for {}: {} face matches, {} speaker bindings",
|
||||
@@ -999,14 +1107,12 @@ async fn generate_seeds_handler(
|
||||
let db = &state.db;
|
||||
let pool = db.pool();
|
||||
|
||||
let count = generate_seed_embeddings(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("{}", e)})),
|
||||
)
|
||||
})?;
|
||||
let count = generate_seed_embeddings(db).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("{}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Auto-trigger identity agent for all ready files
|
||||
if count > 0 {
|
||||
@@ -1019,13 +1125,13 @@ async fn generate_seeds_handler(
|
||||
);
|
||||
for file_uuid in &ready_files {
|
||||
let db = state.db.clone();
|
||||
let redis = crate::core::db::RedisClient::new().ok().map(Arc::new);
|
||||
let fid = file_uuid.clone();
|
||||
tokio::spawn(async move {
|
||||
match run_identity_agent(&db, &fid).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"[GenerateSeeds] Identity agent completed for {}",
|
||||
fid
|
||||
),
|
||||
match run_identity_agent(&db, &fid, redis).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[GenerateSeeds] Identity agent completed for {}", fid)
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"[GenerateSeeds] Identity agent failed for {}: {}",
|
||||
fid,
|
||||
@@ -1044,16 +1150,28 @@ async fn generate_seeds_handler(
|
||||
})))
|
||||
}
|
||||
|
||||
/// Find videos that are ready for identity processing (have face embeddings).
|
||||
/// Find videos that are ready for identity processing (have face embeddings in Qdrant).
|
||||
async fn find_ready_files(pool: &sqlx::PgPool) -> anyhow::Result<Vec<String>> {
|
||||
let fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&format!(
|
||||
"SELECT DISTINCT file_uuid FROM {} WHERE embedding IS NOT NULL AND identity_id IS NULL",
|
||||
fd_table
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.0).collect())
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
// Find files with faces that don't have identity_id set
|
||||
let filter = json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": null}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", filter, 1000).await.unwrap_or_default();
|
||||
|
||||
let mut file_uuids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for point in &points {
|
||||
if let Some(fu) = point["payload"]["file_uuid"].as_str() {
|
||||
file_uuids.insert(fu.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(file_uuids.into_iter().collect())
|
||||
}
|
||||
|
||||
/// API handler: POST /api/v1/agents/identity/run
|
||||
@@ -1071,7 +1189,8 @@ async fn run_identity_handler(
|
||||
)
|
||||
})?;
|
||||
|
||||
match run_identity_agent(&state.db, file_uuid).await {
|
||||
let redis = crate::core::db::RedisClient::new().ok().map(Arc::new);
|
||||
match run_identity_agent(&state.db, file_uuid, redis).await {
|
||||
Ok(()) => Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": format!("Identity agent completed for {}", file_uuid),
|
||||
@@ -1109,29 +1228,28 @@ async fn confirm_identity_handler(
|
||||
Json(req): Json<ConfirmIdentityRequest>,
|
||||
) -> Result<Json<ConfirmIdentityResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
use crate::core::processor::executor::PythonExecutor;
|
||||
|
||||
|
||||
let executor = PythonExecutor::new().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("PythonExecutor error: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
let scripts_dir = executor.script_dir();
|
||||
let python_path = executor.python_path();
|
||||
let script_path = scripts_dir.join("confirm_identity.py");
|
||||
|
||||
let qdrant_url = std::env::var("QDRANT_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:6333".to_string());
|
||||
let qdrant_api_key = std::env::var("QDRANT_API_KEY")
|
||||
.unwrap_or_else(|_| "Test3200Test3200Test3200".to_string());
|
||||
|
||||
let qdrant_url =
|
||||
std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string());
|
||||
let qdrant_api_key =
|
||||
std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string());
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgresql://accusys@localhost:5432/momentry".to_string());
|
||||
let db_schema = std::env::var("DATABASE_SCHEMA")
|
||||
.unwrap_or_else(|_| "dev".to_string());
|
||||
|
||||
let db_schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string());
|
||||
|
||||
let propagate = req.propagate.unwrap_or(true);
|
||||
|
||||
|
||||
let mut cmd = tokio::process::Command::new(python_path);
|
||||
cmd.env("DATABASE_URL", &db_url);
|
||||
cmd.env("DATABASE_SCHEMA", &db_schema);
|
||||
@@ -1144,31 +1262,39 @@ async fn confirm_identity_handler(
|
||||
cmd.arg("--identity-id").arg(req.identity_id.to_string());
|
||||
cmd.arg("--identity-uuid").arg(&req.identity_uuid);
|
||||
cmd.arg("--name").arg(&req.name);
|
||||
|
||||
|
||||
if !propagate {
|
||||
cmd.arg("--no-propagate");
|
||||
}
|
||||
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
|
||||
tracing::info!(
|
||||
"[ConfirmIdentity] Starting for {} trace {} -> {} ({})",
|
||||
req.file_uuid, req.trace_id, req.identity_uuid, req.name
|
||||
req.file_uuid,
|
||||
req.trace_id,
|
||||
req.identity_uuid,
|
||||
req.name
|
||||
);
|
||||
|
||||
|
||||
let output = cmd.output().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("Command failed: {}", e)})),
|
||||
Json(
|
||||
serde_json::json!({"success": false, "message": format!("Command failed: {}", e)}),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
|
||||
if !output.status.success() {
|
||||
tracing::error!("[ConfirmIdentity] Script failed with exit code: {:?}", output.status.code());
|
||||
tracing::error!(
|
||||
"[ConfirmIdentity] Script failed with exit code: {:?}",
|
||||
output.status.code()
|
||||
);
|
||||
tracing::error!("[ConfirmIdentity] stderr: {}", stderr);
|
||||
tracing::error!("[ConfirmIdentity] stdout: {}", stdout);
|
||||
return Err((
|
||||
@@ -1180,9 +1306,9 @@ async fn confirm_identity_handler(
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
tracing::info!("[ConfirmIdentity] stdout: {}", stdout);
|
||||
|
||||
|
||||
let json_start = stdout.find('{');
|
||||
if json_start.is_none() {
|
||||
return Err((
|
||||
@@ -1195,7 +1321,7 @@ async fn confirm_identity_handler(
|
||||
));
|
||||
}
|
||||
let json_str = &stdout[json_start.unwrap()..];
|
||||
|
||||
|
||||
let result: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -1207,14 +1333,17 @@ async fn confirm_identity_handler(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
Ok(Json(ConfirmIdentityResponse {
|
||||
success: result.get("status").and_then(|v| v.as_str()) == Some("success"),
|
||||
file_uuid: req.file_uuid,
|
||||
trace_id: req.trace_id,
|
||||
identity_uuid: req.identity_uuid,
|
||||
name: req.name,
|
||||
steps: result.get("steps").cloned().unwrap_or(serde_json::json!({})),
|
||||
steps: result
|
||||
.get("steps")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({})),
|
||||
propagation: result.get("propagation").cloned(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::core::db::ResourceRecord;
|
||||
use crate::core::db::{QdrantDb, ResourceRecord};
|
||||
|
||||
pub fn identity_routes() -> Router<crate::api::types::AppState> {
|
||||
Router::new()
|
||||
@@ -269,12 +269,7 @@ async fn get_file_identities(
|
||||
let fi_table = crate::core::db::schema::table_name("file_identities");
|
||||
let total = match sqlx::query_scalar::<_, i64>(
|
||||
&format!(
|
||||
r#"SELECT COUNT(DISTINCT identity_id) FROM (
|
||||
SELECT identity_id FROM {} WHERE file_uuid = $1 AND identity_id IS NOT NULL
|
||||
UNION
|
||||
SELECT identity_id FROM {} WHERE file_uuid = $1
|
||||
) combined"#,
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
r#"SELECT COUNT(DISTINCT identity_id) FROM {} WHERE file_uuid = $1 AND identity_id IS NOT NULL"#,
|
||||
fi_table
|
||||
)
|
||||
)
|
||||
@@ -419,7 +414,6 @@ async fn delete_identity(
|
||||
Extension(auth): Extension<crate::api::middleware::UserAuth>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
|
||||
@@ -440,15 +434,27 @@ async fn delete_identity(
|
||||
// Delete identity file from disk
|
||||
let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean);
|
||||
|
||||
// Capture unbound faces before unbinding
|
||||
let unbound_faces: Vec<(String, Option<String>, Option<i32>)> = sqlx::query_as(&format!(
|
||||
"SELECT file_uuid, face_id, trace_id FROM {} WHERE identity_id = $1",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
// Capture unbound faces from Qdrant _faces before unbinding
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
let face_filter = json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default();
|
||||
|
||||
let unbound_faces: Vec<(String, Option<String>, Option<i32>)> = points.iter()
|
||||
.filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let file_uuid = payload["file_uuid"].as_str()?.to_string();
|
||||
let face_id = payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let trace_id = payload["trace_id"].as_i64().map(|t| t as i32);
|
||||
Some((file_uuid, face_id, trace_id))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let face_list: Vec<serde_json::Value> = unbound_faces
|
||||
.into_iter()
|
||||
@@ -494,15 +500,17 @@ async fn delete_identity(
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
|
||||
// Unbind all faces
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE identity_id = $1",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
// Unbind all faces in Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": serde_json::Value::Null});
|
||||
let _ = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await;
|
||||
|
||||
// Delete identity
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
|
||||
@@ -572,17 +580,21 @@ async fn get_identity_files(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = match sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(DISTINCT fd.file_uuid) FROM {} fd WHERE fd.identity_id = $1",
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
))
|
||||
.bind(identity_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => data.len() as i64,
|
||||
};
|
||||
// Get total from Qdrant _faces
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
let face_filter = json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default();
|
||||
let unique_files: std::collections::HashSet<String> = points.iter()
|
||||
.filter_map(|p| p["payload"]["file_uuid"].as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
let total = unique_files.len() as i64;
|
||||
|
||||
Ok(Json(IdentityFilesResponse {
|
||||
success: true,
|
||||
@@ -673,17 +685,14 @@ async fn get_identity_faces(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = match sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {} fd WHERE fd.identity_id = $1",
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
))
|
||||
.bind(identity_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => data.len() as i64,
|
||||
};
|
||||
let qdrant2 = QdrantDb::new();
|
||||
let face_filter2 = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let points2 = qdrant2.scroll_all_points("_faces", face_filter2, 2000).await.unwrap_or_default();
|
||||
let total = points2.len() as i64;
|
||||
|
||||
Ok(Json(IdentityFacesResponse {
|
||||
success: true,
|
||||
@@ -759,151 +768,114 @@ async fn get_file_faces(
|
||||
let page_size = params.page_size.unwrap_or(50);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let st_table = crate::core::db::schema::table_name("strangers");
|
||||
let video_table = crate::core::db::schema::table_name("videos");
|
||||
|
||||
// Build WHERE clauses
|
||||
let mut where_clauses = vec![format!(
|
||||
"fd.file_uuid = '{}'",
|
||||
file_uuid.replace('\'', "''")
|
||||
)];
|
||||
// Get fps
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(25.0);
|
||||
|
||||
// Get face points from Qdrant _faces
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
let mut filter_conditions = vec![
|
||||
json!({"key": "file_uuid", "match": {"value": file_uuid}})
|
||||
];
|
||||
|
||||
if let Some(ref binding) = params.binding {
|
||||
match binding.as_str() {
|
||||
"identity" => {
|
||||
where_clauses.push(format!("fd.identity_id IN (SELECT id FROM {})", id_table));
|
||||
filter_conditions.push(json!({"key": "identity_id", "exists": true}));
|
||||
}
|
||||
"stranger" => {
|
||||
where_clauses.push("fd.stranger_id IS NOT NULL".to_string());
|
||||
}
|
||||
"dangling" => {
|
||||
where_clauses.push(format!(
|
||||
"fd.identity_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM {} WHERE id = fd.identity_id)",
|
||||
id_table
|
||||
));
|
||||
filter_conditions.push(json!({"key": "stranger_id", "exists": true}));
|
||||
}
|
||||
"unbound" => {
|
||||
where_clauses.push("fd.identity_id IS NULL AND fd.stranger_id IS NULL".to_string());
|
||||
filter_conditions.push(json!({"key": "identity_id", "match": {"value": null}}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tid) = params.trace_id {
|
||||
where_clauses.push(format!("fd.trace_id = {}", tid));
|
||||
}
|
||||
if let Some(mc) = params.min_confidence {
|
||||
where_clauses.push(format!("fd.confidence >= {}", mc));
|
||||
}
|
||||
if let Some(sf) = params.start_frame {
|
||||
where_clauses.push(format!("fd.frame_number >= {}", sf));
|
||||
}
|
||||
if let Some(ef) = params.end_frame {
|
||||
where_clauses.push(format!("fd.frame_number <= {}", ef));
|
||||
filter_conditions.push(json!({"key": "trace_id", "match": {"value": tid}}));
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let face_filter = json!({"must": filter_conditions});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 2000).await.unwrap_or_default();
|
||||
|
||||
let select_sql = format!(
|
||||
"SELECT fd.id::bigint as id, fd.file_uuid, \
|
||||
fd.frame_number::bigint as frame_number, \
|
||||
(fd.frame_number::float8 / NULLIF(v.fps, 0)) as timestamp_secs, \
|
||||
fd.face_id, fd.trace_id, \
|
||||
fd.x::float8 as x, fd.y::float8 as y, \
|
||||
fd.width::float8 as width, fd.height::float8 as height, \
|
||||
fd.confidence::float8 as confidence, \
|
||||
fd.identity_id, fd.stranger_id, \
|
||||
i.uuid::text as identity_uuid, i.name as identity_name, \
|
||||
s.metadata as stranger_metadata \
|
||||
FROM {} fd \
|
||||
JOIN {} v ON v.file_uuid = fd.file_uuid \
|
||||
LEFT JOIN {} i ON i.id = fd.identity_id \
|
||||
LEFT JOIN {} s ON s.id = fd.stranger_id \
|
||||
WHERE {} \
|
||||
ORDER BY fd.frame_number, fd.trace_id \
|
||||
LIMIT {} OFFSET {}",
|
||||
fd_table, video_table, id_table, st_table, where_sql, page_size as i64, offset
|
||||
);
|
||||
// Apply additional filters in Rust
|
||||
let filtered: Vec<_> = points.into_iter().filter(|p| {
|
||||
let payload = &p["payload"];
|
||||
let confidence = payload["confidence"].as_f64().unwrap_or(0.0);
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM {} fd \
|
||||
WHERE {}",
|
||||
fd_table, where_sql
|
||||
);
|
||||
if let Some(mc) = params.min_confidence {
|
||||
if confidence < mc { return false; }
|
||||
}
|
||||
if let Some(sf) = params.start_frame {
|
||||
if frame < sf { return false; }
|
||||
}
|
||||
if let Some(ef) = params.end_frame {
|
||||
if frame > ef { return false; }
|
||||
}
|
||||
true
|
||||
}).collect();
|
||||
|
||||
use sqlx::Row;
|
||||
let rows = sqlx::query(&select_sql)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let total = filtered.len() as i64;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
// Apply pagination
|
||||
let paged: Vec<_> = filtered.into_iter().skip(offset as usize).take(page_size as usize).collect();
|
||||
|
||||
let data: Vec<FileFaceItem> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let identity_id: Option<i32> = r.get("identity_id");
|
||||
let identity_uuid: Option<String> = r.get("identity_uuid");
|
||||
let identity_name: Option<String> = r.get("identity_name");
|
||||
let stranger_id: Option<i32> = r.get("stranger_id");
|
||||
// Build response items
|
||||
let mut data = Vec::new();
|
||||
for point in &paged {
|
||||
let payload = &point["payload"];
|
||||
let bbox = &payload["bbox"];
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
let confidence = payload["confidence"].as_f64().unwrap_or(0.0);
|
||||
|
||||
let binding = if let (Some(iid), Some(iuuid), Some(iname)) =
|
||||
(identity_id, identity_uuid, identity_name)
|
||||
{
|
||||
FaceBinding::Identity {
|
||||
identity_id: iid,
|
||||
identity_uuid: iuuid,
|
||||
identity_name: iname,
|
||||
}
|
||||
} else if let Some(sid) = stranger_id {
|
||||
FaceBinding::Stranger {
|
||||
stranger_id: sid,
|
||||
metadata: r
|
||||
.get::<Option<serde_json::Value>, _>("stranger_metadata")
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}
|
||||
} else if let Some(iid) = identity_id {
|
||||
FaceBinding::Dangling {
|
||||
old_identity_id: iid,
|
||||
}
|
||||
} else {
|
||||
FaceBinding::Unbound
|
||||
};
|
||||
|
||||
FileFaceItem {
|
||||
id: r.get("id"),
|
||||
file_uuid: r.get("file_uuid"),
|
||||
frame_number: r.get("frame_number"),
|
||||
timestamp_secs: r.get("timestamp_secs"),
|
||||
face_id: r.get("face_id"),
|
||||
trace_id: r.get("trace_id"),
|
||||
bbox: BBox {
|
||||
x: r.get("x"),
|
||||
y: r.get("y"),
|
||||
width: r.get("width"),
|
||||
height: r.get("height"),
|
||||
},
|
||||
confidence: r.get("confidence"),
|
||||
binding,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let item = FileFaceItem {
|
||||
id: 0,
|
||||
file_uuid: file_uuid.clone(),
|
||||
frame_number: frame,
|
||||
timestamp_secs: Some(frame as f64 / fps),
|
||||
face_id: payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
trace_id: payload["trace_id"].as_i64().map(|t| t as i32),
|
||||
bbox: BBox {
|
||||
x: bbox["x"].as_f64().unwrap_or(0.0),
|
||||
y: bbox["y"].as_f64().unwrap_or(0.0),
|
||||
width: bbox["width"].as_f64().unwrap_or(0.0),
|
||||
height: bbox["height"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
confidence,
|
||||
binding: FaceBinding::Unbound,
|
||||
};
|
||||
data.push(item);
|
||||
}
|
||||
|
||||
Ok(Json(FileFacesResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
page: page as usize,
|
||||
page_size: page_size as usize,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- List Face Candidates ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityChunksResponse {
|
||||
pub success: bool,
|
||||
@@ -1305,76 +1277,62 @@ async fn set_profile_from_face(
|
||||
Json(req): Json<SetProfileFromFaceRequest>,
|
||||
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
use crate::core::db::schema;
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
let videos_table = schema::table_name("videos");
|
||||
|
||||
let uuid_clean = identity_uuid.replace('-', "");
|
||||
|
||||
let (face_identifier, use_trace, use_frame) = match (&req.face_id, req.id, req.trace_id) {
|
||||
(Some(fid), _, _) => (fid.clone(), false, None),
|
||||
(None, Some(id), _) => (id.to_string(), false, None),
|
||||
(None, None, Some(trace_id)) => (trace_id.to_string(), true, req.frame_number),
|
||||
(Some(fid), _, _) => (fid.clone(), None, None),
|
||||
(None, Some(id), _) => (id.to_string(), None, None),
|
||||
(None, None, Some(trace_id)) => (trace_id.to_string(), Some(trace_id), req.frame_number),
|
||||
(None, None, None) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": "Either face_id, id, or trace_id is required"})),
|
||||
Json(
|
||||
serde_json::json!({"success": false, "message": "Either face_id, id, or trace_id is required"}),
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let row: Option<(i64, i32, i32, i32, i32, f64)> = if use_trace {
|
||||
// Get face data from Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let row: Option<(i64, i32, i32, i32, i32, f64)> = if let Some(trace_id) = use_trace {
|
||||
let mut filter_conds = vec![
|
||||
json!({"key": "file_uuid", "match": {"value": req.file_uuid}}),
|
||||
json!({"key": "trace_id", "match": {"value": trace_id}})
|
||||
];
|
||||
if let Some(frame) = use_frame {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND frame_number = $3 LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(use_trace)
|
||||
.bind(frame as i32)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND trace_id = $2 ORDER BY confidence DESC LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(use_trace)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
filter_conds.push(json!({"key": "frame", "match": {"value": frame}}));
|
||||
}
|
||||
let face_filter = json!({"must": filter_conds});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 10).await.unwrap_or_default();
|
||||
points.first().map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let bbox = &payload["bbox"];
|
||||
(
|
||||
payload["frame"].as_i64().unwrap_or(0),
|
||||
bbox["x"].as_f64().unwrap_or(0.0) as i32,
|
||||
bbox["y"].as_f64().unwrap_or(0.0) as i32,
|
||||
bbox["width"].as_f64().unwrap_or(0.0) as i32,
|
||||
bbox["height"].as_f64().unwrap_or(0.0) as i32,
|
||||
payload["confidence"].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
})
|
||||
} else if req.id.is_some() {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
// id lookup not supported in Qdrant - skip
|
||||
None
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
// face_id lookup not supported in Qdrant - skip
|
||||
None
|
||||
};
|
||||
|
||||
let (frame_number, x, y, width, height, confidence) = row.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"success": false, "message": "Face not found"})),
|
||||
)
|
||||
})?;
|
||||
let (frame_number, x, y, w, h, confidence) = row.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"success": false, "message": "Face not found"})),
|
||||
))?;
|
||||
|
||||
let video_row: Option<(String, Option<i32>, Option<i32>)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path, width, height FROM {} WHERE file_uuid = $1",
|
||||
@@ -1400,7 +1358,7 @@ async fn set_profile_from_face(
|
||||
let vw = video_width.unwrap_or(1920);
|
||||
let vh = video_height.unwrap_or(1080);
|
||||
|
||||
crate::core::thumbnail::validator::validate_crop(x, y, width, height, vw, vh).map_err(|e| {
|
||||
crate::core::thumbnail::validator::validate_crop(x, y, w, h, vw, vh).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": format!("Crop validation failed: {}", e)})),
|
||||
@@ -1408,7 +1366,7 @@ async fn set_profile_from_face(
|
||||
})?;
|
||||
|
||||
let select = format!("select=eq(n\\,{})", frame_number);
|
||||
let vf = format!("{},crop={}:{}:{}:{}", select, width, height, x, y);
|
||||
let vf = format!("{},crop={}:{}:{}:{}", select, w, h, x, y);
|
||||
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
@@ -1465,7 +1423,10 @@ async fn set_profile_from_face(
|
||||
success: true,
|
||||
identity_uuid: uuid_clean,
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
message: format!("Profile image set from face {} (frame {}, confidence {:.2})", face_identifier, frame_number, confidence),
|
||||
message: format!(
|
||||
"Profile image set from face {} (frame {}, confidence {:.2})",
|
||||
face_identifier, frame_number, confidence
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1567,21 +1528,20 @@ async fn search_identity_text(
|
||||
) -> Result<Json<IdentityTextResponse>, StatusCode> {
|
||||
use crate::core::db::schema;
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let id_table = schema::table_name("identities");
|
||||
let ib_table = schema::table_name("identity_bindings");
|
||||
let like_q = format!("%{}%", params.q.replace('%', "%%"));
|
||||
let limit = params.limit.unwrap_or(50).min(100);
|
||||
|
||||
let sd_table = schema::table_name("speaker_detections");
|
||||
let query = format!(
|
||||
r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content,
|
||||
fd.identity_id, i.name AS identity_name, i.source AS identity_source,
|
||||
fd.trace_id
|
||||
i.id AS identity_id, i.name AS identity_name, i.source AS identity_source,
|
||||
(c.metadata->>'trace_id')::int AS trace_id
|
||||
FROM {} c
|
||||
LEFT 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
|
||||
LEFT JOIN {} i ON i.id = fd.identity_id
|
||||
LEFT JOIN {} ib ON ib.identity_value = c.metadata->>'trace_id'
|
||||
AND ib.identity_type = 'trace'
|
||||
LEFT 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))
|
||||
|
||||
UNION ALL
|
||||
@@ -1597,7 +1557,7 @@ async fn search_identity_text(
|
||||
|
||||
ORDER BY 3
|
||||
LIMIT $3"#,
|
||||
chunk_table, fd_table, id_table, sd_table, id_table, chunk_table
|
||||
chunk_table, ib_table, id_table, sd_table, id_table, chunk_table
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<
|
||||
@@ -1696,7 +1656,6 @@ async fn search_identities_by_text(
|
||||
) -> Result<Json<IdentitySearchResponse>, StatusCode> {
|
||||
use crate::core::db::schema;
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let like_q = format!("%{}%", params.q.replace('%', "%%"));
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
@@ -1710,26 +1669,26 @@ async fn search_identities_by_text(
|
||||
|
||||
let sd_table = schema::table_name("speaker_detections");
|
||||
let ib_table = schema::table_name("identity_bindings");
|
||||
let fi_table = schema::table_name("file_identities");
|
||||
let query = format!(
|
||||
r#"WITH matched AS (
|
||||
SELECT i.id::int, i.name, i.source, i.tmdb_id,
|
||||
fd.file_uuid, fd.trace_id,
|
||||
c.file_uuid, (c.metadata->>'trace_id')::int AS trace_id,
|
||||
c.chunk_id, c.start_frame, c.end_frame, c.fps,
|
||||
c.start_time, c.end_time, c.text_content
|
||||
FROM {} i
|
||||
JOIN {} fi ON fi.identity_id = i.id
|
||||
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace'
|
||||
JOIN {} fd ON fd.trace_id = ib.identity_value::int
|
||||
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 {} 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)
|
||||
|
||||
UNION ALL
|
||||
UNION ALL
|
||||
|
||||
SELECT i.id::int, i.name, i.source, i.tmdb_id,
|
||||
sd.file_uuid, NULL::int AS trace_id,
|
||||
@@ -1755,7 +1714,7 @@ SELECT *, COUNT(*) OVER() AS total_count
|
||||
FROM deduped
|
||||
ORDER BY name, start_time
|
||||
LIMIT $3 OFFSET $4"#,
|
||||
id_table, ib_table, fd_table, chunk_table, id_table, sd_table, chunk_table
|
||||
id_table, fi_table, ib_table, chunk_table, id_table, sd_table, chunk_table
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&query)
|
||||
@@ -2093,7 +2052,6 @@ async fn undo_identity(
|
||||
|
||||
let table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
let face_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
// Try normal identity lookup
|
||||
let identity_row: Option<(i32,)> = sqlx::query_as(&format!(
|
||||
@@ -2174,22 +2132,23 @@ async fn undo_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Re-bind faces
|
||||
// Re-bind faces via Qdrant _faces
|
||||
if let Some(faces) = snapshot.get("unbound_faces").and_then(|v| v.as_array()) {
|
||||
let qdrant = QdrantDb::new();
|
||||
for face in faces {
|
||||
let file_uuid = face.get("file_uuid").and_then(|v| v.as_str());
|
||||
let face_id = face.get("face_id").and_then(|v| v.as_str());
|
||||
let trace_id = face.get("trace_id").and_then(|v| v.as_i64());
|
||||
if let (Some(fu), Some(fid)) = (file_uuid, face_id) {
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
||||
face_table
|
||||
))
|
||||
.bind(new_id)
|
||||
.bind(fu)
|
||||
.bind(fid)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
if let (Some(fu), Some(tid)) = (file_uuid, trace_id) {
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": fu}},
|
||||
{"key": "trace_id", "match": {"value": tid}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": new_id});
|
||||
let _ = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2377,7 +2336,6 @@ async fn redo_identity(
|
||||
|
||||
let table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
let face_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
// Get identity_id
|
||||
let identity_id: i32 = sqlx::query_scalar(&format!(
|
||||
@@ -2417,14 +2375,17 @@ async fn redo_identity(
|
||||
// ── Delete redo: re-delete the identity ──
|
||||
let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean);
|
||||
|
||||
// Unbind all faces
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE identity_id = $1",
|
||||
face_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
// Unbind all faces in Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": serde_json::Value::Null});
|
||||
let _ = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await;
|
||||
|
||||
// Delete identity
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", table))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,11 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use crate::core::db::{schema, PostgresDb};
|
||||
|
||||
/// Shared video query params: mode=normal|debug, audio=on|off
|
||||
@@ -217,15 +219,32 @@ async fn bbox_overlay_video(
|
||||
|
||||
let start_sec = start_f as f64 / fps;
|
||||
|
||||
// Get face bboxes
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let face_table = schema::table_name("face_detections");
|
||||
let rows: Vec<(i64, i32, i32, i32, i32, Option<i32>, Option<String>)> = sqlx::query_as(
|
||||
&format!("SELECT frame_number, x, y, width, height, trace_id, face_id FROM {} WHERE file_uuid = $1 AND frame_number BETWEEN $2 AND $3 ORDER BY frame_number", face_table)
|
||||
)
|
||||
.bind(face_fuid).bind(start_f).bind(end_f)
|
||||
.fetch_all(state.db.pool()).await
|
||||
.unwrap_or_else(|e| { tracing::error!("bbox query error: {}", e); vec![] });
|
||||
// Get face bboxes 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": face_fuid}},
|
||||
{"key": "frame", "range": {"gte": start_f, "lte": end_f}},
|
||||
{"key": "trace_id", "match": {"value": 1}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 500).await.unwrap_or_default();
|
||||
|
||||
let rows: Vec<(i64, i32, i32, i32, i32, Option<i32>, Option<String>)> = points.iter().filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let frame = payload["frame"].as_i64()?;
|
||||
let bbox = &payload["bbox"];
|
||||
let x = bbox["x"].as_f64()? as i32;
|
||||
let y = bbox["y"].as_f64()? as i32;
|
||||
let w = bbox["width"].as_f64()? as i32;
|
||||
let h = bbox["height"].as_f64()? as i32;
|
||||
let trace_id = payload["trace_id"].as_i64().map(|t| t as i32);
|
||||
let face_id = payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
Some((frame, x, y, w, h, trace_id, face_id))
|
||||
}).collect();
|
||||
|
||||
// Build filters — each bbox enabled only on its frame
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
@@ -334,16 +353,26 @@ async fn trace_video_inner(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (video_path, fps, _width, _height) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Query face detections to find frame range for target trace
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let face_table = schema::table_name("face_detections");
|
||||
let rows: Vec<(i64, i32, i32, i32, i32)> = sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height FROM {} WHERE file_uuid = $1 AND trace_id = $2 ORDER BY frame_number",
|
||||
face_table
|
||||
))
|
||||
.bind(&file_uuid).bind(trace_id)
|
||||
.fetch_all(state.db.pool()).await
|
||||
.unwrap_or_else(|e| { tracing::error!("trace query error: {}", e); vec![] });
|
||||
// Query face detections from Qdrant to find frame range for target trace
|
||||
let qdrant = QdrantDb::new();
|
||||
let trace_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": trace_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", trace_filter, 500).await.unwrap_or_default();
|
||||
|
||||
let rows: Vec<(i64, i32, i32, i32, i32)> = points.iter().filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let frame = payload["frame"].as_i64()?;
|
||||
let bbox = &payload["bbox"];
|
||||
let x = bbox["x"].as_f64()? as i32;
|
||||
let y = bbox["y"].as_f64()? as i32;
|
||||
let w = bbox["width"].as_f64()? as i32;
|
||||
let h = bbox["height"].as_f64()? as i32;
|
||||
Some((frame, x, y, w, h))
|
||||
}).collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
@@ -393,22 +422,50 @@ async fn trace_video_inner(
|
||||
let end_fn = ((start_sec + duration) * fps) as i64;
|
||||
|
||||
// Query all traces with identity names and bbox positions in the visible frame range
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let identities_table = schema::table_name("identities");
|
||||
let all_rows: Vec<(i32, i64, i32, i32, i32, i32, Option<String>)> = sqlx::query_as(&format!(
|
||||
"SELECT fd.trace_id, fd.frame_number, fd.x, fd.y, fd.width, fd.height, i.name \
|
||||
FROM {} fd \
|
||||
LEFT JOIN {} i ON fd.identity_id = i.id \
|
||||
WHERE fd.file_uuid = $1 AND fd.frame_number BETWEEN $2 AND $3 AND fd.trace_id IS NOT NULL \
|
||||
ORDER BY fd.trace_id, fd.frame_number",
|
||||
face_table, identities_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(start_fn)
|
||||
.bind(end_fn)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let all_points = qdrant.scroll_all_points("_faces", json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "frame", "range": {"gte": start_fn, "lte": end_fn}},
|
||||
{"key": "trace_id", "match": {"value": 1}}
|
||||
]
|
||||
}), 1000).await.unwrap_or_default();
|
||||
|
||||
// Get identity names for traces that have identity_id
|
||||
let mut identity_names: HashMap<i32, String> = HashMap::new();
|
||||
for point in &all_points {
|
||||
let payload = &point["payload"];
|
||||
if let Some(iid) = payload["identity_id"].as_i64() {
|
||||
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
|
||||
if iid > 0 && !identity_names.contains_key(&trace_id) {
|
||||
if let Some(name) = sqlx::query_scalar::<_, String>(&format!(
|
||||
"SELECT name FROM {} WHERE id = $1",
|
||||
identities_table
|
||||
))
|
||||
.bind(iid as i32)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
identity_names.insert(trace_id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_rows: Vec<(i32, i64, i32, i32, i32, i32, Option<String>)> = all_points.iter().filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let trace_id = payload["trace_id"].as_i64()? as i32;
|
||||
let frame = payload["frame"].as_i64()?;
|
||||
let bbox = &payload["bbox"];
|
||||
let x = bbox["x"].as_f64()? as i32;
|
||||
let y = bbox["y"].as_f64()? as i32;
|
||||
let w = bbox["width"].as_f64()? as i32;
|
||||
let h = bbox["height"].as_f64()? as i32;
|
||||
let name = identity_names.get(&trace_id).cloned();
|
||||
Some((trace_id, frame, x, y, w, h, name))
|
||||
}).collect();
|
||||
|
||||
// Group frames by trace_id, compute start_frame per trace; collect bbox per frame
|
||||
// frame_number is i64 (BIGINT), so HashMaps need i64 for frame values
|
||||
@@ -1082,21 +1139,31 @@ async fn stranger_video_inner(
|
||||
fps
|
||||
);
|
||||
|
||||
// Query face detections by stranger_id directly
|
||||
let face_table = schema::table_name("face_detections");
|
||||
tracing::debug!("[stranger_video] face_table: {}", face_table);
|
||||
// Query face detections by stranger_id from Qdrant _faces
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let rows: Vec<(i64, i32, i32, i32, i32)> = sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height FROM {} WHERE file_uuid = $1 AND stranger_id = $2 ORDER BY frame_number",
|
||||
face_table
|
||||
))
|
||||
.bind(&file_uuid).bind(stranger_id)
|
||||
.fetch_all(state.db.pool()).await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("[stranger_video] Face query error: {}", e);
|
||||
vec![]
|
||||
let qdrant = QdrantDb::new();
|
||||
let face_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "stranger_id", "match": {"value": stranger_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default();
|
||||
|
||||
let rows: Vec<(i64, i32, i32, i32, i32)> = points.iter()
|
||||
.filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let frame = payload["frame"].as_i64()?;
|
||||
let bbox = &payload["bbox"];
|
||||
let x = bbox["x"].as_f64()? as i32;
|
||||
let y = bbox["y"].as_f64()? as i32;
|
||||
let w = bbox["width"].as_f64()? as i32;
|
||||
let h = bbox["height"].as_f64()? as i32;
|
||||
Some((frame, x, y, w, h))
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!("[stranger_video] Found {} faces", rows.len());
|
||||
|
||||
|
||||
@@ -305,14 +305,21 @@ async fn trigger_processing(
|
||||
tracing::error!("[TRIGGER] Failed to update monitor job for {}: {}", file_uuid, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
|
||||
// Update videos.processing_status to PROCESSING immediately
|
||||
let processor_names_upper: Vec<String> = processors_to_run.iter().map(|p| p.to_uppercase()).collect();
|
||||
let progress: serde_json::Map<String, serde_json::Value> = processors_to_run.iter().map(|p| {
|
||||
(p.to_uppercase(), serde_json::json!({
|
||||
"current_frame": 0, "total_frames": 0, "percentage": 0, "status": "pending"
|
||||
}))
|
||||
}).collect();
|
||||
let processor_names_upper: Vec<String> =
|
||||
processors_to_run.iter().map(|p| p.to_uppercase()).collect();
|
||||
let progress: serde_json::Map<String, serde_json::Value> = processors_to_run
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p.to_uppercase(),
|
||||
serde_json::json!({
|
||||
"current_frame": 0, "total_frames": 0, "percentage": 0, "status": "pending"
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let status = serde_json::json!({
|
||||
"phase": "PROCESSING",
|
||||
"active_processors": processor_names_upper,
|
||||
@@ -320,7 +327,7 @@ async fn trigger_processing(
|
||||
"progress": progress
|
||||
});
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {videos_table} SET status = 'queued', processing_status = $1, updated_at = CURRENT_TIMESTAMP WHERE file_uuid = $2"
|
||||
"UPDATE {videos_table} SET status = 'processing', processing_status = $1, updated_at = CURRENT_TIMESTAMP WHERE file_uuid = $2"
|
||||
))
|
||||
.bind(&status)
|
||||
.bind(&file_uuid)
|
||||
@@ -396,7 +403,7 @@ async fn get_chunk_by_path(
|
||||
row.map(Json).ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
async fn get_progress(file_uuid: Path<String>) -> Result<Json<ProgressResponse>, StatusCode> {
|
||||
async fn get_progress(file_uuid: Path<String>) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let file_uuid = file_uuid.0;
|
||||
let redis = RedisClient::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut conn = redis
|
||||
@@ -459,6 +466,24 @@ async fn get_progress(file_uuid: Path<String>) -> Result<Json<ProgressResponse>,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fetch TKG and Agent progress from Redis
|
||||
let tkg_key = format!("{}progress:{}:tkg", REDIS_KEY_PREFIX.as_str(), file_uuid);
|
||||
let agent_key = format!("{}progress:{}:agent", REDIS_KEY_PREFIX.as_str(), file_uuid);
|
||||
|
||||
let tkg_progress: Option<serde_json::Value> = if let Ok(mut c) = redis.get_conn().await {
|
||||
let val: Option<String> = redis::cmd("GET").arg(&tkg_key).query_async(&mut c).await.ok();
|
||||
val.and_then(|s| serde_json::from_str(&s).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let agent_progress: Option<serde_json::Value> = if let Ok(mut c) = redis.get_conn().await {
|
||||
let val: Option<String> = redis::cmd("GET").arg(&agent_key).query_async(&mut c).await.ok();
|
||||
val.and_then(|s| serde_json::from_str(&s).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let overall = if processors.is_empty() {
|
||||
0
|
||||
} else {
|
||||
@@ -466,20 +491,20 @@ async fn get_progress(file_uuid: Path<String>) -> Result<Json<ProgressResponse>,
|
||||
(sum / processors.len() as u64) as u32
|
||||
};
|
||||
|
||||
Ok(Json(ProgressResponse {
|
||||
file_uuid,
|
||||
user: None,
|
||||
group: None,
|
||||
file_name: video.as_ref().map(|v| v.file_name.clone()),
|
||||
duration: video.as_ref().map(|v| v.duration),
|
||||
overall_progress: overall,
|
||||
cpu_percent: cpu,
|
||||
gpu_percent: gpu,
|
||||
memory_percent: mem_pct,
|
||||
memory_mb: mem_mb,
|
||||
system: Some(sys),
|
||||
processors,
|
||||
}))
|
||||
Ok(Json(serde_json::json!({
|
||||
"file_uuid": file_uuid,
|
||||
"file_name": video.as_ref().map(|v| &v.file_name),
|
||||
"duration": video.as_ref().map(|v| v.duration),
|
||||
"overall_progress": overall,
|
||||
"cpu_percent": cpu,
|
||||
"gpu_percent": gpu,
|
||||
"memory_percent": mem_pct,
|
||||
"memory_mb": mem_mb,
|
||||
"system": sys,
|
||||
"processors": processors,
|
||||
"tkg_progress": tkg_progress,
|
||||
"agent_progress": agent_progress,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn list_jobs(Json(params): Json<JobsQuery>) -> Result<Json<JobListResponse>, StatusCode> {
|
||||
@@ -575,7 +600,7 @@ async fn get_job(Path(uuid): Path<String>) -> Result<Json<JobDetailResponse>, St
|
||||
started_at,
|
||||
updated_at,
|
||||
) = job.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
|
||||
// Calculate queue position (pending or queued jobs ahead of this one)
|
||||
let queue_position = if status == "pending" || status == "queued" {
|
||||
sqlx::query_scalar::<_, i64>(&format!(
|
||||
@@ -714,7 +739,7 @@ async fn get_processor_counts(
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(&json_path) {
|
||||
if let Ok(content) = std::fs::read_to_string(&json_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
// CUT: prioritize scenes count over frame_count
|
||||
if proc_name == "cut" {
|
||||
@@ -737,27 +762,27 @@ if let Ok(content) = std::fs::read_to_string(&json_path) {
|
||||
.map(|v| v as u32);
|
||||
}
|
||||
|
||||
segment_count = json
|
||||
.get("segments")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
chunk_count = json
|
||||
.get("child_chunks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32)
|
||||
.or_else(|| {
|
||||
json.get("parent_chunks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32)
|
||||
});
|
||||
if chunk_count.is_none() {
|
||||
chunk_count = json
|
||||
.get("chunks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
segment_count = json
|
||||
.get("segments")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
chunk_count = json
|
||||
.get("child_chunks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32)
|
||||
.or_else(|| {
|
||||
json.get("parent_chunks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32)
|
||||
});
|
||||
if chunk_count.is_none() {
|
||||
chunk_count = json
|
||||
.get("chunks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push(ProcessorCountInfo {
|
||||
|
||||
469
src/api/scan.rs
469
src/api/scan.rs
@@ -10,6 +10,83 @@ use serde::{Deserialize, Serialize};
|
||||
use super::types::AppState;
|
||||
use crate::core::db::schema;
|
||||
|
||||
/// Comprehensive file stats endpoint — provides all data sources for frontend transparency
|
||||
/// Combines: JSON file status + PostgreSQL counts + Qdrant collections + TKG stats + Identity Agent stats
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileStatsResponse {
|
||||
file_uuid: String,
|
||||
file_name: Option<String>,
|
||||
status: Option<String>,
|
||||
// Processor status
|
||||
processors: Vec<ProcessorStatus>,
|
||||
// PostgreSQL counts
|
||||
postgres: PostgresStats,
|
||||
// Qdrant collection counts
|
||||
qdrant: QdrantStats,
|
||||
// TKG stats
|
||||
tkg: TkgFileStats,
|
||||
// Identity Agent stats
|
||||
identity_agent: IdentityAgentStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProcessorStatus {
|
||||
name: String,
|
||||
status: String,
|
||||
progress: u32,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct PostgresStats {
|
||||
sentence_chunks: i64,
|
||||
trace_chunks: i64,
|
||||
relationship_chunks: i64,
|
||||
identities: i64,
|
||||
file_identities: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct QdrantStats {
|
||||
faces: i64,
|
||||
face_traces: i64,
|
||||
face_identities: i64,
|
||||
text_chunks: i64,
|
||||
speakers: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct TkgFileStats {
|
||||
total_nodes: i64,
|
||||
total_edges: i64,
|
||||
face_track_nodes: i64,
|
||||
gaze_track_nodes: i64,
|
||||
lip_track_nodes: i64,
|
||||
text_region_nodes: i64,
|
||||
appearance_nodes: i64,
|
||||
accessory_nodes: i64,
|
||||
object_nodes: i64,
|
||||
hand_nodes: i64,
|
||||
speaker_nodes: i64,
|
||||
co_occurrence_edges: i64,
|
||||
speaker_face_edges: i64,
|
||||
face_face_edges: i64,
|
||||
mutual_gaze_edges: i64,
|
||||
lip_sync_edges: i64,
|
||||
has_appearance_edges: i64,
|
||||
wears_edges: i64,
|
||||
hand_object_edges: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct IdentityAgentStats {
|
||||
clusters: i64,
|
||||
identities_created: i64,
|
||||
tmdb_matches: i64,
|
||||
speaker_bindings: i64,
|
||||
confirmations: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ScannedFileInfo {
|
||||
file_name: String,
|
||||
@@ -372,9 +449,46 @@ async fn get_ingestion_status(
|
||||
) -> Result<Json<IngestionStatusResponse>, StatusCode> {
|
||||
let pool = state.db.pool();
|
||||
let chunk = schema::table_name("chunk");
|
||||
let fd = schema::table_name("face_detections");
|
||||
let identities = schema::table_name("identities");
|
||||
|
||||
// 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}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default();
|
||||
|
||||
let face_total = points.len() as i64;
|
||||
let mut trace_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||
let mut identity_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||
let mut stranger_traces: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||
|
||||
for point in &points {
|
||||
let payload = &point["payload"];
|
||||
if let Some(tid) = payload["trace_id"].as_i64() {
|
||||
if tid > 0 {
|
||||
trace_ids.insert(tid);
|
||||
if payload["identity_id"].is_null() {
|
||||
stranger_traces.insert(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(iid) = payload["identity_id"].as_i64() {
|
||||
if iid > 0 {
|
||||
identity_ids.insert(iid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trace_count = trace_ids.len() as i64;
|
||||
let identity_count = identity_ids.len() as i64;
|
||||
let strangers = stranger_traces.len() as i64;
|
||||
|
||||
let scene_meta_path = format!(
|
||||
"{}/{}.scene_meta.json",
|
||||
crate::core::config::OUTPUT_DIR.as_str(),
|
||||
@@ -398,14 +512,12 @@ async fn get_ingestion_status(
|
||||
let scene_count = count_sql!(&format!(
|
||||
"SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut'"
|
||||
));
|
||||
let face_total = count_sql!(&format!(
|
||||
"SELECT COUNT(*) FROM {fd} WHERE file_uuid = '{file_uuid}'"
|
||||
));
|
||||
let trace_count = count_sql!(&format!("SELECT COUNT(DISTINCT trace_id) FROM {fd} WHERE file_uuid = '{file_uuid}' AND trace_id IS NOT NULL"));
|
||||
let face_total = face_total;
|
||||
let trace_count = trace_count;
|
||||
let trace_chunks = count_sql!(&format!(
|
||||
"SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'trace'"
|
||||
));
|
||||
let identity_count = count_sql!(&format!("SELECT COUNT(DISTINCT identity_id) FROM {fd} WHERE file_uuid = '{file_uuid}' AND identity_id IS NOT NULL"));
|
||||
let identity_count = identity_count;
|
||||
let tkg_nodes = count_sql!(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'",
|
||||
schema::table_name("tkg_nodes")
|
||||
@@ -414,12 +526,41 @@ async fn get_ingestion_status(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'",
|
||||
schema::table_name("tkg_edges")
|
||||
));
|
||||
let related_identities: Vec<IdentityRef> =
|
||||
|
||||
// Get individual node counts by type
|
||||
let tkg_nodes_table = schema::table_name("tkg_nodes");
|
||||
let face_track_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'face_track'"));
|
||||
let gaze_track_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'gaze_track'"));
|
||||
let lip_track_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'lip_track'"));
|
||||
let text_region_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'text_region'"));
|
||||
let appearance_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'appearance_trace'"));
|
||||
let accessory_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'accessory'"));
|
||||
let object_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'yolo_object'"));
|
||||
let hand_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'hand'"));
|
||||
let speaker_nodes: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_nodes_table} WHERE file_uuid = '{file_uuid}' AND node_type = 'speaker'"));
|
||||
|
||||
// Get individual edge counts by type
|
||||
let tkg_edges_table = schema::table_name("tkg_edges");
|
||||
let co_occurrence_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'CO_OCCURS_WITH'"));
|
||||
let speaker_face_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'SPEAKS_AS'"));
|
||||
let face_face_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'FACE_TO_FACE'"));
|
||||
let mutual_gaze_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'MUTUAL_GAZE'"));
|
||||
let lip_sync_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'LIP_SYNC'"));
|
||||
let has_appearance_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'HAS_APPEARANCE'"));
|
||||
let wears_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'WEARS'"));
|
||||
let hand_object_edges: i64 = count_sql!(&format!("SELECT COUNT(*) FROM {tkg_edges_table} WHERE file_uuid = '{file_uuid}' AND edge_type = 'HAND_OBJECT'"));
|
||||
|
||||
// Rule 2 relationship chunks
|
||||
let rule2_chunks = count_sql!(&format!(
|
||||
"SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'relationship'"
|
||||
));
|
||||
// Get related identities from Qdrant _faces
|
||||
let related_identity_ids: Vec<i64> = identity_ids.into_iter().collect();
|
||||
let related_identities: Vec<IdentityRef> = if !related_identity_ids.is_empty() {
|
||||
let id_list: String = related_identity_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
match sqlx::query_as::<_, (String, String)>(&format!(
|
||||
"SELECT DISTINCT i.uuid::text, i.name FROM {identities} i \
|
||||
JOIN {fd} fd ON fd.identity_id = i.id \
|
||||
WHERE fd.file_uuid = '{file_uuid}' AND fd.identity_id IS NOT NULL \
|
||||
ORDER BY i.name"
|
||||
"SELECT DISTINCT uuid::text, name FROM {identities} \
|
||||
WHERE id IN ({id_list}) ORDER BY name"
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
@@ -435,12 +576,12 @@ async fn get_ingestion_status(
|
||||
tracing::error!("related_identities query failed: {}", e);
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let strangers = count_sql!(&format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {fd} \
|
||||
WHERE file_uuid = '{file_uuid}' AND trace_id IS NOT NULL AND identity_id IS NULL"
|
||||
));
|
||||
let strangers = strangers;
|
||||
|
||||
macro_rules! step {
|
||||
($name:expr, $done:expr, $detail:expr) => {
|
||||
@@ -462,9 +603,9 @@ async fn get_ingestion_status(
|
||||
"auto_vectorize",
|
||||
sentence_embedded > 0,
|
||||
Some(format!("{sentence_embedded} embedded"))
|
||||
),
|
||||
step!(
|
||||
"face_track",
|
||||
),
|
||||
step!(
|
||||
"face_track",
|
||||
trace_count > 0,
|
||||
Some(format!("{trace_count} traces / {face_total} detections"))
|
||||
),
|
||||
@@ -473,11 +614,32 @@ step!(
|
||||
trace_chunks > 0,
|
||||
Some(format!("{trace_chunks} trace chunks"))
|
||||
),
|
||||
// TKG Nodes
|
||||
step!("tkg_face_track", face_track_nodes > 0, Some(format!("{face_track_nodes} nodes"))),
|
||||
step!("tkg_gaze_track", gaze_track_nodes > 0, Some(format!("{gaze_track_nodes} nodes"))),
|
||||
step!("tkg_lip_track", lip_track_nodes > 0, Some(format!("{lip_track_nodes} nodes"))),
|
||||
step!("tkg_text_region", text_region_nodes > 0, Some(format!("{text_region_nodes} nodes"))),
|
||||
step!("tkg_appearance", appearance_nodes > 0, Some(format!("{appearance_nodes} nodes"))),
|
||||
step!("tkg_accessory", accessory_nodes > 0, Some(format!("{accessory_nodes} nodes"))),
|
||||
step!("tkg_object", object_nodes > 0, Some(format!("{object_nodes} nodes"))),
|
||||
step!("tkg_hand", hand_nodes > 0, Some(format!("{hand_nodes} nodes"))),
|
||||
step!("tkg_speaker", speaker_nodes > 0, Some(format!("{speaker_nodes} nodes"))),
|
||||
// TKG Edges
|
||||
step!("tkg_co_occurrence", co_occurrence_edges > 0, Some(format!("{co_occurrence_edges} edges"))),
|
||||
step!("tkg_speaker_face", speaker_face_edges > 0, Some(format!("{speaker_face_edges} edges"))),
|
||||
step!("tkg_face_face", face_face_edges > 0, Some(format!("{face_face_edges} edges"))),
|
||||
step!("tkg_mutual_gaze", mutual_gaze_edges > 0, Some(format!("{mutual_gaze_edges} edges"))),
|
||||
step!("tkg_lip_sync", lip_sync_edges > 0, Some(format!("{lip_sync_edges} edges"))),
|
||||
step!("tkg_has_appearance", has_appearance_edges > 0, Some(format!("{has_appearance_edges} edges"))),
|
||||
step!("tkg_wears", wears_edges > 0, Some(format!("{wears_edges} edges"))),
|
||||
step!("tkg_hand_object", hand_object_edges > 0, Some(format!("{hand_object_edges} edges"))),
|
||||
// Rule 2
|
||||
step!(
|
||||
"tkg",
|
||||
tkg_nodes > 0 || tkg_edges > 0,
|
||||
Some(format!("{tkg_nodes} nodes, {tkg_edges} edges"))
|
||||
"rule2_relationship",
|
||||
rule2_chunks > 0,
|
||||
Some(format!("{rule2_chunks} relationship chunks"))
|
||||
),
|
||||
// Identity & Scene
|
||||
step!(
|
||||
"identity_match",
|
||||
identity_count > 0,
|
||||
@@ -494,6 +656,248 @@ step!(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Comprehensive file stats endpoint — combines all data sources for frontend transparency
|
||||
async fn get_file_stats(
|
||||
State(state): State<AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<FileStatsResponse>, StatusCode> {
|
||||
let pool = state.db.pool();
|
||||
|
||||
// 1. Get file info from PostgreSQL
|
||||
let videos_table = schema::table_name("videos");
|
||||
let file_info: Option<(String, String, String)> = sqlx::query_as(&format!(
|
||||
"SELECT file_uuid, file_name, status FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let (file_uuid_str, file_name, status) = file_info
|
||||
.map(|(uuid, name, s)| (uuid, Some(name), Some(s)))
|
||||
.unwrap_or_else(|| (file_uuid.clone(), None, None));
|
||||
|
||||
// 2. Get processor status from processing_status JSONB
|
||||
let processing_status: serde_json::Value =
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT processing_status FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
|
||||
let processors: Vec<ProcessorStatus> = processing_status
|
||||
.get("progress")
|
||||
.and_then(|p| p.as_object())
|
||||
.map(|progress| {
|
||||
progress
|
||||
.iter()
|
||||
.filter_map(|(name, info)| {
|
||||
info.as_object().map(|obj| {
|
||||
let status = obj
|
||||
.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("pending")
|
||||
.to_string();
|
||||
let progress_val = obj
|
||||
.get("percentage")
|
||||
.and_then(|p| p.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let message = obj
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string());
|
||||
ProcessorStatus {
|
||||
name: name.clone(),
|
||||
status,
|
||||
progress: progress_val,
|
||||
message,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 3. Get PostgreSQL counts
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let identities_table = schema::table_name("identities");
|
||||
let file_identities_table = schema::table_name("file_identities");
|
||||
|
||||
let postgres = PostgresStats {
|
||||
sentence_chunks: sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {chunk_table} WHERE file_uuid = $1 AND chunk_type = 'sentence'"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0),
|
||||
trace_chunks: sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {chunk_table} WHERE file_uuid = $1 AND chunk_type = 'trace'"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0),
|
||||
relationship_chunks: sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {chunk_table} WHERE file_uuid = $1 AND chunk_type = 'relationship'"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0),
|
||||
identities: sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(DISTINCT i.id) FROM {identities_table} i \
|
||||
JOIN {file_identities_table} fi ON fi.identity_id = i.id \
|
||||
WHERE fi.file_uuid = $1"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0),
|
||||
file_identities: sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {file_identities_table} WHERE file_uuid = $1"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
// 4. Get Qdrant stats
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant_db = QdrantDb::new();
|
||||
|
||||
// Face stats
|
||||
let face_filter = json!({
|
||||
"must": [{"key": "file_uuid", "match": {"value": file_uuid}}]
|
||||
});
|
||||
let face_points = qdrant_db
|
||||
.scroll_all_points("_faces", face_filter.clone(), 500)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut face_traces = std::collections::HashSet::new();
|
||||
let mut face_identities = std::collections::HashSet::new();
|
||||
for point in &face_points {
|
||||
let payload = &point["payload"];
|
||||
if let Some(tid) = payload["trace_id"].as_i64() {
|
||||
if tid > 0 {
|
||||
face_traces.insert(tid);
|
||||
}
|
||||
}
|
||||
if let Some(iid) = payload["identity_id"].as_i64() {
|
||||
if iid > 0 {
|
||||
face_identities.insert(iid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text chunk stats (rule1 collection)
|
||||
let schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string());
|
||||
let rule1_collection = format!("momentry_{}_rule1_v2", schema);
|
||||
let text_filter = json!({
|
||||
"must": [{"key": "file_uuid", "match": {"value": file_uuid}}]
|
||||
});
|
||||
let text_points = qdrant_db
|
||||
.scroll_all_points(&rule1_collection, text_filter, 500)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Speaker stats
|
||||
let speaker_collection = format!("momentry_{}_speaker", schema);
|
||||
let speaker_filter = json!({
|
||||
"must": [{"key": "file_uuid", "match": {"value": file_uuid}}]
|
||||
});
|
||||
let speaker_points = qdrant_db
|
||||
.scroll_all_points(&speaker_collection, speaker_filter, 500)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let qdrant_stats = QdrantStats {
|
||||
faces: face_points.len() as i64,
|
||||
face_traces: face_traces.len() as i64,
|
||||
face_identities: face_identities.len() as i64,
|
||||
text_chunks: text_points.len() as i64,
|
||||
speakers: speaker_points.len() as i64,
|
||||
};
|
||||
|
||||
// 5. Get TKG stats from PostgreSQL
|
||||
let tkg_nodes_table = schema::table_name("tkg_nodes");
|
||||
let tkg_edges_table = schema::table_name("tkg_edges");
|
||||
|
||||
let tkg = TkgFileStats {
|
||||
face_track_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "face_track").await,
|
||||
gaze_track_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "gaze_track").await,
|
||||
lip_track_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "lip_track").await,
|
||||
text_region_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "text_region").await,
|
||||
appearance_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "appearance_trace").await,
|
||||
accessory_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "accessory").await,
|
||||
object_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "yolo_object").await,
|
||||
hand_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "hand").await,
|
||||
speaker_nodes: count_by_type(pool, &tkg_nodes_table, &file_uuid, "speaker").await,
|
||||
co_occurrence_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "CO_OCCURS_WITH").await,
|
||||
speaker_face_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "SPEAKS_AS").await,
|
||||
face_face_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "FACE_TO_FACE").await,
|
||||
mutual_gaze_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "MUTUAL_GAZE").await,
|
||||
lip_sync_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "LIP_SYNC").await,
|
||||
has_appearance_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "HAS_APPEARANCE").await,
|
||||
wears_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "WEARS").await,
|
||||
hand_object_edges: count_by_type(pool, &tkg_edges_table, &file_uuid, "HAND_OBJECT").await,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 6. Get Identity Agent stats from Qdrant _seeds
|
||||
let seeds_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}}
|
||||
]
|
||||
});
|
||||
let seed_points = qdrant_db
|
||||
.scroll_all_points("_seeds", seeds_filter, 500)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let identity_agent = IdentityAgentStats {
|
||||
clusters: 0, // From face_clustered.json if available
|
||||
identities_created: face_identities.len() as i64,
|
||||
tmdb_matches: seed_points.iter()
|
||||
.filter(|p| p["payload"]["source"].as_str() == Some("tmdb"))
|
||||
.count() as i64,
|
||||
speaker_bindings: speaker_points.len() as i64,
|
||||
confirmations: 0, // From identity_bindings table
|
||||
};
|
||||
|
||||
Ok(Json(FileStatsResponse {
|
||||
file_uuid: file_uuid_str,
|
||||
file_name,
|
||||
status,
|
||||
processors,
|
||||
postgres,
|
||||
qdrant: qdrant_stats,
|
||||
tkg,
|
||||
identity_agent,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn count_by_type(pool: &sqlx::PgPool, table: &str, file_uuid: &str, type_val: &str) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND (node_type = $2 OR edge_type = $2)",
|
||||
table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(type_val)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn scan_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/files/scan", get(scan_files))
|
||||
@@ -502,4 +906,25 @@ pub fn scan_routes() -> Router<AppState> {
|
||||
"/api/v1/stats/ingestion-status/:file_uuid",
|
||||
get(get_ingestion_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/stats/file/:file_uuid",
|
||||
get(get_file_stats),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/stats/pipeline/:file_uuid",
|
||||
get(get_pipeline_progress_handler),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get segmented pipeline progress with weighted stages
|
||||
async fn get_pipeline_progress_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<crate::core::progress::PipelineProgress>, StatusCode> {
|
||||
let redis_lock = state.redis_cache.get_client().await;
|
||||
let redis_guard = redis_lock.read().await;
|
||||
let pipeline = crate::core::progress::get_pipeline_progress(&*redis_guard, &file_uuid)
|
||||
.await
|
||||
.unwrap_or_else(|| crate::core::progress::PipelineProgress::new(&file_uuid));
|
||||
Ok(Json(pipeline))
|
||||
}
|
||||
|
||||
@@ -149,7 +149,6 @@ pub async fn smart_search(
|
||||
},
|
||||
)?;
|
||||
|
||||
const KEYWORD_FIXED_SCORE: f64 = 0.5;
|
||||
const IDENTITY_FIXED_SCORE: f64 = 0.85;
|
||||
|
||||
let fetch_limit = limit * 3;
|
||||
@@ -302,23 +301,23 @@ pub async fn smart_search(
|
||||
});
|
||||
}
|
||||
|
||||
// Add keyword results (fixed score 0.5)
|
||||
let keyword_fixed = KEYWORD_FIXED_SCORE;
|
||||
for (file_uuid, chunk_id, _) in keyword_results.iter() {
|
||||
// Add keyword results (score from FTS rank, capped at 1.0)
|
||||
for (file_uuid, chunk_id, actual_score) in keyword_results.iter() {
|
||||
let key = (file_uuid.clone(), chunk_id.clone());
|
||||
let capped = actual_score.min(1.0).max(0.1);
|
||||
merged
|
||||
.entry(key)
|
||||
.and_modify(|e| {
|
||||
e.score = e.score.max(keyword_fixed);
|
||||
e.keyword_score = Some(keyword_fixed);
|
||||
e.score = e.score.max(capped);
|
||||
e.keyword_score = Some(capped);
|
||||
e.source = format!("{}_keyword", e.source);
|
||||
})
|
||||
.or_insert(MergedResult {
|
||||
file_uuid: file_uuid.clone(),
|
||||
chunk_id: chunk_id.clone(),
|
||||
score: keyword_fixed,
|
||||
score: capped,
|
||||
semantic_score: None,
|
||||
keyword_score: Some(keyword_fixed),
|
||||
keyword_score: Some(capped),
|
||||
identity_score: None,
|
||||
source: "keyword".to_string(),
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::checkin_api;
|
||||
use super::docs;
|
||||
use super::files;
|
||||
use super::health;
|
||||
use super::health::{health, health_detailed, health_consistency};
|
||||
use super::health::{health, health_consistency, health_detailed};
|
||||
use super::identities;
|
||||
use super::identity_agent_api;
|
||||
use super::identity_api;
|
||||
@@ -138,8 +138,14 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
|
||||
let public_health_routes = Router::new()
|
||||
.route("/api/v1/health", axum::routing::get(health))
|
||||
.route("/api/v1/health/detailed", axum::routing::get(health_detailed))
|
||||
.route("/api/v1/health/consistency", axum::routing::get(health_consistency));
|
||||
.route(
|
||||
"/api/v1/health/detailed",
|
||||
axum::routing::get(health_detailed),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/health/consistency",
|
||||
axum::routing::get(health_consistency),
|
||||
);
|
||||
|
||||
let app = Router::new()
|
||||
.merge(auth::auth_routes())
|
||||
|
||||
@@ -619,6 +619,7 @@ async fn tmdb_match_handler(
|
||||
file_uuid,
|
||||
bindings_created: 0,
|
||||
tmdb_identities_available: 0,
|
||||
message: "TMDb matching disabled - needs reimplementation with _faces collection".to_string(),
|
||||
message: "TMDb matching disabled - needs reimplementation with _faces collection"
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::core::db::PostgresDb;
|
||||
|
||||
@@ -73,6 +74,7 @@ struct TraceInfo {
|
||||
duration_sec: f64,
|
||||
avg_confidence: f64,
|
||||
sample_face_id: Option<String>,
|
||||
thumbnail_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -118,46 +120,76 @@ async fn list_traces_sorted(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(24.0);
|
||||
|
||||
let query = format!(
|
||||
"SELECT tt.*, fd.id AS sample_face_id FROM (
|
||||
SELECT trace_id::int AS trace_id,
|
||||
COUNT(*) AS face_count,
|
||||
MIN(frame_number)::bigint AS start_frame,
|
||||
MAX(frame_number)::bigint AS end_frame,
|
||||
(MAX(frame_number) - MIN(frame_number))::float8 AS duration_sec,
|
||||
AVG(confidence)::float8 AS avg_confidence
|
||||
FROM {}
|
||||
WHERE file_uuid = $1 AND trace_id IS NOT NULL
|
||||
AND confidence >= $5 AND confidence <= $6
|
||||
GROUP BY trace_id
|
||||
HAVING COUNT(*) >= $2
|
||||
ORDER BY {}
|
||||
LIMIT $3 OFFSET $4
|
||||
) tt
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM {}
|
||||
WHERE trace_id = tt.trace_id AND file_uuid = $1
|
||||
ORDER BY confidence DESC LIMIT 1
|
||||
) fd ON true",
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
order_clause,
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
);
|
||||
// Get face points from Qdrant _faces
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let rows: Vec<(i32, i64, i64, i64, f64, f64, Option<i32>)> = sqlx::query_as(&query)
|
||||
.bind(&file_uuid)
|
||||
.bind(min_faces)
|
||||
.bind(effective_limit)
|
||||
.bind(db_offset)
|
||||
.bind(min_confidence)
|
||||
.bind(max_confidence)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let qdrant = QdrantDb::new();
|
||||
let face_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 2000).await.unwrap_or_default();
|
||||
|
||||
let traces: Vec<TraceInfo> = rows
|
||||
// Aggregate by trace_id
|
||||
struct TraceAgg {
|
||||
face_count: i64,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
avg_confidence: f64,
|
||||
sum_confidence: f64,
|
||||
}
|
||||
|
||||
let mut trace_data: HashMap<i32, TraceAgg> = 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 confidence = payload["confidence"].as_f64().unwrap_or(0.5);
|
||||
|
||||
if confidence < min_confidence || confidence > max_confidence {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = trace_data.entry(trace_id).or_insert(TraceAgg {
|
||||
face_count: 0,
|
||||
start_frame: i64::MAX,
|
||||
end_frame: i64::MIN,
|
||||
avg_confidence: 0.0,
|
||||
sum_confidence: 0.0,
|
||||
});
|
||||
entry.face_count += 1;
|
||||
entry.start_frame = entry.start_frame.min(frame);
|
||||
entry.end_frame = entry.end_frame.max(frame);
|
||||
entry.sum_confidence += confidence;
|
||||
}
|
||||
|
||||
// Filter by min_faces and sort
|
||||
let mut traces_vec: Vec<(i32, i64, i64, i64, f64, f64)> = trace_data.into_iter()
|
||||
.filter(|(_, agg)| agg.face_count >= min_faces)
|
||||
.map(|(tid, agg)| {
|
||||
let duration = (agg.end_frame - agg.start_frame) as f64;
|
||||
let avg_conf = if agg.face_count > 0 { agg.sum_confidence / agg.face_count as f64 } else { 0.0 };
|
||||
(tid, agg.face_count, agg.start_frame, agg.end_frame, duration, avg_conf)
|
||||
})
|
||||
.collect();
|
||||
|
||||
match order_clause {
|
||||
"face_count DESC" => traces_vec.sort_by(|a, b| b.1.cmp(&a.1)),
|
||||
"duration_sec DESC" => traces_vec.sort_by(|a, b| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal)),
|
||||
_ => traces_vec.sort_by(|a, b| a.2.cmp(&b.2)),
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
let total_traces = traces_vec.len() as i64;
|
||||
let total_faces: i64 = points.len() as i64;
|
||||
let traces_vec: Vec<_> = traces_vec.into_iter().skip(db_offset as usize).take(effective_limit as usize).collect();
|
||||
|
||||
let traces: Vec<TraceInfo> = traces_vec
|
||||
.into_iter()
|
||||
.map(|(tid, fc, sf, ef, dur, conf, fid)| TraceInfo {
|
||||
.map(|(tid, fc, sf, ef, dur, conf)| TraceInfo {
|
||||
trace_id: tid,
|
||||
face_count: fc,
|
||||
start_frame: sf,
|
||||
@@ -166,19 +198,11 @@ async fn list_traces_sorted(
|
||||
end_time: ef as f64 / fps,
|
||||
duration_sec: dur / fps,
|
||||
avg_confidence: conf,
|
||||
sample_face_id: fid.map(|v| v.to_string()),
|
||||
sample_face_id: None,
|
||||
thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (total_traces, total_faces): (i64, i64) = sqlx::query_as(
|
||||
&format!("SELECT COUNT(DISTINCT trace_id), COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id IS NOT NULL",
|
||||
crate::core::db::schema::table_name("face_detections"))
|
||||
)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(TracesResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
@@ -260,55 +284,57 @@ async fn list_trace_faces(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(24.0);
|
||||
|
||||
let total_detected: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
|
||||
crate::core::db::schema::table_name("face_detections")
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(trace_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
// Get face points from Qdrant _faces for this trace
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let rows: Vec<(
|
||||
i32,
|
||||
i64,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
f32,
|
||||
)> = sqlx::query_as(&format!(
|
||||
"SELECT id, frame_number, x, y, width, height, confidence::float4 \
|
||||
FROM {} WHERE file_uuid = $1 AND trace_id = $2 \
|
||||
ORDER BY frame_number ASC LIMIT $3 OFFSET $4",
|
||||
crate::core::db::schema::table_name("face_detections")
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(trace_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let qdrant = QdrantDb::new();
|
||||
let trace_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": trace_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", trace_filter, 1000).await.unwrap_or_default();
|
||||
|
||||
let total_detected: i64 = points.len() as i64;
|
||||
|
||||
// Apply pagination
|
||||
let paged: Vec<_> = points.into_iter().skip(offset as usize).take(limit as usize).collect();
|
||||
|
||||
let mut faces: Vec<TraceFaceItem> = Vec::new();
|
||||
|
||||
for (i, (id, frame, x, y, w, h, conf)) in rows.iter().enumerate() {
|
||||
for (i, point) in paged.iter().enumerate() {
|
||||
let payload = &point["payload"];
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
let bbox = &payload["bbox"];
|
||||
let x = bbox["x"].as_f64().unwrap_or(0.0) as i32;
|
||||
let y = bbox["y"].as_f64().unwrap_or(0.0) as i32;
|
||||
let w = bbox["width"].as_f64().unwrap_or(0.0) as i32;
|
||||
let h = bbox["height"].as_f64().unwrap_or(0.0) as i32;
|
||||
let conf = payload["confidence"].as_f64().unwrap_or(0.5) as f32;
|
||||
let id = i as i32;
|
||||
|
||||
let cur = (x, y, w, h);
|
||||
|
||||
// Add interpolated frames between previous and current detection
|
||||
if interpolate && i > 0 {
|
||||
let prev = &rows[i - 1];
|
||||
let prev_frame = prev.1;
|
||||
let prev_point = &paged[i - 1];
|
||||
let prev_payload = &prev_point["payload"];
|
||||
let prev_bbox = &prev_payload["bbox"];
|
||||
let prev_frame = prev_payload["frame"].as_i64().unwrap_or(0);
|
||||
let prev_x = prev_bbox["x"].as_f64().unwrap_or(0.0) as i32;
|
||||
let prev_y = prev_bbox["y"].as_f64().unwrap_or(0.0) as i32;
|
||||
let prev_w = prev_bbox["width"].as_f64().unwrap_or(0.0) as i32;
|
||||
let prev_h = prev_bbox["height"].as_f64().unwrap_or(0.0) as i32;
|
||||
let gap = frame - prev_frame;
|
||||
if gap > 1 {
|
||||
for mid in 1..gap {
|
||||
let t = mid as f64 / gap as f64;
|
||||
let mid_x = lerp_i32(prev.2, *x, t);
|
||||
let mid_y = lerp_i32(prev.3, *y, t);
|
||||
let mid_w = lerp_i32(prev.4, *w, t);
|
||||
let mid_h = lerp_i32(prev.5, *h, t);
|
||||
let mid_x = lerp_i32(Some(prev_x), Some(x), t).unwrap_or(0);
|
||||
let mid_y = lerp_i32(Some(prev_y), Some(y), t).unwrap_or(0);
|
||||
let mid_w = lerp_i32(Some(prev_w), Some(w), t).unwrap_or(0);
|
||||
let mid_h = lerp_i32(Some(prev_h), Some(h), t).unwrap_or(0);
|
||||
let mid_frame = prev_frame + mid;
|
||||
let mt = (mid_frame as f64 / fps * 10.0).round() / 10.0;
|
||||
faces.push(TraceFaceItem {
|
||||
@@ -317,10 +343,10 @@ async fn list_trace_faces(
|
||||
end_frame: mid_frame,
|
||||
start_time: mt,
|
||||
end_time: mt,
|
||||
x: mid_x,
|
||||
y: mid_y,
|
||||
width: mid_w,
|
||||
height: mid_h,
|
||||
x: Some(mid_x),
|
||||
y: Some(mid_y),
|
||||
width: Some(mid_w),
|
||||
height: Some(mid_h),
|
||||
confidence: 0.0,
|
||||
interpolated: true,
|
||||
});
|
||||
@@ -329,19 +355,19 @@ async fn list_trace_faces(
|
||||
}
|
||||
|
||||
// Add the real detection
|
||||
let frame_val = *frame;
|
||||
let frame_val = frame;
|
||||
let ft = (frame_val as f64 / fps * 10.0).round() / 10.0;
|
||||
faces.push(TraceFaceItem {
|
||||
id: *id,
|
||||
id,
|
||||
start_frame: frame_val,
|
||||
end_frame: frame_val,
|
||||
start_time: ft,
|
||||
end_time: ft,
|
||||
x: *x,
|
||||
y: *y,
|
||||
width: *w,
|
||||
height: *h,
|
||||
confidence: *conf as f64,
|
||||
x: Some(x),
|
||||
y: Some(y),
|
||||
width: Some(w),
|
||||
height: Some(h),
|
||||
confidence: conf as f64,
|
||||
interpolated: false,
|
||||
});
|
||||
}
|
||||
@@ -413,7 +439,8 @@ where
|
||||
F: Fn(anyhow::Error) -> T,
|
||||
{
|
||||
use crate::core::db::schema;
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
let video_table = schema::table_name("videos");
|
||||
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
@@ -426,15 +453,16 @@ where
|
||||
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?
|
||||
.unwrap_or(25.0);
|
||||
|
||||
let face_count: (i64,) = sqlx::query_as(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(trace_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
|
||||
// Get face count from Qdrant
|
||||
let qdrant = QdrantDb::new();
|
||||
let trace_filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "trace_id", "match": {"value": trace_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", trace_filter, 1000).await.unwrap_or_default();
|
||||
let face_count: (i64,) = (points.len() as i64,);
|
||||
|
||||
struct Candidate {
|
||||
frame: i64,
|
||||
@@ -446,38 +474,35 @@ where
|
||||
score: f64,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>(&format!(
|
||||
"SELECT frame_number::bigint, x, y, width, height, confidence::float8 \
|
||||
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND confidence > 0.7 \
|
||||
AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true) \
|
||||
ORDER BY (width::float8 * height::float8) * confidence::float8 DESC LIMIT 10",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(trace_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
|
||||
// Get top faces by quality from Qdrant
|
||||
let mut candidates: Vec<Candidate> = points.iter()
|
||||
.filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let bbox = &payload["bbox"];
|
||||
let w = bbox["width"].as_f64()? as i32;
|
||||
let h = bbox["height"].as_f64()? as i32;
|
||||
let conf = payload["confidence"].as_f64()?;
|
||||
if conf <= 0.7 { return None; }
|
||||
let score = (w as f64 * h as f64) * conf;
|
||||
Some(Candidate {
|
||||
frame: payload["frame"].as_i64().unwrap_or(0),
|
||||
x: bbox["x"].as_f64()? as i32,
|
||||
y: bbox["y"].as_f64()? as i32,
|
||||
w,
|
||||
h,
|
||||
conf,
|
||||
score,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let rows: Vec<_> = candidates.into_iter().take(10).collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
return Err(err_fn(anyhow::anyhow!("No suitable face found")));
|
||||
}
|
||||
|
||||
let candidates: Vec<Candidate> = rows
|
||||
.into_iter()
|
||||
.map(|(frame, x, y, w, h, conf)| {
|
||||
let score = (w as f64 * h as f64) * conf;
|
||||
Candidate {
|
||||
frame,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
conf,
|
||||
score,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let candidates: Vec<Candidate> = rows;
|
||||
|
||||
let video_path: String = sqlx::query_scalar(&format!(
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
||||
@@ -759,8 +784,9 @@ async fn get_cooccurrence(
|
||||
Path((file_uuid, identity_uuid_a, identity_uuid_b)): Path<(String, String, String)>,
|
||||
) -> Result<Json<CoOccurResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
use crate::core::db::schema;
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
|
||||
// Stage 1: Get identity names and IDs
|
||||
let id_a = sqlx::query_as::<_, (i32, String)>(&format!(
|
||||
@@ -803,27 +829,33 @@ async fn get_cooccurrence(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Stage 2: Find first frame where both identity_ids appear
|
||||
let cooccur: Option<(i64,)> = sqlx::query_as(&format!(
|
||||
"SELECT MIN(fd.frame_number)::bigint FROM {} fd \
|
||||
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
|
||||
AND fd.frame_number IN ( \
|
||||
SELECT frame_number FROM {} \
|
||||
WHERE file_uuid = $1 AND identity_id = $3 \
|
||||
)",
|
||||
fd_table, fd_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(id_a.0)
|
||||
.bind(id_b.0)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
// Stage 2: Find first frame where both identity_ids appear (from Qdrant _faces)
|
||||
let qdrant = QdrantDb::new();
|
||||
|
||||
// Get frames for identity A
|
||||
let filter_a = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "identity_id", "match": {"value": id_a.0}}
|
||||
]
|
||||
});
|
||||
let points_a = qdrant.scroll_all_points("_faces", filter_a, 1000).await.unwrap_or_default();
|
||||
let frames_a: std::collections::HashSet<i64> = points_a.iter()
|
||||
.filter_map(|p| p["payload"]["frame"].as_i64())
|
||||
.collect();
|
||||
|
||||
// Get frames for identity B and find first co-occurrence
|
||||
let filter_b = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "identity_id", "match": {"value": id_b.0}}
|
||||
]
|
||||
});
|
||||
let points_b = qdrant.scroll_all_points("_faces", filter_b, 1000).await.unwrap_or_default();
|
||||
let cooccur: Option<(i64,)> = points_b.iter()
|
||||
.filter_map(|p| p["payload"]["frame"].as_i64())
|
||||
.find(|f| frames_a.contains(f))
|
||||
.map(|f| (f,));
|
||||
|
||||
let (first_frame,) = cooccur.ok_or_else(|| {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "These two identities never appear together in this file"})))
|
||||
@@ -846,24 +878,16 @@ async fn get_cooccurrence(
|
||||
})?
|
||||
.unwrap_or(25.0);
|
||||
|
||||
// Stage 3: Get trace_ids for both at this frame
|
||||
let trace_a: Option<(i32,)> = sqlx::query_as(
|
||||
&format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table)
|
||||
)
|
||||
.bind(&file_uuid).bind(first_frame).bind(id_a.0)
|
||||
.fetch_optional(state.db.pool()).await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
})?;
|
||||
// Stage 3: Get trace_ids for both at this frame (from Qdrant _faces)
|
||||
let trace_a: Option<(i32,)> = points_a.iter()
|
||||
.find(|p| p["payload"]["frame"].as_i64() == Some(first_frame))
|
||||
.and_then(|p| p["payload"]["trace_id"].as_i64())
|
||||
.map(|t| (t as i32,));
|
||||
|
||||
let trace_b: Option<(i32,)> = sqlx::query_as(
|
||||
&format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table)
|
||||
)
|
||||
.bind(&file_uuid).bind(first_frame).bind(id_b.0)
|
||||
.fetch_optional(state.db.pool()).await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
})?;
|
||||
let trace_b: Option<(i32,)> = points_b.iter()
|
||||
.find(|p| p["payload"]["frame"].as_i64() == Some(first_frame))
|
||||
.and_then(|p| p["payload"]["trace_id"].as_i64())
|
||||
.map(|t| (t as i32,));
|
||||
|
||||
// Stage 4: Get representative faces for both traces (reusing select_rep_face)
|
||||
let rep_a = if let Some((tid,)) = trace_a {
|
||||
@@ -914,22 +938,14 @@ async fn get_cooccurrence(
|
||||
None
|
||||
};
|
||||
|
||||
// Total co-occurrence frames (from TKG if available, otherwise from face_detections)
|
||||
let total_cooccurrence_frames: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(DISTINCT fd.frame_number)::bigint FROM {} fd \
|
||||
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
|
||||
AND fd.frame_number IN ( \
|
||||
SELECT frame_number FROM {} \
|
||||
WHERE file_uuid = $1 AND identity_id = $3 \
|
||||
)",
|
||||
fd_table, fd_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(id_a.0)
|
||||
.bind(id_b.0)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
// Total co-occurrence frames (from Qdrant _faces)
|
||||
let frames_b: std::collections::HashSet<i64> = points_b.iter()
|
||||
.filter_map(|p| p["payload"]["frame"].as_i64())
|
||||
.collect();
|
||||
let total_cooccurrence_frames: i64 = points_a.iter()
|
||||
.filter_map(|p| p["payload"]["frame"].as_i64())
|
||||
.filter(|f| frames_b.contains(f))
|
||||
.count() as i64;
|
||||
|
||||
Ok(Json(CoOccurResponse {
|
||||
success: true,
|
||||
@@ -971,7 +987,8 @@ async fn rebuild_tkg(
|
||||
use crate::core::chunk::rule2_ingest::ingest_rule2;
|
||||
use tracing::info;
|
||||
|
||||
let result = crate::core::processor::tkg::build_tkg(&state.db, &file_uuid, &OUTPUT_DIR).await;
|
||||
let redis = crate::core::db::RedisClient::new().ok();
|
||||
let result = crate::core::processor::tkg::build_tkg(&state.db, &file_uuid, &OUTPUT_DIR, redis.map(Arc::new)).await;
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
@@ -987,7 +1004,7 @@ async fn rebuild_tkg(
|
||||
"[TKG] {} relationship edges found, triggering Rule 2 ingestion...",
|
||||
total_edges
|
||||
);
|
||||
match ingest_rule2(state.db.pool(), &file_uuid).await {
|
||||
match ingest_rule2(state.db.pool(), &file_uuid, None, None).await {
|
||||
Ok(count) => info!("[TKG] Rule 2 created {} relationship chunks", count),
|
||||
Err(e) => info!("[TKG] Rule 2 ingestion failed: {}", e),
|
||||
}
|
||||
@@ -1087,26 +1104,26 @@ async fn get_stranger_representative_face(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, stranger_id)): Path<(String, i32)>,
|
||||
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let faces_table = crate::core::db::schema::table_name("face_detections");
|
||||
// Get trace_id from Qdrant _faces by stranger_id
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let trace_id: i32 = sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND stranger_id = $2 LIMIT 1",
|
||||
faces_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(stranger_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Stranger not found"})),
|
||||
))?;
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "stranger_id", "match": {"value": stranger_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", filter, 1).await.unwrap_or_default();
|
||||
|
||||
let trace_id: i32 = points.first()
|
||||
.and_then(|p| p["payload"]["trace_id"].as_i64())
|
||||
.map(|t| t as i32)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Stranger not found"})),
|
||||
))?;
|
||||
|
||||
get_representative_face_inner(&state, &file_uuid, trace_id).await
|
||||
}
|
||||
@@ -1115,26 +1132,25 @@ async fn get_stranger_thumbnail(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, stranger_id)): Path<(String, i32)>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let faces_table = crate::core::db::schema::table_name("face_detections");
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let trace_id: i32 = sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND stranger_id = $2 LIMIT 1",
|
||||
faces_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(stranger_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Stranger not found"})),
|
||||
))?;
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}},
|
||||
{"key": "stranger_id", "match": {"value": stranger_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", filter, 1).await.unwrap_or_default();
|
||||
|
||||
let trace_id: i32 = points.first()
|
||||
.and_then(|p| p["payload"]["trace_id"].as_i64())
|
||||
.map(|t| t as i32)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Stranger not found"})),
|
||||
))?;
|
||||
|
||||
get_trace_thumbnail_inner(&state, &file_uuid, trace_id).await
|
||||
}
|
||||
@@ -1526,7 +1542,7 @@ async fn ingest_rule2(
|
||||
use crate::core::embedding::Embedder;
|
||||
use tracing::info;
|
||||
|
||||
let result = ingest_rule2(state.db.pool(), &file_uuid).await;
|
||||
let result = ingest_rule2(state.db.pool(), &file_uuid, None, None).await;
|
||||
|
||||
match result {
|
||||
Ok(rule2_chunks) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use crate::core::db::{schema, Database, PostgresDb};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -590,76 +591,162 @@ async fn search_persons_internal(
|
||||
req: &UniversalSearchRequest,
|
||||
) -> Result<Vec<SearchResult>, anyhow::Error> {
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let mut sql = format!(
|
||||
"SELECT i.id, i.uuid::text, i.name, COUNT(fd.id) AS appearance_count, \
|
||||
MIN(fd.timestamp_secs) AS first_time, MAX(fd.timestamp_secs) AS last_time, \
|
||||
fd.file_uuid \
|
||||
FROM {} i JOIN {} fd ON fd.identity_id = i.id WHERE 1=1",
|
||||
id_table, fd_table
|
||||
|
||||
// Query matching identities from PostgreSQL
|
||||
let mut id_sql = format!(
|
||||
"SELECT id, uuid::text, name FROM {} WHERE name IS NOT NULL",
|
||||
id_table
|
||||
);
|
||||
|
||||
if let Some(uuid) = &req.file_uuid {
|
||||
sql.push_str(&format!(
|
||||
" AND fd.file_uuid = '{}'",
|
||||
uuid.replace('\'', "''")
|
||||
));
|
||||
}
|
||||
|
||||
if !req.query.is_empty() {
|
||||
let q = req.query.replace('\'', "''");
|
||||
sql.push_str(&format!(" AND i.name ILIKE '%{}%'", q));
|
||||
id_sql.push_str(&format!(" AND name ILIKE '%{}%'", q));
|
||||
}
|
||||
id_sql.push_str(" ORDER BY name ASC");
|
||||
|
||||
let identities: Vec<(i32, String, Option<String>)> =
|
||||
sqlx::query_as(&id_sql).fetch_all(db.pool()).await?;
|
||||
|
||||
if identities.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
sql.push_str(" GROUP BY i.id, i.uuid, i.name, fd.file_uuid");
|
||||
sql.push_str(" ORDER BY appearance_count DESC");
|
||||
sql.push_str(&format!(" LIMIT {}", req.page_size.unwrap_or(20)));
|
||||
// For each identity, scroll _faces points from Qdrant and aggregate per file
|
||||
let qdrant = QdrantDb::new();
|
||||
let limit = req.page_size.unwrap_or(20);
|
||||
|
||||
let rows: Vec<(
|
||||
i32,
|
||||
String,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
String,
|
||||
)> = sqlx::query_as(&sql).fetch_all(db.pool()).await?;
|
||||
// Aggregate frame ranges per (identity_id, file_uuid)
|
||||
use std::collections::HashMap;
|
||||
let mut agg: HashMap<(i32, String), (i64, i64, i64)> = HashMap::new(); // (id, fu) -> (count, min_frame, max_frame)
|
||||
|
||||
let results: Vec<SearchResult> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
identity_id,
|
||||
identity_uuid,
|
||||
name,
|
||||
appearance_count,
|
||||
first_time,
|
||||
last_time,
|
||||
file_uuid,
|
||||
)| {
|
||||
let score = if !req.query.is_empty()
|
||||
&& name.as_ref().map_or(false, |n| {
|
||||
n.to_lowercase().contains(&req.query.to_lowercase())
|
||||
}) {
|
||||
0.95
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
for (id, _uuid, _name) in &identities {
|
||||
let scroll_filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": id}}
|
||||
]
|
||||
});
|
||||
|
||||
SearchResult::Person {
|
||||
file_uuid: Some(file_uuid),
|
||||
identity_id,
|
||||
identity_uuid,
|
||||
name,
|
||||
appearance_count: appearance_count as i32,
|
||||
score,
|
||||
first_appearance_time: first_time,
|
||||
last_appearance_time: last_time,
|
||||
let points = match qdrant
|
||||
.scroll_all_points("_faces", scroll_filter, 1000)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Qdrant scroll failed for identity {}: {}", id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for point in &points {
|
||||
let payload = &point["payload"];
|
||||
let file_uuid = match payload["file_uuid"].as_str() {
|
||||
Some(f) => f.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Apply file_uuid filter if specified
|
||||
if let Some(ref filter_fu) = req.file_uuid {
|
||||
if &file_uuid != filter_fu {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
let entry = agg
|
||||
.entry((*id, file_uuid))
|
||||
.or_insert((0, i64::MAX, i64::MIN));
|
||||
entry.0 += 1;
|
||||
if frame < entry.1 {
|
||||
entry.1 = frame;
|
||||
}
|
||||
if frame > entry.2 {
|
||||
entry.2 = frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache FPS per file_uuid for frame→second conversion
|
||||
use std::collections::HashSet;
|
||||
let file_uuids: HashSet<&str> = agg.keys().map(|(_, fu)| fu.as_str()).collect();
|
||||
let video_table = crate::core::db::schema::table_name("videos");
|
||||
let mut fps_cache: HashMap<String, f64> = HashMap::new();
|
||||
for fu in file_uuids {
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(fu)
|
||||
.fetch_optional(db.pool())
|
||||
.await?
|
||||
.unwrap_or(30.0);
|
||||
fps_cache.insert(fu.to_string(), fps);
|
||||
}
|
||||
|
||||
// Build results
|
||||
let q_lower = req.query.to_lowercase();
|
||||
let mut results: Vec<SearchResult> = identities
|
||||
.iter()
|
||||
.flat_map(|(id, uuid, name)| {
|
||||
let name_str = name.as_deref().unwrap_or("");
|
||||
let name_match = !req.query.is_empty() && name_str.to_lowercase().contains(&q_lower);
|
||||
let score = if name_match { 0.95 } else { 0.5 };
|
||||
// Yield entries for this identity's files
|
||||
let files: Vec<String> = agg
|
||||
.keys()
|
||||
.filter(|(iid, _)| iid == id)
|
||||
.map(|(_, fu)| fu.clone())
|
||||
.collect();
|
||||
if files.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
files
|
||||
.into_iter()
|
||||
.map(|fu| {
|
||||
let (count, min_fr, max_fr) = agg[&(*id, fu.clone())];
|
||||
let fps = fps_cache.get(&fu).copied().unwrap_or(30.0);
|
||||
let first = if min_fr == i64::MAX {
|
||||
None
|
||||
} else {
|
||||
Some(min_fr as f64 / fps)
|
||||
};
|
||||
let last = if max_fr == i64::MIN {
|
||||
None
|
||||
} else {
|
||||
Some(max_fr as f64 / fps)
|
||||
};
|
||||
SearchResult::Person {
|
||||
file_uuid: Some(fu),
|
||||
identity_id: *id,
|
||||
identity_uuid: uuid.clone(),
|
||||
name: name.clone(),
|
||||
appearance_count: count as i32,
|
||||
score,
|
||||
first_appearance_time: first,
|
||||
last_appearance_time: last,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by appearance_count descending, then limit
|
||||
results.sort_by(|a, b| {
|
||||
let a_count = match a {
|
||||
SearchResult::Person {
|
||||
appearance_count, ..
|
||||
} => *appearance_count,
|
||||
_ => 0,
|
||||
};
|
||||
let b_count = match b {
|
||||
SearchResult::Person {
|
||||
appearance_count, ..
|
||||
} => *appearance_count,
|
||||
_ => 0,
|
||||
};
|
||||
b_count.cmp(&a_count)
|
||||
});
|
||||
results.truncate(limit);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -752,49 +839,105 @@ async fn search_persons_by_query(
|
||||
limit: usize,
|
||||
) -> Result<Vec<PersonResult>, anyhow::Error> {
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let mut sql = format!(
|
||||
"SELECT i.id, i.uuid::text, i.name, COUNT(fd.id) AS appearance_count, \
|
||||
MIN(fd.timestamp_secs) AS first_time, MAX(fd.timestamp_secs) AS last_time \
|
||||
FROM {} i JOIN {} fd ON fd.identity_id = i.id \
|
||||
WHERE fd.file_uuid = '{}'",
|
||||
id_table,
|
||||
fd_table,
|
||||
file_uuid.replace('\'', "''")
|
||||
);
|
||||
|
||||
// Query matching identities from PostgreSQL
|
||||
let mut id_sql = format!(
|
||||
"SELECT id, uuid::text, name FROM {} WHERE name IS NOT NULL",
|
||||
id_table
|
||||
);
|
||||
if let Some(q) = query {
|
||||
let safe = q.replace('\'', "''");
|
||||
sql.push_str(&format!(" AND i.name ILIKE '%{}%'", safe));
|
||||
id_sql.push_str(&format!(" AND name ILIKE '%{}%'", safe));
|
||||
}
|
||||
id_sql.push_str(" ORDER BY name ASC");
|
||||
|
||||
let identities: Vec<(i32, String, Option<String>)> =
|
||||
sqlx::query_as(&id_sql).fetch_all(db.pool()).await?;
|
||||
|
||||
if identities.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
sql.push_str(" GROUP BY i.id, i.uuid, i.name");
|
||||
// For each identity, scroll _faces points from Qdrant and aggregate
|
||||
let qdrant = QdrantDb::new();
|
||||
let mut results: Vec<PersonResult> = Vec::new();
|
||||
|
||||
if let Some(min) = min_appearances {
|
||||
sql.push_str(&format!(" HAVING COUNT(fd.id) >= {}", min));
|
||||
for (id, uuid, name) in &identities {
|
||||
let scroll_filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": id}},
|
||||
{"key": "file_uuid", "match": {"value": file_uuid}}
|
||||
]
|
||||
});
|
||||
|
||||
let points = match qdrant
|
||||
.scroll_all_points("_faces", scroll_filter, 1000)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Qdrant scroll failed for identity {}: {}", id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let count = points.len() as i64;
|
||||
if let Some(min) = min_appearances {
|
||||
if (count as i32) < min {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let min_frame = points
|
||||
.iter()
|
||||
.filter_map(|p| p["payload"]["frame"].as_i64())
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let max_frame = points
|
||||
.iter()
|
||||
.filter_map(|p| p["payload"]["frame"].as_i64())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Look up FPS for this file
|
||||
let video_table = crate::core::db::schema::table_name("videos");
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await?
|
||||
.unwrap_or(30.0);
|
||||
|
||||
let first_time = if fps > 0.0 {
|
||||
Some(min_frame as f64 / fps)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let last_time = if fps > 0.0 {
|
||||
Some(max_frame as f64 / fps)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
results.push(PersonResult {
|
||||
identity_id: *id,
|
||||
identity_uuid: uuid.clone(),
|
||||
name: name.clone(),
|
||||
appearance_count: count as i32,
|
||||
first_appearance_time: first_time,
|
||||
last_appearance_time: last_time,
|
||||
});
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY appearance_count DESC");
|
||||
sql.push_str(&format!(" LIMIT {}", limit));
|
||||
|
||||
let rows: Vec<(i32, String, Option<String>, i64, Option<f64>, Option<f64>)> =
|
||||
sqlx::query_as(&sql).fetch_all(db.pool()).await?;
|
||||
|
||||
let results: Vec<PersonResult> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(identity_id, identity_uuid, name, appearance_count, first_time, last_time)| {
|
||||
PersonResult {
|
||||
identity_id,
|
||||
identity_uuid,
|
||||
name,
|
||||
appearance_count: appearance_count as i32,
|
||||
first_appearance_time: first_time,
|
||||
last_appearance_time: last_time,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
// Sort by appearance_count descending, then limit
|
||||
results.sort_by(|a, b| b.appearance_count.cmp(&a.appearance_count));
|
||||
results.truncate(limit);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user