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

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