fix: ASRX duplication, TKG edges, trace ingest, and add pipeline progress publishing

- ASRX handler no longer stores duplicate 'asr' pre_chunks
- Pre_chunks storage made idempotent (delete-before-insert)
- Rule 1 + trace_ingest changed to query 'asrx' not 'asr'
- Trace chunks removed (dynamic from TKG/Qdrant)
- TKG scroll_face_points fixed: trace_id >= 1 (not == 1)
- TKG AsrxSegmentEntry: start/end -> start_time/end_time (match ASRX JSON)
- Unregister error handling: log instead of silent discard
- Add publish_pipeline_progress calls at each pipeline stage
  (processors, rule1, face_trace, identity_agent, TKG, rule2, completion)
This commit is contained in:
Accusys
2026-07-02 10:43:46 +08:00
parent d791d138f2
commit 3eabd45882
65 changed files with 9481 additions and 3856 deletions

View File

@@ -7,6 +7,7 @@ use axum::{
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::core::db::PostgresDb;
@@ -73,6 +74,7 @@ struct TraceInfo {
duration_sec: f64,
avg_confidence: f64,
sample_face_id: Option<String>,
thumbnail_url: String,
}
#[derive(Debug, Serialize)]
@@ -118,46 +120,76 @@ async fn list_traces_sorted(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let query = format!(
"SELECT tt.*, fd.id AS sample_face_id FROM (
SELECT trace_id::int AS trace_id,
COUNT(*) AS face_count,
MIN(frame_number)::bigint AS start_frame,
MAX(frame_number)::bigint AS end_frame,
(MAX(frame_number) - MIN(frame_number))::float8 AS duration_sec,
AVG(confidence)::float8 AS avg_confidence
FROM {}
WHERE file_uuid = $1 AND trace_id IS NOT NULL
AND confidence >= $5 AND confidence <= $6
GROUP BY trace_id
HAVING COUNT(*) >= $2
ORDER BY {}
LIMIT $3 OFFSET $4
) tt
LEFT JOIN LATERAL (
SELECT id FROM {}
WHERE trace_id = tt.trace_id AND file_uuid = $1
ORDER BY confidence DESC LIMIT 1
) fd ON true",
crate::core::db::schema::table_name("face_detections"),
order_clause,
crate::core::db::schema::table_name("face_detections"),
);
// Get face points from Qdrant _faces
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
use std::collections::HashMap;
let rows: Vec<(i32, i64, i64, i64, f64, f64, Option<i32>)> = sqlx::query_as(&query)
.bind(&file_uuid)
.bind(min_faces)
.bind(effective_limit)
.bind(db_offset)
.bind(min_confidence)
.bind(max_confidence)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let qdrant = QdrantDb::new();
let face_filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}}
]
});
let points = qdrant.scroll_all_points("_faces", face_filter, 2000).await.unwrap_or_default();
let traces: Vec<TraceInfo> = rows
// Aggregate by trace_id
struct TraceAgg {
face_count: i64,
start_frame: i64,
end_frame: i64,
avg_confidence: f64,
sum_confidence: f64,
}
let mut trace_data: HashMap<i32, TraceAgg> = HashMap::new();
for point in &points {
let payload = &point["payload"];
let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32;
let frame = payload["frame"].as_i64().unwrap_or(0);
let confidence = payload["confidence"].as_f64().unwrap_or(0.5);
if confidence < min_confidence || confidence > max_confidence {
continue;
}
let entry = trace_data.entry(trace_id).or_insert(TraceAgg {
face_count: 0,
start_frame: i64::MAX,
end_frame: i64::MIN,
avg_confidence: 0.0,
sum_confidence: 0.0,
});
entry.face_count += 1;
entry.start_frame = entry.start_frame.min(frame);
entry.end_frame = entry.end_frame.max(frame);
entry.sum_confidence += confidence;
}
// Filter by min_faces and sort
let mut traces_vec: Vec<(i32, i64, i64, i64, f64, f64)> = trace_data.into_iter()
.filter(|(_, agg)| agg.face_count >= min_faces)
.map(|(tid, agg)| {
let duration = (agg.end_frame - agg.start_frame) as f64;
let avg_conf = if agg.face_count > 0 { agg.sum_confidence / agg.face_count as f64 } else { 0.0 };
(tid, agg.face_count, agg.start_frame, agg.end_frame, duration, avg_conf)
})
.collect();
match order_clause {
"face_count DESC" => traces_vec.sort_by(|a, b| b.1.cmp(&a.1)),
"duration_sec DESC" => traces_vec.sort_by(|a, b| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal)),
_ => traces_vec.sort_by(|a, b| a.2.cmp(&b.2)),
}
// Apply pagination
let total_traces = traces_vec.len() as i64;
let total_faces: i64 = points.len() as i64;
let traces_vec: Vec<_> = traces_vec.into_iter().skip(db_offset as usize).take(effective_limit as usize).collect();
let traces: Vec<TraceInfo> = traces_vec
.into_iter()
.map(|(tid, fc, sf, ef, dur, conf, fid)| TraceInfo {
.map(|(tid, fc, sf, ef, dur, conf)| TraceInfo {
trace_id: tid,
face_count: fc,
start_frame: sf,
@@ -166,19 +198,11 @@ async fn list_traces_sorted(
end_time: ef as f64 / fps,
duration_sec: dur / fps,
avg_confidence: conf,
sample_face_id: fid.map(|v| v.to_string()),
sample_face_id: None,
thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid),
})
.collect();
let (total_traces, total_faces): (i64, i64) = sqlx::query_as(
&format!("SELECT COUNT(DISTINCT trace_id), COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id IS NOT NULL",
crate::core::db::schema::table_name("face_detections"))
)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(TracesResponse {
success: true,
file_uuid,
@@ -260,55 +284,57 @@ async fn list_trace_faces(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let total_detected: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
crate::core::db::schema::table_name("face_detections")
))
.bind(&file_uuid)
.bind(trace_id)
.fetch_one(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Get face points from Qdrant _faces for this trace
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let rows: Vec<(
i32,
i64,
Option<i32>,
Option<i32>,
Option<i32>,
Option<i32>,
f32,
)> = sqlx::query_as(&format!(
"SELECT id, frame_number, x, y, width, height, confidence::float4 \
FROM {} WHERE file_uuid = $1 AND trace_id = $2 \
ORDER BY frame_number ASC LIMIT $3 OFFSET $4",
crate::core::db::schema::table_name("face_detections")
))
.bind(&file_uuid)
.bind(trace_id)
.bind(limit)
.bind(offset)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let qdrant = QdrantDb::new();
let trace_filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": trace_id}}
]
});
let points = qdrant.scroll_all_points("_faces", trace_filter, 1000).await.unwrap_or_default();
let total_detected: i64 = points.len() as i64;
// Apply pagination
let paged: Vec<_> = points.into_iter().skip(offset as usize).take(limit as usize).collect();
let mut faces: Vec<TraceFaceItem> = Vec::new();
for (i, (id, frame, x, y, w, h, conf)) in rows.iter().enumerate() {
for (i, point) in paged.iter().enumerate() {
let payload = &point["payload"];
let frame = payload["frame"].as_i64().unwrap_or(0);
let bbox = &payload["bbox"];
let x = bbox["x"].as_f64().unwrap_or(0.0) as i32;
let y = bbox["y"].as_f64().unwrap_or(0.0) as i32;
let w = bbox["width"].as_f64().unwrap_or(0.0) as i32;
let h = bbox["height"].as_f64().unwrap_or(0.0) as i32;
let conf = payload["confidence"].as_f64().unwrap_or(0.5) as f32;
let id = i as i32;
let cur = (x, y, w, h);
// Add interpolated frames between previous and current detection
if interpolate && i > 0 {
let prev = &rows[i - 1];
let prev_frame = prev.1;
let prev_point = &paged[i - 1];
let prev_payload = &prev_point["payload"];
let prev_bbox = &prev_payload["bbox"];
let prev_frame = prev_payload["frame"].as_i64().unwrap_or(0);
let prev_x = prev_bbox["x"].as_f64().unwrap_or(0.0) as i32;
let prev_y = prev_bbox["y"].as_f64().unwrap_or(0.0) as i32;
let prev_w = prev_bbox["width"].as_f64().unwrap_or(0.0) as i32;
let prev_h = prev_bbox["height"].as_f64().unwrap_or(0.0) as i32;
let gap = frame - prev_frame;
if gap > 1 {
for mid in 1..gap {
let t = mid as f64 / gap as f64;
let mid_x = lerp_i32(prev.2, *x, t);
let mid_y = lerp_i32(prev.3, *y, t);
let mid_w = lerp_i32(prev.4, *w, t);
let mid_h = lerp_i32(prev.5, *h, t);
let mid_x = lerp_i32(Some(prev_x), Some(x), t).unwrap_or(0);
let mid_y = lerp_i32(Some(prev_y), Some(y), t).unwrap_or(0);
let mid_w = lerp_i32(Some(prev_w), Some(w), t).unwrap_or(0);
let mid_h = lerp_i32(Some(prev_h), Some(h), t).unwrap_or(0);
let mid_frame = prev_frame + mid;
let mt = (mid_frame as f64 / fps * 10.0).round() / 10.0;
faces.push(TraceFaceItem {
@@ -317,10 +343,10 @@ async fn list_trace_faces(
end_frame: mid_frame,
start_time: mt,
end_time: mt,
x: mid_x,
y: mid_y,
width: mid_w,
height: mid_h,
x: Some(mid_x),
y: Some(mid_y),
width: Some(mid_w),
height: Some(mid_h),
confidence: 0.0,
interpolated: true,
});
@@ -329,19 +355,19 @@ async fn list_trace_faces(
}
// Add the real detection
let frame_val = *frame;
let frame_val = frame;
let ft = (frame_val as f64 / fps * 10.0).round() / 10.0;
faces.push(TraceFaceItem {
id: *id,
id,
start_frame: frame_val,
end_frame: frame_val,
start_time: ft,
end_time: ft,
x: *x,
y: *y,
width: *w,
height: *h,
confidence: *conf as f64,
x: Some(x),
y: Some(y),
width: Some(w),
height: Some(h),
confidence: conf as f64,
interpolated: false,
});
}
@@ -413,7 +439,8 @@ where
F: Fn(anyhow::Error) -> T,
{
use crate::core::db::schema;
let fd_table = schema::table_name("face_detections");
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let video_table = schema::table_name("videos");
let fps: f64 = sqlx::query_scalar(&format!(
@@ -426,15 +453,16 @@ where
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?
.unwrap_or(25.0);
let face_count: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
fd_table
))
.bind(file_uuid)
.bind(trace_id)
.fetch_one(pool)
.await
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
// Get face count from Qdrant
let qdrant = QdrantDb::new();
let trace_filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": trace_id}}
]
});
let points = qdrant.scroll_all_points("_faces", trace_filter, 1000).await.unwrap_or_default();
let face_count: (i64,) = (points.len() as i64,);
struct Candidate {
frame: i64,
@@ -446,38 +474,35 @@ where
score: f64,
}
let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>(&format!(
"SELECT frame_number::bigint, x, y, width, height, confidence::float8 \
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND confidence > 0.7 \
AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true) \
ORDER BY (width::float8 * height::float8) * confidence::float8 DESC LIMIT 10",
fd_table
))
.bind(file_uuid)
.bind(trace_id)
.fetch_all(pool)
.await
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
// Get top faces by quality from Qdrant
let mut candidates: Vec<Candidate> = points.iter()
.filter_map(|p| {
let payload = &p["payload"];
let bbox = &payload["bbox"];
let w = bbox["width"].as_f64()? as i32;
let h = bbox["height"].as_f64()? as i32;
let conf = payload["confidence"].as_f64()?;
if conf <= 0.7 { return None; }
let score = (w as f64 * h as f64) * conf;
Some(Candidate {
frame: payload["frame"].as_i64().unwrap_or(0),
x: bbox["x"].as_f64()? as i32,
y: bbox["y"].as_f64()? as i32,
w,
h,
conf,
score,
})
})
.collect();
candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
let rows: Vec<_> = candidates.into_iter().take(10).collect();
if rows.is_empty() {
return Err(err_fn(anyhow::anyhow!("No suitable face found")));
}
let candidates: Vec<Candidate> = rows
.into_iter()
.map(|(frame, x, y, w, h, conf)| {
let score = (w as f64 * h as f64) * conf;
Candidate {
frame,
x,
y,
w,
h,
conf,
score,
}
})
.collect();
let candidates: Vec<Candidate> = rows;
let video_path: String = sqlx::query_scalar(&format!(
"SELECT file_path FROM {} WHERE file_uuid = $1",
@@ -759,8 +784,9 @@ async fn get_cooccurrence(
Path((file_uuid, identity_uuid_a, identity_uuid_b)): Path<(String, String, String)>,
) -> Result<Json<CoOccurResponse>, (StatusCode, Json<serde_json::Value>)> {
use crate::core::db::schema;
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
// Stage 1: Get identity names and IDs
let id_a = sqlx::query_as::<_, (i32, String)>(&format!(
@@ -803,27 +829,33 @@ async fn get_cooccurrence(
)
})?;
// Stage 2: Find first frame where both identity_ids appear
let cooccur: Option<(i64,)> = sqlx::query_as(&format!(
"SELECT MIN(fd.frame_number)::bigint FROM {} fd \
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
AND fd.frame_number IN ( \
SELECT frame_number FROM {} \
WHERE file_uuid = $1 AND identity_id = $3 \
)",
fd_table, fd_table
))
.bind(&file_uuid)
.bind(id_a.0)
.bind(id_b.0)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
// Stage 2: Find first frame where both identity_ids appear (from Qdrant _faces)
let qdrant = QdrantDb::new();
// Get frames for identity A
let filter_a = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "identity_id", "match": {"value": id_a.0}}
]
});
let points_a = qdrant.scroll_all_points("_faces", filter_a, 1000).await.unwrap_or_default();
let frames_a: std::collections::HashSet<i64> = points_a.iter()
.filter_map(|p| p["payload"]["frame"].as_i64())
.collect();
// Get frames for identity B and find first co-occurrence
let filter_b = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "identity_id", "match": {"value": id_b.0}}
]
});
let points_b = qdrant.scroll_all_points("_faces", filter_b, 1000).await.unwrap_or_default();
let cooccur: Option<(i64,)> = points_b.iter()
.filter_map(|p| p["payload"]["frame"].as_i64())
.find(|f| frames_a.contains(f))
.map(|f| (f,));
let (first_frame,) = cooccur.ok_or_else(|| {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "These two identities never appear together in this file"})))
@@ -846,24 +878,16 @@ async fn get_cooccurrence(
})?
.unwrap_or(25.0);
// Stage 3: Get trace_ids for both at this frame
let trace_a: Option<(i32,)> = sqlx::query_as(
&format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table)
)
.bind(&file_uuid).bind(first_frame).bind(id_a.0)
.fetch_optional(state.db.pool()).await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
// Stage 3: Get trace_ids for both at this frame (from Qdrant _faces)
let trace_a: Option<(i32,)> = points_a.iter()
.find(|p| p["payload"]["frame"].as_i64() == Some(first_frame))
.and_then(|p| p["payload"]["trace_id"].as_i64())
.map(|t| (t as i32,));
let trace_b: Option<(i32,)> = sqlx::query_as(
&format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table)
)
.bind(&file_uuid).bind(first_frame).bind(id_b.0)
.fetch_optional(state.db.pool()).await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
let trace_b: Option<(i32,)> = points_b.iter()
.find(|p| p["payload"]["frame"].as_i64() == Some(first_frame))
.and_then(|p| p["payload"]["trace_id"].as_i64())
.map(|t| (t as i32,));
// Stage 4: Get representative faces for both traces (reusing select_rep_face)
let rep_a = if let Some((tid,)) = trace_a {
@@ -914,22 +938,14 @@ async fn get_cooccurrence(
None
};
// Total co-occurrence frames (from TKG if available, otherwise from face_detections)
let total_cooccurrence_frames: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(DISTINCT fd.frame_number)::bigint FROM {} fd \
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
AND fd.frame_number IN ( \
SELECT frame_number FROM {} \
WHERE file_uuid = $1 AND identity_id = $3 \
)",
fd_table, fd_table
))
.bind(&file_uuid)
.bind(id_a.0)
.bind(id_b.0)
.fetch_one(state.db.pool())
.await
.unwrap_or(0);
// Total co-occurrence frames (from Qdrant _faces)
let frames_b: std::collections::HashSet<i64> = points_b.iter()
.filter_map(|p| p["payload"]["frame"].as_i64())
.collect();
let total_cooccurrence_frames: i64 = points_a.iter()
.filter_map(|p| p["payload"]["frame"].as_i64())
.filter(|f| frames_b.contains(f))
.count() as i64;
Ok(Json(CoOccurResponse {
success: true,
@@ -971,7 +987,8 @@ async fn rebuild_tkg(
use crate::core::chunk::rule2_ingest::ingest_rule2;
use tracing::info;
let result = crate::core::processor::tkg::build_tkg(&state.db, &file_uuid, &OUTPUT_DIR).await;
let redis = crate::core::db::RedisClient::new().ok();
let result = crate::core::processor::tkg::build_tkg(&state.db, &file_uuid, &OUTPUT_DIR, redis.map(Arc::new)).await;
match result {
Ok(r) => {
@@ -987,7 +1004,7 @@ async fn rebuild_tkg(
"[TKG] {} relationship edges found, triggering Rule 2 ingestion...",
total_edges
);
match ingest_rule2(state.db.pool(), &file_uuid).await {
match ingest_rule2(state.db.pool(), &file_uuid, None, None).await {
Ok(count) => info!("[TKG] Rule 2 created {} relationship chunks", count),
Err(e) => info!("[TKG] Rule 2 ingestion failed: {}", e),
}
@@ -1087,26 +1104,26 @@ async fn get_stranger_representative_face(
State(state): State<crate::api::types::AppState>,
Path((file_uuid, stranger_id)): Path<(String, i32)>,
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
let faces_table = crate::core::db::schema::table_name("face_detections");
// Get trace_id from Qdrant _faces by stranger_id
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let trace_id: i32 = sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND stranger_id = $2 LIMIT 1",
faces_table
))
.bind(&file_uuid)
.bind(stranger_id)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Stranger not found"})),
))?;
let qdrant = QdrantDb::new();
let filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "stranger_id", "match": {"value": stranger_id}}
]
});
let points = qdrant.scroll_all_points("_faces", filter, 1).await.unwrap_or_default();
let trace_id: i32 = points.first()
.and_then(|p| p["payload"]["trace_id"].as_i64())
.map(|t| t as i32)
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Stranger not found"})),
))?;
get_representative_face_inner(&state, &file_uuid, trace_id).await
}
@@ -1115,26 +1132,25 @@ async fn get_stranger_thumbnail(
State(state): State<crate::api::types::AppState>,
Path((file_uuid, stranger_id)): Path<(String, i32)>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let faces_table = crate::core::db::schema::table_name("face_detections");
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let trace_id: i32 = sqlx::query_scalar(&format!(
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND stranger_id = $2 LIMIT 1",
faces_table
))
.bind(&file_uuid)
.bind(stranger_id)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Stranger not found"})),
))?;
let qdrant = QdrantDb::new();
let filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "stranger_id", "match": {"value": stranger_id}}
]
});
let points = qdrant.scroll_all_points("_faces", filter, 1).await.unwrap_or_default();
let trace_id: i32 = points.first()
.and_then(|p| p["payload"]["trace_id"].as_i64())
.map(|t| t as i32)
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Stranger not found"})),
))?;
get_trace_thumbnail_inner(&state, &file_uuid, trace_id).await
}
@@ -1526,7 +1542,7 @@ async fn ingest_rule2(
use crate::core::embedding::Embedder;
use tracing::info;
let result = ingest_rule2(state.db.pool(), &file_uuid).await;
let result = ingest_rule2(state.db.pool(), &file_uuid, None, None).await;
match result {
Ok(rule2_chunks) => {