feat: update core API, database layer, and worker modules
- Remove unused imports (n8n_search, universal_search, Client, Arc, etc.) - Update API endpoints for identity, face recognition, search - Fix postgres_db.rs search_videos parent_uuid column - Add snapshot API and identity agent API - Clean up backup files (.bak, .bak2)
This commit is contained in:
@@ -1,19 +1,11 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing;
|
||||
|
||||
use crate::api::server::AppState;
|
||||
|
||||
pub fn agent_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/agents/translate", post(translate_text))
|
||||
Router::new().route("/api/v1/agents/translate", post(translate_text))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -35,7 +27,6 @@ async fn translate_text(
|
||||
State(_state): State<AppState>,
|
||||
Json(req): Json<TranslationRequest>,
|
||||
) -> Result<Json<TranslationResponse>, (StatusCode, String)> {
|
||||
|
||||
let system_prompt = "You are a professional translator for Momentry Core, a digital asset management system specializing in video analysis.
|
||||
|
||||
## Guidelines:
|
||||
@@ -53,7 +44,7 @@ async fn translate_text(
|
||||
// Call Ollama API
|
||||
let client = Client::new();
|
||||
let ollama_url = "http://localhost:11434/api/generate";
|
||||
|
||||
|
||||
// Using qwen3:latest which is available locally
|
||||
let model = "qwen3:latest".to_string();
|
||||
|
||||
@@ -64,16 +55,27 @@ async fn translate_text(
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let response = client.post(ollama_url)
|
||||
let response = client
|
||||
.post(ollama_url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to call LLM: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to call LLM: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let ollama_resp: serde_json::Value = response.json().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse LLM response: {}", e)))?;
|
||||
let ollama_resp: serde_json::Value = response.json().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse LLM response: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let translated_text = ollama_resp.get("response")
|
||||
let translated_text = ollama_resp
|
||||
.get("response")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Translation failed")
|
||||
.to_string();
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::core::processor::face_recognition::{
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceRecognitionRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub enable_recognition: Option<bool>,
|
||||
pub enable_tracking: Option<bool>,
|
||||
pub enable_clustering: Option<bool>,
|
||||
@@ -33,7 +33,7 @@ pub struct FaceRecognitionResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceRegistrationRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub name: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
@@ -47,7 +47,7 @@ pub struct FaceRegistrationApiResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceSearchRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub embedding: Vec<f32>,
|
||||
pub similarity_threshold: Option<f64>,
|
||||
pub limit: Option<i32>,
|
||||
@@ -71,7 +71,7 @@ pub struct FaceSearchResult {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceListQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub active_only: Option<bool>,
|
||||
@@ -106,7 +106,7 @@ pub fn face_recognition_routes() -> Router<crate::api::server::AppState> {
|
||||
.route("/api/v1/face/:face_id", get(get_face_details))
|
||||
.route("/api/v1/face/:face_id", axum::routing::delete(delete_face))
|
||||
.route(
|
||||
"/api/v1/face/results/:video_uuid",
|
||||
"/api/v1/face/results/:file_uuid",
|
||||
get(get_recognition_results),
|
||||
)
|
||||
}
|
||||
@@ -119,7 +119,7 @@ async fn recognize_faces(
|
||||
|
||||
tracing::info!(
|
||||
"[FACE_RECOGNITION] Starting recognition for video: {}, processing_id: {}",
|
||||
request.video_uuid,
|
||||
request.file_uuid,
|
||||
processing_id
|
||||
);
|
||||
|
||||
@@ -134,12 +134,12 @@ async fn recognize_faces(
|
||||
}
|
||||
};
|
||||
|
||||
let video_record = match db.get_video_by_uuid(&request.video_uuid).await {
|
||||
let video_record = match db.get_video_by_uuid(&request.file_uuid).await {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Video not found: {}", request.video_uuid),
|
||||
format!("Video not found: {}", request.file_uuid),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -178,13 +178,13 @@ async fn recognize_faces(
|
||||
};
|
||||
|
||||
// Store results in database
|
||||
if let Err(e) = store_recognition_results(&db, &request.video_uuid, &result).await {
|
||||
if let Err(e) = store_recognition_results(&db, &request.file_uuid, &result).await {
|
||||
tracing::warn!("Failed to store recognition results: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(FaceRecognitionResponse {
|
||||
success: true,
|
||||
message: format!("Face recognition completed for {}", request.video_uuid),
|
||||
message: format!("Face recognition completed for {}", request.file_uuid),
|
||||
result: Some(result),
|
||||
processing_id,
|
||||
}))
|
||||
@@ -334,7 +334,7 @@ async fn register_face_api(
|
||||
.bind(&name)
|
||||
.bind(&embedding_str)
|
||||
.bind(&attrs_json)
|
||||
.bind(&metadata.unwrap_or(serde_json::json!({})))
|
||||
.bind(serde_json::to_string(&metadata.unwrap_or(serde_json::json!({}))).unwrap())
|
||||
.execute(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -694,7 +694,7 @@ async fn delete_face(
|
||||
|
||||
async fn get_recognition_results(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(video_uuid): Path<String>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
@@ -708,7 +708,7 @@ async fn get_recognition_results(
|
||||
|
||||
let query = r#"
|
||||
SELECT
|
||||
video_uuid,
|
||||
file_uuid,
|
||||
frame_count,
|
||||
fps,
|
||||
total_faces,
|
||||
@@ -718,7 +718,7 @@ async fn get_recognition_results(
|
||||
processing_time_secs,
|
||||
created_at
|
||||
FROM face_recognition_results
|
||||
WHERE video_uuid = $1
|
||||
WHERE file_uuid = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
@@ -734,7 +734,7 @@ async fn get_recognition_results(
|
||||
Option<f64>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
)> = match sqlx::query_as(query)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -749,7 +749,7 @@ async fn get_recognition_results(
|
||||
|
||||
match result {
|
||||
Some((
|
||||
video_uuid,
|
||||
file_uuid,
|
||||
frame_count,
|
||||
fps,
|
||||
total_faces,
|
||||
@@ -761,7 +761,7 @@ async fn get_recognition_results(
|
||||
)) => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"video_uuid": video_uuid,
|
||||
"file_uuid": file_uuid,
|
||||
"frame_count": frame_count,
|
||||
"fps": fps,
|
||||
"total_faces": total_faces,
|
||||
@@ -776,14 +776,14 @@ async fn get_recognition_results(
|
||||
}
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No recognition results found for video: {}", video_uuid),
|
||||
format!("No recognition results found for video: {}", file_uuid),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn store_recognition_results(
|
||||
db: &PostgresDb,
|
||||
video_uuid: &str,
|
||||
file_uuid: &str,
|
||||
result: &FaceRecognitionResult,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let total_faces = result.frames.iter().map(|f| f.faces.len()).sum::<usize>();
|
||||
@@ -796,7 +796,7 @@ async fn store_recognition_results(
|
||||
|
||||
let query = r#"
|
||||
INSERT INTO face_recognition_results (
|
||||
video_uuid,
|
||||
file_uuid,
|
||||
frame_count,
|
||||
fps,
|
||||
total_faces,
|
||||
@@ -804,7 +804,7 @@ async fn store_recognition_results(
|
||||
clusters_count,
|
||||
result_data
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (video_uuid) DO UPDATE SET
|
||||
ON CONFLICT (file_uuid) DO UPDATE SET
|
||||
frame_count = EXCLUDED.frame_count,
|
||||
fps = EXCLUDED.fps,
|
||||
total_faces = EXCLUDED.total_faces,
|
||||
@@ -815,7 +815,7 @@ async fn store_recognition_results(
|
||||
"#;
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(video_uuid)
|
||||
.bind(file_uuid)
|
||||
.bind(result.frame_count as i64)
|
||||
.bind(result.fps)
|
||||
.bind(total_faces as i32)
|
||||
@@ -840,7 +840,7 @@ async fn store_recognition_results(
|
||||
|
||||
let insert_query = r#"
|
||||
INSERT INTO face_detections (
|
||||
video_uuid,
|
||||
file_uuid,
|
||||
frame_number,
|
||||
timestamp_secs,
|
||||
face_id,
|
||||
@@ -854,7 +854,7 @@ async fn store_recognition_results(
|
||||
identity_confidence,
|
||||
cluster_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12, $13)
|
||||
ON CONFLICT (video_uuid, frame_number, x, y, width, height) DO UPDATE SET
|
||||
ON CONFLICT (file_uuid, frame_number, x, y, width, height) DO UPDATE SET
|
||||
face_id = EXCLUDED.face_id,
|
||||
confidence = EXCLUDED.confidence,
|
||||
embedding = EXCLUDED.embedding,
|
||||
@@ -874,7 +874,7 @@ async fn store_recognition_results(
|
||||
.map(|c| c.cluster_id.clone());
|
||||
|
||||
sqlx::query(insert_query)
|
||||
.bind(video_uuid)
|
||||
.bind(file_uuid)
|
||||
.bind(frame.frame as i64)
|
||||
.bind(frame.timestamp)
|
||||
.bind(face.face_id.as_deref())
|
||||
@@ -908,7 +908,7 @@ async fn store_recognition_results(
|
||||
let cluster_query = r#"
|
||||
INSERT INTO face_clusters (
|
||||
cluster_id,
|
||||
video_uuid,
|
||||
file_uuid,
|
||||
centroid,
|
||||
size,
|
||||
representative_face_id,
|
||||
@@ -923,7 +923,7 @@ async fn store_recognition_results(
|
||||
|
||||
sqlx::query(cluster_query)
|
||||
.bind(&cluster.cluster_id)
|
||||
.bind(video_uuid)
|
||||
.bind(file_uuid)
|
||||
.bind(¢roid_str)
|
||||
.bind(cluster.size as i32)
|
||||
.bind(cluster.representative_face_id.as_deref())
|
||||
|
||||
673
src/api/five_w1h_agent_api.rs
Normal file
673
src/api/five_w1h_agent_api.rs
Normal file
@@ -0,0 +1,673 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::api::server::AppState;
|
||||
use crate::core::db::PostgresDb;
|
||||
|
||||
pub fn five_w1h_agent_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/agents/5w1h/analyze", post(analyze_5w1h))
|
||||
.route("/api/v1/agents/5w1h/batch", post(batch_analyze_5w1h))
|
||||
.route("/api/v1/agents/5w1h/status", get(get_5w1h_status))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Analyze5W1HRequest {
|
||||
pub file_uuid: String,
|
||||
pub scene_group_size: Option<usize>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Analyze5W1HResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub summaries: Vec<SummaryChunk>,
|
||||
pub processing_status: FiveW1HProcessingStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SummaryChunk {
|
||||
pub chunk_id: String,
|
||||
pub summary: String,
|
||||
pub analysis_5w1h: FiveW1HAnalysis,
|
||||
pub start_frame: i64,
|
||||
pub end_frame: i64,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub fps: f64,
|
||||
pub scene_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FiveW1HAnalysis {
|
||||
pub who: Vec<String>,
|
||||
pub what: Vec<String>,
|
||||
pub location: Vec<String>,
|
||||
pub when: String,
|
||||
pub why: String,
|
||||
pub how: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FiveW1HProcessingStatus {
|
||||
pub status: String,
|
||||
pub scenes_processed: i32,
|
||||
pub scenes_total: i32,
|
||||
pub progress_pct: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchAnalyze5W1HRequest {
|
||||
pub file_uuids: Vec<String>,
|
||||
pub scene_group_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BatchAnalyze5W1HResponse {
|
||||
pub success: bool,
|
||||
pub jobs: Vec<BatchJobStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BatchJobStatus {
|
||||
pub file_uuid: String,
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
async fn analyze_5w1h(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<Analyze5W1HRequest>,
|
||||
) -> Result<Json<Analyze5W1HResponse>, (StatusCode, String)> {
|
||||
let db = PostgresDb::from_pool(state.db.pool().clone());
|
||||
|
||||
let scene_group_size = req.scene_group_size.unwrap_or(7);
|
||||
let model = req.model.unwrap_or_else(|| "gemma4:latest".to_string());
|
||||
|
||||
let rule3_chunks = fetch_rule3_chunks(&db, &req.file_uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if rule3_chunks.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"No Rule 3 chunks found for this video".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let scenes_total = rule3_chunks.len() as i32;
|
||||
|
||||
update_agent_status(&db, &req.file_uuid, "running", 0, scenes_total, 0.0)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let summaries =
|
||||
process_scene_groups(&db, &req.file_uuid, &rule3_chunks, scene_group_size, &model)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to process scene groups: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
|
||||
let scenes_processed = rule3_chunks.len() as i32;
|
||||
let progress_pct = 100.0;
|
||||
|
||||
update_agent_status(
|
||||
&db,
|
||||
&req.file_uuid,
|
||||
"completed",
|
||||
scenes_processed,
|
||||
scenes_total,
|
||||
progress_pct,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(Analyze5W1HResponse {
|
||||
success: true,
|
||||
file_uuid: req.file_uuid,
|
||||
summaries,
|
||||
processing_status: FiveW1HProcessingStatus {
|
||||
status: "completed".to_string(),
|
||||
scenes_processed,
|
||||
scenes_total,
|
||||
progress_pct,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn batch_analyze_5w1h(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchAnalyze5W1HRequest>,
|
||||
) -> Result<Json<BatchAnalyze5W1HResponse>, (StatusCode, String)> {
|
||||
let scene_group_size = req.scene_group_size.unwrap_or(7);
|
||||
let jobs: Vec<BatchJobStatus> = req
|
||||
.file_uuids
|
||||
.iter()
|
||||
.map(|uuid| {
|
||||
let uuid_clone = uuid.clone();
|
||||
let db_clone = PostgresDb::from_pool(state.db.pool().clone());
|
||||
let group_size = scene_group_size;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = process_single_video_5w1h(&db_clone, &uuid_clone, group_size).await;
|
||||
});
|
||||
|
||||
BatchJobStatus {
|
||||
file_uuid: uuid.clone(),
|
||||
status: "queued".to_string(),
|
||||
message: "Job queued for async processing".to_string(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BatchAnalyze5W1HResponse {
|
||||
success: true,
|
||||
jobs,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_5w1h_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = PostgresDb::from_pool(state.db.pool().clone());
|
||||
|
||||
let videos_with_5w1h = fetch_videos_with_5w1h_status(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"videos": videos_with_5w1h
|
||||
})))
|
||||
}
|
||||
|
||||
async fn fetch_rule3_chunks(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<Vec<Rule3Chunk>> {
|
||||
let table = crate::core::db::schema::table_name("chunks");
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT chunk_id, start_frame, end_frame, fps, content, metadata
|
||||
FROM {}
|
||||
WHERE uuid = $1 AND (chunk_type = 'scene' OR chunk_type = 'cut')
|
||||
ORDER BY start_frame
|
||||
"#,
|
||||
table
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&query)
|
||||
.bind(file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
|
||||
let chunks: Vec<Rule3Chunk> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let chunk_id: String = row.try_get("chunk_id").unwrap_or_default();
|
||||
let start_frame: i64 = row.try_get("start_frame").unwrap_or(0);
|
||||
let end_frame: i64 = row.try_get("end_frame").unwrap_or(0);
|
||||
let fps: f64 = row.try_get("fps").unwrap_or(30.0);
|
||||
let content: serde_json::Value =
|
||||
row.try_get("content").unwrap_or(serde_json::Value::Null);
|
||||
let metadata: serde_json::Value =
|
||||
row.try_get("metadata").unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let summary = content
|
||||
.get("data")
|
||||
.and_then(|d| d.get("scene_number"))
|
||||
.and_then(|s| s.as_u64())
|
||||
.map(|n| format!("Scene {}", n))
|
||||
.unwrap_or_else(|| {
|
||||
content
|
||||
.get("data")
|
||||
.and_then(|d| d.get("summary"))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("No summary")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
Rule3Chunk {
|
||||
chunk_id,
|
||||
start_frame,
|
||||
end_frame,
|
||||
fps,
|
||||
summary,
|
||||
metadata,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
async fn process_scene_groups(
|
||||
db: &PostgresDb,
|
||||
file_uuid: &str,
|
||||
rule3_chunks: &[Rule3Chunk],
|
||||
group_size: usize,
|
||||
model: &str,
|
||||
) -> anyhow::Result<Vec<SummaryChunk>> {
|
||||
let mut summaries = Vec::new();
|
||||
let chunks_total = rule3_chunks.len();
|
||||
|
||||
for (group_idx, group) in rule3_chunks.chunks(group_size).enumerate() {
|
||||
let context_text = group
|
||||
.iter()
|
||||
.map(|c| c.summary.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
let faces = aggregate_faces(group);
|
||||
let objects = aggregate_objects(group);
|
||||
|
||||
let llm_result = call_llm_for_5w1h(&context_text, &faces, &objects, model).await?;
|
||||
|
||||
let start_frame = group.first().map(|c| c.start_frame).unwrap_or(0);
|
||||
let end_frame = group.last().map(|c| c.end_frame).unwrap_or(0);
|
||||
let fps = group.first().map(|c| c.fps).unwrap_or(30.0);
|
||||
|
||||
let start_time = start_frame as f64 / fps;
|
||||
let end_time = end_frame as f64 / fps;
|
||||
|
||||
let chunk_id = format!("summary_{}_{}", file_uuid, group_idx);
|
||||
|
||||
let summary = SummaryChunk {
|
||||
chunk_id: chunk_id.clone(),
|
||||
summary: llm_result.summary,
|
||||
analysis_5w1h: llm_result.analysis_5w1h,
|
||||
start_frame,
|
||||
end_frame,
|
||||
start_time,
|
||||
end_time,
|
||||
fps,
|
||||
scene_count: group.len(),
|
||||
};
|
||||
|
||||
store_summary_chunk(db, file_uuid, &summary, group).await?;
|
||||
|
||||
let scenes_processed = ((group_idx + 1) * group_size).min(chunks_total) as i32;
|
||||
let progress_pct = (scenes_processed as f64 / chunks_total as f64) * 100.0;
|
||||
|
||||
update_agent_status(
|
||||
db,
|
||||
file_uuid,
|
||||
"running",
|
||||
scenes_processed,
|
||||
chunks_total as i32,
|
||||
progress_pct,
|
||||
)
|
||||
.await?;
|
||||
|
||||
summaries.push(summary);
|
||||
}
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
async fn call_llm_for_5w1h(
|
||||
context_text: &str,
|
||||
faces: &[String],
|
||||
objects: &[String],
|
||||
model: &str,
|
||||
) -> anyhow::Result<LLM5W1HResult> {
|
||||
let system_prompt = r#"You are a video scene analysis assistant for Momentry Core.
|
||||
|
||||
## Task:
|
||||
Analyze the provided video scenes and extract structured 5W1H information.
|
||||
|
||||
## Output Format (JSON):
|
||||
{
|
||||
"summary": "A brief 2-3 sentence summary of these scenes",
|
||||
"5w1h": {
|
||||
"who": ["List of main characters/actors"],
|
||||
"what": ["List of main events/actions"],
|
||||
"where": ["List of locations/settings"],
|
||||
"when": "Time of day or temporal context",
|
||||
"why": "Motivation or reason for events",
|
||||
"how": "Method or process used"
|
||||
}
|
||||
}
|
||||
|
||||
## Guidelines:
|
||||
- Keep summaries concise and natural
|
||||
- Extract key information, not details
|
||||
- Return ONLY valid JSON, no explanations"#;
|
||||
|
||||
let prompt = format!(
|
||||
r#"Analyze these video scenes:
|
||||
|
||||
## Scene Summaries:
|
||||
{}
|
||||
|
||||
## Detected Faces:
|
||||
{}
|
||||
|
||||
## Detected Objects:
|
||||
{}
|
||||
|
||||
Return the 5W1H analysis in JSON format."#,
|
||||
context_text,
|
||||
faces.join(", "),
|
||||
objects.join(", ")
|
||||
);
|
||||
|
||||
let client = Client::new();
|
||||
let ollama_url = "http://localhost:11434/api/generate";
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"system": system_prompt,
|
||||
"stream": false,
|
||||
"format": "json"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(ollama_url)
|
||||
.json(&body)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let ollama_resp: serde_json::Value = response.json().await?;
|
||||
|
||||
let response_text = ollama_resp
|
||||
.get("response")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("{}");
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(response_text)?;
|
||||
|
||||
let summary = parsed
|
||||
.get("summary")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("No summary generated")
|
||||
.to_string();
|
||||
|
||||
let analysis_5w1h = FiveW1HAnalysis {
|
||||
who: parsed
|
||||
.get("5w1h")
|
||||
.and_then(|w| w.get("who"))
|
||||
.and_then(|w| w.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
what: parsed
|
||||
.get("5w1h")
|
||||
.and_then(|w| w.get("what"))
|
||||
.and_then(|w| w.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
location: parsed
|
||||
.get("5w1h")
|
||||
.and_then(|w| w.get("where"))
|
||||
.and_then(|w| w.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
when: parsed
|
||||
.get("5w1h")
|
||||
.and_then(|w| w.get("when"))
|
||||
.and_then(|w| w.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
why: parsed
|
||||
.get("5w1h")
|
||||
.and_then(|w| w.get("why"))
|
||||
.and_then(|w| w.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
how: parsed
|
||||
.get("5w1h")
|
||||
.and_then(|w| w.get("how"))
|
||||
.and_then(|w| w.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
Ok(LLM5W1HResult {
|
||||
summary,
|
||||
analysis_5w1h,
|
||||
})
|
||||
}
|
||||
|
||||
async fn store_summary_chunk(
|
||||
db: &PostgresDb,
|
||||
file_uuid: &str,
|
||||
summary: &SummaryChunk,
|
||||
group: &[Rule3Chunk],
|
||||
) -> anyhow::Result<()> {
|
||||
let table = crate::core::db::schema::table_name("chunks");
|
||||
|
||||
let content = serde_json::json!({
|
||||
"rule": "rule4",
|
||||
"data": {
|
||||
"summary": summary.summary,
|
||||
"5w1h": {
|
||||
"who": summary.analysis_5w1h.who,
|
||||
"what": summary.analysis_5w1h.what,
|
||||
"where": summary.analysis_5w1h.location,
|
||||
"when": summary.analysis_5w1h.when,
|
||||
"why": summary.analysis_5w1h.why,
|
||||
"how": summary.analysis_5w1h.how,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"scene_count": summary.scene_count,
|
||||
"scene_chunk_ids": group.iter().map(|c| c.chunk_id.clone()).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
INSERT INTO {} (
|
||||
uuid, chunk_id, chunk_index, chunk_type,
|
||||
start_frame, end_frame, fps, start_time, end_time, content, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'summary', $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb)
|
||||
ON CONFLICT (uuid, chunk_id) DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"#,
|
||||
table
|
||||
);
|
||||
|
||||
let start_time = summary.start_time;
|
||||
let end_time = summary.end_time;
|
||||
|
||||
sqlx::query(&query)
|
||||
.bind(file_uuid)
|
||||
.bind(&summary.chunk_id)
|
||||
.bind(0)
|
||||
.bind(summary.start_frame)
|
||||
.bind(summary.end_frame)
|
||||
.bind(summary.fps)
|
||||
.bind(start_time)
|
||||
.bind(end_time)
|
||||
.bind(&content)
|
||||
.bind(&metadata)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_agent_status(
|
||||
db: &PostgresDb,
|
||||
file_uuid: &str,
|
||||
status: &str,
|
||||
scenes_processed: i32,
|
||||
scenes_total: i32,
|
||||
progress_pct: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
let table = crate::core::db::schema::table_name("videos");
|
||||
|
||||
let agent_status = serde_json::json!({
|
||||
"five_w1h": {
|
||||
"status": status,
|
||||
"scenes_processed": scenes_processed,
|
||||
"scenes_total": scenes_total,
|
||||
"progress_pct": progress_pct
|
||||
}
|
||||
});
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
UPDATE {}
|
||||
SET processing_status = jsonb_set(
|
||||
COALESCE(processing_status, '{{}}'::jsonb),
|
||||
'{{agents}}',
|
||||
$1::jsonb
|
||||
)
|
||||
WHERE uuid = $2
|
||||
"#,
|
||||
table
|
||||
);
|
||||
|
||||
sqlx::query(&query)
|
||||
.bind(&agent_status)
|
||||
.bind(file_uuid)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_videos_with_5w1h_status(db: &PostgresDb) -> anyhow::Result<Vec<serde_json::Value>> {
|
||||
let table = crate::core::db::schema::table_name("videos");
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT uuid, processing_status->'agents'->'five_w1h' as agent_status
|
||||
FROM {}
|
||||
WHERE processing_status->'agents'->'five_w1h' IS NOT NULL
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 50
|
||||
"#,
|
||||
table
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&query).fetch_all(db.pool()).await?;
|
||||
|
||||
let videos: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let uuid: String = row.try_get("uuid").unwrap_or_default();
|
||||
let status: Option<serde_json::Value> = row.try_get("agent_status").ok();
|
||||
|
||||
serde_json::json!({
|
||||
"uuid": uuid,
|
||||
"five_w1h_status": status
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(videos)
|
||||
}
|
||||
|
||||
async fn process_single_video_5w1h(
|
||||
db: &PostgresDb,
|
||||
file_uuid: &str,
|
||||
scene_group_size: usize,
|
||||
) -> anyhow::Result<()> {
|
||||
let rule3_chunks = fetch_rule3_chunks(db, file_uuid).await?;
|
||||
|
||||
if rule3_chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scenes_total = rule3_chunks.len() as i32;
|
||||
|
||||
update_agent_status(db, file_uuid, "running", 0, scenes_total, 0.0).await?;
|
||||
|
||||
let _ = process_scene_groups(
|
||||
db,
|
||||
file_uuid,
|
||||
&rule3_chunks,
|
||||
scene_group_size,
|
||||
"gemma4:latest",
|
||||
)
|
||||
.await?;
|
||||
|
||||
update_agent_status(
|
||||
db,
|
||||
file_uuid,
|
||||
"completed",
|
||||
scenes_total,
|
||||
scenes_total,
|
||||
100.0,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn aggregate_faces(group: &[Rule3Chunk]) -> Vec<String> {
|
||||
group
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.metadata
|
||||
.get("faces")
|
||||
.and_then(|f| f.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn aggregate_objects(group: &[Rule3Chunk]) -> Vec<String> {
|
||||
group
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.metadata
|
||||
.get("objects")
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rule3Chunk {
|
||||
chunk_id: String,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
fps: f64,
|
||||
summary: String,
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LLM5W1HResult {
|
||||
summary: String,
|
||||
analysis_5w1h: FiveW1HAnalysis,
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::core::db::{schema, Database, PostgresDb};
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterFromPersonRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub person_id: String,
|
||||
pub identity_name: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterFromFaceRequest {
|
||||
pub face_json_path: String,
|
||||
pub identity_name: String,
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterFromPersonResponse {
|
||||
pub success: bool,
|
||||
@@ -26,10 +35,135 @@ pub struct RegisterFromPersonResponse {
|
||||
pub person_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterFromFaceResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub identity_uuid: Option<String>,
|
||||
pub identity_name: String,
|
||||
pub total_vectors: Option<i32>,
|
||||
pub angle_coverage: Option<Vec<String>>,
|
||||
pub quality_avg: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn identity_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/identities/from-person", post(register_from_person))
|
||||
.route("/api/v1/identities/from-face", post(register_from_face))
|
||||
.route("/api/v1/identities", get(list_identities))
|
||||
.route("/api/v1/faces/candidates", get(list_face_candidates))
|
||||
.route(
|
||||
"/api/v1/identities/:identity_id/faces",
|
||||
get(get_identity_faces),
|
||||
)
|
||||
.route("/api/v1/faces/:face_id/thumbnail", get(get_face_thumbnail))
|
||||
}
|
||||
|
||||
/// Register a Global Identity from face.json with multi-angle reference vectors.
|
||||
/// Calls select_face_reference_vectors_v2.py for automatic reference selection.
|
||||
async fn register_from_face(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(req): Json<RegisterFromFaceRequest>,
|
||||
) -> Result<Json<RegisterFromFaceResponse>, (StatusCode, String)> {
|
||||
let schema = req.schema.unwrap_or("dev".to_string());
|
||||
let python_path =
|
||||
std::env::var("MOMENTRY_PYTHON_PATH").unwrap_or("/opt/homebrew/bin/python3.11".to_string());
|
||||
|
||||
let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR").unwrap_or_else(|_| {
|
||||
let mut path = std::env::current_dir().unwrap_or_default();
|
||||
path.push("scripts");
|
||||
path.to_string_lossy().to_string()
|
||||
});
|
||||
|
||||
let script_path = format!("{}/select_face_reference_vectors_v2.py", scripts_dir);
|
||||
|
||||
tracing::info!(
|
||||
"Registering identity '{}' from face.json: {}",
|
||||
req.identity_name,
|
||||
req.face_json_path
|
||||
);
|
||||
|
||||
let output = Command::new(&python_path)
|
||||
.arg(&script_path)
|
||||
.arg("--face-json")
|
||||
.arg(&req.face_json_path)
|
||||
.arg("--identity-name")
|
||||
.arg(&req.identity_name)
|
||||
.arg("--register")
|
||||
.arg("--schema")
|
||||
.arg(&schema)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to execute script: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Script failed: {}", stderr),
|
||||
));
|
||||
}
|
||||
|
||||
let db = PostgresDb::init().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("DB error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let query = r#"
|
||||
SELECT uuid, reference_data->'total_references' as total,
|
||||
reference_data->'angles_covered' as angles,
|
||||
reference_data->'quality_avg' as quality
|
||||
FROM identities
|
||||
WHERE name = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
let row: Option<(String, Option<i32>, Option<Vec<String>>, Option<f64>)> =
|
||||
sqlx::query_as(query)
|
||||
.bind(&req.identity_name)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
match row {
|
||||
Some((uuid, total, angles, quality)) => Ok(Json(RegisterFromFaceResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Successfully registered identity '{}' with {} reference vectors",
|
||||
req.identity_name,
|
||||
total.unwrap_or(0)
|
||||
),
|
||||
identity_uuid: Some(uuid),
|
||||
identity_name: req.identity_name,
|
||||
total_vectors: total,
|
||||
angle_coverage: angles,
|
||||
quality_avg: quality,
|
||||
})),
|
||||
None => Ok(Json(RegisterFromFaceResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Identity '{}' registered, but details not found",
|
||||
req.identity_name
|
||||
),
|
||||
identity_uuid: None,
|
||||
identity_name: req.identity_name,
|
||||
total_vectors: None,
|
||||
angle_coverage: None,
|
||||
quality_avg: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a Global Identity from a specific Person in a video.
|
||||
@@ -61,10 +195,10 @@ async fn register_from_person(
|
||||
|
||||
// 1. Check if Person exists
|
||||
let person_query =
|
||||
"SELECT id, name FROM person_identities WHERE person_id = $1 AND video_uuid = $2";
|
||||
"SELECT id, name FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
|
||||
let person: Option<(i32, Option<String>)> = match sqlx::query_as(person_query)
|
||||
.bind(&req.person_id)
|
||||
.bind(&req.video_uuid)
|
||||
.bind(&req.file_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
{
|
||||
@@ -84,7 +218,7 @@ async fn register_from_person(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!(
|
||||
"Person '{}' not found in video '{}'",
|
||||
req.person_id, req.video_uuid
|
||||
req.person_id, req.file_uuid
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -149,7 +283,7 @@ async fn register_from_person(
|
||||
.bind("person_id") // identity_type
|
||||
.bind(&req.person_id) // identity_value
|
||||
.bind(1.0) // confidence
|
||||
.bind(&serde_json::json!({"auto_updated": true}))
|
||||
.bind(serde_json::to_string(&serde_json::json!({"auto_updated": true})).unwrap())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
@@ -286,3 +420,420 @@ pub struct IdentityListResponse {
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceCandidatesQuery {
|
||||
pub file_uuid: Option<String>,
|
||||
pub min_confidence: Option<f64>,
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceCandidate {
|
||||
pub id: i32,
|
||||
pub face_id: Option<String>,
|
||||
pub file_uuid: String,
|
||||
pub frame_number: i64,
|
||||
pub confidence: f64,
|
||||
pub bbox: Option<serde_json::Value>,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceCandidatesResponse {
|
||||
pub candidates: Vec<FaceCandidate>,
|
||||
pub total: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IdentityFacesQuery {
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFace {
|
||||
pub id: i32,
|
||||
pub face_id: Option<String>,
|
||||
pub file_uuid: String,
|
||||
pub frame_number: i64,
|
||||
pub confidence: f64,
|
||||
pub bbox: Option<serde_json::Value>,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFacesResponse {
|
||||
pub identity_id: i32,
|
||||
pub faces: Vec<IdentityFace>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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, confidence, bbox, 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,
|
||||
f64,
|
||||
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())
|
||||
.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, confidence, bbox, attributes
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND confidence >= $1
|
||||
ORDER BY confidence DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
table
|
||||
);
|
||||
match sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f64,
|
||||
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),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let candidates: Vec<FaceCandidate> = rows
|
||||
.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,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(FaceCandidatesResponse {
|
||||
candidates,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_identity_faces(
|
||||
axum::extract::Path(identity_id): axum::extract::Path<i32>,
|
||||
Query(query): Query<IdentityFacesQuery>,
|
||||
) -> Result<Json<IdentityFacesResponse>, (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_size = std::cmp::min(query.page_size.unwrap_or(100), 1000);
|
||||
let offset = (query.page.unwrap_or(1) - 1) * page_size;
|
||||
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let count_sql = format!("SELECT COUNT(*) FROM {} WHERE identity_id = $1", table);
|
||||
|
||||
let total: i64 = match sqlx::query_scalar(&count_sql)
|
||||
.bind(identity_id)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
||||
FROM {}
|
||||
WHERE identity_id = $1
|
||||
ORDER BY confidence DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
table
|
||||
);
|
||||
|
||||
let rows = match sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f64,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
>(&sql)
|
||||
.bind(identity_id)
|
||||
.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),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let faces: Vec<IdentityFace> = rows
|
||||
.into_iter()
|
||||
.map(|r| IdentityFace {
|
||||
id: r.0,
|
||||
face_id: r.1,
|
||||
file_uuid: r.2,
|
||||
frame_number: r.3,
|
||||
confidence: r.4,
|
||||
bbox: r.5,
|
||||
attributes: r.6,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(IdentityFacesResponse {
|
||||
identity_id,
|
||||
faces,
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_face_thumbnail(
|
||||
Path(face_id): Path<i32>,
|
||||
) -> Result<impl IntoResponse, (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 table_fd = crate::core::db::schema::table_name("face_detections");
|
||||
let table_v = crate::core::db::schema::table_name("videos");
|
||||
|
||||
let sql = format!(
|
||||
"SELECT fd.frame_number, fd.bbox, v.file_path, v.fps
|
||||
FROM {} fd
|
||||
JOIN {} v ON fd.file_uuid = v.uuid
|
||||
WHERE fd.id = $1",
|
||||
table_fd, table_v
|
||||
);
|
||||
|
||||
let row: Option<(i64, Option<serde_json::Value>, String, f64)> = match sqlx::query_as(&sql)
|
||||
.bind(face_id)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let (frame_number, bbox_json, file_path, fps) = match row {
|
||||
Some(r) => r,
|
||||
None => return Err((StatusCode::NOT_FOUND, format!("Face {} not found", face_id))),
|
||||
};
|
||||
|
||||
let bbox: Bbox = match bbox_json {
|
||||
Some(json) => serde_json::from_value(json).unwrap_or(Bbox {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
None => Bbox {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
};
|
||||
|
||||
let timestamp = frame_number as f64 / fps;
|
||||
|
||||
let crop_filter = format!("crop={}:{}:{}:{}", bbox.width, bbox.height, bbox.x, bbox.y);
|
||||
|
||||
let output = match Command::new("ffmpeg")
|
||||
.args(&[
|
||||
"-ss",
|
||||
×tamp.to_string(),
|
||||
"-i",
|
||||
&file_path,
|
||||
"-vf",
|
||||
&crop_filter,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("ffmpeg error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("ffmpeg failed: {}", stderr),
|
||||
));
|
||||
}
|
||||
|
||||
let response = axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||
.body(Body::from(output.stdout))
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Response error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Bbox {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
}
|
||||
|
||||
603
src/api/identity_agent_api.rs
Normal file
603
src/api/identity_agent_api.rs
Normal file
@@ -0,0 +1,603 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::server::AppState;
|
||||
|
||||
pub fn identity_agent_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/agents/identity/analyze", post(analyze_identity))
|
||||
.route("/api/v1/agents/identity/suggest", post(suggest_merges))
|
||||
.route("/api/v1/agents/identity/status", get(get_identity_status))
|
||||
.route(
|
||||
"/api/v1/agents/suggest/clustering",
|
||||
post(suggest_clustering),
|
||||
)
|
||||
.route("/api/v1/agents/suggest/merge", post(suggest_merge))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AnalyzeIdentityRequest {
|
||||
pub file_uuid: String,
|
||||
pub auto_merge_threshold: Option<f64>,
|
||||
pub llm_threshold: Option<f64>,
|
||||
pub use_llm: Option<bool>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AnalyzeIdentityResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub identities: Vec<IdentityResult>,
|
||||
pub processing_status: IdentityProcessingStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityResult {
|
||||
pub identity_id: String,
|
||||
pub person_ids: Vec<String>,
|
||||
pub speaker_ids: Vec<String>,
|
||||
pub confidence: f64,
|
||||
pub evidence: IdentityEvidence,
|
||||
pub reasoning: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityEvidence {
|
||||
pub face_similarity: Option<f64>,
|
||||
pub speaker_overlap: f64,
|
||||
pub time_overlap: f64,
|
||||
pub frame_ratio: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityProcessingStatus {
|
||||
pub status: String,
|
||||
pub persons_analyzed: i32,
|
||||
pub identities_created: i32,
|
||||
pub merges_suggested: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuggestMergesRequest {
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuggestMergesResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub merge_suggestions: Vec<MergeSuggestion>,
|
||||
pub naming_suggestions: Vec<NamingSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MergeSuggestion {
|
||||
pub target_person_id: String,
|
||||
pub source_person_ids: Vec<String>,
|
||||
pub confidence: f64,
|
||||
pub reasons: Vec<String>,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NamingSuggestion {
|
||||
pub person_id: String,
|
||||
pub suggested_name: String,
|
||||
pub confidence: f64,
|
||||
pub reasoning: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityStatusResponse {
|
||||
pub success: bool,
|
||||
pub agent_name: String,
|
||||
pub version: String,
|
||||
pub supported_models: Vec<String>,
|
||||
pub default_thresholds: DefaultThresholds,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DefaultThresholds {
|
||||
pub auto_merge_threshold: f64,
|
||||
pub llm_threshold: f64,
|
||||
pub face_similarity_threshold: f64,
|
||||
}
|
||||
|
||||
async fn analyze_identity(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AnalyzeIdentityRequest>,
|
||||
) -> Result<Json<AnalyzeIdentityResponse>, (StatusCode, String)> {
|
||||
let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string());
|
||||
|
||||
let video_dir = PathBuf::from(&output_dir).join(&req.file_uuid);
|
||||
|
||||
let face_clustered_path = video_dir.join(format!("{}.face_clustered.json", req.file_uuid));
|
||||
let asrx_path = video_dir.join(format!("{}.asrx.json", req.file_uuid));
|
||||
|
||||
if !face_clustered_path.exists() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Face clustered data not found for video: {}", req.file_uuid),
|
||||
));
|
||||
}
|
||||
|
||||
let face_data: serde_json::Value = std::fs::read_to_string(&face_clustered_path)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read face data: {}", e),
|
||||
)
|
||||
})?
|
||||
.parse()
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse face data: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let asrx_data: Option<serde_json::Value> = if asrx_path.exists() {
|
||||
Some(
|
||||
std::fs::read_to_string(&asrx_path)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read asrx data: {}", e),
|
||||
)
|
||||
})?
|
||||
.parse()
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse asrx data: {}", e),
|
||||
)
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let persons = extract_persons_from_face_data(&face_data);
|
||||
let speakers = extract_speakers_from_asrx_data(&asrx_data);
|
||||
|
||||
let identities = analyze_person_speaker_overlap(&persons, &speakers);
|
||||
|
||||
let processing_status = IdentityProcessingStatus {
|
||||
status: "completed".to_string(),
|
||||
persons_analyzed: persons.len() as i32,
|
||||
identities_created: identities.len() as i32,
|
||||
merges_suggested: 0,
|
||||
};
|
||||
|
||||
Ok(Json(AnalyzeIdentityResponse {
|
||||
success: true,
|
||||
file_uuid: req.file_uuid.clone(),
|
||||
identities,
|
||||
processing_status,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn suggest_merges(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SuggestMergesRequest>,
|
||||
) -> Result<Json<SuggestMergesResponse>, (StatusCode, String)> {
|
||||
let analyze_req = AnalyzeIdentityRequest {
|
||||
file_uuid: req.file_uuid.clone(),
|
||||
auto_merge_threshold: Some(0.8),
|
||||
llm_threshold: Some(0.5),
|
||||
use_llm: Some(true),
|
||||
model: Some("gemma4".to_string()),
|
||||
};
|
||||
|
||||
let analyze_result = analyze_identity(State(state), Json(analyze_req)).await?;
|
||||
|
||||
let merge_suggestions: Vec<MergeSuggestion> = analyze_result
|
||||
.identities
|
||||
.iter()
|
||||
.filter(|id| id.person_ids.len() > 1)
|
||||
.map(|id| {
|
||||
let reasons = vec![
|
||||
format!(
|
||||
"Shared speaker overlap: {:.0}%",
|
||||
id.evidence.speaker_overlap * 100.0
|
||||
),
|
||||
format!(
|
||||
"Face similarity: {:.2}",
|
||||
id.evidence.face_similarity.unwrap_or(0.0)
|
||||
),
|
||||
format!("Confidence: {:.2}", id.confidence),
|
||||
];
|
||||
|
||||
MergeSuggestion {
|
||||
target_person_id: id.person_ids[0].clone(),
|
||||
source_person_ids: id.person_ids[1..].to_vec(),
|
||||
confidence: id.confidence,
|
||||
reasons,
|
||||
action: if id.confidence > 0.8 {
|
||||
"auto_apply"
|
||||
} else {
|
||||
"review_needed"
|
||||
}
|
||||
.to_string(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SuggestMergesResponse {
|
||||
success: true,
|
||||
file_uuid: req.file_uuid,
|
||||
merge_suggestions,
|
||||
naming_suggestions: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_identity_status() -> Result<Json<IdentityStatusResponse>, (StatusCode, String)> {
|
||||
Ok(Json(IdentityStatusResponse {
|
||||
success: true,
|
||||
agent_name: "Identity Agent".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
supported_models: vec!["gemma4".to_string(), "qwen3".to_string()],
|
||||
default_thresholds: DefaultThresholds {
|
||||
auto_merge_threshold: 0.8,
|
||||
llm_threshold: 0.5,
|
||||
face_similarity_threshold: 0.3,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_persons_from_face_data(face_data: &serde_json::Value) -> Vec<PersonData> {
|
||||
let mut persons = Vec::new();
|
||||
|
||||
if let Some(frames) = face_data.get("frames").and_then(|f| f.as_array()) {
|
||||
let mut person_frames_map: std::collections::HashMap<String, Vec<i32>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for frame in frames {
|
||||
if let Some(frame_num) = frame.get("frame").and_then(|f| f.as_i64()) {
|
||||
if let Some(person_id) = frame.get("person_id").and_then(|p| p.as_str()) {
|
||||
person_frames_map
|
||||
.entry(person_id.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(frame_num as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (person_id, frames) in person_frames_map {
|
||||
persons.push(PersonData {
|
||||
person_id,
|
||||
frames,
|
||||
avg_embedding: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
persons
|
||||
}
|
||||
|
||||
fn extract_speakers_from_asrx_data(asrx_data: &Option<serde_json::Value>) -> Vec<SpeakerData> {
|
||||
let mut speakers = Vec::new();
|
||||
|
||||
if let Some(data) = asrx_data {
|
||||
if let Some(segments) = data.get("segments").and_then(|s| s.as_array()) {
|
||||
let mut speaker_segments_map: std::collections::HashMap<String, Vec<(f64, f64)>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for segment in segments {
|
||||
if let Some(speaker_id) = segment.get("speaker").and_then(|s| s.as_str()) {
|
||||
let start = segment.get("start").and_then(|s| s.as_f64()).unwrap_or(0.0);
|
||||
let end = segment.get("end").and_then(|e| e.as_f64()).unwrap_or(0.0);
|
||||
|
||||
speaker_segments_map
|
||||
.entry(speaker_id.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.push((start, end));
|
||||
}
|
||||
}
|
||||
|
||||
for (speaker_id, segments) in speaker_segments_map {
|
||||
speakers.push(SpeakerData {
|
||||
speaker_id,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
speakers
|
||||
}
|
||||
|
||||
fn analyze_person_speaker_overlap(
|
||||
persons: &[PersonData],
|
||||
speakers: &[SpeakerData],
|
||||
) -> Vec<IdentityResult> {
|
||||
let mut identities = Vec::new();
|
||||
|
||||
for (i, person) in persons.iter().enumerate() {
|
||||
let identity_id = format!("identity_{}", i + 1);
|
||||
|
||||
let mut speaker_ids = Vec::new();
|
||||
let mut max_overlap: f64 = 0.0;
|
||||
|
||||
for speaker in speakers {
|
||||
let overlap_frames = calculate_overlap(person, speaker);
|
||||
let overlap_ratio = overlap_frames as f64 / person.frames.len() as f64;
|
||||
|
||||
if overlap_ratio > 0.5 {
|
||||
speaker_ids.push(speaker.speaker_id.clone());
|
||||
max_overlap = max_overlap.max(overlap_ratio);
|
||||
}
|
||||
}
|
||||
|
||||
let confidence = if speaker_ids.len() > 0 {
|
||||
0.7 + max_overlap * 0.2
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
let reasoning = if speaker_ids.len() > 0 {
|
||||
format!(
|
||||
"Person has high overlap with speakers: {}",
|
||||
speaker_ids.join(", ")
|
||||
)
|
||||
} else {
|
||||
"Person has no speaker overlap".to_string()
|
||||
};
|
||||
|
||||
identities.push(IdentityResult {
|
||||
identity_id,
|
||||
person_ids: vec![person.person_id.clone()],
|
||||
speaker_ids,
|
||||
confidence,
|
||||
evidence: IdentityEvidence {
|
||||
face_similarity: None,
|
||||
speaker_overlap: max_overlap,
|
||||
time_overlap: max_overlap,
|
||||
frame_ratio: person.frames.len() as f64 / 1000.0,
|
||||
},
|
||||
reasoning,
|
||||
});
|
||||
}
|
||||
|
||||
identities
|
||||
}
|
||||
|
||||
fn calculate_overlap(person: &PersonData, speaker: &SpeakerData) -> i32 {
|
||||
let mut overlap_count = 0;
|
||||
|
||||
for frame_num in &person.frames {
|
||||
let frame_time = *frame_num as f64 / 23.976;
|
||||
|
||||
for (start, end) in &speaker.segments {
|
||||
if frame_time >= *start && frame_time <= *end {
|
||||
overlap_count += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overlap_count
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuggestClusteringRequest {
|
||||
pub file_uuid: Option<String>,
|
||||
pub min_cluster_size: Option<usize>,
|
||||
pub similarity_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuggestClusteringResponse {
|
||||
pub success: bool,
|
||||
pub suggestions: Vec<ClusteringSuggestion>,
|
||||
pub total_unclustered: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClusteringSuggestion {
|
||||
pub cluster_id: String,
|
||||
pub face_count: usize,
|
||||
pub avg_confidence: f64,
|
||||
pub suggested_name: Option<String>,
|
||||
pub representative_face: Option<String>,
|
||||
}
|
||||
|
||||
async fn suggest_clustering(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SuggestClusteringRequest>,
|
||||
) -> Result<Json<SuggestClusteringResponse>, (StatusCode, String)> {
|
||||
let min_cluster_size = req.min_cluster_size.unwrap_or(3);
|
||||
|
||||
let file_filter = match &req.file_uuid {
|
||||
Some(uuid) => format!("AND fc.file_uuid = '{}'", uuid),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT fc.cluster_id, fc.file_uuid, fc.n_faces, fc.metadata
|
||||
FROM face_clusters fc
|
||||
WHERE fc.n_faces >= $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identities i
|
||||
WHERE i.metadata->>'cluster_id' = fc.cluster_id
|
||||
)
|
||||
{}
|
||||
ORDER BY fc.n_faces DESC
|
||||
"#,
|
||||
file_filter
|
||||
);
|
||||
|
||||
let pool = state.db.pool();
|
||||
let rows = sqlx::query(&query)
|
||||
.bind(min_cluster_size as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let suggestions: Vec<ClusteringSuggestion> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let cluster_id: String = row.get("cluster_id");
|
||||
let n_faces: i32 = row.get("n_faces");
|
||||
let metadata: serde_json::Value =
|
||||
row.try_get("metadata").unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let avg_confidence = metadata
|
||||
.get("avg_confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let representative_face = metadata
|
||||
.get("representative_face_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
ClusteringSuggestion {
|
||||
cluster_id,
|
||||
face_count: n_faces as usize,
|
||||
avg_confidence,
|
||||
suggested_name: None,
|
||||
representative_face,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_unclustered: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM face_detections fd
|
||||
WHERE fd.identity_id IS NULL
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(SuggestClusteringResponse {
|
||||
success: true,
|
||||
suggestions,
|
||||
total_unclustered: total_unclustered as usize,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuggestMergeRequest {
|
||||
pub identity_id: Option<String>,
|
||||
pub similarity_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuggestMergeResponse {
|
||||
pub success: bool,
|
||||
pub suggestions: Vec<IdentityMergeSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityMergeSuggestion {
|
||||
pub source_identity_id: String,
|
||||
pub target_identity_id: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub similarity_score: f64,
|
||||
pub shared_files: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
async fn suggest_merge(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SuggestMergeRequest>,
|
||||
) -> Result<Json<SuggestMergeResponse>, (StatusCode, String)> {
|
||||
let similarity_threshold = req.similarity_threshold.unwrap_or(0.8);
|
||||
|
||||
let identity_filter = match &req.identity_id {
|
||||
Some(id) => format!("AND i1.uuid = '{}' OR i2.uuid = '{}'", id, id),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT
|
||||
i1.uuid as source_uuid,
|
||||
i2.uuid as target_uuid,
|
||||
i1.name as source_name,
|
||||
i2.name as target_name,
|
||||
COUNT(DISTINCT fi1.file_uuid) as shared_files
|
||||
FROM identities i1
|
||||
JOIN identities i2 ON i1.id < i2.id
|
||||
LEFT JOIN file_identities fi1 ON fi1.identity_id = i1.id
|
||||
LEFT JOIN file_identities fi2 ON fi2.identity_id = i2.id AND fi1.file_uuid = fi2.file_uuid
|
||||
WHERE i1.identity_type = 'people'
|
||||
AND i2.identity_type = 'people'
|
||||
AND i1.id != i2.id
|
||||
{}
|
||||
GROUP BY i1.uuid, i2.uuid, i1.name, i2.name
|
||||
HAVING COUNT(DISTINCT fi1.file_uuid) > 0
|
||||
ORDER BY shared_files DESC
|
||||
LIMIT 50
|
||||
"#,
|
||||
identity_filter
|
||||
);
|
||||
|
||||
let pool = state.db.pool();
|
||||
let rows = sqlx::query(&query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let suggestions: Vec<IdentityMergeSuggestion> = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let shared_files: i64 = row.get("shared_files");
|
||||
if shared_files > 0 {
|
||||
let similarity = (shared_files as f64 / 10.0).min(1.0);
|
||||
if similarity >= similarity_threshold {
|
||||
Some(IdentityMergeSuggestion {
|
||||
source_identity_id: row.get("source_uuid"),
|
||||
target_identity_id: row.get("target_uuid"),
|
||||
source_name: row.get("source_name"),
|
||||
target_name: row.get("target_name"),
|
||||
similarity_score: similarity,
|
||||
shared_files: shared_files as usize,
|
||||
reason: format!(
|
||||
"Share {} file(s) - similarity: {:.1}%",
|
||||
shared_files,
|
||||
similarity * 100.0
|
||||
),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SuggestMergeResponse {
|
||||
success: true,
|
||||
suggestions,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PersonData {
|
||||
person_id: String,
|
||||
frames: Vec<i32>,
|
||||
avg_embedding: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SpeakerData {
|
||||
speaker_id: String,
|
||||
segments: Vec<(f64, f64)>,
|
||||
}
|
||||
@@ -8,17 +8,27 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::db::{Database, PostgresDb, ResourceRecord};
|
||||
use crate::core::db::ResourceRecord;
|
||||
|
||||
pub fn identity_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/people", get(list_people))
|
||||
.route("/api/v1/people/search", post(search_people))
|
||||
.route("/api/v1/people/candidates", get(list_candidates))
|
||||
.route("/api/v1/people/{identity_id}/confirm-candidate", post(confirm_candidate))
|
||||
.route("/api/v1/people/{identity_id}/reject-candidate", post(reject_candidate))
|
||||
.route(
|
||||
"/api/v1/people/:identity_id/confirm-candidate",
|
||||
post(confirm_candidate),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/people/:identity_id/reject-candidate",
|
||||
post(reject_candidate),
|
||||
)
|
||||
.route("/api/v1/files", get(list_files))
|
||||
.route("/api/v1/files/{uuid}", get(get_file_detail))
|
||||
.route("/api/v1/files/:uuid", get(get_file_detail))
|
||||
.route("/api/v1/files/:uuid/identities", get(get_file_identities))
|
||||
.route("/api/v1/identities/:uuid", get(get_identity_detail))
|
||||
.route("/api/v1/identities/:uuid/files", get(get_identity_files))
|
||||
.route("/api/v1/identities/:uuid/chunks", get(get_identity_chunks))
|
||||
.route("/api/v1/resources/register", post(register_resource))
|
||||
.route("/api/v1/resources/heartbeat", post(heartbeat_resource))
|
||||
.route("/api/v1/resources", get(list_resources))
|
||||
@@ -59,18 +69,24 @@ async fn list_people(
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state.db.list_people(page_size as i32, offset).await
|
||||
let records = state
|
||||
.db
|
||||
.list_people(page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// TODO: Get total count
|
||||
let total = 100; // Placeholder
|
||||
|
||||
let data = records.into_iter().map(|r| PeopleItem {
|
||||
identity_id: r.uuid,
|
||||
name: r.name,
|
||||
metadata: r.metadata,
|
||||
created_at: r.created_at,
|
||||
}).collect();
|
||||
let data = records
|
||||
.into_iter()
|
||||
.map(|r| PeopleItem {
|
||||
identity_id: r.uuid,
|
||||
name: r.name,
|
||||
metadata: r.metadata,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(PeopleResponse {
|
||||
success: true,
|
||||
@@ -96,15 +112,21 @@ async fn search_people(
|
||||
let page_size = req.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state.db.search_people(&req.query, page_size as i32, offset).await
|
||||
let records = state
|
||||
.db
|
||||
.search_people(&req.query, page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data: Vec<PeopleItem> = records.into_iter().map(|r| PeopleItem {
|
||||
identity_id: r.uuid,
|
||||
name: r.name,
|
||||
metadata: r.metadata,
|
||||
created_at: r.created_at,
|
||||
}).collect();
|
||||
let data: Vec<PeopleItem> = records
|
||||
.into_iter()
|
||||
.map(|r| PeopleItem {
|
||||
identity_id: r.uuid,
|
||||
name: r.name,
|
||||
metadata: r.metadata,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(PeopleResponse {
|
||||
success: true,
|
||||
@@ -145,14 +167,20 @@ async fn list_candidates(
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state.db.get_people_candidates(page_size as i32, offset).await
|
||||
let records = state
|
||||
.db
|
||||
.get_people_candidates(page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data = records.into_iter().map(|r| CandidateItem {
|
||||
pre_chunk_id: r.id,
|
||||
file_uuid: r.file_uuid,
|
||||
data: r.data,
|
||||
}).collect();
|
||||
let data = records
|
||||
.into_iter()
|
||||
.map(|r| CandidateItem {
|
||||
pre_chunk_id: r.id,
|
||||
file_uuid: r.file_uuid,
|
||||
data: r.data,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(CandidatesResponse {
|
||||
success: true,
|
||||
@@ -184,7 +212,10 @@ async fn confirm_candidate(
|
||||
let identity_id = Uuid::parse_str(&identity_id_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
state.db.confirm_candidate(req.pre_chunk_id, identity_id).await
|
||||
state
|
||||
.db
|
||||
.confirm_candidate(req.pre_chunk_id, identity_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(ConfirmCandidateResponse {
|
||||
@@ -198,7 +229,10 @@ async fn reject_candidate(
|
||||
Path(_identity_id_str): Path<String>, // Unused, but consistent with route
|
||||
Json(req): Json<ConfirmCandidateRequest>,
|
||||
) -> Result<Json<ConfirmCandidateResponse>, (StatusCode, String)> {
|
||||
state.db.reject_candidate(req.pre_chunk_id).await
|
||||
state
|
||||
.db
|
||||
.reject_candidate(req.pre_chunk_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(ConfirmCandidateResponse {
|
||||
@@ -240,15 +274,21 @@ async fn list_files(
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state.db.list_files(page_size as i32, offset).await
|
||||
let records = state
|
||||
.db
|
||||
.list_files(page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data = records.into_iter().map(|r| FileItem {
|
||||
file_uuid: r.uuid,
|
||||
file_name: r.file_name,
|
||||
file_path: r.file_path,
|
||||
status: "ready".to_string(),
|
||||
}).collect();
|
||||
let data = records
|
||||
.into_iter()
|
||||
.map(|r| FileItem {
|
||||
file_uuid: r.file_uuid,
|
||||
file_name: r.file_name,
|
||||
file_path: r.file_path,
|
||||
status: "ready".to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(FilesResponse {
|
||||
success: true,
|
||||
@@ -261,23 +301,349 @@ async fn list_files(
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileDetailResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub file_name: String,
|
||||
pub file_path: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
async fn get_file_detail(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<FileDetailResponse>, (StatusCode, String)> {
|
||||
// Need a method to get single file
|
||||
// For now, placeholder
|
||||
Ok(Json(FileDetailResponse {
|
||||
let file = state
|
||||
.db
|
||||
.get_file_by_uuid(&uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match file {
|
||||
Some(f) => Ok(Json(FileDetailResponse {
|
||||
success: true,
|
||||
file_uuid: f.file_uuid,
|
||||
file_name: f.file_name,
|
||||
file_path: f.file_path,
|
||||
metadata: f.probe_json,
|
||||
created_at: f.created_at,
|
||||
})),
|
||||
None => Err((StatusCode::NOT_FOUND, format!("File not found: {}", uuid))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileIdentitiesResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub total: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub data: Vec<FileIdentityItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileIdentityItem {
|
||||
pub identity_id: i32,
|
||||
pub name: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub face_count: Option<i32>,
|
||||
pub speaker_count: Option<i32>,
|
||||
pub first_appearance: Option<f64>,
|
||||
pub last_appearance: Option<f64>,
|
||||
pub confidence: Option<f64>,
|
||||
}
|
||||
|
||||
async fn get_file_identities(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<FileIdentitiesResponse>, (StatusCode, String)> {
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.get_file_identities(&uuid, page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data: Vec<FileIdentityItem> = records
|
||||
.into_iter()
|
||||
.map(|r| FileIdentityItem {
|
||||
identity_id: r.identity_id,
|
||||
name: r.name,
|
||||
metadata: r.metadata,
|
||||
face_count: r.face_count,
|
||||
speaker_count: r.speaker_count,
|
||||
first_appearance: r.first_appearance,
|
||||
last_appearance: r.last_appearance,
|
||||
confidence: r.confidence,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(FileIdentitiesResponse {
|
||||
success: true,
|
||||
file_uuid: uuid,
|
||||
file_name: "Unknown".to_string(),
|
||||
file_path: "/path/to/file".to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
total: data.len() as i64,
|
||||
page,
|
||||
page_size,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityDetailResponse {
|
||||
pub success: bool,
|
||||
pub uuid: Uuid,
|
||||
pub name: String,
|
||||
pub identity_type: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub reference_data: serde_json::Value,
|
||||
pub tmdb_id: Option<i32>,
|
||||
pub tmdb_profile: Option<String>,
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
async fn get_identity_detail(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
) -> Result<Json<IdentityDetailResponse>, (StatusCode, String)> {
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
let identity = state
|
||||
.db
|
||||
.get_identity_by_uuid(&uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match identity {
|
||||
Some(i) => Ok(Json(IdentityDetailResponse {
|
||||
success: true,
|
||||
uuid: i.uuid,
|
||||
name: i.name,
|
||||
identity_type: i.identity_type,
|
||||
source: i.source,
|
||||
status: i.status,
|
||||
metadata: i.metadata,
|
||||
reference_data: i.reference_data,
|
||||
tmdb_id: i.tmdb_id,
|
||||
tmdb_profile: i.tmdb_profile,
|
||||
created_at: i.created_at,
|
||||
updated_at: i.updated_at,
|
||||
})),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Identity not found: {}", uuid),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFilesResponse {
|
||||
pub success: bool,
|
||||
pub identity_uuid: Uuid,
|
||||
pub total: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub data: Vec<IdentityFileItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFileItem {
|
||||
pub file_uuid: String,
|
||||
pub file_name: String,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
pub face_count: Option<i32>,
|
||||
pub speaker_count: Option<i32>,
|
||||
pub first_appearance: Option<f64>,
|
||||
pub last_appearance: Option<f64>,
|
||||
pub confidence: Option<f64>,
|
||||
}
|
||||
|
||||
async fn get_identity_files(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityFilesResponse>, (StatusCode, String)> {
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.get_identity_files(&uuid, page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data: Vec<IdentityFileItem> = records
|
||||
.into_iter()
|
||||
.map(|r| IdentityFileItem {
|
||||
file_uuid: r.file_uuid,
|
||||
file_name: r.file_name,
|
||||
file_path: r.file_path,
|
||||
status: r.status,
|
||||
face_count: r.face_count,
|
||||
speaker_count: r.speaker_count,
|
||||
first_appearance: r.first_appearance,
|
||||
last_appearance: r.last_appearance,
|
||||
confidence: r.confidence,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(IdentityFilesResponse {
|
||||
success: true,
|
||||
identity_uuid: uuid,
|
||||
total: data.len() as i64,
|
||||
page,
|
||||
page_size,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFacesResponse {
|
||||
pub success: bool,
|
||||
pub identity_uuid: Uuid,
|
||||
pub total: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub data: Vec<IdentityFaceItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFaceItem {
|
||||
pub id: i64,
|
||||
pub file_uuid: String,
|
||||
pub frame_number: i64,
|
||||
pub timestamp_secs: f64,
|
||||
pub face_id: Option<String>,
|
||||
pub bbox: BBox,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BBox {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
async fn get_identity_faces(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityFacesResponse>, (StatusCode, String)> {
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(50);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.get_identity_faces(&uuid, page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data: Vec<IdentityFaceItem> = records
|
||||
.into_iter()
|
||||
.map(|r| IdentityFaceItem {
|
||||
id: r.id,
|
||||
file_uuid: r.file_uuid,
|
||||
frame_number: r.frame_number,
|
||||
timestamp_secs: r.timestamp_secs,
|
||||
face_id: r.face_id,
|
||||
bbox: BBox {
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
},
|
||||
confidence: r.confidence,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(IdentityFacesResponse {
|
||||
success: true,
|
||||
identity_uuid: uuid,
|
||||
total: data.len() as i64,
|
||||
page,
|
||||
page_size,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityChunksResponse {
|
||||
pub success: bool,
|
||||
pub identity_uuid: Uuid,
|
||||
pub total: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub data: Vec<IdentityChunkItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityChunkItem {
|
||||
pub id: i64,
|
||||
pub file_uuid: String,
|
||||
pub chunk_id: String,
|
||||
pub chunk_type: String,
|
||||
pub start_time: Option<f64>,
|
||||
pub end_time: Option<f64>,
|
||||
pub text_content: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_identity_chunks(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityChunksResponse>, (StatusCode, String)> {
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.get_identity_chunks(&uuid, page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data: Vec<IdentityChunkItem> = records
|
||||
.into_iter()
|
||||
.map(|r| IdentityChunkItem {
|
||||
id: r.id,
|
||||
file_uuid: r.file_uuid,
|
||||
chunk_id: r.chunk_id,
|
||||
chunk_type: r.chunk_type,
|
||||
start_time: r.start_time,
|
||||
end_time: r.end_time,
|
||||
text_content: r.text_content,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(IdentityChunksResponse {
|
||||
success: true,
|
||||
identity_uuid: uuid,
|
||||
total: data.len() as i64,
|
||||
page,
|
||||
page_size,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -326,7 +692,10 @@ async fn register_resource(
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
state.db.register_resource(resource).await
|
||||
state
|
||||
.db
|
||||
.register_resource(resource)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(ResourceResponse {
|
||||
@@ -347,7 +716,10 @@ async fn heartbeat_resource(
|
||||
Json(req): Json<HeartbeatRequest>,
|
||||
) -> Result<Json<ResourceResponse>, (StatusCode, String)> {
|
||||
let status = req.status.unwrap_or("online".to_string());
|
||||
state.db.heartbeat_resource(&req.resource_id, &status).await
|
||||
state
|
||||
.db
|
||||
.heartbeat_resource(&req.resource_id, &status)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(ResourceResponse {
|
||||
@@ -360,17 +732,23 @@ async fn heartbeat_resource(
|
||||
async fn list_resources(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
) -> Result<Json<ResourceResponse>, (StatusCode, String)> {
|
||||
let records = state.db.list_resources().await
|
||||
let records = state
|
||||
.db
|
||||
.list_resources()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data: Vec<ResourceItem> = records.into_iter().map(|r| ResourceItem {
|
||||
resource_id: r.resource_id,
|
||||
resource_type: r.resource_type,
|
||||
category: r.category,
|
||||
capabilities: r.capabilities,
|
||||
status: r.status,
|
||||
last_heartbeat: r.last_heartbeat,
|
||||
}).collect();
|
||||
let data: Vec<ResourceItem> = records
|
||||
.into_iter()
|
||||
.map(|r| ResourceItem {
|
||||
resource_id: r.resource_id,
|
||||
resource_type: r.resource_type,
|
||||
category: r.category,
|
||||
capabilities: r.capabilities,
|
||||
status: r.status,
|
||||
last_heartbeat: r.last_heartbeat,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ResourceResponse {
|
||||
success: true,
|
||||
|
||||
@@ -211,7 +211,7 @@ pub async fn get_signal_timeline(
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AVSuggestRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub overlap_threshold: Option<f64>, // default 0.6
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ pub async fn suggest_audio_visual_bindings(
|
||||
|
||||
// 1. Get Face signals and their time ranges
|
||||
let face_signals = db
|
||||
.list_unbound_signals(&req.video_uuid, "face")
|
||||
.list_unbound_signals(&req.file_uuid, "face")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -243,7 +243,7 @@ pub async fn suggest_audio_visual_bindings(
|
||||
})?;
|
||||
|
||||
let speaker_signals = db
|
||||
.list_unbound_signals(&req.video_uuid, "speaker")
|
||||
.list_unbound_signals(&req.file_uuid, "speaker")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -263,11 +263,11 @@ pub async fn suggest_audio_visual_bindings(
|
||||
|
||||
// Placeholder: Calculate overlap by fetching timelines
|
||||
let face_timeline = db
|
||||
.get_chunks_by_signal(&req.video_uuid, "face", face_id)
|
||||
.get_chunks_by_signal(&req.file_uuid, "face", face_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let speaker_timeline = db
|
||||
.get_chunks_by_signal(&req.video_uuid, "speaker", speaker_id)
|
||||
.get_chunks_by_signal(&req.file_uuid, "speaker", speaker_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
@@ -21,13 +21,27 @@ pub struct ApiState {
|
||||
pub db: Arc<PostgresDb>,
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS: &[&str] = &[
|
||||
"/api/v1/faces/", // Thumbnail paths (partial match)
|
||||
];
|
||||
|
||||
fn is_public_path(path: &str) -> bool {
|
||||
PUBLIC_PATHS.iter().any(|prefix| path.starts_with(prefix)) && path.ends_with("/thumbnail")
|
||||
}
|
||||
|
||||
pub async fn api_key_validation(
|
||||
State(state): State<ApiState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
tracing::info!("[MIDDLEWARE] Starting API key validation");
|
||||
tracing::info!("[MIDDLEWARE] Path: {:?}", request.uri().path());
|
||||
tracing::info!("[MIDDLEWARE] Path: {:?}", path);
|
||||
|
||||
if is_public_path(path) {
|
||||
tracing::info!("[MIDDLEWARE] Public path, skipping auth: {}", path);
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let headers = request.headers();
|
||||
tracing::info!("[MIDDLEWARE] All headers: {:?}", headers);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod agent_api;
|
||||
pub mod face_recognition;
|
||||
pub mod five_w1h_agent_api;
|
||||
pub mod identities;
|
||||
pub mod identity_agent_api;
|
||||
pub mod identity_api;
|
||||
pub mod identity_binding;
|
||||
pub mod middleware;
|
||||
@@ -8,6 +10,7 @@ pub mod n8n_search;
|
||||
pub mod person_identity;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod snapshot_api;
|
||||
pub mod universal_search;
|
||||
pub mod visual_chunk_search;
|
||||
pub mod who;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::core::db::{Bm25Result, PostgresDb};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -35,7 +34,7 @@ pub async fn n8n_search_smart(
|
||||
req: SmartSearchRequest,
|
||||
) -> Result<SmartSearchResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let video_uuid = req.uuid.clone();
|
||||
let file_uuid = req.uuid.clone();
|
||||
|
||||
// 1. Call LLM to extract 5W1H (Fallback to keywords if LLM fails)
|
||||
let dimensions = match parse_query_with_llm(&req.query).await {
|
||||
@@ -93,10 +92,7 @@ pub async fn n8n_search_smart(
|
||||
|
||||
// A. Keyword Search (BM25)
|
||||
if !keywords.is_empty() {
|
||||
if let Ok(results) = db
|
||||
.search_bm25(&keywords, video_uuid.as_deref(), limit)
|
||||
.await
|
||||
{
|
||||
if let Ok(results) = db.search_bm25(&keywords, file_uuid.as_deref(), limit).await {
|
||||
for sr in results {
|
||||
add_hit(&mut hits, &mut seen_chunk_ids, sr, 1.0);
|
||||
}
|
||||
@@ -106,7 +102,7 @@ pub async fn n8n_search_smart(
|
||||
// B. Who Search (Person Matching)
|
||||
if let Some(who_query) = &dimensions.who {
|
||||
// 1. Search Person
|
||||
if let Ok(persons) = db.search_person_candidates(who_query, &video_uuid, 5).await {
|
||||
if let Ok(persons) = db.search_person_candidates(who_query, &file_uuid, 5).await {
|
||||
if !persons.is_empty() {
|
||||
let person_id = persons[0]
|
||||
.get("candidate_id")
|
||||
@@ -122,7 +118,7 @@ pub async fn n8n_search_smart(
|
||||
|
||||
// Re-run BM25 with person name to find specific chunks and boost them
|
||||
if let Ok(results) = db
|
||||
.search_bm25(person_name, video_uuid.as_deref(), limit)
|
||||
.search_bm25(person_name, file_uuid.as_deref(), limit)
|
||||
.await
|
||||
{
|
||||
for sr in results {
|
||||
|
||||
@@ -10,7 +10,6 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::db::schema;
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
use crate::core::person_identity::{
|
||||
ChunkPersonInfo, CreatePersonIdentityRequest, PersonIdentity, PersonIdentityResponse,
|
||||
@@ -20,7 +19,7 @@ use crate::core::person_identity::{
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IdentifyPersonsRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub auto_match: Option<bool>,
|
||||
pub match_threshold: Option<f64>,
|
||||
}
|
||||
@@ -39,19 +38,19 @@ pub struct FaceListQuery {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VideoUuidQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PersonTimelineQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceThumbnailQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>, // Which face detection to use (default: 0)
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
||||
// Structs for parsing face_clustered.json
|
||||
@@ -85,7 +84,7 @@ pub struct ChunkPersonsResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergePersonsRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub target_person_id: String,
|
||||
pub source_person_ids: Vec<String>,
|
||||
}
|
||||
@@ -115,7 +114,7 @@ pub struct MergeHistoryResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PersonListQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub min_appearances: Option<i32>,
|
||||
@@ -124,13 +123,13 @@ pub struct PersonListQuery {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AutoIdentifyRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub min_speaker_confidence: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SimilarPersonsQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub threshold: Option<f64>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
@@ -210,7 +209,7 @@ pub struct AutoIdentifyResponse {
|
||||
}
|
||||
|
||||
pub struct AggregateBySpeakerRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub auto_merge: bool, // If true, automatically merge duplicates
|
||||
}
|
||||
|
||||
@@ -311,14 +310,14 @@ async fn identify_persons(
|
||||
|
||||
tracing::info!(
|
||||
"[PERSON_IDENTITY] Identifying persons for video: {}",
|
||||
request.video_uuid
|
||||
request.file_uuid
|
||||
);
|
||||
|
||||
let auto_match = request.auto_match.unwrap_or(true);
|
||||
let threshold = request.match_threshold.unwrap_or(0.5);
|
||||
|
||||
if auto_match {
|
||||
let matches = match auto_match_face_speaker(&db, &request.video_uuid, threshold).await {
|
||||
let matches = match auto_match_face_speaker(&db, &request.file_uuid, threshold).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
@@ -333,7 +332,7 @@ async fn identify_persons(
|
||||
let person = match create_person_identity(
|
||||
&db,
|
||||
CreatePersonIdentityRequest {
|
||||
video_uuid: request.video_uuid.clone(),
|
||||
file_uuid: request.file_uuid.clone(),
|
||||
face_identity_id: None,
|
||||
speaker_id: Some(match_result.speaker_id.clone()),
|
||||
name: None,
|
||||
@@ -372,7 +371,7 @@ async fn identify_persons(
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PersonDetailQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -381,7 +380,7 @@ pub struct SearchIdentitiesRequest {
|
||||
pub speaker_id: Option<String>,
|
||||
pub gender: Option<String>,
|
||||
pub min_appearances: Option<i32>,
|
||||
pub video_uuid: Option<String>, // Optional: search only in specific video
|
||||
pub file_uuid: Option<String>, // Optional: search only in specific video
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -404,7 +403,7 @@ async fn search_identities(
|
||||
let mut sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
id, person_id, face_identity_id, speaker_id, video_uuid,
|
||||
id, person_id, face_identity_id, speaker_id, file_uuid,
|
||||
name, original_name, character_name, gender, age,
|
||||
appearance_count, total_appearance_duration,
|
||||
first_appearance_time, last_appearance_time, is_confirmed
|
||||
@@ -435,8 +434,8 @@ async fn search_identities(
|
||||
conditions.push(format!("appearance_count >= {}", min_count));
|
||||
}
|
||||
|
||||
if let Some(vid) = &req.video_uuid {
|
||||
conditions.push(format!("video_uuid = '{}'", vid.replace('\'', "''")));
|
||||
if let Some(vid) = &req.file_uuid {
|
||||
conditions.push(format!("file_uuid = '{}'", vid.replace('\'', "''")));
|
||||
}
|
||||
|
||||
if !conditions.is_empty() {
|
||||
@@ -482,7 +481,7 @@ async fn search_identities(
|
||||
person_id,
|
||||
face_id,
|
||||
speaker_id,
|
||||
video_uuid,
|
||||
file_uuid,
|
||||
name,
|
||||
original_name,
|
||||
character_name,
|
||||
@@ -498,7 +497,7 @@ async fn search_identities(
|
||||
"id": id,
|
||||
"person_id": person_id,
|
||||
"face_identity_id": face_id,
|
||||
"video_uuid": video_uuid,
|
||||
"file_uuid": file_uuid,
|
||||
"profile": {
|
||||
"name": name,
|
||||
"original_name": original_name,
|
||||
@@ -579,12 +578,12 @@ async fn get_identity_videos(
|
||||
// Each video has its own local person_id, speaker_id, and character_name
|
||||
let videos_query = r#"
|
||||
SELECT
|
||||
pi.video_uuid, v.file_name, v.file_path,
|
||||
pi.file_uuid, v.file_name, v.file_path,
|
||||
pi.person_id, pi.speaker_id, pi.character_name,
|
||||
pi.appearance_count,
|
||||
pi.total_appearance_duration, pi.first_appearance_time, pi.last_appearance_time
|
||||
FROM '"{}' pi
|
||||
LEFT JOIN videos v ON pi.video_uuid = v.uuid
|
||||
LEFT JOIN videos v ON pi.file_uuid = v.uuid
|
||||
WHERE pi.face_identity_id = $1
|
||||
ORDER BY pi.last_appearance_time DESC
|
||||
"#;
|
||||
@@ -599,7 +598,7 @@ async fn get_identity_videos(
|
||||
.map(|row| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"video_uuid": row.get::<String, _>("video_uuid"),
|
||||
"file_uuid": row.get::<String, _>("file_uuid"),
|
||||
"file_name": row.get::<Option<String>, _>("file_name"),
|
||||
"file_path": row.get::<Option<String>, _>("file_path"),
|
||||
"person_id": row.get::<String, _>("person_id"),
|
||||
@@ -659,12 +658,12 @@ async fn get_identity_faces(
|
||||
// Fetch distinct face detections for this identity
|
||||
let sql = r#"
|
||||
SELECT
|
||||
fd.id as detection_id, fd.video_uuid, fd.frame_number,
|
||||
fd.id as detection_id, fd.file_uuid, fd.frame_number,
|
||||
fd.timestamp_secs, fd.x, fd.y, fd.width, fd.height,
|
||||
fd.cluster_id, v.file_name
|
||||
FROM face_detections fd
|
||||
JOIN person_identities pi ON fd.video_uuid = pi.video_uuid
|
||||
LEFT JOIN videos v ON fd.video_uuid = v.uuid
|
||||
JOIN person_identities pi ON fd.file_uuid = pi.file_uuid
|
||||
LEFT JOIN videos v ON fd.file_uuid = v.uuid
|
||||
WHERE pi.face_identity_id = $1
|
||||
ORDER BY fd.timestamp_secs DESC
|
||||
LIMIT $2
|
||||
@@ -682,7 +681,7 @@ async fn get_identity_faces(
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"detection_id": row.get::<i32, _>("detection_id"),
|
||||
"video_uuid": row.get::<String, _>("video_uuid"),
|
||||
"file_uuid": row.get::<String, _>("file_uuid"),
|
||||
"file_name": row.get::<Option<String>, _>("file_name"),
|
||||
"frame_number": row.get::<i64, _>("frame_number"),
|
||||
"timestamp": row.get::<f64, _>("timestamp_secs"),
|
||||
@@ -715,7 +714,7 @@ async fn get_identity_faces(
|
||||
/// List all faces in a video (both registered and unregistered)
|
||||
async fn get_video_faces(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(video_uuid): Path<String>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
@@ -735,13 +734,13 @@ async fn get_video_faces(
|
||||
pi.person_id, pi.face_identity_id, pi.name, pi.is_confirmed
|
||||
FROM face_clusters fc
|
||||
LEFT JOIN person_identities pi ON fc.cluster_id = pi.person_id
|
||||
AND pi.video_uuid = fc.video_uuid
|
||||
WHERE fc.video_uuid = $1
|
||||
AND pi.file_uuid = fc.file_uuid
|
||||
WHERE fc.file_uuid = $1
|
||||
ORDER BY fc.size DESC
|
||||
"#;
|
||||
|
||||
let clusters: Vec<serde_json::Value> = match sqlx::query(clusters_query)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -785,7 +784,7 @@ async fn get_video_faces(
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"video_uuid": video_uuid,
|
||||
"file_uuid": file_uuid,
|
||||
"total_faces": total_faces,
|
||||
"registered_count": registered_count,
|
||||
"unregistered_count": total_faces - registered_count,
|
||||
@@ -799,7 +798,7 @@ async fn register_identity(
|
||||
Path(person_id): Path<String>,
|
||||
Query(query): Query<VideoUuidQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let video_uuid = query.video_uuid;
|
||||
let file_uuid = query.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -813,11 +812,11 @@ async fn register_identity(
|
||||
// 1. Fetch person info
|
||||
let person_query = r#"
|
||||
SELECT id, name, face_identity_id FROM '"{}'
|
||||
WHERE person_id = $1 AND video_uuid = $2
|
||||
WHERE person_id = $1 AND file_uuid = $2
|
||||
"#;
|
||||
let person: Option<(i32, Option<String>, Option<i32>)> = match sqlx::query_as(person_query)
|
||||
.bind(&person_id)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -835,7 +834,7 @@ async fn register_identity(
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Person '{}' not found in video '{}'", person_id, video_uuid),
|
||||
format!("Person '{}' not found in video '{}'", person_id, file_uuid),
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -851,11 +850,11 @@ async fn register_identity(
|
||||
|
||||
// 2. Get cluster centroid or detections embedding
|
||||
let cluster_query = r#"
|
||||
SELECT centroid FROM face_clusters WHERE cluster_id = $1 AND video_uuid = $2
|
||||
SELECT centroid FROM face_clusters WHERE cluster_id = $1 AND file_uuid = $2
|
||||
"#;
|
||||
let centroid: Option<Vec<f32>> = sqlx::query_scalar(cluster_query)
|
||||
.bind(&person_id)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
.ok()
|
||||
@@ -923,7 +922,7 @@ async fn get_person_details(
|
||||
Path(person_id): Path<String>,
|
||||
Query(query): Query<PersonDetailQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let video_uuid = query.video_uuid;
|
||||
let file_uuid = query.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -941,7 +940,7 @@ async fn get_person_details(
|
||||
first_appearance_time, last_appearance_time,
|
||||
is_confirmed, created_at, updated_at
|
||||
FROM '"{}'
|
||||
WHERE person_id = $1 AND video_uuid = $2
|
||||
WHERE person_id = $1 AND file_uuid = $2
|
||||
"#;
|
||||
|
||||
let person: Option<(
|
||||
@@ -959,7 +958,7 @@ async fn get_person_details(
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
)> = match sqlx::query_as(query)
|
||||
.bind(&person_id)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1001,7 +1000,7 @@ async fn get_person_details(
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdatePersonQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn update_person_identity(
|
||||
@@ -1010,7 +1009,7 @@ async fn update_person_identity(
|
||||
Query(query): Query<UpdatePersonQuery>,
|
||||
Json(request): Json<UpdatePersonIdentityRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let video_uuid = query.video_uuid;
|
||||
let file_uuid = query.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -1083,10 +1082,10 @@ async fn get_person_timeline(
|
||||
}
|
||||
};
|
||||
|
||||
let name_query = "SELECT name FROM person_identities WHERE person_id = $1 AND video_uuid = $2";
|
||||
let name_query = "SELECT name FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
|
||||
let name: Option<String> = match sqlx::query_scalar(name_query)
|
||||
.bind(&person_id)
|
||||
.bind(&query.video_uuid)
|
||||
.bind(&query.file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1102,13 +1101,13 @@ async fn get_person_timeline(
|
||||
let timeline_query = r#"
|
||||
SELECT start_time, end_time, duration, confidence
|
||||
FROM person_appearances
|
||||
WHERE person_id = $1 AND video_uuid = $2
|
||||
WHERE person_id = $1 AND file_uuid = $2
|
||||
ORDER BY start_time ASC
|
||||
"#;
|
||||
|
||||
let timeline: Vec<(f64, f64, f64, f64)> = match sqlx::query_as(timeline_query)
|
||||
.bind(&person_id)
|
||||
.bind(&query.video_uuid)
|
||||
.bind(&query.file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1139,13 +1138,13 @@ async fn get_person_timeline(
|
||||
MAX(end_time) as last_appearance,
|
||||
AVG(confidence) as average_confidence
|
||||
FROM person_appearances
|
||||
WHERE person_id = $1 AND video_uuid = $2
|
||||
WHERE person_id = $1 AND file_uuid = $2
|
||||
"#;
|
||||
|
||||
let stats: (i64, Option<f64>, Option<f64>, Option<f64>, Option<f64>) =
|
||||
match sqlx::query_as(stats_query)
|
||||
.bind(&person_id)
|
||||
.bind(&query.video_uuid)
|
||||
.bind(&query.file_uuid)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1179,7 +1178,7 @@ async fn get_person_appearances(
|
||||
Path(person_id): Path<String>,
|
||||
Query(query): Query<PersonDetailQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let video_uuid = query.video_uuid;
|
||||
let file_uuid = query.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -1192,18 +1191,18 @@ async fn get_person_appearances(
|
||||
|
||||
let query = r#"
|
||||
SELECT
|
||||
person_id, video_uuid, start_time, end_time, duration,
|
||||
person_id, file_uuid, start_time, end_time, duration,
|
||||
face_detection_id, asrx_segment_start, asrx_segment_end,
|
||||
confidence, created_at
|
||||
FROM person_appearances
|
||||
WHERE person_id = $1 AND video_uuid = $2
|
||||
WHERE person_id = $1 AND file_uuid = $2
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 100
|
||||
"#;
|
||||
|
||||
let appearances: Vec<serde_json::Value> = match sqlx::query(query)
|
||||
.bind(&person_id)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1213,7 +1212,7 @@ async fn get_person_appearances(
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"person_id": row.get::<String, _>("person_id"),
|
||||
"video_uuid": row.get::<String, _>("video_uuid"),
|
||||
"file_uuid": row.get::<String, _>("file_uuid"),
|
||||
"start_time": row.get::<f64, _>("start_time"),
|
||||
"end_time": row.get::<f64, _>("end_time"),
|
||||
"duration": row.get::<f64, _>("duration"),
|
||||
@@ -1240,7 +1239,7 @@ async fn get_person_appearances(
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChunkPersonsQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn get_chunk_persons(
|
||||
@@ -1248,7 +1247,7 @@ async fn get_chunk_persons(
|
||||
Path(chunk_id): Path<String>,
|
||||
Query(query): Query<ChunkPersonsQuery>,
|
||||
) -> Result<Json<ChunkPersonsResponse>, (StatusCode, String)> {
|
||||
let video_uuid = &query.video_uuid;
|
||||
let file_uuid = &query.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -1290,7 +1289,7 @@ async fn get_chunk_persons(
|
||||
}
|
||||
};
|
||||
|
||||
let (video_uuid, start_time, end_time, _metadata) = chunk;
|
||||
let (file_uuid, start_time, end_time, _metadata) = chunk;
|
||||
|
||||
let persons_query = r#"
|
||||
SELECT
|
||||
@@ -1300,7 +1299,7 @@ async fn get_chunk_persons(
|
||||
LEAST(pa.end_time, $3) - GREATEST(pa.start_time, $2) as overlap_duration
|
||||
FROM person_appearances pa
|
||||
JOIN person_identities pi ON pa.person_id = pi.person_id
|
||||
WHERE pa.video_uuid = $1
|
||||
WHERE pa.file_uuid = $1
|
||||
AND pa.start_time < $3
|
||||
AND pa.end_time > $2
|
||||
ORDER BY overlap_duration DESC
|
||||
@@ -1308,7 +1307,7 @@ async fn get_chunk_persons(
|
||||
|
||||
let persons: Vec<ChunkPersonInfo> =
|
||||
match sqlx::query_as::<_, (String, Option<String>, f64, f64)>(persons_query)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.bind(start_time)
|
||||
.bind(end_time)
|
||||
.fetch_all(db.pool())
|
||||
@@ -1349,15 +1348,15 @@ async fn get_person_thumbnail(
|
||||
// 1. Locate the face_clustered.json file
|
||||
let json_path = format!(
|
||||
"output/{}/{}_face_clustered.json",
|
||||
query.video_uuid, query.video_uuid
|
||||
query.file_uuid, query.file_uuid
|
||||
);
|
||||
let json_path2 = format!(
|
||||
"output/{}/{}.face_clustered.json",
|
||||
query.video_uuid, query.video_uuid
|
||||
query.file_uuid, query.file_uuid
|
||||
);
|
||||
|
||||
// Fallback path if the naming convention is slightly different
|
||||
let fallback_path = format!("output/{}/face_clustered.json", query.video_uuid);
|
||||
let fallback_path = format!("output/{}/face_clustered.json", query.file_uuid);
|
||||
|
||||
let path = if std::path::Path::new(&json_path).exists() {
|
||||
json_path
|
||||
@@ -1370,7 +1369,7 @@ async fn get_person_thumbnail(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!(
|
||||
"Face data not found for video: {}. Tried: {}, {}, {}",
|
||||
query.video_uuid, json_path, json_path2, fallback_path
|
||||
query.file_uuid, json_path, json_path2, fallback_path
|
||||
),
|
||||
));
|
||||
};
|
||||
@@ -1418,7 +1417,7 @@ async fn get_person_thumbnail(
|
||||
let (timestamp, face) = detections[index];
|
||||
|
||||
// 3. Locate the video file
|
||||
let video_path = format!("output/{}/{}.mp4", query.video_uuid, query.video_uuid);
|
||||
let video_path = format!("output/{}/{}.mp4", query.file_uuid, query.file_uuid);
|
||||
if !std::path::Path::new(&video_path).exists() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1489,12 +1488,12 @@ async fn create_person_identity(
|
||||
|
||||
let query = r#"
|
||||
INSERT INTO person_identities (
|
||||
person_id, video_uuid, face_identity_id, speaker_id,
|
||||
person_id, file_uuid, face_identity_id, speaker_id,
|
||||
name, metadata, confidence
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING
|
||||
id, person_id, face_identity_id, speaker_id,
|
||||
video_uuid, confidence, name, metadata,
|
||||
file_uuid, confidence, name, metadata,
|
||||
first_appearance_time, last_appearance_time,
|
||||
total_appearance_duration, appearance_count,
|
||||
created_at, updated_at, is_confirmed
|
||||
@@ -1502,11 +1501,11 @@ async fn create_person_identity(
|
||||
|
||||
let person: PersonIdentity = sqlx::query_as(query)
|
||||
.bind(&person_id)
|
||||
.bind(&request.video_uuid)
|
||||
.bind(&request.file_uuid)
|
||||
.bind(&request.face_identity_id)
|
||||
.bind(&request.speaker_id)
|
||||
.bind(&request.name)
|
||||
.bind(&request.metadata.unwrap_or(serde_json::json!({})))
|
||||
.bind(serde_json::to_string(&request.metadata.unwrap_or(serde_json::json!({}))).unwrap())
|
||||
.bind(0.0)
|
||||
.fetch_one(db.pool())
|
||||
.await?;
|
||||
@@ -1516,13 +1515,13 @@ async fn create_person_identity(
|
||||
|
||||
async fn auto_match_face_speaker(
|
||||
db: &PostgresDb,
|
||||
video_uuid: &str,
|
||||
file_uuid: &str,
|
||||
threshold: f64,
|
||||
) -> Result<Vec<PersonMatch>, anyhow::Error> {
|
||||
let query = "SELECT * FROM auto_match_face_speaker($1, $2)";
|
||||
|
||||
let matches: Vec<PersonMatch> = sqlx::query_as(query)
|
||||
.bind(video_uuid)
|
||||
.bind(file_uuid)
|
||||
.bind(threshold)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
@@ -1548,9 +1547,9 @@ async fn auto_identify_persons(
|
||||
// 1. Load face_clustered.json
|
||||
let clustered_path = format!(
|
||||
"output/{}/{}.face_clustered.json",
|
||||
request.video_uuid, request.video_uuid
|
||||
request.file_uuid, request.file_uuid
|
||||
);
|
||||
let fallback_path = format!("output/{}/face_clustered.json", request.video_uuid);
|
||||
let fallback_path = format!("output/{}/face_clustered.json", request.file_uuid);
|
||||
let path = if std::path::Path::new(&clustered_path).exists() {
|
||||
clustered_path
|
||||
} else if std::path::Path::new(&fallback_path).exists() {
|
||||
@@ -1560,7 +1559,7 @@ async fn auto_identify_persons(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!(
|
||||
"face_clustered.json not found for video: {}",
|
||||
request.video_uuid
|
||||
request.file_uuid
|
||||
),
|
||||
));
|
||||
};
|
||||
@@ -1615,7 +1614,7 @@ async fn auto_identify_persons(
|
||||
// 3. Load ASRX from chunks
|
||||
let asrx_query = "SELECT chunk_id, content::text FROM chunks WHERE uuid = $1 AND chunk_type = 'trace' AND chunk_id LIKE 'trace_asrx_%'";
|
||||
let asrx_chunks: Vec<(String, String)> = match sqlx::query_as(asrx_query)
|
||||
.bind(&request.video_uuid)
|
||||
.bind(&request.file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1631,7 +1630,7 @@ async fn auto_identify_persons(
|
||||
// Also check sentence chunks for speaker_id
|
||||
let sentence_query = "SELECT content::text FROM chunks WHERE uuid = $1 AND chunk_type = 'sentence' AND content ? 'speaker_id'";
|
||||
let sentence_chunks: Vec<String> = match sqlx::query_scalar(sentence_query)
|
||||
.bind(&request.video_uuid)
|
||||
.bind(&request.file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -1801,7 +1800,7 @@ async fn list_persons(
|
||||
}
|
||||
};
|
||||
|
||||
let video_uuid = query.video_uuid.replace("'", "''");
|
||||
let file_uuid = query.file_uuid.replace("'", "''");
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
@@ -1811,25 +1810,25 @@ async fn list_persons(
|
||||
let (sql, count_sql) = if has_speaker {
|
||||
if min_appearances > 0 {
|
||||
(
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE video_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", video_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1", video_uuid),
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", file_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1", file_uuid),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE video_uuid = '{}' AND speaker_id IS NOT NULL ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", video_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}' AND speaker_id IS NOT NULL", video_uuid),
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", file_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL", file_uuid),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if min_appearances > 0 {
|
||||
(
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE video_uuid = '{}' AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", video_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}' AND appearance_count >= $1", video_uuid),
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", file_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}' AND appearance_count >= $1", file_uuid),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE video_uuid = '{}' ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", video_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}'", video_uuid),
|
||||
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", file_uuid),
|
||||
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}'", file_uuid),
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -1925,7 +1924,7 @@ async fn merge_persons(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(request): Json<MergePersonsRequest>,
|
||||
) -> Result<Json<MergePersonsResponse>, (StatusCode, String)> {
|
||||
let video_uuid = &request.video_uuid;
|
||||
let file_uuid = &request.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -2150,7 +2149,7 @@ async fn undo_merge(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(request): Json<UndoMergeRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// video_uuid is validated through merge_history lookup
|
||||
// file_uuid is validated through merge_history lookup
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -2285,7 +2284,10 @@ async fn undo_merge(
|
||||
.bind(source_duration)
|
||||
.bind(source_first)
|
||||
.bind(source_last)
|
||||
.bind(&serde_json::json!({"restored_from_merge": _merge_id}))
|
||||
.bind(
|
||||
serde_json::to_string(&serde_json::json!({"restored_from_merge": _merge_id}))
|
||||
.unwrap(),
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
@@ -2333,14 +2335,14 @@ async fn undo_merge(
|
||||
/// Get merge history
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergeHistoryQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn get_merge_history(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Query(query): Query<MergeHistoryQuery>,
|
||||
) -> Result<Json<MergeHistoryResponse>, (StatusCode, String)> {
|
||||
let _video_uuid = &query.video_uuid;
|
||||
let _file_uuid = &query.file_uuid;
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
@@ -2412,16 +2414,16 @@ async fn get_similar_persons(
|
||||
}
|
||||
};
|
||||
|
||||
let video_uuid = query.video_uuid;
|
||||
let file_uuid = query.file_uuid;
|
||||
let threshold = query.threshold.unwrap_or(0.5);
|
||||
let limit = query.limit.unwrap_or(10);
|
||||
|
||||
// Find the speaker_id of the requested person
|
||||
let get_speaker_query =
|
||||
"SELECT speaker_id FROM person_identities WHERE person_id = $1 AND video_uuid = $2";
|
||||
"SELECT speaker_id FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
|
||||
let current_speaker_id: Option<String> = match sqlx::query_scalar(get_speaker_query)
|
||||
.bind(&person_id)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -2437,7 +2439,7 @@ async fn get_similar_persons(
|
||||
let results = match current_speaker_id {
|
||||
Some(sid) => {
|
||||
// Find others with same speaker_id
|
||||
let similar_query = "SELECT person_id, name, speaker_id, appearance_count, first_appearance_time, last_appearance_time FROM person_identities WHERE speaker_id = $1 AND person_id != $2 AND video_uuid = $3 ORDER BY appearance_count DESC LIMIT $4";
|
||||
let similar_query = "SELECT person_id, name, speaker_id, appearance_count, first_appearance_time, last_appearance_time FROM person_identities WHERE speaker_id = $1 AND person_id != $2 AND file_uuid = $3 ORDER BY appearance_count DESC LIMIT $4";
|
||||
let rows: Vec<(
|
||||
String,
|
||||
Option<String>,
|
||||
@@ -2448,7 +2450,7 @@ async fn get_similar_persons(
|
||||
)> = match sqlx::query_as(similar_query)
|
||||
.bind(&sid)
|
||||
.bind(&person_id)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.bind(limit)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
@@ -2509,13 +2511,13 @@ async fn get_person_suggestions(
|
||||
// 1. Naming suggestions: Persons with NULL name but high appearance count.
|
||||
// 2. Merge suggestions: Persons sharing the same speaker_id.
|
||||
|
||||
let video_uuid = req.video_uuid;
|
||||
let file_uuid = req.file_uuid;
|
||||
|
||||
// Naming suggestions
|
||||
let naming_query = "SELECT person_id, name, speaker_id, appearance_count FROM person_identities WHERE video_uuid = $1 AND (name IS NULL OR name = person_id) AND appearance_count > 50 ORDER BY appearance_count DESC LIMIT 10";
|
||||
let naming_query = "SELECT person_id, name, speaker_id, appearance_count FROM person_identities WHERE file_uuid = $1 AND (name IS NULL OR name = person_id) AND appearance_count > 50 ORDER BY appearance_count DESC LIMIT 10";
|
||||
let naming_rows: Vec<(String, Option<String>, Option<String>, i32)> =
|
||||
match sqlx::query_as(naming_query)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -2538,9 +2540,9 @@ async fn get_person_suggestions(
|
||||
}
|
||||
|
||||
// Merge suggestions (Speaker overlap)
|
||||
let merge_query = "SELECT person_id, speaker_id, appearance_count FROM person_identities WHERE video_uuid = $1 AND speaker_id IS NOT NULL ORDER BY speaker_id, appearance_count DESC";
|
||||
let merge_query = "SELECT person_id, speaker_id, appearance_count FROM person_identities WHERE file_uuid = $1 AND speaker_id IS NOT NULL ORDER BY speaker_id, appearance_count DESC";
|
||||
let merge_rows: Vec<(String, String, i32)> = match sqlx::query_as(merge_query)
|
||||
.bind(&video_uuid)
|
||||
.bind(&file_uuid)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
@@ -2644,14 +2646,14 @@ async fn confirm_person_suggestion(
|
||||
/// Request to unbind speaker from person
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UnbindSpeakerRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Request to reassign speaker to person
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReassignSpeakerRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub speaker_id: String,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
@@ -2659,7 +2661,7 @@ pub struct ReassignSpeakerRequest {
|
||||
/// Request to remove a specific appearance
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RemoveAppearanceRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub appearance_id: i32,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
@@ -2667,7 +2669,7 @@ pub struct RemoveAppearanceRequest {
|
||||
/// Request to reassign appearance to another person
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReassignAppearanceRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub appearance_id: i32,
|
||||
pub target_person_id: String,
|
||||
pub reason: Option<String>,
|
||||
@@ -2676,7 +2678,7 @@ pub struct ReassignAppearanceRequest {
|
||||
/// Request to split a person into two
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SplitPersonRequest {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub new_person_id: String,
|
||||
pub appearance_ids_to_move: Vec<i32>,
|
||||
pub new_person_name: Option<String>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,6 @@
|
||||
use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tracing;
|
||||
|
||||
use crate::core::db::PostgresDb;
|
||||
|
||||
// --- Request / Response Structures ---
|
||||
|
||||
|
||||
1064
src/api/server.rs
1064
src/api/server.rs
File diff suppressed because it is too large
Load Diff
335
src/api/snapshot_api.rs
Normal file
335
src/api/snapshot_api.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::processor::snapshot_agent::SnapshotAgent;
|
||||
use crate::core::storage::snapshot_manager::SnapshotManager;
|
||||
|
||||
pub fn snapshot_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots",
|
||||
get(get_file_snapshots).post(generate_file_snapshots),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots/status",
|
||||
get(get_file_snapshot_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots/migrate",
|
||||
post(migrate_file_snapshots),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots/teardown",
|
||||
post(teardown_file_snapshots),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/identities/:uuid/snapshots",
|
||||
get(get_identity_snapshots).post(generate_identity_snapshots),
|
||||
)
|
||||
}
|
||||
|
||||
// --- File Snapshot Endpoints ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileSnapshotsResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub tier: String,
|
||||
pub hits: u64,
|
||||
pub types: Vec<String>,
|
||||
}
|
||||
|
||||
async fn get_file_snapshots(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<FileSnapshotsResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
|
||||
let hits = state
|
||||
.redis_cache
|
||||
.get_snapshot_hits(&uuid)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let tier = SnapshotManager::compute_tier(hits);
|
||||
let types = manager.list_snapshot_types(&uuid);
|
||||
|
||||
state.redis_cache.update_last_access(&uuid).await.ok();
|
||||
|
||||
Ok(Json(FileSnapshotsResponse {
|
||||
success: true,
|
||||
file_uuid: uuid,
|
||||
tier: tier.to_string(),
|
||||
hits,
|
||||
types,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SnapshotStatusResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub status: String,
|
||||
pub progress: Option<f32>,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
async fn get_file_snapshot_status(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<SnapshotStatusResponse>, (StatusCode, String)> {
|
||||
let status_json = state
|
||||
.redis_cache
|
||||
.get_snapshot_status(&uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let status: String = status_json
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("cold")
|
||||
.to_string();
|
||||
|
||||
let progress: Option<f32> = status_json
|
||||
.get("progress")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32);
|
||||
|
||||
let hits = state
|
||||
.redis_cache
|
||||
.get_snapshot_hits(&uuid)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let tier = SnapshotManager::compute_tier(hits);
|
||||
|
||||
Ok(Json(SnapshotStatusResponse {
|
||||
success: true,
|
||||
file_uuid: uuid,
|
||||
status,
|
||||
progress,
|
||||
tier: tier.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GenerateSnapshotRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub snapshot_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerateSnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn generate_file_snapshots(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Json(req): Json<GenerateSnapshotRequest>,
|
||||
) -> Result<Json<GenerateSnapshotResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
let agent = SnapshotAgent::default();
|
||||
|
||||
manager
|
||||
.ensure_file_dirs(&uuid)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
state
|
||||
.redis_cache
|
||||
.set_snapshot_status(&uuid, "generating", Some(0.0))
|
||||
.await
|
||||
.map_err(|e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let snapshot_type = req.snapshot_type.as_deref().unwrap_or("faces");
|
||||
|
||||
tracing::info!(
|
||||
"Starting snapshot generation for file_uuid={}, type={}",
|
||||
uuid,
|
||||
snapshot_type
|
||||
);
|
||||
|
||||
match agent.generate_file_snapshots(&uuid, snapshot_type).await {
|
||||
Ok(_) => {
|
||||
state
|
||||
.redis_cache
|
||||
.set_snapshot_status(&uuid, "ready", Some(1.0))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!("Snapshot generation completed for file_uuid={}", uuid);
|
||||
Ok(Json(GenerateSnapshotResponse {
|
||||
success: true,
|
||||
message: format!("Snapshot generation completed for type: {}", snapshot_type),
|
||||
file_uuid: uuid,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
state
|
||||
.redis_cache
|
||||
.set_snapshot_status(&uuid, "failed", None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::error!("Snapshot generation failed for file_uuid={}: {}", uuid, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Snapshot generation failed: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MigrateSnapshotRequest {
|
||||
pub parent_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MigrateSnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub file_uuid: String,
|
||||
pub migrated_types: Vec<String>,
|
||||
}
|
||||
|
||||
async fn migrate_file_snapshots(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Json(req): Json<MigrateSnapshotRequest>,
|
||||
) -> Result<Json<MigrateSnapshotResponse>, (StatusCode, String)> {
|
||||
let agent = SnapshotAgent::default();
|
||||
|
||||
tracing::info!(
|
||||
"Starting snapshot migration from parent_uuid={} to file_uuid={}",
|
||||
req.parent_uuid,
|
||||
uuid
|
||||
);
|
||||
|
||||
match agent.migrate_snapshots(&uuid, &req.parent_uuid).await {
|
||||
Ok(migrated) => {
|
||||
state
|
||||
.redis_cache
|
||||
.set_migrate_hint(&uuid, &req.parent_uuid, migrated.len() as u64)
|
||||
.await
|
||||
.map_err(|e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(MigrateSnapshotResponse {
|
||||
success: true,
|
||||
message: format!("Migrated {} snapshot types", migrated.len()),
|
||||
file_uuid: uuid,
|
||||
migrated_types: migrated,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Snapshot migration failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Migration failed: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identity Snapshot Endpoints ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentitySnapshotsResponse {
|
||||
pub success: bool,
|
||||
pub identity_uuid: String,
|
||||
pub has_reference: bool,
|
||||
pub face_count: usize,
|
||||
}
|
||||
|
||||
async fn get_identity_snapshots(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<IdentitySnapshotsResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
|
||||
let identity_dir = manager.identity_snapshot_dir(&uuid);
|
||||
let has_reference = identity_dir.join("reference.jpg").exists();
|
||||
|
||||
let face_count = if identity_dir.join("faces").exists() {
|
||||
std::fs::read_dir(identity_dir.join("faces"))
|
||||
.map(|entries| entries.flatten().count())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(Json(IdentitySnapshotsResponse {
|
||||
success: true,
|
||||
identity_uuid: uuid,
|
||||
has_reference,
|
||||
face_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerateIdentitySnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub identity_uuid: String,
|
||||
}
|
||||
|
||||
async fn generate_identity_snapshots(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<GenerateIdentitySnapshotResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
|
||||
manager
|
||||
.ensure_identity_dirs(&uuid)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!("Snapshot generation requested for identity_uuid={}", uuid);
|
||||
|
||||
Ok(Json(GenerateIdentitySnapshotResponse {
|
||||
success: true,
|
||||
message: "Identity snapshot directories created".to_string(),
|
||||
identity_uuid: uuid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TeardownSnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn teardown_file_snapshots(
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<TeardownSnapshotResponse>, (StatusCode, String)> {
|
||||
let agent = SnapshotAgent::default();
|
||||
|
||||
tracing::info!("Manual teardown requested for file_uuid={}", uuid);
|
||||
|
||||
match agent.auto_tear_down(&uuid).await {
|
||||
Ok(_) => Ok(Json(TeardownSnapshotResponse {
|
||||
success: true,
|
||||
message: "Snapshot teardown completed".to_string(),
|
||||
file_uuid: uuid,
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("Snapshot teardown failed for file_uuid={}: {}", uuid, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Teardown failed: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ pub struct FrameResult {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PersonSearchQuery {
|
||||
pub video_uuid: String,
|
||||
pub file_uuid: String,
|
||||
pub query: Option<String>,
|
||||
pub min_appearances: Option<i32>,
|
||||
pub max_age: Option<i32>, // New filter for "children"
|
||||
|
||||
@@ -9,8 +9,6 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::db::Database;
|
||||
|
||||
// --- Request / Response Structures ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -24,7 +22,7 @@ pub struct WhoQuery {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WhoCandidatesRequest {
|
||||
pub query: String,
|
||||
pub video_uuid: Option<String>,
|
||||
pub file_uuid: Option<String>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ pub async fn get_who_candidates(
|
||||
let query_str = format!("%{}%", req.query);
|
||||
|
||||
let results = db
|
||||
.search_person_candidates(&query_str, &req.video_uuid, limit)
|
||||
.search_person_candidates(&query_str, &req.file_uuid, limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user