use serde::{Deserialize, Serialize}; use std::collections::HashSet; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentityProfile { pub id: Option, pub display_name: String, pub real_name: Option, pub brand_name: Option, pub aliases: Vec, pub handles: Vec, pub domains: Vec, pub professional_summary: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentityScore { pub score: f64, pub exact_external_identifier: bool, pub reasons: Vec, } pub fn score(candidate: &IdentityProfile, existing: &IdentityProfile) -> IdentityScore { let candidate_handles = set(&candidate.handles); let existing_handles = set(&existing.handles); let candidate_domains = set(&candidate.domains); let existing_domains = set(&existing.domains); let shared_handle = !candidate_handles.is_disjoint(&existing_handles); let shared_domain = !candidate_domains.is_disjoint(&existing_domains); let candidate_names = names(candidate); let existing_names = names(existing); let shared_name = !candidate_names.is_disjoint(&existing_names); let mut value: f64 = 0.0; let mut reasons = Vec::new(); if shared_handle { value += 0.75; reasons.push("handle social idêntico".into()); } if shared_domain { value += 0.65; reasons.push("domínio idêntico".into()); } if shared_name { value += 0.25; reasons.push("nome/alias equivalente".into()); } let professional_overlap = token_overlap( &candidate.professional_summary, &existing.professional_summary, ); if professional_overlap >= 0.35 { value += 0.2 * professional_overlap; reasons.push("contexto profissional compatível".into()); } IdentityScore { score: value.min(1.0), exact_external_identifier: shared_handle || shared_domain, reasons, } } pub fn safe_automatic_match(result: &IdentityScore) -> bool { result.exact_external_identifier && result.score >= 0.8 } pub fn normalize_name(value: &str) -> String { value .trim() .to_lowercase() .chars() .map(|character| { if character.is_alphanumeric() || character.is_whitespace() { character } else { ' ' } }) .collect::() .split_whitespace() .collect::>() .join(" ") } fn names(profile: &IdentityProfile) -> HashSet { std::iter::once(Some(profile.display_name.as_str())) .chain(std::iter::once(profile.real_name.as_deref())) .chain(std::iter::once(profile.brand_name.as_deref())) .chain(profile.aliases.iter().map(|value| Some(value.as_str()))) .flatten() .map(normalize_name) .filter(|value| !value.is_empty()) .collect() } fn set(values: &[String]) -> HashSet { values .iter() .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect() } fn token_overlap(left: &str, right: &str) -> f64 { let left = normalize_name(left) .split_whitespace() .filter(|token| token.len() > 3) .map(str::to_owned) .collect::>(); let right = normalize_name(right) .split_whitespace() .filter(|token| token.len() > 3) .map(str::to_owned) .collect::>(); if left.is_empty() || right.is_empty() { return 0.0; } left.intersection(&right).count() as f64 / left.union(&right).count() as f64 } #[cfg(test)] mod tests { use super::*; fn profile(handle: &str) -> IdentityProfile { IdentityProfile { id: None, display_name: "Maria Silva".into(), real_name: None, brand_name: None, aliases: vec![], handles: vec![handle.into()], domains: vec![], professional_summary: "fundadora de empresa de tecnologia".into(), } } #[test] fn exact_handle_can_match() { let score = score(&profile("@maria"), &profile("@maria")); assert!(safe_automatic_match(&score)); } #[test] fn name_alone_never_auto_merges() { let score = score(&profile("@maria1"), &profile("@maria2")); assert!(!safe_automatic_match(&score)); } }