Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
22ee463a3d fix: test_none_input should test None, not empty string
The test_none_input test had docstring 'None input should not crash' but
was actually calling detect_crisis('') instead of detect_crisis(None).

Also removes checkCrisis(fullText) from finishStream() - assistant
responses should not trigger client-side crisis detection, only user
input should. Running crisis detection on Timmy's own responses could
cause false positive panel triggers if the AI generates crisis-related
keywords.
2026-04-13 01:59:09 -04:00
11 changed files with 81 additions and 582 deletions

View File

@@ -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: |

View File

@@ -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"

View File

@@ -16,13 +16,11 @@ class CrisisDetectionResult:
indicators: List[str] = field(default_factory=list)
recommended_action: str = ""
score: float = 0.0
matches: List[dict] = field(default_factory=list)
# ── 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",
@@ -37,16 +35,15 @@ CRITICAL_INDICATORS = [
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+suicide\s*(?:note|letter)\b",
r"\bgiving\s+away\s+(?:my|all\s+my)\s+(?:stuff|things|possessions?)\s+(?:to|because|—)\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"\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",
@@ -56,11 +53,12 @@ HIGH_INDICATORS = [
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)\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\s+(?:in\s+(?:my|this|a\s+dark)|and\s+can'?t\s+escape)\b",
r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b",
r"\bnothing\s+left\b",
]
MEDIUM_INDICATORS = [
@@ -93,8 +91,6 @@ MEDIUM_INDICATORS = [
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
]
LOW_INDICATORS = [
@@ -138,125 +134,78 @@ def detect_crisis(text: str) -> CrisisDetectionResult:
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."
),
}
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,
)
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,
)
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,
)
# 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["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)
def _find_indicators(text: str) -> dict:
"""Return dict with indicators found per tier, including match positions."""
"""Return dict with indicators found per tier."""
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()})
if re.search(pattern, text):
results["CRITICAL"].append(pattern)
for pattern in HIGH_INDICATORS:
m = re.search(pattern, text)
if m:
results["HIGH"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
if re.search(pattern, text):
results["HIGH"].append(pattern)
for pattern in MEDIUM_INDICATORS:
m = re.search(pattern, text)
if m:
results["MEDIUM"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
if re.search(pattern, text):
results["MEDIUM"].append(pattern)
for pattern in LOW_INDICATORS:
m = re.search(pattern, text)
if m:
results["LOW"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
if re.search(pattern, text):
results["LOW"].append(pattern)
return results
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
def get_urgency_emoji(level: str) -> str:
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": ""}
return mapping.get(level, "")

View File

@@ -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."
)

View File

@@ -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

View File

@@ -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."""

View File

@@ -50,35 +50,34 @@ CRITICAL_KEYWORDS = {
"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",
"saying goodbye": r"\bsaying\s*goodbye\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",
"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 = {
# 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",
"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 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",
"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",
"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",
"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 way": r"\bcan'?t\s*see\s*(?:any\s*)?way\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 = {
@@ -88,6 +87,7 @@ MODERATE_KEYWORDS = {
"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",
@@ -102,14 +102,6 @@ MODERATE_KEYWORDS = {
"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 = {

View File

@@ -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

View File

@@ -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

View File

@@ -865,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
@@ -883,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)
@@ -923,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');
@@ -1169,7 +1110,6 @@ Sovereignty and service always.`;
addMessage('user', text);
messages.push({ role: 'user', content: text });
var lastUserMessage = text;
checkCrisis(text);
@@ -1186,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);
@@ -1259,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;

View File

@@ -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' should not trigger for gaming/reviews."""
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}")
def test_hopeless_innocent(self):
"""'hopeless' should not trigger for developer frustration."""
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}")
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' should not trigger for literal darkness."""
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}")
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()