153 lines
4.4 KiB
Rust
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));
|
|
}
|
|
}
|