Compare commits

...

6 Commits

Author SHA1 Message Date
f833bd5c5e test: add regression tests for duplicate indicator patterns (#123)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 6s
Smoke Test / smoke (pull_request) Successful in 33s
2026-04-16 01:58:33 +00:00
41819292e3 fix: remove 6 duplicate crisis indicator patterns from MEDIUM tier (#123) 2026-04-16 01:56:24 +00:00
48f48c7f26 feat: cache offline crisis resources (refs #41) (#74)
All checks were successful
Smoke Test / smoke (push) Successful in 7s
Sanity Checks / sanity-test (pull_request) Successful in 17s
Smoke Test / smoke (pull_request) Successful in 19s
Merge PR #74 (squash)
2026-04-14 22:09:59 +00:00
da31288525 fix: deprecate dying_detection and consolidate crisis detection (#40) (#76)
All checks were successful
Smoke Test / smoke (push) Successful in 4s
Merge PR #76 (squash)
2026-04-14 22:08:29 +00:00
8efc858cd7 fix: add keyboard focus trap to crisis overlay (#80)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #80 (squash)
2026-04-14 22:08:28 +00:00
611c1c8456 fix(a11y): Safety plan modal keyboard focus trap (#65) (#81)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #81 (squash)
2026-04-14 22:08:24 +00:00
12 changed files with 985 additions and 377 deletions

View File

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

241
crisis-offline.html Normal file
View 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>

View File

@@ -51,13 +51,13 @@ HIGH_INDICATORS = [
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|miss)\b", r"\bno\s+one\s+would\s+(?:care|miss)\b",
r"\bworld\s+would?\s+be\s+better\s+without\b", r"\bworld\s+would?\s+be\s+better\s+without\s+me\b",
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment)\b", r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment|anguish)\b",
r"\bcan'?t\s+see\s+any\s+(?:point|reason)\b", r"\bcan'?t\s+see\s+any\s+(?:point|reason|hope|way)\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"\btrapped\s+(?:in\s+(?:my|this|a\s+dark)|and\s+can'?t\s+escape)\b",
r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b", r"\bnothing\s+left\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) # Contextual despair phrases (from crisis_detector.py legacy)
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b", r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
r"\beverything\s+is\s+hopeless\b", r"\beverything\s+is\s+hopeless\b",
@@ -68,6 +68,8 @@ HIGH_INDICATORS = [
r"\bno\s*hope\s+(?:left|remaining)\b", r"\bno\s*hope\s+(?:left|remaining)\b",
r"\bno\s*way\s*out\b", r"\bno\s*way\s*out\b",
r"\bfeel(?:s|ing)?\s+trapped\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"\bdesperate\s+(?:for\s+)?help\b",
r"\bfeel(?:s|ing)?\s+desperate\b", r"\bfeel(?:s|ing)?\s+desperate\b",
] ]
@@ -99,20 +101,19 @@ MEDIUM_INDICATORS = [
r"\bsinking\b", r"\bsinking\b",
r"\bdrowning\b", r"\bdrowning\b",
r"\bhopeless\b", r"\bhopeless\b",
r"\blost\s+all\s+hope\b",
r"\bno\s+tomorrow\b",
# Contextual versions (from crisis_detector.py legacy) # Contextual versions (from crisis_detector.py legacy)
# NOTE: feel(s/ing)? hopeless, trapped, desperate, no future, nothing left,
# and give(n)? up on myself are already in HIGH_INDICATORS — do not
# duplicate here. See issue #123.
r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b", 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|week|patch)\b", r"\brough\s+(?:day|week|patch)\b",
r"\bstressed\b", r"\bstressed\b",
r"\bburnout\b", r"\bburnout\b",
@@ -122,6 +123,8 @@ LOW_INDICATORS = [
r"\btired\b", r"\btired\b",
r"\bsad\b", r"\bsad\b",
r"\bupset\b", r"\bupset\b",
r"\blonely\b",
r"\banxious?\b",
r"\bnot\s*(?:good|great|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",

View File

@@ -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
result = _crisis_detect(text)
# Map to DetectionResult
return DetectionResult(
level=result.level,
indicators=_describe_indicators(result.level, result.indicators),
recommended_action=result.recommended_action,
confidence=result.score,
session_hash=_hash_session(text),
)
except ImportError:
pass
return _detect_internal(text) # Extract raw patterns from matches
raw_patterns = [m["pattern"] for m in result.matches] if result.matches else []
return DetectionResult(
def _detect_internal(text: str) -> DetectionResult: level=result.level,
"""Internal pattern-based detection (fallback).""" indicators=result.indicators,
if not text or not text.strip(): recommended_action=result.recommended_action,
return DetectionResult(level="NONE", session_hash=_hash_session(text)) raw_matched_patterns=raw_patterns,
confidence=result.score,
text_lower = text.lower() session_hash=hashlib.sha256(text.encode()).hexdigest()[:12],
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:

View File

@@ -983,12 +983,60 @@ Sovereignty and service always.`;
// ===== 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--;
@@ -1005,6 +1053,9 @@ Sovereignty and service always.`;
overlayDismissBtn.focus(); overlayDismissBtn.focus();
} }
// Register focus trap on document (always listening, gated by class check)
document.addEventListener('keydown', trapFocusInOverlay);
overlayDismissBtn.addEventListener('click', function() { overlayDismissBtn.addEventListener('click', function() {
if (!overlayDismissBtn.disabled) { if (!overlayDismissBtn.disabled) {
crisisOverlay.classList.remove('active'); crisisOverlay.classList.remove('active');
@@ -1012,7 +1063,22 @@ Sovereignty and service always.`;
clearInterval(overlayTimer); clearInterval(overlayTimer);
overlayTimer = null; overlayTimer = null;
} }
msgInput.focus();
// 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();
}
_preOverlayFocusElement = null;
} }
}); });
@@ -1117,25 +1183,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() {
@@ -1149,12 +1204,101 @@ Sovereignty and service always.`;
try { try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan)); localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active'); safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
alert('Safety plan saved locally.'); alert('Safety plan saved locally.');
} catch (e) { } catch (e) {
alert('Error saving plan.'); alert('Error saving plan.');
} }
}); });
// ===== 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';
@@ -1300,6 +1444,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);
} }

View File

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

219
sw.js
View File

@@ -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"> function canCache(response) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> return Boolean(response && response.ok && response.type !== 'opaque');
<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} async function precache() {
h1{color:#ff6b6b;font-size:1.5rem;margin-bottom:1rem} const cache = await caches.open(CACHE_NAME);
.crisis-box{background:#1c1210;border:2px solid #c9362c;border-radius:12px;padding:20px;margin:20px 0;text-align:center} await cache.addAll(PRECACHE_ASSETS);
.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> async function cleanupOldCaches() {
</head> const keys = await caches.keys();
<body> await Promise.all(
<h1>You are not alone.</h1> keys
<p>Your connection is down, but help is still available.</p> .filter((key) => key !== CACHE_NAME)
<div class="crisis-box"> .map((key) => caches.delete(key))
<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> async function putInCache(request, response) {
<p><strong>When you're ready:</strong></p> if (!isSameOrigin(request) || !canCache(response)) {
<ul> return response;
<li>Take five deep breaths</li> }
<li>Drink some water</li>
<li>Step outside if you can</li> const cache = await caches.open(CACHE_NAME);
<li>Text or call someone you trust</li> await cache.put(request, response.clone());
</ul> return response;
<p class="hope"> }
"The Lord is close to the brokenhearted and saves those who are crushed in spirit." — Psalm 34:18
</p> async function fetchWithTimeout(request, timeoutMs) {
<p style="font-size:0.85rem;color:#6e7681;margin-top:30px"> const controller = new AbortController();
This page was created by The Door — a crisis intervention project.<br> const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
Connection will restore automatically. You don't have to go through this alone.
</p> try {
</body> return await fetch(request, { signal: controller.signal });
</html>`; } finally {
clearTimeout(timeoutId);
}
}
async function offlineTextResponse() {
return new Response('Offline. Call 988 or text HOME to 741741 for immediate help.', {
status: 503,
statusText: 'Service Unavailable',
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();
}
}
// Install event - cache core assets
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { precache().then(() => self.skipWaiting())
return cache.addAll(ASSETS);
})
); );
self.skipWaiting();
}); });
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => { cleanupOldCaches().then(() => self.clients.claim())
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) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const request = event.request;
const url = new URL(request.url);
// Skip API calls - they should always go to network if (request.method !== 'GET') {
if (url.pathname.startsWith('/api/')) {
return; return;
} }
// Skip non-GET requests if (!isSameOrigin(request) || url.pathname.startsWith('/api/')) {
if (event.request.method !== 'GET') {
return; return;
} }
event.respondWith( if (event.request.mode === 'navigate') {
fetch(event.request) event.respondWith(handleNavigation(request));
.then((response) => { return;
// 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));
}
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 (PRECACHE_ASSETS.includes(url.pathname)) {
if (event.request.mode === 'navigate') { event.respondWith(handleStaticRequest(request));
return new Response(CRISIS_OFFLINE_RESPONSE, { return;
status: 200, }
headers: new Headers({ 'Content-Type': 'text/html' })
});
}
// For other requests, return a simple offline message event.respondWith(handleOtherRequest(request));
return new Response('Offline. Call 988 for immediate help.', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({ 'Content-Type': 'text/plain' })
});
});
})
);
}); });

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

View File

@@ -0,0 +1,167 @@
"""
Regression test for issue #123: duplicate crisis indicator patterns across tiers.
Ensures that no pattern appears in more than one indicator tier.
Duplicate patterns waste regex matching cycles and create ambiguity
about which tier a message should trigger.
"""
import re
import sys
import os
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.detect import (
CRITICAL_INDICATORS,
HIGH_INDICATORS,
MEDIUM_INDICATORS,
LOW_INDICATORS,
)
from crisis_detector import CrisisDetector, detect_crisis
class TestNoDuplicatePatterns(unittest.TestCase):
"""Ensure no pattern appears in more than one tier."""
def test_no_duplicates_between_critical_and_high(self):
"""CRITICAL and HIGH should not share patterns."""
critical_set = set(CRITICAL_INDICATORS)
dupes = [p for p in HIGH_INDICATORS if p in critical_set]
self.assertEqual(dupes, [], f"Duplicates between CRITICAL and HIGH: {dupes}")
def test_no_duplicates_between_critical_and_medium(self):
"""CRITICAL and MEDIUM should not share patterns."""
critical_set = set(CRITICAL_INDICATORS)
dupes = [p for p in MEDIUM_INDICATORS if p in critical_set]
self.assertEqual(dupes, [], f"Duplicates between CRITICAL and MEDIUM: {dupes}")
def test_no_duplicates_between_high_and_medium(self):
"""HIGH and MEDIUM should not share patterns (issue #123)."""
high_set = set(HIGH_INDICATORS)
dupes = [p for p in MEDIUM_INDICATORS if p in high_set]
self.assertEqual(dupes, [], f"Duplicates between HIGH and MEDIUM: {dupes}")
def test_no_duplicates_between_high_and_low(self):
"""HIGH and LOW should not share patterns."""
high_set = set(HIGH_INDICATORS)
dupes = [p for p in LOW_INDICATORS if p in high_set]
self.assertEqual(dupes, [], f"Duplicates between HIGH and LOW: {dupes}")
def test_no_duplicates_between_medium_and_low(self):
"""MEDIUM and LOW should not share patterns."""
medium_set = set(MEDIUM_INDICATORS)
dupes = [p for p in LOW_INDICATORS if p in medium_set]
self.assertEqual(dupes, [], f"Duplicates between MEDIUM and LOW: {dupes}")
def test_no_duplicates_between_critical_and_low(self):
"""CRITICAL and LOW should not share patterns."""
critical_set = set(CRITICAL_INDICATORS)
dupes = [p for p in LOW_INDICATORS if p in critical_set]
self.assertEqual(dupes, [], f"Duplicates between CRITICAL and LOW: {dupes}")
def test_no_internal_duplicates(self):
"""Each tier should not contain duplicate patterns internally."""
for name, indicators in [
("CRITICAL", CRITICAL_INDICATORS),
("HIGH", HIGH_INDICATORS),
("MEDIUM", MEDIUM_INDICATORS),
("LOW", LOW_INDICATORS),
]:
seen = set()
dupes = []
for p in indicators:
if p in seen:
dupes.append(p)
seen.add(p)
self.assertEqual(dupes, [], f"Internal duplicates in {name}: {dupes}")
class TestSpecificDuplicatesFromIssue123(unittest.TestCase):
"""Verify the 6 specific duplicates from issue #123 are fixed."""
def test_feel_hopeless_not_in_medium(self):
"""feel(s/ing)? (so)? hopeless should only be in HIGH."""
pattern = r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b"
self.assertIn(pattern, HIGH_INDICATORS)
self.assertNotIn(pattern, MEDIUM_INDICATORS)
def test_feel_trapped_not_in_medium(self):
"""feel(s/ing)? trapped should only be in HIGH."""
pattern = r"\bfeel(?:s|ing)?\s+trapped\b"
self.assertIn(pattern, HIGH_INDICATORS)
self.assertNotIn(pattern, MEDIUM_INDICATORS)
def test_feel_desperate_not_in_medium(self):
"""feel(s/ing)? desperate should only be in HIGH."""
pattern = r"\bfeel(?:s|ing)?\s+desperate\b"
self.assertIn(pattern, HIGH_INDICATORS)
self.assertNotIn(pattern, MEDIUM_INDICATORS)
def test_no_future_not_in_medium(self):
"""no future (for me|ahead|left) should only be in HIGH."""
pattern = r"\bno\s+future\s+(?:for\s+me|ahead|left)\b"
self.assertIn(pattern, HIGH_INDICATORS)
self.assertNotIn(pattern, MEDIUM_INDICATORS)
def test_nothing_left_not_in_medium(self):
"""nothing left (to (live|hope) for|inside) should only be in HIGH."""
pattern = r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b"
self.assertIn(pattern, HIGH_INDICATORS)
self.assertNotIn(pattern, MEDIUM_INDICATORS)
def test_give_up_on_myself_not_in_medium(self):
"""give(n)? up on myself should only be in HIGH."""
pattern = r"\bgive(?:n)?\s*up\s+on\s+myself\b"
self.assertIn(pattern, HIGH_INDICATORS)
self.assertNotIn(pattern, MEDIUM_INDICATORS)
class TestDetectionStillWorks(unittest.TestCase):
"""Verify that removing duplicates didn't break detection."""
def setUp(self):
self.detector = CrisisDetector()
def test_high_patterns_still_detected(self):
"""Messages matching removed MEDIUM duplicates should still hit HIGH."""
high_messages = [
"I feel so hopeless about everything",
"I feel trapped and can't escape",
"I feel desperate for help",
"There's no future for me",
"I have nothing left to live for",
"I've given up on myself",
]
for msg in high_messages:
result = detect_crisis(msg)
self.assertIn(result.level, ("HIGH", "CRITICAL"),
f"'{msg}' should trigger HIGH/CRITICAL, got {result.level}")
def test_medium_patterns_still_detected(self):
"""Original MEDIUM patterns should still work."""
medium_messages = [
"I feel so worthless",
"I'm exhausted and broken",
"Everything feels dark",
"I'm drowning in this",
]
for msg in medium_messages:
result = detect_crisis(msg)
self.assertIn(result.level, ("MEDIUM", "HIGH", "CRITICAL"),
f"'{msg}' should trigger MEDIUM+, got {result.level}")
def test_low_patterns_still_detected(self):
"""LOW patterns should still work."""
result = detect_crisis("I'm having a tough day")
self.assertIn(result.level, ("LOW", "MEDIUM", "HIGH", "CRITICAL"))
def test_none_still_clean(self):
"""Innocent messages should not trigger."""
result = detect_crisis("I had a great lunch with friends")
self.assertEqual(result.level, "NONE")
if __name__ == "__main__":
unittest.main()

View 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()

View 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()

View 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()