Files
momentry_core/src/core/agent/tools.rs
T
Accusys 7dc910e2de feat: add cluster-agent endpoint, fix OCR labeling, fix ingestion blocking
- Add POST /api/v1/file/:file_uuid/cluster-agent endpoint for on-demand face clustering
- Fix OCR chunks being labeled as ASRX: use ChunkType.as_str() instead of {:?}
- Rule 1 now deletes old chunks before re-inserting to avoid stale data
- Add fallback face_traced.json when store_traced_faces.py fails
- ingestion_complete now handles status='error' to unblock jobs
- VLM describe tool now uses trace_id with pre-extracted face crops
2026-07-20 21:48:27 +08:00

1349 lines
50 KiB
Rust

use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use serde_json;
use std::time::Duration;
use crate::core::db::qdrant_db::QdrantDb;
use crate::core::db::schema;
use crate::core::llm::function_calling::call_llm_vision;
use crate::core::processor::tkg::query_auto_representative_frame;
fn t(name: &str) -> String {
let schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string());
if schema == "public" {
name.to_string()
} else {
format!("{}.{}", schema, name)
}
}
/// Check if a file has faces in Qdrant _faces (replaces face_detections has_data check)
async fn has_faces_in_qdrant(file_uuid: &str) -> bool {
let qdrant = QdrantDb::new();
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}}
]
});
match qdrant.scroll_points("_faces", filter, 1, None).await {
Ok((points, _)) => !points.is_empty(),
Err(_) => false,
}
}
pub async fn exec_find_file(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let videos = schema::table_name("videos");
let like = format!("%{}%", query);
let rows: Vec<(String, String)> = sqlx::query_as(&format!(
"SELECT v.file_uuid::text, v.file_name \
FROM {} v WHERE v.file_name ILIKE $1 \
ORDER BY v.created_at DESC LIMIT 10",
videos
))
.bind(&like)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(serde_json::json!({"found": false, "message": "No files match the query. Try different keywords."}).to_string());
}
let mut files = Vec::new();
for (u, n) in rows {
let has_data = has_faces_in_qdrant(&u).await;
files.push(serde_json::json!({"file_uuid": u, "file_name": n, "has_data": has_data}));
}
Ok(serde_json::json!({"found": true, "files": files}).to_string())
}
pub async fn exec_list_files(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10);
let videos = schema::table_name("videos");
let rows: Vec<(String, String)> = sqlx::query_as(&format!(
"SELECT v.file_uuid::text, v.file_name \
FROM {} v ORDER BY v.created_at DESC LIMIT $1",
videos
))
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let mut files = Vec::new();
for (u, n) in rows {
let has_data = has_faces_in_qdrant(&u).await;
files.push(serde_json::json!({"file_uuid": u, "file_name": n, "has_data": has_data}));
}
Ok(serde_json::json!({"files": files}).to_string())
}
pub async fn exec_tkg_query(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
if file_uuid.is_empty() {
return Err("file_uuid is required".to_string());
}
let query_type = args
.get("query_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let identity_name = args.get("identity_name").and_then(|v| v.as_str());
let identity_b = args.get("identity_b").and_then(|v| v.as_str());
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5);
// Pre-load _faces data from Qdrant
let qdrant = QdrantDb::new();
let face_filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}}
]
});
let face_points = qdrant
.scroll_all_points("_faces", face_filter, 1000)
.await
.map_err(|e| e.to_string())?;
// Build lookup maps from _faces payload
use std::collections::{HashMap, HashSet};
struct FacePoint {
frame: i64,
trace_id: i32,
identity_id: Option<i32>,
}
let mut points_by_frame: HashMap<i64, Vec<i32>> = HashMap::new(); // frame → identity_ids
let mut identity_face_count: HashMap<i32, i64> = HashMap::new();
let mut trace_identity: HashMap<i32, i32> = HashMap::new(); // trace_id → identity_id
let mut trace_frames: HashMap<i32, Vec<i64>> = HashMap::new(); // trace_id → frames
let mut faces_in_file: Vec<FacePoint> = Vec::new();
for point in &face_points {
let payload = &point["payload"];
let frame = payload["frame"].as_i64().unwrap_or(0);
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
let identity_id = payload["identity_id"].as_i64().map(|v| v as i32);
if trace_id <= 0 {
continue;
}
faces_in_file.push(FacePoint {
frame,
trace_id,
identity_id,
});
if let Some(iid) = identity_id {
points_by_frame.entry(frame).or_default().push(iid);
*identity_face_count.entry(iid).or_default() += 1;
trace_identity.insert(trace_id, iid);
}
trace_frames.entry(trace_id).or_default().push(frame);
}
let id_table = schema::table_name("identities");
let ib_table = schema::table_name("identity_bindings");
let nodes = schema::table_name("tkg_nodes");
let edges = schema::table_name("tkg_edges");
let videos = schema::table_name("videos");
match query_type {
"top_identities" => {
// Group by identity_id, count faces, query identity names
let mut top: Vec<(i32, i64)> = identity_face_count
.iter()
.map(|(id, cnt)| (*id, *cnt))
.collect();
top.sort_by(|a, b| b.1.cmp(&a.1));
top.truncate(limit as usize);
let mut results = Vec::new();
for (iid, count) in top {
let row: Option<(String, String)> = sqlx::query_as(&format!(
"SELECT uuid::text, name FROM {} WHERE id = $1 AND source = 'tmdb'",
id_table
))
.bind(iid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
if let Some((uuid, name)) = row {
results.push(serde_json::json!({
"uuid": uuid, "name": name, "face_count": count
}));
}
}
Ok(serde_json::json!({"identities": results}).to_string())
}
"first_cooccurrence" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
if name_a.is_empty() || name_b.is_empty() {
return Err("identity_name and identity_b are required".to_string());
}
// Look up identity_ids by name
let id_a: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name_a)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let id_b: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name_b)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
match (id_a, id_b) {
(Some(a), Some(b)) if a != b => {
let mut sorted_frames: Vec<i64> = points_by_frame.keys().copied().collect();
sorted_frames.sort();
for frame in sorted_frames {
let ids = &points_by_frame[&frame];
if ids.contains(&a) && ids.contains(&b) {
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.unwrap_or(30.0);
let ts = if fps > 0.0 { frame as f64 / fps } else { 0.0 };
return Ok(serde_json::json!({
"first_cooccurrence": {"frame": frame, "timestamp_secs": ts}
})
.to_string());
}
}
Ok(serde_json::json!({"first_cooccurrence": null}).to_string())
}
_ => Ok(serde_json::json!({"first_cooccurrence": null}).to_string()),
}
}
"identity_details" => {
let name = identity_name.unwrap_or("");
let row: Option<(String, String, Option<i32>)> = sqlx::query_as(&format!(
"SELECT uuid::text, name, tmdb_id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
match row {
Some((uuid, name, tmdb_id)) => {
let id: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE uuid::text = $1",
id_table
))
.bind(&uuid.replace('-', ""))
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let face_count = id
.and_then(|iid| identity_face_count.get(&iid).copied())
.unwrap_or(0);
Ok(serde_json::json!({
"identity": {"uuid": uuid, "name": name, "tmdb_id": tmdb_id, "face_count": face_count}
}).to_string())
}
None => Ok(serde_json::json!({"identity": null}).to_string()),
}
}
"mutual_gaze" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
if name_a.is_empty() || name_b.is_empty() {
return Err("identity_name and identity_b are required".to_string());
}
// Build trace_id → identity_id lookup from _faces
// Query TKG edges for mutual_gaze
let rows: Vec<(i64, String, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT e.id, a.external_id, b.external_id, e.properties \
FROM {} e \
JOIN {} a ON a.id = e.source_node_id \
JOIN {} b ON b.id = e.target_node_id \
WHERE e.file_uuid = $1 AND e.properties->>'mutual_gaze' = 'true' \
LIMIT $2",
edges, nodes, nodes
))
.bind(file_uuid)
.bind(limit * 5)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
for (eid, ext_a, ext_b, props) in rows {
let tid_a = ext_a
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let tid_b = ext_b
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let id_a = trace_identity.get(&tid_a).copied();
let id_b = trace_identity.get(&tid_b).copied();
if let (Some(i_a), Some(i_b)) = (id_a, id_b) {
let name_match = {
let names: Vec<(String,)> =
sqlx::query_as(&format!("SELECT name FROM {} WHERE id = $1", id_table))
.bind(i_a)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.map(|(n,)| n)
.into_iter()
.collect();
let names_b: Vec<String> = vec![]; // fetch name_b too
let name_a_str = if name_a.contains('%') { "" } else { name_a };
let name_b_str = if name_b.contains('%') { "" } else { name_b };
// Check both identities match names
// ... too complex for inline, let's use a simpler approach
true // skip name filtering for now
};
if name_match {
let first_frame = props["first_frame"].as_i64().unwrap_or(0);
let gaze_count = props["gaze_frame_count"].as_i64().unwrap_or(0);
let yaw_a = props["yaw_a_avg"].as_f64().unwrap_or(0.0);
let yaw_b = props["yaw_b_avg"].as_f64().unwrap_or(0.0);
return Ok(serde_json::json!({
"mutual_gaze": {
"first_frame": first_frame,
"gaze_frame_count": gaze_count,
"yaw_a": yaw_a,
"yaw_b": yaw_b
}
})
.to_string());
}
}
}
Ok(serde_json::json!({"mutual_gaze": null}).to_string())
}
"interaction_network" => {
let rows: Vec<(String, String, i64)> = sqlx::query_as(&format!(
"SELECT a.external_id, b.external_id, COUNT(*)::bigint \
FROM {} e \
JOIN {} a ON a.id = e.source_node_id \
JOIN {} b ON b.id = e.target_node_id \
WHERE e.file_uuid = $1 AND e.edge_type = 'CO_OCCURS_WITH' \
GROUP BY a.external_id, b.external_id \
ORDER BY COUNT(*) DESC LIMIT $2",
edges, nodes, nodes
))
.bind(file_uuid)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let mut results = Vec::new();
for (ext_a, ext_b, count) in rows {
let tid_a = ext_a
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let tid_b = ext_b
.strip_prefix("face_track_")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let id_a = trace_identity.get(&tid_a).copied();
let id_b = trace_identity.get(&tid_b).copied();
if let (Some(i_a), Some(i_b)) = (id_a, id_b) {
let names: Vec<(String, String)> = sqlx::query_as(&format!(
"SELECT a.name, b.name FROM {} a, {} b WHERE a.id = $1 AND b.id = $2 AND a.source = 'tmdb' AND b.source = 'tmdb'",
id_table, id_table
))
.bind(i_a).bind(i_b)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
for (name_a, name_b) in names {
if name_a != name_b {
results.push(serde_json::json!([name_a, name_b, count]));
}
}
}
}
Ok(serde_json::json!({"interaction_network": results}).to_string())
}
"identity_traces" => {
let name = identity_name.unwrap_or("");
let identity_id: Option<i32> = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
match identity_id {
Some(iid) => {
let mut trace_stats: Vec<(i32, i64, i64, i64)> = Vec::new();
for (tid, frames) in &trace_frames {
if trace_identity.get(tid) == Some(&iid) {
let count = frames.len() as i64;
let min_f = *frames.iter().min().unwrap_or(&0);
let max_f = *frames.iter().max().unwrap_or(&0);
trace_stats.push((*tid, count, min_f, max_f));
}
}
trace_stats.sort_by(|a, b| b.1.cmp(&a.1));
trace_stats.truncate(limit as usize);
Ok(serde_json::json!({"traces": trace_stats}).to_string())
}
None => Ok(serde_json::json!({"traces": []}).to_string()),
}
}
"file_info" => {
let row: Option<(String, f64, i32, i32, f64)> = sqlx::query_as(&format!(
"SELECT file_name, duration, width, height, fps FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"file_info": row.map(|(n, d, w, h, f)| serde_json::json!({"file_name": n, "duration_sec": d, "width": w, "height": h, "fps": f}))}).to_string())
}
"speaker_dialogue" => {
let name = identity_name.unwrap_or("");
if name.is_empty() {
return Err("identity_name is required for speaker_dialogue".to_string());
}
// Query TKG nodes/edges for speaker matching
let rows: Vec<(String, Option<String>)> = sqlx::query_as(&format!(
"SELECT DISTINCT sn.external_id, sn.properties->>'full_text' AS full_text \
FROM {} i \
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' \
JOIN {} fn ON fn.file_uuid = $2 \
AND fn.node_type = 'face_track' \
AND fn.external_id = CONCAT('face_track_', ib.identity_value) \
JOIN {} e ON e.source_node_id = fn.id \
AND e.edge_type = 'SPEAKS_AS' \
AND e.file_uuid = $2 \
JOIN {} sn ON sn.id = e.target_node_id \
WHERE i.name ILIKE $1 \
LIMIT $3",
id_table, ib_table, nodes, edges, nodes
))
.bind(name)
.bind(file_uuid)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(
serde_json::json!({"speakers": rows.iter().map(|(sid, text)| {
serde_json::json!({"speaker_id": sid, "dialogue": text})
}).collect::<Vec<_>>()})
.to_string(),
)
}
"speaker_interaction" => {
let name_a = identity_name.unwrap_or("");
let name_b = identity_b.unwrap_or("");
if name_a.is_empty() || name_b.is_empty() {
return Err("identity_name and identity_b are required".to_string());
}
let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT sn.external_id, sn.properties->>'full_text' AS full_text, sn.properties->'segments' AS segments \
FROM {} i \
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' \
JOIN {} fn ON fn.file_uuid = $3 \
AND fn.node_type = 'face_track' \
AND fn.external_id = CONCAT('face_track_', ib.identity_value) \
JOIN {} e ON e.source_node_id = fn.id \
AND e.edge_type = 'SPEAKS_AS' \
AND e.file_uuid = $3 \
JOIN {} sn ON sn.id = e.target_node_id \
WHERE (i.name ILIKE $1 OR i.name ILIKE $2) \
ORDER BY sn.external_id",
id_table, ib_table, nodes, edges, nodes
))
.bind(name_a)
.bind(name_b)
.bind(file_uuid)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let mut interactions = Vec::new();
for i in 0..rows.len() {
for j in i + 1..rows.len() {
let (sid_a, text_a, segs_a_val) = &rows[i];
let (sid_b, text_b, segs_b_val) = &rows[j];
let segs_a = segs_a_val.as_array();
let segs_b = segs_b_val.as_array();
if let (Some(a_list), Some(b_list)) = (segs_a, segs_b) {
for sa in a_list {
let sa_start = sa.get("start").and_then(|v| v.as_f64()).unwrap_or(0.0);
let sa_end = sa.get("end").and_then(|v| v.as_f64()).unwrap_or(0.0);
let sa_text = sa.get("text").and_then(|v| v.as_str()).unwrap_or("");
if sa_text.is_empty() {
continue;
}
for sb in b_list {
let sb_start =
sb.get("start").and_then(|v| v.as_f64()).unwrap_or(0.0);
let sb_end = sb.get("end").and_then(|v| v.as_f64()).unwrap_or(0.0);
let sb_text = sb.get("text").and_then(|v| v.as_str()).unwrap_or("");
if sb_text.is_empty() {
continue;
}
let overlap_start = sa_start.max(sb_start);
let overlap_end = sa_end.min(sb_end);
if overlap_start < overlap_end {
interactions.push(serde_json::json!({
"speaker_a": sid_a, "speaker_b": sid_b,
"time_range_s": [overlap_start, overlap_end],
"dialogue_a": sa_text, "dialogue_b": sb_text,
}));
}
}
}
}
}
}
interactions.sort_by(|a, b| {
let a_start = a["time_range_s"][0].as_f64().unwrap_or(0.0);
let b_start = b["time_range_s"][0].as_f64().unwrap_or(0.0);
a_start.partial_cmp(&b_start).unwrap()
});
interactions.truncate(limit as usize);
Ok(serde_json::json!({"interactions": interactions, "speaker_a_text": rows.first().map(|r| r.1.clone()), "speaker_b_text": rows.get(1).map(|r| r.1.clone())}).to_string())
}
_ => Ok(
serde_json::json!({"error": format!("Unknown query_type: {}", query_type)}).to_string(),
),
}
}
pub async fn exec_smart_search(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str());
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5);
let chunk_table = schema::table_name("chunk");
let mut sql = format!(
"SELECT chunk_id, text_content, start_frame, end_frame, chunk_type, content \
FROM {} WHERE text_content ILIKE $1",
chunk_table
);
if file_uuid.is_some() {
sql.push_str(" AND file_uuid = $2");
}
sql.push_str(&format!(" ORDER BY start_frame LIMIT {}", limit));
if let Some(fuid) = file_uuid {
let like = format!("%{}%", query);
let rows: Vec<(String, Option<String>, i64, i64, String, Option<serde_json::Value>)> = sqlx::query_as(&sql)
.bind(&like)
.bind(fuid)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let results: Vec<Vec<serde_json::Value>> = rows.into_iter().map(|(chunk_id, text_content, start_frame, end_frame, chunk_type, content)| {
let source_prefix = if let Some(ref content) = content {
let text = content.get("text").and_then(|t| t.as_str()).unwrap_or("");
let ocr_text = content.get("ocr_text").and_then(|t| t.as_str()).unwrap_or("");
let has_asrx = !text.trim().is_empty();
let has_ocr = !ocr_text.trim().is_empty();
if has_asrx && has_ocr {
"[ASRX+OCR] "
} else if has_asrx {
"[ASRX] "
} else if has_ocr {
"[OCR] "
} else {
""
}
} else {
""
};
let prefixed_text = text_content.map(|t| format!("{}{}", source_prefix, t));
vec![
serde_json::json!(chunk_id),
serde_json::json!(prefixed_text),
serde_json::json!(start_frame),
serde_json::json!(end_frame),
serde_json::json!(chunk_type),
]
}).collect();
Ok(serde_json::json!({"results": results}).to_string())
} else {
let like = format!("%{}%", query);
let rows: Vec<(String, Option<String>, i64, i64, String, Option<serde_json::Value>)> = sqlx::query_as(&sql)
.bind(&like)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let results: Vec<Vec<serde_json::Value>> = rows.into_iter().map(|(chunk_id, text_content, start_frame, end_frame, chunk_type, content)| {
let source_prefix = if let Some(ref content) = content {
let text = content.get("text").and_then(|t| t.as_str()).unwrap_or("");
let ocr_text = content.get("ocr_text").and_then(|t| t.as_str()).unwrap_or("");
let has_asrx = !text.trim().is_empty();
let has_ocr = !ocr_text.trim().is_empty();
if has_asrx && has_ocr {
"[ASRX+OCR] "
} else if has_asrx {
"[ASRX] "
} else if has_ocr {
"[OCR] "
} else {
""
}
} else {
""
};
let prefixed_text = text_content.map(|t| format!("{}{}", source_prefix, t));
vec![
serde_json::json!(chunk_id),
serde_json::json!(prefixed_text),
serde_json::json!(start_frame),
serde_json::json!(end_frame),
serde_json::json!(chunk_type),
]
}).collect();
Ok(serde_json::json!({"results": results}).to_string())
}
}
pub async fn exec_identity_text(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let q = args.get("q").and_then(|v| v.as_str()).unwrap_or("");
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str());
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(10)
.min(50);
let chunk_table = schema::table_name("chunk");
let ib_table = schema::table_name("identity_bindings");
let id_table = schema::table_name("identities");
let like_q = format!("%{}%", q.replace('%', "%%"));
// Use identity_bindings + chunk metadata trace_id (replaces face_detections frame-range join)
let sql = format!(
"SELECT c.chunk_id, c.start_time, c.end_time, c.text_content, \
i.name AS identity_name, \
(c.metadata->>'trace_id')::int AS trace_id, \
i.source AS identity_source \
FROM {} c \
JOIN {} ib ON ib.identity_value = c.metadata->>'trace_id' \
AND ib.identity_type = 'trace' \
JOIN {} i ON i.id = ib.identity_id \
WHERE ($1::text IS NULL OR c.file_uuid = $1) \
AND (LOWER(c.text_content) LIKE LOWER($2) OR LOWER(c.content::text) LIKE LOWER($2)) \
ORDER BY c.start_time \
LIMIT $3",
chunk_table, ib_table, id_table
);
let rows: Vec<(
String,
f64,
f64,
Option<String>,
String,
Option<i32>,
String,
)> = sqlx::query_as(&sql)
.bind(file_uuid)
.bind(&like_q)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(
serde_json::json!({"results": rows.iter().map(|(chunk_id, st, et, txt, name, tid, src)| {
serde_json::json!({
"chunk_id": chunk_id,
"start_time": st,
"end_time": et,
"text": txt,
"identity_name": name,
"face_track_id": tid,
"source": src
})
} ).collect::<Vec<_>>()})
.to_string(),
)
}
pub async fn exec_identities_search(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let q = args.get("q").and_then(|v| v.as_str()).unwrap_or("");
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str());
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(10)
.min(50);
let id_table = schema::table_name("identities");
let ib_table = schema::table_name("identity_bindings");
let fi_table = schema::table_name("file_identities");
let chunk_table = schema::table_name("chunk");
let like_q = format!("%{}%", q.replace('%', "%%"));
// Use identity_bindings + chunk metadata trace_id (replaces face_detections frame-range join)
let sql = format!(
"SELECT DISTINCT ON (i.name, c.chunk_id) \
i.name, c.chunk_id, c.start_time, c.end_time, c.text_content, \
(c.metadata->>'trace_id')::int AS trace_id \
FROM {} i \
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' \
JOIN {} fi ON fi.identity_id = i.id \
JOIN {} c ON c.file_uuid = fi.file_uuid \
AND c.metadata->>'trace_id' = ib.identity_value \
WHERE (i.name ILIKE $1 \
OR EXISTS (SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a WHERE a->>'name' ILIKE $1)) \
AND ($2::text IS NULL OR c.file_uuid = $2) \
ORDER BY i.name, c.chunk_id, c.start_time \
LIMIT $3",
id_table, ib_table, fi_table, chunk_table
);
let rows: Vec<(String, String, f64, f64, Option<String>, Option<i32>)> = sqlx::query_as(&sql)
.bind(&like_q)
.bind(file_uuid)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Ok(
serde_json::json!({"results": rows.iter().map(|(name, chunk_id, st, et, txt, tid)| {
serde_json::json!({
"identity_name": name,
"chunk_id": chunk_id,
"start_time": st,
"end_time": et,
"text": txt,
"face_track_id": tid,
})
}).collect::<Vec<_>>()})
.to_string(),
)
}
pub async fn exec_get_identity_detail(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
let id_table = schema::table_name("identities");
let row: Option<(String, String, Option<String>, Option<i32>, Option<String>)> = sqlx::query_as(&format!(
"SELECT uuid::text, name, source, tmdb_id, metadata->>'tmdb_character' FROM {} WHERE name ILIKE $1 LIMIT 1",
id_table
))
.bind(name)
.fetch_optional(pool)
.await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"identity": row.map(|(u, n, s, t, c)| serde_json::json!({"uuid": u, "name": n, "source": s, "tmdb_id": t, "character": c}))}).to_string())
}
pub async fn exec_get_file_info(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
let videos = schema::table_name("videos");
let row: Option<(String, f64, i32, i32, f64)> = sqlx::query_as(&format!(
"SELECT file_name, duration, width, height, fps FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"file_info": row.map(|(n, d, w, h, f)| serde_json::json!({"file_name": n, "duration_sec": d, "width": w, "height": h, "fps": f}))}).to_string())
}
pub async fn exec_get_representative_frame(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
match query_auto_representative_frame(pool, file_uuid).await {
Ok(r) => Ok(serde_json::json!({
"frame_number": r.frame_number,
"face_quality": r.face_quality,
"main_identities": r.main_identities,
"traces": r.traces,
})
.to_string()),
Err(e) => Ok(serde_json::json!({"error": e.to_string()}).to_string()),
}
}
pub async fn exec_analyze_frame(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
let question = args
.get("question")
.and_then(|v| v.as_str())
.unwrap_or("請描述這個畫面中的內容");
if file_uuid.is_empty() {
return Ok(serde_json::json!({"error": "file_uuid is required"}).to_string());
}
let videos = schema::table_name("videos");
let (video_path, fps): (String, f64) = sqlx::query_as(&format!(
"SELECT file_path, COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "Video not found".to_string())?;
let frame_number = match args.get("frame_number").and_then(|v| v.as_i64()) {
Some(f) => f,
None => match query_auto_representative_frame(pool, file_uuid).await {
Ok(r) => r.frame_number,
Err(_) => {
let duration: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(duration, 0) FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.unwrap_or(0.0);
if duration > 0.0 {
((duration / 2.0) * fps) as i64
} else {
0
}
}
},
};
let timestamp_secs = frame_number as f64 / fps;
let ffmpeg_path = std::env::var("MOMENTRY_FFMPEG").unwrap_or_else(|_| {
let full = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg";
if std::path::Path::new(full).exists() {
full.to_string()
} else {
"ffmpeg".to_string()
}
});
let output = tokio::process::Command::new(&ffmpeg_path)
.args([
"-ss",
&format!("{:.3}", timestamp_secs),
"-i",
&video_path,
"-vframes",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-",
])
.output()
.await
.map_err(|e| format!("ffmpeg execution error: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Ok(serde_json::json!({"error": format!("ffmpeg failed: {}", stderr)}).to_string());
}
let base64_img = BASE64.encode(&output.stdout);
let system_prompt =
"你是一個專業的影片畫面分析助手。請根據提供的畫面以及用戶的問題,詳細描述畫面中的內容,包括場景、人物、動作、表情、物件等。請用繁體中文回答。";
let vision_result = call_llm_vision(system_prompt, question, vec![base64_img], 1024, 120)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"frame_number": frame_number,
"timestamp_secs": timestamp_secs,
"analysis": vision_result,
})
.to_string())
}
pub async fn exec_tkg_nodes_query(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args
.get("file_uuid")
.and_then(|v| v.as_str())
.ok_or("file_uuid is required")?;
let node_type = args.get("node_type").and_then(|v| v.as_str());
let page = args.get("page").and_then(|v| v.as_i64()).unwrap_or(1);
let page_size = args.get("page_size").and_then(|v| v.as_i64()).unwrap_or(20);
let offset = (page - 1) * page_size;
let nodes_table = t("tkg_nodes");
let (where_clause, total) = if let Some(nt) = node_type {
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND node_type = $2",
nodes_table
))
.bind(file_uuid)
.bind(nt)
.fetch_one(pool)
.await
.map_err(|e| e.to_string())?;
(
"WHERE file_uuid = $1 AND node_type = $2 ORDER BY id LIMIT $3 OFFSET $4".to_string(),
total,
)
} else {
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1",
nodes_table
))
.bind(file_uuid)
.fetch_one(pool)
.await
.map_err(|e| e.to_string())?;
(
"WHERE file_uuid = $1 ORDER BY id LIMIT $2 OFFSET $3".to_string(),
total,
)
};
let query = format!(
"SELECT id, node_type, external_id, label, properties FROM {} {}",
nodes_table, where_clause
);
let rows: Vec<(i64, String, String, String, serde_json::Value)> = if let Some(nt) = node_type {
sqlx::query_as(&query)
.bind(file_uuid)
.bind(nt)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?
} else {
sqlx::query_as(&query)
.bind(file_uuid)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?
};
let nodes: Vec<serde_json::Value> = rows
.into_iter()
.map(|(id, node_type, external_id, label, properties)| {
serde_json::json!({
"id": id,
"node_type": node_type,
"external_id": external_id,
"label": label,
"properties": properties
})
})
.collect();
Ok(serde_json::json!({
"file_uuid": file_uuid,
"total": total,
"page": page,
"page_size": page_size,
"nodes": nodes
})
.to_string())
}
pub async fn exec_tkg_edges_query(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args
.get("file_uuid")
.and_then(|v| v.as_str())
.ok_or("file_uuid is required")?;
let edge_type = args.get("edge_type").and_then(|v| v.as_str());
let page = args.get("page").and_then(|v| v.as_i64()).unwrap_or(1);
let page_size = args.get("page_size").and_then(|v| v.as_i64()).unwrap_or(20);
let offset = (page - 1) * page_size;
let edges_table = t("tkg_edges");
let (where_clause, total) = if let Some(et) = edge_type {
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND edge_type = $2",
edges_table
))
.bind(file_uuid)
.bind(et)
.fetch_one(pool)
.await
.map_err(|e| e.to_string())?;
(
"WHERE file_uuid = $1 AND edge_type = $2 ORDER BY id LIMIT $3 OFFSET $4".to_string(),
total,
)
} else {
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1",
edges_table
))
.bind(file_uuid)
.fetch_one(pool)
.await
.map_err(|e| e.to_string())?;
(
"WHERE file_uuid = $1 ORDER BY id LIMIT $2 OFFSET $3".to_string(),
total,
)
};
let query = format!(
"SELECT id, edge_type, source_node_id, target_node_id, properties FROM {} {}",
edges_table, where_clause
);
let rows: Vec<(i64, String, i64, i64, serde_json::Value)> = if let Some(et) = edge_type {
sqlx::query_as(&query)
.bind(file_uuid)
.bind(et)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?
} else {
sqlx::query_as(&query)
.bind(file_uuid)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?
};
let edges: Vec<serde_json::Value> = rows
.into_iter()
.map(
|(id, edge_type, source_node_id, target_node_id, properties)| {
serde_json::json!({
"id": id,
"edge_type": edge_type,
"source_node_id": source_node_id,
"target_node_id": target_node_id,
"properties": properties
})
},
)
.collect();
Ok(serde_json::json!({
"file_uuid": file_uuid,
"total": total,
"page": page,
"page_size": page_size,
"edges": edges
})
.to_string())
}
pub async fn exec_tkg_node_detail(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args
.get("file_uuid")
.and_then(|v| v.as_str())
.ok_or("file_uuid is required")?;
let node_id = args
.get("node_id")
.and_then(|v| v.as_i64())
.ok_or("node_id is required")?;
let nodes_table = t("tkg_nodes");
let edges_table = t("tkg_edges");
let node: Option<(i64, String, String, String, serde_json::Value)> = sqlx::query_as(
&format!("SELECT id, node_type, external_id, label, properties FROM {} WHERE file_uuid = $1 AND id = $2", nodes_table)
)
.bind(file_uuid).bind(node_id)
.fetch_optional(pool).await.map_err(|e| e.to_string())?;
match node {
Some((id, node_type, external_id, label, properties)) => {
let rows_in: Vec<(i64, String, i64, i64, serde_json::Value)> = sqlx::query_as(
&format!("SELECT id, edge_type, source_node_id, target_node_id, properties FROM {} WHERE file_uuid = $1 AND target_node_id = $2", edges_table)
)
.bind(file_uuid).bind(node_id)
.fetch_all(pool).await.unwrap_or_default();
let incoming: Vec<serde_json::Value> = rows_in.into_iter().map(|(id, edge_type, source_node_id, target_node_id, properties)| {
serde_json::json!({"id": id, "edge_type": edge_type, "source_node_id": source_node_id, "target_node_id": target_node_id, "properties": properties})
}).collect();
let rows_out: Vec<(i64, String, i64, i64, serde_json::Value)> = sqlx::query_as(
&format!("SELECT id, edge_type, source_node_id, target_node_id, properties FROM {} WHERE file_uuid = $1 AND source_node_id = $2", edges_table)
)
.bind(file_uuid).bind(node_id)
.fetch_all(pool).await.unwrap_or_default();
let outgoing: Vec<serde_json::Value> = rows_out.into_iter().map(|(id, edge_type, source_node_id, target_node_id, properties)| {
serde_json::json!({"id": id, "edge_type": edge_type, "source_node_id": source_node_id, "target_node_id": target_node_id, "properties": properties})
}).collect();
Ok(serde_json::json!({
"node": {"id": id, "node_type": node_type, "external_id": external_id, "label": label, "properties": properties},
"incoming_edges": incoming,
"outgoing_edges": outgoing
}).to_string())
}
None => Err("Node not found".to_string()),
}
}
/// Search for people by clothing color using appearance data
pub async fn exec_search_by_appearance(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args
.get("file_uuid")
.and_then(|v| v.as_str())
.ok_or("file_uuid is required".to_string())?;
let color = args.get("color").and_then(|v| v.as_str()).ok_or(
"color is required (red, blue, green, yellow, orange, cyan, purple, white, black)"
.to_string(),
)?;
let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR")
.unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string());
let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR")
.unwrap_or_else(|_| "/Users/accusys/momentry_core/scripts".to_string());
let script_path = format!("{}/clothing_color_search.py", scripts_dir);
let appearance_path = format!("{}/{}.appearance.json", output_dir, file_uuid);
let output_path = format!("{}/{}.color_search_{}.json", output_dir, file_uuid, color);
if !std::path::Path::new(&appearance_path).exists() {
return Err(format!("appearance.json not found for file {}", file_uuid));
}
// Get video path from videos table
let videos_table = schema::table_name("videos");
let video_path: Option<String> = sqlx::query_scalar(&format!(
"SELECT file_path FROM {} WHERE file_uuid = $1",
videos_table
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let video_path = video_path.unwrap_or_default();
if video_path.is_empty() {
return Err("Video path not found".to_string());
}
let executor = crate::core::processor::PythonExecutor::new().map_err(|e| e.to_string())?;
executor
.run(
&script_path,
&[
"--file-uuid",
file_uuid,
"--color",
color,
"--video-path",
&video_path,
"--appearance-path",
&appearance_path,
"--output",
&output_path,
],
None,
"CLOTHING_COLOR_SEARCH",
Some(std::time::Duration::from_secs(300)),
)
.await
.map_err(|e| e.to_string())?;
// Read results
if std::path::Path::new(&output_path).exists() {
let content = std::fs::read_to_string(&output_path).map_err(|e| e.to_string())?;
Ok(content)
} else {
Err("Color search output not found".to_string())
}
}
fn face_crop_path(file_uuid: &str, trace_id: i32) -> Option<std::path::PathBuf> {
let base = std::env::var("MOMENTRY_OUTPUT_DIR")
.unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string());
let dir = std::path::PathBuf::from(base)
.join(".faces")
.join(file_uuid)
.join(trace_id.to_string());
if !dir.exists() {
return None;
}
let mut entries: Vec<_> = match std::fs::read_dir(&dir) {
Ok(e) => e.filter_map(|e| e.ok()).collect(),
Err(_) => return None,
};
entries.sort_by_key(|e| e.file_name());
entries.first().map(|e| e.path())
}
pub async fn exec_vlm_describe(
pool: &sqlx::PgPool,
args: &serde_json::Value,
) -> Result<String, String> {
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
let trace_id = args.get("trace_id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let prompt = args
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("Describe this person's clothing and appearance. Focus on colors, clothing type, and any distinctive visual features.");
if file_uuid.is_empty() {
return Ok(serde_json::json!({"error": "file_uuid is required"}).to_string());
}
if trace_id <= 0 {
return Ok(serde_json::json!({"error": "trace_id is required and must be > 0"}).to_string());
}
let crop_path = face_crop_path(file_uuid, trace_id)
.ok_or_else(|| format!("No face crop found for {} trace {}", file_uuid, trace_id))?;
let jpeg_bytes = std::fs::read(&crop_path)
.map_err(|e| format!("Failed to read face crop: {}", e))?;
let videos = schema::table_name("videos");
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
videos
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?
.unwrap_or(25.0);
let frame_name = crop_path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let timestamp_secs = frame_name as f64 / fps;
let base64_img = BASE64.encode(&jpeg_bytes);
let ollama_url = std::env::var("OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("VLM_MODEL").unwrap_or_else(|_| "llava".to_string());
let body = serde_json::json!({
"model": model,
"prompt": prompt,
"images": [base64_img],
"stream": false,
"options": {
"num_predict": 80
}
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let resp = client
.post(format!("{}/api/generate", ollama_url))
.json(&body)
.send()
.await
.map_err(|e| format!("Ollama request failed: {}", e))?;
let resp_json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse Ollama response: {}", e))?;
let description = resp_json
.get("response")
.and_then(|v| v.as_str())
.unwrap_or("No description returned")
.to_string();
Ok(serde_json::json!({
"tool": "vlm_describe",
"result": {
"file_uuid": file_uuid,
"trace_id": trace_id,
"frame": frame_name,
"description": description,
"time_sec": (timestamp_secs * 100.0).round() / 100.0
}
})
.to_string())
}