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:
Warren
2026-04-30 15:07:02 +08:00
parent 8f2208dd63
commit 2b23d1cfbd
148 changed files with 8553 additions and 48637 deletions

View File

@@ -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();

View File

@@ -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(&centroid_str)
.bind(cluster.size as i32)
.bind(cluster.representative_face_id.as_deref())

View 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,
}

View File

@@ -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",
&timestamp.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,
}

View 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)>,
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 ---

File diff suppressed because it is too large Load Diff

335
src/api/snapshot_api.rs Normal file
View 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),
))
}
}
}

View File

@@ -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"

View File

@@ -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| {
(