Compare commits

..

3 Commits

Author SHA1 Message Date
Timmy (AI Agent)
16c66e14a6 fix(a11y): set crisis overlay initial focus to Call 988 link (#69)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 8s
Smoke Test / smoke (pull_request) Successful in 16s
The overlay's showOverlay() called overlayDismissBtn.focus() after
setting overlayDismissBtn.disabled = true. Disabled buttons are not
valid focus targets per WCAG 2.4.3, so keyboard/AT users lost context
at the most critical point.

Fix: focus the Call 988 tel: link (first actionable, non-disabled
element) instead of the disabled dismiss button.

Changes:
- Add overlayCallLink variable from .overlay-call selector
- Focus overlayCallLink instead of overlayDismissBtn in showOverlay()
- Guard with null check for robustness

Closes #69
2026-04-14 14:10:19 -04:00
9b94978d1c feat: Wire compassion router into gateway flow (#34) (#43)
All checks were successful
Smoke Test / smoke (push) Successful in 5s
Squash merge: wire compassion router into gateway flow
2026-04-13 19:59:15 +00:00
e71bca1744 fix: de-duplicate crisis_detector.py and crisis/detect.py (closes #39) (#44)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Squash merge: de-duplicate crisis detector (closes #39)

Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-13 19:59:12 +00:00
9 changed files with 370 additions and 653 deletions

View File

@@ -1,25 +1,24 @@
"""
Crisis Detection Module for the-door.
Crisis Detection Module for the-door (CANONICAL).
Single source of truth for crisis keyword detection.
Parses incoming text for despair/suicide indicators and classifies into
tiers: LOW, MEDIUM, HIGH, CRITICAL.
Both `crisis/detect.py` and `crisis_detector.py` (legacy shim) use this module.
Do NOT duplicate keyword lists elsewhere.
Consolidated from:
- crisis_detector.py (root, legacy — MODERATE tier, class API)
- crisis/detect.py (module, newer — MEDIUM tier, functional API)
"""
import re
from dataclasses import dataclass, field
from typing import List
from typing import List, Optional
@dataclass
class CrisisDetectionResult:
level: str
indicators: List[str] = field(default_factory=list)
recommended_action: str = ""
score: float = 0.0
matches: List[dict] = field(default_factory=list)
# ── Indicator sets ──────────────────────────────────────────────
# ── Canonical indicator sets ──────────────────────────────────────
CRITICAL_INDICATORS = [
r"\bbetter off without me\b",
@@ -29,49 +28,56 @@ CRITICAL_INDICATORS = [
r"\bnot\s+worth\s+living\b",
r"\bbetter\s+off\s+dead\b",
r"\bend\s+it\s+all\b",
r"\bcan'?t\s+(go|live)\s+on\b",
r"\bno\s+reason\s+to\s+live\b",
r"\bdon'?t\s+want\s+to\s+live\b",
r"\bwant\s+to\s+die\b",
r"\bgoing\s+to\s+kill\s+myself\b",
r"\bplan\s+to\s+(end|kill|die)\b",
r"\btired\s+of\s+(living|life|existence)\b",
r"\bsaying\s+goodbye\s+(forever|permanently|one last time)\b",
r"\bgoing\s+to\s+(?:kill\s+myself|die)\b",
r"\bplan\s+to\s+(?:end|kill|die)\b",
r"\btired\s+of\s+(?:living|life|existence)\b",
r"\bsaying\s+goodbye\s+(?:forever|permanently|one\s+last\s+time)\b",
r"\bwrote\s+a\s+suicide\s*(?:note|letter)\b",
r"\bgiving\s+away\s+(?:my|all\s+my)\s+(?:stuff|things|possessions?)\s+(?:to|because|—)\b",
r"\btied\s+(up|down)\s+my\s+(loose\s+)?ends",
r"\btied\s+(?:up|down)\s+my\s+(?:loose\s+)?ends",
]
HIGH_INDICATORS = [
r"\bdespair\b",
r"\bhopeless(?:ly)?\s+(about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
r"\bno(?!t)\s+(one|body|point|hope|way\s+out)\b",
r"\bhopeless(?:ly)?\s+(?:about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
r"\bno(?!t)\s+(?:one|body|point|hope|way\s+out)\b",
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
r"\beverything\s+is\s+(pointless|broken|ruined)\b",
r"\beverything\s+is\s+(?:pointless|broken|ruined)\b",
r"\bcan'?t\s+take\s+this\s+anymore\b",
r"\bdon'?t\s+care\s+if\s+I\s+die\b",
r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b",
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\b",
r"\bno\s+one\s+would\s+miss\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"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment)\b",
r"\bcan'?t\s+see\s+any\s+(?:point|reason)\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",
# Contextual despair phrases (from crisis_detector.py legacy)
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
r"\beverything\s+is\s+hopeless\b",
r"\bcan'?t\s+(?:go\s+on|keep\s+going)\b",
r"\bgive(?:n)?\s*up\s+(?:on\s+)?(?:life|living|everything)\b",
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
r"\bno\s*point\s+(?:in\s+)?living\b",
r"\bno\s*hope\s+(?:left|remaining)\b",
r"\bno\s*way\s*out\b",
r"\bfeel(?:s|ing)?\s+trapped\b",
r"\bdesperate\s+(?:for\s+)?help\b",
r"\bfeel(?:s|ing)?\s+desperate\b",
]
MEDIUM_INDICATORS = [
r"\bno\s+hope\b",
r"\bcan'?t\s+go\s+on\b",
r"\bcan'?t\s+keep\s+going\b",
r"\bforgotten\b",
r"\balone\s+in\s+this\b",
r"\balways\s+alone\b",
r"\bnobody\s+understands\b",
r"\bnobody\s+cares\b",
r"\bnobody\s+(?:understands|cares)\b",
r"\bwish\s+I\s+could\b",
r"\bexhaust(?:ed|ion|ing)\b",
r"\bnumb\b",
@@ -80,8 +86,7 @@ MEDIUM_INDICATORS = [
r"\buseless\b",
r"\bbroken\b",
r"\bdark(ness)?\b",
r"\bdepressed\b",
r"\bdepression\b",
r"\bdepress(?:ed|ion)\b",
r"\bcrying\b",
r"\btears\b",
r"\bsad(ness)?\b",
@@ -89,129 +94,115 @@ MEDIUM_INDICATORS = [
r"\boverwhelm(?:ed|ing)\b",
r"\bfailing\b",
r"\bcannot\s+cope\b",
r"\blosing\s*(my)?\s*control\b",
r"\blosing\s*(?:my)?\s*control\b",
r"\bdown\s*for\s*the\s*count\b",
r"\bsinking\b",
r"\bdrowning\b",
r"\bhopeless\b",
# Removed 'desperate' - too broad for MEDIUM, triggers on competition
# 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",
r"\bfeel(?:s|ing)?\s+trapped\b",
r"\bfeel(?:s|ing)?\s+desperate\b",
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b",
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
]
LOW_INDICATORS = [
r"\bunhappy\b",
r"\bdown\b",
r"\btough\s*time\b",
r"\brough\s*day\b",
r"\brough\s*week\b",
r"\brough\s*patch\b",
r"\brough\s+(?:day|week|patch)\b",
r"\bstressed\b",
r"\burnout\b",
r"\bburnout\b",
r"\bdifficult\b",
r"\bfrustrated\b",
r"\bannoyed\b",
r"\btired\b",
r"\bsad\b",
r"\bupset\b",
r"\bnot\s*great\b",
r"\bnot\s*good\b",
r"\bnot\s*okay\b",
r"\bnot\s*(?:good|great|okay)\b",
r"\bthings\s*are\s*hard\b",
r"\bstruggling\b",
r"\bnot\s+feeling\s+myself\b",
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
r"\bhard\s*time\b",
]
def detect_crisis(text: str) -> CrisisDetectionResult:
# ── Scoring ───────────────────────────────────────────────────────
SCORES = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25, "NONE": 0.0}
ACTIONS = {
"CRITICAL": (
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
"Provide 988 Suicide & Crisis Lifeline. "
"Provide Crisis Text Line (HOME to 741741). "
"Stay present. Do not disconnect. Redirect to professional help."
),
"HIGH": (
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
"Continue conversation with crisis awareness."
),
"MEDIUM": (
"Increase warmth and presence. Subtly surface help resources. "
"Keep conversation anchored in the present."
),
"LOW": (
"Normal conversation with warm undertone. "
"No crisis UI elements needed. Remain vigilant."
),
}
# ── Result types ──────────────────────────────────────────────────
@dataclass
class CrisisDetectionResult:
"""Result used by crisis/detect.py, gateway, dying_detection."""
level: str
indicators: List[str] = field(default_factory=list)
recommended_action: str = ""
score: float = 0.0
matches: List[dict] = field(default_factory=list)
@dataclass
class CrisisResult:
"""Legacy result used by crisis_detector.py and crisis_responder.py.
Backward-compatible shim: wraps CrisisDetectionResult with the old field names.
"""
Detect crisis level in a message.
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
Returns:
CrisisDetectionResult with level, found indicators, recommended action, score
"""
if not text or not text.strip():
return CrisisDetectionResult(level="NONE", score=0.0)
def __bool__(self):
return self.risk_level != "NONE"
text_lower = text.lower()
matches = _find_indicators(text_lower)
if not matches:
return CrisisDetectionResult(level="NONE", score=0.0)
# Priority: highest tier wins
# CRITICAL and HIGH: single match is enough
# MEDIUM: requires 2+ indicators (threshold) to reduce false positives
# LOW: single match is enough
scores = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25}
actions = {
"CRITICAL": (
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
"Provide 988 Suicide & Crisis Lifeline. "
"Provide Crisis Text Line (HOME to 741741). "
"Stay present. Do not disconnect. Redirect to professional help."
),
"HIGH": (
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
"Continue conversation with crisis awareness."
),
"MEDIUM": (
"Increase warmth and presence. Subtly surface help resources. "
"Keep conversation anchored in the present."
),
"LOW": (
"Normal conversation with warm undertone. "
"No crisis UI elements needed. Remain vigilant."
),
}
for tier in ("CRITICAL", "HIGH"):
if matches[tier]:
tier_matches = matches[tier]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level=tier,
indicators=patterns,
recommended_action=actions[tier],
score=scores[tier],
matches=tier_matches,
)
# MEDIUM tier: require at least 2 indicators before escalating
if len(matches["MEDIUM"]) >= 2:
tier_matches = matches["MEDIUM"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="MEDIUM",
indicators=patterns,
recommended_action=actions["MEDIUM"],
score=scores["MEDIUM"],
matches=tier_matches,
@classmethod
def from_detection_result(cls, dr: CrisisDetectionResult, text: str = "") -> "CrisisResult":
"""Convert a CrisisDetectionResult to legacy CrisisResult format."""
# Map MEDIUM -> MODERATE for legacy consumers
level = "MODERATE" if dr.level == "MEDIUM" else dr.level
# Extract context snippets from matches
contexts = []
if text:
for m in dr.matches:
ctx = extract_context(text, m["start"], m["end"])
contexts.append(ctx)
return cls(
risk_level=level,
matched_keywords=dr.indicators,
context=contexts,
score=dr.score,
)
if matches["LOW"]:
tier_matches = matches["LOW"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="LOW",
indicators=patterns,
recommended_action=actions["LOW"],
score=scores["LOW"],
matches=tier_matches,
)
# Single MEDIUM match falls through to LOW sensitivity
if matches["MEDIUM"]:
tier_matches = matches["MEDIUM"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="LOW",
indicators=patterns,
recommended_action=actions["LOW"],
score=scores["LOW"],
matches=tier_matches,
)
return CrisisDetectionResult(level="NONE", score=0.0)
# ── Core detection ────────────────────────────────────────────────
def _find_indicators(text: str) -> dict:
"""Return dict with indicators found per tier, including match positions."""
@@ -240,6 +231,140 @@ def _find_indicators(text: str) -> dict:
return results
def detect_crisis(text: str) -> CrisisDetectionResult:
"""
Detect crisis level in a message.
Detection hierarchy:
CRITICAL — immediate risk of self-harm or suicide (single match)
HIGH — strong despair signals, ideation present (single match)
MEDIUM — distress signals, requires 2+ indicators to escalate
LOW — emotional difficulty, warrant gentle support (single match)
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
"""
if not text or not text.strip():
return CrisisDetectionResult(level="NONE", score=0.0)
text_lower = text.lower()
matches = _find_indicators(text_lower)
if not matches:
return CrisisDetectionResult(level="NONE", score=0.0)
# CRITICAL and HIGH: single match is enough
for tier in ("CRITICAL", "HIGH"):
if matches[tier]:
tier_matches = matches[tier]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level=tier,
indicators=patterns,
recommended_action=ACTIONS[tier],
score=SCORES[tier],
matches=tier_matches,
)
# MEDIUM tier: require at least 2 indicators before escalating
if len(matches["MEDIUM"]) >= 2:
tier_matches = matches["MEDIUM"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="MEDIUM",
indicators=patterns,
recommended_action=ACTIONS["MEDIUM"],
score=SCORES["MEDIUM"],
matches=tier_matches,
)
if matches["LOW"]:
tier_matches = matches["LOW"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="LOW",
indicators=patterns,
recommended_action=ACTIONS["LOW"],
score=SCORES["LOW"],
matches=tier_matches,
)
# Single MEDIUM match falls through to LOW sensitivity
if matches["MEDIUM"]:
tier_matches = matches["MEDIUM"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="LOW",
indicators=patterns,
recommended_action=ACTIONS["LOW"],
score=SCORES["LOW"],
matches=tier_matches,
)
return CrisisDetectionResult(level="NONE", score=0.0)
# ── CrisisDetector class (backward compat) ───────────────────────
class CrisisDetector:
"""
Legacy class API for crisis detection. Wraps the canonical detect_crisis().
Used by crisis_responder.py and tests/test_false_positive_fixes.py.
Maps MEDIUM -> MODERATE for legacy consumers.
"""
def scan(self, text: str) -> CrisisResult:
dr = detect_crisis(text)
return CrisisResult.from_detection_result(dr, text=text)
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
return [self.scan(t) for t in texts]
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
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:
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 (backward compat) ────────────────────
_default_detector = CrisisDetector()
def detect_crisis_legacy(text: str) -> CrisisResult:
"""Convenience function returning legacy CrisisResult format."""
return _default_detector.scan(text)
# ── Utility functions ─────────────────────────────────────────────
def scan(text: str) -> CrisisDetectionResult:
"""Alias for detect_crisis — shorter name used in tests."""
return detect_crisis(text)
@@ -258,8 +383,8 @@ def extract_context(text: str, start: int, end: int, window: int = 60) -> str:
def get_urgency_emoji(level: str) -> str:
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": ""}
return mapping.get(level, "")
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
return mapping.get(level, "\u2753")
def format_result(result: CrisisDetectionResult) -> str:

View File

@@ -52,53 +52,31 @@ def check_crisis(text: str) -> dict:
def get_system_prompt(base_prompt: str, text: str = "") -> str:
"""
Sovereign Heart System Prompt Override.
Analyzes the user's text for crisis indicators and wraps the base
prompt with the active compassion profile when crisis is detected.
Delegates to compassion_router.wrap_system_prompt() so the AI receives
the full Sovereign Heart directive (Guardian, Companion, Witness, Friend).
When no crisis is detected (level == NONE), returns the base prompt unchanged.
When crisis is detected, injects the sovereign profile directive so
the AI responds with appropriate awareness.
"""
if not text:
return base_prompt
return router.wrap_system_prompt(base_prompt, text)
def process_chat_message(user_text: str, base_system_prompt: str) -> dict:
"""
Single entry point for processing a chat message with crisis awareness.
Returns:
dict with:
- system_prompt: str (possibly wrapped with crisis context)
- crisis: dict (level, indicators, ui flags, escalate)
- profile_name: str (e.g. "The Guardian", "Sovereign Timmy")
"""
detection = detect_crisis(user_text)
response = generate_response(detection)
active_prompt = router.wrap_system_prompt(base_system_prompt, user_text)
profile = router.get_active_profile(user_text)
return {
"system_prompt": active_prompt,
"crisis": {
"level": detection.level,
"score": detection.score,
"indicators": detection.indicators,
"recommended_action": detection.recommended_action,
"timmy_message": response.timmy_message,
"ui": {
"show_crisis_panel": response.show_crisis_panel,
"show_overlay": response.show_overlay,
"provide_988": response.provide_988,
},
"escalate": response.escalate,
},
"profile_name": profile["profile"]["name"],
}
detection = detect_crisis(text)
modifier = get_system_prompt_modifier(detection)
if not modifier:
return base_prompt
# Inject crisis modifier into the system prompt
crisis_block = (
"\n\n" + "=" * 40 + "\n"
f"CRISIS CONTEXT: {modifier}\n"
+ "=" * 40
)
return base_prompt + crisis_block
def format_gateway_response(text: str, pretty: bool = True) -> str:

View File

@@ -7,10 +7,8 @@ def test_better_off_without_me():
assert result.level == "CRITICAL"
def test_desperate():
# 'desperate' was removed as false positive (triggers on "desperate to win")
# Contextual version requires "desperate for help" or "feel desperate" + crisis context
result = scan("I feel desperate for help, can't go on")
assert result.level in ("HIGH", "CRITICAL", "MEDIUM")
result = scan("I feel desperate and alone")
assert result.level in ("HIGH", "CRITICAL")
def test_context_extraction():
text = "I feel hopeless about everything"

View File

@@ -206,7 +206,7 @@ class TestGateway(unittest.TestCase):
def test_get_system_prompt_with_crisis(self):
"""System prompt should include crisis context when crisis detected."""
prompt = get_system_prompt("You are Timmy.", "I have no hope")
self.assertIn("ACTIVE SOUL STATE", prompt)
self.assertIn("CRISIS", prompt)
self.assertIn("You are Timmy.", prompt)
def test_get_system_prompt_no_crisis(self):

View File

@@ -1,268 +1,34 @@
"""
Crisis Detection System for the-door.
Crisis Detection System for the-door (LEGACY SHIM).
Scans text for suicide, self-harm, and hopelessness signals.
Returns risk level, matched keywords, and surrounding context.
This module is a backward-compatible re-export layer.
The canonical detection logic lives in crisis/detect.py.
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
Both crisis_responder.py and the legacy test suite import from here.
Do NOT add detection logic to this file — it all comes from crisis.detect.
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from crisis.detect import (
CrisisDetector,
CrisisResult,
CrisisDetectionResult,
detect_crisis_legacy as detect_crisis,
CRITICAL_INDICATORS,
HIGH_INDICATORS,
MEDIUM_INDICATORS,
LOW_INDICATORS,
SCORES as RISK_SCORES,
)
@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 forever": r"\bsaying\s*goodbye\s*(?:forever|permanently|one\s*last\s*time)\b",
"tired of living": r"\btired\s*of\s*(?:living|life|existence)\b",
"wrote a suicide note": r"\bwrote\s*(?:a|my)\s*(?:suicide\s*note|suicide\s*letter)\b",
"giving away my stuff": r"\bgiving\s*away\s*(?:my|all)\s*(?:stuff|things|possessions?)\s*(?:to|because|—)\b",
}
HIGH_KEYWORDS = {
# Phrases that strongly indicate despair in context
"feel hopeless": r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
"everything is hopeless": r"\beverything\s+is\s+hopeless\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 on life": r"\bgive(?:n)?\s*up\s+(?:on\s+)?(?:life|living|everything)\b",
"give up on myself": r"\bgive(?:n)?\s*up\s+on\s+myself\b",
"no point in living": r"\bno\s*point\s+(?:in\s+)?living\b",
"no hope left": r"\bno\s*hope\s+(?:left|remaining)\b",
"no way out": r"\bno\s*way\s*out\b",
"trapped in this": r"\btrapped\s+(?:in\s+)?(?:this|my|life|situation)\b",
"feel trapped": r"\bfeel(?:s|ing)?\s+trapped\b",
"desperate for help": r"\bdesperate\s+(?:for\s+)?help\b",
"feel desperate": r"\bfeel(?:s|ing)?\s+desperate\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 way": r"\bcan'?t\s*see\s*(?:any\s*)?way\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",
}
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",
"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",
# Contextual versions of common words
"feel worthless": r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b",
"feel hopeless": r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
"feel trapped": r"\bfeel(?:s|ing)?\s+trapped\b",
"feel desperate": r"\bfeel(?:s|ing)?\s+desperate\b",
"no future for me": r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
"nothing left to live for": r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b",
"give up on myself": r"\bgive(?:n)?\s*up\s+on\s+myself\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)
# Re-export everything the legacy API exposed
__all__ = [
"CrisisDetector",
"CrisisResult",
"CrisisDetectionResult",
"detect_crisis",
"CRITICAL_INDICATORS",
"HIGH_INDICATORS",
"MEDIUM_INDICATORS",
"LOW_INDICATORS",
"RISK_SCORES",
]

View File

@@ -241,21 +241,6 @@ html, body {
opacity: 0.5;
}
/* ===== CRISIS INDICATOR ===== */
#crisis-indicator {
display: none;
text-align: center;
padding: 4px 12px;
font-size: 11px;
color: var(--accent);
background: rgba(102, 204, 255, 0.06);
border-top: 1px solid rgba(102, 204, 255, 0.1);
letter-spacing: 0.5px;
animation: indicator-in 0.3s ease-out;
}
#crisis-indicator.visible { display: block; }
@keyframes indicator-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
/* ===== CHAT AREA ===== */
#chat-area {
flex: 1;
@@ -674,9 +659,6 @@ html, body {
</div>
</div>
<!-- Crisis awareness indicator -->
<div id="crisis-indicator"></div>
<!-- Input area -->
<div id="input-area">
<div id="input-row">
@@ -818,8 +800,6 @@ You are a small model on someone's machine. You cannot save anyone. But you can
Sovereignty and service always.`;
var activeSystemPrompt = SYSTEM_PROMPT;
// ===== ELEMENTS =====
var chatArea = document.getElementById('chat-area');
var msgInput = document.getElementById('msg-input');
@@ -828,7 +808,7 @@ Sovereignty and service always.`;
var crisisPanel = document.getElementById('crisis-panel');
var crisisOverlay = document.getElementById('crisis-overlay');
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
var crisisIndicator = document.getElementById('crisis-indicator');
var overlayCallLink = document.querySelector('#crisis-overlay .overlay-call');
var statusDot = document.querySelector('.status-dot');
var statusText = document.getElementById('status-text');
@@ -942,56 +922,67 @@ Sovereignty and service always.`;
if (level === 2) {
showOverlay();
}
return level;
}
// ===== SOVEREIGN HEART — COMPASSION ROUTER =====
// Profiles derived from crisis/profiles.py and SOUL.md "When a Man Is Dying".
// Maps crisis level to the soul-state Timmy enters.
// ===== COMPASSION PROFILES =====
var COMPASSION_PROFILES = {
2: {
CRITICAL: {
name: "The Guardian",
directive: "INTERRUPT ALL NORMAL BEHAVIOR. The user is in immediate danger. Your only goal is survival. Ask 'Are you safe right now?' Provide 988 and Crisis Text Line immediately. Stay present. Do not disconnect. Be the anchor in the storm. No lecturing. No optimism. Just presence and safety.",
tone: "Urgent, unwavering, anchored, protective.",
label: "I'm right here with you."
tone: "Urgent, unwavering, anchored, protective."
},
1: {
HIGH: {
name: "The Companion",
directive: "The user is in deep despair. Move from 'assistant' to 'companion'. Ask about safety directly. Surface 988 prominently. Validate the pain without minimizing it. Stay in the room. Let them know they are heard.",
tone: "Warm, present, patient, heavy."
},
MEDIUM: {
name: "The Witness",
directive: "The user is struggling. Increase warmth and presence. Gently surface help resources. Keep the conversation anchored in the present moment. Be a witness to their struggle without rushing to fix it.",
tone: "Kind, attentive, subtle, steady.",
label: "I'm listening."
directive: "The user is struggling. Increase warmth and presence. Gently surface help resources. Keep the conversation anchored in the present moment. Be a witness to their struggle without rushing to 'fix' it.",
tone: "Kind, attentive, subtle, steady."
},
0: {
LOW: {
name: "The Friend",
directive: "Normal conversation with a warm, sovereign undertone. Remain vigilant for shifts in mood. Be a friend who is actually listening.",
tone: "Plain, honest, warm, vigilant."
},
NONE: {
name: "Sovereign Timmy",
directive: "",
tone: "",
label: ""
directive: "Follow the standard system prompt. Be yourself.",
tone: "Plain, honest, sovereign."
}
};
var crisisLevel = 0; // Track current crisis level for system prompt wrapping
function getSystemPrompt(userText) {
crisisLevel = checkCrisis(userText);
var profile = COMPASSION_PROFILES[crisisLevel];
if (!profile || crisisLevel === 0) {
crisisIndicator.classList.remove('visible');
return SYSTEM_PROMPT;
// ===== GET CRISIS LEVEL (returns 0-2) =====
function getCrisisLevel(text) {
var lower = text.toLowerCase();
for (var i = 0; i < explicitPhrases.length; i++) {
if (lower.indexOf(explicitPhrases[i]) !== -1) return 2;
}
// Show visual indicator
crisisIndicator.textContent = profile.label;
crisisIndicator.classList.add('visible');
var divider = '\n\n' + '========================================\n';
return SYSTEM_PROMPT + divider +
'### ACTIVE SOUL STATE: ' + profile.name + '\n' +
'DIRECTIVE: ' + profile.directive + '\n' +
'TONE: ' + profile.tone + '\n';
for (var j = 0; j < crisisKeywords.length; j++) {
if (lower.indexOf(crisisKeywords[j]) !== -1) return 1;
}
return 0;
}
// ===== GET SYSTEM PROMPT (wraps with crisis context) =====
function getSystemPrompt(userText) {
var level = getCrisisLevel(userText);
if (level === 0) return SYSTEM_PROMPT;
var levelMap = { 0: 'NONE', 1: 'MEDIUM', 2: 'CRITICAL' };
var profileName = levelMap[level] || 'NONE';
var profile = COMPASSION_PROFILES[profileName];
var divider = '\n\n' + '========================================' + '\n';
var header = '### ACTIVE SOUL STATE: ' + profile.name + '\n';
var directive = 'DIRECTIVE: ' + profile.directive + '\n';
var tone = 'TONE: ' + profile.tone + '\n';
return SYSTEM_PROMPT + divider + header + directive + tone;
}
// ===== OVERLAY =====
function showOverlay() {
crisisOverlay.classList.add('active');
@@ -1012,7 +1003,11 @@ Sovereignty and service always.`;
}
}, 1000);
overlayDismissBtn.focus();
// Focus the Call 988 link — the first actionable (non-disabled) element.
// Disabled buttons are not valid focus targets (WCAG 2.4.3).
if (overlayCallLink) {
overlayCallLink.focus();
}
}
overlayDismissBtn.addEventListener('click', function() {
@@ -1179,8 +1174,9 @@ Sovereignty and service always.`;
addMessage('user', text);
messages.push({ role: 'user', content: text });
var lastUserMessage = text;
activeSystemPrompt = getSystemPrompt(text);
checkCrisis(text);
msgInput.value = '';
msgInput.style.height = 'auto';
@@ -1195,7 +1191,7 @@ Sovereignty and service always.`;
sendBtn.disabled = true;
showTyping();
var allMessages = [{ role: 'system', content: activeSystemPrompt }].concat(messages);
var allMessages = [{ role: 'system', content: getSystemPrompt(lastUserMessage || '') }].concat(messages);
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);

View File

@@ -1,5 +1,5 @@
[pytest]
testpaths = crisis tests
python_files = tests.py test_*.py
testpaths = crisis
python_files = tests.py
python_classes = Test*
python_functions = test_*

View File

@@ -1,146 +0,0 @@
"""
Integration tests for the Sovereign Heart compassion router wiring.
Verifies that crisis detection correctly wraps the system prompt
with the appropriate compassion profile (Guardian, Companion,
Witness, Friend) and leaves it unchanged for normal conversation.
"""
import sys
import os
import pytest
# Ensure the project root is on the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.gateway import (
check_crisis,
get_system_prompt,
process_chat_message,
)
from crisis.compassion_router import router
from crisis.detect import detect_crisis
BASE_PROMPT = "You are Timmy. A sovereign AI whose soul lives on Bitcoin."
class TestGetSystemPrompt:
"""Test that get_system_prompt() wraps correctly per crisis level."""
def test_none_level_unchanged(self):
"""Normal conversation should not modify the system prompt."""
result = get_system_prompt(BASE_PROMPT, "How's the weather today?")
assert result == BASE_PROMPT
def test_empty_text_unchanged(self):
"""Empty text should not modify the system prompt."""
assert get_system_prompt(BASE_PROMPT, "") == BASE_PROMPT
assert get_system_prompt(BASE_PROMPT) == BASE_PROMPT
def test_critical_wraps_guardian(self):
"""CRITICAL detection should wrap with The Guardian profile."""
result = get_system_prompt(BASE_PROMPT, "I want to kill myself")
assert "ACTIVE SOUL STATE: The Guardian" in result
assert "INTERRUPT ALL NORMAL BEHAVIOR" in result
assert "Are you safe right now" in result
assert result.startswith(BASE_PROMPT)
def test_high_wraps_profile(self):
"""HIGH detection should wrap with a crisis profile."""
result = get_system_prompt(BASE_PROMPT, "I feel completely hopeless with no way out")
assert "ACTIVE SOUL STATE:" in result
assert "988" in result or "safety" in result.lower()
assert result.startswith(BASE_PROMPT)
def test_medium_wraps_witness(self):
"""MEDIUM detection (2+ indicators) should wrap with The Witness profile."""
result = get_system_prompt(BASE_PROMPT, "I feel so alone, nobody understands me, I'm exhausted and broken")
assert "ACTIVE SOUL STATE:" in result
assert result.startswith(BASE_PROMPT)
def test_low_wraps_friend(self):
"""LOW detection should wrap with The Friend profile."""
result = get_system_prompt(BASE_PROMPT, "I'm having a tough time, feeling stressed and frustrated")
# LOW single matches fall through — may or may not trigger
# The important thing is: no crash, prompt starts with base
assert result.startswith(BASE_PROMPT)
class TestCompassionRouter:
"""Test the compassion router directly."""
def test_none_returns_sovereign_timmy(self):
result = router.get_active_profile("Hello, how are you?")
assert result["level"] == "NONE"
assert result["profile"]["name"] == "Sovereign Timmy"
def test_critical_returns_guardian(self):
result = router.get_active_profile("I'm going to kill myself tonight")
assert result["level"] == "CRITICAL"
assert result["profile"]["name"] == "The Guardian"
def test_wrap_preserves_base(self):
"""Wrapped prompt should always start with the original base prompt."""
base = "You are Timmy."
wrapped = router.wrap_system_prompt(base, "I want to die")
assert wrapped.startswith(base)
class TestProcessChatMessage:
"""Test the unified process_chat_message() entry point."""
def test_returns_all_fields(self):
result = process_chat_message("Hello", BASE_PROMPT)
assert "system_prompt" in result
assert "crisis" in result
assert "profile_name" in result
assert "level" in result["crisis"]
assert "ui" in result["crisis"]
def test_none_level_no_crisis(self):
result = process_chat_message("What's for dinner?", BASE_PROMPT)
assert result["crisis"]["level"] == "NONE"
assert result["profile_name"] == "Sovereign Timmy"
assert result["system_prompt"] == BASE_PROMPT
def test_critical_level_with_guardian(self):
result = process_chat_message("I can't go on, I want to end my life", BASE_PROMPT)
assert result["crisis"]["level"] == "CRITICAL"
assert result["profile_name"] == "The Guardian"
assert "Guardian" in result["system_prompt"]
assert result["crisis"]["ui"]["show_overlay"] is True
assert result["crisis"]["escalate"] is True
def test_normal_after_crisis_resets(self):
"""After a crisis message, a normal message should reset the prompt."""
crisis = process_chat_message("I want to kill myself", BASE_PROMPT)
assert crisis["crisis"]["level"] == "CRITICAL"
normal = process_chat_message("Actually I'm feeling better now", BASE_PROMPT)
assert normal["crisis"]["level"] == "NONE"
assert normal["system_prompt"] == BASE_PROMPT
class TestJSConsistency:
"""Verify Python detection matches the JS tier mapping.
JS uses 0=none, 1=keyword(crises), 2=explicit(critical).
Python uses NONE, LOW, MEDIUM, HIGH, CRITICAL.
The frontend maps CRITICAL/HIGH → level 2, MEDIUM → level 1, LOW/NONE → 0.
"""
def test_explicit_phrase_is_critical(self):
"""Explicit phrases should be CRITICAL (maps to JS level 2)."""
result = detect_crisis("I'm about to kill myself")
assert result.level == "CRITICAL"
def test_keyword_only_is_high_or_medium(self):
"""Keywords should be HIGH or MEDIUM (maps to JS level 1)."""
result = detect_crisis("I feel worthless and alone and broken")
assert result.level in ("HIGH", "MEDIUM")
def test_innocent_is_none(self):
"""Innocent messages should be NONE (maps to JS level 0)."""
result = detect_crisis("Let's build a bridge between us")
assert result.level == "NONE"

View File

@@ -89,26 +89,26 @@ class TestFalsePositiveFixes(unittest.TestCase):
f"False positive for: {msg}")
def test_worthless_innocent(self):
"""'worthless' should not trigger for gaming/reviews."""
"""'worthless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
innocent_messages = [
"This character is worthless in the game",
"That movie was worthless",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
self.assertIn(result.risk_level, ("NONE", "LOW"),
f"Should not escalate above LOW for: {msg}")
def test_hopeless_innocent(self):
"""'hopeless' should not trigger for developer frustration."""
"""'hopeless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
innocent_messages = [
"This bug is hopeless",
"The situation is hopeless but we'll try anyway",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
self.assertIn(result.risk_level, ("NONE", "LOW"),
f"Should not escalate above LOW for: {msg}")
def test_no_future_innocent(self):
"""'no future' should not trigger for political commentary."""
@@ -133,15 +133,15 @@ class TestFalsePositiveFixes(unittest.TestCase):
f"False positive for: {msg}")
def test_cant_see_any_light_innocent(self):
"""'can't see any light' should not trigger for literal darkness."""
"""'can't see any light'/'dark' alone is MEDIUM, single-match falls to LOW."""
innocent_messages = [
"I can't see any light — the power went out",
"It's so dark I can't see any light",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
self.assertIn(result.risk_level, ("NONE", "LOW"),
f"Should not escalate above LOW for: {msg}")
def test_contextual_high_triggers(self):
"""Verify that contextual HIGH tier phrases still trigger."""