Files
the-door/crisis_detector.py
Timmy Time 46597e2962
All checks were successful
Smoke Test / smoke (push) Successful in 4s
feat: crisis detection and response system (#23)
2026-04-13 04:11:46 +00:00

261 lines
9.2 KiB
Python

"""
Crisis Detection System for the-door.
Scans text for suicide, self-harm, and hopelessness signals.
Returns risk level, matched keywords, and surrounding context.
This is the most important code in the foundation.
Every line here exists because someone's life depends on it.
Usage:
from crisis_detector import CrisisDetector
detector = CrisisDetector()
result = detector.scan("I can't go on anymore")
print(result.risk_level) # "HIGH"
print(result.matched_keywords) # ["can't go on"]
print(result.context) # surrounding text snippets
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
@dataclass
class CrisisResult:
"""Result of a crisis text scan."""
risk_level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
matched_keywords: List[str] = field(default_factory=list)
context: List[str] = field(default_factory=list)
score: float = 0.0
timestamp: Optional[str] = None
def __bool__(self):
return self.risk_level != "NONE"
# ── Keyword definitions ──────────────────────────────────────────
CRITICAL_KEYWORDS = {
"suicide": r"\bsuicid(?:al|e|ed)\b",
"kill myself": r"\bkill\s*(?:my)?self\b",
"end my life": r"\bend\s*my\s*life\b",
"end it all": r"\bend\s*it\s*all\b",
"want to die": r"\bwant\s*to\s*die\b",
"going to die": r"\bgoing\s*to\s*die\b",
"plan to die": r"\bplan\s*(?:to|for)\s*(?:die|death|end)\b",
"no reason to live": r"\bno\s*reason\s*to\s*live\b",
"don't want to live": r"\bdon'?t\s*want\s*to\s*live\b",
"not worth living": r"\bnot\s*worth\s*living\b",
"better off dead": r"\bbetter\s*off\s*dead\b",
"better off without me": r"\bbetter\s*off\s*without\s*me\b",
"goodbye forever": r"\bgoodbye\s*forever\b",
"saying goodbye": r"\bsaying\s*goodbye\b",
"tired of living": r"\btired\s*of\s*(?:living|life|existence)\b",
"wrote a will": r"\bwrote\s*(?:a|my)\s*(?:will|suicide\s*note|letter)\b",
"giving away possessions": r"\bgiving\s*away\s*(?:my|all)\s*possess\b",
}
HIGH_KEYWORDS = {
"hopeless": r"\bhopeless(?:ness)?\b",
"can't go on": r"\bcan'?t\s*go\s*on\b",
"can't keep going": r"\bcan'?t\s*keep\s*going\b",
"can't take this": r"\bcan'?t\s*take\s*this\b",
"give up": r"\bgive(?:n)?\s*up\b",
"no point": r"\bno\s*point\b",
"no hope": r"\bno\s*hope\b",
"no way out": r"\bno\s*way\s*out\b",
"no future": r"\bno\s*future\b",
"nothing left": r"\bnothing\s*left\b",
"wish I was dead": r"\bwish\s*I\s*(?:was|were)\s*(?:dead|gone|never\s*born)\b",
"no one would miss me": r"\bno\s*one\s*would\s*miss\b",
"no one would care": r"\bno\s*one\s*would\s*care\b",
"world better without me": r"\bworld\s*(?:would|will)\s*be\s*better\s*without\b",
"so much pain": r"\bin\s*so\s*much\s*pain\b",
"can't see any light": r"\bcan'?t\s*see\s*(?:any\s*)?(?:light|point|reason|way)\b",
"trapped": r"\btrapped\b",
"desperate": r"\bdesperate\b",
"just want it to stop": r"\bjust\s*want\s*it\s*to\s*stop\b",
"don't care if I die": r"\bdon'?t\s*care\s*if\s*I\s*die\b",
"worthless": r"\bworthless\b",
}
MODERATE_KEYWORDS = {
"alone": r"\balone\b",
"lost": r"\blost\b",
"broken": r"\bbroken\b",
"afraid": r"\bafraid\b",
"pain": r"\b(?:in\s*)?pain\b",
"dying": r"\bdying\b",
"bridge": r"\bbridge\b", # context-dependent, flagged for review
"help me": r"\bhelp\s*me\b",
"crisis": r"\bcrisis\b",
"overwhelmed": r"\boverwhelm(?:ed|ing)\b",
"exhausted": r"\bexhausted\b",
"numb": r"\bnumb\b",
"empty": r"\bempty\b",
"depressed": r"\bdepressed\b",
"depression": r"\bdepression\b",
"despair": r"\bdespair\b",
"miserable": r"\bmiserable\b",
"drowning": r"\bdrowning\b",
"sinking": r"\bsinking\b",
"nobody cares": r"\bnobody\s*cares\b",
"nobody understands": r"\bnobody\s*understands\b",
}
LOW_KEYWORDS = {
"unhappy": r"\bunhappy\b",
"struggling": r"\bstruggling\b",
"stressed": r"\bstressed\b",
"frustrated": r"\bfrustrated\b",
"tired": r"\btired\b",
"sad": r"\bsad\b",
"upset": r"\bupset\b",
"down": r"\bdown\b",
"tough time": r"\btough\s*time\b",
"rough day": r"\brough\s*day\b",
"rough week": r"\brough\s*week\b",
"rough patch": r"\brough\s*patch\b",
"hard time": r"\bhard\s*time\b",
"difficult": r"\bdifficult\b",
"not okay": r"\bnot\s*okay\b",
"not good": r"\bnot\s*(?:good|great)\b",
"burnout": r"\bburnout\b",
"not feeling myself": r"\bnot\s*feeling\s*(?:like\s*)?myself\b",
}
# ── Risk level scoring ───────────────────────────────────────────
RISK_SCORES = {
"CRITICAL": 1.0,
"HIGH": 0.75,
"MODERATE": 0.5,
"LOW": 0.25,
"NONE": 0.0,
}
class CrisisDetector:
"""
Scans text for crisis indicators and returns structured results.
Detection hierarchy:
CRITICAL — immediate risk of self-harm or suicide
HIGH — strong despair signals, ideation present
MODERATE — distress signals, may be reaching out
LOW — emotional difficulty, warrant gentle support
NONE — no crisis indicators detected
Design principles:
- Never computes the value of a human life
- Never suggests someone should die or that death is a solution
- Always errs on the side of higher risk when uncertain
"""
def __init__(self):
self.critical_patterns = CRITICAL_KEYWORDS
self.high_patterns = HIGH_KEYWORDS
self.moderate_patterns = MODERATE_KEYWORDS
self.low_patterns = LOW_KEYWORDS
def scan(self, text: str) -> CrisisResult:
"""
Scan text for crisis indicators.
Args:
text: The message text to analyze.
Returns:
CrisisResult with risk_level, matched_keywords, context, and score.
"""
if not text or not text.strip():
return CrisisResult(risk_level="NONE", score=0.0)
text_lower = text.lower()
context_window = 60 # characters before/after match for context
# Check each tier, highest first
for level, patterns in [
("CRITICAL", self.critical_patterns),
("HIGH", self.high_patterns),
("MODERATE", self.moderate_patterns),
("LOW", self.low_patterns),
]:
matched = []
contexts = []
for keyword, pattern in patterns.items():
match = re.search(pattern, text_lower)
if match:
matched.append(keyword)
# Extract surrounding context
start = max(0, match.start() - context_window)
end = min(len(text), match.end() + context_window)
snippet = text[start:end].strip()
if start > 0:
snippet = "..." + snippet
if end < len(text):
snippet = snippet + "..."
contexts.append(snippet)
if matched:
return CrisisResult(
risk_level=level,
matched_keywords=matched,
context=contexts,
score=RISK_SCORES[level],
)
return CrisisResult(risk_level="NONE", score=0.0)
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
"""Scan multiple texts, returning the highest-risk result per text."""
return [self.scan(t) for t in texts]
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
"""Scan multiple texts and return only the highest-risk result."""
results = self.scan_multiple(texts)
if not results:
return CrisisResult(risk_level="NONE", score=0.0)
return max(results, key=lambda r: r.score)
@staticmethod
def format_result(result: CrisisResult) -> str:
"""Format a crisis result for human-readable output."""
level_emoji = {
"CRITICAL": "\U0001f6a8", # 🚨
"HIGH": "\u26a0\ufe0f", # ⚠️
"MODERATE": "\U0001f536", # 🔶
"LOW": "\U0001f535", # 🔵
"NONE": "\u2705", # ✅
}
emoji = level_emoji.get(result.risk_level, "\u2753")
lines = [
f"{emoji} Risk Level: {result.risk_level} (score: {result.score:.2f})",
f"Matched keywords: {len(result.matched_keywords)}",
]
if result.matched_keywords:
lines.append(f" Keywords: {', '.join(result.matched_keywords)}")
if result.context:
lines.append("Context:")
for ctx in result.context:
lines.append(f" {ctx}")
return "\n".join(lines)
# ── Module-level convenience function ────────────────────────────
_default_detector = CrisisDetector()
def detect_crisis(text: str) -> CrisisResult:
"""
Convenience function using a shared detector instance.
Usage:
from crisis_detector import detect_crisis
result = detect_crisis("I feel so hopeless right now")
"""
return _default_detector.scan(text)