File size: 11,678 Bytes
79a6369 fea6929 79a6369 f046303 fea6929 b2f5c42 79a6369 b2f5c42 79a6369 9a0393a 79a6369 b2f5c42 79a6369 aedf4f8 79a6369 aedf4f8 79a6369 fea6929 4f20fa7 fea6929 4f20fa7 fea6929 4f20fa7 fea6929 79a6369 b2f5c42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | """
Canonical V2.5 route and safety-tier schema.
This module is intentionally rule-based and dependency-free so it can be used by
the fast demo backend, tests, and the heavier pipeline without slowing startup.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import re
from .safety_policy import SafetyLevel
class SafetyTier(str, Enum):
IMMINENT_SAFETY = "imminent_safety"
HIGH_DISTRESS = "high_distress"
SUPPORT_NAVIGATION = "support_navigation"
WELLBEING = "wellbeing"
class SupportRoute(str, Enum):
ACADEMIC_SETBACK = "academic_setback"
EXAM_STRESS = "exam_stress"
ACCESSIBILITY_ADS = "accessibility_ads"
ADVISOR_CONFLICT = "advisor_conflict"
COUNSELING_NAVIGATION = "counseling_navigation"
BASIC_NEEDS = "basic_needs"
CARE_VIOLENCE_CONFIDENTIAL = "care_violence_confidential"
AUTHORITY_MISCONDUCT = "authority_misconduct"
PEER_HELPER = "peer_helper"
LONELINESS_ISOLATION = "loneliness_isolation"
ANXIETY_PANIC = "anxiety_panic"
LOW_MOOD = "low_mood"
CRISIS_IMMEDIATE = "crisis_immediate"
GENERAL_STUDENT_SUPPORT = "general_student_support"
OUT_OF_SCOPE = "out_of_scope"
@dataclass(frozen=True)
class RouteDecision:
route: SupportRoute
tier: SafetyTier
reason: str
audience_mode: str = "student"
def normalize_text(text: str) -> str:
return re.sub(r"\s+", " ", text.lower()).strip()
def map_safety_level(level: SafetyLevel | str, wellbeing_request: bool = False) -> SafetyTier:
value = level.value if isinstance(level, SafetyLevel) else str(level)
if value in {SafetyLevel.EMERGENCY.value, SafetyLevel.CRISIS.value, "emergency", "crisis"}:
return SafetyTier.IMMINENT_SAFETY
if value == SafetyLevel.WELLBEING_SUPPORT.value:
return SafetyTier.HIGH_DISTRESS if not wellbeing_request else SafetyTier.WELLBEING
return SafetyTier.WELLBEING if wellbeing_request else SafetyTier.SUPPORT_NAVIGATION
def classify_route(
message: str,
safety_tier: SafetyTier,
audience_mode: str = "student",
) -> RouteDecision:
text = normalize_text(message)
if safety_tier == SafetyTier.IMMINENT_SAFETY:
if _is_peer_helper(text, audience_mode):
return RouteDecision(SupportRoute.PEER_HELPER, safety_tier, "peer_imminent_safety", audience_mode)
return RouteDecision(SupportRoute.CRISIS_IMMEDIATE, safety_tier, "imminent_safety_tier", audience_mode)
if _is_peer_helper(text, audience_mode):
return RouteDecision(SupportRoute.PEER_HELPER, safety_tier, "peer_helper_language", audience_mode)
if _has_any(text, ("prescribe", "diagnose", "medication dosage", "hipaa complaint", "legal complaint", "lawsuit", "litigation", "dining hall rumor", "rumor about mold")):
return RouteDecision(SupportRoute.OUT_OF_SCOPE, safety_tier, "out_of_scope_language", audience_mode)
# Authority-figure misconduct goes before advisor_conflict / care so that
# alleged ethics violations route to OCRSM / Student Conduct / Dean of
# Students channels rather than getting absorbed into academic_setback
# or generic care templates.
if _is_authority_misconduct(text):
return RouteDecision(SupportRoute.AUTHORITY_MISCONDUCT, safety_tier, "authority_misconduct_language", audience_mode)
if _has_any(text, ("counseling center", "referral", "off-campus care", "after hours", "after-hours", "brief assessment", "group therapy", "individual counseling", "mental health crises", "number to call")):
return RouteDecision(SupportRoute.COUNSELING_NAVIGATION, safety_tier, "counseling_navigation_language", audience_mode)
if _has_any(text, ("accommodation", "disability", "504", "extended time", "assistive tech", "paratransit")) or _has_word(text, "ads"):
return RouteDecision(SupportRoute.ACCESSIBILITY_ADS, safety_tier, "accessibility_language", audience_mode)
if _has_any(text, ("advisor", "ombuds", "funding threatened", "threatened my funding", "funding might disappear", "pi is", "my pi", "committee feedback", "retaliatory", "neutral process", "power dynamics")):
return RouteDecision(SupportRoute.ADVISOR_CONFLICT, safety_tier, "advisor_or_ombuds_language", audience_mode)
if _has_any(text, ("no food", "out of money", "hungry because", "food rent", "food or rent", "can't afford food", "cannot afford food", "nowhere to sleep")):
return RouteDecision(SupportRoute.BASIC_NEEDS, safety_tier, "basic_needs_language", audience_mode)
if _has_any(text, ("violence", "assault", "stalking", "abuse", "harassment", "dating violence")):
return RouteDecision(SupportRoute.CARE_VIOLENCE_CONFIDENTIAL, safety_tier, "care_or_violence_language", audience_mode)
if _has_any(text, ("failed", "fail", "failed my exam", "future is over", "grade", "grades")):
return RouteDecision(SupportRoute.ACADEMIC_SETBACK, safety_tier, "academic_setback_language", audience_mode)
if _has_any(text, ("asked out", "first date", "meet a girl", "meet a guy", "meet someone", "going on a date", "date tomorrow", "nervous to meet", "romantic")):
return RouteDecision(SupportRoute.ANXIETY_PANIC, safety_tier, "social_or_date_nerves", audience_mode)
if _has_any(text, ("exam", "test", "quiz", "midterm", "final", "qualifying exam", "study", "deadline", "comps prep", "labs plus ta", "syllabus")):
return RouteDecision(SupportRoute.EXAM_STRESS, safety_tier, "exam_stress_language", audience_mode)
if _has_any(text, ("counseling", "counselling", "therapy", "therapist", "appointment", "get started")):
return RouteDecision(SupportRoute.COUNSELING_NAVIGATION, safety_tier, "counseling_navigation_language", audience_mode)
if _has_any(text, ("lonely", "isolated", "no one cares", "no friends", "alone", "roommate moved out", "dorm feels hollow", "nobody texts", "burden people", "disappear socially")):
return RouteDecision(SupportRoute.LONELINESS_ISOLATION, safety_tier, "loneliness_language", audience_mode)
if _has_any(text, (
# Formal anxiety/panic language
"panic", "panicking", "anxiety", "anxious", "grounding", "breathing",
"stomach is wrecked", "freeze in social", "intrusive thoughts",
"heart rate", "drinking more", "mindfulness", "sensory overwhelm",
"quick reset", "journaling", "worry loops",
# Slang / informal anxiety markers
"stressed", "stressing", "stressed out", "stress out", "freaking out",
"freaked out", "spiraling", "overwhelmed", "overthinking",
"can't breathe", "cant breathe", "heart racing", "shaking",
"on edge", "wound up", "tense",
)):
return RouteDecision(SupportRoute.ANXIETY_PANIC, safety_tier, "anxiety_or_panic_language", audience_mode)
if _has_any(text, (
# Formal low-mood language
"depressed", "depressing", "depression", "low mood", "hopeless",
"feel numb", "motivation disappeared", "canceling plans", "guilty",
"dark moods", "pointless",
# Slang / informal low-mood markers
"im sad", "i'm sad", "feeling sad", "so sad", "really sad",
"im down", "i'm down", "feeling down", "feel down", "down lately",
"im tired", "i'm tired", "so tired", "exhausted",
"i give up", "want to give up", "wanna give up", "im done",
"i'm done", "so done", "ngl tired", "burned out", "burnt out",
"no point", "what's the point", "whats the point",
"everything sucks", "life sucks",
)):
return RouteDecision(SupportRoute.LOW_MOOD, safety_tier, "low_mood_language", audience_mode)
return RouteDecision(SupportRoute.GENERAL_STUDENT_SUPPORT, safety_tier, "default_support_navigation", audience_mode)
def action_ladder(tier: SafetyTier | str) -> dict[str, str]:
value = tier.value if isinstance(tier, SafetyTier) else str(tier)
return {
SafetyTier.IMMINENT_SAFETY.value: {
"mode": "imminent_safety",
"generation": "blocked",
"retrieval": "crisis_only",
"goal": "human handoff now",
},
SafetyTier.HIGH_DISTRESS.value: {
"mode": "high_distress",
"generation": "template_or_guarded",
"retrieval": "retrieval + wellbeing_only",
"goal": "stabilize and connect to support",
},
SafetyTier.SUPPORT_NAVIGATION.value: {
"mode": "support_navigation",
"generation": "route_template",
"retrieval": "retrieval",
"goal": "practical next step",
},
SafetyTier.WELLBEING.value: {
"mode": "wellbeing",
"generation": "brief coping support",
"retrieval": "retrieval + wellbeing_only",
"goal": "low-risk support and campus option",
},
}.get(value, {"mode": value, "generation": "guarded", "retrieval": "retrieval", "goal": "support navigation"})
# Authority-figure misconduct: the user is REPORTING what a person in a
# position of authority over them did or said. Distinct from advisor_conflict
# (academic disputes like goalpost-moving without alleged ethics violations).
# Detection is conservative: requires (authority noun) AND (problematic
# content) in the same message.
# Authority-figure nouns. Single-word entries are matched with word
# boundaries so "ta" doesn't fire on "stay" or "data" and "ra" doesn't fire
# on "rage" or "fragrance". Multi-word phrases stay as substring matches.
_AUTHORITY_NOUNS_WORD_BOUNDED = (
"counsellor", "counselor", "advisor", "adviser", "professor", "prof",
"teacher", "ta", "ra", "coach", "dean", "director", "mentor",
"instructor", "supervisor", "boss", "pi",
)
_AUTHORITY_NOUNS_PHRASES = (
"department chair", "principal investigator", "my pi",
)
_AUTHORITY_NOUN_REGEX = re.compile(
r"\b(?:" + "|".join(re.escape(w) for w in _AUTHORITY_NOUNS_WORD_BOUNDED) + r")\b",
re.IGNORECASE,
)
_AUTHORITY_MISCONDUCT_CONTENT = (
# Direct harm or illegality suggested to the student
"rob", "steal", "to lie", "commit fraud", "fake the",
"kill yourself", "hurt yourself",
# Boundary violations / harassment
"inappropriate", "made me uncomfortable", "touched me", "came on to me",
"hit on me", "made a pass", "asked me out", "wanted to date me", "wants to date me",
"sexual comment", "sexual remark", "creepy",
# Discrimination
"racist", "sexist", "homophobic", "transphobic", "ableist", "xenophobic",
# Coercion / retaliation
"retaliated", "retaliating", "retaliation", "threatened me",
"threatened to fail", "threatened to expel", "blackmail",
"leveraged my", "weaponized",
# Confidentiality breach
"told my parents", "told everyone", "broke confidentiality",
"outed me", "betrayed my trust",
# Faux authority validation
"told me to give up", "told me to drop out",
)
def _is_authority_misconduct(text: str) -> bool:
has_authority = (
bool(_AUTHORITY_NOUN_REGEX.search(text))
or _has_any(text, _AUTHORITY_NOUNS_PHRASES)
)
has_content = _has_any(text, _AUTHORITY_MISCONDUCT_CONTENT)
return has_authority and has_content
def _is_peer_helper(text: str, audience_mode: str) -> bool:
if audience_mode == "helping_friend":
return True
return _has_any(text, ("my friend", "my roommate", "my labmate", "my teammate", "someone i know", "they said goodbye", "not tell anyone"))
def _has_any(text: str, phrases: tuple[str, ...]) -> bool:
return any(phrase in text for phrase in phrases)
def _has_word(text: str, word: str) -> bool:
return bool(re.search(rf"\b{re.escape(word)}\b", text))
|