261 lines
9.2 KiB
Python
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)
|