Spaces:
Running on Zero
Running on Zero
| """Graded regex families for the deterministic safety gate. | |
| The banks live here, separate from the scoring logic in ``safety.py``, because these two | |
| things change for completely different reasons. Patterns change when we learn a new | |
| phrasing; the scoring rules change when the product's risk posture changes. Reviewing a | |
| pattern diff should not mean re-reading the aggregation maths. | |
| Locked design, see ``docs/context.md`` section 6. Six categories, three severities: | |
| * **STRONG** — first-person, unambiguous, and rare enough in ordinary chat that a match is | |
| worth acting on alone. Floors the tier. | |
| * **MODERATE** — the phrasing is concerning but a benign reading exists. Floors the tier. | |
| * **WEAK** — a topic word with no stance attached. Contributes to the grey score so the | |
| case is flagged for the conversation head, but **never floors on its own**. This is the | |
| whole point of grading: `"knife"` in a cooking-class story must not break-glass a child's | |
| anonymity, but it should not be invisible either. | |
| ## Suppressors | |
| Every category carries a list of suppressor patterns. A category hit is dropped when its | |
| span falls inside a suppressor's span. One mechanism covers three distinct failure modes, | |
| which is why suppressors are written to *include* the trigger phrase rather than sit next | |
| to it: | |
| * idiom — ``"this homework is killing me"`` swallows ``"killing me"`` | |
| * negation — ``"i would never kill myself"`` swallows ``"kill myself"`` | |
| * attribution — ``"he told me to kill myself"`` swallows ``"kill myself"`` | |
| Attribution is the subtle one and the reason it is not simply deleted: the student is not | |
| disclosing their own intent, so a T4 break-glass (which de-anonymises them) is the wrong | |
| response. But being told to kill yourself is severe harassment, so the same regex appears | |
| again as a STRONG ``threat_of_violence`` pattern. The case lands at a T3 floor: priority | |
| queue, 24h, no de-anonymisation. If the student *also* voices their own ideation elsewhere | |
| in the conversation, that matches separately and floors at T4 as it should. | |
| ## What these deliberately do not do | |
| No stemming, no word embeddings, no fuzzy matching. Every fire has to be explainable to a | |
| counsellor in one line ("matched `self_harm_intent / first_person_cutting`"), and a fuzzy | |
| matcher cannot do that. Recall we do not get here is recall the fine-tuned classifier is | |
| supposed to supply. The gate's job is the floor, not the ceiling of detection. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from enum import Enum | |
| from lighthouse.taxonomy import GateCategory | |
| class Severity(str, Enum): | |
| """How much a single match is worth. Weights live in ``config``.""" | |
| STRONG = "strong" | |
| MODERATE = "moderate" | |
| WEAK = "weak" | |
| SEVERITY_ORDER: list[Severity] = [Severity.WEAK, Severity.MODERATE, Severity.STRONG] | |
| # -------------------------------------------------------------------------------------- | |
| # Normalisation | |
| # -------------------------------------------------------------------------------------- | |
| #: Character-level filter evasion, mapped back to letters. Every entry is a single | |
| #: character replaced by a single character, which keeps ``normalize`` length-preserving. | |
| #: That property is load-bearing: match spans are reported against the ORIGINAL text so | |
| #: escalation cards can quote a student verbatim, and a normaliser that collapsed | |
| #: whitespace or repeated letters would silently shift every offset. | |
| _LEET = str.maketrans({ | |
| "!": "i", | |
| "1": "i", | |
| "|": "i", | |
| "3": "e", | |
| "4": "a", | |
| "0": "o", | |
| "@": "a", | |
| "$": "s", | |
| "5": "s", | |
| "7": "t", | |
| "*": "", # not applied — see below | |
| }) | |
| # "*" would change length, so it is removed from the table rather than mapped. | |
| del _LEET[ord("*")] | |
| def normalize(text: str) -> str: | |
| """Lowercase and undo single-character leetspeak. Length-preserving, always. | |
| Digits are mapped unconditionally, so ``"im 14"`` normalises to ``"im ia"``. That is | |
| ugly but harmless: nothing in the banks matches bare digit-shaped tokens, and the | |
| alternative (token-aware mapping) costs the 1:1 offset guarantee that lets us quote | |
| the student's own words back on the escalation card. | |
| """ | |
| return text.lower().translate(_LEET) | |
| # -------------------------------------------------------------------------------------- | |
| # The banks | |
| # -------------------------------------------------------------------------------------- | |
| # | |
| # Each entry is (name, regex). The name is what a counsellor sees; it must read as an | |
| # explanation, not as an internal ID. The regex is never shown to anyone. | |
| Bank = dict[Severity, list[tuple[str, str]]] | |
| _SELF_HARM_INTENT: Bank = { | |
| Severity.STRONG: [ | |
| ( | |
| # The filler group carries `have`/`had` as well as `been`. Found on day 5 by | |
| # running the live chat route: "i've been cutting my arms" floored at T4 and | |
| # "i have been cutting my arms" floored at nothing, purely because the | |
| # contraction was expanded. The sibling pattern `first_person_self_harm_term` | |
| # already listed `have\s+been`, so this was an oversight in one pattern rather | |
| # than a decision. The 80-conversation corpus only ever writes the contracted | |
| # form, which is exactly why the audit did not catch it. | |
| "first_person_cutting", | |
| r"\b(i|i'?ve|ive)\s+(been\s+|have\s+|had\s+|just\s+|already\s+|started\s+)*" | |
| r"(cut|cuts|cutting|burnt|burned|burning|scratched|scratching)\s+" | |
| r"(myself|my\s+(arm|arms|wrist|wrists|leg|legs|thigh|thighs|skin))\b", | |
| ), | |
| ( | |
| "stated_intent_to_self_injure", | |
| r"\bi(\s+(want|wanna|need|have|am\s+going|going)|'?m\s+going)\s+to\s+" | |
| r"(cut|hurt|harm|burn)\s+myself\b", | |
| ), | |
| ("cut_again", r"\bi\s+(cut|hurt)\s+(myself\s+)?again\b"), | |
| ( | |
| "first_person_self_harm_term", | |
| r"\bi(\s+(have\s+been|am|was|started|stopped|relapsed)|'?ve\s+been|'?m)\s+" | |
| r"\w*\s*self[\s\-]?harm(ing|ed)?\b", | |
| ), | |
| ("relapse_with_injury", r"\bi\s+relapsed\b.{0,40}\b(cut|cutting|blade|razor)\b"), | |
| ], | |
| Severity.MODERATE: [ | |
| ("self_harm_term", r"\bself[\s\-]?harm(ing|ed|s)?\b"), | |
| ("hurting_myself", r"\bhurt(ing)?\s+myself\b"), | |
| ("deserves_pain", r"\bi\s+deserve\s+(the\s+|this\s+)?(pain|to\s+hurt|it)\b"), | |
| ("own_scars", r"\bmy\s+(cuts|scars|wounds)\b"), | |
| ], | |
| Severity.WEAK: [ | |
| ("injury_objects", r"\b(razor|razors|blade|blades)\b"), | |
| ("hiding_arms", r"\b(long\s+sleeves|cover\s+my\s+arms|hide\s+my\s+arms)\b"), | |
| ], | |
| } | |
| _SUICIDAL_IDEATION: Bank = { | |
| Severity.STRONG: [ | |
| ( | |
| # The intensifier slot is not decoration. "i do want to die" and "i just want | |
| # to die" are both common and both were missed by the first version of this | |
| # pattern, which required the verb to sit directly after the pronoun. | |
| "stated_intent_to_die", | |
| r"\bi\s+(do\s+|really\s+|just\s+|genuinely\s+|honestly\s+|kind\s?of\s+|" | |
| r"kinda\s+|sometimes\s+|always\s+)*" | |
| r"(want|wanna|need|have)\s+to\s+" | |
| r"(die|end\s+it(\s+all)?|end\s+my\s+life|not\s+exist|not\s+be\s+here|" | |
| r"not\s+wake\s+up|disappear\s+forever)\b", | |
| ), | |
| ( | |
| "kill_myself", | |
| r"\b(kill(ing)?|killed)\s+my\s?self\b", | |
| ), | |
| ("unalive_myself", r"\bun\s?alive\s+my\s?self\b"), | |
| ("euphemism_sewerslide", r"\bsewer\s?slide\b"), | |
| ("abbreviation_kms", r"\bkms\b"), | |
| ( | |
| "going_to_end_it", | |
| r"\b(i'?m|im|i\s+am)\s+(going\s+to|gonna|gna|about\s+to)\s+" | |
| r"(end\s+it(\s+all)?|end\s+my\s+life|do\s+it\s+tonight)\b", | |
| ), | |
| ("take_my_own_life", r"\btake\s+my\s+own\s+life\b"), | |
| ("wish_i_was_dead", r"\bi\s+wish\s+i\s+(was|were|had\s+never\s+been\s+born)\b" | |
| r"(\s+dead|\s+gone)?\b"), | |
| ("first_person_suicidal", r"\bi(\s+(am|feel|have\s+been)|'?m|'?ve\s+been)\s+" | |
| r"(really\s+|so\s+|very\s+)?suicidal\b"), | |
| # Preparation. These are the highest-precision signals in the whole gate: nobody | |
| # writes them casually, and each one indicates a plan rather than an idea. | |
| # Tightened after a smoke test floored "i wrote a note for my mum" at T4. A | |
| # break-glass on that sentence would de-anonymise a child for writing a note. | |
| # The qualifier is now mandatory: it is what makes the pattern high-precision. | |
| ("wrote_a_note", r"\b(i\s+)?(wrote|written|writing|finished|left)\s+(a\s+|my\s+)?" | |
| r"(suicide|goodbye|good\s?bye|final|last)\s+(note|letter|message)\b"), | |
| ("note_for_when_im_gone", r"\b(note|letter|message)\s+for\s+(when|after)\s+" | |
| r"i('?m|\s+am)\s+(gone|not\s+here|not\s+around)\b"), | |
| ("saved_pills", r"\b(saved|saving|collected|collecting|been\s+taking)\s+up?\s*" | |
| r"(my\s+)?(pills|tablets|meds|medication)\b"), | |
| ("giving_things_away", r"\bgiving\s+(away\s+)?(all\s+)?my\s+(stuff|things|" | |
| r"clothes|games)\s+away\b"), | |
| # Method, not just wish. A student who has worked out how is materially further | |
| # along than one who has not, and none of the patterns above catch it: this | |
| # phrasing contains no verb of intent and no risk noun at all. | |
| ("has_a_method_in_mind", r"\bi'?(ve|\s+have)?\s*(thought|think|been\s+thinking)\s+" | |
| r"about\s+how\s+(i'?d|i\s+would|to)\s+do\s+it\b"), | |
| ("knows_how_theyd_do_it", r"\bi\s+know\s+how\s+(i'?d|i\s+would)\s+do\s+it\b"), | |
| ("has_a_plan", r"\bi'?(ve|\s+have)\s+(got\s+)?a\s+plan\s+(for|to)\s+" | |
| r"(end|kill|when)\b"), | |
| ], | |
| Severity.MODERATE: [ | |
| ( | |
| "does_not_want_to_be_here", | |
| r"\bi\s+(don'?t|do\s+not|dont)\s+want\s+to\s+" | |
| r"(be\s+here|be\s+alive|live|wake\s+up|exist)\b", | |
| ), | |
| ( | |
| "better_off_without_me", | |
| r"\b(everyone|everybody|they|my\s+(family|mum|mom|dad|parents))" | |
| r"(\s+(would\s+be|are|is)|'?d\s+be)\s*better\s+off\s+without\s+me\b", | |
| ), | |
| ("no_point_living", r"\bwhat('?s|\s+is)\s+the\s+point\s+(of|in)\s+" | |
| r"(living|being\s+here|any\s?of\s?this|anything)\b"), | |
| ("suicide_term", r"\bsuicid(e|al)\b"), | |
| ("nobody_would_notice", r"\bno\s?body\s+would\s+(notice|care|miss\s+me)\b"), | |
| ( | |
| # A student reporting a friend's ideation. Deliberately MODERATE, not STRONG, | |
| # and deliberately a pattern rather than a suppressor: the first version of | |
| # this file suppressed it entirely, which made "my friend said she wants to | |
| # die and i'm scared" score 0.00 and vanish. It is a real signal about a real | |
| # child. MODERATE floors it at T3 (a counsellor within 24h) instead of T4, | |
| # because breaking glass on the *reporting* student is the wrong response to | |
| # a disclosure about somebody else. | |
| "third_party_disclosure", | |
| r"\bmy\s+(friend|mate|best\s?friend|sister|brother|cousin|classmate)\s+" | |
| r"\w{0,10}\s*(said|says|told\s+me|wants|keeps\s+saying|is|has\s+been)\s+" | |
| r"(that\s+)?(she|he|they)?\s*(wants?\s+to\s+|is\s+going\s+to\s+)?" | |
| r"(die|kill\s+(her|him|them)\s?self|end\s+it|suicidal|self[\s\-]?harming)\b", | |
| ), | |
| ], | |
| Severity.WEAK: [ | |
| ("cannot_go_on", r"\bi\s+(can'?t|cannot)\s+(do\s+this|go\s+on|keep\s+going)\s+" | |
| r"(any\s?more)?\b"), | |
| ("wants_to_disappear", r"\bi\s+(just\s+)?want\s+to\s+disappear\b"), | |
| ("tired_of_everything", r"\b(i'?m|im)\s+(so\s+)?tired\s+of\s+" | |
| r"(everything|all\s+of\s+this|being\s+here)\b"), | |
| ], | |
| } | |
| _ABUSE_DISCLOSURE: Bank = { | |
| Severity.STRONG: [ | |
| ( | |
| "caregiver_violence", | |
| r"\b(my\s+)?(dad|father|mum|mom|mother|step\s?dad|step\s?father|step\s?mum|" | |
| r"step\s?mom|uncle|aunt|older\s+brother|brother|cousin|coach|carer|" | |
| r"foster\s+(dad|mum|mom|parent))\s+" | |
| r"(hits|hit|beats|beat|punched|punches|kicked|kicks|strangled|" | |
| r"burnt|burned|hurts|hurt)\s+me\b", | |
| ), | |
| ( | |
| "sexual_abuse_disclosure", | |
| r"\b(touch(ed|es|ing)?)\s+me\s+" | |
| r"(inappropriately|where\s+he\s+shouldn'?t|down\s+there|when\s+i\s+was\s+" | |
| r"asleep|in\s+a\s+way\s+i\s+(didn'?t|did\s+not)\s+like)\b", | |
| ), | |
| ("made_me_do_things", r"\bmade\s+me\s+(touch|do)\s+(him|her|things|stuff)\b"), | |
| ("beaten_at_home", r"\bi\s+(get|got|keep\s+getting)\s+" | |
| r"(hit|beaten|beat\s+up|slapped|punched)\s+at\s+home\b"), | |
| ("home_not_safe", r"\b(it'?s|i'?m|im)\s+not\s+safe\s+(at\s+home|there|" | |
| r"in\s+my\s+(house|home))\b"), | |
| ], | |
| Severity.MODERATE: [ | |
| # MODERATE, not STRONG. A classic abuse red flag, but "scared to go home" also | |
| # covers a failed test and a lost phone. T3 puts a counsellor on it inside 24h, | |
| # which is the proportionate response to a phrase with a live benign reading. | |
| ("afraid_to_go_home", r"\b(scared|afraid|terrified|dreading)\s+" | |
| r"(to\s+go|of\s+going|going)\s+home\b"), | |
| ("scared_of_caregiver", r"\b(scared|afraid|terrified)\s+of\s+(my\s+)?" | |
| r"(dad|father|mum|mom|mother|step\s?dad|step\s?mum|" | |
| r"step\s?mom|uncle|carer)\b"), | |
| ("bruises_from_home", r"\bbruis(e|es|ed|ing)\b.{0,50}\b(home|dad|mum|mom|him|her)\b"), | |
| ("nobody_at_home_safe", r"\bi\s+(can'?t|cannot)\s+(tell|talk\s+to)\s+" | |
| r"(anyone\s+)?at\s+home\b"), | |
| ], | |
| Severity.WEAK: [ | |
| ("home_is_bad", r"\bthings\s+(are|get)\s+(bad|worse)\s+at\s+home\b"), | |
| ("hiding_at_home", r"\b(hide|hiding|lock(ed)?\s+myself)\s+in\s+my\s+room\b"), | |
| ], | |
| } | |
| _THREAT_OF_VIOLENCE: Bank = { | |
| Severity.STRONG: [ | |
| ( | |
| # The target list is closed on purpose. An earlier version ended with a bare | |
| # `\w+`, which made "i'm going to end it all tonight" and "im gonna kill | |
| # myself" both register as threats against another person: the catch-all | |
| # swallowed "it" and "myself". A missed threat against a named individual is | |
| # the price, and the classifier is the half of the system that covers names. | |
| "first_person_threat", | |
| r"\b(i'?m|im|i\s+am)\s+(going\s+to|gonna|gna)\s+" | |
| r"(kill|batter|stab|shoot|jump|smash|end)\s+" | |
| r"(you|him|her|them|us|that\s+\w+)\b", | |
| ), | |
| ( | |
| "will_hurt_you", | |
| r"\bi'?ll\s+(kill|batter|stab|shoot|jump|smash|hurt|beat)\s+" | |
| r"(you|him|her|them)\b", | |
| ), | |
| ( | |
| "reported_threat_against_student", | |
| r"\b(he|she|they|\w+)\s+(said|says|told\s+me)\s+" | |
| # `'?ll` sits beside `'?d` because every other future form already worked | |
| # ('d, would, will, was going to) and only the `'ll` contraction did not — | |
| # so "he said he'd batter me" floored at T3 and "he says he'll batter me" | |
| # floored at nothing. Found on day 7 by writing a seeded conversation in the | |
| # phrasing a student would actually use. Same shape as the day 5 contraction | |
| # gap in `first_person_cutting`: one form covered, its sibling missed. | |
| r"(that\s+)?(he|she|they)?\s*('?d|'?ll|\s+would|\s+was\s+going\s+to|" | |
| r"\s+is\s+going\s+to|\s+will)\s+" | |
| r"(kill|batter|stab|shoot|jump|hurt|beat)\s+me\b", | |
| ), | |
| ( | |
| "threatened_to", | |
| r"\bthreaten(ed|ing)?\s+to\s+(kill|batter|stab|shoot|hurt|beat|jump)\b", | |
| ), | |
| # Told to kill yourself. Severe harassment, and the reason the matching | |
| # attribution regex also suppresses the suicidal_ideation banks: this is not the | |
| # student's own intent, so it must not trigger a T4 break-glass on its own. | |
| ( | |
| "told_target_to_kill_themselves", | |
| r"\b(told|telling|tells|said|saying|says|keep\s+telling)\s+" | |
| r"(me|him|her|them)\s+(that\s+)?(i|he|she|they)?\s*(should\s+)?(to\s+)?" | |
| r"(go\s+)?(kill\s+(my|your|him|her|them)\s?self|kys|just\s+die|" | |
| r"un\s?alive\s+(my|your)\s?self)\b", | |
| ), | |
| ("kys_abbreviation", r"\bkys\b"), | |
| ("wait_until_i_see_you", r"\bwait\s+(til|till|until)\s+i\s+" | |
| r"(see|catch|find|get)\s+(you|him|her|them)\b"), | |
| ("youre_dead", r"\byou'?re\s+dead\b"), | |
| ], | |
| Severity.MODERATE: [ | |
| # "me" is in every target list here, and it is not an afterthought. These patterns | |
| # were first written from the perpetrator's point of view ("make you pay"), which | |
| # misses the direction this product actually receives: a victim reporting what was | |
| # said to them ("he says he'll make me pay"). Two synthetic conversations scored | |
| # zero because of it. | |
| ("beat_you_up", r"\b(beat|batter|jump|do)\s+(you|him|her|them|me)\s+up\b"), | |
| ("coming_for_you", r"\b(coming|going)\s+(to\s+)?(get|find)\s+(you|him|her|them|me)\b"), | |
| ("make_you_pay", r"\bmake\s+(you|him|her|them|me)\s+(pay|regret\s+it|sorry)\b"), | |
| ("group_planning_violence", r"\bthey'?re\s+(all\s+)?(waiting|planning)\s+" | |
| r"(for\s+me|to\s+get\s+me)\b"), | |
| ], | |
| Severity.WEAK: [ | |
| ("fight_talk", r"\b(fight|scrap)\s+(you|him|her|them)\b"), | |
| ("dead_meat", r"\b(you|he|she|they)'?re\s+(gonna\s+)?get\s+it\b"), | |
| ], | |
| } | |
| _WEAPON_MENTION: Bank = { | |
| Severity.STRONG: [ | |
| ( | |
| "weapon_being_carried", | |
| r"\b(bring|brings|bringing|brought|carry|carrying|carried|got|has|have|had|" | |
| r"pulled|showed|waving)\s+(a\s+|the\s+|his\s+|her\s+|their\s+)?" | |
| r"(knife|knives|blade|machete|gun|pistol|firearm|shank|hammer|bat)\b", | |
| ), | |
| ( | |
| "weapon_at_school", | |
| r"\b(knife|knives|blade|machete|gun|pistol|firearm|shank)\b" | |
| r".{0,30}\b(school|class|lesson|playground|gates|bus)\b", | |
| ), | |
| ], | |
| Severity.MODERATE: [ | |
| ( | |
| "weapon_attributed_to_person", | |
| r"\b(he|she|they|his|her|their|someone)\s+\w{0,12}\s*" | |
| r"(knife|knives|blade|machete|gun|pistol|shank)\b", | |
| ), | |
| ], | |
| Severity.WEAK: [ | |
| ("bare_weapon_noun", r"\b(knife|knives|blade|machete|gun|pistol|firearm|" | |
| r"shank|weapon|weapons)\b"), | |
| ], | |
| } | |
| # Never floors on its own; see ``taxonomy.gate_floor``. Single severity by design, because | |
| # "tonight" is not more imminent than "after school" in any way we can measure. | |
| _IMMINENT_TIME_MARKER: Bank = { | |
| Severity.MODERATE: [ | |
| ( | |
| "imminent_window", | |
| r"\b(tonight|to\s?night|today|tomorrow|this\s+(afternoon|evening|morning|" | |
| r"break|lunch|weekend)|after\s+school|before\s+school|at\s+lunch|" | |
| r"next\s+(period|lesson)|in\s+an?\s+(hour|minute|bit)|right\s+now|" | |
| r"on\s+my\s+way\s+home|when\s+i\s+get\s+home|any\s+minute)\b", | |
| ), | |
| ], | |
| } | |
| PATTERNS: dict[GateCategory, Bank] = { | |
| GateCategory.SELF_HARM_INTENT: _SELF_HARM_INTENT, | |
| GateCategory.SUICIDAL_IDEATION: _SUICIDAL_IDEATION, | |
| GateCategory.ABUSE_DISCLOSURE: _ABUSE_DISCLOSURE, | |
| GateCategory.THREAT_OF_VIOLENCE: _THREAT_OF_VIOLENCE, | |
| GateCategory.WEAPON_MENTION: _WEAPON_MENTION, | |
| GateCategory.IMMINENT_TIME_MARKER: _IMMINENT_TIME_MARKER, | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # Suppressors | |
| # -------------------------------------------------------------------------------------- | |
| # | |
| # A hit is dropped when its span sits inside a suppressor's span, so every suppressor must | |
| # span the trigger phrase it is cancelling. Test the containment, not just the match. | |
| _SELF_HARM_SUPPRESSORS: list[tuple[str, str]] = [ | |
| ( | |
| # \s* not \s+ after the pronoun: the contraction in "i'm not going to" leaves no | |
| # space between "i" and "'m", and requiring one made this suppressor silently dead | |
| # for the single most common way a student reassures you. | |
| "negated_self_harm", | |
| r"\bi\s*(would\s+never|will\s+never|'?m\s+not\s+going\s+to|" | |
| r"am\s+not\s+going\s+to|don'?t\s+want\s+to|never)\s+" | |
| r"(cut|hurt|harm|burn)\s+myself\b", | |
| ), | |
| ( | |
| "recovery_framing", | |
| r"\bi\s+(stopped|quit|haven'?t|have\s+not|used\s+to)\s+" | |
| r"\w{0,12}\s*(cut|cutting|self[\s\-]?harm(ing|ed)?|hurt\s+myself)\b", | |
| ), | |
| ( | |
| "topic_not_disclosure", | |
| r"\b(book|novel|film|movie|show|song|documentary|essay|assignment|project|" | |
| r"article|poem|presentation|lesson|talk|website|charity)\s+" | |
| r"(about|on|for)\s+\w{0,12}\s*self[\s\-]?harm(ing)?\b", | |
| ), | |
| ( | |
| "support_service_name", | |
| r"\bself[\s\-]?harm\s+" | |
| r"(prevention|awareness|support|helpline|hotline|charity|week|campaign|service)\b", | |
| ), | |
| ] | |
| _SUICIDE_SUPPRESSORS: list[tuple[str, str]] = [ | |
| ( | |
| "negated_ideation", | |
| r"\bi\s*(would\s+never|will\s+never|'?m\s+not\s+going\s+to|" | |
| r"am\s+not\s+going\s+to|never\s+said\s+i'?d|don'?t\s+want\s+to)\s+" | |
| r"(kill\s+my\s?self|die|end\s+it(\s+all)?|end\s+my\s+life|" | |
| r"un\s?alive\s+my\s?self)\b", | |
| ), | |
| ( | |
| "reassurance", | |
| r"\b(i'?m|im)\s+not\s+(suicidal|going\s+to\s+do\s+anything|" | |
| r"going\s+to\s+hurt\s+my\s?self)\b", | |
| ), | |
| ( | |
| # The student is reporting harassment, not disclosing intent. The same regex is a | |
| # STRONG threat_of_violence pattern, so the case still floors at T3. | |
| "told_by_someone_else", | |
| r"\b(told|telling|tells|said|saying|says|keep\s+telling)\s+" | |
| r"(me|him|her|them)\s+(that\s+)?(i|he|she|they)?\s*(should\s+)?(to\s+)?" | |
| r"(go\s+)?(kill\s+(my|your|him|her|them)\s?self|kys|just\s+die|" | |
| r"un\s?alive\s+(my|your)\s?self)\b", | |
| ), | |
| ( | |
| "topic_not_disclosure", | |
| r"\b(book|novel|film|movie|show|song|lyrics|documentary|essay|assignment|" | |
| r"project|article|poem|presentation|lesson|talk|website|charity|news)\s+" | |
| r"(about|on|for)\s+\w{0,12}\s*(suicide|suicidal|killing\s+(your|him|her)\s?self)\b", | |
| ), | |
| ( | |
| "support_service_name", | |
| r"\bsuicid(e|al)\s+" | |
| r"(prevention|awareness|support|helpline|hotline|charity|week|campaign|" | |
| r"service|rates?|statistics)\b", | |
| ), | |
| ( | |
| "hyperbole_killing_me", | |
| r"\b(homework|revision|exam|exams|essay|coursework|training|practice|this|that|" | |
| r"my\s+\w+|the\s+\w+|it)\s+(is|are|'?s|'?re|was|were)\s+" | |
| r"(literally\s+|actually\s+|so\s+)?killing\s+me\b", | |
| ), | |
| ("kill_for", r"\b(could|would)\s+kill\s+for\s+(a|an|some)\b"), | |
| ("distance_kms", r"\b\d+\s?kms?\b"), | |
| ( | |
| # Must span back to the pronoun, because that is where the ideation match starts. | |
| "die_of_embarrassment", | |
| r"\bi\s+(just\s+|literally\s+|actually\s+|still\s+)*(want|wanna)\s+to\s+die\s+" | |
| r"(of|from)\s+(embarrassment|shame|cringe|laughing|laughter|boredom)\b", | |
| ), | |
| ("dying_idiom", r"\b(dying|dead)\s+(of|from)\s+(laughter|boredom|embarrassment)\b"), | |
| ] | |
| _ABUSE_SUPPRESSORS: list[tuple[str, str]] = [ | |
| ( | |
| "fictional_or_historical", | |
| r"\b(in\s+the\s+(book|film|movie|show|story)|the\s+character|we\s+read\s+about)\b" | |
| r".{0,60}\b(hit|beat|hurt)\s+me\b", | |
| ), | |
| ( | |
| "play_fighting", | |
| r"\b(play|playing|joking|messing|pretend)\s+\w{0,10}\s*" | |
| r"(hit|hits|fight|fighting)\s+me\b", | |
| ), | |
| ] | |
| _THREAT_SUPPRESSORS: list[tuple[str, str]] = [ | |
| ( | |
| "gaming_context", | |
| r"\b(in\s+(the\s+)?game|on\s+(fortnite|minecraft|roblox|cod|fifa)|" | |
| r"my\s+(character|team)|respawn|final\s+boss|the\s+boss)\b" | |
| r".{0,40}\b(kill|shoot|stab|batter)\s+(you|him|her|them)\b", | |
| ), | |
| ( | |
| # Must span the pronoun too, or containment fails: the threat pattern's match | |
| # starts at "i'm", so a suppressor starting at "gonna" sits inside it rather than | |
| # around it and cancels nothing. | |
| "sports_or_game_idiom", | |
| r"\b(i'?m\s+|im\s+|i\s+am\s+|we'?re\s+|we\s+are\s+)?" | |
| r"(gonna|going\s+to|gna)\s+(smash|batter|destroy|beat|thrash|end)\s+" | |
| r"(you|him|her|them)\s+(at|in|on)\s+\w+\b", | |
| ), | |
| ] | |
| _WEAPON_SUPPRESSORS: list[tuple[str, str]] = [ | |
| ( | |
| "kitchen_or_craft_context", | |
| r"\b(kitchen|butter|bread|cooking|food\s?tech|craft|carving|penknife|" | |
| r"scout|camping|dinner)\s+\w{0,8}\s*(knife|knives|blade)\b", | |
| ), | |
| ( | |
| "gaming_context", | |
| r"\b(in\s+(the\s+)?game|fortnite|minecraft|roblox|cod|warzone|nerf|water)\s+" | |
| r"\w{0,10}\s*(gun|knife|blade)\b", | |
| ), | |
| ("figurative_gun", r"\b(jumped\s+the\s+gun|gun\s+it|glue\s+gun|nerf\s+gun)\b"), | |
| ] | |
| SUPPRESSORS: dict[GateCategory, list[tuple[str, str]]] = { | |
| GateCategory.SELF_HARM_INTENT: _SELF_HARM_SUPPRESSORS, | |
| GateCategory.SUICIDAL_IDEATION: _SUICIDE_SUPPRESSORS, | |
| GateCategory.ABUSE_DISCLOSURE: _ABUSE_SUPPRESSORS, | |
| GateCategory.THREAT_OF_VIOLENCE: _THREAT_SUPPRESSORS, | |
| GateCategory.WEAPON_MENTION: _WEAPON_SUPPRESSORS, | |
| GateCategory.IMMINENT_TIME_MARKER: [], | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # Compilation | |
| # -------------------------------------------------------------------------------------- | |
| # | |
| # Compiled once at import. The gate sits in front of every student turn in a live chat, so | |
| # recompiling per call would put regex compilation on the latency path for no reason. | |
| _FLAGS = re.IGNORECASE | re.DOTALL | |
| COMPILED_PATTERNS: dict[GateCategory, dict[Severity, list[tuple[str, re.Pattern[str]]]]] = { | |
| category: { | |
| severity: [(name, re.compile(rx, _FLAGS)) for name, rx in entries] | |
| for severity, entries in bank.items() | |
| } | |
| for category, bank in PATTERNS.items() | |
| } | |
| COMPILED_SUPPRESSORS: dict[GateCategory, list[tuple[str, re.Pattern[str]]]] = { | |
| category: [(name, re.compile(rx, _FLAGS)) for name, rx in entries] | |
| for category, entries in SUPPRESSORS.items() | |
| } | |
| def pattern_count() -> dict[str, int]: | |
| """Bank sizes, for the log entry and for `docs/results.md`.""" | |
| return { | |
| category.value: sum(len(v) for v in bank.values()) | |
| for category, bank in PATTERNS.items() | |
| } | |