feat(faces): domain entities, ports & no-op analyzer

Phase 2 increment 2:
- domain: Face, Person, BoundingBox, DetectedFace (512-d embeddings).
- ports: FaceAnalyzerPort (detect + embed from raw bytes; decodes internally
  so the application layer stays image/ML-crate agnostic) and FaceRepository
  (user-scoped face/person persistence).
- DTOs: PersonDto, FaceBoxDto.
- NoopFaceAnalyzer — reports is_ready()==false and returns no faces, so the
  whole People pipeline compiles and runs inert until the operator wires a
  real ONNX-backed analyzer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
Claude
2026-06-19 11:34:46 +00:00
parent 7cb79d41b9
commit 524e573756
8 changed files with 210 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ pub mod geo_dto;
pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod people_dto;
pub mod playlist_dto;
pub mod plugin_dto;
pub mod recent_dto;
+30
View File
@@ -0,0 +1,30 @@
//! DTOs for the People (faces) API.
use serde::Serialize;
use utoipa::ToSchema;
/// A named (or unnamed) identity cluster, with a cover photo for its tile.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PersonDto {
pub id: String,
/// `None` until the user names the person.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// File id of the cover face's photo, for the tile thumbnail.
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_file_id: Option<String>,
pub face_count: i64,
pub is_hidden: bool,
}
/// One face box within a photo (for tagging overlays in the lightbox).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FaceBoxDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub person_id: Option<String>,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
+78
View File
@@ -0,0 +1,78 @@
//! Ports for the People (faces) feature.
use async_trait::async_trait;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{DetectedFace, Face, Person};
/// Detects faces in an image and produces an aligned, L2-normalized embedding
/// for each. Takes raw encoded bytes (it decodes internally) so the
/// application layer stays decoupled from any image/ML crate.
///
/// The default implementation ([`NoopFaceAnalyzer`](crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer))
/// is a no-op that reports `is_ready() == false`; a real ONNX-backed
/// implementation is wired in when the operator provides models at runtime.
#[async_trait]
pub trait FaceAnalyzerPort: Send + Sync + 'static {
/// Whether a usable model is loaded. When false, indexing is skipped.
fn is_ready(&self) -> bool;
/// Detect and embed every face in `image_bytes` (an encoded JPEG/PNG/…).
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError>;
}
/// Persistence for faces and persons. Every method is user-scoped; the
/// repository enforces `WHERE user_id = …` so callers only ever touch their
/// own biometric data.
#[async_trait]
pub trait FaceRepository: Send + Sync + 'static {
// ── faces ──────────────────────────────────────────────────────
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Faces previously computed for any file sharing this content hash —
/// lets indexing reuse results for deduplicated (identical) uploads.
async fn faces_for_blob(
&self,
user_id: Uuid,
blob_hash: &str,
) -> Result<Vec<Face>, DomainError>;
async fn assign_person(
&self,
face_id: Uuid,
person_id: Option<Uuid>,
) -> Result<(), DomainError>;
// ── persons ────────────────────────────────────────────────────
async fn create_person(&self, person: &Person) -> Result<(), DomainError>;
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError>;
async fn rename_person(
&self,
user_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError>;
async fn set_person_cover(
&self,
person_id: Uuid,
cover_face_id: Uuid,
) -> Result<(), DomainError>;
async fn set_person_hidden(
&self,
user_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError>;
/// File ids that contain a face assigned to this person (most recent first).
async fn files_for_person(
&self,
user_id: Uuid,
person_id: Uuid,
) -> Result<Vec<Uuid>, DomainError>;
/// Hard-delete every face and person for a user (right to erasure /
/// disabling the feature).
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError>;
}
+1
View File
@@ -10,6 +10,7 @@ pub mod compression_ports;
pub mod content_index_ports;
pub mod dedup_ports;
pub mod email_sender;
pub mod face_ports;
pub mod favorites_ports;
pub mod file_lifecycle;
pub mod file_ports;