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:
603
src/api/identity_agent_api.rs
Normal file
603
src/api/identity_agent_api.rs
Normal file
@@ -0,0 +1,603 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::server::AppState;
|
||||
|
||||
pub fn identity_agent_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/agents/identity/analyze", post(analyze_identity))
|
||||
.route("/api/v1/agents/identity/suggest", post(suggest_merges))
|
||||
.route("/api/v1/agents/identity/status", get(get_identity_status))
|
||||
.route(
|
||||
"/api/v1/agents/suggest/clustering",
|
||||
post(suggest_clustering),
|
||||
)
|
||||
.route("/api/v1/agents/suggest/merge", post(suggest_merge))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AnalyzeIdentityRequest {
|
||||
pub file_uuid: String,
|
||||
pub auto_merge_threshold: Option<f64>,
|
||||
pub llm_threshold: Option<f64>,
|
||||
pub use_llm: Option<bool>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AnalyzeIdentityResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub identities: Vec<IdentityResult>,
|
||||
pub processing_status: IdentityProcessingStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityResult {
|
||||
pub identity_id: String,
|
||||
pub person_ids: Vec<String>,
|
||||
pub speaker_ids: Vec<String>,
|
||||
pub confidence: f64,
|
||||
pub evidence: IdentityEvidence,
|
||||
pub reasoning: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityEvidence {
|
||||
pub face_similarity: Option<f64>,
|
||||
pub speaker_overlap: f64,
|
||||
pub time_overlap: f64,
|
||||
pub frame_ratio: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityProcessingStatus {
|
||||
pub status: String,
|
||||
pub persons_analyzed: i32,
|
||||
pub identities_created: i32,
|
||||
pub merges_suggested: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuggestMergesRequest {
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuggestMergesResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub merge_suggestions: Vec<MergeSuggestion>,
|
||||
pub naming_suggestions: Vec<NamingSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MergeSuggestion {
|
||||
pub target_person_id: String,
|
||||
pub source_person_ids: Vec<String>,
|
||||
pub confidence: f64,
|
||||
pub reasons: Vec<String>,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NamingSuggestion {
|
||||
pub person_id: String,
|
||||
pub suggested_name: String,
|
||||
pub confidence: f64,
|
||||
pub reasoning: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityStatusResponse {
|
||||
pub success: bool,
|
||||
pub agent_name: String,
|
||||
pub version: String,
|
||||
pub supported_models: Vec<String>,
|
||||
pub default_thresholds: DefaultThresholds,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DefaultThresholds {
|
||||
pub auto_merge_threshold: f64,
|
||||
pub llm_threshold: f64,
|
||||
pub face_similarity_threshold: f64,
|
||||
}
|
||||
|
||||
async fn analyze_identity(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AnalyzeIdentityRequest>,
|
||||
) -> Result<Json<AnalyzeIdentityResponse>, (StatusCode, String)> {
|
||||
let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string());
|
||||
|
||||
let video_dir = PathBuf::from(&output_dir).join(&req.file_uuid);
|
||||
|
||||
let face_clustered_path = video_dir.join(format!("{}.face_clustered.json", req.file_uuid));
|
||||
let asrx_path = video_dir.join(format!("{}.asrx.json", req.file_uuid));
|
||||
|
||||
if !face_clustered_path.exists() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Face clustered data not found for video: {}", req.file_uuid),
|
||||
));
|
||||
}
|
||||
|
||||
let face_data: serde_json::Value = std::fs::read_to_string(&face_clustered_path)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read face data: {}", e),
|
||||
)
|
||||
})?
|
||||
.parse()
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse face data: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let asrx_data: Option<serde_json::Value> = if asrx_path.exists() {
|
||||
Some(
|
||||
std::fs::read_to_string(&asrx_path)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read asrx data: {}", e),
|
||||
)
|
||||
})?
|
||||
.parse()
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse asrx data: {}", e),
|
||||
)
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let persons = extract_persons_from_face_data(&face_data);
|
||||
let speakers = extract_speakers_from_asrx_data(&asrx_data);
|
||||
|
||||
let identities = analyze_person_speaker_overlap(&persons, &speakers);
|
||||
|
||||
let processing_status = IdentityProcessingStatus {
|
||||
status: "completed".to_string(),
|
||||
persons_analyzed: persons.len() as i32,
|
||||
identities_created: identities.len() as i32,
|
||||
merges_suggested: 0,
|
||||
};
|
||||
|
||||
Ok(Json(AnalyzeIdentityResponse {
|
||||
success: true,
|
||||
file_uuid: req.file_uuid.clone(),
|
||||
identities,
|
||||
processing_status,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn suggest_merges(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SuggestMergesRequest>,
|
||||
) -> Result<Json<SuggestMergesResponse>, (StatusCode, String)> {
|
||||
let analyze_req = AnalyzeIdentityRequest {
|
||||
file_uuid: req.file_uuid.clone(),
|
||||
auto_merge_threshold: Some(0.8),
|
||||
llm_threshold: Some(0.5),
|
||||
use_llm: Some(true),
|
||||
model: Some("gemma4".to_string()),
|
||||
};
|
||||
|
||||
let analyze_result = analyze_identity(State(state), Json(analyze_req)).await?;
|
||||
|
||||
let merge_suggestions: Vec<MergeSuggestion> = analyze_result
|
||||
.identities
|
||||
.iter()
|
||||
.filter(|id| id.person_ids.len() > 1)
|
||||
.map(|id| {
|
||||
let reasons = vec![
|
||||
format!(
|
||||
"Shared speaker overlap: {:.0}%",
|
||||
id.evidence.speaker_overlap * 100.0
|
||||
),
|
||||
format!(
|
||||
"Face similarity: {:.2}",
|
||||
id.evidence.face_similarity.unwrap_or(0.0)
|
||||
),
|
||||
format!("Confidence: {:.2}", id.confidence),
|
||||
];
|
||||
|
||||
MergeSuggestion {
|
||||
target_person_id: id.person_ids[0].clone(),
|
||||
source_person_ids: id.person_ids[1..].to_vec(),
|
||||
confidence: id.confidence,
|
||||
reasons,
|
||||
action: if id.confidence > 0.8 {
|
||||
"auto_apply"
|
||||
} else {
|
||||
"review_needed"
|
||||
}
|
||||
.to_string(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SuggestMergesResponse {
|
||||
success: true,
|
||||
file_uuid: req.file_uuid,
|
||||
merge_suggestions,
|
||||
naming_suggestions: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_identity_status() -> Result<Json<IdentityStatusResponse>, (StatusCode, String)> {
|
||||
Ok(Json(IdentityStatusResponse {
|
||||
success: true,
|
||||
agent_name: "Identity Agent".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
supported_models: vec!["gemma4".to_string(), "qwen3".to_string()],
|
||||
default_thresholds: DefaultThresholds {
|
||||
auto_merge_threshold: 0.8,
|
||||
llm_threshold: 0.5,
|
||||
face_similarity_threshold: 0.3,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_persons_from_face_data(face_data: &serde_json::Value) -> Vec<PersonData> {
|
||||
let mut persons = Vec::new();
|
||||
|
||||
if let Some(frames) = face_data.get("frames").and_then(|f| f.as_array()) {
|
||||
let mut person_frames_map: std::collections::HashMap<String, Vec<i32>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for frame in frames {
|
||||
if let Some(frame_num) = frame.get("frame").and_then(|f| f.as_i64()) {
|
||||
if let Some(person_id) = frame.get("person_id").and_then(|p| p.as_str()) {
|
||||
person_frames_map
|
||||
.entry(person_id.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(frame_num as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (person_id, frames) in person_frames_map {
|
||||
persons.push(PersonData {
|
||||
person_id,
|
||||
frames,
|
||||
avg_embedding: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
persons
|
||||
}
|
||||
|
||||
fn extract_speakers_from_asrx_data(asrx_data: &Option<serde_json::Value>) -> Vec<SpeakerData> {
|
||||
let mut speakers = Vec::new();
|
||||
|
||||
if let Some(data) = asrx_data {
|
||||
if let Some(segments) = data.get("segments").and_then(|s| s.as_array()) {
|
||||
let mut speaker_segments_map: std::collections::HashMap<String, Vec<(f64, f64)>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for segment in segments {
|
||||
if let Some(speaker_id) = segment.get("speaker").and_then(|s| s.as_str()) {
|
||||
let start = segment.get("start").and_then(|s| s.as_f64()).unwrap_or(0.0);
|
||||
let end = segment.get("end").and_then(|e| e.as_f64()).unwrap_or(0.0);
|
||||
|
||||
speaker_segments_map
|
||||
.entry(speaker_id.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.push((start, end));
|
||||
}
|
||||
}
|
||||
|
||||
for (speaker_id, segments) in speaker_segments_map {
|
||||
speakers.push(SpeakerData {
|
||||
speaker_id,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
speakers
|
||||
}
|
||||
|
||||
fn analyze_person_speaker_overlap(
|
||||
persons: &[PersonData],
|
||||
speakers: &[SpeakerData],
|
||||
) -> Vec<IdentityResult> {
|
||||
let mut identities = Vec::new();
|
||||
|
||||
for (i, person) in persons.iter().enumerate() {
|
||||
let identity_id = format!("identity_{}", i + 1);
|
||||
|
||||
let mut speaker_ids = Vec::new();
|
||||
let mut max_overlap: f64 = 0.0;
|
||||
|
||||
for speaker in speakers {
|
||||
let overlap_frames = calculate_overlap(person, speaker);
|
||||
let overlap_ratio = overlap_frames as f64 / person.frames.len() as f64;
|
||||
|
||||
if overlap_ratio > 0.5 {
|
||||
speaker_ids.push(speaker.speaker_id.clone());
|
||||
max_overlap = max_overlap.max(overlap_ratio);
|
||||
}
|
||||
}
|
||||
|
||||
let confidence = if speaker_ids.len() > 0 {
|
||||
0.7 + max_overlap * 0.2
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
let reasoning = if speaker_ids.len() > 0 {
|
||||
format!(
|
||||
"Person has high overlap with speakers: {}",
|
||||
speaker_ids.join(", ")
|
||||
)
|
||||
} else {
|
||||
"Person has no speaker overlap".to_string()
|
||||
};
|
||||
|
||||
identities.push(IdentityResult {
|
||||
identity_id,
|
||||
person_ids: vec![person.person_id.clone()],
|
||||
speaker_ids,
|
||||
confidence,
|
||||
evidence: IdentityEvidence {
|
||||
face_similarity: None,
|
||||
speaker_overlap: max_overlap,
|
||||
time_overlap: max_overlap,
|
||||
frame_ratio: person.frames.len() as f64 / 1000.0,
|
||||
},
|
||||
reasoning,
|
||||
});
|
||||
}
|
||||
|
||||
identities
|
||||
}
|
||||
|
||||
fn calculate_overlap(person: &PersonData, speaker: &SpeakerData) -> i32 {
|
||||
let mut overlap_count = 0;
|
||||
|
||||
for frame_num in &person.frames {
|
||||
let frame_time = *frame_num as f64 / 23.976;
|
||||
|
||||
for (start, end) in &speaker.segments {
|
||||
if frame_time >= *start && frame_time <= *end {
|
||||
overlap_count += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overlap_count
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuggestClusteringRequest {
|
||||
pub file_uuid: Option<String>,
|
||||
pub min_cluster_size: Option<usize>,
|
||||
pub similarity_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuggestClusteringResponse {
|
||||
pub success: bool,
|
||||
pub suggestions: Vec<ClusteringSuggestion>,
|
||||
pub total_unclustered: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClusteringSuggestion {
|
||||
pub cluster_id: String,
|
||||
pub face_count: usize,
|
||||
pub avg_confidence: f64,
|
||||
pub suggested_name: Option<String>,
|
||||
pub representative_face: Option<String>,
|
||||
}
|
||||
|
||||
async fn suggest_clustering(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SuggestClusteringRequest>,
|
||||
) -> Result<Json<SuggestClusteringResponse>, (StatusCode, String)> {
|
||||
let min_cluster_size = req.min_cluster_size.unwrap_or(3);
|
||||
|
||||
let file_filter = match &req.file_uuid {
|
||||
Some(uuid) => format!("AND fc.file_uuid = '{}'", uuid),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT fc.cluster_id, fc.file_uuid, fc.n_faces, fc.metadata
|
||||
FROM face_clusters fc
|
||||
WHERE fc.n_faces >= $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identities i
|
||||
WHERE i.metadata->>'cluster_id' = fc.cluster_id
|
||||
)
|
||||
{}
|
||||
ORDER BY fc.n_faces DESC
|
||||
"#,
|
||||
file_filter
|
||||
);
|
||||
|
||||
let pool = state.db.pool();
|
||||
let rows = sqlx::query(&query)
|
||||
.bind(min_cluster_size as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let suggestions: Vec<ClusteringSuggestion> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let cluster_id: String = row.get("cluster_id");
|
||||
let n_faces: i32 = row.get("n_faces");
|
||||
let metadata: serde_json::Value =
|
||||
row.try_get("metadata").unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let avg_confidence = metadata
|
||||
.get("avg_confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let representative_face = metadata
|
||||
.get("representative_face_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
ClusteringSuggestion {
|
||||
cluster_id,
|
||||
face_count: n_faces as usize,
|
||||
avg_confidence,
|
||||
suggested_name: None,
|
||||
representative_face,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_unclustered: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM face_detections fd
|
||||
WHERE fd.identity_id IS NULL
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(SuggestClusteringResponse {
|
||||
success: true,
|
||||
suggestions,
|
||||
total_unclustered: total_unclustered as usize,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuggestMergeRequest {
|
||||
pub identity_id: Option<String>,
|
||||
pub similarity_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuggestMergeResponse {
|
||||
pub success: bool,
|
||||
pub suggestions: Vec<IdentityMergeSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityMergeSuggestion {
|
||||
pub source_identity_id: String,
|
||||
pub target_identity_id: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub similarity_score: f64,
|
||||
pub shared_files: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
async fn suggest_merge(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SuggestMergeRequest>,
|
||||
) -> Result<Json<SuggestMergeResponse>, (StatusCode, String)> {
|
||||
let similarity_threshold = req.similarity_threshold.unwrap_or(0.8);
|
||||
|
||||
let identity_filter = match &req.identity_id {
|
||||
Some(id) => format!("AND i1.uuid = '{}' OR i2.uuid = '{}'", id, id),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT
|
||||
i1.uuid as source_uuid,
|
||||
i2.uuid as target_uuid,
|
||||
i1.name as source_name,
|
||||
i2.name as target_name,
|
||||
COUNT(DISTINCT fi1.file_uuid) as shared_files
|
||||
FROM identities i1
|
||||
JOIN identities i2 ON i1.id < i2.id
|
||||
LEFT JOIN file_identities fi1 ON fi1.identity_id = i1.id
|
||||
LEFT JOIN file_identities fi2 ON fi2.identity_id = i2.id AND fi1.file_uuid = fi2.file_uuid
|
||||
WHERE i1.identity_type = 'people'
|
||||
AND i2.identity_type = 'people'
|
||||
AND i1.id != i2.id
|
||||
{}
|
||||
GROUP BY i1.uuid, i2.uuid, i1.name, i2.name
|
||||
HAVING COUNT(DISTINCT fi1.file_uuid) > 0
|
||||
ORDER BY shared_files DESC
|
||||
LIMIT 50
|
||||
"#,
|
||||
identity_filter
|
||||
);
|
||||
|
||||
let pool = state.db.pool();
|
||||
let rows = sqlx::query(&query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let suggestions: Vec<IdentityMergeSuggestion> = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let shared_files: i64 = row.get("shared_files");
|
||||
if shared_files > 0 {
|
||||
let similarity = (shared_files as f64 / 10.0).min(1.0);
|
||||
if similarity >= similarity_threshold {
|
||||
Some(IdentityMergeSuggestion {
|
||||
source_identity_id: row.get("source_uuid"),
|
||||
target_identity_id: row.get("target_uuid"),
|
||||
source_name: row.get("source_name"),
|
||||
target_name: row.get("target_name"),
|
||||
similarity_score: similarity,
|
||||
shared_files: shared_files as usize,
|
||||
reason: format!(
|
||||
"Share {} file(s) - similarity: {:.1}%",
|
||||
shared_files,
|
||||
similarity * 100.0
|
||||
),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SuggestMergeResponse {
|
||||
success: true,
|
||||
suggestions,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PersonData {
|
||||
person_id: String,
|
||||
frames: Vec<i32>,
|
||||
avg_embedding: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SpeakerData {
|
||||
speaker_id: String,
|
||||
segments: Vec<(f64, f64)>,
|
||||
}
|
||||
Reference in New Issue
Block a user