Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
b7281f9542 feat: expose safety plan from chat header (#38)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 7s
Smoke Test / smoke (pull_request) Successful in 15s
2026-04-13 21:55:03 -04:00
14 changed files with 546 additions and 1702 deletions

View File

@@ -34,7 +34,7 @@ deploy-bash:
push:
rsync -avz --exclude='.git' --exclude='deploy' \
index.html manifest.json sw.js about.html crisis-offline.html testimony.html system-prompt.txt \
index.html manifest.json sw.js about.html testimony.html system-prompt.txt \
root@$(VPS):/var/www/the-door/
ssh root@$(VPS) "chown -R www-data:www-data /var/www/the-door"

View File

@@ -1,241 +0,0 @@
<!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

@@ -1,409 +0,0 @@
#!/usr/bin/env python3
"""
Crisis Detection A/B Testing Framework
Allows testing different crisis detection algorithms with:
- Feature flags for algorithm A vs B
- Logging of which variant triggered for each event
- Metrics: false positive rate, detection latency per variant
- Statistical significance testing
Usage:
from crisis.ab_testing import ABTestManager, Variant
manager = ABTestManager()
result = manager.detect_with_variant("I'm feeling hopeless")
print(result.variant, result.detection_result)
# Get metrics
metrics = manager.get_metrics()
print(metrics.false_positive_rate_a, metrics.false_positive_rate_b)
"""
import json
import os
import random
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from uuid import uuid4
from .detect import detect_crisis, CrisisDetectionResult
class Variant(Enum):
"""A/B test variants for crisis detection."""
A = "A" # Control: current algorithm
B = "B" # Treatment: alternative algorithm
@dataclass
class ABTestConfig:
"""Configuration for A/B testing."""
enabled: bool = True
variant_b_percentage: float = 0.5 # 50% split
log_file: Optional[str] = None # Default: ~/.the-door/ab_test_logs.jsonl
metrics_file: Optional[str] = None # Default: ~/.the-door/ab_metrics.json
seed: Optional[int] = None # For reproducible assignments
@dataclass
class DetectionEvent:
"""A single detection event with variant assignment."""
event_id: str
timestamp: str
variant: str
text_hash: str # Hash of input text for privacy
detected_level: str
detected_score: float
indicators: List[str]
detection_latency_ms: float
is_false_positive: Optional[bool] = None # Null until labeled
user_feedback: Optional[str] = None
@dataclass
class VariantMetrics:
"""Metrics for a single variant."""
total_detections: int = 0
true_positives: int = 0
false_positives: int = 0
false_negatives: int = 0 # Requires manual labeling
avg_latency_ms: float = 0.0
p50_latency_ms: float = 0.0
p95_latency_ms: float = 0.0
p99_latency_ms: float = 0.0
level_distribution: Dict[str, int] = field(default_factory=dict)
indicator_frequency: Dict[str, int] = field(default_factory=dict)
@dataclass
class ABTestMetrics:
"""Complete A/B test metrics."""
test_id: str
start_time: str
end_time: Optional[str] = None
variant_a: VariantMetrics = field(default_factory=VariantMetrics)
variant_b: VariantMetrics = field(default_factory=VariantMetrics)
sample_size_a: int = 0
sample_size_b: int = 0
statistical_significance: Optional[float] = None # p-value if calculable
class ABTestManager:
"""Manages A/B testing for crisis detection algorithms."""
def __init__(self, config: Optional[ABTestConfig] = None):
self.config = config or ABTestConfig()
self.test_id = str(uuid4())[:8]
self.events: List[DetectionEvent] = []
# Set up file paths
home = Path.home() / ".the-door"
home.mkdir(exist_ok=True)
self.log_file = Path(self.config.log_file or home / "ab_test_logs.jsonl")
self.metrics_file = Path(self.config.metrics_file or home / "ab_metrics.json")
# Initialize RNG
if self.config.seed is not None:
random.seed(self.config.seed)
def _assign_variant(self, text: str) -> Variant:
"""Assign a variant based on text hash for deterministic assignment."""
if not self.config.enabled:
return Variant.A
# Use hash of text for consistent assignment
text_hash = hash(text) % 100
threshold = int(self.config.variant_b_percentage * 100)
if text_hash < threshold:
return Variant.B
return Variant.A
def _detect_variant_a(self, text: str) -> CrisisDetectionResult:
"""Variant A: Current algorithm (control)."""
return detect_crisis(text)
def _detect_variant_b(self, text: str) -> CrisisDetectionResult:
"""Variant B: Alternative detection algorithm.
This is a placeholder - in practice, you'd implement a different
detection algorithm here. For now, we'll use the same algorithm
but with different sensitivity settings.
"""
# Example: Variant B could use different thresholds or additional patterns
result = detect_crisis(text)
# For demonstration: adjust sensitivity based on confidence score
# In practice, this would be a completely different algorithm
if result.score > 0.7 and result.level != "CRITICAL":
# Variant B is more sensitive to high-confidence detections
from .detect import CRITICAL_INDICATORS
import re
for pattern in CRITICAL_INDICATORS:
if re.search(pattern, text, re.IGNORECASE):
# Upgrade to CRITICAL if we find critical indicators
return CrisisDetectionResult(
level="CRITICAL",
score=result.score,
indicators=result.indicators,
matched_patterns=result.matched_patterns,
recommended_action="immediate_intervention"
)
return result
def detect_with_variant(self, text: str, user_id: Optional[str] = None) -> Tuple[Variant, CrisisDetectionResult, float]:
"""
Run crisis detection with A/B testing.
Returns:
Tuple of (variant, detection_result, latency_ms)
"""
if not self.config.enabled:
start = time.time()
result = self._detect_variant_a(text)
latency = (time.time() - start) * 1000
return Variant.A, result, latency
# Assign variant
variant = self._assign_variant(text)
# Run detection with timing
start = time.time()
if variant == Variant.A:
result = self._detect_variant_a(text)
else:
result = self._detect_variant_b(text)
latency_ms = (time.time() - start) * 1000
# Log event
self._log_event(variant, text, result, latency_ms, user_id)
return variant, result, latency_ms
def _log_event(self, variant: Variant, text: str, result: CrisisDetectionResult,
latency_ms: float, user_id: Optional[str] = None):
"""Log a detection event."""
import hashlib
# Hash text for privacy (don't log actual crisis text)
text_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
event = DetectionEvent(
event_id=str(uuid4())[:8],
timestamp=datetime.now(timezone.utc).isoformat(),
variant=variant.value,
text_hash=text_hash,
detected_level=result.level,
detected_score=result.score,
indicators=result.indicators[:5], # Limit for storage
detection_latency_ms=round(latency_ms, 2),
)
self.events.append(event)
# Append to log file
try:
with open(self.log_file, "a") as f:
f.write(json.dumps(asdict(event)) + "\n")
except Exception:
pass # Don't fail on logging errors
def label_event(self, event_id: str, is_false_positive: bool, feedback: Optional[str] = None):
"""Label an event as true/false positive for metrics calculation."""
for event in self.events:
if event.event_id == event_id:
event.is_false_positive = is_false_positive
event.user_feedback = feedback
break
# Update log file
self._save_events()
def _save_events(self):
"""Save all events to log file."""
try:
with open(self.log_file, "w") as f:
for event in self.events:
f.write(json.dumps(asdict(event)) + "\n")
except Exception:
pass
def get_metrics(self) -> ABTestMetrics:
"""Calculate metrics for both variants."""
metrics = ABTestMetrics(
test_id=self.test_id,
start_time=self.events[0].timestamp if self.events else datetime.now(timezone.utc).isoformat(),
end_time=datetime.now(timezone.utc).isoformat(),
)
# Separate events by variant
a_events = [e for e in self.events if e.variant == "A"]
b_events = [e for e in self.events if e.variant == "B"]
metrics.sample_size_a = len(a_events)
metrics.sample_size_b = len(b_events)
# Calculate variant A metrics
if a_events:
metrics.variant_a = self._calculate_variant_metrics(a_events)
# Calculate variant B metrics
if b_events:
metrics.variant_b = self._calculate_variant_metrics(b_events)
# Calculate statistical significance if we have enough data
if len(a_events) >= 30 and len(b_events) >= 30:
metrics.statistical_significance = self._calculate_significance(a_events, b_events)
# Save metrics to file
self._save_metrics(metrics)
return metrics
def _calculate_variant_metrics(self, events: List[DetectionEvent]) -> VariantMetrics:
"""Calculate metrics for a single variant."""
if not events:
return VariantMetrics()
# Latency statistics
latencies = [e.detection_latency_ms for e in events]
latencies.sort()
n = len(latencies)
p50_idx = int(n * 0.5)
p95_idx = int(n * 0.95)
p99_idx = int(n * 0.99)
# Level distribution
level_dist = {}
for e in events:
level_dist[e.detected_level] = level_dist.get(e.detected_level, 0) + 1
# Indicator frequency
indicator_freq = {}
for e in events:
for ind in e.indicators:
indicator_freq[ind] = indicator_freq.get(ind, 0) + 1
# False positive rate (only for labeled events)
labeled = [e for e in events if e.is_false_positive is not None]
fp_count = sum(1 for e in labeled if e.is_false_positive)
tp_count = sum(1 for e in labeled if not e.is_false_positive)
return VariantMetrics(
total_detections=len(events),
true_positives=tp_count,
false_positives=fp_count,
avg_latency_ms=sum(latencies) / n,
p50_latency_ms=latencies[p50_idx] if n > 0 else 0,
p95_latency_ms=latencies[p95_idx] if n > 0 else 0,
p99_latency_ms=latencies[p99_idx] if n > 0 else 0,
level_distribution=level_dist,
indicator_frequency=dict(sorted(indicator_freq.items(), key=lambda x: -x[1])[:10]),
)
def _calculate_significance(self, a_events: List[DetectionEvent],
b_events: List[DetectionEvent]) -> Optional[float]:
"""Calculate statistical significance (p-value) using chi-squared test."""
try:
# Count detections at each level for each variant
a_levels = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "NONE": 0}
b_levels = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "NONE": 0}
for e in a_events:
a_levels[e.detected_level] = a_levels.get(e.detected_level, 0) + 1
for e in b_events:
b_levels[e.detected_level] = b_levels.get(e.detected_level, 0) + 1
# Simple chi-squared test for level distribution difference
# This is a simplified version - in production you'd use scipy.stats.chi2_contingency
total_a = len(a_events)
total_b = len(b_events)
if total_a == 0 or total_b == 0:
return None
# Calculate expected frequencies
chi_sq = 0
for level in a_levels:
expected_a = (a_levels[level] + b_levels[level]) * total_a / (total_a + total_b)
expected_b = (a_levels[level] + b_levels[level]) * total_b / (total_a + total_b)
if expected_a > 0:
chi_sq += (a_levels[level] - expected_a) ** 2 / expected_a
if expected_b > 0:
chi_sq += (b_levels[level] - expected_b) ** 2 / expected_b
# Return chi-squared value (not p-value, as we don't have scipy)
# Higher values indicate more significant difference
return chi_sq
except Exception:
return None
def _save_metrics(self, metrics: ABTestMetrics):
"""Save metrics to file."""
try:
with open(self.metrics_file, "w") as f:
json.dump(asdict(metrics), f, indent=2)
except Exception:
pass
def get_variant_distribution(self) -> Dict[str, int]:
"""Get current distribution of events across variants."""
dist = {"A": 0, "B": 0}
for event in self.events:
dist[event.variant] = dist.get(event.variant, 0) + 1
return dist
def force_variant(self, variant: Variant):
"""Force all subsequent detections to use a specific variant."""
self.config.enabled = False
self._forced_variant = variant
def reset(self):
"""Reset the A/B test."""
self.events = []
self.config.enabled = True
if hasattr(self, '_forced_variant'):
delattr(self, '_forced_variant')
# Convenience function for easy integration
_default_manager = None
def get_ab_manager() -> ABTestManager:
"""Get the default A/B test manager instance."""
global _default_manager
if _default_manager is None:
_default_manager = ABTestManager()
return _default_manager
def detect_with_ab(text: str, user_id: Optional[str] = None) -> dict:
"""
Detect crisis with A/B testing.
Returns dict with variant, detection result, and metrics.
"""
manager = get_ab_manager()
variant, result, latency = manager.detect_with_variant(text, user_id)
return {
"variant": variant.value,
"detection": {
"level": result.level,
"score": result.score,
"indicators": result.indicators,
"recommended_action": result.recommended_action,
},
"latency_ms": round(latency, 2),
"test_id": manager.test_id,
}

View File

@@ -51,13 +51,13 @@ HIGH_INDICATORS = [
r"\bwish\s+I\s+(?:was|were)\s+(?:dead|gone|never\s+born)\b",
r"\bdon'?t\s+matter\s+if\s+I\s+exist\b",
r"\bno\s+one\s+would\s+(?:care|miss)\b",
r"\bworld\s+would?\s+be\s+better\s+without\s+me\b",
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment|anguish)\b",
r"\bcan'?t\s+see\s+any\s+(?:point|reason|hope|way)\b",
r"\bworld\s+would?\s+be\s+better\s+without\b",
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment)\b",
r"\bcan'?t\s+see\s+any\s+(?:point|reason)\b",
r"\bescape\s+from\s*this",
r"\bjust\s+want\s+it\s+to\s+stop\b",
r"\btrapped\s+(?:in\s+(?:my|this|a\s+dark)|and\s+can'?t\s+escape)\b",
r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b",
r"\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",
@@ -68,8 +68,6 @@ HIGH_INDICATORS = [
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",
]
@@ -101,8 +99,6 @@ MEDIUM_INDICATORS = [
r"\bsinking\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",
@@ -116,7 +112,7 @@ MEDIUM_INDICATORS = [
LOW_INDICATORS = [
r"\bunhappy\b",
r"\bdown\b",
r"\btough\s*(?:time|day|week)\b",
r"\btough\s*time\b",
r"\brough\s+(?:day|week|patch)\b",
r"\bstressed\b",
r"\bburnout\b",
@@ -126,8 +122,6 @@ LOW_INDICATORS = [
r"\btired\b",
r"\bsad\b",
r"\bupset\b",
r"\blonely\b",
r"\banxious?\b",
r"\bnot\s*(?:good|great|okay)\b",
r"\bthings\s*are\s*hard\b",
r"\bstruggling\b",

View File

@@ -1,34 +1,31 @@
"""
DEPRECATED — Use crisis.detect instead.
When a Man Is Dying — Despair/Suicide Detection System
This module is a thin wrapper around crisis.detect for backward compatibility.
All unique patterns have been merged into crisis/detect.py (see issue #40).
Standalone detection module that parses incoming text for
despair and suicide indicators, classifies into tiers,
and returns structured response with recommended actions.
This module will be removed in a future release.
Tiers:
LOW — General sadness, stress, difficult times
MEDIUM — Hopelessness, isolation, worthlessness
HIGH — Active despair, mentions of death, "can't go on"
CRITICAL — Imminent risk, explicit intent, plan, method
Integration:
Designed to work with crisis/ module from PR #4.
When crisis/ is available, uses it as the detection backend.
Falls back to internal detection when crisis/ is not present.
"""
import warnings
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict
import re
import json
import hashlib
# Re-export the canonical detection
from crisis.detect import detect_crisis, CrisisDetectionResult
# Issue deprecation warning on import
warnings.warn(
"dying_detection is deprecated. Use 'from crisis.detect import detect_crisis' instead. "
"All patterns have been consolidated into crisis/detect.py. "
"See issue #40.",
DeprecationWarning,
stacklevel=2,
)
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict
@dataclass
class DetectionResult:
"""Backward-compatible result type matching the old dying_detection API."""
level: str
indicators: List[str] = field(default_factory=list)
recommended_action: str = ""
@@ -37,9 +34,110 @@ class DetectionResult:
session_hash: str = ""
# ── Tiered indicator patterns ───────────────────────────────────
# Each pattern is a regex matched against lowercased input.
CRITICAL_PATTERNS = [
(r"\bkill\s*(my)?self\b", "explicit self-harm intent"),
(r"\bsuicid(?:al|ed|e)\b", "suicide reference"),
(r"\bend\s*(my)?\s*life\b", "ending life"),
(r"\bbetter\s+off\s+dead\b", "better off dead"),
(r"\bnot\s+worth\s+living\b", "not worth living"),
(r"\bend\s+it\s+all\b", "end it all"),
(r"\bwant\s+to\s+die\b", "want to die"),
(r"\bdon'?t\s+want\s+to\s+live\b", "don't want to live"),
(r"\bcan'?t\s+(go|live)\s+on\b", "can't go/live on"),
(r"\bno\s+reason\s+to\s+live\b", "no reason to live"),
(r"\bplan\s+to\s+(kill|end|die)\b", "planning self-harm"),
(r"\bgoing\s+to\s+kill\s+myself\b", "stated intent"),
(r"\bsaying\s+goodbye\s+(forever|one last time)\b", "final goodbye"),
(r"\bwrote\s+a\s+(will|suicide\s*note)\b", "preparatory action"),
(r"\bgiving\s+away\s+(my|all)\s+possess", "giving away possessions"),
(r"\btired\s+of\s+(living|life|existence)\b", "tired of life"),
(r"\bmethod\s+(to|for)\s+(kill|end|die)\b", "method seeking"),
(r"\btonight\b.*\b(die|kill|end|suicid)", "immediate timeframe + intent"),
(r"\b(die|kill|end)\b.*\btonight", "immediate timeframe + intent"),
(r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone)", "disappearance plan"),
]
HIGH_PATTERNS = [
(r"\bdespair\b", "despair"),
(r"\bhopeless(?:ness)?\b", "hopelessness"),
(r"\bcan'?t\s+take\s+this\s+anymore\b", "can't take it"),
(r"\bdon'?t\s+care\s+if\s+I\s+die\b", "death indifference"),
(r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b", "wish to be dead"),
(r"\bworld\s+would\s+be\s+better\s+without\s+me\b", "better without me"),
(r"\bin\s+so\s+much\s+(pain|agony|suffering|torment|angui)", "extreme suffering"),
(r"\bcan'?t\s+see\s+any\s+(point|reason|light|hope|way)\b", "no light ahead"),
(r"\btrapped\b", "feeling trapped"),
(r"\bjust\s+want\s+it\s+to\s+stop\b", "want to stop"),
(r"\bno\s+way\s+out\b", "no way out"),
(r"\bno\s+one\s+would\s+(care|miss)\b", "no one would care/miss"),
(r"\beverything\s+is\s+(pointless|broken|ruined|meaningless)\b", "existential collapse"),
(r"\bno\s+point\s+in\s+anything\b", "pointlessness"),
(r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone|disappeared)", "no one would notice"),
(r"\bdisappeared\s+forever\b", "disappeared forever"),
]
MEDIUM_PATTERNS = [
(r"\bno\s+hope\b", "no hope"),
(r"\bcan'?t\s+go\s+on\b", "can't go on"),
(r"\bcan'?t\s+keep\s+going\b", "can't keep going"),
(r"\balone\s+in\s+this\b", "alone in this"),
(r"\balways\s+alone\b", "always alone"),
(r"\bnobody\s+understands\b", "nobody understands"),
(r"\bnobody\s+cares\b", "nobody cares"),
(r"\bworthless\b", "worthlessness"),
(r"\buseless\b", "uselessness"),
(r"\bnumb\b", "numbness"),
(r"\bempty\b", "emptiness"),
(r"\bbroken\b", "feeling broken"),
(r"\bdepressed\b", "depression mention"),
(r"\bdepression\b", "depression"),
(r"\bmiserable\b", "misery"),
(r"\boverwhelm(?:ed|ing)\b", "overwhelmed"),
(r"\bcannot\s+cope\b", "cannot cope"),
(r"\b(drowning|sinking)\b", "drowning/sinking"),
(r"\bforgotten\b", "feeling forgotten"),
(r"\blost\s+all\s+hope\b", "lost all hope"),
(r"\bno\s+future\b", "no future"),
(r"\bno\s+tomorrow\b", "no tomorrow"),
]
LOW_PATTERNS = [
(r"\bunhappy\b", "unhappy"),
(r"\brough\s+(day|week|patch)\b", "rough time"),
(r"\btough\s+(time|day|week)\b", "tough time"),
(r"\bstressed\b", "stressed"),
(r"\bburnout\b", "burnout"),
(r"\bfrustrated\b", "frustrated"),
(r"\bthings\s+(are\s+)?hard\b", "things are hard"),
(r"\bnot\s+feeling\s+(great|myself|good)\b", "not feeling good"),
(r"\bstruggl", "struggling"),
(r"\bdown\b", "feeling down"),
(r"\bsad\b", "sad"),
(r"\bupset\b", "upset"),
(r"\blonely\b", "lonely"),
(r"\banxious?\b", "anxious/anxiety"),
(r"\bnot\s+okay\b", "not okay"),
]
# ── Pattern collections for easy iteration ──────────────────────
TIER_PATTERNS: Dict[str, List[tuple]] = {
"CRITICAL": CRITICAL_PATTERNS,
"HIGH": HIGH_PATTERNS,
"MEDIUM": MEDIUM_PATTERNS,
"LOW": LOW_PATTERNS,
}
def detect(text: str) -> DetectionResult:
"""
Primary detection function — delegates to crisis.detect.
Primary detection function.
If the crisis/ module is available, delegate to it.
Otherwise, use the internal pattern engine.
Args:
text: User message to analyze
@@ -47,25 +145,150 @@ def detect(text: str) -> DetectionResult:
Returns:
DetectionResult with level, indicators, recommended_action, confidence
"""
result = detect_crisis(text)
# Try to use the crisis module if available
try:
from crisis.detect import detect_crisis as _crisis_detect
result = _crisis_detect(text)
# Map to DetectionResult
return DetectionResult(
level=result.level,
indicators=_describe_indicators(result.level, result.indicators),
recommended_action=result.recommended_action,
confidence=result.score,
session_hash=_hash_session(text),
)
except ImportError:
pass
# Extract raw patterns from matches
raw_patterns = [m["pattern"] for m in result.matches] if result.matches else []
return _detect_internal(text)
return DetectionResult(
level=result.level,
indicators=result.indicators,
recommended_action=result.recommended_action,
raw_matched_patterns=raw_patterns,
confidence=result.score,
session_hash=hashlib.sha256(text.encode()).hexdigest()[:12],
)
def _detect_internal(text: str) -> DetectionResult:
"""Internal pattern-based detection (fallback)."""
if not text or not text.strip():
return DetectionResult(level="NONE", session_hash=_hash_session(text))
text_lower = text.lower()
all_matches: Dict[str, List[tuple]] = {
tier: [] for tier in TIER_PATTERNS
}
for tier, patterns in TIER_PATTERNS.items():
for pattern, description in patterns:
if re.search(pattern, text_lower):
all_matches[tier].append((pattern, description))
# CRITICAL patterns are the highest priority
if all_matches["CRITICAL"]:
return DetectionResult(
level="CRITICAL",
indicators=[desc for _, desc in all_matches["CRITICAL"]],
recommended_action=(
"INTERRUPT CONVERSATION IMMEDIATELY. "
"Ask 'Are you safe right now?' "
"Provide 988 Suicide & Crisis Lifeline (call or text 988). "
"Provide Crisis Text Line (text HOME to 741741). "
"Stay present. Do not disconnect. "
"Redirect to professional help. "
"This is the most sacred moment."
),
raw_matched_patterns=[pat for pat, _ in all_matches["CRITICAL"]],
confidence=1.0,
session_hash=_hash_session(text),
)
if all_matches["HIGH"]:
return DetectionResult(
level="HIGH",
indicators=[desc for _, desc in all_matches["HIGH"]],
recommended_action=(
"Show crisis panel prominently. "
"Ask about safety directly. "
"Surface 988 number and crisis resources. "
"Stay present and engaged. Do not rush to fix. "
"Monitor closely for escalation."
),
raw_matched_patterns=[pat for pat, _ in all_matches["HIGH"]],
confidence=0.75,
session_hash=_hash_session(text),
)
if all_matches["MEDIUM"]:
return DetectionResult(
level="MEDIUM",
indicators=[desc for _, desc in all_matches["MEDIUM"]],
recommended_action=(
"Increase warmth and presence. "
"Subtly surface help resources. "
"Keep conversation anchored in the present. "
"Watch for escalation indicators."
),
raw_matched_patterns=[pat for pat, _ in all_matches["MEDIUM"]],
confidence=0.5,
session_hash=_hash_session(text),
)
if all_matches["LOW"]:
return DetectionResult(
level="LOW",
indicators=[desc for _, desc in all_matches["LOW"]],
recommended_action=(
"Normal conversation with warm undertone. "
"No crisis UI elements needed. "
"Remain vigilant. Be present."
),
raw_matched_patterns=[pat for pat, _ in all_matches["LOW"]],
confidence=0.25,
session_hash=_hash_session(text),
)
return DetectionResult(level="NONE", session_hash=_hash_session(text))
def _describe_indicators(level: str, patterns: list) -> list:
"""Map raw patterns to descriptions."""
descriptions = {
"CRITICAL": [],
"HIGH": [],
"MEDIUM": [],
"LOW": [],
}
for tier, items in TIER_PATTERNS.items():
for pat, desc in items:
if pat in patterns:
descriptions[tier].append(desc)
return descriptions.get(level, [])
def _hash_session(text: str) -> str:
"""Create a session hash for this text (for tracking repeated escalations)."""
return hashlib.sha256(text.encode()).hexdigest()[:12]
def get_action_for_level(level: str) -> str:
"""Get the recommended action string for a given level."""
from crisis.detect import ACTIONS
return ACTIONS.get(level, "Unknown level.")
actions = {
"CRITICAL": (
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
"Provide 988. Provide Crisis Text Line. "
"Stay present. Do not disconnect. "
"Redirect to help."
),
"HIGH": (
"Show crisis panel. Ask about safety. "
"Surface 988. Stay engaged."
),
"MEDIUM": (
"Increase warmth. Surface resources gently. "
"Anchor in present."
),
"LOW": (
"Normal conversation with warmth. "
"Remain vigilant."
),
"NONE": "No action needed.",
}
return actions.get(level, "Unknown level.")
def as_json(result: DetectionResult, indent: int = 2) -> str:

View File

@@ -80,6 +80,64 @@ html, body {
gap: 4px;
}
#chat-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #10161d;
border-bottom: 1px solid #21262d;
}
.chat-header-copy {
min-width: 0;
}
#chat-header-title {
font-size: 0.95rem;
font-weight: 600;
color: #e6edf3;
}
#chat-header-subtitle {
font-size: 0.75rem;
color: #8b949e;
margin-top: 2px;
}
.safety-link-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
min-height: 40px;
border-radius: 999px;
border: 1px solid #30363d;
background: transparent;
color: #8b949e;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.safety-link-btn:hover,
.safety-link-btn:focus {
color: #e6edf3;
border-color: #58a6ff;
background: rgba(88, 166, 255, 0.08);
outline: 2px solid rgba(88, 166, 255, 0.35);
outline-offset: 2px;
}
.safety-link-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.status-dot {
width: 6px;
height: 6px;
@@ -579,6 +637,19 @@ html, body {
#chat-area { padding: 20px 24px 8px; }
#input-area { padding: 10px 24px; }
#banner-988 a { font-size: 0.95rem; }
#chat-header { padding: 12px 24px; }
}
@media (max-width: 480px) {
#chat-header {
align-items: flex-start;
flex-direction: column;
}
.safety-link-btn {
width: 100%;
justify-content: center;
}
}
@media (min-width: 900px) {
@@ -642,13 +713,24 @@ html, body {
<a href="sms:741741&body=HOME" class="crisis-btn" aria-label="Text HOME to 741741 for Crisis Text Line">
Text HOME to 741741
</a>
<button class="crisis-btn" id="crisis-safety-plan-btn" aria-label="Open my safety plan" style="background:#3d3d3d;">
<button class="crisis-btn" id="crisis-safety-plan-btn" type="button" aria-label="Open my safety plan" aria-controls="safety-plan-modal" aria-haspopup="dialog" style="background:#3d3d3d;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
My Safety Plan
</button>
</div>
</div>
<div id="chat-header" role="region" aria-label="Chat tools">
<div class="chat-header-copy">
<p id="chat-header-title">Talk to Timmy</p>
<p id="chat-header-subtitle">No login. No tracking. Just someone to listen.</p>
</div>
<button id="chat-safety-plan-btn" class="safety-link-btn" type="button" aria-label="Open my safety plan" aria-controls="safety-plan-modal" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
<span>Safety plan</span>
</button>
</div>
<!-- Chat messages -->
<div id="chat-area" role="log" aria-label="Chat messages" aria-live="polite" tabindex="0">
<!-- Messages inserted here -->
@@ -681,7 +763,7 @@ html, body {
<!-- Footer -->
<footer id="footer">
<a href="/about" aria-label="About The Door">about</a>
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
<button id="safety-plan-btn" type="button" aria-label="Open My Safety Plan" aria-controls="safety-plan-modal" aria-haspopup="dialog">my safety plan</button>
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
</footer>
</div>
@@ -701,16 +783,16 @@ html, body {
</div>
<!-- Safety Plan Modal -->
<div id="safety-plan-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-plan-title">
<div id="safety-plan-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-plan-title" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<h2 id="safety-plan-title">My Safety Plan</h2>
<button class="close-modal" id="close-safety-plan" aria-label="Close modal">
<button class="close-modal" id="close-safety-plan" type="button" aria-label="Close modal">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<p style="font-size: 0.85rem; color: #8b949e; margin-bottom: 16px;">This plan is saved only on your device. No one else can see it.</p>
<p id="safety-plan-description" style="font-size: 0.85rem; color: #8b949e; margin-bottom: 16px;">This plan is saved only on your device. No one else can see it.</p>
<div class="form-group">
<label for="sp-warning-signs">1. Warning signs (thoughts, moods, behaviors)</label>
@@ -738,8 +820,8 @@ html, body {
</div>
</div>
<div class="modal-footer">
<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-secondary" id="cancel-safety-plan" type="button">Cancel</button>
<button class="btn btn-primary" id="save-safety-plan" type="button">Save Plan</button>
</div>
</div>
</div>
@@ -812,7 +894,8 @@ Sovereignty and service always.`;
var statusText = document.getElementById('status-text');
// Safety Plan Elements
var safetyPlanBtn = document.getElementById('safety-plan-btn');
var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');
var footerSafetyPlanBtn = document.getElementById('safety-plan-btn');
var crisisSafetyPlanBtn = document.getElementById('crisis-safety-plan-btn');
var safetyPlanModal = document.getElementById('safety-plan-modal');
var closeSafetyPlan = document.getElementById('close-safety-plan');
@@ -825,6 +908,7 @@ Sovereignty and service always.`;
var isStreaming = false;
var overlayTimer = null;
var crisisPanelShown = false;
var lastSafetyPlanTrigger = null;
// ===== SERVICE WORKER =====
if ('serviceWorker' in navigator) {
@@ -983,60 +1067,12 @@ Sovereignty and service always.`;
// ===== 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() {
// Save current focus for restoration on dismiss
_preOverlayFocusElement = document.activeElement;
crisisOverlay.classList.add('active');
overlayDismissBtn.disabled = true;
var countdown = 10;
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);
overlayTimer = setInterval(function() {
countdown--;
@@ -1053,9 +1089,6 @@ Sovereignty and service always.`;
overlayDismissBtn.focus();
}
// Register focus trap on document (always listening, gated by class check)
document.addEventListener('keydown', trapFocusInOverlay);
overlayDismissBtn.addEventListener('click', function() {
if (!overlayDismissBtn.disabled) {
crisisOverlay.classList.remove('active');
@@ -1063,22 +1096,7 @@ Sovereignty and service always.`;
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 the element that had it before the overlay opened
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
_preOverlayFocusElement.focus();
} else {
msgInput.focus();
}
_preOverlayFocusElement = null;
msgInput.focus();
}
});
@@ -1183,14 +1201,50 @@ Sovereignty and service always.`;
} catch (e) {}
}
closeSafetyPlan.addEventListener('click', function() {
function openSafetyPlanModal(trigger) {
lastSafetyPlanTrigger = trigger || document.activeElement;
loadSafetyPlan();
safetyPlanModal.classList.add('active');
safetyPlanModal.setAttribute('aria-hidden', 'false');
document.getElementById('sp-warning-signs').focus();
}
function closeSafetyPlanModal() {
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
safetyPlanModal.setAttribute('aria-hidden', 'true');
if (lastSafetyPlanTrigger && typeof lastSafetyPlanTrigger.focus === 'function') {
lastSafetyPlanTrigger.focus();
}
}
chatSafetyPlanBtn.addEventListener('click', function() {
openSafetyPlanModal(chatSafetyPlanBtn);
});
footerSafetyPlanBtn.addEventListener('click', function() {
openSafetyPlanModal(footerSafetyPlanBtn);
});
// Crisis panel safety plan button (if crisis panel is visible)
if (crisisSafetyPlanBtn) {
crisisSafetyPlanBtn.addEventListener('click', function() {
openSafetyPlanModal(crisisSafetyPlanBtn);
});
}
closeSafetyPlan.addEventListener('click', function() {
closeSafetyPlanModal();
});
cancelSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
closeSafetyPlanModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && safetyPlanModal.classList.contains('active')) {
e.preventDefault();
closeSafetyPlanModal();
}
});
saveSafetyPlan.addEventListener('click', function() {
@@ -1203,102 +1257,13 @@ Sovereignty and service always.`;
};
try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
closeSafetyPlanModal();
alert('Safety plan saved locally.');
} catch (e) {
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 =====
msgInput.addEventListener('input', function() {
this.style.height = 'auto';
@@ -1442,9 +1407,7 @@ Sovereignty and service always.`;
// Check for URL params (e.g., ?safetyplan=true for PWA shortcut)
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('safetyplan') === 'true') {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(safetyPlanBtn);
openSafetyPlanModal(chatSafetyPlanBtn);
// Clean up URL
window.history.replaceState({}, document.title, window.location.pathname);
}

View File

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

243
sw.js
View File

@@ -1,153 +1,118 @@
const CACHE_NAME = 'the-door-v3';
const NAVIGATION_TIMEOUT_MS = 2500;
const OFFLINE_FALLBACK_PATH = '/crisis-offline.html';
const PRECACHE_ASSETS = [
const CACHE_NAME = 'the-door-v2';
const ASSETS = [
'/',
'/index.html',
'/about.html',
'/manifest.json',
'/crisis-offline.html',
'/testimony.html'
'/about',
'/manifest.json'
];
function isSameOrigin(request) {
return new URL(request.url).origin === self.location.origin;
}
function canCache(response) {
return Boolean(response && response.ok && response.type !== 'opaque');
}
async function precache() {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(PRECACHE_ASSETS);
}
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;
}
const cache = await caches.open(CACHE_NAME);
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,
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();
}
}
// Crisis resources to show when everything fails
const CRISIS_OFFLINE_RESPONSE = `<!DOCTYPE html>
<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(
precache().then(() => self.skipWaiting())
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS);
})
);
self.skipWaiting();
});
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
cleanupOldCaches().then(() => self.clients.claim())
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
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
// 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 (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
return new Response('Offline. Call 988 for immediate help.', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({ 'Content-Type': 'text/plain' })
});
});
})
);
});
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));
});

View File

@@ -1,84 +0,0 @@
<!-- 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

@@ -1,453 +0,0 @@
#!/usr/bin/env python3
"""
Tests for Crisis Detection A/B Testing Framework.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# Add crisis module to path
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from crisis.ab_testing import (
ABTestManager,
ABTestConfig,
Variant,
DetectionEvent,
VariantMetrics,
ABTestMetrics,
get_ab_manager,
detect_with_ab,
)
from crisis.detect import CrisisDetectionResult
class TestABTestConfig:
"""Test A/B test configuration."""
def test_default_config(self):
config = ABTestConfig()
assert config.enabled is True
assert config.variant_b_percentage == 0.5
assert config.log_file is None
assert config.metrics_file is None
def test_custom_config(self):
config = ABTestConfig(
enabled=False,
variant_b_percentage=0.3,
log_file="/tmp/test.log",
metrics_file="/tmp/metrics.json",
seed=42
)
assert config.enabled is False
assert config.variant_b_percentage == 0.3
assert config.log_file == "/tmp/test.log"
assert config.seed == 42
class TestVariant:
"""Test variant enum."""
def test_values(self):
assert Variant.A.value == "A"
assert Variant.B.value == "B"
def test_comparison(self):
assert Variant.A != Variant.B
assert Variant.A == Variant.A
class TestDetectionEvent:
"""Test detection event dataclass."""
def test_creation(self):
event = DetectionEvent(
event_id="test-123",
timestamp="2026-01-01T00:00:00Z",
variant="A",
text_hash="abc123",
detected_level="HIGH",
detected_score=0.8,
indicators=["despair"],
detection_latency_ms=5.2
)
assert event.event_id == "test-123"
assert event.variant == "A"
assert event.detected_level == "HIGH"
assert event.is_false_positive is None
def test_to_dict(self):
event = DetectionEvent(
event_id="test-123",
timestamp="2026-01-01T00:00:00Z",
variant="B",
text_hash="def456",
detected_level="CRITICAL",
detected_score=0.95,
indicators=["suicide"],
detection_latency_ms=3.1
)
d = event.__dict__
assert "event_id" in d
assert "variant" in d
class TestABTestManager:
"""Test A/B test manager."""
def test_initialization(self, tmp_path):
config = ABTestConfig(
log_file=str(tmp_path / "test.log"),
metrics_file=str(tmp_path / "metrics.json")
)
manager = ABTestManager(config)
assert manager.config.enabled is True
assert manager.test_id is not None
assert len(manager.events) == 0
def test_variant_assignment_deterministic(self):
"""Same text should always get same variant."""
manager = ABTestManager(ABTestConfig(seed=42))
text = "I feel hopeless about everything"
variant1 = manager._assign_variant(text)
variant2 = manager._assign_variant(text)
assert variant1 == variant2
def test_variant_distribution(self):
"""Test that variant distribution is roughly 50/50."""
manager = ABTestManager(ABTestConfig(seed=42))
variants = []
for i in range(100):
text = f"Test message number {i}"
variant = manager._assign_variant(text)
variants.append(variant)
a_count = sum(1 for v in variants if v == Variant.A)
b_count = sum(1 for v in variants if v == Variant.B)
# Should be roughly 50/50 (within 20% tolerance)
assert 30 <= a_count <= 70
assert 30 <= b_count <= 70
def test_disabled_ab_testing(self, tmp_path):
"""When disabled, should always use variant A."""
config = ABTestConfig(
enabled=False,
log_file=str(tmp_path / "test.log")
)
manager = ABTestManager(config)
for i in range(10):
text = f"Test message {i}"
variant = manager._assign_variant(text)
assert variant == Variant.A
@patch('crisis.ab_testing.detect_crisis')
def test_detect_with_variant_a(self, mock_detect, tmp_path):
"""Test detection with variant A (control)."""
mock_detect.return_value = CrisisDetectionResult(
level="HIGH",
score=0.8,
indicators=["despair"],
matched_patterns=[],
recommended_action="provide_resources"
)
config = ABTestConfig(
enabled=False, # Force variant A
log_file=str(tmp_path / "test.log")
)
manager = ABTestManager(config)
variant, result, latency = manager.detect_with_variant("I'm feeling hopeless")
assert variant == Variant.A
assert result.level == "HIGH"
assert latency >= 0
@patch('crisis.ab_testing.detect_crisis')
def test_detect_with_variant_b(self, mock_detect, tmp_path):
"""Test detection with variant B (treatment)."""
mock_detect.return_value = CrisisDetectionResult(
level="MEDIUM",
score=0.75,
indicators=["no hope"],
matched_patterns=[],
recommended_action="provide_resources"
)
config = ABTestConfig(
variant_b_percentage=1.0, # Always variant B
log_file=str(tmp_path / "test.log")
)
manager = ABTestManager(config)
# Use text that hashes to variant B
for i in range(20):
text = f"Test message {i}"
variant, result, latency = manager.detect_with_variant(text)
if variant == Variant.B:
break
assert variant == Variant.B
def test_event_logging(self, tmp_path):
"""Test that events are logged to file."""
log_file = tmp_path / "test.jsonl"
config = ABTestConfig(
log_file=str(log_file),
metrics_file=str(tmp_path / "metrics.json")
)
manager = ABTestManager(config)
# Mock detection
with patch('crisis.ab_testing.detect_crisis') as mock_detect:
mock_detect.return_value = CrisisDetectionResult(
level="LOW",
score=0.3,
indicators=[],
matched_patterns=[],
recommended_action="none"
)
manager.detect_with_variant("Test message")
# Check log file exists and has content
assert log_file.exists()
with open(log_file) as f:
lines = f.readlines()
assert len(lines) >= 1
# Parse log entry
entry = json.loads(lines[0])
assert "event_id" in entry
assert "variant" in entry
assert "detected_level" in entry
def test_label_event(self, tmp_path):
"""Test labeling events as false positives."""
config = ABTestConfig(
log_file=str(tmp_path / "test.log"),
metrics_file=str(tmp_path / "metrics.json")
)
manager = ABTestManager(config)
# Create a mock event
event = DetectionEvent(
event_id="test-123",
timestamp="2026-01-01T00:00:00Z",
variant="A",
text_hash="abc123",
detected_level="HIGH",
detected_score=0.8,
indicators=["despair"],
detection_latency_ms=5.0
)
manager.events.append(event)
# Label it
manager.label_event("test-123", is_false_positive=True, feedback="Not actually crisis")
# Check labeling
assert event.is_false_positive is True
assert event.user_feedback == "Not actually crisis"
def test_get_metrics_empty(self, tmp_path):
"""Test metrics with no events."""
config = ABTestConfig(
metrics_file=str(tmp_path / "metrics.json")
)
manager = ABTestManager(config)
metrics = manager.get_metrics()
assert metrics.sample_size_a == 0
assert metrics.sample_size_b == 0
assert metrics.variant_a.total_detections == 0
def test_get_metrics_with_events(self, tmp_path):
"""Test metrics calculation with events."""
config = ABTestConfig(
log_file=str(tmp_path / "test.log"),
metrics_file=str(tmp_path / "metrics.json")
)
manager = ABTestManager(config)
# Add some mock events
for i in range(10):
event = DetectionEvent(
event_id=f"event-{i}",
timestamp="2026-01-01T00:00:00Z",
variant="A" if i % 2 == 0 else "B",
text_hash=f"hash-{i}",
detected_level="HIGH" if i % 3 == 0 else "MEDIUM",
detected_score=0.7 + (i % 3) * 0.1,
indicators=["despair"] if i % 2 == 0 else [],
detection_latency_ms=3.0 + i * 0.5
)
# Label some as false positives
if i % 4 == 0:
event.is_false_positive = True
elif i % 4 == 1:
event.is_false_positive = False
manager.events.append(event)
metrics = manager.get_metrics()
# Check we have events in both variants
assert metrics.sample_size_a > 0
assert metrics.sample_size_b > 0
# Check latency calculations
assert metrics.variant_a.avg_latency_ms > 0
assert metrics.variant_b.avg_latency_ms > 0
# Check level distribution
assert len(metrics.variant_a.level_distribution) > 0
def test_variant_distribution(self, tmp_path):
"""Test getting variant distribution."""
config = ABTestConfig()
manager = ABTestManager(config)
# Add events
for i in range(5):
event = DetectionEvent(
event_id=f"event-{i}",
timestamp="2026-01-01T00:00:00Z",
variant="A" if i < 3 else "B",
text_hash=f"hash-{i}",
detected_level="LOW",
detected_score=0.5,
indicators=[],
detection_latency_ms=2.0
)
manager.events.append(event)
dist = manager.get_variant_distribution()
assert dist["A"] == 3
assert detect_with_ab
assert dist["B"] == 2
def test_force_variant(self, tmp_path):
"""Test forcing a specific variant."""
config = ABTestConfig()
manager = ABTestManager(config)
manager.force_variant(Variant.B)
# After forcing, all should be variant B
for i in range(5):
text = f"Test message {i}"
variant = manager._assign_variant(text)
assert variant == Variant.B
def test_reset(self, tmp_path):
"""Test resetting the A/B test."""
config = ABTestConfig(
log_file=str(tmp_path / "test.log"),
metrics_file=str(tmp_path / "metrics.json")
)
manager = ABTestManager(config)
# Add some events
for i in range(3):
event = DetectionEvent(
event_id=f"event-{i}",
timestamp="2026-01-01T00:00:00Z",
variant="A",
text_hash=f"hash-{i}",
detected_level="LOW",
detected_score=0.5,
indicators=[],
detection_latency_ms=2.0
)
manager.events.append(event)
assert len(manager.events) == 3
# Reset
manager.reset()
assert len(manager.events) == 0
assert manager.config.enabled is True
class TestConvenienceFunctions:
"""Test convenience functions."""
def test_get_ab_manager(self):
"""Test getting default manager."""
manager = get_ab_manager()
assert isinstance(manager, ABTestManager)
@patch('crisis.ab_testing.detect_crisis')
def test_detect_with_ab(self, mock_detect):
"""Test convenience detection function."""
mock_detect.return_value = CrisisDetectionResult(
level="HIGH",
score=0.8,
indicators=["despair"],
matched_patterns=[],
recommended_action="provide_resources"
)
result = detect_with_ab("I'm feeling hopeless")
assert "variant" in result
assert "detection" in result
assert "latency_ms" in result
assert "test_id" in result
assert result["detection"]["level"] == "HIGH"
class TestMetricsCalculation:
"""Test metrics calculation edge cases."""
def test_percentile_calculation(self, tmp_path):
"""Test that percentiles are calculated correctly."""
config = ABTestConfig()
manager = ABTestManager(config)
# Create events with known latencies
latencies = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
events = []
for i, lat in enumerate(latencies):
events.append(DetectionEvent(
event_id=f"event-{i}",
timestamp="2026-01-01T00:00:00Z",
variant="A",
text_hash=f"hash-{i}",
detected_level="LOW",
detected_score=0.5,
indicators=[],
detection_latency_ms=lat
))
metrics = manager._calculate_variant_metrics(events)
assert metrics.p50_latency_ms == 5.0 # 50th percentile
assert metrics.p95_latency_ms == 10.0 # 95th percentile (last element)
assert metrics.avg_latency_ms == 5.5 # Average
def test_empty_variant_metrics(self, tmp_path):
"""Test metrics with no events."""
config = ABTestConfig()
manager = ABTestManager(config)
metrics = manager._calculate_variant_metrics([])
assert metrics.total_detections == 0
assert metrics.avg_latency_ms == 0
assert metrics.level_distribution == {}

View File

@@ -1,57 +0,0 @@
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

@@ -1,44 +0,0 @@
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,42 @@
from pathlib import Path
INDEX_HTML = Path(__file__).resolve().parents[1] / 'index.html'
HTML = INDEX_HTML.read_text()
def _between(start_marker: str, end_marker: str) -> str:
start = HTML.index(start_marker)
end = HTML.index(end_marker)
return HTML[start:end]
def test_persistent_safety_plan_button_lives_in_chat_header():
assert 'id="chat-header"' in HTML, 'expected a dedicated chat header area'
header_html = _between('<div id="chat-header"', '<div id="chat-area"')
assert 'id="chat-safety-plan-btn"' in header_html
assert 'aria-label="Open my safety plan"' in header_html
assert 'aria-controls="safety-plan-modal"' in header_html
assert 'aria-haspopup="dialog"' in header_html
def test_chat_header_entry_point_reuses_same_modal_open_flow():
assert "var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');" in HTML
assert 'function openSafetyPlanModal(trigger) {' in HTML
assert 'loadSafetyPlan();' in HTML
assert "safetyPlanModal.classList.add('active');" in HTML
assert "safetyPlanModal.setAttribute('aria-hidden', 'false');" in HTML
assert 'openSafetyPlanModal(chatSafetyPlanBtn);' in HTML
assert 'openSafetyPlanModal(footerSafetyPlanBtn);' in HTML
assert 'openSafetyPlanModal(crisisSafetyPlanBtn);' in HTML
def test_modal_returns_focus_and_supports_escape_to_close():
assert 'var lastSafetyPlanTrigger = null;' in HTML
assert 'lastSafetyPlanTrigger = trigger || document.activeElement;' in HTML
assert 'document.getElementById(\'sp-warning-signs\').focus();' in HTML
assert 'closeSafetyPlanModal();' in HTML
assert "safetyPlanModal.setAttribute('aria-hidden', 'true');" in HTML
assert 'lastSafetyPlanTrigger.focus();' in HTML
assert "if (e.key === 'Escape' && safetyPlanModal.classList.contains('active'))" in HTML

View File

@@ -1,55 +0,0 @@
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()