diff --git a/src/faces.rs b/src/faces.rs index 5b7a232..1dc6c6c 100644 --- a/src/faces.rs +++ b/src/faces.rs @@ -47,7 +47,7 @@ use std::sync::{Arc, Mutex}; /// Visual identity. The optional `entity_id` bridges this person to an /// LLM-extracted knowledge-graph entity (textual side). Persons are NOT /// auto-bridged at creation — only when the user explicitly links them in -/// the management UI, or when bootstrap finds an exact-name match. +/// the management UI. #[derive(Serialize, Queryable, Clone, Debug)] pub struct Person { pub id: i32, @@ -366,6 +366,10 @@ pub struct EmbeddingsQuery { pub limit: i64, #[serde(default)] pub offset: i64, + /// Restrict to one person's faces. Used by the similar-unassigned + /// suggester to fetch a centroid pool. When set, takes precedence + /// over `unassigned` (the more specific filter wins). + pub person_id: Option, } fn default_unassigned() -> bool { @@ -429,6 +433,7 @@ pub trait FaceDao: Send + Sync { ctx: &opentelemetry::Context, library_id: Option, unassigned: bool, + person_id: Option, limit: i64, offset: i64, ) -> anyhow::Result>; @@ -863,6 +868,7 @@ impl FaceDao for SqliteFaceDao { ctx: &opentelemetry::Context, library_id: Option, unassigned: bool, + person_id: Option, limit: i64, offset: i64, ) -> anyhow::Result> { @@ -876,7 +882,13 @@ impl FaceDao for SqliteFaceDao { if let Some(lib) = library_id { query = query.filter(face_detections::library_id.eq(lib)); } - if unassigned { + // person_id is the more specific filter — when both it and + // `unassigned` are supplied, prefer the explicit person id and + // ignore the IS NULL constraint (which would always return + // empty for an assigned person). + if let Some(pid) = person_id { + query = query.filter(face_detections::person_id.eq(pid)); + } else if unassigned { query = query.filter(face_detections::person_id.is_null()); } let rows = query @@ -1676,18 +1688,10 @@ where .route(web::get().to(list_persons_handler::)) .route(web::post().to(create_person_handler::)), ) - .service( - web::resource("/persons/bootstrap") - .route(web::post().to(bootstrap_persons_handler::)), - ) .service( web::resource("/persons/ignore-bucket") .route(web::post().to(ignore_bucket_handler::)), ) - .service( - web::resource("/tags/people-bootstrap-candidates") - .route(web::get().to(bootstrap_candidates_handler::)), - ) .service( web::resource("/persons/{id}") .route(web::get().to(get_person_handler::)) @@ -1702,340 +1706,6 @@ where ) } -// ── Bootstrap (Phase 4) ───────────────────────────────────────────────────── - -#[derive(Serialize, Debug, Clone)] -pub struct BootstrapCandidate { - /// Display name — most-frequent capitalization across the case-insensitive - /// group, or simply the first one seen if it's a tie. - pub name: String, - /// Lowercased name; the stable key for grouping and the auto-bind path. - pub normalized_name: String, - /// Sum of `tagged_photo` counts across all capitalizations of this name. - pub usage_count: i64, - /// Heuristic suggestion; the UI defaults this to checked but the user - /// confirms before [`bootstrap_persons_handler`] actually creates rows. - pub looks_like_person: bool, - /// True when a `persons` row already exists for this name (any case). - /// The UI hides these — re-running bootstrap is idempotent so it's fine - /// either way, but the noise isn't worth showing. - pub already_exists: bool, -} - -#[derive(Serialize, Debug)] -pub struct BootstrapCandidatesResponse { - pub candidates: Vec, -} - -#[derive(Deserialize, Debug)] -pub struct BootstrapPersonsReq { - pub names: Vec, -} - -#[derive(Serialize, Debug)] -pub struct BootstrapPersonsResponse { - pub created: Vec, - pub skipped: Vec, -} - -#[derive(Serialize, Debug)] -pub struct BootstrapSkipped { - pub name: String, - pub reason: String, -} - -/// Hard filter for the bootstrap candidate list. Returns true if the tag -/// could plausibly be a person name; returns false to drop it from the -/// candidates entirely (not just leave looks_like_person=false). -/// -/// Rules — all required: -/// - At least 3 characters after trimming. Two-letter tags ("AB", "OK") -/// are almost always abbreviations or markers, not names. -/// - No emoji or symbol-class characters. SQL-side string sort already -/// surfaces those at the top of the tag list; filtering them keeps -/// the candidate UI focused on names rather than chart-junk. -/// - No control characters or null bytes. -pub(crate) fn is_plausible_name_token(raw: &str) -> bool { - let trimmed = raw.trim(); - if trimmed.chars().count() < 3 { - return false; - } - for c in trimmed.chars() { - // Letter / mark / decimal-digit / connector-punctuation / - // dash / apostrophe / period / whitespace are all plausible in a - // name. Anything else (emoji, symbols, math operators, arrows, - // box drawing, control codes) disqualifies the whole tag. - if c.is_alphabetic() - || c.is_whitespace() - || matches!(c, '\'' | '-' | '.' | '_' | '\u{2019}') - { - continue; - } - if c.is_ascii_digit() { - // Digits don't disqualify here — `looks_like_person` rejects - // them later, but `is_plausible_name_token` is just about - // "could this be in the candidate list at all?". A tag like - // "Sarah2" stays as a candidate (display-flagged not-a-person - // by looks_like_person) so the operator can still spot and - // confirm it manually if it's an alias. - continue; - } - return false; - } - true -} - -/// Conservative "this tag *might* be a person name" heuristic. False -/// negatives are fine — the operator confirms in the UI before any row -/// is created. False positives are also fine for the same reason; the -/// goal is just to default sensible candidates to checked. -/// -/// Rules: -/// - 1–2 whitespace-separated words -/// - Each word starts with an uppercase character -/// - No digits anywhere (rejects "Trip 2018", "2024", etc.) -/// - Single-word names not on a small denylist of common non-person -/// tags (cat, christmas, beach, ...). Two-word names skip the -/// denylist because a real two-word person name is the dominant -/// case ("Sarah Smith") and false-blocking it is worse than false- -/// accepting "Sunset Walk". -pub(crate) fn looks_like_person(raw: &str) -> bool { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return false; - } - let words: Vec<&str> = trimmed.split_whitespace().collect(); - if !(1..=2).contains(&words.len()) { - return false; - } - for w in &words { - let Some(first) = w.chars().next() else { - return false; - }; - if !first.is_uppercase() { - return false; - } - if w.chars().any(|c| c.is_ascii_digit()) { - return false; - } - } - if words.len() == 1 { - const DENY: &[&str] = &[ - // Pets / animals - "cat", - "dog", - "kitten", - "puppy", - "bird", - "fish", - "pet", - "pets", - // Events / occasions - "birthday", - "christmas", - "halloween", - "easter", - "thanksgiving", - "wedding", - "anniversary", - "vacation", - "holiday", - "party", - "trip", - "graduation", - "concert", - // Places (generic) - "home", - "work", - "beach", - "park", - "hotel", - "restaurant", - "office", - "house", - "garden", - // Subjects / styles - "food", - "sunset", - "sunrise", - "landscape", - "portrait", - "selfie", - "nature", - "flowers", - "flower", - "snow", - "rain", - "sky", - // Buckets - "untagged", - "favorites", - "favourites", - "misc", - "other", - "random", - ]; - let lower = trimmed.to_lowercase(); - if DENY.iter().any(|w| *w == lower) { - return false; - } - } - true -} - -async fn bootstrap_candidates_handler( - _: Claims, - request: HttpRequest, - face_dao: web::Data>, - tag_dao: web::Data>, -) -> impl Responder { - use std::collections::HashMap; - let context = extract_context_from_request(&request); - let span = global_tracer().start_with_context("faces.bootstrap_candidates", &context); - let span_context = opentelemetry::Context::current_with_span(span); - - // All tags + their counts. Path filter unused — bootstrap is library-wide. - let tags_with_counts = { - let mut td = tag_dao.lock().expect("tag dao lock"); - match crate::tags::TagDao::get_all_tags(&mut *td, &span_context, None) { - Ok(t) => t, - Err(e) => return HttpResponse::InternalServerError().body(format!("{:#}", e)), - } - }; - - // Group by lowercase name. Pick the most-frequent capitalization - // for the display name (ties broken by first-seen). Filter out - // short tags and tags carrying non-name characters (emojis, symbols) - // before grouping — they're noise no operator would tick, so showing - // them just makes the candidate list harder to scan. - struct Group { - display: String, - display_freq: i64, - total_count: i64, - } - let mut groups: HashMap = HashMap::new(); - for (count, tag) in tags_with_counts { - if !is_plausible_name_token(&tag.name) { - continue; - } - let lower = tag.name.to_lowercase(); - let g = groups.entry(lower).or_insert_with(|| Group { - display: tag.name.clone(), - display_freq: 0, - total_count: 0, - }); - g.total_count += count; - if count > g.display_freq { - g.display = tag.name.clone(); - g.display_freq = count; - } - } - - // Cross-reference against existing persons (bulk one-query lookup). - let lower_names: Vec = groups.keys().cloned().collect(); - let existing = { - let mut fd = face_dao.lock().expect("face dao lock"); - match fd.find_persons_by_names_ci(&span_context, &lower_names) { - Ok(m) => m, - Err(e) => return HttpResponse::InternalServerError().body(format!("{:#}", e)), - } - }; - - let mut candidates: Vec = groups - .into_iter() - .map(|(lower, g)| BootstrapCandidate { - looks_like_person: looks_like_person(&g.display), - already_exists: existing.contains_key(&lower), - name: g.display, - normalized_name: lower, - usage_count: g.total_count, - }) - .collect(); - // Sort: persons-first heuristic by descending count, then alphabetical. - // Persons-likely candidates surface near the top so the user doesn't - // scroll past dozens of "vacation"-style tags to find them. - candidates.sort_by(|a, b| { - b.looks_like_person - .cmp(&a.looks_like_person) - .then(b.usage_count.cmp(&a.usage_count)) - .then(a.normalized_name.cmp(&b.normalized_name)) - }); - - HttpResponse::Ok().json(BootstrapCandidatesResponse { candidates }) -} - -async fn bootstrap_persons_handler( - _: Claims, - request: HttpRequest, - body: web::Json, - face_dao: web::Data>, -) -> impl Responder { - let context = extract_context_from_request(&request); - let span = global_tracer().start_with_context("faces.bootstrap_persons", &context); - let span_context = opentelemetry::Context::current_with_span(span); - - let mut created: Vec = Vec::new(); - let mut skipped: Vec = Vec::new(); - - let mut dao = face_dao.lock().expect("face dao lock"); - - // Pre-fetch the existing-name set so a duplicate request reports - // "already exists" (skipped) rather than firing N inserts that all - // 409 against the UNIQUE COLLATE NOCASE constraint. - let lower_names: Vec = body.names.iter().map(|n| n.to_lowercase()).collect(); - let existing = match dao.find_persons_by_names_ci(&span_context, &lower_names) { - Ok(m) => m, - Err(e) => return HttpResponse::InternalServerError().body(format!("{:#}", e)), - }; - - for name in &body.names { - let trimmed = name.trim(); - if trimmed.is_empty() { - skipped.push(BootstrapSkipped { - name: name.clone(), - reason: "empty name".into(), - }); - continue; - } - let lower = trimmed.to_lowercase(); - if existing.contains_key(&lower) { - skipped.push(BootstrapSkipped { - name: trimmed.to_string(), - reason: "person already exists".into(), - }); - continue; - } - match dao.create_person( - &span_context, - &CreatePersonReq { - name: trimmed.to_string(), - notes: None, - entity_id: None, - is_ignored: false, - }, - /*from_tag*/ true, - ) { - Ok(p) => created.push(p), - Err(e) => { - if is_unique_violation(&e) { - // Race with a concurrent create; treat as skipped. - skipped.push(BootstrapSkipped { - name: trimmed.to_string(), - reason: "person already exists".into(), - }); - } else { - skipped.push(BootstrapSkipped { - name: trimmed.to_string(), - reason: format!("{:#}", e), - }); - } - } - } - } - - HttpResponse::Ok().json(BootstrapPersonsResponse { created, skipped }) -} - // ── Stats / list ──────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -2132,6 +1802,7 @@ async fn embeddings_handler( &span_context, query.library, query.unassigned, + query.person_id, limit, offset, ) @@ -2796,77 +2467,7 @@ mod tests { ); } - // ── Phase 4: bootstrap heuristic + cosine + DAO support ───────────── - - #[test] - fn is_plausible_name_token_filters_short_and_emoji() { - // Hard filter applied before grouping — emojis and tags shorter - // than 3 chars never make it into the candidate list, regardless - // of looks_like_person's later assessment. - assert!(is_plausible_name_token("Cameron")); - assert!(is_plausible_name_token("Sarah Smith")); - assert!(is_plausible_name_token("O'Brien")); - assert!(is_plausible_name_token("Jean-Luc")); - assert!(is_plausible_name_token("St. James")); - assert!(is_plausible_name_token("Renée")); - assert!(is_plausible_name_token("José")); - // Asian script names — the alphabetic/letter check covers any - // script, not just Latin. - assert!(is_plausible_name_token("田中太郎")); - - // Below the 3-character floor. - assert!(!is_plausible_name_token("")); - assert!(!is_plausible_name_token(" ")); - assert!(!is_plausible_name_token("Bo")); - assert!(!is_plausible_name_token("AB")); - // Trim before counting — surrounding whitespace doesn't count. - assert!(!is_plausible_name_token(" AB ")); - - // Emoji / symbol classes get the whole tag dropped. - assert!(!is_plausible_name_token("🐱cat")); - assert!(!is_plausible_name_token("Heart ❤")); - assert!(!is_plausible_name_token("📸Photo")); - assert!(!is_plausible_name_token("→ Trip")); - assert!(!is_plausible_name_token("★Vacation")); - - // Digits are kept (handled by looks_like_person, not here). - assert!(is_plausible_name_token("Trip 2018")); - assert!(is_plausible_name_token("2024")); - } - - #[test] - fn looks_like_person_accepts_typical_names() { - assert!(looks_like_person("Cameron")); - assert!(looks_like_person("Sarah Smith")); - assert!(looks_like_person("Mary Jane")); - // Non-ASCII title-cased single word still counts. - assert!(looks_like_person("Renée")); - } - - #[test] - fn looks_like_person_rejects_obvious_non_people() { - // Digits, lowercase, three-or-more words, denylist hits. - assert!(!looks_like_person("2018")); - assert!(!looks_like_person("Trip 2018")); - assert!(!looks_like_person("trip")); - assert!(!looks_like_person("Birthday Party Cake")); - assert!(!looks_like_person("cat")); - assert!(!looks_like_person("Cat")); // denied even when title-cased - assert!(!looks_like_person("Christmas")); - assert!(!looks_like_person("home")); - assert!(!looks_like_person("")); - assert!(!looks_like_person(" ")); - } - - #[test] - fn looks_like_person_two_words_skips_denylist() { - // Two-word names get a pass on the single-word denylist — - // "Sunset Walk" is much more likely a real album than a person, - // but false-accepting is fine because the operator confirms. - // What matters is we don't false-reject "Sarah Smith". - assert!(looks_like_person("Sunset Walk")); - assert!(looks_like_person("Sarah Smith")); - } + // ── Phase 4: cosine + DAO support ─────────────────────────────────── #[test] fn cosine_similarity_known_vectors() { @@ -3339,6 +2940,82 @@ mod tests { assert_eq!(faces[0].person_id, Some(alice.id)); } + #[test] + fn list_embeddings_filters_by_person_id() { + // Apollo's similar-unassigned suggester relies on this filter to + // pull a single person's embeddings without paging the whole + // detected set client-side. When person_id is set it must win + // over `unassigned=true` (otherwise the IS NULL constraint would + // always return an empty set for an assigned person). + let mut dao = fresh_dao(); + diesel::sql_query( + "INSERT OR IGNORE INTO libraries (id, name, root_path, created_at) \ + VALUES (1, 'main', '/tmp', 0)", + ) + .execute(dao.connection.lock().unwrap().deref_mut()) + .expect("seed libraries"); + + let alice = dao + .create_person( + &ctx(), + &CreatePersonReq { + name: "Alice".into(), + notes: None, + entity_id: None, + is_ignored: false, + }, + false, + ) + .unwrap(); + let bob = dao + .create_person( + &ctx(), + &CreatePersonReq { + name: "Bob".into(), + notes: None, + entity_id: None, + is_ignored: false, + }, + false, + ) + .unwrap(); + + let mk_row = |hash: &str, person: Option| InsertFaceDetectionInput { + library_id: 1, + content_hash: hash.into(), + rel_path: format!("{hash}.jpg"), + bbox: Some((0.1, 0.1, 0.2, 0.2)), + embedding: Some(vec![0u8; 2048]), + confidence: Some(0.9), + source: "auto".into(), + person_id: person, + status: "detected".into(), + model_version: "buffalo_l".into(), + }; + dao.store_detection(&ctx(), mk_row("a1", Some(alice.id))).unwrap(); + dao.store_detection(&ctx(), mk_row("a2", Some(alice.id))).unwrap(); + dao.store_detection(&ctx(), mk_row("b1", Some(bob.id))).unwrap(); + dao.store_detection(&ctx(), mk_row("u1", None)).unwrap(); + + // person_id=alice returns only alice's two faces — ignoring the + // (default-true) `unassigned` filter, which would have selected + // u1 only. + let alice_rows = dao + .list_embeddings(&ctx(), None, true, Some(alice.id), 100, 0) + .unwrap(); + assert_eq!(alice_rows.len(), 2); + assert!(alice_rows + .iter() + .all(|(r, _)| r.person_id == Some(alice.id))); + + // unassigned=true with no person_id behaves as before. + let unassigned_rows = dao + .list_embeddings(&ctx(), None, true, None, 100, 0) + .unwrap(); + assert_eq!(unassigned_rows.len(), 1); + assert_eq!(unassigned_rows[0].0.content_hash, "u1"); + } + // ── crop_image_to_bbox ────────────────────────────────────────────── // Pure helper used by the manual face-create handler. Generate a tiny // image in memory, write it to a temp file, then exercise the bbox