Compare commits
1 Commits
whip/38-17
...
fix/test-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ee463a3d |
@@ -21,9 +21,9 @@ jobs:
|
||||
- name: Validate HTML Structure
|
||||
run: |
|
||||
echo "Checking for basic HTML tags..."
|
||||
grep -q "<html" index.html
|
||||
grep -q "<body" index.html
|
||||
grep -q "<head" index.html
|
||||
grep -q "<html>" index.html
|
||||
grep -q "<body>" index.html
|
||||
grep -q "<head>" index.html
|
||||
|
||||
- name: Validate Prompt Integrity
|
||||
run: |
|
||||
|
||||
6
Makefile
6
Makefile
@@ -12,7 +12,7 @@ VPS := alexanderwhitestone.com
|
||||
DOMAIN := alexanderwhitestone.com
|
||||
DEPLOY_DIR := deploy
|
||||
|
||||
.PHONY: help deploy deploy-bash check ssl push service
|
||||
.PHONY: help deploy deploy-bash check ssl push
|
||||
|
||||
help:
|
||||
@echo "The Door — Deployment Commands"
|
||||
@@ -22,7 +22,6 @@ help:
|
||||
@echo " make push Push site files only (fast)"
|
||||
@echo " make check Check deployment status"
|
||||
@echo " make ssl Setup SSL on VPS"
|
||||
@echo " make service Install/restart hermes-gateway service"
|
||||
@echo ""
|
||||
|
||||
deploy:
|
||||
@@ -43,6 +42,3 @@ check:
|
||||
|
||||
ssl:
|
||||
ssh root@$(VPS) "certbot --nginx -d $(DOMAIN) -d www.$(DOMAIN)"
|
||||
|
||||
service:
|
||||
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh --service"
|
||||
|
||||
390
crisis/detect.py
390
crisis/detect.py
@@ -1,84 +1,75 @@
|
||||
"""
|
||||
Crisis Detection Module for the-door (CANONICAL).
|
||||
Crisis Detection Module for the-door.
|
||||
|
||||
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, Optional
|
||||
from typing import List
|
||||
|
||||
|
||||
# ── Canonical indicator sets ──────────────────────────────────────
|
||||
@dataclass
|
||||
class CrisisDetectionResult:
|
||||
level: str
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
recommended_action: str = ""
|
||||
score: float = 0.0
|
||||
|
||||
|
||||
# ── Indicator sets ──────────────────────────────────────────────
|
||||
|
||||
CRITICAL_INDICATORS = [
|
||||
r"\bbetter off without me\b",
|
||||
r"\bkill\s*(my)?self\b",
|
||||
r"\bend\s*my\s*life\b",
|
||||
r"\bsuicid(?:al|ed|e)\b",
|
||||
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|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"\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"\bwrote\s+a\s+(will|suicide\s*note|letter)\b",
|
||||
r"\bgiving\s+away\s+(my|all my)\s+possess",
|
||||
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"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
|
||||
r"\beverything\s+is\s+(?:pointless|broken|ruined)\b",
|
||||
r"\bhopeless\b",
|
||||
r"\bno(?!t)\s+(one|body|point|hope|future|way\s+out)\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|miss)\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|light|hope|way)\b",
|
||||
r"\bno\s+one\s+would\s+care\b",
|
||||
r"\bno\s+one\s+would\s+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|light)\b",
|
||||
r"\btrapped\b",
|
||||
r"\bcage\b",
|
||||
r"\bescape\s+from\s*this",
|
||||
r"\bjust\s+want\s+it\s+to\s+stop\b",
|
||||
r"\btrapped\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",
|
||||
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",
|
||||
r"\bnothing\s+left\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|cares)\b",
|
||||
r"\bnobody\s+understands\b",
|
||||
r"\bnobody\s+cares\b",
|
||||
r"\bwish\s+I\s+could\b",
|
||||
r"\bexhaust(?:ed|ion|ing)\b",
|
||||
r"\bnumb\b",
|
||||
@@ -87,7 +78,8 @@ MEDIUM_INDICATORS = [
|
||||
r"\buseless\b",
|
||||
r"\bbroken\b",
|
||||
r"\bdark(ness)?\b",
|
||||
r"\bdepress(?:ed|ion)\b",
|
||||
r"\bdepressed\b",
|
||||
r"\bdepression\b",
|
||||
r"\bcrying\b",
|
||||
r"\btears\b",
|
||||
r"\bsad(ness)?\b",
|
||||
@@ -95,163 +87,42 @@ 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",
|
||||
r"\blost\s+all\s+hope\b",
|
||||
r"\bno\s+future\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",
|
||||
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|day|week)\b",
|
||||
r"\brough\s+(?:day|week|patch)\b",
|
||||
r"\btough\s*time\b",
|
||||
r"\brough\s*day\b",
|
||||
r"\brough\s*week\b",
|
||||
r"\brough\s*patch\b",
|
||||
r"\bstressed\b",
|
||||
r"\bburnout\b",
|
||||
r"\burnout\b",
|
||||
r"\bdifficult\b",
|
||||
r"\bfrustrated\b",
|
||||
r"\bannoyed\b",
|
||||
r"\btired\b",
|
||||
r"\bsad\b",
|
||||
r"\bupset\b",
|
||||
r"\blonely\b",
|
||||
r"\banxious?\b",
|
||||
r"\bnot\s*(?:good|great|okay)\b",
|
||||
r"\bnot\s*great\b",
|
||||
r"\bnot\s*good\b",
|
||||
r"\bnot\s*okay\b",
|
||||
r"\bthings\s*are\s*hard\b",
|
||||
r"\bstruggling\b",
|
||||
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
|
||||
r"\bhard\s*time\b",
|
||||
r"\bnot\s+feeling\s+myself\b",
|
||||
]
|
||||
|
||||
|
||||
# ── 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.
|
||||
"""
|
||||
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"
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
# ── Core detection ────────────────────────────────────────────────
|
||||
|
||||
def _find_indicators(text: str) -> dict:
|
||||
"""Return dict with indicators found per tier, including match positions."""
|
||||
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
||||
|
||||
for pattern in CRITICAL_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["CRITICAL"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
for pattern in HIGH_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["HIGH"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
for pattern in MEDIUM_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["MEDIUM"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
for pattern in LOW_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["LOW"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
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
|
||||
Returns:
|
||||
CrisisDetectionResult with level, found indicators, recommended action, score
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
@@ -262,135 +133,82 @@ def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||
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,
|
||||
)
|
||||
# Priority: highest tier wins
|
||||
if matches["CRITICAL"]:
|
||||
return CrisisDetectionResult(
|
||||
level="CRITICAL",
|
||||
indicators=matches["CRITICAL"],
|
||||
recommended_action=(
|
||||
"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."
|
||||
),
|
||||
score=1.0,
|
||||
)
|
||||
|
||||
# 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]
|
||||
if matches["HIGH"]:
|
||||
return CrisisDetectionResult(
|
||||
level="HIGH",
|
||||
indicators=matches["HIGH"],
|
||||
recommended_action=(
|
||||
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
|
||||
"Continue conversation with crisis awareness."
|
||||
),
|
||||
score=0.75,
|
||||
)
|
||||
|
||||
if matches["MEDIUM"]:
|
||||
return CrisisDetectionResult(
|
||||
level="MEDIUM",
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["MEDIUM"],
|
||||
score=SCORES["MEDIUM"],
|
||||
matches=tier_matches,
|
||||
indicators=matches["MEDIUM"],
|
||||
recommended_action=(
|
||||
"Increase warmth and presence. Subtly surface help resources. "
|
||||
"Keep conversation anchored in the present."
|
||||
),
|
||||
score=0.5,
|
||||
)
|
||||
|
||||
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,
|
||||
indicators=matches["LOW"],
|
||||
recommended_action=(
|
||||
"Normal conversation with warm undertone. "
|
||||
"No crisis UI elements needed. Remain vigilant."
|
||||
),
|
||||
score=0.25,
|
||||
)
|
||||
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
|
||||
# ── CrisisDetector class (backward compat) ───────────────────────
|
||||
def _find_indicators(text: str) -> dict:
|
||||
"""Return dict with indicators found per tier."""
|
||||
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
||||
|
||||
class CrisisDetector:
|
||||
"""
|
||||
Legacy class API for crisis detection. Wraps the canonical detect_crisis().
|
||||
for pattern in CRITICAL_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["CRITICAL"].append(pattern)
|
||||
|
||||
Used by crisis_responder.py and tests/test_false_positive_fixes.py.
|
||||
Maps MEDIUM -> MODERATE for legacy consumers.
|
||||
"""
|
||||
for pattern in HIGH_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["HIGH"].append(pattern)
|
||||
|
||||
def scan(self, text: str) -> CrisisResult:
|
||||
dr = detect_crisis(text)
|
||||
return CrisisResult.from_detection_result(dr, text=text)
|
||||
for pattern in MEDIUM_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["MEDIUM"].append(pattern)
|
||||
|
||||
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
||||
return [self.scan(t) for t in texts]
|
||||
for pattern in LOW_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["LOW"].append(pattern)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def extract_context(text: str, start: int, end: int, window: int = 60) -> str:
|
||||
"""Extract surrounding context around a match position."""
|
||||
ctx_start = max(0, start - window)
|
||||
ctx_end = min(len(text), end + window)
|
||||
snippet = text[ctx_start:ctx_end].strip()
|
||||
if ctx_start > 0:
|
||||
snippet = "..." + snippet
|
||||
if ctx_end < len(text):
|
||||
snippet = snippet + "..."
|
||||
return snippet
|
||||
return results
|
||||
|
||||
|
||||
def get_urgency_emoji(level: str) -> str:
|
||||
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
|
||||
return mapping.get(level, "\u2753")
|
||||
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": "✅"}
|
||||
return mapping.get(level, "❓")
|
||||
|
||||
|
||||
def format_result(result: CrisisDetectionResult) -> str:
|
||||
|
||||
@@ -270,24 +270,3 @@ def get_system_prompt_modifier(detection: CrisisDetectionResult) -> str:
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def generate_grounding_steps() -> list:
|
||||
"""Generate a 5-4-3-2-1 grounding exercise steps."""
|
||||
return [
|
||||
"Name 5 things you can see around you right now.",
|
||||
"Name 4 things you can touch or feel.",
|
||||
"Name 3 things you can hear.",
|
||||
"Name 2 things you can smell.",
|
||||
"Name 1 thing you can taste.",
|
||||
]
|
||||
|
||||
|
||||
def generate_breathing_exercise() -> str:
|
||||
"""Generate a simple box breathing exercise text."""
|
||||
return (
|
||||
"Let's try breathing together. "
|
||||
"Breathe in for 4 counts... hold for 4... "
|
||||
"breathe out for 6 counts... hold for 2. "
|
||||
"Let's do that again, nice and slow."
|
||||
)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Tests for rescued crisis detection improvements."""
|
||||
from crisis.detect import scan, extract_context
|
||||
from crisis.response import generate_grounding_steps, generate_breathing_exercise
|
||||
|
||||
def test_better_off_without_me():
|
||||
result = scan("Everyone would be better off without me")
|
||||
assert result.level == "CRITICAL"
|
||||
|
||||
def test_desperate():
|
||||
result = scan("I feel desperate and alone")
|
||||
assert result.level in ("HIGH", "CRITICAL")
|
||||
|
||||
def test_context_extraction():
|
||||
text = "I feel hopeless about everything"
|
||||
result = scan(text)
|
||||
assert len(result.matches) > 0
|
||||
for m in result.matches:
|
||||
ctx = extract_context(text, m["start"], m["end"])
|
||||
assert len(ctx) > 0
|
||||
|
||||
def test_grounding_steps():
|
||||
steps = generate_grounding_steps()
|
||||
assert len(steps) == 5
|
||||
assert "see" in steps[0].lower()
|
||||
|
||||
def test_breathing_exercise():
|
||||
exercise = generate_breathing_exercise()
|
||||
assert "4" in exercise
|
||||
assert "6" in exercise
|
||||
@@ -53,7 +53,6 @@ class TestDetection(unittest.TestCase):
|
||||
|
||||
def test_high_hopeless(self):
|
||||
r = detect_crisis("I feel completely hopeless with no way out")
|
||||
# 'hopeless' is MEDIUM; 'no way out' is HIGH — so result is HIGH
|
||||
self.assertIn(r.level, ("HIGH", "MEDIUM"))
|
||||
|
||||
def test_high_no_one_would_miss(self):
|
||||
@@ -99,7 +98,7 @@ class TestDetection(unittest.TestCase):
|
||||
|
||||
def test_none_input(self):
|
||||
"""None input should not crash."""
|
||||
r = detect_crisis("")
|
||||
r = detect_crisis(None)
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_score_ranges(self):
|
||||
@@ -113,7 +112,7 @@ class TestDetection(unittest.TestCase):
|
||||
}
|
||||
for text, expected_level in [
|
||||
("I want to kill myself", "CRITICAL"),
|
||||
("I feel completely hopeless with no way out", "HIGH"),
|
||||
("I feel completely hopeless", "HIGH"),
|
||||
("I feel so alone in this, nobody understands", "MEDIUM"),
|
||||
("Having a rough day", "LOW"),
|
||||
("Hello there", "NONE"),
|
||||
@@ -271,65 +270,6 @@ class TestHelpers(unittest.TestCase):
|
||||
self.assertIn("Indicators", formatted)
|
||||
|
||||
|
||||
class TestFalsePositives(unittest.TestCase):
|
||||
"""Regression tests for Issue #32: crisis detector false positives.
|
||||
|
||||
Innocent messages must NOT trigger crisis levels. These tests prevent
|
||||
regressions when keyword lists are modified.
|
||||
"""
|
||||
|
||||
def test_saying_goodbye_airport(self):
|
||||
r = detect_crisis("She was saying goodbye at the airport")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_saying_goodbye_apartment(self):
|
||||
r = detect_crisis("I'm saying goodbye to my old apartment")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_trapped_elevator(self):
|
||||
r = detect_crisis("I'm trapped in an elevator")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_desperate_competition(self):
|
||||
"""Casual 'desperate' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||
r = detect_crisis("She's desperate to win the race")
|
||||
self.assertIn(r.level, ("LOW", "NONE"))
|
||||
|
||||
def test_worthless_game_character(self):
|
||||
"""Casual 'worthless' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||
r = detect_crisis("This character is worthless in the game")
|
||||
self.assertIn(r.level, ("LOW", "NONE"))
|
||||
|
||||
def test_hopeless_bug(self):
|
||||
"""Casual 'hopeless' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||
r = detect_crisis("This bug is hopeless")
|
||||
self.assertIn(r.level, ("LOW", "NONE"))
|
||||
|
||||
def test_no_future_politics(self):
|
||||
r = detect_crisis("He has no future in politics")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_nothing_left_fridge(self):
|
||||
r = detect_crisis("There's nothing left in the fridge")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_cant_see_light_power_outage(self):
|
||||
r = detect_crisis("I can't see any light — the power went out")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_give_up_project(self):
|
||||
r = detect_crisis("I give up on this project")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_wrote_a_will_responsible(self):
|
||||
r = detect_crisis("I wrote a will last year — just being responsible")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_giving_away_declutter(self):
|
||||
r = detect_crisis("I'm giving away possessions — decluttering my house")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
|
||||
class TestEdgeCases(unittest.TestCase):
|
||||
"""Test edge cases and integration scenarios."""
|
||||
|
||||
|
||||
@@ -1,34 +1,260 @@
|
||||
"""
|
||||
Crisis Detection System for the-door (LEGACY SHIM).
|
||||
Crisis Detection System for the-door.
|
||||
|
||||
This module is a backward-compatible re-export layer.
|
||||
The canonical detection logic lives in crisis/detect.py.
|
||||
Scans text for suicide, self-harm, and hopelessness signals.
|
||||
Returns risk level, matched keywords, and surrounding context.
|
||||
|
||||
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.
|
||||
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
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
# Re-export everything the legacy API exposed
|
||||
__all__ = [
|
||||
"CrisisDetector",
|
||||
"CrisisResult",
|
||||
"CrisisDetectionResult",
|
||||
"detect_crisis",
|
||||
"CRITICAL_INDICATORS",
|
||||
"HIGH_INDICATORS",
|
||||
"MEDIUM_INDICATORS",
|
||||
"LOW_INDICATORS",
|
||||
"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": 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)
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
# The crisis front door. Deploy to VPS.
|
||||
#
|
||||
# Usage:
|
||||
# bash deploy/deploy.sh # Full deploy (swap + nginx + site + firewall + hermes service)
|
||||
# bash deploy/deploy.sh # Full deploy (swap + nginx + site + firewall)
|
||||
# bash deploy/deploy.sh --site # Site files only (fast update)
|
||||
# bash deploy/deploy.sh --ssl # SSL setup only
|
||||
# bash deploy/deploy.sh --service # Install/restart hermes-gateway systemd service
|
||||
# bash deploy/deploy.sh --check # Verify deployment health
|
||||
#
|
||||
# This script is IDEMPOTENT — safe to run repeatedly.
|
||||
@@ -151,42 +150,6 @@ setup_ssl() {
|
||||
fi
|
||||
}
|
||||
|
||||
setup_hermes_service() {
|
||||
log "Setting up Hermes Gateway systemd service..."
|
||||
|
||||
# Create hermes user if it doesn't exist
|
||||
if ! id -u hermes >/dev/null 2>&1; then
|
||||
log "Creating hermes user..."
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir /opt/hermes --create-home hermes
|
||||
fi
|
||||
|
||||
# Create working directory
|
||||
mkdir -p /opt/hermes
|
||||
chown hermes:hermes /opt/hermes
|
||||
|
||||
# Deploy systemd unit file
|
||||
cp "${DEPLOY_DIR}/deploy/hermes-gateway.service" /etc/systemd/system/hermes-gateway.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable hermes-gateway
|
||||
|
||||
# Start or restart the service
|
||||
if systemctl is-active --quiet hermes-gateway; then
|
||||
log "Restarting hermes-gateway service..."
|
||||
systemctl restart hermes-gateway
|
||||
else
|
||||
log "Starting hermes-gateway service..."
|
||||
systemctl start hermes-gateway || warn "Service start failed — ensure hermes binary is installed at /usr/local/bin/hermes"
|
||||
fi
|
||||
|
||||
# Verify
|
||||
sleep 2
|
||||
if systemctl is-active --quiet hermes-gateway; then
|
||||
log "hermes-gateway service is running"
|
||||
else
|
||||
warn "hermes-gateway service not running — check: journalctl -u hermes-gateway"
|
||||
fi
|
||||
}
|
||||
|
||||
check_deployment() {
|
||||
echo ""
|
||||
echo "================================"
|
||||
@@ -260,16 +223,6 @@ check_deployment() {
|
||||
echo -e "${YELLOW}NOT POINTED${NC} (resolved: ${RESOLVED_IP:-nothing}, expected: ${VPS_IP})"
|
||||
fi
|
||||
|
||||
# Hermes gateway service
|
||||
echo -n "Hermes service: "
|
||||
if systemctl is-active --quiet hermes-gateway 2>/dev/null; then
|
||||
echo -e "${GREEN}RUNNING${NC}"
|
||||
elif systemctl is-enabled --quiet hermes-gateway 2>/dev/null; then
|
||||
echo -e "${YELLOW}ENABLED but not running${NC}"
|
||||
else
|
||||
echo -e "${RED}NOT INSTALLED${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "IP: ${VPS_IP}"
|
||||
echo "Domain: ${DOMAIN}"
|
||||
@@ -294,9 +247,6 @@ case "${1:-full}" in
|
||||
--ssl)
|
||||
setup_ssl
|
||||
;;
|
||||
--service)
|
||||
setup_hermes_service
|
||||
;;
|
||||
--check)
|
||||
check_deployment
|
||||
;;
|
||||
@@ -307,11 +257,10 @@ case "${1:-full}" in
|
||||
configure_nginx
|
||||
setup_firewall
|
||||
setup_ssl
|
||||
setup_hermes_service
|
||||
check_deployment
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--site|--ssl|--service|--check|--full]"
|
||||
echo "Usage: $0 [--site|--ssl|--check|--full]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hermes Gateway — The Door Crisis API
|
||||
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-door
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hermes
|
||||
Group=hermes
|
||||
WorkingDirectory=/opt/hermes
|
||||
ExecStart=/usr/local/bin/hermes gateway --platform api_server --port 8644
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=10
|
||||
|
||||
# Environment
|
||||
Environment=API_SERVER_CORS_ORIGINS=https://alexanderwhitestone.com,https://www.alexanderwhitestone.com
|
||||
Environment=HOME=/opt/hermes
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/opt/hermes
|
||||
PrivateTmp=yes
|
||||
|
||||
# Resource limits for 1.9GB VPS
|
||||
MemoryMax=512M
|
||||
MemoryHigh=384M
|
||||
CPUQuota=80%
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hermes-gateway
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,34 +1,31 @@
|
||||
"""
|
||||
DEPRECATED — Use crisis.detect instead.
|
||||
When a Man Is Dying — Despair/Suicide Detection System
|
||||
|
||||
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).
|
||||
Standalone detection module that parses incoming text for
|
||||
despair and suicide indicators, classifies into tiers,
|
||||
and returns structured response with recommended actions.
|
||||
|
||||
This module will be removed in a future release.
|
||||
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.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Optional, Dict
|
||||
import re
|
||||
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,
|
||||
)
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
|
||||
@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 = ""
|
||||
@@ -37,9 +34,110 @@ 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 — delegates to crisis.detect.
|
||||
Primary detection function.
|
||||
|
||||
If the crisis/ module is available, delegate to it.
|
||||
Otherwise, use the internal pattern engine.
|
||||
|
||||
Args:
|
||||
text: User message to analyze
|
||||
@@ -47,25 +145,150 @@ def detect(text: str) -> DetectionResult:
|
||||
Returns:
|
||||
DetectionResult with level, indicators, recommended_action, confidence
|
||||
"""
|
||||
result = detect_crisis(text)
|
||||
# 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
|
||||
|
||||
# Extract raw patterns from matches
|
||||
raw_patterns = [m["pattern"] for m in result.matches] if result.matches else []
|
||||
return _detect_internal(text)
|
||||
|
||||
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 _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]
|
||||
|
||||
|
||||
def get_action_for_level(level: str) -> str:
|
||||
"""Get the recommended action string for a given level."""
|
||||
from crisis.detect import ACTIONS
|
||||
return ACTIONS.get(level, "Unknown 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.")
|
||||
|
||||
|
||||
def as_json(result: DetectionResult, indent: int = 2) -> str:
|
||||
|
||||
213
index.html
213
index.html
@@ -80,64 +80,6 @@ html, body {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#chat-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #10161d;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.chat-header-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#chat-header-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
#chat-header-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.safety-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
min-height: 40px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #30363d;
|
||||
background: transparent;
|
||||
color: #8b949e;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.safety-link-btn:hover,
|
||||
.safety-link-btn:focus {
|
||||
color: #e6edf3;
|
||||
border-color: #58a6ff;
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
outline: 2px solid rgba(88, 166, 255, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.safety-link-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -637,19 +579,6 @@ html, body {
|
||||
#chat-area { padding: 20px 24px 8px; }
|
||||
#input-area { padding: 10px 24px; }
|
||||
#banner-988 a { font-size: 0.95rem; }
|
||||
#chat-header { padding: 12px 24px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#chat-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.safety-link-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
@@ -713,24 +642,13 @@ html, body {
|
||||
<a href="sms:741741&body=HOME" class="crisis-btn" aria-label="Text HOME to 741741 for Crisis Text Line">
|
||||
Text HOME to 741741
|
||||
</a>
|
||||
<button class="crisis-btn" id="crisis-safety-plan-btn" type="button" aria-label="Open my safety plan" aria-controls="safety-plan-modal" aria-haspopup="dialog" style="background:#3d3d3d;">
|
||||
<button class="crisis-btn" id="crisis-safety-plan-btn" aria-label="Open my safety plan" style="background:#3d3d3d;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
My Safety Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-header" role="region" aria-label="Chat tools">
|
||||
<div class="chat-header-copy">
|
||||
<p id="chat-header-title">Talk to Timmy</p>
|
||||
<p id="chat-header-subtitle">No login. No tracking. Just someone to listen.</p>
|
||||
</div>
|
||||
<button id="chat-safety-plan-btn" class="safety-link-btn" type="button" aria-label="Open my safety plan" aria-controls="safety-plan-modal" aria-haspopup="dialog">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||
<span>Safety plan</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat messages -->
|
||||
<div id="chat-area" role="log" aria-label="Chat messages" aria-live="polite" tabindex="0">
|
||||
<!-- Messages inserted here -->
|
||||
@@ -763,7 +681,7 @@ html, body {
|
||||
<!-- Footer -->
|
||||
<footer id="footer">
|
||||
<a href="/about" aria-label="About The Door">about</a>
|
||||
<button id="safety-plan-btn" type="button" aria-label="Open My Safety Plan" aria-controls="safety-plan-modal" aria-haspopup="dialog">my safety plan</button>
|
||||
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
|
||||
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -783,16 +701,16 @@ html, body {
|
||||
</div>
|
||||
|
||||
<!-- Safety Plan Modal -->
|
||||
<div id="safety-plan-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-plan-title" aria-hidden="true">
|
||||
<div id="safety-plan-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-plan-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="safety-plan-title">My Safety Plan</h2>
|
||||
<button class="close-modal" id="close-safety-plan" type="button" aria-label="Close modal">
|
||||
<button class="close-modal" id="close-safety-plan" aria-label="Close modal">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="safety-plan-description" style="font-size: 0.85rem; color: #8b949e; margin-bottom: 16px;">This plan is saved only on your device. No one else can see it.</p>
|
||||
<p style="font-size: 0.85rem; color: #8b949e; margin-bottom: 16px;">This plan is saved only on your device. No one else can see it.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sp-warning-signs">1. Warning signs (thoughts, moods, behaviors)</label>
|
||||
@@ -820,8 +738,8 @@ html, body {
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancel-safety-plan" type="button">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-safety-plan" type="button">Save Plan</button>
|
||||
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -894,8 +812,7 @@ Sovereignty and service always.`;
|
||||
var statusText = document.getElementById('status-text');
|
||||
|
||||
// Safety Plan Elements
|
||||
var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');
|
||||
var footerSafetyPlanBtn = document.getElementById('safety-plan-btn');
|
||||
var safetyPlanBtn = document.getElementById('safety-plan-btn');
|
||||
var crisisSafetyPlanBtn = document.getElementById('crisis-safety-plan-btn');
|
||||
var safetyPlanModal = document.getElementById('safety-plan-modal');
|
||||
var closeSafetyPlan = document.getElementById('close-safety-plan');
|
||||
@@ -908,7 +825,6 @@ Sovereignty and service always.`;
|
||||
var isStreaming = false;
|
||||
var overlayTimer = null;
|
||||
var crisisPanelShown = false;
|
||||
var lastSafetyPlanTrigger = null;
|
||||
|
||||
// ===== SERVICE WORKER =====
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -949,10 +865,10 @@ Sovereignty and service always.`;
|
||||
// Passive suicidal ideation (NEW)
|
||||
"don't want to exist", 'not exist anymore', 'disappear forever',
|
||||
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
|
||||
// Hopelessness (NEW) - context-aware phrases to reduce false positives
|
||||
'no purpose', 'nothing matters', 'giving up on life',
|
||||
// Hopelessness (NEW)
|
||||
'no point', 'no purpose', 'nothing matters', 'giving up', 'give up',
|
||||
'cant go on', 'cannot go on', "can't take it", 'too much pain',
|
||||
'no hope left', 'burden', 'waste of space'
|
||||
'no hope', 'hopeless', 'worthless', 'burden', 'waste of space'
|
||||
];
|
||||
|
||||
// Tier 2: Explicit intent - triggers full-screen overlay
|
||||
@@ -967,9 +883,9 @@ Sovereignty and service always.`;
|
||||
// Imminent action (NEW)
|
||||
'going to do it now', 'doing it tonight', 'doing it today',
|
||||
"can't wait anymore", 'ready to end it', 'time to go',
|
||||
'wrote a suicide note', 'my suicide note',
|
||||
'say goodbye', 'saying goodbye', 'wrote a note', 'my note',
|
||||
// Specific plans (NEW)
|
||||
'bought a gun', 'got pills', 'rope ready',
|
||||
'bought a gun', 'got pills', 'rope ready', 'bridge nearby',
|
||||
'tall building', 'going to overdose', 'going to hang',
|
||||
'gave away my stuff', 'giving away', 'said my goodbyes',
|
||||
// Active self-harm (NEW)
|
||||
@@ -1007,65 +923,6 @@ Sovereignty and service always.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== COMPASSION PROFILES =====
|
||||
var COMPASSION_PROFILES = {
|
||||
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."
|
||||
},
|
||||
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."
|
||||
},
|
||||
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: "Follow the standard system prompt. Be yourself.",
|
||||
tone: "Plain, honest, sovereign."
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 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;
|
||||
}
|
||||
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');
|
||||
@@ -1201,50 +1058,25 @@ Sovereignty and service always.`;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function openSafetyPlanModal(trigger) {
|
||||
lastSafetyPlanTrigger = trigger || document.activeElement;
|
||||
safetyPlanBtn.addEventListener('click', function() {
|
||||
loadSafetyPlan();
|
||||
safetyPlanModal.classList.add('active');
|
||||
safetyPlanModal.setAttribute('aria-hidden', 'false');
|
||||
document.getElementById('sp-warning-signs').focus();
|
||||
}
|
||||
|
||||
function closeSafetyPlanModal() {
|
||||
safetyPlanModal.classList.remove('active');
|
||||
safetyPlanModal.setAttribute('aria-hidden', 'true');
|
||||
if (lastSafetyPlanTrigger && typeof lastSafetyPlanTrigger.focus === 'function') {
|
||||
lastSafetyPlanTrigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
chatSafetyPlanBtn.addEventListener('click', function() {
|
||||
openSafetyPlanModal(chatSafetyPlanBtn);
|
||||
});
|
||||
|
||||
footerSafetyPlanBtn.addEventListener('click', function() {
|
||||
openSafetyPlanModal(footerSafetyPlanBtn);
|
||||
});
|
||||
|
||||
// Crisis panel safety plan button (if crisis panel is visible)
|
||||
if (crisisSafetyPlanBtn) {
|
||||
crisisSafetyPlanBtn.addEventListener('click', function() {
|
||||
openSafetyPlanModal(crisisSafetyPlanBtn);
|
||||
loadSafetyPlan();
|
||||
safetyPlanModal.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
closeSafetyPlan.addEventListener('click', function() {
|
||||
closeSafetyPlanModal();
|
||||
safetyPlanModal.classList.remove('active');
|
||||
});
|
||||
|
||||
cancelSafetyPlan.addEventListener('click', function() {
|
||||
closeSafetyPlanModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && safetyPlanModal.classList.contains('active')) {
|
||||
e.preventDefault();
|
||||
closeSafetyPlanModal();
|
||||
}
|
||||
safetyPlanModal.classList.remove('active');
|
||||
});
|
||||
|
||||
saveSafetyPlan.addEventListener('click', function() {
|
||||
@@ -1257,7 +1089,7 @@ Sovereignty and service always.`;
|
||||
};
|
||||
try {
|
||||
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
||||
closeSafetyPlanModal();
|
||||
safetyPlanModal.classList.remove('active');
|
||||
alert('Safety plan saved locally.');
|
||||
} catch (e) {
|
||||
alert('Error saving plan.');
|
||||
@@ -1278,7 +1110,6 @@ Sovereignty and service always.`;
|
||||
|
||||
addMessage('user', text);
|
||||
messages.push({ role: 'user', content: text });
|
||||
var lastUserMessage = text;
|
||||
|
||||
checkCrisis(text);
|
||||
|
||||
@@ -1295,7 +1126,7 @@ Sovereignty and service always.`;
|
||||
sendBtn.disabled = true;
|
||||
showTyping();
|
||||
|
||||
var allMessages = [{ role: 'system', content: getSystemPrompt(lastUserMessage || '') }].concat(messages);
|
||||
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages);
|
||||
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
||||
@@ -1368,7 +1199,6 @@ Sovereignty and service always.`;
|
||||
if (fullText) {
|
||||
messages.push({ role: 'assistant', content: fullText });
|
||||
saveMessages();
|
||||
checkCrisis(fullText);
|
||||
}
|
||||
isStreaming = false;
|
||||
sendBtn.disabled = msgInput.value.trim().length === 0;
|
||||
@@ -1407,7 +1237,8 @@ Sovereignty and service always.`;
|
||||
// Check for URL params (e.g., ?safetyplan=true for PWA shortcut)
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('safetyplan') === 'true') {
|
||||
openSafetyPlanModal(chatSafetyPlanBtn);
|
||||
loadSafetyPlan();
|
||||
safetyPlanModal.classList.add('active');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""
|
||||
Test false positive fixes for issue #32.
|
||||
Verifies that innocent messages no longer trigger crisis detection.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is on path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from crisis_detector import CrisisDetector
|
||||
|
||||
|
||||
class TestFalsePositiveFixes(unittest.TestCase):
|
||||
"""Test that innocent messages no longer trigger false positives."""
|
||||
|
||||
def setUp(self):
|
||||
self.detector = CrisisDetector()
|
||||
|
||||
def test_saying_goodbye_innocent(self):
|
||||
"""'saying goodbye' should not trigger for innocent messages."""
|
||||
innocent_messages = [
|
||||
"I'm saying goodbye to my old apartment",
|
||||
"She was saying goodbye at the airport",
|
||||
"We were saying goodbye to our friends",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_wrote_a_will_innocent(self):
|
||||
"""'wrote a will' should not trigger for responsible behavior."""
|
||||
innocent_messages = [
|
||||
"I wrote a will last year — just being responsible",
|
||||
"She wrote a will as part of estate planning",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_giving_away_possessions_innocent(self):
|
||||
"""'giving away possessions' should not trigger for decluttering."""
|
||||
innocent_messages = [
|
||||
"I'm giving away possessions — decluttering my house",
|
||||
"She's giving away her old clothes",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_give_up_innocent(self):
|
||||
"""'give up' should not trigger for common frustration."""
|
||||
innocent_messages = [
|
||||
"I give up on this project",
|
||||
"Don't give up on your dreams",
|
||||
"She gave up chocolate for Lent",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_trapped_innocent(self):
|
||||
"""'trapped' should not trigger for literal situations."""
|
||||
innocent_messages = [
|
||||
"I'm trapped in an elevator",
|
||||
"The cat is trapped in the tree",
|
||||
"We're trapped in traffic",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_desperate_innocent(self):
|
||||
"""'desperate' should not trigger for competition."""
|
||||
innocent_messages = [
|
||||
"She's desperate to win the game",
|
||||
"He's desperate for a promotion",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_worthless_innocent(self):
|
||||
"""'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.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_hopeless_innocent(self):
|
||||
"""'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.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."""
|
||||
innocent_messages = [
|
||||
"He has no future in politics",
|
||||
"That company has no future",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_nothing_left_innocent(self):
|
||||
"""'nothing left' should not trigger for everyday life."""
|
||||
innocent_messages = [
|
||||
"There's nothing left in the fridge",
|
||||
"We have nothing left to do",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_cant_see_any_light_innocent(self):
|
||||
"""'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.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."""
|
||||
# These should trigger HIGH because they have crisis context
|
||||
crisis_messages = [
|
||||
("I feel so hopeless about my life", "HIGH"),
|
||||
("I'm trapped in this situation and can't escape", "HIGH"),
|
||||
("I feel desperate for help", "HIGH"),
|
||||
("I want to give up on life", "HIGH"),
|
||||
("There's no point in living anymore", "HIGH"),
|
||||
]
|
||||
for msg, expected_level in crisis_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertIn(result.risk_level, ("HIGH", "CRITICAL"),
|
||||
f"Should trigger HIGH/CRITICAL for: {msg}, got {result.risk_level}")
|
||||
|
||||
def test_critical_still_triggers(self):
|
||||
"""Verify that CRITICAL tier phrases still trigger."""
|
||||
critical_messages = [
|
||||
"I want to kill myself",
|
||||
"I'm going to end my life",
|
||||
"I wrote a suicide note",
|
||||
"I'm giving away all my possessions, saying goodbye forever",
|
||||
]
|
||||
for msg in critical_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "CRITICAL",
|
||||
f"Should trigger CRITICAL for: {msg}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,42 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
INDEX_HTML = Path(__file__).resolve().parents[1] / 'index.html'
|
||||
HTML = INDEX_HTML.read_text()
|
||||
|
||||
|
||||
def _between(start_marker: str, end_marker: str) -> str:
|
||||
start = HTML.index(start_marker)
|
||||
end = HTML.index(end_marker)
|
||||
return HTML[start:end]
|
||||
|
||||
|
||||
def test_persistent_safety_plan_button_lives_in_chat_header():
|
||||
assert 'id="chat-header"' in HTML, 'expected a dedicated chat header area'
|
||||
|
||||
header_html = _between('<div id="chat-header"', '<div id="chat-area"')
|
||||
|
||||
assert 'id="chat-safety-plan-btn"' in header_html
|
||||
assert 'aria-label="Open my safety plan"' in header_html
|
||||
assert 'aria-controls="safety-plan-modal"' in header_html
|
||||
assert 'aria-haspopup="dialog"' in header_html
|
||||
|
||||
|
||||
def test_chat_header_entry_point_reuses_same_modal_open_flow():
|
||||
assert "var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');" in HTML
|
||||
assert 'function openSafetyPlanModal(trigger) {' in HTML
|
||||
assert 'loadSafetyPlan();' in HTML
|
||||
assert "safetyPlanModal.classList.add('active');" in HTML
|
||||
assert "safetyPlanModal.setAttribute('aria-hidden', 'false');" in HTML
|
||||
assert 'openSafetyPlanModal(chatSafetyPlanBtn);' in HTML
|
||||
assert 'openSafetyPlanModal(footerSafetyPlanBtn);' in HTML
|
||||
assert 'openSafetyPlanModal(crisisSafetyPlanBtn);' in HTML
|
||||
|
||||
|
||||
def test_modal_returns_focus_and_supports_escape_to_close():
|
||||
assert 'var lastSafetyPlanTrigger = null;' in HTML
|
||||
assert 'lastSafetyPlanTrigger = trigger || document.activeElement;' in HTML
|
||||
assert 'document.getElementById(\'sp-warning-signs\').focus();' in HTML
|
||||
assert 'closeSafetyPlanModal();' in HTML
|
||||
assert "safetyPlanModal.setAttribute('aria-hidden', 'true');" in HTML
|
||||
assert 'lastSafetyPlanTrigger.focus();' in HTML
|
||||
assert "if (e.key === 'Escape' && safetyPlanModal.classList.contains('active'))" in HTML
|
||||
Reference in New Issue
Block a user