1use crate::common::{compile_regex, confidence, context_boost};
2use cloakrs_core::{Confidence, EntityType, Locale, PiiEntity, Recognizer, Span};
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6static PERSON_NAME_REGEX: Lazy<Regex> = Lazy::new(|| {
7 compile_regex(
8 r"\b(?:(?:Mr|Mrs|Ms|Dr)\.?\s+)?([A-Z][a-z]{1,24})(?:\s+[A-Z]\.)?\s+([A-Z][a-z]{1,24}(?:[-'][A-Z][a-z]{1,24})?)\b",
9 )
10});
11
12const CONTEXT_WORDS: &[&str] = &[
13 "name",
14 "customer",
15 "client",
16 "patient",
17 "employee",
18 "user",
19 "contact",
20 "person",
21 "account holder",
22 "full name",
23 "signed by",
24 "author",
25 "recipient",
26 "sender",
27 "attn",
28 "dear",
29 "mr",
30 "mrs",
31 "ms",
32 "dr",
33];
34
35const FIRST_NAMES: &[&str] = &[
36 "aaron",
37 "adam",
38 "alex",
39 "alice",
40 "anna",
41 "anthony",
42 "barbara",
43 "benjamin",
44 "brian",
45 "carla",
46 "charles",
47 "chris",
48 "christopher",
49 "daniel",
50 "david",
51 "elizabeth",
52 "emily",
53 "emma",
54 "eric",
55 "fatima",
56 "george",
57 "hannah",
58 "isabella",
59 "james",
60 "jane",
61 "jan",
62 "jennifer",
63 "john",
64 "jose",
65 "joseph",
66 "julia",
67 "kadir",
68 "laura",
69 "linda",
70 "lisa",
71 "maria",
72 "mark",
73 "mary",
74 "michael",
75 "mohammed",
76 "nancy",
77 "olivia",
78 "patricia",
79 "paul",
80 "peter",
81 "robert",
82 "sarah",
83 "susan",
84 "thomas",
85 "william",
86];
87
88const LAST_NAMES: &[&str] = &[
89 "adams",
90 "anderson",
91 "brown",
92 "clark",
93 "davis",
94 "evans",
95 "garcia",
96 "green",
97 "hall",
98 "harris",
99 "jackson",
100 "johnson",
101 "jones",
102 "khan",
103 "lee",
104 "lewis",
105 "martin",
106 "martinez",
107 "miller",
108 "moore",
109 "nguyen",
110 "patel",
111 "perez",
112 "roberts",
113 "rodriguez",
114 "sanchez",
115 "smith",
116 "taylor",
117 "thomas",
118 "thompson",
119 "walker",
120 "white",
121 "williams",
122 "wilson",
123 "wright",
124 "young",
125];
126
127const NON_NAME_PHRASES: &[&str] = &[
128 "api key",
129 "aws access",
130 "credit card",
131 "date of",
132 "not found",
133 "user path",
134 "physical address",
135 "social security",
136 "json web",
137 "united states",
138 "new york",
139 "san francisco",
140 "los angeles",
141];
142
143#[derive(Debug, Clone, Copy, Default)]
155pub struct PersonNameRecognizer;
156
157impl Recognizer for PersonNameRecognizer {
158 fn id(&self) -> &str {
159 "person_name_dictionary_v1"
160 }
161
162 fn entity_type(&self) -> EntityType {
163 EntityType::PersonName
164 }
165
166 fn supported_locales(&self) -> &[Locale] {
167 &[]
168 }
169
170 fn scan(&self, text: &str) -> Vec<PiiEntity> {
171 PERSON_NAME_REGEX
172 .captures_iter(text)
173 .filter_map(|captures| {
174 let matched = captures.get(0)?;
175 let first = captures.get(1)?.as_str();
176 let last = captures.get(2)?.as_str();
177 if !self.validate_parts(first, last)
178 || !valid_boundary(text, matched.start(), matched.end())
179 {
180 return None;
181 }
182 Some(PiiEntity {
183 entity_type: self.entity_type(),
184 span: Span::new(matched.start(), matched.end()),
185 text: matched.as_str().to_string(),
186 confidence: self.compute_confidence(text, matched.start(), first, last),
187 recognizer_id: self.id().to_string(),
188 })
189 })
190 .collect()
191 }
192
193 fn validate(&self, candidate: &str) -> bool {
194 let Some(captures) = PERSON_NAME_REGEX.captures(candidate) else {
195 return false;
196 };
197 captures
198 .get(0)
199 .is_some_and(|matched| matched.as_str() == candidate)
200 && captures
201 .get(1)
202 .zip(captures.get(2))
203 .is_some_and(|(first, last)| self.validate_parts(first.as_str(), last.as_str()))
204 }
205}
206
207impl PersonNameRecognizer {
208 fn validate_parts(&self, first: &str, last: &str) -> bool {
209 let phrase = format!(
210 "{} {}",
211 first.to_ascii_lowercase(),
212 last.to_ascii_lowercase()
213 );
214 if NON_NAME_PHRASES.contains(&phrase.as_str()) {
215 return false;
216 }
217 FIRST_NAMES.contains(&first.to_ascii_lowercase().as_str())
218 && last
219 .split(['-', '\''])
220 .all(|part| LAST_NAMES.contains(&part.to_ascii_lowercase().as_str()))
221 }
222
223 fn compute_confidence(&self, text: &str, start: usize, first: &str, last: &str) -> Confidence {
224 let boost = context_boost(text, start, CONTEXT_WORDS);
225 let dictionary_strength = if first.len() >= 4 && last.len() >= 4 {
226 0.08
227 } else {
228 0.0
229 };
230 confidence(0.42 + dictionary_strength + boost)
231 }
232}
233
234fn valid_boundary(text: &str, start: usize, end: usize) -> bool {
235 let before = text[..start].chars().next_back();
236 let after = text[end..].chars().next();
237 !before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
238 && !after.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 fn texts(input: &str) -> Vec<String> {
246 PersonNameRecognizer
247 .scan(input)
248 .into_iter()
249 .map(|finding| finding.text)
250 .collect()
251 }
252
253 #[test]
254 fn test_person_name_john_smith_detected() {
255 assert_eq!(texts("customer name John Smith"), ["John Smith"]);
256 }
257
258 #[test]
259 fn test_person_name_jane_doe_like_dictionary_rejected() {
260 assert!(texts("contact Jane Doe").is_empty());
261 }
262
263 #[test]
264 fn test_person_name_middle_initial_detected() {
265 assert_eq!(texts("patient John A. Smith"), ["John A. Smith"]);
266 }
267
268 #[test]
269 fn test_person_name_hyphenated_last_detected() {
270 assert_eq!(texts("client Maria Garcia-Smith"), ["Maria Garcia-Smith"]);
271 }
272
273 #[test]
274 fn test_person_name_in_email_rejected() {
275 assert!(texts("email john.smith@example.com").is_empty());
276 }
277
278 #[test]
279 fn test_person_name_lowercase_rejected() {
280 assert!(texts("customer john smith").is_empty());
281 }
282
283 #[test]
284 fn test_person_name_non_dictionary_rejected() {
285 assert!(texts("customer River Table").is_empty());
286 }
287
288 #[test]
289 fn test_person_name_common_phrase_rejected() {
290 assert!(texts("United States").is_empty());
291 }
292
293 #[test]
294 fn test_person_name_embedded_word_rejected() {
295 assert!(texts("xJohn Smithy").is_empty());
296 }
297
298 #[test]
299 fn test_person_name_validate_accepts_known_name() {
300 assert!(PersonNameRecognizer.validate("John Smith"));
301 }
302
303 #[test]
304 fn test_person_name_validate_rejects_unknown_name() {
305 assert!(!PersonNameRecognizer.validate("Blue Widget"));
306 }
307
308 #[test]
309 fn test_person_name_context_boosts_confidence() {
310 let with_context = PersonNameRecognizer.scan("customer name John Smith");
311 let without_context = PersonNameRecognizer.scan("value John Smith");
312 assert!(with_context[0].confidence > without_context[0].confidence);
313 }
314
315 #[test]
316 fn test_person_name_title_context_boosts_confidence() {
317 let with_context = PersonNameRecognizer.scan("Dr. John Smith");
318 let without_context = PersonNameRecognizer.scan("value John Smith");
319 assert!(with_context[0].confidence > without_context[0].confidence);
320 }
321
322 #[test]
323 fn test_person_name_supported_locales_are_universal() {
324 assert!(PersonNameRecognizer.supported_locales().is_empty());
325 }
326
327 #[test]
328 fn test_person_name_entity_type_is_person_name() {
329 assert_eq!(PersonNameRecognizer.entity_type(), EntityType::PersonName);
330 }
331}