Spaces:
Running
Running
improve anonymization of the names
Browse files- processors/anonymizer.py +38 -3
processors/anonymizer.py
CHANGED
|
@@ -110,6 +110,41 @@ _PLACEHOLDERS = {
|
|
| 110 |
}
|
| 111 |
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
def _regex_anonymize(text: str) -> str:
|
| 114 |
"""Pure-regex anonymization for the formatted identifiers we can match safely."""
|
| 115 |
text = _EMAIL_RE.sub("[EMAIL]", text)
|
|
@@ -142,7 +177,7 @@ def anonymize_text(text: str, language: str = "en") -> str:
|
|
| 142 |
"Refusing to process the text (set ANONYMIZER_ALLOW_FALLBACK=true only "
|
| 143 |
"for local development)."
|
| 144 |
)
|
| 145 |
-
return _regex_anonymize(text)
|
| 146 |
|
| 147 |
try:
|
| 148 |
results = _analyzer.analyze(text=text, language=language)
|
|
@@ -158,14 +193,14 @@ def anonymize_text(text: str, language: str = "en") -> str:
|
|
| 158 |
analyzer_results=results,
|
| 159 |
operators=operators,
|
| 160 |
)
|
| 161 |
-
return anonymized.text
|
| 162 |
except Exception as e: # pragma: no cover
|
| 163 |
print(f"[anonymizer] Presidio failed at runtime: {e}")
|
| 164 |
if not fallback_allowed():
|
| 165 |
raise AnonymizationUnavailable(
|
| 166 |
f"Presidio failed on this text ({e}); refusing to fall back to regex-only masking."
|
| 167 |
) from e
|
| 168 |
-
return _regex_anonymize(text)
|
| 169 |
|
| 170 |
|
| 171 |
def detect_pii(text: str, language: str = "en") -> list[dict[str, Any]]:
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
|
| 113 |
+
# Personal names that NER-based masking (Presidio/spaCy) commonly misses in customer-support chat:
|
| 114 |
+
# agent sign-offs ("^Lynn", "*MelindaR", "- Laura :)", "-Dinesh") and greetings ("Hi Guy").
|
| 115 |
+
# Measured on 119 annotated person names, Presidio alone left ~32 % of them visible, mostly exactly
|
| 116 |
+
# these forms. The rules are deliberately narrow (a marker or a greeting is required) so that
|
| 117 |
+
# ordinary capitalised words are not masked.
|
| 118 |
+
_SIGNOFF_MARKER_RE = re.compile(r"(?<![\w])[\^~*]\s?[A-Z][A-Za-z]{1,14}(?![\w])")
|
| 119 |
+
_SIGNOFF_DASH_RE = re.compile(r"(?<=\s)[-\u2013]\s?[A-Z][a-z]{1,14}(?=\s*(?::\)|\d+/\d+|[.!]?\s*$))")
|
| 120 |
+
_GREETING_RE = re.compile(r"\b(Hi|Hello|Hey|Dear|Thanks|Thank you)(,?\s+)([A-Z][a-z]{1,14})\b(?!\s+[A-Z])")
|
| 121 |
+
_NOT_A_NAME = {
|
| 122 |
+
"There", "All", "Team", "Everyone", "Everybody", "Again", "So", "Much", "You", "Guys", "Support", "Care", "Help",
|
| 123 |
+
"Sir", "Madam", "Customer", "Friend", "Folks", "Amazon", "Apple", "Uber", "Spotify",
|
| 124 |
+
"Do", "My", "Is", "It", "We", "The", "How", "What", "Please", "Sorry", "Yes", "No", "Ok", "Okay",
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def mask_name_patterns(text: str) -> str:
|
| 129 |
+
"""Mask agent sign-offs and greeted first names that NER-based masking tends to miss."""
|
| 130 |
+
|
| 131 |
+
def token_of(m: re.Match) -> str:
|
| 132 |
+
return re.sub(r"^[\^~*\-–]\s?", "", m.group(0))
|
| 133 |
+
|
| 134 |
+
def signoff(m: re.Match) -> str:
|
| 135 |
+
return m.group(0) if token_of(m) in _NOT_A_NAME else "[NAME]"
|
| 136 |
+
|
| 137 |
+
def dash(m: re.Match) -> str:
|
| 138 |
+
return m.group(0) if token_of(m) in _NOT_A_NAME else "- [NAME]"
|
| 139 |
+
|
| 140 |
+
def greeting(m: re.Match) -> str:
|
| 141 |
+
return m.group(0) if m.group(3) in _NOT_A_NAME else f"{m.group(1)}{m.group(2)}[NAME]"
|
| 142 |
+
|
| 143 |
+
text = _SIGNOFF_MARKER_RE.sub(signoff, text)
|
| 144 |
+
text = _SIGNOFF_DASH_RE.sub(dash, text)
|
| 145 |
+
return _GREETING_RE.sub(greeting, text)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
def _regex_anonymize(text: str) -> str:
|
| 149 |
"""Pure-regex anonymization for the formatted identifiers we can match safely."""
|
| 150 |
text = _EMAIL_RE.sub("[EMAIL]", text)
|
|
|
|
| 177 |
"Refusing to process the text (set ANONYMIZER_ALLOW_FALLBACK=true only "
|
| 178 |
"for local development)."
|
| 179 |
)
|
| 180 |
+
return mask_name_patterns(_regex_anonymize(text))
|
| 181 |
|
| 182 |
try:
|
| 183 |
results = _analyzer.analyze(text=text, language=language)
|
|
|
|
| 193 |
analyzer_results=results,
|
| 194 |
operators=operators,
|
| 195 |
)
|
| 196 |
+
return mask_name_patterns(anonymized.text)
|
| 197 |
except Exception as e: # pragma: no cover
|
| 198 |
print(f"[anonymizer] Presidio failed at runtime: {e}")
|
| 199 |
if not fallback_allowed():
|
| 200 |
raise AnonymizationUnavailable(
|
| 201 |
f"Presidio failed on this text ({e}); refusing to fall back to regex-only masking."
|
| 202 |
) from e
|
| 203 |
+
return mask_name_patterns(_regex_anonymize(text))
|
| 204 |
|
| 205 |
|
| 206 |
def detect_pii(text: str, language: str = "en") -> list[dict[str, Any]]:
|