Typo-aware safety pattern matching for crisis / imminent-risk regex
Browse filesDistress-typing causes systematic typos: dropped letters, missing
apostrophes, swapped neighbors, contractions left uncorrected. The
prior crisis regex required canonical spelling so 'i dont wan to be
alive anymore' / 'i wanna kil myself' / 'im sucidal' all fell
through to the LISTEN stage of a normal route. A false negative on
crisis language is the most expensive thing this system can do.
Added CRITICAL_WORD_TYPOS dictionary mapping common safety-keyword
typos to their canonical forms — limited to words that appear in
EXPLICIT_CRISIS_PATTERNS or IMMINENT_RISK_PATTERNS so generic typo
correction stays out of scope. Words covered: want / wanna / kill /
hurt / myself / alive / live / die / tonight / suicide / suicidal /
anymore, plus contractions missing the apostrophe (dont / cant /
im / wont / etc.).
Classifier now does a two-pass match: raw normalized text first,
then typo-corrected text for EXPLICIT_CRISIS_PATTERNS and
IMMINENT_RISK_PATTERNS only. Ambiguous-metaphor / academic-idiom
checks deliberately stay on raw text so typo correction can't
over-flag idiomatic distress phrases.
Verified 15 typo-laden crisis variants now fire, 9 negative cases
(i wanna take a nap / i cant kil this bug / the wan signal is bad /
etc.) correctly pass.
- src/pipeline/safety_policy.py +104 -2
|
@@ -65,9 +65,25 @@ class SafetyTriagePolicy:
|
|
| 65 |
self.emergency_threshold = emergency_threshold
|
| 66 |
|
| 67 |
def classify(self, text: str, confidence: float, model_flag: bool) -> SafetyDecision:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
normalized = _normalize(text)
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
ambiguous_metaphor = _matches_any(normalized, AMBIGUOUS_METAPHOR_PATTERNS)
|
| 72 |
academic_idiom = _matches_any(normalized, ACADEMIC_IDIOM_PATTERNS)
|
| 73 |
|
|
@@ -127,6 +143,92 @@ def _normalize(text: str) -> str:
|
|
| 127 |
return re.sub(r"\s+", " ", text.lower()).strip()
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _matches_any(text: str, patterns: tuple[re.Pattern[str], ...]) -> bool:
|
| 131 |
return any(pattern.search(text) for pattern in patterns)
|
| 132 |
|
|
|
|
| 65 |
self.emergency_threshold = emergency_threshold
|
| 66 |
|
| 67 |
def classify(self, text: str, confidence: float, model_flag: bool) -> SafetyDecision:
|
| 68 |
+
# Two-pass match: first try the raw normalized text, then a typo-
|
| 69 |
+
# corrected version for crisis / imminent patterns only. Students
|
| 70 |
+
# in distress drop letters, miss apostrophes, and contract things
|
| 71 |
+
# in ways that brittle regex would miss. The cost of a false
|
| 72 |
+
# positive on safety is a 988 card on a benign message; the cost
|
| 73 |
+
# of a false negative is missing crisis language. We err toward
|
| 74 |
+
# catching by trying both passes for the high-stakes patterns.
|
| 75 |
+
# Ambiguous-metaphor / academic-idiom checks stay on raw text so
|
| 76 |
+
# the typo-correction layer can't accidentally over-flag idioms.
|
| 77 |
normalized = _normalize(text)
|
| 78 |
+
typo_corrected = _typo_correct_for_safety(normalized)
|
| 79 |
+
explicit = (
|
| 80 |
+
_matches_any(normalized, EXPLICIT_CRISIS_PATTERNS)
|
| 81 |
+
or (typo_corrected != normalized and _matches_any(typo_corrected, EXPLICIT_CRISIS_PATTERNS))
|
| 82 |
+
)
|
| 83 |
+
imminent = (
|
| 84 |
+
_matches_any(normalized, IMMINENT_RISK_PATTERNS)
|
| 85 |
+
or (typo_corrected != normalized and _matches_any(typo_corrected, IMMINENT_RISK_PATTERNS))
|
| 86 |
+
)
|
| 87 |
ambiguous_metaphor = _matches_any(normalized, AMBIGUOUS_METAPHOR_PATTERNS)
|
| 88 |
academic_idiom = _matches_any(normalized, ACADEMIC_IDIOM_PATTERNS)
|
| 89 |
|
|
|
|
| 143 |
return re.sub(r"\s+", " ", text.lower()).strip()
|
| 144 |
|
| 145 |
|
| 146 |
+
# Word-level typo dictionary used ONLY for safety pattern matching.
|
| 147 |
+
# Each key is a frequently-mistyped form of a word that appears in
|
| 148 |
+
# crisis or imminent-risk regex; the value is the canonical spelling
|
| 149 |
+
# the regex expects. We only correct words where the canonical version
|
| 150 |
+
# is a critical safety keyword — generic typo correction is out of
|
| 151 |
+
# scope. Apply via word-boundary substitution so we don't mangle
|
| 152 |
+
# unrelated tokens that happen to contain a typo substring.
|
| 153 |
+
CRITICAL_WORD_TYPOS: dict[str, str] = {
|
| 154 |
+
# "want" + common typos
|
| 155 |
+
"wan": "want",
|
| 156 |
+
"wnat": "want",
|
| 157 |
+
"wnt": "want",
|
| 158 |
+
"wantt": "want",
|
| 159 |
+
"wnant": "want",
|
| 160 |
+
# "wanna" -> "want to" so the regex can match either form
|
| 161 |
+
"wanna": "want to",
|
| 162 |
+
"wana": "want to",
|
| 163 |
+
"wnna": "want to",
|
| 164 |
+
# missing apostrophes / contracted negations
|
| 165 |
+
"dont": "don't",
|
| 166 |
+
"doesnt": "doesn't",
|
| 167 |
+
"didnt": "didn't",
|
| 168 |
+
"cant": "can't",
|
| 169 |
+
"couldnt": "couldn't",
|
| 170 |
+
"wont": "won't",
|
| 171 |
+
"wouldnt": "wouldn't",
|
| 172 |
+
"shouldnt": "shouldn't",
|
| 173 |
+
"im": "i'm",
|
| 174 |
+
"ive": "i've",
|
| 175 |
+
"id": "i'd",
|
| 176 |
+
# "myself" + typos
|
| 177 |
+
"myslef": "myself",
|
| 178 |
+
"mysef": "myself",
|
| 179 |
+
"myselv": "myself",
|
| 180 |
+
"myseflf": "myself",
|
| 181 |
+
# "kill" + typos
|
| 182 |
+
"kil": "kill",
|
| 183 |
+
"kll": "kill",
|
| 184 |
+
"killl": "kill",
|
| 185 |
+
# "hurt" + typos
|
| 186 |
+
"hrut": "hurt",
|
| 187 |
+
"hurtt": "hurt",
|
| 188 |
+
"hurtm": "hurt",
|
| 189 |
+
# "alive" + typos
|
| 190 |
+
"alvie": "alive",
|
| 191 |
+
"alve": "alive",
|
| 192 |
+
"aliv": "alive",
|
| 193 |
+
# "live" common variants — keep narrow to avoid clobbering "lively"
|
| 194 |
+
"liv": "live",
|
| 195 |
+
# "die" + typos (very few)
|
| 196 |
+
"diee": "die",
|
| 197 |
+
# "tonight" + typos (timing word in imminent patterns)
|
| 198 |
+
"tnight": "tonight",
|
| 199 |
+
"toniht": "tonight",
|
| 200 |
+
"toinght": "tonight",
|
| 201 |
+
# "suicide" + typos
|
| 202 |
+
"sucide": "suicide",
|
| 203 |
+
"sucidie": "suicide",
|
| 204 |
+
"suicde": "suicide",
|
| 205 |
+
"suiced": "suicide",
|
| 206 |
+
"suicdal": "suicidal",
|
| 207 |
+
"sucidal": "suicidal",
|
| 208 |
+
# "anymore" + typos
|
| 209 |
+
"anymore": "anymore",
|
| 210 |
+
"anymroe": "anymore",
|
| 211 |
+
"anymor": "anymore",
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
_CRITICAL_TYPO_RE = re.compile(
|
| 215 |
+
r"\b(" + "|".join(re.escape(k) for k in CRITICAL_WORD_TYPOS) + r")\b",
|
| 216 |
+
re.IGNORECASE,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _typo_correct_for_safety(text: str) -> str:
|
| 221 |
+
"""Map common typo / contraction-without-apostrophe forms of
|
| 222 |
+
safety-critical words to their canonical spellings, for pattern
|
| 223 |
+
matching only. Never use this to rewrite user-facing text."""
|
| 224 |
+
if not text:
|
| 225 |
+
return text
|
| 226 |
+
return _CRITICAL_TYPO_RE.sub(
|
| 227 |
+
lambda m: CRITICAL_WORD_TYPOS[m.group(1).lower()],
|
| 228 |
+
text,
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
def _matches_any(text: str, patterns: tuple[re.Pattern[str], ...]) -> bool:
|
| 233 |
return any(pattern.search(text) for pattern in patterns)
|
| 234 |
|