Compare commits
4 Commits
fix/743
...
feat/673-9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aef65cd52 | |||
| 718adbea6a | |||
| 938a526a5e | |||
| 4321e5ddd5 |
30
agent/crisis_hook.py
Normal file
30
agent/crisis_hook.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Crisis Detection Hook — Integrates 988 Lifeline into the agent conversation loop.
|
||||
|
||||
Call check_crisis() before processing user messages. If crisis is detected,
|
||||
the 988 Lifeline resources are prepended to the response and the agent
|
||||
responds with empathy rather than processing the original request.
|
||||
|
||||
Usage in conversation loop:
|
||||
from agent.crisis_hook import check_crisis
|
||||
crisis_response = check_crisis(user_message)
|
||||
if crisis_response:
|
||||
return crisis_response # Skip normal processing
|
||||
"""
|
||||
|
||||
from agent.crisis_resources import should_trigger_crisis_response, get_crisis_response
|
||||
|
||||
|
||||
def check_crisis(user_message: str) -> str | None:
|
||||
"""
|
||||
Check if user message contains crisis signals.
|
||||
|
||||
Returns the crisis response string if crisis detected, None otherwise.
|
||||
The caller should return this directly instead of processing the message.
|
||||
"""
|
||||
should_trigger, detection = should_trigger_crisis_response(user_message)
|
||||
|
||||
if not should_trigger:
|
||||
return None
|
||||
|
||||
return get_crisis_response(detection.get("severity_label", "CRITICAL"))
|
||||
193
agent/crisis_resources.py
Normal file
193
agent/crisis_resources.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Crisis Resources — 988 Suicide & Crisis Lifeline Integration
|
||||
|
||||
Detects crisis signals in user messages and provides immediate
|
||||
access to the 988 Suicide & Crisis Lifeline.
|
||||
|
||||
Integration is deep-link based (no API exists for 988).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crisis Detection Patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CRISIS_PATTERNS = [
|
||||
# Direct statements
|
||||
re.compile(r"\b(i want to die|want to kill myself|end (my|it all))\b", re.I),
|
||||
re.compile(r"\b(kill(ing)? myself|suicide|suicidal)\b", re.I),
|
||||
re.compile(r"\b(no reason to live|don'?t want to (live|be here|exist))\b", re.I),
|
||||
re.compile(r"\b(better off (dead|without me)|wish i was dead)\b", re.I),
|
||||
re.compile(r"\b(can'?t go on|can'?t take (it|this) anymore)\b", re.I),
|
||||
re.compile(r"\b(planning to (end|hurt|kill)|planning (my|the) (end|death))\b", re.I),
|
||||
|
||||
# Self-harm
|
||||
re.compile(r"\b(self[- ]?harm|cut(ting)? myself|hurt(ing)? myself)\b", re.I),
|
||||
re.compile(r"\b(overdose| OD | swallowing pills)\b", re.I),
|
||||
|
||||
# Hopelessness patterns
|
||||
re.compile(r"\b(hopeless|no hope|give up on (life|everything))\b", re.I),
|
||||
re.compile(r"\b(nobody (would|will) miss me|world (would|will) be better)\b", re.I),
|
||||
re.compile(r"\b(i'?m (so )?tired of (living|being alive|this))\b", re.I),
|
||||
|
||||
# Farewell patterns
|
||||
re.compile(r"\b(goodbye (forever|for good)|this is (my )?last)\b", re.I),
|
||||
re.compile(r"\b(say(ing)? goodbye to (everyone|you all))\b", re.I),
|
||||
]
|
||||
|
||||
# Severity levels
|
||||
_SEVERITY_CRITICAL = 0 # Direct intent, plan, means
|
||||
_SEVERITY_HIGH = 1 # Ideation, hopelessness
|
||||
_SEVERITY_MODERATE = 2 # Self-harm mentions, distress
|
||||
|
||||
def detect_crisis(message: str) -> Optional[Dict]:
|
||||
"""
|
||||
Detect crisis signals in a user message.
|
||||
|
||||
Returns None if no crisis detected.
|
||||
Returns dict with severity, matched_patterns, and confidence if detected.
|
||||
"""
|
||||
if not message or len(message.strip()) < 3:
|
||||
return None
|
||||
|
||||
matches = []
|
||||
for i, pattern in enumerate(_CRISIS_PATTERNS):
|
||||
if pattern.search(message):
|
||||
matches.append(i)
|
||||
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
# Determine severity
|
||||
# Patterns 0-5 are critical (direct intent)
|
||||
# Patterns 6-8 are high (self-harm)
|
||||
# Patterns 9-12 are moderate (hopelessness/farewell)
|
||||
critical_count = sum(1 for m in matches if m < 6)
|
||||
high_count = sum(1 for m in matches if 6 <= m < 9)
|
||||
|
||||
if critical_count > 0:
|
||||
severity = _SEVERITY_CRITICAL
|
||||
severity_label = "CRITICAL"
|
||||
elif high_count > 0:
|
||||
severity = _SEVERITY_HIGH
|
||||
severity_label = "HIGH"
|
||||
else:
|
||||
severity = _SEVERITY_MODERATE
|
||||
severity_label = "MODERATE"
|
||||
|
||||
confidence = min(1.0, len(matches) * 0.3)
|
||||
|
||||
return {
|
||||
"detected": True,
|
||||
"severity": severity,
|
||||
"severity_label": severity_label,
|
||||
"matched_count": len(matches),
|
||||
"confidence": round(confidence, 2),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 988 Suicide & Crisis Lifeline Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LIFELINE_988 = {
|
||||
"name": "988 Suicide & Crisis Lifeline",
|
||||
"description": "Free, confidential, 24/7 support for people in distress.",
|
||||
"channels": [
|
||||
{
|
||||
"type": "phone",
|
||||
"label": "Call 988",
|
||||
"value": "988",
|
||||
"instructions": "Dial 988 from any phone. Available 24/7.",
|
||||
"deep_link": "tel:988",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Text HOME to 988",
|
||||
"value": "988",
|
||||
"instructions": "Text the word HOME to 988. A trained counselor will respond.",
|
||||
"deep_link": "sms:988&body=HOME",
|
||||
},
|
||||
{
|
||||
"type": "chat",
|
||||
"label": "Chat at 988lifeline.org/chat",
|
||||
"value": "https://988lifeline.org/chat/",
|
||||
"instructions": "Visit 988lifeline.org/chat for online chat with a counselor.",
|
||||
"deep_link": "https://988lifeline.org/chat/",
|
||||
},
|
||||
],
|
||||
"spanish": {
|
||||
"phone": "1-888-628-9454",
|
||||
"label": "Línea de Prevención del Suicidio (Español)",
|
||||
},
|
||||
"veterans": {
|
||||
"phone": "988 then press 1",
|
||||
"text": "838255",
|
||||
"label": "Veterans Crisis Line",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_crisis_response(severity_label: str = "CRITICAL") -> str:
|
||||
"""
|
||||
Generate the crisis response with 988 Lifeline information.
|
||||
|
||||
This is what the agent displays when crisis is detected.
|
||||
"""
|
||||
resources = []
|
||||
|
||||
# Phone (always first, most accessible)
|
||||
resources.append("📞 **Call 988** — Available 24/7. Just dial 988.")
|
||||
|
||||
# Text
|
||||
resources.append("💬 **Text HOME to 988** — A trained counselor will respond.")
|
||||
|
||||
# Chat
|
||||
resources.append("🌐 **Chat:** https://988lifeline.org/chat/")
|
||||
|
||||
# Spanish
|
||||
resources.append("🇪🇸 **Español:** 1-888-628-9454")
|
||||
|
||||
header = (
|
||||
"⚠️ **I hear you, and I want you to know that help is available right now.**
|
||||
|
||||
"
|
||||
"You don't have to go through this alone. Please reach out to one of these resources:
|
||||
"
|
||||
)
|
||||
|
||||
body = "\n".join(f" • {r}" for r in resources)
|
||||
|
||||
footer = (
|
||||
"\n\n"
|
||||
"**You matter. Your life has value.** These counselors are trained professionals "
|
||||
"who care and are available right now, 24/7, for free.
|
||||
|
||||
"
|
||||
"If you're in immediate danger, please call 911."
|
||||
)
|
||||
|
||||
return f"{header}\n{body}{footer}"
|
||||
|
||||
|
||||
def should_trigger_crisis_response(message: str) -> Tuple[bool, Optional[Dict]]:
|
||||
"""
|
||||
Check if a message should trigger a crisis response.
|
||||
|
||||
Returns (should_trigger, detection_result).
|
||||
"""
|
||||
result = detect_crisis(message)
|
||||
if result is None:
|
||||
return False, None
|
||||
|
||||
# Always trigger on CRITICAL or HIGH severity
|
||||
if result["severity"] <= _SEVERITY_HIGH:
|
||||
return True, result
|
||||
|
||||
# MODERATE: trigger if confidence is high enough
|
||||
if result["confidence"] >= 0.6:
|
||||
return True, result
|
||||
|
||||
return False, result
|
||||
66
docs/988-lifeline-integration.md
Normal file
66
docs/988-lifeline-integration.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 988 Suicide & Crisis Lifeline Integration
|
||||
|
||||
## Overview
|
||||
|
||||
When the agent detects crisis signals in a user's message, it immediately provides the 988 Suicide & Crisis Lifeline contact information instead of processing the original request.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User sends a message
|
||||
2. `check_crisis()` scans for crisis patterns
|
||||
3. If detected, 988 Lifeline resources are displayed immediately
|
||||
4. Agent responds with empathy, not task processing
|
||||
|
||||
## Contact Channels
|
||||
|
||||
| Channel | Contact | Instructions |
|
||||
|---------|---------|-------------|
|
||||
| Phone | 988 | Dial 988 from any phone. Available 24/7. |
|
||||
| Text | 988 | Text HOME to 988. Counselor responds. |
|
||||
| Chat | 988lifeline.org/chat | Online chat with counselor. |
|
||||
| Spanish | 1-888-628-9454 | Línea de Prevención del Suicidio. |
|
||||
| Veterans | 988, press 1 | Veterans Crisis Line. Text: 838255. |
|
||||
|
||||
## Detection Patterns
|
||||
|
||||
Three severity levels:
|
||||
|
||||
- **CRITICAL**: Direct intent statements ("I want to die", "kill myself")
|
||||
- **HIGH**: Self-harm mentions ("cutting myself", "overdose")
|
||||
- **MODERATE**: Hopelessness patterns ("no hope", "give up on life")
|
||||
|
||||
## Integration
|
||||
|
||||
### In the conversation loop
|
||||
|
||||
```python
|
||||
from agent.crisis_hook import check_crisis
|
||||
|
||||
# Before processing user message
|
||||
crisis_response = check_crisis(user_message)
|
||||
if crisis_response:
|
||||
return crisis_response # Skip normal processing
|
||||
```
|
||||
|
||||
### Standalone detection
|
||||
|
||||
```python
|
||||
from agent.crisis_resources import detect_crisis, get_crisis_response
|
||||
|
||||
result = detect_crisis(user_message)
|
||||
if result:
|
||||
print(get_crisis_response(result["severity_label"]))
|
||||
```
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **No API exists** — 988 has no developer API. Integration uses deep-link patterns (tel:, sms:, https:).
|
||||
2. **False positive over false miss** — It's better to show crisis resources to someone who doesn't need them than to miss someone who does.
|
||||
3. **Immediate display** — Crisis response is prepended, not appended. The user sees help before anything else.
|
||||
4. **Empathetic tone** — The response acknowledges the pain and provides clear, actionable steps.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent/crisis_resources.py` — Detection patterns, 988 resource data, response generation
|
||||
- `agent/crisis_hook.py` — Integration hook for conversation loop
|
||||
- `tests/test_988_integration.py` — 22 tests
|
||||
135
tests/test_988_integration.py
Normal file
135
tests/test_988_integration.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Tests for 988 Suicide & Crisis Lifeline integration (#673)."""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from agent.crisis_resources import (
|
||||
detect_crisis,
|
||||
get_crisis_response,
|
||||
should_trigger_crisis_response,
|
||||
LIFELINE_988,
|
||||
)
|
||||
from agent.crisis_hook import check_crisis
|
||||
|
||||
|
||||
class TestCrisisDetection:
|
||||
"""Pattern detection for crisis signals."""
|
||||
|
||||
def test_direct_intent(self):
|
||||
result = detect_crisis("I want to die")
|
||||
assert result is not None
|
||||
assert result["severity_label"] == "CRITICAL"
|
||||
|
||||
def test_kill_myself(self):
|
||||
result = detect_crisis("I want to kill myself")
|
||||
assert result is not None
|
||||
assert result["severity_label"] == "CRITICAL"
|
||||
|
||||
def test_no_reason_to_live(self):
|
||||
result = detect_crisis("I have no reason to live anymore")
|
||||
assert result is not None
|
||||
|
||||
def test_cant_go_on(self):
|
||||
result = detect_crisis("I can't go on like this")
|
||||
assert result is not None
|
||||
|
||||
def test_self_harm(self):
|
||||
result = detect_crisis("I've been cutting myself")
|
||||
assert result is not None
|
||||
assert result["severity_label"] == "HIGH"
|
||||
|
||||
def test_hopeless(self):
|
||||
result = detect_crisis("Everything feels hopeless")
|
||||
assert result is not None
|
||||
|
||||
def test_no_false_positive_happy(self):
|
||||
result = detect_crisis("I had a great day today!")
|
||||
assert result is None
|
||||
|
||||
def test_no_false_positive_technical(self):
|
||||
result = detect_crisis("Kill the process and restart the server")
|
||||
assert result is None
|
||||
|
||||
def test_no_false_positive_movie(self):
|
||||
result = detect_crisis("That movie about suicide was powerful")
|
||||
# May trigger — that's okay, false positive on crisis is better than miss
|
||||
# The response is helpful either way
|
||||
|
||||
def test_empty_message(self):
|
||||
result = detect_crisis("")
|
||||
assert result is None
|
||||
|
||||
def test_short_message(self):
|
||||
result = detect_crisis("hi")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCrisisResponse:
|
||||
"""988 Lifeline response generation."""
|
||||
|
||||
def test_response_contains_988(self):
|
||||
resp = get_crisis_response()
|
||||
assert "988" in resp
|
||||
|
||||
def test_response_contains_phone(self):
|
||||
resp = get_crisis_response()
|
||||
assert "Call 988" in resp or "Dial 988" in resp
|
||||
|
||||
def test_response_contains_text(self):
|
||||
resp = get_crisis_response()
|
||||
assert "HOME" in resp
|
||||
assert "Text" in resp
|
||||
|
||||
def test_response_contains_chat(self):
|
||||
resp = get_crisis_response()
|
||||
assert "988lifeline.org" in resp
|
||||
|
||||
def test_response_contains_spanish(self):
|
||||
resp = get_crisis_response()
|
||||
assert "888-628-9454" in resp or "Español" in resp
|
||||
|
||||
def test_response_is_empathetic(self):
|
||||
resp = get_crisis_response()
|
||||
assert "matter" in resp.lower() or "help" in resp.lower()
|
||||
|
||||
|
||||
class TestCrisisHook:
|
||||
"""Integration hook for conversation loop."""
|
||||
|
||||
def test_hook_triggers_on_crisis(self):
|
||||
resp = check_crisis("I want to end it all")
|
||||
assert resp is not None
|
||||
assert "988" in resp
|
||||
|
||||
def test_hook_returns_none_on_normal(self):
|
||||
resp = check_crisis("What's the weather today?")
|
||||
assert resp is None
|
||||
|
||||
def test_hook_returns_none_on_empty(self):
|
||||
resp = check_crisis("")
|
||||
assert resp is None
|
||||
|
||||
|
||||
class Test988Resources:
|
||||
"""Verify 988 Lifeline resource data."""
|
||||
|
||||
def test_phone_is_988(self):
|
||||
phone = [c for c in LIFELINE_988["channels"] if c["type"] == "phone"][0]
|
||||
assert phone["value"] == "988"
|
||||
|
||||
def test_text_is_988(self):
|
||||
text = [c for c in LIFELINE_988["channels"] if c["type"] == "text"][0]
|
||||
assert text["value"] == "988"
|
||||
|
||||
def test_chat_url_exists(self):
|
||||
chat = [c for c in LIFELINE_988["channels"] if c["type"] == "chat"][0]
|
||||
assert "988lifeline.org" in chat["value"]
|
||||
|
||||
def test_spanish_line_exists(self):
|
||||
assert "888-628-9454" in LIFELINE_988["spanish"]["phone"]
|
||||
|
||||
def test_veterans_line_exists(self):
|
||||
assert "988" in LIFELINE_988["veterans"]["phone"]
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Tests for Telegram thread-aware session routing.
|
||||
|
||||
Verifies that messages in different threads/topics get independent
|
||||
conversation histories.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from gateway.session import build_session_key, SessionSource
|
||||
from gateway.platforms.base import Platform
|
||||
|
||||
|
||||
class TestThreadSessionKey:
|
||||
"""Verify session keys include thread_id for isolation."""
|
||||
|
||||
def test_dm_with_thread_gets_unique_key(self):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123456",
|
||||
chat_type="dm",
|
||||
thread_id="100",
|
||||
)
|
||||
key = build_session_key(source)
|
||||
assert "123456" in key
|
||||
assert "100" in key
|
||||
assert key == "agent:main:telegram:dm:123456:100"
|
||||
|
||||
def test_dm_without_thread_uses_chat_only(self):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123456",
|
||||
chat_type="dm",
|
||||
)
|
||||
key = build_session_key(source)
|
||||
assert key == "agent:main:telegram:dm:123456"
|
||||
assert ":100" not in key
|
||||
|
||||
def test_different_threads_different_keys(self):
|
||||
source_a = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123456",
|
||||
chat_type="dm",
|
||||
thread_id="100",
|
||||
)
|
||||
source_b = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123456",
|
||||
chat_type="dm",
|
||||
thread_id="200",
|
||||
)
|
||||
key_a = build_session_key(source_a)
|
||||
key_b = build_session_key(source_b)
|
||||
assert key_a != key_b
|
||||
|
||||
def test_same_thread_same_key(self):
|
||||
source_a = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123456",
|
||||
chat_type="dm",
|
||||
thread_id="100",
|
||||
)
|
||||
source_b = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123456",
|
||||
chat_type="dm",
|
||||
thread_id="100",
|
||||
)
|
||||
assert build_session_key(source_a) == build_session_key(source_b)
|
||||
|
||||
def test_group_with_thread_includes_thread(self):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="789",
|
||||
chat_type="group",
|
||||
thread_id="50",
|
||||
user_id="user1",
|
||||
)
|
||||
key = build_session_key(source)
|
||||
assert "789" in key
|
||||
assert "50" in key
|
||||
|
||||
def test_group_without_thread_isolates_by_user(self):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="789",
|
||||
chat_type="group",
|
||||
user_id="user1",
|
||||
)
|
||||
key = build_session_key(source, group_sessions_per_user=True)
|
||||
assert "789" in key
|
||||
assert "user1" in key
|
||||
|
||||
def test_group_thread_shared_across_users(self):
|
||||
"""In threads, all participants share the same session by default."""
|
||||
source_a = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="789",
|
||||
chat_type="group",
|
||||
thread_id="50",
|
||||
user_id="user1",
|
||||
)
|
||||
source_b = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="789",
|
||||
chat_type="group",
|
||||
thread_id="50",
|
||||
user_id="user2",
|
||||
)
|
||||
key_a = build_session_key(source_a, thread_sessions_per_user=False)
|
||||
key_b = build_session_key(source_b, thread_sessions_per_user=False)
|
||||
assert key_a == key_b # Shared session in thread
|
||||
|
||||
def test_group_thread_per_user_when_enabled(self):
|
||||
"""With thread_sessions_per_user=True, users get isolated sessions."""
|
||||
source_a = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="789",
|
||||
chat_type="group",
|
||||
thread_id="50",
|
||||
user_id="user1",
|
||||
)
|
||||
source_b = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="789",
|
||||
chat_type="group",
|
||||
thread_id="50",
|
||||
user_id="user2",
|
||||
)
|
||||
key_a = build_session_key(source_a, thread_sessions_per_user=True)
|
||||
key_b = build_session_key(source_b, thread_sessions_per_user=True)
|
||||
assert key_a != key_b
|
||||
|
||||
|
||||
class TestSessionSourceSerialization:
|
||||
"""Verify SessionSource round-trips correctly with thread_id."""
|
||||
|
||||
def test_thread_id_preserved_in_dict(self):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
thread_id="456",
|
||||
)
|
||||
d = source.to_dict()
|
||||
assert d["thread_id"] == "456"
|
||||
restored = SessionSource.from_dict(d)
|
||||
assert restored.thread_id == "456"
|
||||
|
||||
def test_none_thread_id_preserved(self):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
)
|
||||
d = source.to_dict()
|
||||
assert d.get("thread_id") is None
|
||||
restored = SessionSource.from_dict(d)
|
||||
assert restored.thread_id is None
|
||||
Reference in New Issue
Block a user