Files
leadcast/backend/src/identity_resolution.rs
T
2026-07-26 21:12:38 -03:00

153 lines
4.4 KiB
Rust

use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityProfile {
pub id: Option<Uuid>,
pub display_name: String,
pub real_name: Option<String>,
pub brand_name: Option<String>,
pub aliases: Vec<String>,
pub handles: Vec<String>,
pub domains: Vec<String>,
pub professional_summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityScore {
pub score: f64,
pub exact_external_identifier: bool,
pub reasons: Vec<String>,
}
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::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn names(profile: &IdentityProfile) -> HashSet<String> {
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<String> {
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::<HashSet<_>>();
let right = normalize_name(right)
.split_whitespace()
.filter(|token| token.len() > 3)
.map(str::to_owned)
.collect::<HashSet<_>>();
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));
}
}