feat: add source prefix to search results

- Add [OCR], [ASRX], [ASRX+OCR] prefix to text_content
- Add content field to SemanticSearchResult struct
- Update SQL queries to include content field
- Helps users distinguish the source of search results
This commit is contained in:
Accusys
2026-07-19 14:06:06 +08:00
parent 5e83ee7dac
commit 87aa7e0c40
10 changed files with 3947 additions and 6 deletions
+391
View File
@@ -0,0 +1,391 @@
use axum::{
Extension, Json,
extract::{Query, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::core::db::schema;
use crate::api::middleware::UserAuth;
use crate::api::types::AppState;
// ─── Trace Profile ───
#[derive(Deserialize)]
pub struct TraceProfileQuery {
pub file_uuid: String,
pub trace_id: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TraceProfile {
pub file_uuid: String,
pub trace_id: i64,
pub name: String,
pub key_frame: Option<i64>,
pub key_face: Option<String>,
pub aliases: Option<serde_json::Value>,
pub bbox: Option<serde_json::Value>,
pub properties: serde_json::Value,
}
#[derive(Deserialize)]
pub struct UpdateTraceProfileRequest {
pub file_uuid: String,
pub trace_id: i64,
pub name: Option<String>,
pub key_frame: Option<i64>,
pub key_face: Option<String>,
pub aliases: Option<serde_json::Value>,
pub properties: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct UpdateTraceProfileGroupRequest {
pub file_uuid: String,
pub trace_ids: Vec<i64>,
pub name: String,
}
pub async fn get_trace_profile_handler(
State(state): State<AppState>,
Extension(_auth): Extension<UserAuth>,
Query(params): Query<TraceProfileQuery>,
) -> Result<Json<TraceProfile>, StatusCode> {
let tkg_table = schema::table_name("tkg_nodes");
let external_id = format!("face_track_{}", params.trace_id);
let row: Option<(String, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT label, external_id, properties FROM {} \
WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2",
tkg_table
))
.bind(&params.file_uuid)
.bind(&external_id)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[TraceProfile] DB error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let (label, _ext_id, properties) = row.ok_or(StatusCode::NOT_FOUND)?;
let key_frame = properties.get("key_frame").and_then(|v| v.as_i64());
let key_face = properties
.get("key_face")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let aliases = properties.get("aliases").cloned();
let bbox = properties.get("avg_bbox").cloned();
Ok(Json(TraceProfile {
file_uuid: params.file_uuid,
trace_id: params.trace_id,
name: label,
key_frame,
key_face,
aliases,
bbox,
properties,
}))
}
pub async fn update_trace_profile_handler(
State(state): State<AppState>,
Extension(_auth): Extension<UserAuth>,
Json(req): Json<UpdateTraceProfileRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let tkg_table = schema::table_name("tkg_nodes");
let external_id = format!("face_track_{}", req.trace_id);
// Get current node
let current: Option<(String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT label, properties FROM {} \
WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2",
tkg_table
))
.bind(&req.file_uuid)
.bind(&external_id)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[TraceProfile] DB error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let (current_label, mut current_props) = current.ok_or(StatusCode::NOT_FOUND)?;
// Build updates
let mut updates: Vec<String> = Vec::new();
if let Some(ref new_name) = req.name {
if new_name != &current_label {
updates.push(format!("label = $3"));
}
}
// Merge properties
if let Some(ref new_props) = req.properties {
if let Some(obj) = new_props.as_object() {
for (k, v) in obj {
current_props[k] = v.clone();
}
}
}
if let Some(ref key_frame) = req.key_frame {
current_props["key_frame"] = serde_json::json!(key_frame);
}
if let Some(ref key_face) = req.key_face {
current_props["key_face"] = serde_json::json!(key_face);
}
if let Some(ref aliases) = req.aliases {
current_props["aliases"] = aliases.clone();
}
if updates.is_empty() && req.properties.is_none() && req.key_frame.is_none()
&& req.key_face.is_none() && req.aliases.is_none()
{
return Ok(Json(serde_json::json!({
"success": true,
"message": "No changes"
})));
}
let mut query = format!(
"UPDATE {} SET properties = $4",
tkg_table
);
if !updates.is_empty() {
query.push_str(", ");
query.push_str(&updates.join(", "));
}
query.push_str(" WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2");
let param_idx = if updates.is_empty() { 3 } else { 4 };
query.push_str(&format!(" RETURNING id"));
let result = if !updates.is_empty() {
sqlx::query(&query)
.bind(&req.file_uuid)
.bind(&external_id)
.bind(req.name.as_ref().unwrap_or(&current_label))
.bind(&current_props)
.execute(state.db.pool())
.await
} else {
sqlx::query(&query)
.bind(&req.file_uuid)
.bind(&external_id)
.bind(&current_props)
.execute(state.db.pool())
.await
};
match result {
Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({
"success": true,
"message": "Trace profile updated",
"file_uuid": req.file_uuid,
"trace_id": req.trace_id
}))),
Ok(_) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("[TraceProfile] Update failed: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn update_trace_profile_group_handler(
State(state): State<AppState>,
Extension(_auth): Extension<UserAuth>,
Json(req): Json<UpdateTraceProfileGroupRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let tkg_table = schema::table_name("tkg_nodes");
let mut updated = 0;
for trace_id in &req.trace_ids {
let external_id = format!("face_track_{}", trace_id);
let result = sqlx::query(&format!(
"UPDATE {} SET label = $1 \
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
tkg_table
))
.bind(&req.name)
.bind(&req.file_uuid)
.bind(&external_id)
.execute(state.db.pool())
.await;
if let Ok(res) = result {
updated += res.rows_affected();
}
}
Ok(Json(serde_json::json!({
"success": true,
"message": format!("Updated {} traces in group", updated),
"file_uuid": req.file_uuid,
"updated_count": updated
})))
}
// ─── File Profile ───
#[derive(Deserialize)]
pub struct FileProfileQuery {
pub file_uuid: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileProfile {
pub file_uuid: String,
pub file_name: String,
pub file_path: String,
pub status: String,
pub duration: f64,
pub width: i32,
pub height: i32,
pub fps: f64,
pub total_frames: i64,
}
#[derive(Deserialize)]
pub struct UpdateFileProfileRequest {
pub file_uuid: String,
pub file_path: Option<String>,
pub file_name: Option<String>,
}
pub async fn get_file_profile_handler(
State(state): State<AppState>,
Extension(_auth): Extension<UserAuth>,
Query(params): Query<FileProfileQuery>,
) -> Result<Json<FileProfile>, StatusCode> {
let videos_table = schema::table_name("videos");
let row: Option<(String, String, String, String, f64, i32, i32, f64, i64)> =
sqlx::query_as(&format!(
"SELECT file_uuid, file_name, file_path, status, duration, width, height, fps, \
COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1",
videos_table
))
.bind(&params.file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[FileProfile] DB error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let (file_uuid, file_name, file_path, status, duration, width, height, fps, total_frames) =
row.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(FileProfile {
file_uuid,
file_name,
file_path,
status,
duration,
width,
height,
fps,
total_frames,
}))
}
pub async fn update_file_profile_handler(
State(state): State<AppState>,
Extension(_auth): Extension<UserAuth>,
Json(req): Json<UpdateFileProfileRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let videos_table = schema::table_name("videos");
let mut updates: Vec<String> = Vec::new();
let mut param_idx: i32 = 1;
if req.file_path.is_some() {
updates.push(format!("file_path = ${}", param_idx));
param_idx += 1;
}
if req.file_name.is_some() {
updates.push(format!("file_name = ${}", param_idx));
param_idx += 1;
}
if updates.is_empty() {
return Ok(Json(serde_json::json!({
"success": true,
"message": "No changes"
})));
}
updates.push("updated_at = CURRENT_TIMESTAMP".to_string());
let sql = format!(
"UPDATE {} SET {} WHERE file_uuid = ${}",
videos_table,
updates.join(", "),
param_idx
);
// Build the SQL and execute with correct bind order
let result = match (&req.file_path, &req.file_name) {
(Some(fp), Some(fn_)) => {
sqlx::query(&sql)
.bind(fp)
.bind(fn_)
.bind(&req.file_uuid)
.execute(state.db.pool())
.await
}
(Some(fp), None) => {
sqlx::query(&sql)
.bind(fp)
.bind(&req.file_uuid)
.execute(state.db.pool())
.await
}
(None, Some(fn_)) => {
sqlx::query(&sql)
.bind(fn_)
.bind(&req.file_uuid)
.execute(state.db.pool())
.await
}
(None, None) => unreachable!(),
};
match result {
Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({
"success": true,
"message": "File profile updated",
"file_uuid": req.file_uuid
}))),
Ok(_) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("[FileProfile] Update failed: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
// ─── Routes ───
pub fn profile_routes() -> axum::Router<AppState> {
use axum::routing::{get, put};
axum::Router::new()
.route("/api/v1/trace-profile", get(get_trace_profile_handler))
.route("/api/v1/trace-profile", put(update_trace_profile_handler))
.route(
"/api/v1/trace-profile/group",
put(update_trace_profile_group_handler),
)
.route("/api/v1/file-profile", get(get_file_profile_handler))
.route("/api/v1/file-profile", put(update_file_profile_handler))
}