diff --git a/docs_v1.0/API_WORKSPACE/modules/08_identity_agent.md b/docs_v1.0/API_WORKSPACE/modules/08_identity_agent.md index 7fb3c5f..24b051f 100644 --- a/docs_v1.0/API_WORKSPACE/modules/08_identity_agent.md +++ b/docs_v1.0/API_WORKSPACE/modules/08_identity_agent.md @@ -65,4 +65,63 @@ curl -s -X POST "$API/api/v1/agents/identity/match-from-trace" \ ``` --- -*Updated: 2026-05-19 12:49:24* + +### `POST /api/v1/agents/identity/confirm` + +**Auth**: Required +**Scope**: file-level + +Confirm identity binding for a trace. This marks the trace as confirmed in TKG, updates face_detections, adds to _seeds, and optionally triggers Round 2 propagation. + +#### Request Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | Yes | Video file UUID | +| `trace_id` | integer | Yes | Face trace ID to confirm | +| `identity_id` | integer | Yes | Identity internal ID | +| `identity_uuid` | string | Yes | Identity UUID | +| `name` | string | Yes | Identity name | +| `propagate` | boolean | No | Auto-trigger Round 2 matching (default: true) | + +#### Example + +```bash +curl -s -X POST "$API/api/v1/agents/identity/confirm" \ + -H "Authorization: Bearer $JWT" \ + -H "Content-Type: application/json" \ + -d '{"file_uuid": "'"$FILE_UUID"'", "trace_id": 10, "identity_id": 42, "identity_uuid": "'"$IDENTITY_UUID"'", "name": "Cary Grant", "propagate": false}' +``` + +#### Response (200) + +```json +{ + "success": true, + "file_uuid": "384b0ff44aaaa1f1", + "trace_id": 10, + "identity_uuid": "a9a90105...", + "name": "Cary Grant", + "steps": { + "tkg_updated": true, + "qdrant_updated": 150, + "pg_updated": 150, + "seed_added": true + }, + "propagation": { + "matched": 5, + "message": "Propagation completed" + } +} +``` + +#### Side Effects + +1. TKG face_track node status → 'confirmed' +2. Qdrant _faces: identity_uuid added to payload +3. PG face_detections: identity_id set +4. Trace centroid added to _seeds (source='propagation') +5. Round 2 matching triggered (if propagate=true) + +--- +*Updated: 2026-06-26 00:30:00* diff --git a/scripts/confirm_identity.py b/scripts/confirm_identity.py index 63e4a11..de5bfdc 100755 --- a/scripts/confirm_identity.py +++ b/scripts/confirm_identity.py @@ -284,7 +284,7 @@ def main(): if args.json: result = batch_confirm_from_json(args.file_uuid, args.json, propagate) - elif args.trace_id and args.identity_id and args.identity_uuid and args.name: + elif args.trace_id is not None and args.identity_id is not None and args.identity_uuid and args.name: result = confirm_single_trace( args.file_uuid, args.trace_id, diff --git a/src/api/identity_agent_api.rs b/src/api/identity_agent_api.rs index a9170e9..f8bd951 100644 --- a/src/api/identity_agent_api.rs +++ b/src/api/identity_agent_api.rs @@ -31,6 +31,10 @@ pub fn identity_agent_routes() -> Router { "/api/v1/agents/identity/run", post(run_identity_handler), ) + .route( + "/api/v1/agents/identity/confirm", + post(confirm_identity_handler), + ) } #[derive(Debug, Serialize)] @@ -661,12 +665,80 @@ fn average_embeddings<'a>(embeddings: impl Iterator>) -> Vec /// Unknown: greedy stranger clustering (TH=0.40) /// Writes identity_ref/stranger_ref to Qdrant payload, TKG nodes, and face_detections. async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result { - tracing::warn!( - "[FaceMatch] Face matching disabled - FaceEmbeddingDb removed. \ - TODO: Reimplement with _faces collection for {}", - file_uuid + use crate::core::processor::executor::PythonExecutor; + use std::time::Duration; + + let executor = PythonExecutor::new()?; + + let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); + + let output_path = std::path::PathBuf::from(&output_dir) + .join(file_uuid) + .join(format!("{}.identity_match_round1.json", file_uuid)); + + std::fs::create_dir_all(output_path.parent().unwrap()).ok(); + + let scripts_dir = executor.script_dir(); + let python_path = executor.python_path(); + let script_path = scripts_dir.join("identity_matcher.py"); + + let qdrant_url = std::env::var("QDRANT_URL") + .unwrap_or_else(|_| "http://localhost:6333".to_string()); + let qdrant_api_key = std::env::var("QDRANT_API_KEY") + .unwrap_or_else(|_| "Test3200Test3200Test3200".to_string()); + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://accusys@localhost:5432/momentry".to_string()); + + let mut cmd = tokio::process::Command::new(python_path); + cmd.env("MOMENTRY_OUTPUT_DIR", &output_dir); + cmd.env("DATABASE_SCHEMA", "public"); + cmd.env("MOMENTRY_DB_SCHEMA", "public"); + cmd.env("DATABASE_URL", &db_url); + cmd.env("QDRANT_URL", &qdrant_url); + cmd.env("QDRANT_API_KEY", &qdrant_api_key); + cmd.arg(&script_path); + cmd.arg("--file-uuid").arg(file_uuid); + cmd.arg("--round").arg("1"); + cmd.arg("--mark-tkg"); + cmd.arg("--output").arg(&output_path); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + tracing::info!("[FaceMatch] Starting identity_matcher for {}", file_uuid); + + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + tracing::error!("[FaceMatch] identity_matcher failed with exit code: {:?}", output.status.code()); + tracing::error!("[FaceMatch] stderr: {}", stderr); + tracing::error!("[FaceMatch] stdout: {}", stdout); + return Ok(0); + } + + tracing::info!("[FaceMatch] stdout: {}", stdout); + + if !output_path.exists() { + tracing::info!("[FaceMatch] No matches found for {}", file_uuid); + return Ok(0); + } + + let content = std::fs::read_to_string(&output_path)?; + let result: serde_json::Value = serde_json::from_str(&content)?; + + let matched = result.get("matched").and_then(|v| v.as_i64()).unwrap_or(0) as usize; + let tkg_updated = result.get("tkg_nodes_updated").and_then(|v| v.as_i64()).unwrap_or(0) as usize; + + tracing::info!( + "[FaceMatch] Round 1 for {}: {} matches, {} TKG nodes updated", + file_uuid, matched, tkg_updated ); - Ok(0) + + Ok(matched) } /// Fallback: PostgreSQL-based matching (disabled - embedding column removed) @@ -1011,6 +1083,142 @@ async fn run_identity_handler( } } +#[derive(Debug, Deserialize)] +struct ConfirmIdentityRequest { + file_uuid: String, + trace_id: i32, + identity_id: i32, + identity_uuid: String, + name: String, + propagate: Option, +} + +#[derive(Debug, Serialize)] +struct ConfirmIdentityResponse { + success: bool, + file_uuid: String, + trace_id: i32, + identity_uuid: String, + name: String, + steps: serde_json::Value, + propagation: Option, +} + +async fn confirm_identity_handler( + State(_state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + use crate::core::processor::executor::PythonExecutor; + + let executor = PythonExecutor::new().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"success": false, "message": format!("PythonExecutor error: {}", e)})), + ) + })?; + + let scripts_dir = executor.script_dir(); + let python_path = executor.python_path(); + let script_path = scripts_dir.join("confirm_identity.py"); + + let qdrant_url = std::env::var("QDRANT_URL") + .unwrap_or_else(|_| "http://localhost:6333".to_string()); + let qdrant_api_key = std::env::var("QDRANT_API_KEY") + .unwrap_or_else(|_| "Test3200Test3200Test3200".to_string()); + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://accusys@localhost:5432/momentry".to_string()); + let db_schema = std::env::var("DATABASE_SCHEMA") + .unwrap_or_else(|_| "dev".to_string()); + + let propagate = req.propagate.unwrap_or(true); + + let mut cmd = tokio::process::Command::new(python_path); + cmd.env("DATABASE_URL", &db_url); + cmd.env("DATABASE_SCHEMA", &db_schema); + cmd.env("MOMENTRY_DB_SCHEMA", &db_schema); + cmd.env("QDRANT_URL", &qdrant_url); + cmd.env("QDRANT_API_KEY", &qdrant_api_key); + cmd.arg(&script_path); + cmd.arg("--file-uuid").arg(&req.file_uuid); + cmd.arg("--trace-id").arg(req.trace_id.to_string()); + cmd.arg("--identity-id").arg(req.identity_id.to_string()); + cmd.arg("--identity-uuid").arg(&req.identity_uuid); + cmd.arg("--name").arg(&req.name); + + if !propagate { + cmd.arg("--no-propagate"); + } + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + tracing::info!( + "[ConfirmIdentity] Starting for {} trace {} -> {} ({})", + req.file_uuid, req.trace_id, req.identity_uuid, req.name + ); + + let output = cmd.output().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"success": false, "message": format!("Command failed: {}", e)})), + ) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + tracing::error!("[ConfirmIdentity] Script failed with exit code: {:?}", output.status.code()); + tracing::error!("[ConfirmIdentity] stderr: {}", stderr); + tracing::error!("[ConfirmIdentity] stdout: {}", stdout); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "message": format!("Script failed: {}", stderr), + "stdout": stdout.to_string(), + })), + )); + } + + tracing::info!("[ConfirmIdentity] stdout: {}", stdout); + + let json_start = stdout.find('{'); + if json_start.is_none() { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "message": "No JSON output found", + "stdout": stdout.to_string(), + })), + )); + } + let json_str = &stdout[json_start.unwrap()..]; + + let result: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "message": format!("Failed to parse output: {}", e), + "stdout": stdout.to_string(), + "json_str": json_str.to_string(), + })), + ) + })?; + + Ok(Json(ConfirmIdentityResponse { + success: result.get("status").and_then(|v| v.as_str()) == Some("success"), + file_uuid: req.file_uuid, + trace_id: req.trace_id, + identity_uuid: req.identity_uuid, + name: req.name, + steps: result.get("steps").cloned().unwrap_or(serde_json::json!({})), + propagation: result.get("propagation").cloned(), + })) +} + /// Read all TMDb identities with profile photos, extract face embeddings, store in Qdrant as seeds. pub async fn generate_seed_embeddings(db: &PostgresDb) -> anyhow::Result { tracing::warn!(