feat: identity text search endpoints — /search/identity_text + /identities/search
This commit is contained in:
@@ -6,6 +6,7 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::core::db::ResourceRecord;
|
use crate::core::db::ResourceRecord;
|
||||||
@@ -33,6 +34,9 @@ pub fn identity_routes() -> Router<crate::api::server::AppState> {
|
|||||||
.route("/api/v1/resource/register", post(register_resource))
|
.route("/api/v1/resource/register", post(register_resource))
|
||||||
.route("/api/v1/resource/heartbeat", post(heartbeat_resource))
|
.route("/api/v1/resource/heartbeat", post(heartbeat_resource))
|
||||||
.route("/api/v1/resources", get(list_resources))
|
.route("/api/v1/resources", get(list_resources))
|
||||||
|
// Experiment: identity text search (non-polluting, separate endpoint)
|
||||||
|
.route("/api/v1/search/identity_text", get(search_identity_text))
|
||||||
|
.route("/api/v1/identities/search", get(search_identities_by_text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Files Endpoints ---
|
// --- Files Endpoints ---
|
||||||
@@ -632,3 +636,153 @@ async fn list_resources(
|
|||||||
data: None,
|
data: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Experiment: Identity Text Search ──────────────────────────
|
||||||
|
// Separate endpoints — do not modify existing API behavior.
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IdentityTextQuery {
|
||||||
|
uuid: String,
|
||||||
|
q: String,
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IdentityTextHit {
|
||||||
|
file_uuid: String,
|
||||||
|
chunk_id: String,
|
||||||
|
start_time: f64,
|
||||||
|
end_time: f64,
|
||||||
|
text_content: String,
|
||||||
|
identity_id: Option<i32>,
|
||||||
|
identity_name: Option<String>,
|
||||||
|
identity_source: Option<String>,
|
||||||
|
trace_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IdentityTextResponse {
|
||||||
|
success: bool,
|
||||||
|
total: i64,
|
||||||
|
results: Vec<IdentityTextHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path A: Search chunk text → associated identities
|
||||||
|
async fn search_identity_text(
|
||||||
|
State(state): State<crate::api::server::AppState>,
|
||||||
|
Query(params): Query<IdentityTextQuery>,
|
||||||
|
) -> Result<Json<IdentityTextResponse>, StatusCode> {
|
||||||
|
use crate::core::db::schema;
|
||||||
|
let chunk_table = schema::table_name("chunk");
|
||||||
|
let fd_table = schema::table_name("face_detections");
|
||||||
|
let id_table = schema::table_name("identities");
|
||||||
|
let like_q = format!("%{}%", params.q.replace('%', "%%"));
|
||||||
|
let limit = params.limit.unwrap_or(50).min(100);
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content,
|
||||||
|
fd.identity_id, i.name AS identity_name, i.source AS identity_source,
|
||||||
|
fd.trace_id
|
||||||
|
FROM {} c
|
||||||
|
LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid
|
||||||
|
AND fd.frame_number BETWEEN c.start_frame AND c.end_frame
|
||||||
|
AND fd.identity_id IS NOT NULL
|
||||||
|
LEFT JOIN {} i ON i.id = fd.identity_id
|
||||||
|
WHERE c.file_uuid = $1 AND LOWER(c.text_content) LIKE LOWER($2)
|
||||||
|
ORDER BY c.start_time
|
||||||
|
LIMIT $3"#,
|
||||||
|
chunk_table, fd_table, id_table
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, (String, String, f64, f64, String, Option<i32>, Option<String>, Option<String>, Option<i32>)>(&query)
|
||||||
|
.bind(¶ms.uuid).bind(&like_q).bind(limit)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let results: Vec<IdentityTextHit> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit {
|
||||||
|
file_uuid: fu, chunk_id: cid, start_time: st, end_time: et, text_content: txt,
|
||||||
|
identity_id: iid, identity_name: iname, identity_source: isrc, trace_id: tid,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = results.len() as i64;
|
||||||
|
Ok(Json(IdentityTextResponse { success: true, total, results }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IdentitySearchQuery {
|
||||||
|
q: String,
|
||||||
|
uuid: Option<String>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IdentitySearchHit {
|
||||||
|
identity_id: i32,
|
||||||
|
name: String,
|
||||||
|
source: Option<String>,
|
||||||
|
tmdb_id: Option<i32>,
|
||||||
|
file_uuid: String,
|
||||||
|
trace_id: Option<i32>,
|
||||||
|
chunk_id: String,
|
||||||
|
start_time: f64,
|
||||||
|
text_content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IdentitySearchResponse {
|
||||||
|
success: bool,
|
||||||
|
total: i64,
|
||||||
|
results: Vec<IdentitySearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path B: Search identity name → associated chunk text
|
||||||
|
async fn search_identities_by_text(
|
||||||
|
State(state): State<crate::api::server::AppState>,
|
||||||
|
Query(params): Query<IdentitySearchQuery>,
|
||||||
|
) -> Result<Json<IdentitySearchResponse>, StatusCode> {
|
||||||
|
use crate::core::db::schema;
|
||||||
|
let id_table = schema::table_name("identities");
|
||||||
|
let ib_table = schema::table_name("identity_bindings");
|
||||||
|
let fd_table = schema::table_name("face_detections");
|
||||||
|
let chunk_table = schema::table_name("chunk");
|
||||||
|
let like_q = format!("%{}%", params.q.replace('%', "%%"));
|
||||||
|
let limit = params.limit.unwrap_or(50).min(100);
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
r#"SELECT i.id, i.name, i.source, i.tmdb_id,
|
||||||
|
fd.file_uuid, fd.trace_id,
|
||||||
|
c.chunk_id, c.start_time, c.text_content
|
||||||
|
FROM {} i
|
||||||
|
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace'
|
||||||
|
JOIN {} fd ON fd.trace_id = ib.identity_value::int
|
||||||
|
JOIN {} c ON c.file_uuid = fd.file_uuid
|
||||||
|
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0)
|
||||||
|
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0)
|
||||||
|
WHERE i.name ILIKE $1
|
||||||
|
AND ($2::text IS NULL OR fd.file_uuid = $2)
|
||||||
|
ORDER BY i.name, c.start_time
|
||||||
|
LIMIT $3"#,
|
||||||
|
id_table, ib_table, fd_table, chunk_table
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, (i32, String, Option<String>, Option<i32>, String, Option<i32>, String, f64, String)>(&query)
|
||||||
|
.bind(&like_q).bind(¶ms.uuid).bind(limit)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let results: Vec<IdentitySearchHit> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(iid, name, src, tid, fu, trace_id, cid, st, txt)| IdentitySearchHit {
|
||||||
|
identity_id: iid, name, source: src, tmdb_id: tid,
|
||||||
|
file_uuid: fu, trace_id, chunk_id: cid, start_time: st, text_content: txt,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = results.len() as i64;
|
||||||
|
Ok(Json(IdentitySearchResponse { success: true, total, results }))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user