MukulRay commited on
Commit
937b034
·
1 Parent(s): fa72baf

Typo-aware safety pattern matching for crisis / imminent-risk regex

Browse files

Distress-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.

Files changed (1) hide show
  1. src/pipeline/safety_policy.py +104 -2
src/pipeline/safety_policy.py CHANGED
@@ -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
- explicit = _matches_any(normalized, EXPLICIT_CRISIS_PATTERNS)
70
- imminent = _matches_any(normalized, IMMINENT_RISK_PATTERNS)
 
 
 
 
 
 
 
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