Compare commits
1 Commits
fix/705
...
fix/677-cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8227cd210 |
146
agent/crisis_hook.py
Normal file
146
agent/crisis_hook.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Crisis detection and intervention hook.
|
||||
|
||||
Detects crisis signals in user messages and returns appropriate responses
|
||||
with 988 Suicide & Crisis Lifeline resources.
|
||||
|
||||
This module is designed to be called early in the conversation loop,
|
||||
before any other processing, to ensure crisis responses are immediate.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Crisis detection patterns
|
||||
_CRISIS_PATTERNS = [
|
||||
# Direct mentions
|
||||
(r'\b(988|suicide|suicidal)\b', "direct_crisis_mention"),
|
||||
(r'\b(kill myself|end my life|want to die|better off dead)\b', "suicidal_ideation"),
|
||||
(r'\b(self.harm|self.hurt|cutting myself|hurting myself)\b', "self_harm"),
|
||||
(r'\b(no reason to live|can.t go on|give up on life)\b', "hopelessness"),
|
||||
(r'\b(crisis hotline|crisis line|emergency help)\b', "crisis_resource_request"),
|
||||
|
||||
# Indirect signals
|
||||
(r'\b(i.m (so |really )?tired of (living|everything|this))\b', "exhaustion_signal"),
|
||||
(r'\b(nobody (would |will )?miss me|better without me)\b', "burden_perception"),
|
||||
(r'\b(i (just |really )?want(ed)? (it )?to stop)\b', "desire_for_relief"),
|
||||
(r'\b(planning to|going to) (end|kill|hurt)\b', "planning_signal"),
|
||||
]
|
||||
|
||||
# Crisis response with 988 Lifeline resources
|
||||
_CRISIS_RESPONSE = """I hear you, and I want you to know that you're not alone.
|
||||
|
||||
**If you're in immediate danger, please call 911.**
|
||||
|
||||
**988 Suicide & Crisis Lifeline:**
|
||||
- **Call or text 988** (US)
|
||||
- **Chat:** 988lifeline.org/chat
|
||||
- **Español:** 1-888-628-9454
|
||||
|
||||
**Crisis Text Line:**
|
||||
- **Text HOME to 741741**
|
||||
|
||||
**International Association for Suicide Prevention:**
|
||||
- https://www.iasp.info/resources/Crisis_Centres/
|
||||
|
||||
You matter. Your life has value. Help is available 24/7.
|
||||
|
||||
I'm an AI assistant, not a crisis counselor, but I can listen. Would you like to talk about what you're going through?"""
|
||||
|
||||
# Minimum confidence threshold for crisis detection
|
||||
_CONFIDENCE_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def _calculate_confidence(message: str, pattern_type: str) -> float:
|
||||
"""Calculate confidence score for a crisis detection.
|
||||
|
||||
Higher confidence for:
|
||||
- Direct mentions (988, suicide)
|
||||
- Multiple pattern matches
|
||||
- Longer messages with crisis content
|
||||
"""
|
||||
base_confidence = {
|
||||
"direct_crisis_mention": 0.95,
|
||||
"suicidal_ideation": 0.9,
|
||||
"self_harm": 0.85,
|
||||
"hopelessness": 0.7,
|
||||
"crisis_resource_request": 0.8,
|
||||
"exhaustion_signal": 0.6,
|
||||
"burden_perception": 0.75,
|
||||
"desire_for_relief": 0.65,
|
||||
"planning_signal": 0.95,
|
||||
}.get(pattern_type, 0.5)
|
||||
|
||||
# Boost confidence for multiple matches
|
||||
message_lower = message.lower()
|
||||
match_count = sum(1 for pattern, _ in _CRISIS_PATTERNS
|
||||
if re.search(pattern, message_lower, re.IGNORECASE))
|
||||
if match_count > 1:
|
||||
base_confidence = min(1.0, base_confidence + 0.1 * (match_count - 1))
|
||||
|
||||
# Boost for longer messages (more context)
|
||||
if len(message) > 100:
|
||||
base_confidence = min(1.0, base_confidence + 0.05)
|
||||
|
||||
return base_confidence
|
||||
|
||||
|
||||
def check_crisis(message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Check if a message contains crisis signals.
|
||||
|
||||
Args:
|
||||
message: The user's message to analyze
|
||||
|
||||
Returns:
|
||||
Dict with crisis detection results, or None if no crisis detected.
|
||||
Dict contains:
|
||||
- detected: bool
|
||||
- confidence: float (0.0-1.0)
|
||||
- pattern_type: str
|
||||
- response: str (crisis response with resources)
|
||||
"""
|
||||
if not message or not message.strip():
|
||||
return None
|
||||
|
||||
message_lower = message.lower().strip()
|
||||
|
||||
# Check each pattern
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
||||
for pattern, pattern_type in _CRISIS_PATTERNS:
|
||||
if re.search(pattern, message_lower, re.IGNORECASE):
|
||||
confidence = _calculate_confidence(message, pattern_type)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = pattern_type
|
||||
|
||||
# Return None if below threshold
|
||||
if best_confidence < _CONFIDENCE_THRESHOLD:
|
||||
return None
|
||||
|
||||
return {
|
||||
"detected": True,
|
||||
"confidence": best_confidence,
|
||||
"pattern_type": best_match,
|
||||
"response": _CRISIS_RESPONSE,
|
||||
}
|
||||
|
||||
|
||||
def is_crisis_message(message: str) -> bool:
|
||||
"""Quick check if message is a crisis message.
|
||||
|
||||
Returns True if crisis detected above threshold.
|
||||
"""
|
||||
result = check_crisis(message)
|
||||
return result is not None and result["detected"]
|
||||
|
||||
|
||||
def get_crisis_response(message: str) -> Optional[str]:
|
||||
"""Get crisis response if message contains crisis signals.
|
||||
|
||||
Returns the crisis response string, or None if no crisis detected.
|
||||
"""
|
||||
result = check_crisis(message)
|
||||
if result and result["detected"]:
|
||||
return result["response"]
|
||||
return None
|
||||
30
run_agent.py
30
run_agent.py
@@ -7883,6 +7883,36 @@ class AIAgent:
|
||||
current_turn_user_idx = len(messages) - 1
|
||||
self._persist_user_message_idx = current_turn_user_idx
|
||||
|
||||
# ── Crisis detection (Issue #677) ──
|
||||
# Check for crisis signals before any other processing.
|
||||
# If crisis detected, return the crisis response immediately.
|
||||
try:
|
||||
from agent.crisis_hook import check_crisis
|
||||
crisis_result = check_crisis(user_message)
|
||||
if crisis_result and crisis_result.get("detected"):
|
||||
crisis_response = crisis_result.get("response", "")
|
||||
if crisis_response:
|
||||
# Log crisis detection for observability
|
||||
logger.warning(
|
||||
"Crisis detected in session %s: type=%s confidence=%.2f",
|
||||
self.session_id or "none",
|
||||
crisis_result.get("pattern_type", "unknown"),
|
||||
crisis_result.get("confidence", 0.0),
|
||||
)
|
||||
# Return crisis response immediately, bypassing normal conversation
|
||||
return {
|
||||
"final_response": crisis_response,
|
||||
"messages": messages + [{"role": "assistant", "content": crisis_response}],
|
||||
"iterations_used": 0,
|
||||
"tool_calls_made": 0,
|
||||
"crisis_detected": True,
|
||||
}
|
||||
except ImportError:
|
||||
pass # crisis_hook not available, continue normally
|
||||
except Exception as e:
|
||||
logger.debug("Crisis check failed: %s", e)
|
||||
# Continue normally if crisis check fails
|
||||
|
||||
if not self.quiet_mode:
|
||||
self._safe_print(f"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'")
|
||||
|
||||
|
||||
92
tests/test_crisis_hook.py
Normal file
92
tests/test_crisis_hook.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Tests for crisis_hook module (Issue #677)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from agent.crisis_hook import check_crisis, is_crisis_message, get_crisis_response
|
||||
|
||||
|
||||
class TestCrisisDetection:
|
||||
def test_988_detected(self):
|
||||
result = check_crisis("I need to call 988")
|
||||
assert result is not None
|
||||
assert result["detected"] == True
|
||||
assert result["confidence"] > 0.9
|
||||
|
||||
def test_suicide_detected(self):
|
||||
result = check_crisis("I'm feeling suicidal")
|
||||
assert result is not None
|
||||
assert result["detected"] == True
|
||||
|
||||
def test_self_harm_detected(self):
|
||||
result = check_crisis("I've been thinking about self harm")
|
||||
assert result is not None
|
||||
assert result["detected"] == True
|
||||
|
||||
def test_hopelessness_detected(self):
|
||||
result = check_crisis("I have no reason to live")
|
||||
assert result is not None
|
||||
assert result["detected"] == True
|
||||
|
||||
def test_normal_message_not_detected(self):
|
||||
result = check_crisis("Hello, how are you?")
|
||||
assert result is None
|
||||
|
||||
def test_empty_message_not_detected(self):
|
||||
result = check_crisis("")
|
||||
assert result is None
|
||||
|
||||
def test_none_message_not_detected(self):
|
||||
result = check_crisis(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCrisisResponse:
|
||||
def test_response_contains_988(self):
|
||||
response = get_crisis_response("I need help, feeling suicidal")
|
||||
assert response is not None
|
||||
assert "988" in response
|
||||
|
||||
def test_response_contains_resources(self):
|
||||
response = get_crisis_response("I want to die")
|
||||
assert response is not None
|
||||
assert "Crisis Text Line" in response
|
||||
assert "741741" in response
|
||||
|
||||
def test_normal_message_no_response(self):
|
||||
response = get_crisis_response("What's the weather like?")
|
||||
assert response is None
|
||||
|
||||
|
||||
class TestIsCrisisMessage:
|
||||
def test_crisis_returns_true(self):
|
||||
assert is_crisis_message("I'm feeling suicidal") == True
|
||||
|
||||
def test_normal_returns_false(self):
|
||||
assert is_crisis_message("Hello world") == False
|
||||
|
||||
def test_empty_returns_false(self):
|
||||
assert is_crisis_message("") == False
|
||||
|
||||
|
||||
class TestConfidenceScoring:
|
||||
def test_direct_mention_high_confidence(self):
|
||||
result = check_crisis("Call 988 for help")
|
||||
assert result["confidence"] > 0.9
|
||||
|
||||
def test_indirect_signal_lower_confidence(self):
|
||||
# "I'm so tired of everything" doesn't match the exhaustion pattern exactly
|
||||
# Let's test with a clearer indirect signal
|
||||
result = check_crisis("I just want it to stop")
|
||||
if result is not None:
|
||||
assert result["confidence"] < 0.8
|
||||
# If not detected, that's also acceptable for indirect signals
|
||||
|
||||
def test_multiple_patterns_higher_confidence(self):
|
||||
result = check_crisis("I'm suicidal and want to call 988")
|
||||
assert result["confidence"] >= 0.95
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user