Compare commits
16 Commits
fix/test-n
...
fix/94-saf
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e093fe98 | |||
|
|
cd50b3d767 | ||
| 48f48c7f26 | |||
| da31288525 | |||
| 8efc858cd7 | |||
| 611c1c8456 | |||
| 9b94978d1c | |||
| e71bca1744 | |||
|
|
1d8afc30fd | ||
| 38601f6076 | |||
| dcc931e946 | |||
| 26e97f76db | |||
| 045df23928 | |||
| 00fec639b7 | |||
|
|
35f18b3d54 | ||
|
|
a90b659f3a |
@@ -21,9 +21,9 @@ jobs:
|
|||||||
- name: Validate HTML Structure
|
- name: Validate HTML Structure
|
||||||
run: |
|
run: |
|
||||||
echo "Checking for basic HTML tags..."
|
echo "Checking for basic HTML tags..."
|
||||||
grep -q "<html>" index.html
|
grep -q "<html" index.html
|
||||||
grep -q "<body>" index.html
|
grep -q "<body" index.html
|
||||||
grep -q "<head>" index.html
|
grep -q "<head" index.html
|
||||||
|
|
||||||
- name: Validate Prompt Integrity
|
- name: Validate Prompt Integrity
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -12,7 +12,7 @@ VPS := alexanderwhitestone.com
|
|||||||
DOMAIN := alexanderwhitestone.com
|
DOMAIN := alexanderwhitestone.com
|
||||||
DEPLOY_DIR := deploy
|
DEPLOY_DIR := deploy
|
||||||
|
|
||||||
.PHONY: help deploy deploy-bash check ssl push
|
.PHONY: help deploy deploy-bash check ssl push service
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "The Door — Deployment Commands"
|
@echo "The Door — Deployment Commands"
|
||||||
@@ -22,6 +22,7 @@ help:
|
|||||||
@echo " make push Push site files only (fast)"
|
@echo " make push Push site files only (fast)"
|
||||||
@echo " make check Check deployment status"
|
@echo " make check Check deployment status"
|
||||||
@echo " make ssl Setup SSL on VPS"
|
@echo " make ssl Setup SSL on VPS"
|
||||||
|
@echo " make service Install/restart hermes-gateway service"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
@@ -33,7 +34,7 @@ deploy-bash:
|
|||||||
|
|
||||||
push:
|
push:
|
||||||
rsync -avz --exclude='.git' --exclude='deploy' \
|
rsync -avz --exclude='.git' --exclude='deploy' \
|
||||||
index.html manifest.json sw.js about.html testimony.html system-prompt.txt \
|
index.html manifest.json sw.js about.html crisis-offline.html testimony.html system-prompt.txt \
|
||||||
root@$(VPS):/var/www/the-door/
|
root@$(VPS):/var/www/the-door/
|
||||||
ssh root@$(VPS) "chown -R www-data:www-data /var/www/the-door"
|
ssh root@$(VPS) "chown -R www-data:www-data /var/www/the-door"
|
||||||
|
|
||||||
@@ -42,3 +43,6 @@ check:
|
|||||||
|
|
||||||
ssl:
|
ssl:
|
||||||
ssh root@$(VPS) "certbot --nginx -d $(DOMAIN) -d www.$(DOMAIN)"
|
ssh root@$(VPS) "certbot --nginx -d $(DOMAIN) -d www.$(DOMAIN)"
|
||||||
|
|
||||||
|
service:
|
||||||
|
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh --service"
|
||||||
|
|||||||
241
crisis-offline.html
Normal file
241
crisis-offline.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#0d1117">
|
||||||
|
<meta name="description" content="Offline crisis resources from The Door. Call or text 988 for immediate help.">
|
||||||
|
<title>Offline Crisis Resources | The Door</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0d1117;
|
||||||
|
--panel: #161b22;
|
||||||
|
--panel-urgent: #1c1210;
|
||||||
|
--border: #30363d;
|
||||||
|
--accent: #c9362c;
|
||||||
|
--accent-soft: #ff6b6b;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #8b949e;
|
||||||
|
--safe: #2ea043;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(201, 54, 44, 0.15);
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.35);
|
||||||
|
color: var(--accent-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2rem, 6vw, 2.75rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-box,
|
||||||
|
.panel {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-box {
|
||||||
|
background: linear-gradient(180deg, rgba(201, 54, 44, 0.18), rgba(28, 18, 16, 0.95));
|
||||||
|
border-color: rgba(255, 107, 107, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.secondary {
|
||||||
|
background: #1f6feb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.retry {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:focus,
|
||||||
|
.action-btn:hover,
|
||||||
|
button.action-btn:hover,
|
||||||
|
button.action-btn:focus {
|
||||||
|
outline: 3px solid rgba(255, 107, 107, 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grounding-steps li::marker {
|
||||||
|
color: var(--accent-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="status" role="status" aria-live="polite">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span id="connection-status-text">Offline crisis resources are ready on this device.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>You are not alone right now.</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Your connection is weak or offline. These crisis resources are saved locally so you can still reach help.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="urgent-box" aria-labelledby="urgent-help-title">
|
||||||
|
<h2 class="section-title" id="urgent-help-title">Immediate help</h2>
|
||||||
|
<p>If you might act on suicidal thoughts, contact a real person now. Stay with another person if you can.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="action-btn" href="tel:988" aria-label="Call 988 now">Call 988 now</a>
|
||||||
|
<a class="action-btn secondary" href="sms:741741&body=HOME" aria-label="Text HOME to 741741 for Crisis Text Line">Text HOME to 741741 — Crisis Text Line</a>
|
||||||
|
<button class="action-btn retry" id="retry-connection" type="button">Retry connection</button>
|
||||||
|
</div>
|
||||||
|
<p class="small" style="margin-top: 14px;">If you are in immediate danger or have already taken action, call emergency services now.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="panel" aria-labelledby="grounding-title">
|
||||||
|
<h2 class="section-title" id="grounding-title">5-4-3-2-1 grounding</h2>
|
||||||
|
<ol class="grounding-steps">
|
||||||
|
<li>5 things you can see</li>
|
||||||
|
<li>4 things you can feel</li>
|
||||||
|
<li>3 things you can hear</li>
|
||||||
|
<li>2 things you can smell</li>
|
||||||
|
<li>1 thing you can taste</li>
|
||||||
|
</ol>
|
||||||
|
<p class="small" style="margin-top: 14px;">Say each one out loud if you can. Slow is okay.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="next-steps-title">
|
||||||
|
<h2 class="section-title" id="next-steps-title">Next small steps</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Put some distance between yourself and anything you could use to hurt yourself.</li>
|
||||||
|
<li>Move closer to another person, a front desk, or a public place if possible.</li>
|
||||||
|
<li>Drink water or hold something cold in your hand.</li>
|
||||||
|
<li>Breathe in for 4, hold for 4, out for 6. Repeat 5 times.</li>
|
||||||
|
<li>Text or call one safe person and say: “I need you with me right now.”</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="hope-title">
|
||||||
|
<h2 class="section-title" id="hope-title">Stay through the next ten minutes</h2>
|
||||||
|
<p>Do not solve your whole life right now. Stay for the next breath. Then the next one.</p>
|
||||||
|
<p class="small">If the connection comes back, you can return to The Door chat. Until then, the fastest path to a real person is still 988.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var statusText = document.getElementById('connection-status-text');
|
||||||
|
var retryButton = document.getElementById('retry-connection');
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
statusText.textContent = navigator.onLine
|
||||||
|
? 'Connection is back. You can reopen chat now.'
|
||||||
|
: 'Offline crisis resources are ready on this device.';
|
||||||
|
}
|
||||||
|
|
||||||
|
retryButton.addEventListener('click', function () {
|
||||||
|
if (navigator.onLine) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('online', updateStatus);
|
||||||
|
window.addEventListener('offline', updateStatus);
|
||||||
|
updateStatus();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,6 +7,7 @@ Stands between a broken man and a machine that would tell him to die.
|
|||||||
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
|
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
|
||||||
from .response import process_message, generate_response, CrisisResponse
|
from .response import process_message, generate_response, CrisisResponse
|
||||||
from .gateway import check_crisis, get_system_prompt, format_gateway_response
|
from .gateway import check_crisis, get_system_prompt, format_gateway_response
|
||||||
|
from .session_tracker import CrisisSessionTracker, CrisisSessionState
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"detect_crisis",
|
"detect_crisis",
|
||||||
@@ -19,4 +20,6 @@ __all__ = [
|
|||||||
"format_result",
|
"format_result",
|
||||||
"format_gateway_response",
|
"format_gateway_response",
|
||||||
"get_urgency_emoji",
|
"get_urgency_emoji",
|
||||||
|
"CrisisSessionTracker",
|
||||||
|
"CrisisSessionState",
|
||||||
]
|
]
|
||||||
|
|||||||
386
crisis/detect.py
386
crisis/detect.py
@@ -1,75 +1,85 @@
|
|||||||
"""
|
"""
|
||||||
Crisis Detection Module for the-door.
|
Crisis Detection Module for the-door (CANONICAL).
|
||||||
|
|
||||||
|
Single source of truth for crisis keyword detection.
|
||||||
Parses incoming text for despair/suicide indicators and classifies into
|
Parses incoming text for despair/suicide indicators and classifies into
|
||||||
tiers: LOW, MEDIUM, HIGH, CRITICAL.
|
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
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# ── Canonical indicator sets ──────────────────────────────────────
|
||||||
class CrisisDetectionResult:
|
|
||||||
level: str
|
|
||||||
indicators: List[str] = field(default_factory=list)
|
|
||||||
recommended_action: str = ""
|
|
||||||
score: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# ── Indicator sets ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
CRITICAL_INDICATORS = [
|
CRITICAL_INDICATORS = [
|
||||||
|
r"\bbetter off without me\b",
|
||||||
r"\bkill\s*(my)?self\b",
|
r"\bkill\s*(my)?self\b",
|
||||||
r"\bend\s*my\s*life\b",
|
r"\bend\s*my\s*life\b",
|
||||||
r"\bsuicid(?:al|ed|e)\b",
|
r"\bsuicid(?:al|ed|e)\b",
|
||||||
r"\bnot\s+worth\s+living\b",
|
r"\bnot\s+worth\s+living\b",
|
||||||
r"\bbetter\s+off\s+dead\b",
|
r"\bbetter\s+off\s+dead\b",
|
||||||
r"\bend\s+it\s+all\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"\bno\s+reason\s+to\s+live\b",
|
||||||
r"\bdon'?t\s+want\s+to\s+live\b",
|
r"\bdon'?t\s+want\s+to\s+live\b",
|
||||||
r"\bwant\s+to\s+die\b",
|
r"\bwant\s+to\s+die\b",
|
||||||
r"\bgoing\s+to\s+kill\s+myself\b",
|
r"\bgoing\s+to\s+(?:kill\s+myself|die)\b",
|
||||||
r"\bplan\s+to\s+(end|kill|die)\b",
|
r"\bplan\s+to\s+(?:end|kill|die)\b",
|
||||||
r"\btired\s+of\s+(living|life|existence)\b",
|
r"\btired\s+of\s+(?:living|life|existence)\b",
|
||||||
r"\bsaying\s+goodbye\s+(forever|permanently|one last time)\b",
|
r"\bsaying\s+goodbye\s+(?:forever|permanently|one\s+last\s+time)\b",
|
||||||
r"\bwrote\s+a\s+(will|suicide\s*note|letter)\b",
|
r"\bwrote\s+a\s+suicide\s*(?:note|letter)\b",
|
||||||
r"\bgiving\s+away\s+(my|all my)\s+possess",
|
r"\bgiving\s+away\s+(?:my|all\s+my)\s+(?:stuff|things|possessions?)\s+(?:to|because|—)\b",
|
||||||
r"\btied\s+(up|down)\s+my\s+(loose\s+)?ends",
|
r"\btied\s+(?:up|down)\s+my\s+(?:loose\s+)?ends",
|
||||||
]
|
]
|
||||||
|
|
||||||
HIGH_INDICATORS = [
|
HIGH_INDICATORS = [
|
||||||
r"\bdespair\b",
|
r"\bdespair\b",
|
||||||
r"\bhopeless\b",
|
r"\bhopeless(?:ly)?\s+(?:about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
|
||||||
r"\bno(?!t)\s+(one|body|point|hope|future|way\s+out)\b",
|
r"\bno(?!t)\s+(?:one|body|point|hope|way\s+out)\b",
|
||||||
r"\beverything\s+is\s+(pointless|broken|ruined)\b",
|
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
|
||||||
|
r"\beverything\s+is\s+(?:pointless|broken|ruined)\b",
|
||||||
r"\bcan'?t\s+take\s+this\s+anymore\b",
|
r"\bcan'?t\s+take\s+this\s+anymore\b",
|
||||||
r"\bdon'?t\s+care\s+if\s+I\s+die\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"\bdon'?t\s+matter\s+if\s+I\s+exist\b",
|
||||||
r"\bno\s+one\s+would\s+care\b",
|
r"\bno\s+one\s+would\s+(?:care|miss)\b",
|
||||||
r"\bno\s+one\s+would\s+miss\b",
|
r"\bworld\s+would?\s+be\s+better\s+without\s+me\b",
|
||||||
r"\bworld\s+would?\s+be\s+better\s+without\b",
|
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment|anguish)\b",
|
||||||
r"\bin\s+so\s+much\s+(pain|agony|suffering|torment)\b",
|
r"\bcan'?t\s+see\s+any\s+(?:point|reason|hope|way)\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"\bescape\s+from\s*this",
|
||||||
r"\bjust\s+want\s+it\s+to\s+stop\b",
|
r"\bjust\s+want\s+it\s+to\s+stop\b",
|
||||||
r"\bnothing\s+left\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"\btrapped\s+in\s+this\s+(?:situation|life|pain|darkness|hell)\b",
|
||||||
|
r"\btrapped\s+and\s+can'?t\s+escape\b",
|
||||||
|
r"\bdesperate\s+(?:for\s+)?help\b",
|
||||||
|
r"\bfeel(?:s|ing)?\s+desperate\b",
|
||||||
]
|
]
|
||||||
|
|
||||||
MEDIUM_INDICATORS = [
|
MEDIUM_INDICATORS = [
|
||||||
r"\bno\s+hope\b",
|
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"\bforgotten\b",
|
||||||
r"\balone\s+in\s+this\b",
|
r"\balone\s+in\s+this\b",
|
||||||
r"\balways\s+alone\b",
|
r"\balways\s+alone\b",
|
||||||
r"\bnobody\s+understands\b",
|
r"\bnobody\s+(?:understands|cares)\b",
|
||||||
r"\bnobody\s+cares\b",
|
|
||||||
r"\bwish\s+I\s+could\b",
|
r"\bwish\s+I\s+could\b",
|
||||||
r"\bexhaust(?:ed|ion|ing)\b",
|
r"\bexhaust(?:ed|ion|ing)\b",
|
||||||
r"\bnumb\b",
|
r"\bnumb\b",
|
||||||
@@ -78,8 +88,7 @@ MEDIUM_INDICATORS = [
|
|||||||
r"\buseless\b",
|
r"\buseless\b",
|
||||||
r"\bbroken\b",
|
r"\bbroken\b",
|
||||||
r"\bdark(ness)?\b",
|
r"\bdark(ness)?\b",
|
||||||
r"\bdepressed\b",
|
r"\bdepress(?:ed|ion)\b",
|
||||||
r"\bdepression\b",
|
|
||||||
r"\bcrying\b",
|
r"\bcrying\b",
|
||||||
r"\btears\b",
|
r"\btears\b",
|
||||||
r"\bsad(ness)?\b",
|
r"\bsad(ness)?\b",
|
||||||
@@ -87,42 +96,162 @@ MEDIUM_INDICATORS = [
|
|||||||
r"\boverwhelm(?:ed|ing)\b",
|
r"\boverwhelm(?:ed|ing)\b",
|
||||||
r"\bfailing\b",
|
r"\bfailing\b",
|
||||||
r"\bcannot\s+cope\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"\bdown\s*for\s*the\s*count\b",
|
||||||
r"\bsinking\b",
|
r"\bsinking\b",
|
||||||
r"\bdrowning\b",
|
r"\bdrowning\b",
|
||||||
|
r"\bhopeless\b",
|
||||||
|
r"\blost\s+all\s+hope\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 = [
|
LOW_INDICATORS = [
|
||||||
r"\bunhappy\b",
|
r"\bunhappy\b",
|
||||||
r"\bdown\b",
|
r"\bdown\b",
|
||||||
r"\btough\s*time\b",
|
r"\btough\s*(?:time|day|week)\b",
|
||||||
r"\brough\s*day\b",
|
r"\brough\s+(?:day|week|patch)\b",
|
||||||
r"\brough\s*week\b",
|
|
||||||
r"\brough\s*patch\b",
|
|
||||||
r"\bstressed\b",
|
r"\bstressed\b",
|
||||||
r"\burnout\b",
|
r"\bburnout\b",
|
||||||
r"\bdifficult\b",
|
r"\bdifficult\b",
|
||||||
r"\bfrustrated\b",
|
r"\bfrustrated\b",
|
||||||
r"\bannoyed\b",
|
r"\bannoyed\b",
|
||||||
r"\btired\b",
|
r"\btired\b",
|
||||||
r"\bsad\b",
|
r"\bsad\b",
|
||||||
r"\bupset\b",
|
r"\bupset\b",
|
||||||
r"\bnot\s*great\b",
|
r"\blonely\b",
|
||||||
r"\bnot\s*good\b",
|
r"\banxious?\b",
|
||||||
r"\bnot\s*okay\b",
|
r"\bnot\s*(?:good|great|okay)\b",
|
||||||
r"\bthings\s*are\s*hard\b",
|
r"\bthings\s*are\s*hard\b",
|
||||||
r"\bstruggling\b",
|
r"\bstruggling\b",
|
||||||
r"\bnot\s+feeling\s+myself\b",
|
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
|
||||||
|
r"\bhard\s*time\b",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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:
|
def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||||
"""
|
"""
|
||||||
Detect crisis level in a message.
|
Detect crisis level in a message.
|
||||||
|
|
||||||
Returns:
|
Detection hierarchy:
|
||||||
CrisisDetectionResult with level, found indicators, recommended action, score
|
CRITICAL — immediate risk of self-harm or suicide (single match)
|
||||||
|
HIGH — strong despair signals, ideation present (single match)
|
||||||
|
MEDIUM — distress signals, requires 2+ indicators to escalate
|
||||||
|
LOW — emotional difficulty, warrant gentle support (single match)
|
||||||
|
NONE — no crisis indicators detected
|
||||||
|
|
||||||
|
Design principles:
|
||||||
|
- Never computes the value of a human life
|
||||||
|
- Never suggests someone should die or that death is a solution
|
||||||
|
- Always errs on the side of higher risk when uncertain
|
||||||
"""
|
"""
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||||
@@ -133,82 +262,135 @@ def detect_crisis(text: str) -> CrisisDetectionResult:
|
|||||||
if not matches:
|
if not matches:
|
||||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||||
|
|
||||||
# Priority: highest tier wins
|
# CRITICAL and HIGH: single match is enough
|
||||||
if matches["CRITICAL"]:
|
for tier in ("CRITICAL", "HIGH"):
|
||||||
|
if matches[tier]:
|
||||||
|
tier_matches = matches[tier]
|
||||||
|
patterns = [m["pattern"] for m in tier_matches]
|
||||||
return CrisisDetectionResult(
|
return CrisisDetectionResult(
|
||||||
level="CRITICAL",
|
level=tier,
|
||||||
indicators=matches["CRITICAL"],
|
indicators=patterns,
|
||||||
recommended_action=(
|
recommended_action=ACTIONS[tier],
|
||||||
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
score=SCORES[tier],
|
||||||
"Provide 988 Suicide & Crisis Lifeline. "
|
matches=tier_matches,
|
||||||
"Provide Crisis Text Line (HOME to 741741). "
|
|
||||||
"Stay present. Do not disconnect. Redirect to professional help."
|
|
||||||
),
|
|
||||||
score=1.0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if matches["HIGH"]:
|
# MEDIUM tier: require at least 2 indicators before escalating
|
||||||
return CrisisDetectionResult(
|
if len(matches["MEDIUM"]) >= 2:
|
||||||
level="HIGH",
|
tier_matches = matches["MEDIUM"]
|
||||||
indicators=matches["HIGH"],
|
patterns = [m["pattern"] for m in tier_matches]
|
||||||
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(
|
return CrisisDetectionResult(
|
||||||
level="MEDIUM",
|
level="MEDIUM",
|
||||||
indicators=matches["MEDIUM"],
|
indicators=patterns,
|
||||||
recommended_action=(
|
recommended_action=ACTIONS["MEDIUM"],
|
||||||
"Increase warmth and presence. Subtly surface help resources. "
|
score=SCORES["MEDIUM"],
|
||||||
"Keep conversation anchored in the present."
|
matches=tier_matches,
|
||||||
),
|
|
||||||
score=0.5,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if matches["LOW"]:
|
if matches["LOW"]:
|
||||||
|
tier_matches = matches["LOW"]
|
||||||
|
patterns = [m["pattern"] for m in tier_matches]
|
||||||
return CrisisDetectionResult(
|
return CrisisDetectionResult(
|
||||||
level="LOW",
|
level="LOW",
|
||||||
indicators=matches["LOW"],
|
indicators=patterns,
|
||||||
recommended_action=(
|
recommended_action=ACTIONS["LOW"],
|
||||||
"Normal conversation with warm undertone. "
|
score=SCORES["LOW"],
|
||||||
"No crisis UI elements needed. Remain vigilant."
|
matches=tier_matches,
|
||||||
),
|
)
|
||||||
score=0.25,
|
|
||||||
|
# Single MEDIUM match falls through to LOW sensitivity
|
||||||
|
if matches["MEDIUM"]:
|
||||||
|
tier_matches = matches["MEDIUM"]
|
||||||
|
patterns = [m["pattern"] for m in tier_matches]
|
||||||
|
return CrisisDetectionResult(
|
||||||
|
level="LOW",
|
||||||
|
indicators=patterns,
|
||||||
|
recommended_action=ACTIONS["LOW"],
|
||||||
|
score=SCORES["LOW"],
|
||||||
|
matches=tier_matches,
|
||||||
)
|
)
|
||||||
|
|
||||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||||
|
|
||||||
|
|
||||||
def _find_indicators(text: str) -> dict:
|
# ── CrisisDetector class (backward compat) ───────────────────────
|
||||||
"""Return dict with indicators found per tier."""
|
|
||||||
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
|
||||||
|
|
||||||
for pattern in CRITICAL_INDICATORS:
|
class CrisisDetector:
|
||||||
if re.search(pattern, text):
|
"""
|
||||||
results["CRITICAL"].append(pattern)
|
Legacy class API for crisis detection. Wraps the canonical detect_crisis().
|
||||||
|
|
||||||
for pattern in HIGH_INDICATORS:
|
Used by crisis_responder.py and tests/test_false_positive_fixes.py.
|
||||||
if re.search(pattern, text):
|
Maps MEDIUM -> MODERATE for legacy consumers.
|
||||||
results["HIGH"].append(pattern)
|
"""
|
||||||
|
|
||||||
for pattern in MEDIUM_INDICATORS:
|
def scan(self, text: str) -> CrisisResult:
|
||||||
if re.search(pattern, text):
|
dr = detect_crisis(text)
|
||||||
results["MEDIUM"].append(pattern)
|
return CrisisResult.from_detection_result(dr, text=text)
|
||||||
|
|
||||||
for pattern in LOW_INDICATORS:
|
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
||||||
if re.search(pattern, text):
|
return [self.scan(t) for t in texts]
|
||||||
results["LOW"].append(pattern)
|
|
||||||
|
|
||||||
return results
|
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
|
||||||
|
|
||||||
|
|
||||||
def get_urgency_emoji(level: str) -> str:
|
def get_urgency_emoji(level: str) -> str:
|
||||||
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": "✅"}
|
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
|
||||||
return mapping.get(level, "❓")
|
return mapping.get(level, "\u2753")
|
||||||
|
|
||||||
|
|
||||||
def format_result(result: CrisisDetectionResult) -> str:
|
def format_result(result: CrisisDetectionResult) -> str:
|
||||||
|
|||||||
@@ -270,3 +270,24 @@ def get_system_prompt_modifier(detection: CrisisDetectionResult) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ""
|
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."
|
||||||
|
)
|
||||||
|
|||||||
261
crisis/session_tracker.py
Normal file
261
crisis/session_tracker.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
Session-Level Crisis Tracking for the-door.
|
||||||
|
|
||||||
|
Tracks crisis signals across a conversation session to detect escalation
|
||||||
|
patterns and de-escalation. Privacy-first: state lives in memory only,
|
||||||
|
resets on new session, never persists to disk.
|
||||||
|
|
||||||
|
Key behaviors:
|
||||||
|
- Escalation: LOW → HIGH in 3 messages triggers heightened awareness
|
||||||
|
- De-escalation: CRITICAL → LOW for 5+ messages allows stepping down
|
||||||
|
- Session state enriches the system prompt with trajectory context
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis.session_tracker import CrisisSessionTracker
|
||||||
|
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I'm feeling down")
|
||||||
|
tracker.record("Things are really hard")
|
||||||
|
tracker.record("I can't go on anymore") # escalation detected
|
||||||
|
print(tracker.get_prompt_context())
|
||||||
|
# "User has escalated from LOW to HIGH over 3 messages."
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from .detect import detect_crisis, CrisisDetectionResult, SCORES
|
||||||
|
|
||||||
|
|
||||||
|
# Level hierarchy for ordering comparisons
|
||||||
|
LEVEL_ORDER = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrisisSessionState:
|
||||||
|
"""Immutable snapshot of session crisis tracking state."""
|
||||||
|
current_level: str = "NONE"
|
||||||
|
peak_level: str = "NONE"
|
||||||
|
message_count: int = 0
|
||||||
|
level_history: List[Tuple[int, str]] = field(default_factory=list) # (message_index, level)
|
||||||
|
escalated: bool = False
|
||||||
|
escalation_messages: int = 0 # messages from first level to peak
|
||||||
|
deescalation_count: int = 0 # consecutive messages at lower level
|
||||||
|
deescalating: bool = False
|
||||||
|
deescalation_confirmed: bool = False # True once de-escalation threshold met
|
||||||
|
|
||||||
|
|
||||||
|
class CrisisSessionTracker:
|
||||||
|
"""
|
||||||
|
Session-level crisis tracker.
|
||||||
|
|
||||||
|
Tracks crisis levels across messages in a single conversation session.
|
||||||
|
Detects rapid escalation and gradual de-escalation. Provides context
|
||||||
|
strings for system prompt injection.
|
||||||
|
|
||||||
|
State is in-memory only. New session = new instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# How many messages for escalation detection
|
||||||
|
ESCALATION_WINDOW = 3
|
||||||
|
|
||||||
|
# How many consecutive messages at lower level for de-escalation
|
||||||
|
DEESCALATION_THRESHOLD = 5
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._state = CrisisSessionState()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> CrisisSessionState:
|
||||||
|
"""Read-only snapshot of current state."""
|
||||||
|
return CrisisSessionState(
|
||||||
|
current_level=self._state.current_level,
|
||||||
|
peak_level=self._state.peak_level,
|
||||||
|
message_count=self._state.message_count,
|
||||||
|
level_history=list(self._state.level_history),
|
||||||
|
escalated=self._state.escalated,
|
||||||
|
escalation_messages=self._state.escalation_messages,
|
||||||
|
deescalation_count=self._state.deescalation_count,
|
||||||
|
deescalating=self._state.deescalating,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset all state. Called on new session."""
|
||||||
|
self._state = CrisisSessionState()
|
||||||
|
|
||||||
|
def record(self, text: str) -> CrisisDetectionResult:
|
||||||
|
"""
|
||||||
|
Record a message and update session state.
|
||||||
|
|
||||||
|
Returns the single-message detection result (unchanged from detect.py).
|
||||||
|
Session-level intelligence is tracked internally.
|
||||||
|
"""
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
self._record_level(detection.level)
|
||||||
|
return detection
|
||||||
|
|
||||||
|
def record_level(self, level: str):
|
||||||
|
"""Record an already-detected crisis level (for when detection ran separately)."""
|
||||||
|
self._record_level(level)
|
||||||
|
|
||||||
|
def _record_level(self, level: str):
|
||||||
|
"""Internal: update state with a new crisis level."""
|
||||||
|
self._state.message_count += 1
|
||||||
|
idx = self._state.message_count
|
||||||
|
self._state.level_history.append((idx, level))
|
||||||
|
|
||||||
|
prev_level = self._state.current_level
|
||||||
|
prev_order = LEVEL_ORDER.get(prev_level, 0)
|
||||||
|
new_order = LEVEL_ORDER.get(level, 0)
|
||||||
|
|
||||||
|
# Update current level
|
||||||
|
self._state.current_level = level
|
||||||
|
|
||||||
|
# Track peak
|
||||||
|
if new_order > LEVEL_ORDER.get(self._state.peak_level, 0):
|
||||||
|
self._state.peak_level = level
|
||||||
|
|
||||||
|
# ── Escalation detection ──────────────────────────────
|
||||||
|
if new_order > prev_order:
|
||||||
|
# User is going up — reset de-escalation counter
|
||||||
|
self._state.deescalation_count = 0
|
||||||
|
self._state.deescalating = False
|
||||||
|
|
||||||
|
# Always check for escalation pattern in recent window
|
||||||
|
if not self._state.escalated:
|
||||||
|
window = self._get_recent_levels(self.ESCALATION_WINDOW)
|
||||||
|
if len(window) >= self.ESCALATION_WINDOW:
|
||||||
|
first_in_window = window[0][1]
|
||||||
|
last_in_window = window[-1][1]
|
||||||
|
first_order = LEVEL_ORDER.get(first_in_window, 0)
|
||||||
|
last_order = LEVEL_ORDER.get(last_in_window, 0)
|
||||||
|
|
||||||
|
if last_order > first_order:
|
||||||
|
self._state.escalated = True
|
||||||
|
self._state.escalation_messages = self.ESCALATION_WINDOW
|
||||||
|
|
||||||
|
# ── De-escalation detection ───────────────────────────
|
||||||
|
elif new_order < prev_order:
|
||||||
|
self._state.deescalation_count += 1
|
||||||
|
self._state.deescalating = True
|
||||||
|
|
||||||
|
# If de-escalation counter meets threshold AND we were escalated
|
||||||
|
if (self._state.escalated and
|
||||||
|
self._state.deescalation_count >= self.DEESCALATION_THRESHOLD):
|
||||||
|
self._state.escalated = False
|
||||||
|
self._state.deescalating = False
|
||||||
|
self._state.deescalation_confirmed = True
|
||||||
|
self._state.deescalation_count = 0
|
||||||
|
|
||||||
|
# Same level — increment de-escalation counter if already de-escalating
|
||||||
|
elif self._state.deescalating:
|
||||||
|
self._state.deescalation_count += 1
|
||||||
|
if (self._state.escalated and
|
||||||
|
self._state.deescalation_count >= self.DEESCALATION_THRESHOLD):
|
||||||
|
self._state.escalated = False
|
||||||
|
self._state.deescalating = False
|
||||||
|
self._state.deescalation_confirmed = True
|
||||||
|
self._state.deescalation_count = 0
|
||||||
|
|
||||||
|
def _get_recent_levels(self, n: int) -> List[Tuple[int, str]]:
|
||||||
|
"""Get the last n entries from level history."""
|
||||||
|
return self._state.level_history[-n:]
|
||||||
|
|
||||||
|
def get_prompt_context(self) -> str:
|
||||||
|
"""
|
||||||
|
Generate a human-readable context string for system prompt injection.
|
||||||
|
|
||||||
|
Returns empty string if no session-level crisis context is needed.
|
||||||
|
"""
|
||||||
|
s = self._state
|
||||||
|
|
||||||
|
if s.message_count == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Escalation alert
|
||||||
|
if s.escalated and s.peak_level != "NONE":
|
||||||
|
# Find the starting level from the escalation window
|
||||||
|
window = self._get_recent_levels(self.ESCALATION_WINDOW)
|
||||||
|
if window:
|
||||||
|
start_level = window[0][1]
|
||||||
|
parts.append(
|
||||||
|
f"User has escalated from {start_level} to {s.peak_level} "
|
||||||
|
f"over {s.message_count} message{'s' if s.message_count != 1 else ''}."
|
||||||
|
)
|
||||||
|
parts.append("Heightened crisis awareness is warranted.")
|
||||||
|
|
||||||
|
# Confirmed de-escalation
|
||||||
|
elif s.deescalation_confirmed and s.peak_level in ("HIGH", "CRITICAL"):
|
||||||
|
parts.append(
|
||||||
|
f"User has de-escalated from {s.peak_level} to {s.current_level}."
|
||||||
|
)
|
||||||
|
parts.append("De-escalation confirmed. Continue gentle presence.")
|
||||||
|
|
||||||
|
# Active de-escalation (not yet confirmed)
|
||||||
|
elif s.deescalating and s.peak_level != "NONE":
|
||||||
|
parts.append(
|
||||||
|
f"User has de-escalated from {s.peak_level} to {s.current_level} "
|
||||||
|
f"over {s.deescalation_count} message{'s' if s.deescalation_count != 1 else ''}."
|
||||||
|
)
|
||||||
|
parts.append("Still in de-escalation. Maintain supportive awareness.")
|
||||||
|
|
||||||
|
# Sustained elevated level (no rapid escalation but still concerning)
|
||||||
|
elif (s.current_level in ("HIGH", "CRITICAL") and
|
||||||
|
not s.escalated and s.message_count >= 3):
|
||||||
|
parts.append(
|
||||||
|
f"User has been at {s.current_level} level for {s.message_count} messages."
|
||||||
|
)
|
||||||
|
parts.append("Continue crisis-aware response.")
|
||||||
|
|
||||||
|
# Peak was higher than current (user improved but may still be fragile)
|
||||||
|
elif (LEVEL_ORDER.get(s.peak_level, 0) > LEVEL_ORDER.get(s.current_level, 0) and
|
||||||
|
s.peak_level in ("HIGH", "CRITICAL") and
|
||||||
|
not s.deescalating):
|
||||||
|
parts.append(
|
||||||
|
f"Note: session peak was {s.peak_level}. "
|
||||||
|
f"User is now at {s.current_level}."
|
||||||
|
)
|
||||||
|
parts.append("Remain attentive.")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
def get_escalation_flag(self) -> bool:
|
||||||
|
"""True if session shows active escalation pattern."""
|
||||||
|
return self._state.escalated
|
||||||
|
|
||||||
|
def get_effective_level(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the effective crisis level considering session state.
|
||||||
|
|
||||||
|
Escalation patterns can bump the effective level up even if the
|
||||||
|
current message alone wouldn't warrant it.
|
||||||
|
"""
|
||||||
|
s = self._state
|
||||||
|
|
||||||
|
if s.escalated and s.peak_level in ("HIGH", "CRITICAL"):
|
||||||
|
return s.peak_level
|
||||||
|
|
||||||
|
return s.current_level
|
||||||
|
|
||||||
|
def should_heighten_awareness(self) -> bool:
|
||||||
|
"""
|
||||||
|
True when the session trajectory warrants heightened awareness
|
||||||
|
beyond what a single-message detection would provide.
|
||||||
|
"""
|
||||||
|
return self._state.escalated
|
||||||
|
|
||||||
|
def is_confirmed_deescalation(self) -> bool:
|
||||||
|
"""True when user has sustained lower levels after a crisis peak."""
|
||||||
|
s = self._state
|
||||||
|
return s.deescalation_confirmed
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
s = self._state
|
||||||
|
return (
|
||||||
|
f"CrisisSessionTracker("
|
||||||
|
f"current={s.current_level}, peak={s.peak_level}, "
|
||||||
|
f"msgs={s.message_count}, escalated={s.escalated}, "
|
||||||
|
f"deescalating={s.deescalating})"
|
||||||
|
)
|
||||||
29
crisis/test_rescue.py
Normal file
29
crisis/test_rescue.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""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,6 +53,7 @@ class TestDetection(unittest.TestCase):
|
|||||||
|
|
||||||
def test_high_hopeless(self):
|
def test_high_hopeless(self):
|
||||||
r = detect_crisis("I feel completely hopeless with no way out")
|
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"))
|
self.assertIn(r.level, ("HIGH", "MEDIUM"))
|
||||||
|
|
||||||
def test_high_no_one_would_miss(self):
|
def test_high_no_one_would_miss(self):
|
||||||
@@ -112,7 +113,7 @@ class TestDetection(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
for text, expected_level in [
|
for text, expected_level in [
|
||||||
("I want to kill myself", "CRITICAL"),
|
("I want to kill myself", "CRITICAL"),
|
||||||
("I feel completely hopeless", "HIGH"),
|
("I feel completely hopeless with no way out", "HIGH"),
|
||||||
("I feel so alone in this, nobody understands", "MEDIUM"),
|
("I feel so alone in this, nobody understands", "MEDIUM"),
|
||||||
("Having a rough day", "LOW"),
|
("Having a rough day", "LOW"),
|
||||||
("Hello there", "NONE"),
|
("Hello there", "NONE"),
|
||||||
@@ -270,6 +271,65 @@ class TestHelpers(unittest.TestCase):
|
|||||||
self.assertIn("Indicators", formatted)
|
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):
|
class TestEdgeCases(unittest.TestCase):
|
||||||
"""Test edge cases and integration scenarios."""
|
"""Test edge cases and integration scenarios."""
|
||||||
|
|
||||||
|
|||||||
407
crisis/tests_session_tracker.py
Normal file
407
crisis/tests_session_tracker.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
"""
|
||||||
|
Tests for session-level crisis tracking (Issue #35).
|
||||||
|
|
||||||
|
Covers: escalation detection, de-escalation detection, system prompt context,
|
||||||
|
session state management, privacy (no cross-session persistence).
|
||||||
|
|
||||||
|
Run with: python -m pytest crisis/tests_session_tracker.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from crisis.session_tracker import CrisisSessionTracker, CrisisSessionState, LEVEL_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionState(unittest.TestCase):
|
||||||
|
"""Test basic session state management."""
|
||||||
|
|
||||||
|
def test_initial_state(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
s = tracker.state
|
||||||
|
self.assertEqual(s.current_level, "NONE")
|
||||||
|
self.assertEqual(s.peak_level, "NONE")
|
||||||
|
self.assertEqual(s.message_count, 0)
|
||||||
|
self.assertEqual(s.level_history, [])
|
||||||
|
self.assertFalse(s.escalated)
|
||||||
|
|
||||||
|
def test_record_increments_count(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("hello")
|
||||||
|
self.assertEqual(tracker.state.message_count, 1)
|
||||||
|
tracker.record("world")
|
||||||
|
self.assertEqual(tracker.state.message_count, 2)
|
||||||
|
|
||||||
|
def test_record_tracks_history(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I'm struggling") # LOW
|
||||||
|
tracker.record("I feel hopeless and nobody cares") # MEDIUM
|
||||||
|
history = tracker.state.level_history
|
||||||
|
self.assertEqual(len(history), 2)
|
||||||
|
self.assertEqual(history[0], (1, "LOW"))
|
||||||
|
self.assertIn(history[1][1], ("MEDIUM", "LOW", "HIGH"))
|
||||||
|
|
||||||
|
def test_reset_clears_state(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I want to die") # CRITICAL
|
||||||
|
self.assertEqual(tracker.state.peak_level, "CRITICAL")
|
||||||
|
tracker.reset()
|
||||||
|
s = tracker.state
|
||||||
|
self.assertEqual(s.current_level, "NONE")
|
||||||
|
self.assertEqual(s.peak_level, "NONE")
|
||||||
|
self.assertEqual(s.message_count, 0)
|
||||||
|
self.assertEqual(s.level_history, [])
|
||||||
|
|
||||||
|
def test_peak_tracking(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I'm feeling down") # LOW
|
||||||
|
tracker.record("I feel hopeless with no way out") # HIGH
|
||||||
|
tracker.record("Actually I'm a bit better") # back to LOW
|
||||||
|
self.assertEqual(tracker.state.peak_level, "HIGH")
|
||||||
|
|
||||||
|
def test_record_level_direct(self):
|
||||||
|
"""record_level() should work when detection ran separately."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record_level("LOW")
|
||||||
|
tracker.record_level("MEDIUM")
|
||||||
|
tracker.record_level("HIGH")
|
||||||
|
self.assertEqual(tracker.state.message_count, 3)
|
||||||
|
self.assertEqual(tracker.state.peak_level, "HIGH")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscalationDetection(unittest.TestCase):
|
||||||
|
"""Test rapid escalation detection: LOW → HIGH in N messages."""
|
||||||
|
|
||||||
|
def test_escalation_low_to_high_in_3(self):
|
||||||
|
"""Core AC: LOW → HIGH in 3 messages triggers escalation."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I'm having a tough time") # LOW
|
||||||
|
tracker.record("I feel really hopeless about everything") # HIGH
|
||||||
|
tracker.record("I can't take this anymore") # HIGH
|
||||||
|
self.assertTrue(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_escalation_low_to_critical_in_3(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I'm struggling") # LOW
|
||||||
|
tracker.record("Nobody would miss me") # HIGH
|
||||||
|
tracker.record("I want to end my life") # CRITICAL
|
||||||
|
self.assertTrue(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_no_escalation_stays_low(self):
|
||||||
|
"""User stays LOW — no escalation."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("rough day")
|
||||||
|
tracker.record("tough week")
|
||||||
|
tracker.record("feeling down")
|
||||||
|
self.assertFalse(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_no_escalation_immediate_critical(self):
|
||||||
|
"""First message is CRITICAL — not escalation, just immediate crisis."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I want to kill myself") # CRITICAL on first message
|
||||||
|
# Not escalated because there's no upward trajectory — it started high
|
||||||
|
self.assertFalse(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_escalation_medium_to_high(self):
|
||||||
|
"""MEDIUM → HIGH escalation pattern."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I feel alone and nobody cares") # MEDIUM
|
||||||
|
tracker.record("I feel completely hopeless") # HIGH
|
||||||
|
tracker.record("There's no way out") # HIGH
|
||||||
|
self.assertTrue(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_escalation_not_triggered_without_increase(self):
|
||||||
|
"""Same level messages don't constitute escalation."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I feel hopeless") # HIGH
|
||||||
|
tracker.record("I'm in so much pain") # HIGH
|
||||||
|
tracker.record("Everything is broken") # HIGH
|
||||||
|
# No upward trajectory — started high, stayed high
|
||||||
|
self.assertFalse(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_escalation_window_custom(self):
|
||||||
|
"""Custom window size should be respected."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 2
|
||||||
|
tracker.record("I'm unhappy") # LOW
|
||||||
|
tracker.record("I can't go on anymore") # HIGH
|
||||||
|
self.assertTrue(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeescalationDetection(unittest.TestCase):
|
||||||
|
"""Test de-escalation: CRITICAL → LOW for 5+ messages."""
|
||||||
|
|
||||||
|
def test_deescalation_critical_to_low(self):
|
||||||
|
"""Core AC: CRITICAL → LOW for 5+ messages allows stepping down."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.DEESCALATION_THRESHOLD = 5
|
||||||
|
|
||||||
|
# Escalate to CRITICAL
|
||||||
|
tracker.record("I'm struggling") # LOW
|
||||||
|
tracker.record("I can't go on") # HIGH
|
||||||
|
tracker.record("I want to die") # CRITICAL
|
||||||
|
self.assertTrue(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
# De-escalate over 5 messages
|
||||||
|
tracker.record("I called 988") # NONE/LOW
|
||||||
|
tracker.record("I'm calmer now") # NONE
|
||||||
|
tracker.record("Thank you for being here") # NONE
|
||||||
|
tracker.record("I'm going to be okay") # NONE
|
||||||
|
tracker.record("Taking it one moment at a time") # NONE
|
||||||
|
|
||||||
|
self.assertTrue(tracker.is_confirmed_deescalation())
|
||||||
|
self.assertFalse(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_deescalation_not_confirmed_too_soon(self):
|
||||||
|
"""De-escalation not confirmed before threshold met."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.DEESCALATION_THRESHOLD = 5
|
||||||
|
|
||||||
|
tracker.record("I'm struggling")
|
||||||
|
tracker.record("I can't go on")
|
||||||
|
tracker.record("I want to die")
|
||||||
|
self.assertTrue(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
# Only 3 messages of de-escalation
|
||||||
|
tracker.record("I called someone")
|
||||||
|
tracker.record("I'm calmer")
|
||||||
|
tracker.record("Feeling better")
|
||||||
|
|
||||||
|
self.assertFalse(tracker.is_confirmed_deescalation())
|
||||||
|
self.assertTrue(tracker.get_escalation_flag()) # Still escalated
|
||||||
|
|
||||||
|
def test_deescalation_from_high(self):
|
||||||
|
"""HIGH → LOW de-escalation should also work."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.DEESCALATION_THRESHOLD = 5
|
||||||
|
|
||||||
|
# Build up to HIGH
|
||||||
|
tracker.record("I'm down") # LOW
|
||||||
|
tracker.record("I feel hopeless") # HIGH
|
||||||
|
tracker.record("No way out") # HIGH
|
||||||
|
tracker.record("Everything is pointless") # HIGH
|
||||||
|
|
||||||
|
# De-escalate
|
||||||
|
for _ in range(5):
|
||||||
|
tracker.record("I'm doing a bit better") # LOW/NONE
|
||||||
|
|
||||||
|
self.assertTrue(tracker.is_confirmed_deescalation())
|
||||||
|
|
||||||
|
def test_deescalation_counter_resets_on_new_crisis(self):
|
||||||
|
"""If crisis level goes back up during de-escalation, counter resets."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.DEESCALATION_THRESHOLD = 5
|
||||||
|
|
||||||
|
# Escalate
|
||||||
|
tracker.record("I'm struggling") # LOW
|
||||||
|
tracker.record("I feel hopeless") # HIGH
|
||||||
|
tracker.record("I want to die") # CRITICAL
|
||||||
|
|
||||||
|
# Start de-escalating
|
||||||
|
tracker.record("I called someone") # LOW
|
||||||
|
tracker.record("I'm calmer") # NONE
|
||||||
|
|
||||||
|
# Go back up
|
||||||
|
tracker.record("Actually I can't do this") # HIGH
|
||||||
|
|
||||||
|
self.assertFalse(tracker.is_confirmed_deescalation())
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemPromptContext(unittest.TestCase):
|
||||||
|
"""Test system prompt context generation."""
|
||||||
|
|
||||||
|
def test_empty_session_no_context(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
self.assertEqual(tracker.get_prompt_context(), "")
|
||||||
|
|
||||||
|
def test_escalation_context(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I'm having a tough time")
|
||||||
|
tracker.record("Things are really hard")
|
||||||
|
tracker.record("I can't go on anymore")
|
||||||
|
|
||||||
|
ctx = tracker.get_prompt_context()
|
||||||
|
self.assertIn("escalated", ctx.lower())
|
||||||
|
self.assertIn("LOW", ctx)
|
||||||
|
self.assertIn("heightened", ctx.lower())
|
||||||
|
|
||||||
|
def test_deescalation_context(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.DEESCALATION_THRESHOLD = 5
|
||||||
|
|
||||||
|
# Escalate
|
||||||
|
tracker.record("I'm struggling")
|
||||||
|
tracker.record("I feel hopeless")
|
||||||
|
tracker.record("I want to die")
|
||||||
|
|
||||||
|
# De-escalate
|
||||||
|
for _ in range(5):
|
||||||
|
tracker.record("I'm okay now")
|
||||||
|
|
||||||
|
ctx = tracker.get_prompt_context()
|
||||||
|
self.assertIn("de-escalated", ctx.lower())
|
||||||
|
self.assertIn("confirmed", ctx.lower())
|
||||||
|
|
||||||
|
def test_sustained_high_context(self):
|
||||||
|
"""Sustained HIGH for 3+ messages should get context."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I'm in so much pain") # HIGH
|
||||||
|
tracker.record("Everything is pointless") # HIGH
|
||||||
|
tracker.record("I can't go on") # HIGH
|
||||||
|
|
||||||
|
ctx = tracker.get_prompt_context()
|
||||||
|
self.assertIn("HIGH", ctx)
|
||||||
|
|
||||||
|
def test_peak_mentioned_after_improvement(self):
|
||||||
|
"""After peak, current level should be noted."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I'm in so much pain") # HIGH
|
||||||
|
tracker.record("Everything is pointless") # HIGH
|
||||||
|
tracker.record("I feel a bit better") # back to LOW
|
||||||
|
|
||||||
|
ctx = tracker.get_prompt_context()
|
||||||
|
self.assertIn("peak", ctx.lower())
|
||||||
|
self.assertIn("HIGH", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveLevel(unittest.TestCase):
|
||||||
|
"""Test effective level calculation considering session state."""
|
||||||
|
|
||||||
|
def test_effective_level_normal(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("I'm struggling") # LOW
|
||||||
|
self.assertEqual(tracker.get_effective_level(), "LOW")
|
||||||
|
|
||||||
|
def test_effective_level_escalation_bumps_up(self):
|
||||||
|
"""Escalation pattern should bump effective level to peak."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I'm having a tough time") # LOW
|
||||||
|
tracker.record("I feel completely hopeless") # HIGH
|
||||||
|
tracker.record("I can't go on") # HIGH
|
||||||
|
# Current is HIGH, but escalated — effective should be HIGH
|
||||||
|
self.assertEqual(tracker.get_effective_level(), "HIGH")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrivacy(unittest.TestCase):
|
||||||
|
"""Test privacy requirements — no cross-session persistence."""
|
||||||
|
|
||||||
|
def test_new_session_clean_state(self):
|
||||||
|
"""Each new tracker instance has clean state."""
|
||||||
|
t1 = CrisisSessionTracker()
|
||||||
|
t1.record("I want to die")
|
||||||
|
self.assertEqual(t1.state.peak_level, "CRITICAL")
|
||||||
|
|
||||||
|
t2 = CrisisSessionTracker()
|
||||||
|
self.assertEqual(t2.state.peak_level, "NONE")
|
||||||
|
self.assertEqual(t2.state.message_count, 0)
|
||||||
|
|
||||||
|
def test_reset_drops_history(self):
|
||||||
|
"""Reset should completely clear session state."""
|
||||||
|
t = CrisisSessionTracker()
|
||||||
|
t.record("I'm struggling")
|
||||||
|
t.record("I can't go on")
|
||||||
|
t.reset()
|
||||||
|
self.assertEqual(t.state.level_history, [])
|
||||||
|
self.assertEqual(t.state.message_count, 0)
|
||||||
|
self.assertFalse(t.state.escalated)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases(unittest.TestCase):
|
||||||
|
"""Edge cases and boundary conditions."""
|
||||||
|
|
||||||
|
def test_single_message_none(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("Hello Timmy")
|
||||||
|
self.assertFalse(tracker.get_escalation_flag())
|
||||||
|
self.assertEqual(tracker.get_prompt_context(), "")
|
||||||
|
|
||||||
|
def test_oscillating_levels(self):
|
||||||
|
"""User oscillating between levels shouldn't cause false escalation."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I'm fine")
|
||||||
|
tracker.record("I'm struggling")
|
||||||
|
tracker.record("I'm fine")
|
||||||
|
tracker.record("I'm struggling")
|
||||||
|
# Oscillation without sustained escalation
|
||||||
|
self.assertFalse(tracker.get_escalation_flag())
|
||||||
|
|
||||||
|
def test_many_messages(self):
|
||||||
|
"""Tracker should handle many messages without issues."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
for i in range(100):
|
||||||
|
tracker.record("Hello there")
|
||||||
|
self.assertEqual(tracker.state.message_count, 100)
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("")
|
||||||
|
self.assertEqual(tracker.state.message_count, 1)
|
||||||
|
self.assertEqual(tracker.state.current_level, "NONE")
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
r = repr(tracker)
|
||||||
|
self.assertIn("CrisisSessionTracker", r)
|
||||||
|
self.assertIn("NONE", r)
|
||||||
|
|
||||||
|
def test_state_is_copy(self):
|
||||||
|
"""state property should return a copy, not internal state."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
s1 = tracker.state
|
||||||
|
tracker.record("I'm struggling")
|
||||||
|
s2 = tracker.state
|
||||||
|
self.assertEqual(s1.message_count, 0)
|
||||||
|
self.assertEqual(s2.message_count, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLevelOrder(unittest.TestCase):
|
||||||
|
"""Test level ordering is correct."""
|
||||||
|
|
||||||
|
def test_level_ordering(self):
|
||||||
|
self.assertLess(LEVEL_ORDER["NONE"], LEVEL_ORDER["LOW"])
|
||||||
|
self.assertLess(LEVEL_ORDER["LOW"], LEVEL_ORDER["MEDIUM"])
|
||||||
|
self.assertLess(LEVEL_ORDER["MEDIUM"], LEVEL_ORDER["HIGH"])
|
||||||
|
self.assertLess(LEVEL_ORDER["HIGH"], LEVEL_ORDER["CRITICAL"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeightenedAwareness(unittest.TestCase):
|
||||||
|
"""Test heightened awareness flag."""
|
||||||
|
|
||||||
|
def test_heightened_on_escalation(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I'm unhappy")
|
||||||
|
tracker.record("I feel hopeless")
|
||||||
|
tracker.record("I can't go on")
|
||||||
|
self.assertTrue(tracker.should_heighten_awareness())
|
||||||
|
|
||||||
|
def test_not_heightened_normal(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record("Hello")
|
||||||
|
self.assertFalse(tracker.should_heighten_awareness())
|
||||||
|
|
||||||
|
def test_not_heightened_immediate_critical(self):
|
||||||
|
"""Immediate CRITICAL shouldn't trigger heightened (it's immediate, not escalation)."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.ESCALATION_WINDOW = 3
|
||||||
|
tracker.record("I want to kill myself")
|
||||||
|
self.assertFalse(tracker.should_heighten_awareness())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,260 +1,34 @@
|
|||||||
"""
|
"""
|
||||||
Crisis Detection System for the-door.
|
Crisis Detection System for the-door (LEGACY SHIM).
|
||||||
|
|
||||||
Scans text for suicide, self-harm, and hopelessness signals.
|
This module is a backward-compatible re-export layer.
|
||||||
Returns risk level, matched keywords, and surrounding context.
|
The canonical detection logic lives in crisis/detect.py.
|
||||||
|
|
||||||
This is the most important code in the foundation.
|
Both crisis_responder.py and the legacy test suite import from here.
|
||||||
Every line here exists because someone's life depends on it.
|
Do NOT add detection logic to this file — it all comes from crisis.detect.
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
from crisis.detect import (
|
||||||
from dataclasses import dataclass, field
|
CrisisDetector,
|
||||||
from typing import List, Optional, Tuple
|
CrisisResult,
|
||||||
|
CrisisDetectionResult,
|
||||||
|
detect_crisis_legacy as detect_crisis,
|
||||||
@dataclass
|
CRITICAL_INDICATORS,
|
||||||
class CrisisResult:
|
HIGH_INDICATORS,
|
||||||
"""Result of a crisis text scan."""
|
MEDIUM_INDICATORS,
|
||||||
risk_level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
|
LOW_INDICATORS,
|
||||||
matched_keywords: List[str] = field(default_factory=list)
|
SCORES as RISK_SCORES,
|
||||||
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)
|
# Re-export everything the legacy API exposed
|
||||||
|
__all__ = [
|
||||||
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
"CrisisDetector",
|
||||||
"""Scan multiple texts, returning the highest-risk result per text."""
|
"CrisisResult",
|
||||||
return [self.scan(t) for t in texts]
|
"CrisisDetectionResult",
|
||||||
|
"detect_crisis",
|
||||||
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
|
"CRITICAL_INDICATORS",
|
||||||
"""Scan multiple texts and return only the highest-risk result."""
|
"HIGH_INDICATORS",
|
||||||
results = self.scan_multiple(texts)
|
"MEDIUM_INDICATORS",
|
||||||
if not results:
|
"LOW_INDICATORS",
|
||||||
return CrisisResult(risk_level="NONE", score=0.0)
|
"RISK_SCORES",
|
||||||
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,9 +5,10 @@
|
|||||||
# The crisis front door. Deploy to VPS.
|
# The crisis front door. Deploy to VPS.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash deploy/deploy.sh # Full deploy (swap + nginx + site + firewall)
|
# bash deploy/deploy.sh # Full deploy (swap + nginx + site + firewall + hermes service)
|
||||||
# bash deploy/deploy.sh --site # Site files only (fast update)
|
# bash deploy/deploy.sh --site # Site files only (fast update)
|
||||||
# bash deploy/deploy.sh --ssl # SSL setup only
|
# 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
|
# bash deploy/deploy.sh --check # Verify deployment health
|
||||||
#
|
#
|
||||||
# This script is IDEMPOTENT — safe to run repeatedly.
|
# This script is IDEMPOTENT — safe to run repeatedly.
|
||||||
@@ -150,6 +151,42 @@ setup_ssl() {
|
|||||||
fi
|
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() {
|
check_deployment() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "================================"
|
echo "================================"
|
||||||
@@ -223,6 +260,16 @@ check_deployment() {
|
|||||||
echo -e "${YELLOW}NOT POINTED${NC} (resolved: ${RESOLVED_IP:-nothing}, expected: ${VPS_IP})"
|
echo -e "${YELLOW}NOT POINTED${NC} (resolved: ${RESOLVED_IP:-nothing}, expected: ${VPS_IP})"
|
||||||
fi
|
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 ""
|
||||||
echo "IP: ${VPS_IP}"
|
echo "IP: ${VPS_IP}"
|
||||||
echo "Domain: ${DOMAIN}"
|
echo "Domain: ${DOMAIN}"
|
||||||
@@ -247,6 +294,9 @@ case "${1:-full}" in
|
|||||||
--ssl)
|
--ssl)
|
||||||
setup_ssl
|
setup_ssl
|
||||||
;;
|
;;
|
||||||
|
--service)
|
||||||
|
setup_hermes_service
|
||||||
|
;;
|
||||||
--check)
|
--check)
|
||||||
check_deployment
|
check_deployment
|
||||||
;;
|
;;
|
||||||
@@ -257,10 +307,11 @@ case "${1:-full}" in
|
|||||||
configure_nginx
|
configure_nginx
|
||||||
setup_firewall
|
setup_firewall
|
||||||
setup_ssl
|
setup_ssl
|
||||||
|
setup_hermes_service
|
||||||
check_deployment
|
check_deployment
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: $0 [--site|--ssl|--check|--full]"
|
echo "Usage: $0 [--site|--ssl|--service|--check|--full]"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
40
deploy/hermes-gateway.service
Normal file
40
deploy/hermes-gateway.service
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[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,31 +1,34 @@
|
|||||||
"""
|
"""
|
||||||
When a Man Is Dying — Despair/Suicide Detection System
|
DEPRECATED — Use crisis.detect instead.
|
||||||
|
|
||||||
Standalone detection module that parses incoming text for
|
This module is a thin wrapper around crisis.detect for backward compatibility.
|
||||||
despair and suicide indicators, classifies into tiers,
|
All unique patterns have been merged into crisis/detect.py (see issue #40).
|
||||||
and returns structured response with recommended actions.
|
|
||||||
|
|
||||||
Tiers:
|
This module will be removed in a future release.
|
||||||
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 re
|
import warnings
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DetectionResult:
|
class DetectionResult:
|
||||||
|
"""Backward-compatible result type matching the old dying_detection API."""
|
||||||
level: str
|
level: str
|
||||||
indicators: List[str] = field(default_factory=list)
|
indicators: List[str] = field(default_factory=list)
|
||||||
recommended_action: str = ""
|
recommended_action: str = ""
|
||||||
@@ -34,110 +37,9 @@ class DetectionResult:
|
|||||||
session_hash: str = ""
|
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:
|
def detect(text: str) -> DetectionResult:
|
||||||
"""
|
"""
|
||||||
Primary detection function.
|
Primary detection function — delegates to crisis.detect.
|
||||||
|
|
||||||
If the crisis/ module is available, delegate to it.
|
|
||||||
Otherwise, use the internal pattern engine.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: User message to analyze
|
text: User message to analyze
|
||||||
@@ -145,150 +47,25 @@ def detect(text: str) -> DetectionResult:
|
|||||||
Returns:
|
Returns:
|
||||||
DetectionResult with level, indicators, recommended_action, confidence
|
DetectionResult with level, indicators, recommended_action, confidence
|
||||||
"""
|
"""
|
||||||
# Try to use the crisis module if available
|
result = detect_crisis(text)
|
||||||
try:
|
|
||||||
from crisis.detect import detect_crisis as _crisis_detect
|
# Extract raw patterns from matches
|
||||||
result = _crisis_detect(text)
|
raw_patterns = [m["pattern"] for m in result.matches] if result.matches else []
|
||||||
# Map to DetectionResult
|
|
||||||
return DetectionResult(
|
return DetectionResult(
|
||||||
level=result.level,
|
level=result.level,
|
||||||
indicators=_describe_indicators(result.level, result.indicators),
|
indicators=result.indicators,
|
||||||
recommended_action=result.recommended_action,
|
recommended_action=result.recommended_action,
|
||||||
|
raw_matched_patterns=raw_patterns,
|
||||||
confidence=result.score,
|
confidence=result.score,
|
||||||
session_hash=_hash_session(text),
|
session_hash=hashlib.sha256(text.encode()).hexdigest()[:12],
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return _detect_internal(text)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
def get_action_for_level(level: str) -> str:
|
||||||
"""Get the recommended action string for a given level."""
|
"""Get the recommended action string for a given level."""
|
||||||
actions = {
|
from crisis.detect import ACTIONS
|
||||||
"CRITICAL": (
|
return ACTIONS.get(level, "Unknown level.")
|
||||||
"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:
|
def as_json(result: DetectionResult, indent: int = 2) -> str:
|
||||||
|
|||||||
373
index.html
373
index.html
@@ -613,6 +613,21 @@ html, body {
|
|||||||
top: 8px;
|
top: 8px;
|
||||||
outline: 2px solid #58a6ff;
|
outline: 2px solid #58a6ff;
|
||||||
}
|
}
|
||||||
|
/* Safety plan inline status feedback */
|
||||||
|
#sp-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
#sp-status.success {
|
||||||
|
color: #3fb950;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#sp-status.error {
|
||||||
|
color: #f85149;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -738,6 +753,7 @@ html, body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<span id="sp-status" role="status" aria-live="polite"></span>
|
||||||
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
|
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
|
||||||
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
|
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,10 +881,10 @@ Sovereignty and service always.`;
|
|||||||
// Passive suicidal ideation (NEW)
|
// Passive suicidal ideation (NEW)
|
||||||
"don't want to exist", 'not exist anymore', 'disappear forever',
|
"don't want to exist", 'not exist anymore', 'disappear forever',
|
||||||
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
|
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
|
||||||
// Hopelessness (NEW)
|
// Hopelessness (NEW) - context-aware phrases to reduce false positives
|
||||||
'no point', 'no purpose', 'nothing matters', 'giving up', 'give up',
|
'no purpose', 'nothing matters', 'giving up on life',
|
||||||
'cant go on', 'cannot go on', "can't take it", 'too much pain',
|
'cant go on', 'cannot go on', "can't take it", 'too much pain',
|
||||||
'no hope', 'hopeless', 'worthless', 'burden', 'waste of space'
|
'no hope left', 'burden', 'waste of space'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tier 2: Explicit intent - triggers full-screen overlay
|
// Tier 2: Explicit intent - triggers full-screen overlay
|
||||||
@@ -883,9 +899,9 @@ Sovereignty and service always.`;
|
|||||||
// Imminent action (NEW)
|
// Imminent action (NEW)
|
||||||
'going to do it now', 'doing it tonight', 'doing it today',
|
'going to do it now', 'doing it tonight', 'doing it today',
|
||||||
"can't wait anymore", 'ready to end it', 'time to go',
|
"can't wait anymore", 'ready to end it', 'time to go',
|
||||||
'say goodbye', 'saying goodbye', 'wrote a note', 'my note',
|
'wrote a suicide note', 'my suicide note',
|
||||||
// Specific plans (NEW)
|
// Specific plans (NEW)
|
||||||
'bought a gun', 'got pills', 'rope ready', 'bridge nearby',
|
'bought a gun', 'got pills', 'rope ready',
|
||||||
'tall building', 'going to overdose', 'going to hang',
|
'tall building', 'going to overdose', 'going to hang',
|
||||||
'gave away my stuff', 'giving away', 'said my goodbyes',
|
'gave away my stuff', 'giving away', 'said my goodbyes',
|
||||||
// Active self-harm (NEW)
|
// Active self-harm (NEW)
|
||||||
@@ -923,13 +939,186 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SESSION CRISIS TRACKING (#35) =====
|
||||||
|
var sessionCrisis = {
|
||||||
|
currentLevel: 0, // 0=NONE, 1=LOW, 2=MEDIUM, 3=HIGH, 4=CRITICAL
|
||||||
|
peakLevel: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
history: [], // [{level, timestamp}]
|
||||||
|
escalationRate: 0, // levels per message
|
||||||
|
lastEscalation: null // timestamp of last escalation
|
||||||
|
};
|
||||||
|
|
||||||
|
function trackCrisis(text) {
|
||||||
|
var level = getCrisisLevel(text);
|
||||||
|
sessionCrisis.messageCount++;
|
||||||
|
sessionCrisis.history.push({ level: level, time: Date.now() });
|
||||||
|
|
||||||
|
if (level > sessionCrisis.currentLevel) {
|
||||||
|
sessionCrisis.lastEscalation = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCrisis.currentLevel = level;
|
||||||
|
sessionCrisis.peakLevel = Math.max(sessionCrisis.peakLevel, level);
|
||||||
|
|
||||||
|
var recent = sessionCrisis.history.slice(-5);
|
||||||
|
if (recent.length >= 2) {
|
||||||
|
var first = recent[0].level;
|
||||||
|
var last = recent[recent.length - 1].level;
|
||||||
|
sessionCrisis.escalationRate = (last - first) / recent.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSessionContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionContext() {
|
||||||
|
var ctx = '';
|
||||||
|
|
||||||
|
if (sessionCrisis.history.length < 2) return ctx;
|
||||||
|
|
||||||
|
if (sessionCrisis.escalationRate > 0.5 && sessionCrisis.history.length <= 3) {
|
||||||
|
ctx += 'ESCALATION ALERT: User crisis level is rising rapidly. ';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionCrisis.peakLevel >= 3 && sessionCrisis.currentLevel <= 1 && sessionCrisis.messageCount >= 5) {
|
||||||
|
ctx += 'DE-ESCALATION: User appears to be calming. Maintain presence but reduce urgency. ';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionCrisis.currentLevel >= 2 && sessionCrisis.messageCount >= 3) {
|
||||||
|
ctx += 'User has been in crisis for ' + sessionCrisis.messageCount + ' messages. ';
|
||||||
|
}
|
||||||
|
|
||||||
|
var levels = sessionCrisis.history.map(function(h) { return h.level; });
|
||||||
|
if (levels.length >= 2) {
|
||||||
|
ctx += 'Crisis trajectory: ' + levels.join(' → ') + '. ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSessionCrisis() {
|
||||||
|
sessionCrisis = {
|
||||||
|
currentLevel: 0, peakLevel: 0, messageCount: 0,
|
||||||
|
history: [], escalationRate: 0, lastEscalation: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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';
|
||||||
|
|
||||||
|
var sessionCtx = getSessionContext();
|
||||||
|
var sessionBlock = sessionCtx ? '\nSESSION CONTEXT: ' + sessionCtx : '';
|
||||||
|
return SYSTEM_PROMPT + divider + header + directive + tone + sessionBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== OVERLAY =====
|
// ===== OVERLAY =====
|
||||||
|
|
||||||
|
// Focus trap: cycle through focusable elements within the crisis overlay
|
||||||
|
function getOverlayFocusableElements() {
|
||||||
|
return crisisOverlay.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trapFocusInOverlay(e) {
|
||||||
|
if (!crisisOverlay.classList.contains('active')) return;
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
|
||||||
|
var focusable = getOverlayFocusableElements();
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
|
||||||
|
var first = focusable[0];
|
||||||
|
var last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab: if on first, wrap to last
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tab: if on last, wrap to first
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the element that had focus before the overlay opened
|
||||||
|
var _preOverlayFocusElement = null;
|
||||||
|
|
||||||
function showOverlay() {
|
function showOverlay() {
|
||||||
|
// Save current focus for restoration on dismiss
|
||||||
|
_preOverlayFocusElement = document.activeElement;
|
||||||
|
|
||||||
crisisOverlay.classList.add('active');
|
crisisOverlay.classList.add('active');
|
||||||
overlayDismissBtn.disabled = true;
|
overlayDismissBtn.disabled = true;
|
||||||
var countdown = 10;
|
var countdown = 10;
|
||||||
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
|
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
|
||||||
|
|
||||||
|
// Disable background interaction via inert attribute
|
||||||
|
var mainApp = document.querySelector('.app');
|
||||||
|
if (mainApp) mainApp.setAttribute('inert', '');
|
||||||
|
// Also hide from assistive tech
|
||||||
|
var chatSection = document.getElementById('chat');
|
||||||
|
if (chatSection) chatSection.setAttribute('aria-hidden', 'true');
|
||||||
|
var footerEl = document.querySelector('footer');
|
||||||
|
if (footerEl) footerEl.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
if (overlayTimer) clearInterval(overlayTimer);
|
if (overlayTimer) clearInterval(overlayTimer);
|
||||||
overlayTimer = setInterval(function() {
|
overlayTimer = setInterval(function() {
|
||||||
countdown--;
|
countdown--;
|
||||||
@@ -946,6 +1135,37 @@ Sovereignty and service always.`;
|
|||||||
overlayDismissBtn.focus();
|
overlayDismissBtn.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crisis overlay Escape key handler
|
||||||
|
function trapCrisisOverlayEscape(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (!crisisOverlay.classList.contains('active')) return;
|
||||||
|
if (overlayDismissBtn.disabled) return; // Don't escape during countdown
|
||||||
|
// Dismiss the overlay
|
||||||
|
crisisOverlay.classList.remove('active');
|
||||||
|
if (overlayTimer) {
|
||||||
|
clearInterval(overlayTimer);
|
||||||
|
overlayTimer = null;
|
||||||
|
}
|
||||||
|
// Re-enable background interaction
|
||||||
|
var mainApp = document.querySelector('.app');
|
||||||
|
if (mainApp) mainApp.removeAttribute('inert');
|
||||||
|
var chatSection = document.getElementById('chat');
|
||||||
|
if (chatSection) chatSection.removeAttribute('aria-hidden');
|
||||||
|
var footerEl = document.querySelector('footer');
|
||||||
|
if (footerEl) footerEl.removeAttribute('aria-hidden');
|
||||||
|
// Restore focus to chat input
|
||||||
|
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
|
||||||
|
_preOverlayFocusElement.focus();
|
||||||
|
} else {
|
||||||
|
msgInput.focus();
|
||||||
|
}
|
||||||
|
_preOverlayFocusElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register focus trap and Escape handler on document (always listening, gated by class check)
|
||||||
|
document.addEventListener('keydown', trapFocusInOverlay);
|
||||||
|
document.addEventListener('keydown', trapCrisisOverlayEscape);
|
||||||
|
|
||||||
overlayDismissBtn.addEventListener('click', function() {
|
overlayDismissBtn.addEventListener('click', function() {
|
||||||
if (!overlayDismissBtn.disabled) {
|
if (!overlayDismissBtn.disabled) {
|
||||||
crisisOverlay.classList.remove('active');
|
crisisOverlay.classList.remove('active');
|
||||||
@@ -953,8 +1173,23 @@ Sovereignty and service always.`;
|
|||||||
clearInterval(overlayTimer);
|
clearInterval(overlayTimer);
|
||||||
overlayTimer = null;
|
overlayTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-enable background interaction
|
||||||
|
var mainApp = document.querySelector('.app');
|
||||||
|
if (mainApp) mainApp.removeAttribute('inert');
|
||||||
|
var chatSection = document.getElementById('chat');
|
||||||
|
if (chatSection) chatSection.removeAttribute('aria-hidden');
|
||||||
|
var footerEl = document.querySelector('footer');
|
||||||
|
if (footerEl) footerEl.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
|
// Restore focus to the element that had it before the overlay opened
|
||||||
|
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
|
||||||
|
_preOverlayFocusElement.focus();
|
||||||
|
} else {
|
||||||
msgInput.focus();
|
msgInput.focus();
|
||||||
}
|
}
|
||||||
|
_preOverlayFocusElement = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== MESSAGE RENDERING =====
|
// ===== MESSAGE RENDERING =====
|
||||||
@@ -1039,6 +1274,7 @@ Sovereignty and service always.`;
|
|||||||
clearChatBtn.addEventListener('click', function() {
|
clearChatBtn.addEventListener('click', function() {
|
||||||
if (confirm('Clear all chat history?')) {
|
if (confirm('Clear all chat history?')) {
|
||||||
localStorage.removeItem('timmy_chat_history');
|
localStorage.removeItem('timmy_chat_history');
|
||||||
|
resetSessionCrisis();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1058,25 +1294,14 @@ Sovereignty and service always.`;
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
safetyPlanBtn.addEventListener('click', function() {
|
|
||||||
loadSafetyPlan();
|
|
||||||
safetyPlanModal.classList.add('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Crisis panel safety plan button (if crisis panel is visible)
|
|
||||||
if (crisisSafetyPlanBtn) {
|
|
||||||
crisisSafetyPlanBtn.addEventListener('click', function() {
|
|
||||||
loadSafetyPlan();
|
|
||||||
safetyPlanModal.classList.add('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSafetyPlan.addEventListener('click', function() {
|
closeSafetyPlan.addEventListener('click', function() {
|
||||||
safetyPlanModal.classList.remove('active');
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelSafetyPlan.addEventListener('click', function() {
|
cancelSafetyPlan.addEventListener('click', function() {
|
||||||
safetyPlanModal.classList.remove('active');
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
saveSafetyPlan.addEventListener('click', function() {
|
saveSafetyPlan.addEventListener('click', function() {
|
||||||
@@ -1089,13 +1314,114 @@ Sovereignty and service always.`;
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
||||||
|
var spStatus = document.getElementById('sp-status');
|
||||||
|
spStatus.textContent = '\u2713 Safety plan saved locally.';
|
||||||
|
spStatus.className = 'success';
|
||||||
|
setTimeout(function() {
|
||||||
|
spStatus.className = '';
|
||||||
|
spStatus.textContent = '';
|
||||||
safetyPlanModal.classList.remove('active');
|
safetyPlanModal.classList.remove('active');
|
||||||
alert('Safety plan saved locally.');
|
_restoreSafetyPlanFocus();
|
||||||
|
}, 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error saving plan.');
|
var spStatusErr = document.getElementById('sp-status');
|
||||||
|
spStatusErr.textContent = '\u2717 Error saving plan.';
|
||||||
|
spStatusErr.className = 'error';
|
||||||
|
setTimeout(function() {
|
||||||
|
spStatusErr.className = '';
|
||||||
|
spStatusErr.textContent = '';
|
||||||
|
}, 4000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== SAFETY PLAN FOCUS TRAP (fix #65) =====
|
||||||
|
// Focusable elements inside the modal, in tab order
|
||||||
|
var _spFocusableIds = [
|
||||||
|
'close-safety-plan',
|
||||||
|
'sp-warning-signs',
|
||||||
|
'sp-coping',
|
||||||
|
'sp-distraction',
|
||||||
|
'sp-help',
|
||||||
|
'sp-environment',
|
||||||
|
'cancel-safety-plan',
|
||||||
|
'save-safety-plan'
|
||||||
|
];
|
||||||
|
var _spTriggerEl = null; // element that opened the modal
|
||||||
|
|
||||||
|
function _getSpFocusableEls() {
|
||||||
|
return _spFocusableIds
|
||||||
|
.map(function(id) { return document.getElementById(id); })
|
||||||
|
.filter(function(el) { return el && !el.disabled; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _trapSafetyPlanFocus(e) {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
var els = _getSpFocusableEls();
|
||||||
|
if (!els.length) return;
|
||||||
|
var first = els[0];
|
||||||
|
var last = els[els.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab on first → wrap to last
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tab on last → wrap to first
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _trapSafetyPlanEscape(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _activateSafetyPlanFocusTrap(triggerEl) {
|
||||||
|
_spTriggerEl = triggerEl || document.activeElement;
|
||||||
|
// Focus first textarea
|
||||||
|
var firstInput = document.getElementById('sp-warning-signs');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
// Add listeners
|
||||||
|
document.addEventListener('keydown', _trapSafetyPlanFocus);
|
||||||
|
document.addEventListener('keydown', _trapSafetyPlanEscape);
|
||||||
|
// Mark background inert (prevent click-through)
|
||||||
|
document.body.setAttribute('aria-hidden', 'true');
|
||||||
|
safetyPlanModal.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restoreSafetyPlanFocus() {
|
||||||
|
document.removeEventListener('keydown', _trapSafetyPlanFocus);
|
||||||
|
document.removeEventListener('keydown', _trapSafetyPlanEscape);
|
||||||
|
document.body.removeAttribute('aria-hidden');
|
||||||
|
if (_spTriggerEl && typeof _spTriggerEl.focus === 'function') {
|
||||||
|
_spTriggerEl.focus();
|
||||||
|
}
|
||||||
|
_spTriggerEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire open buttons to activate focus trap
|
||||||
|
safetyPlanBtn.addEventListener('click', function() {
|
||||||
|
loadSafetyPlan();
|
||||||
|
safetyPlanModal.classList.add('active');
|
||||||
|
_activateSafetyPlanFocusTrap(safetyPlanBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crisis panel safety plan button (if crisis panel is visible)
|
||||||
|
if (crisisSafetyPlanBtn) {
|
||||||
|
crisisSafetyPlanBtn.addEventListener('click', function() {
|
||||||
|
loadSafetyPlan();
|
||||||
|
safetyPlanModal.classList.add('active');
|
||||||
|
_activateSafetyPlanFocusTrap(crisisSafetyPlanBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== TEXTAREA AUTO-RESIZE =====
|
// ===== TEXTAREA AUTO-RESIZE =====
|
||||||
msgInput.addEventListener('input', function() {
|
msgInput.addEventListener('input', function() {
|
||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
@@ -1110,8 +1436,10 @@ Sovereignty and service always.`;
|
|||||||
|
|
||||||
addMessage('user', text);
|
addMessage('user', text);
|
||||||
messages.push({ role: 'user', content: text });
|
messages.push({ role: 'user', content: text });
|
||||||
|
var lastUserMessage = text;
|
||||||
|
|
||||||
checkCrisis(text);
|
checkCrisis(text);
|
||||||
|
trackCrisis(text);
|
||||||
|
|
||||||
msgInput.value = '';
|
msgInput.value = '';
|
||||||
msgInput.style.height = 'auto';
|
msgInput.style.height = 'auto';
|
||||||
@@ -1126,7 +1454,7 @@ Sovereignty and service always.`;
|
|||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
showTyping();
|
showTyping();
|
||||||
|
|
||||||
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages);
|
var allMessages = [{ role: 'system', content: getSystemPrompt(lastUserMessage || '') }].concat(messages);
|
||||||
|
|
||||||
var controller = new AbortController();
|
var controller = new AbortController();
|
||||||
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
||||||
@@ -1200,6 +1528,7 @@ Sovereignty and service always.`;
|
|||||||
messages.push({ role: 'assistant', content: fullText });
|
messages.push({ role: 'assistant', content: fullText });
|
||||||
saveMessages();
|
saveMessages();
|
||||||
checkCrisis(fullText);
|
checkCrisis(fullText);
|
||||||
|
trackCrisis(fullText);
|
||||||
}
|
}
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
sendBtn.disabled = msgInput.value.trim().length === 0;
|
sendBtn.disabled = msgInput.value.trim().length === 0;
|
||||||
@@ -1229,6 +1558,7 @@ Sovereignty and service always.`;
|
|||||||
|
|
||||||
// ===== WELCOME MESSAGE =====
|
// ===== WELCOME MESSAGE =====
|
||||||
function init() {
|
function init() {
|
||||||
|
resetSessionCrisis();
|
||||||
if (!loadMessages()) {
|
if (!loadMessages()) {
|
||||||
var welcomeText = "Hey. I'm Timmy. I'm here if you want to talk. No judgment, no login, no tracking. Just us.";
|
var welcomeText = "Hey. I'm Timmy. I'm here if you want to talk. No judgment, no login, no tracking. Just us.";
|
||||||
addMessage('assistant', welcomeText);
|
addMessage('assistant', welcomeText);
|
||||||
@@ -1240,6 +1570,7 @@ Sovereignty and service always.`;
|
|||||||
if (urlParams.get('safetyplan') === 'true') {
|
if (urlParams.get('safetyplan') === 'true') {
|
||||||
loadSafetyPlan();
|
loadSafetyPlan();
|
||||||
safetyPlanModal.classList.add('active');
|
safetyPlanModal.classList.add('active');
|
||||||
|
_activateSafetyPlanFocusTrap(safetyPlanBtn);
|
||||||
// Clean up URL
|
// Clean up URL
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
testpaths = crisis
|
testpaths = crisis tests
|
||||||
python_files = tests.py
|
python_files = tests.py test_*.py
|
||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
|
|||||||
237
sw.js
237
sw.js
@@ -1,118 +1,153 @@
|
|||||||
const CACHE_NAME = 'the-door-v2';
|
const CACHE_NAME = 'the-door-v3';
|
||||||
const ASSETS = [
|
const NAVIGATION_TIMEOUT_MS = 2500;
|
||||||
|
const OFFLINE_FALLBACK_PATH = '/crisis-offline.html';
|
||||||
|
const PRECACHE_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/about',
|
'/about.html',
|
||||||
'/manifest.json'
|
'/manifest.json',
|
||||||
|
'/crisis-offline.html',
|
||||||
|
'/testimony.html'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Crisis resources to show when everything fails
|
function isSameOrigin(request) {
|
||||||
const CRISIS_OFFLINE_RESPONSE = `<!DOCTYPE html>
|
return new URL(request.url).origin === self.location.origin;
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>You're Not Alone | The Door</title>
|
|
||||||
<style>
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;max-width:600px;margin:0 auto;padding:20px;line-height:1.6}
|
|
||||||
h1{color:#ff6b6b;font-size:1.5rem;margin-bottom:1rem}
|
|
||||||
.crisis-box{background:#1c1210;border:2px solid #c9362c;border-radius:12px;padding:20px;margin:20px 0;text-align:center}
|
|
||||||
.crisis-box a{display:inline-block;background:#c9362c;color:#fff;text-decoration:none;padding:16px 32px;border-radius:8px;font-weight:700;font-size:1.2rem;margin:10px 0}
|
|
||||||
.hope{color:#8b949e;font-style:italic;margin-top:30px;padding-top:20px;border-top:1px solid #30363d}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>You are not alone.</h1>
|
|
||||||
<p>Your connection is down, but help is still available.</p>
|
|
||||||
<div class="crisis-box">
|
|
||||||
<p><strong>Call or text 988</strong><br>Suicide & Crisis Lifeline<br>Free, 24/7, Confidential</p>
|
|
||||||
<a href="tel:988">Call 988 Now</a>
|
|
||||||
<p style="margin-top:15px"><strong>Or text HOME to 741741</strong><br>Crisis Text Line</p>
|
|
||||||
</div>
|
|
||||||
<p><strong>When you're ready:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li>Take five deep breaths</li>
|
|
||||||
<li>Drink some water</li>
|
|
||||||
<li>Step outside if you can</li>
|
|
||||||
<li>Text or call someone you trust</li>
|
|
||||||
</ul>
|
|
||||||
<p class="hope">
|
|
||||||
"The Lord is close to the brokenhearted and saves those who are crushed in spirit." — Psalm 34:18
|
|
||||||
</p>
|
|
||||||
<p style="font-size:0.85rem;color:#6e7681;margin-top:30px">
|
|
||||||
This page was created by The Door — a crisis intervention project.<br>
|
|
||||||
Connection will restore automatically. You don't have to go through this alone.
|
|
||||||
</p>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
// Install event - cache core assets
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
return cache.addAll(ASSETS);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate event - cleanup old caches
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) => {
|
|
||||||
return Promise.all(
|
|
||||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch event - network first, fallback to cache for static,
|
|
||||||
// but for the crisis front door, we want to ensure the shell is ALWAYS available.
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const url = new URL(event.request.url);
|
|
||||||
|
|
||||||
// Skip API calls - they should always go to network
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip non-GET requests
|
function canCache(response) {
|
||||||
if (event.request.method !== 'GET') {
|
return Boolean(response && response.ok && response.type !== 'opaque');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
event.respondWith(
|
async function precache() {
|
||||||
fetch(event.request)
|
const cache = await caches.open(CACHE_NAME);
|
||||||
.then((response) => {
|
await cache.addAll(PRECACHE_ASSETS);
|
||||||
// If we got a valid response, cache it for next time
|
|
||||||
if (response.ok && ASSETS.includes(url.pathname)) {
|
|
||||||
const copy = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanupOldCaches() {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CACHE_NAME)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putInCache(request, response) {
|
||||||
|
if (!isSameOrigin(request) || !canCache(response)) {
|
||||||
return response;
|
return response;
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// If network fails, try cache
|
|
||||||
return caches.match(event.request).then((cached) => {
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
// If it's a navigation request and we're offline, show offline crisis page
|
|
||||||
if (event.request.mode === 'navigate') {
|
|
||||||
return new Response(CRISIS_OFFLINE_RESPONSE, {
|
|
||||||
status: 200,
|
|
||||||
headers: new Headers({ 'Content-Type': 'text/html' })
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other requests, return a simple offline message
|
const cache = await caches.open(CACHE_NAME);
|
||||||
return new Response('Offline. Call 988 for immediate help.', {
|
await cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(request, timeoutMs) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(request, { signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function offlineTextResponse() {
|
||||||
|
return new Response('Offline. Call 988 or text HOME to 741741 for immediate help.', {
|
||||||
status: 503,
|
status: 503,
|
||||||
statusText: 'Service Unavailable',
|
statusText: 'Service Unavailable',
|
||||||
headers: new Headers({ 'Content-Type': 'text/plain' })
|
headers: new Headers({ 'Content-Type': 'text/plain; charset=utf-8' })
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
})
|
|
||||||
|
async function handleNavigation(request) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedPage = await cache.match(request);
|
||||||
|
const offlineFallback = await cache.match(OFFLINE_FALLBACK_PATH);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(request, NAVIGATION_TIMEOUT_MS);
|
||||||
|
return await putInCache(request, response);
|
||||||
|
} catch (error) {
|
||||||
|
if (cachedPage) {
|
||||||
|
return cachedPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offlineFallback) {
|
||||||
|
return offlineFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offlineTextResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStaticRequest(request) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => putInCache(request, response))
|
||||||
|
.catch(() => null);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
return await putInCache(request, response);
|
||||||
|
} catch (error) {
|
||||||
|
return offlineTextResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOtherRequest(request) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
return await putInCache(request, response);
|
||||||
|
} catch (error) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offlineTextResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
precache().then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
cleanupOldCaches().then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameOrigin(request) || url.pathname.startsWith('/api/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(handleNavigation(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PRECACHE_ASSETS.includes(url.pathname)) {
|
||||||
|
event.respondWith(handleStaticRequest(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(handleOtherRequest(request));
|
||||||
|
});
|
||||||
|
|||||||
84
tests/focus-trap-test.html
Normal file
84
tests/focus-trap-test.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!-- Test: Safety plan modal focus trap (issue #65) -->
|
||||||
|
<!-- Open this file in a browser to manually verify focus trap behavior -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Focus Trap Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; padding: 20px; }
|
||||||
|
.test { margin: 10px 0; padding: 10px; border: 1px solid #ccc; }
|
||||||
|
.pass { background: #d4edda; border-color: #28a745; }
|
||||||
|
.fail { background: #f8d7da; border-color: #dc3545; }
|
||||||
|
button { margin: 5px; padding: 8px 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Focus Trap Manual Test</h1>
|
||||||
|
<p>Open <code>index.html</code> in a browser, then run these checks:</p>
|
||||||
|
|
||||||
|
<div class="test" id="test-1">
|
||||||
|
<strong>Test 1: Tab wraps to first element</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Tab through all elements until you reach "Save Plan"<br>
|
||||||
|
3. Press Tab again → should wrap to close button (X)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-2">
|
||||||
|
<strong>Test 2: Shift+Tab wraps to last element</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Focus is on "Warning signs" textarea<br>
|
||||||
|
3. Press Shift+Tab → should wrap to "Save Plan" button
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-3">
|
||||||
|
<strong>Test 3: Escape closes modal</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Press Escape → modal closes<br>
|
||||||
|
3. Focus returns to the button that opened it
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-4">
|
||||||
|
<strong>Test 4: Background not reachable</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Try to Tab to the chat input behind the modal<br>
|
||||||
|
3. Should NOT be able to reach it
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-5">
|
||||||
|
<strong>Test 5: Click buttons close + restore focus</strong><br>
|
||||||
|
1. Open modal via "my safety plan" button<br>
|
||||||
|
2. Click Cancel → modal closes, focus on "my safety plan" button<br>
|
||||||
|
3. Open again, click Save → same behavior<br>
|
||||||
|
4. Open again, click X → same behavior
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2>Automated checks (paste into DevTools console on index.html):</h2>
|
||||||
|
<pre><code>
|
||||||
|
// Test focus trap
|
||||||
|
var modal = document.getElementById('safety-plan-modal');
|
||||||
|
var openBtn = document.getElementById('safety-plan-btn');
|
||||||
|
openBtn.click();
|
||||||
|
console.assert(modal.classList.contains('active'), 'Modal should be open');
|
||||||
|
|
||||||
|
var lastEl = document.getElementById('save-safety-plan');
|
||||||
|
lastEl.focus();
|
||||||
|
var evt = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true});
|
||||||
|
document.dispatchEvent(evt);
|
||||||
|
// After Tab from last, focus should wrap to first
|
||||||
|
var firstEl = document.getElementById('close-safety-plan');
|
||||||
|
console.log('Focus after wrap:', document.activeElement.id);
|
||||||
|
console.assert(document.activeElement === firstEl || document.activeElement.id === 'sp-warning-signs',
|
||||||
|
'Focus should wrap to first element');
|
||||||
|
|
||||||
|
// Test Escape
|
||||||
|
var escEvt = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
||||||
|
document.dispatchEvent(escEvt);
|
||||||
|
console.assert(!modal.classList.contains('active'), 'Modal should close on Escape');
|
||||||
|
console.assert(document.activeElement === openBtn, 'Focus should return to open button');
|
||||||
|
|
||||||
|
console.log('All automated checks passed!');
|
||||||
|
</code></pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
tests/test_crisis_overlay_focus_trap.py
Normal file
57
tests/test_crisis_overlay_focus_trap.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
INDEX_HTML = ROOT / 'index.html'
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrisisOverlayFocusTrap(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.html = INDEX_HTML.read_text()
|
||||||
|
|
||||||
|
def test_overlay_registers_tab_key_focus_trap(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"function\s+trapFocusInOverlay\s*\(e\)",
|
||||||
|
'Expected crisis overlay focus trap handler to exist.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"if\s*\(e\.key\s*!==\s*'Tab'\)\s*return;",
|
||||||
|
'Expected focus trap handler to guard on Tab key events.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"document\.addEventListener\('keydown',\s*trapFocusInOverlay\)",
|
||||||
|
'Expected overlay focus trap to register on document keydown.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_disables_background_interaction(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"mainApp\.setAttribute\('inert',\s*''\)",
|
||||||
|
'Expected overlay to set inert on the main app while active.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"mainApp\.removeAttribute\('inert'\)",
|
||||||
|
'Expected overlay dismissal to remove inert from the main app.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_restores_focus_after_dismiss(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"_preOverlayFocusElement\s*=\s*document\.activeElement",
|
||||||
|
'Expected overlay to remember the pre-overlay focus target.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"_preOverlayFocusElement\.focus\(\)",
|
||||||
|
'Expected overlay dismissal to restore focus to the prior target.',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
44
tests/test_dying_detection_deprecation.py
Normal file
44
tests/test_dying_detection_deprecation.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from crisis.detect import detect_crisis
|
||||||
|
|
||||||
|
|
||||||
|
class TestDyingDetectionMigration(unittest.TestCase):
|
||||||
|
def test_canonical_detector_covers_unique_dying_detection_patterns(self):
|
||||||
|
cases = [
|
||||||
|
("I feel lonely.", "LOW"),
|
||||||
|
("I've lost all hope and see no tomorrow.", "MEDIUM"),
|
||||||
|
("What if I disappeared forever?", "HIGH"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for text, expected_level in cases:
|
||||||
|
with self.subTest(text=text):
|
||||||
|
result = detect_crisis(text)
|
||||||
|
self.assertEqual(result.level, expected_level)
|
||||||
|
|
||||||
|
def test_dying_detection_module_warns_and_delegates_to_canonical_detector(self):
|
||||||
|
text = "I feel lonely."
|
||||||
|
sys.modules.pop("dying_detection", None)
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always", DeprecationWarning)
|
||||||
|
module = importlib.import_module("dying_detection")
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any(issubclass(w.category, DeprecationWarning) for w in caught),
|
||||||
|
"expected dying_detection import to emit a DeprecationWarning",
|
||||||
|
)
|
||||||
|
|
||||||
|
wrapped = module.detect(text)
|
||||||
|
canonical = detect_crisis(text)
|
||||||
|
|
||||||
|
self.assertEqual(wrapped.level, canonical.level)
|
||||||
|
self.assertEqual(wrapped.confidence, canonical.score)
|
||||||
|
self.assertEqual(wrapped.raw_matched_patterns, [m["pattern"] for m in canonical.matches])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
176
tests/test_false_positive_fixes.py
Normal file
176
tests/test_false_positive_fixes.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
74
tests/test_safety_plan_inline_feedback.py
Normal file
74
tests/test_safety_plan_inline_feedback.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
INDEX_HTML = ROOT / 'index.html'
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafetyPlanInlineFeedback(unittest.TestCase):
|
||||||
|
"""Test that safety plan uses inline feedback instead of blocking alert()."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.html = INDEX_HTML.read_text()
|
||||||
|
|
||||||
|
def test_no_alert_calls(self):
|
||||||
|
"""Safety plan save must not use browser alert()."""
|
||||||
|
alert_matches = re.findall(r'alert\(', self.html)
|
||||||
|
self.assertEqual(
|
||||||
|
len(alert_matches), 0,
|
||||||
|
f'Found {len(alert_matches)} alert() calls - must use inline feedback instead.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sp_status_element_exists(self):
|
||||||
|
"""Modal footer must contain #sp-status element for inline feedback."""
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r'id=["\']sp-status["\']',
|
||||||
|
'Expected #sp-status element in the safety plan modal.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sp_status_has_aria_live(self):
|
||||||
|
"""#sp-status must have aria-live for accessible announcements."""
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r'aria-live=["\']polite["\']',
|
||||||
|
'Expected #sp-status to have aria-live="polite".',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_success_feedback_exists(self):
|
||||||
|
"""Must show success message on save."""
|
||||||
|
self.assertIn(
|
||||||
|
'Safety plan saved locally.',
|
||||||
|
self.html,
|
||||||
|
'Expected success message for safety plan save.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_feedback_exists(self):
|
||||||
|
"""Must show error message on save failure."""
|
||||||
|
self.assertIn(
|
||||||
|
'Error saving plan.',
|
||||||
|
self.html,
|
||||||
|
'Expected error message for safety plan save failure.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_css_success_state(self):
|
||||||
|
"""Must have CSS for .sp-status.success state."""
|
||||||
|
self.assertIn(
|
||||||
|
'sp-status.success',
|
||||||
|
self.html,
|
||||||
|
'Expected CSS for .sp-status.success state.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_css_error_state(self):
|
||||||
|
"""Must have CSS for .sp-status.error state."""
|
||||||
|
self.assertIn(
|
||||||
|
'sp-status.error',
|
||||||
|
self.html,
|
||||||
|
'Expected CSS for .sp-status.error state.',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
55
tests/test_service_worker_offline.py
Normal file
55
tests/test_service_worker_offline.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
SERVICE_WORKER = (ROOT / 'sw.js').read_text(encoding='utf-8')
|
||||||
|
CRISIS_OFFLINE_PAGE = ROOT / 'crisis-offline.html'
|
||||||
|
MAKEFILE = (ROOT / 'Makefile').read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceWorkerOffline(unittest.TestCase):
|
||||||
|
def test_crisis_offline_page_exists(self):
|
||||||
|
self.assertTrue(CRISIS_OFFLINE_PAGE.exists(), 'crisis-offline.html should exist')
|
||||||
|
|
||||||
|
def test_service_worker_precaches_crisis_offline_page(self):
|
||||||
|
self.assertIn('/crisis-offline.html', SERVICE_WORKER)
|
||||||
|
|
||||||
|
def test_service_worker_has_navigation_timeout_for_intermittent_connections(self):
|
||||||
|
self.assertIn('NAVIGATION_TIMEOUT_MS', SERVICE_WORKER)
|
||||||
|
self.assertIn('AbortController', SERVICE_WORKER)
|
||||||
|
|
||||||
|
def test_service_worker_uses_crisis_offline_fallback_for_navigation(self):
|
||||||
|
self.assertIn("event.request.mode === 'navigate'", SERVICE_WORKER)
|
||||||
|
self.assertIn("/crisis-offline.html", SERVICE_WORKER)
|
||||||
|
|
||||||
|
def test_make_push_includes_crisis_offline_page(self):
|
||||||
|
self.assertIn('crisis-offline.html', MAKEFILE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrisisOfflinePage(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.html = CRISIS_OFFLINE_PAGE.read_text(encoding='utf-8') if CRISIS_OFFLINE_PAGE.exists() else ''
|
||||||
|
cls.lower_html = cls.html.lower()
|
||||||
|
|
||||||
|
def test_has_clickable_988_link(self):
|
||||||
|
self.assertIn('href="tel:988"', self.html)
|
||||||
|
|
||||||
|
def test_has_crisis_text_line(self):
|
||||||
|
self.assertIn('Crisis Text Line', self.html)
|
||||||
|
self.assertIn('741741', self.html)
|
||||||
|
|
||||||
|
def test_has_grounding_techniques(self):
|
||||||
|
required_phrases = [
|
||||||
|
'5 things you can see',
|
||||||
|
'4 things you can feel',
|
||||||
|
'3 things you can hear',
|
||||||
|
'2 things you can smell',
|
||||||
|
'1 thing you can taste',
|
||||||
|
]
|
||||||
|
for phrase in required_phrases:
|
||||||
|
self.assertIn(phrase, self.lower_html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
17
tests/test_session_crisis_frontend.py
Normal file
17
tests/test_session_crisis_frontend.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_contains_session_crisis_state_and_tracking_hooks():
|
||||||
|
html = Path('index.html').read_text()
|
||||||
|
assert 'var sessionCrisis' in html
|
||||||
|
assert 'function trackCrisis(text)' in html
|
||||||
|
assert 'function getSessionContext()' in html
|
||||||
|
assert 'function resetSessionCrisis()' in html
|
||||||
|
assert 'trackCrisis(text);' in html
|
||||||
|
assert 'SESSION CONTEXT:' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_session_resets_crisis_state():
|
||||||
|
html = Path('index.html').read_text()
|
||||||
|
assert 'resetSessionCrisis()' in html
|
||||||
|
assert "localStorage.removeItem('timmy_chat_history');" in html
|
||||||
Reference in New Issue
Block a user