diff --git a/src/faces.rs b/src/faces.rs index 3d97d07..10cd0b6 100644 --- a/src/faces.rs +++ b/src/faces.rs @@ -2107,6 +2107,8 @@ async fn update_face_handler( request: HttpRequest, path: web::Path, body: web::Json, + app_state: web::Data, + face_client: web::Data, face_dao: web::Data>, ) -> impl Responder { let context = extract_context_from_request(&request); @@ -2121,21 +2123,92 @@ async fn update_face_handler( }; let bbox_patch = body.bbox.as_ref().map(|b| (b.x, b.y, b.w, b.h)); - // bbox change → embedding becomes stale. Phase 2 only stores the new - // bbox; re-embed is a Phase 3 concern (it requires reading the image - // off disk and going back through face_client.embed). For now log a - // warning so we can spot orphan-embedding rows. - if bbox_patch.is_some() { - warn!( - "PATCH /image/faces/{}: bbox updated; embedding now stale (Phase 3 will re-embed)", - id - ); + // Bbox change → re-embed. The embedding is what auto-bind and the + // cluster suggester key on, so leaving it stale would silently + // corrupt every downstream similarity match. We crop the new bbox, + // pass it through face_client.embed, and store the fresh vector. + // Net cost: one Apollo round-trip per bbox edit (~100-500ms on + // CPU); acceptable for a manual operator action. + let mut new_embedding: Option> = None; + if let Some((bx, by, bw, bh)) = bbox_patch { + if !face_client.is_enabled() { + return HttpResponse::ServiceUnavailable() + .body("face client disabled — bbox edit requires Apollo"); + } + // Look up the current row so we know which photo to crop. + let current = { + let mut dao = face_dao.lock().expect("face dao lock"); + match dao.get_face(&span_context, id) { + Ok(Some(r)) => r, + Ok(None) => return HttpResponse::NotFound().finish(), + Err(e) => return HttpResponse::InternalServerError().body(e.to_string()), + } + }; + let library = match app_state.library_by_id(current.library_id) { + Some(l) => l.clone(), + None => { + return HttpResponse::InternalServerError().body(format!( + "face row references unknown library_id {}", + current.library_id + )); + } + }; + let abs_path = library.resolve(¤t.rel_path); + let crop_bytes = match crop_image_to_bbox(&abs_path, bx, by, bw, bh) { + Ok(b) => b, + Err(e) => { + warn!( + "PATCH /image/faces/{}: crop failed for {:?}: {:?}", + id, abs_path, e + ); + return HttpResponse::BadRequest() + .body(format!("cannot crop new bbox: {}", e)); + } + }; + let meta = DetectMeta { + content_hash: current.content_hash.clone(), + library_id: current.library_id, + rel_path: current.rel_path.clone(), + orientation: None, + model_version: Some(current.model_version.clone()), + }; + match face_client.embed(crop_bytes, meta).await { + Ok(resp) => { + if let Some(face) = resp.faces.first() { + match face.decode_embedding() { + Ok(b) => new_embedding = Some(b), + Err(e) => { + warn!( + "PATCH /image/faces/{}: bad embedding from face service: {:?}", + id, e + ); + return HttpResponse::BadGateway() + .body("invalid embedding from face service"); + } + } + } else { + return HttpResponse::UnprocessableEntity() + .body("no face in new bbox"); + } + } + Err(FaceDetectError::Permanent(e)) => { + return HttpResponse::UnprocessableEntity().body(format!("{}", e)); + } + Err(FaceDetectError::Transient(e)) => { + return HttpResponse::ServiceUnavailable().body(format!("{}", e)); + } + Err(FaceDetectError::Disabled) => { + return HttpResponse::ServiceUnavailable() + .body("face client disabled mid-flight"); + } + } } let mut dao = face_dao.lock().expect("face dao lock"); - dao.update_face(&span_context, id, person_patch, bbox_patch, None) - .map(|row| HttpResponse::Ok().json(row)) - .into_http_internal_err() + match dao.update_face(&span_context, id, person_patch, bbox_patch, new_embedding) { + Ok(row) => HttpResponse::Ok().json(row), + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + } } async fn delete_face_handler(