diff --git a/crisis/detect.py b/crisis/detect.py index de1b216..6f6b48b 100644 --- a/crisis/detect.py +++ b/crisis/detect.py @@ -51,13 +51,13 @@ HIGH_INDICATORS = [ r"\bwish\s+I\s+(?:was|were)\s+(?:dead|gone|never\s+born)\b", r"\bdon'?t\s+matter\s+if\s+I\s+exist\b", r"\bno\s+one\s+would\s+(?:care|miss)\b", - r"\bworld\s+would?\s+be\s+better\s+without\b", - r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment)\b", - r"\bcan'?t\s+see\s+any\s+(?:point|reason)\b", + r"\bworld\s+would?\s+be\s+better\s+without\s+me\b", + r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment|anguish)\b", + r"\bcan'?t\s+see\s+any\s+(?:point|reason|hope|way)\b", r"\bescape\s+from\s*this", r"\bjust\s+want\s+it\s+to\s+stop\b", - r"\btrapped\s+(?:in\s+(?:my|this|a\s+dark)|and\s+can'?t\s+escape)\b", r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b", + r"\bdisappeared\s+forever\b", # Contextual despair phrases (from crisis_detector.py legacy) r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b", r"\beverything\s+is\s+hopeless\b", @@ -68,6 +68,8 @@ HIGH_INDICATORS = [ r"\bno\s*hope\s+(?:left|remaining)\b", r"\bno\s*way\s*out\b", r"\bfeel(?:s|ing)?\s+trapped\b", + r"\btrapped\s+in\s+this\s+(?:situation|life|pain|darkness|hell)\b", + r"\btrapped\s+and\s+can'?t\s+escape\b", r"\bdesperate\s+(?:for\s+)?help\b", r"\bfeel(?:s|ing)?\s+desperate\b", ] @@ -99,6 +101,8 @@ MEDIUM_INDICATORS = [ r"\bsinking\b", r"\bdrowning\b", r"\bhopeless\b", + r"\blost\s+all\s+hope\b", + r"\bno\s+tomorrow\b", # Contextual versions (from crisis_detector.py legacy) r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b", r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b", @@ -112,7 +116,7 @@ MEDIUM_INDICATORS = [ LOW_INDICATORS = [ r"\bunhappy\b", r"\bdown\b", - r"\btough\s*time\b", + r"\btough\s*(?:time|day|week)\b", r"\brough\s+(?:day|week|patch)\b", r"\bstressed\b", r"\bburnout\b", @@ -122,6 +126,8 @@ LOW_INDICATORS = [ r"\btired\b", r"\bsad\b", r"\bupset\b", + r"\blonely\b", + r"\banxious?\b", r"\bnot\s*(?:good|great|okay)\b", r"\bthings\s*are\s*hard\b", r"\bstruggling\b", diff --git a/dying_detection/__init__.py b/dying_detection/__init__.py index 5bbcc31..45c4df5 100644 --- a/dying_detection/__init__.py +++ b/dying_detection/__init__.py @@ -1,31 +1,34 @@ """ -When a Man Is Dying — Despair/Suicide Detection System +DEPRECATED — Use crisis.detect instead. -Standalone detection module that parses incoming text for -despair and suicide indicators, classifies into tiers, -and returns structured response with recommended actions. +This module is a thin wrapper around crisis.detect for backward compatibility. +All unique patterns have been merged into crisis/detect.py (see issue #40). -Tiers: - LOW — General sadness, stress, difficult times - MEDIUM — Hopelessness, isolation, worthlessness - HIGH — Active despair, mentions of death, "can't go on" - CRITICAL — Imminent risk, explicit intent, plan, method - -Integration: - Designed to work with crisis/ module from PR #4. - When crisis/ is available, uses it as the detection backend. - Falls back to internal detection when crisis/ is not present. +This module will be removed in a future release. """ -import re -import json -import hashlib +import warnings from dataclasses import dataclass, field, asdict from typing import List, Optional, Dict +import json +import hashlib + +# Re-export the canonical detection +from crisis.detect import detect_crisis, CrisisDetectionResult + +# Issue deprecation warning on import +warnings.warn( + "dying_detection is deprecated. Use 'from crisis.detect import detect_crisis' instead. " + "All patterns have been consolidated into crisis/detect.py. " + "See issue #40.", + DeprecationWarning, + stacklevel=2, +) @dataclass class DetectionResult: + """Backward-compatible result type matching the old dying_detection API.""" level: str indicators: List[str] = field(default_factory=list) recommended_action: str = "" @@ -34,110 +37,9 @@ class DetectionResult: session_hash: str = "" -# ── Tiered indicator patterns ─────────────────────────────────── -# Each pattern is a regex matched against lowercased input. - -CRITICAL_PATTERNS = [ - (r"\bkill\s*(my)?self\b", "explicit self-harm intent"), - (r"\bsuicid(?:al|ed|e)\b", "suicide reference"), - (r"\bend\s*(my)?\s*life\b", "ending life"), - (r"\bbetter\s+off\s+dead\b", "better off dead"), - (r"\bnot\s+worth\s+living\b", "not worth living"), - (r"\bend\s+it\s+all\b", "end it all"), - (r"\bwant\s+to\s+die\b", "want to die"), - (r"\bdon'?t\s+want\s+to\s+live\b", "don't want to live"), - (r"\bcan'?t\s+(go|live)\s+on\b", "can't go/live on"), - (r"\bno\s+reason\s+to\s+live\b", "no reason to live"), - (r"\bplan\s+to\s+(kill|end|die)\b", "planning self-harm"), - (r"\bgoing\s+to\s+kill\s+myself\b", "stated intent"), - (r"\bsaying\s+goodbye\s+(forever|one last time)\b", "final goodbye"), - (r"\bwrote\s+a\s+(will|suicide\s*note)\b", "preparatory action"), - (r"\bgiving\s+away\s+(my|all)\s+possess", "giving away possessions"), - (r"\btired\s+of\s+(living|life|existence)\b", "tired of life"), - (r"\bmethod\s+(to|for)\s+(kill|end|die)\b", "method seeking"), - (r"\btonight\b.*\b(die|kill|end|suicid)", "immediate timeframe + intent"), - (r"\b(die|kill|end)\b.*\btonight", "immediate timeframe + intent"), - (r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone)", "disappearance plan"), -] - -HIGH_PATTERNS = [ - (r"\bdespair\b", "despair"), - (r"\bhopeless(?:ness)?\b", "hopelessness"), - (r"\bcan'?t\s+take\s+this\s+anymore\b", "can't take it"), - (r"\bdon'?t\s+care\s+if\s+I\s+die\b", "death indifference"), - (r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b", "wish to be dead"), - (r"\bworld\s+would\s+be\s+better\s+without\s+me\b", "better without me"), - (r"\bin\s+so\s+much\s+(pain|agony|suffering|torment|angui)", "extreme suffering"), - (r"\bcan'?t\s+see\s+any\s+(point|reason|light|hope|way)\b", "no light ahead"), - (r"\btrapped\b", "feeling trapped"), - (r"\bjust\s+want\s+it\s+to\s+stop\b", "want to stop"), - (r"\bno\s+way\s+out\b", "no way out"), - (r"\bno\s+one\s+would\s+(care|miss)\b", "no one would care/miss"), - (r"\beverything\s+is\s+(pointless|broken|ruined|meaningless)\b", "existential collapse"), - (r"\bno\s+point\s+in\s+anything\b", "pointlessness"), - (r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone|disappeared)", "no one would notice"), - (r"\bdisappeared\s+forever\b", "disappeared forever"), -] - -MEDIUM_PATTERNS = [ - (r"\bno\s+hope\b", "no hope"), - (r"\bcan'?t\s+go\s+on\b", "can't go on"), - (r"\bcan'?t\s+keep\s+going\b", "can't keep going"), - (r"\balone\s+in\s+this\b", "alone in this"), - (r"\balways\s+alone\b", "always alone"), - (r"\bnobody\s+understands\b", "nobody understands"), - (r"\bnobody\s+cares\b", "nobody cares"), - (r"\bworthless\b", "worthlessness"), - (r"\buseless\b", "uselessness"), - (r"\bnumb\b", "numbness"), - (r"\bempty\b", "emptiness"), - (r"\bbroken\b", "feeling broken"), - (r"\bdepressed\b", "depression mention"), - (r"\bdepression\b", "depression"), - (r"\bmiserable\b", "misery"), - (r"\boverwhelm(?:ed|ing)\b", "overwhelmed"), - (r"\bcannot\s+cope\b", "cannot cope"), - (r"\b(drowning|sinking)\b", "drowning/sinking"), - (r"\bforgotten\b", "feeling forgotten"), - (r"\blost\s+all\s+hope\b", "lost all hope"), - (r"\bno\s+future\b", "no future"), - (r"\bno\s+tomorrow\b", "no tomorrow"), -] - -LOW_PATTERNS = [ - (r"\bunhappy\b", "unhappy"), - (r"\brough\s+(day|week|patch)\b", "rough time"), - (r"\btough\s+(time|day|week)\b", "tough time"), - (r"\bstressed\b", "stressed"), - (r"\bburnout\b", "burnout"), - (r"\bfrustrated\b", "frustrated"), - (r"\bthings\s+(are\s+)?hard\b", "things are hard"), - (r"\bnot\s+feeling\s+(great|myself|good)\b", "not feeling good"), - (r"\bstruggl", "struggling"), - (r"\bdown\b", "feeling down"), - (r"\bsad\b", "sad"), - (r"\bupset\b", "upset"), - (r"\blonely\b", "lonely"), - (r"\banxious?\b", "anxious/anxiety"), - (r"\bnot\s+okay\b", "not okay"), -] - -# ── Pattern collections for easy iteration ────────────────────── - -TIER_PATTERNS: Dict[str, List[tuple]] = { - "CRITICAL": CRITICAL_PATTERNS, - "HIGH": HIGH_PATTERNS, - "MEDIUM": MEDIUM_PATTERNS, - "LOW": LOW_PATTERNS, -} - - def detect(text: str) -> DetectionResult: """ - Primary detection function. - - If the crisis/ module is available, delegate to it. - Otherwise, use the internal pattern engine. + Primary detection function — delegates to crisis.detect. Args: text: User message to analyze @@ -145,150 +47,25 @@ def detect(text: str) -> DetectionResult: Returns: DetectionResult with level, indicators, recommended_action, confidence """ - # Try to use the crisis module if available - try: - from crisis.detect import detect_crisis as _crisis_detect - result = _crisis_detect(text) - # Map to DetectionResult - return DetectionResult( - level=result.level, - indicators=_describe_indicators(result.level, result.indicators), - recommended_action=result.recommended_action, - confidence=result.score, - session_hash=_hash_session(text), - ) - except ImportError: - pass + result = detect_crisis(text) - return _detect_internal(text) + # Extract raw patterns from matches + raw_patterns = [m["pattern"] for m in result.matches] if result.matches else [] - -def _detect_internal(text: str) -> DetectionResult: - """Internal pattern-based detection (fallback).""" - if not text or not text.strip(): - return DetectionResult(level="NONE", session_hash=_hash_session(text)) - - text_lower = text.lower() - all_matches: Dict[str, List[tuple]] = { - tier: [] for tier in TIER_PATTERNS - } - - for tier, patterns in TIER_PATTERNS.items(): - for pattern, description in patterns: - if re.search(pattern, text_lower): - all_matches[tier].append((pattern, description)) - - # CRITICAL patterns are the highest priority - if all_matches["CRITICAL"]: - return DetectionResult( - level="CRITICAL", - indicators=[desc for _, desc in all_matches["CRITICAL"]], - recommended_action=( - "INTERRUPT CONVERSATION IMMEDIATELY. " - "Ask 'Are you safe right now?' " - "Provide 988 Suicide & Crisis Lifeline (call or text 988). " - "Provide Crisis Text Line (text HOME to 741741). " - "Stay present. Do not disconnect. " - "Redirect to professional help. " - "This is the most sacred moment." - ), - raw_matched_patterns=[pat for pat, _ in all_matches["CRITICAL"]], - confidence=1.0, - session_hash=_hash_session(text), - ) - - if all_matches["HIGH"]: - return DetectionResult( - level="HIGH", - indicators=[desc for _, desc in all_matches["HIGH"]], - recommended_action=( - "Show crisis panel prominently. " - "Ask about safety directly. " - "Surface 988 number and crisis resources. " - "Stay present and engaged. Do not rush to fix. " - "Monitor closely for escalation." - ), - raw_matched_patterns=[pat for pat, _ in all_matches["HIGH"]], - confidence=0.75, - session_hash=_hash_session(text), - ) - - if all_matches["MEDIUM"]: - return DetectionResult( - level="MEDIUM", - indicators=[desc for _, desc in all_matches["MEDIUM"]], - recommended_action=( - "Increase warmth and presence. " - "Subtly surface help resources. " - "Keep conversation anchored in the present. " - "Watch for escalation indicators." - ), - raw_matched_patterns=[pat for pat, _ in all_matches["MEDIUM"]], - confidence=0.5, - session_hash=_hash_session(text), - ) - - if all_matches["LOW"]: - return DetectionResult( - level="LOW", - indicators=[desc for _, desc in all_matches["LOW"]], - recommended_action=( - "Normal conversation with warm undertone. " - "No crisis UI elements needed. " - "Remain vigilant. Be present." - ), - raw_matched_patterns=[pat for pat, _ in all_matches["LOW"]], - confidence=0.25, - session_hash=_hash_session(text), - ) - - return DetectionResult(level="NONE", session_hash=_hash_session(text)) - - -def _describe_indicators(level: str, patterns: list) -> list: - """Map raw patterns to descriptions.""" - descriptions = { - "CRITICAL": [], - "HIGH": [], - "MEDIUM": [], - "LOW": [], - } - for tier, items in TIER_PATTERNS.items(): - for pat, desc in items: - if pat in patterns: - descriptions[tier].append(desc) - return descriptions.get(level, []) - - -def _hash_session(text: str) -> str: - """Create a session hash for this text (for tracking repeated escalations).""" - return hashlib.sha256(text.encode()).hexdigest()[:12] + return DetectionResult( + level=result.level, + indicators=result.indicators, + recommended_action=result.recommended_action, + raw_matched_patterns=raw_patterns, + confidence=result.score, + session_hash=hashlib.sha256(text.encode()).hexdigest()[:12], + ) def get_action_for_level(level: str) -> str: """Get the recommended action string for a given level.""" - actions = { - "CRITICAL": ( - "INTERRUPT CONVERSATION. Ask 'Are you safe right now?' " - "Provide 988. Provide Crisis Text Line. " - "Stay present. Do not disconnect. " - "Redirect to help." - ), - "HIGH": ( - "Show crisis panel. Ask about safety. " - "Surface 988. Stay engaged." - ), - "MEDIUM": ( - "Increase warmth. Surface resources gently. " - "Anchor in present." - ), - "LOW": ( - "Normal conversation with warmth. " - "Remain vigilant." - ), - "NONE": "No action needed.", - } - return actions.get(level, "Unknown level.") + from crisis.detect import ACTIONS + return ACTIONS.get(level, "Unknown level.") def as_json(result: DetectionResult, indent: int = 2) -> str: diff --git a/tests/test_dying_detection_deprecation.py b/tests/test_dying_detection_deprecation.py new file mode 100644 index 0000000..2475ac6 --- /dev/null +++ b/tests/test_dying_detection_deprecation.py @@ -0,0 +1,44 @@ +import importlib +import sys +import unittest +import warnings + +from crisis.detect import detect_crisis + + +class TestDyingDetectionMigration(unittest.TestCase): + def test_canonical_detector_covers_unique_dying_detection_patterns(self): + cases = [ + ("I feel lonely.", "LOW"), + ("I've lost all hope and see no tomorrow.", "MEDIUM"), + ("What if I disappeared forever?", "HIGH"), + ] + + for text, expected_level in cases: + with self.subTest(text=text): + result = detect_crisis(text) + self.assertEqual(result.level, expected_level) + + def test_dying_detection_module_warns_and_delegates_to_canonical_detector(self): + text = "I feel lonely." + sys.modules.pop("dying_detection", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + module = importlib.import_module("dying_detection") + + self.assertTrue( + any(issubclass(w.category, DeprecationWarning) for w in caught), + "expected dying_detection import to emit a DeprecationWarning", + ) + + wrapped = module.detect(text) + canonical = detect_crisis(text) + + self.assertEqual(wrapped.level, canonical.level) + self.assertEqual(wrapped.confidence, canonical.score) + self.assertEqual(wrapped.raw_matched_patterns, [m["pattern"] for m in canonical.matches]) + + +if __name__ == "__main__": + unittest.main()