first commit
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user