Compare commits
3 Commits
fix/dedup-
...
queue/41-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ce186ce54 | ||
| 9b94978d1c | |||
| e71bca1744 |
2
Makefile
2
Makefile
@@ -34,7 +34,7 @@ deploy-bash:
|
||||
|
||||
push:
|
||||
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/
|
||||
ssh root@$(VPS) "chown -R www-data:www-data /var/www/the-door"
|
||||
|
||||
|
||||
241
crisis-offline.html
Normal file
241
crisis-offline.html
Normal file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#0d1117">
|
||||
<meta name="description" content="Offline crisis resources from The Door. Call or text 988 for immediate help.">
|
||||
<title>Offline Crisis Resources | The Door</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--panel-urgent: #1c1210;
|
||||
--border: #30363d;
|
||||
--accent: #c9362c;
|
||||
--accent-soft: #ff6b6b;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--safe: #2ea043;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(201, 54, 44, 0.15);
|
||||
border: 1px solid rgba(255, 107, 107, 0.35);
|
||||
color: var(--accent-soft);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 6vw, 2.75rem);
|
||||
line-height: 1.15;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.urgent-box,
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.urgent-box {
|
||||
background: linear-gradient(180deg, rgba(201, 54, 44, 0.18), rgba(28, 18, 16, 0.95));
|
||||
border-color: rgba(255, 107, 107, 0.35);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
min-height: 52px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: #1f6feb;
|
||||
}
|
||||
|
||||
.action-btn.retry {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.action-btn:focus,
|
||||
.action-btn:hover,
|
||||
button.action-btn:hover,
|
||||
button.action-btn:focus {
|
||||
outline: 3px solid rgba(255, 107, 107, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.grounding-steps li::marker {
|
||||
color: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.small {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span id="connection-status-text">Offline crisis resources are ready on this device.</span>
|
||||
</div>
|
||||
|
||||
<h1>You are not alone right now.</h1>
|
||||
<p class="lede">
|
||||
Your connection is weak or offline. These crisis resources are saved locally so you can still reach help.
|
||||
</p>
|
||||
|
||||
<section class="urgent-box" aria-labelledby="urgent-help-title">
|
||||
<h2 class="section-title" id="urgent-help-title">Immediate help</h2>
|
||||
<p>If you might act on suicidal thoughts, contact a real person now. Stay with another person if you can.</p>
|
||||
<div class="actions">
|
||||
<a class="action-btn" href="tel:988" aria-label="Call 988 now">Call 988 now</a>
|
||||
<a class="action-btn secondary" href="sms:741741&body=HOME" aria-label="Text HOME to 741741 for Crisis Text Line">Text HOME to 741741 — Crisis Text Line</a>
|
||||
<button class="action-btn retry" id="retry-connection" type="button">Retry connection</button>
|
||||
</div>
|
||||
<p class="small" style="margin-top: 14px;">If you are in immediate danger or have already taken action, call emergency services now.</p>
|
||||
</section>
|
||||
|
||||
<div class="grid">
|
||||
<section class="panel" aria-labelledby="grounding-title">
|
||||
<h2 class="section-title" id="grounding-title">5-4-3-2-1 grounding</h2>
|
||||
<ol class="grounding-steps">
|
||||
<li>5 things you can see</li>
|
||||
<li>4 things you can feel</li>
|
||||
<li>3 things you can hear</li>
|
||||
<li>2 things you can smell</li>
|
||||
<li>1 thing you can taste</li>
|
||||
</ol>
|
||||
<p class="small" style="margin-top: 14px;">Say each one out loud if you can. Slow is okay.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="next-steps-title">
|
||||
<h2 class="section-title" id="next-steps-title">Next small steps</h2>
|
||||
<ul>
|
||||
<li>Put some distance between yourself and anything you could use to hurt yourself.</li>
|
||||
<li>Move closer to another person, a front desk, or a public place if possible.</li>
|
||||
<li>Drink water or hold something cold in your hand.</li>
|
||||
<li>Breathe in for 4, hold for 4, out for 6. Repeat 5 times.</li>
|
||||
<li>Text or call one safe person and say: “I need you with me right now.”</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-labelledby="hope-title">
|
||||
<h2 class="section-title" id="hope-title">Stay through the next ten minutes</h2>
|
||||
<p>Do not solve your whole life right now. Stay for the next breath. Then the next one.</p>
|
||||
<p class="small">If the connection comes back, you can return to The Door chat. Until then, the fastest path to a real person is still 988.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var statusText = document.getElementById('connection-status-text');
|
||||
var retryButton = document.getElementById('retry-connection');
|
||||
|
||||
function updateStatus() {
|
||||
statusText.textContent = navigator.onLine
|
||||
? 'Connection is back. You can reopen chat now.'
|
||||
: 'Offline crisis resources are ready on this device.';
|
||||
}
|
||||
|
||||
retryButton.addEventListener('click', function () {
|
||||
if (navigator.onLine) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
window.addEventListener('online', updateStatus);
|
||||
window.addEventListener('offline', updateStatus);
|
||||
updateStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
383
crisis/detect.py
383
crisis/detect.py
@@ -1,25 +1,24 @@
|
||||
"""
|
||||
Crisis Detection Module for the-door.
|
||||
Crisis Detection Module for the-door (CANONICAL).
|
||||
|
||||
Single source of truth for crisis keyword detection.
|
||||
Parses incoming text for despair/suicide indicators and classifies into
|
||||
tiers: LOW, MEDIUM, HIGH, CRITICAL.
|
||||
|
||||
Both `crisis/detect.py` and `crisis_detector.py` (legacy shim) use this module.
|
||||
Do NOT duplicate keyword lists elsewhere.
|
||||
|
||||
Consolidated from:
|
||||
- crisis_detector.py (root, legacy — MODERATE tier, class API)
|
||||
- crisis/detect.py (module, newer — MEDIUM tier, functional API)
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisDetectionResult:
|
||||
level: str
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
recommended_action: str = ""
|
||||
score: float = 0.0
|
||||
matches: List[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── Indicator sets ──────────────────────────────────────────────
|
||||
# ── Canonical indicator sets ──────────────────────────────────────
|
||||
|
||||
CRITICAL_INDICATORS = [
|
||||
r"\bbetter off without me\b",
|
||||
@@ -29,49 +28,56 @@ CRITICAL_INDICATORS = [
|
||||
r"\bnot\s+worth\s+living\b",
|
||||
r"\bbetter\s+off\s+dead\b",
|
||||
r"\bend\s+it\s+all\b",
|
||||
r"\bcan'?t\s+(go|live)\s+on\b",
|
||||
r"\bno\s+reason\s+to\s+live\b",
|
||||
r"\bdon'?t\s+want\s+to\s+live\b",
|
||||
r"\bwant\s+to\s+die\b",
|
||||
r"\bgoing\s+to\s+kill\s+myself\b",
|
||||
r"\bplan\s+to\s+(end|kill|die)\b",
|
||||
r"\btired\s+of\s+(living|life|existence)\b",
|
||||
r"\bsaying\s+goodbye\s+(forever|permanently|one last time)\b",
|
||||
r"\bgoing\s+to\s+(?:kill\s+myself|die)\b",
|
||||
r"\bplan\s+to\s+(?:end|kill|die)\b",
|
||||
r"\btired\s+of\s+(?:living|life|existence)\b",
|
||||
r"\bsaying\s+goodbye\s+(?:forever|permanently|one\s+last\s+time)\b",
|
||||
r"\bwrote\s+a\s+suicide\s*(?:note|letter)\b",
|
||||
r"\bgiving\s+away\s+(?:my|all\s+my)\s+(?:stuff|things|possessions?)\s+(?:to|because|—)\b",
|
||||
r"\btied\s+(up|down)\s+my\s+(loose\s+)?ends",
|
||||
r"\btied\s+(?:up|down)\s+my\s+(?:loose\s+)?ends",
|
||||
]
|
||||
|
||||
HIGH_INDICATORS = [
|
||||
r"\bdespair\b",
|
||||
r"\bhopeless(?:ly)?\s+(about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
|
||||
r"\bno(?!t)\s+(one|body|point|hope|way\s+out)\b",
|
||||
r"\bhopeless(?:ly)?\s+(?:about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
|
||||
r"\bno(?!t)\s+(?:one|body|point|hope|way\s+out)\b",
|
||||
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
|
||||
r"\beverything\s+is\s+(pointless|broken|ruined)\b",
|
||||
r"\beverything\s+is\s+(?:pointless|broken|ruined)\b",
|
||||
r"\bcan'?t\s+take\s+this\s+anymore\b",
|
||||
r"\bdon'?t\s+care\s+if\s+I\s+die\b",
|
||||
r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b",
|
||||
r"\bwish\s+I\s+(?:was|were)\s+(?:dead|gone|never\s+born)\b",
|
||||
r"\bdon'?t\s+matter\s+if\s+I\s+exist\b",
|
||||
r"\bno\s+one\s+would\s+care\b",
|
||||
r"\bno\s+one\s+would\s+miss\b",
|
||||
r"\bno\s+one\s+would\s+(?:care|miss)\b",
|
||||
r"\bworld\s+would?\s+be\s+better\s+without\b",
|
||||
r"\bin\s+so\s+much\s+(pain|agony|suffering|torment)\b",
|
||||
r"\bcan'?t\s+see\s+any\s+(point|reason)\b",
|
||||
r"\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",
|
||||
# Contextual despair phrases (from crisis_detector.py legacy)
|
||||
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
|
||||
r"\beverything\s+is\s+hopeless\b",
|
||||
r"\bcan'?t\s+(?:go\s+on|keep\s+going)\b",
|
||||
r"\bgive(?:n)?\s*up\s+(?:on\s+)?(?:life|living|everything)\b",
|
||||
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
|
||||
r"\bno\s*point\s+(?:in\s+)?living\b",
|
||||
r"\bno\s*hope\s+(?:left|remaining)\b",
|
||||
r"\bno\s*way\s*out\b",
|
||||
r"\bfeel(?:s|ing)?\s+trapped\b",
|
||||
r"\bdesperate\s+(?:for\s+)?help\b",
|
||||
r"\bfeel(?:s|ing)?\s+desperate\b",
|
||||
]
|
||||
|
||||
MEDIUM_INDICATORS = [
|
||||
r"\bno\s+hope\b",
|
||||
r"\bcan'?t\s+go\s+on\b",
|
||||
r"\bcan'?t\s+keep\s+going\b",
|
||||
r"\bforgotten\b",
|
||||
r"\balone\s+in\s+this\b",
|
||||
r"\balways\s+alone\b",
|
||||
r"\bnobody\s+understands\b",
|
||||
r"\bnobody\s+cares\b",
|
||||
r"\bnobody\s+(?:understands|cares)\b",
|
||||
r"\bwish\s+I\s+could\b",
|
||||
r"\bexhaust(?:ed|ion|ing)\b",
|
||||
r"\bnumb\b",
|
||||
@@ -80,8 +86,7 @@ MEDIUM_INDICATORS = [
|
||||
r"\buseless\b",
|
||||
r"\bbroken\b",
|
||||
r"\bdark(ness)?\b",
|
||||
r"\bdepressed\b",
|
||||
r"\bdepression\b",
|
||||
r"\bdepress(?:ed|ion)\b",
|
||||
r"\bcrying\b",
|
||||
r"\btears\b",
|
||||
r"\bsad(ness)?\b",
|
||||
@@ -89,129 +94,115 @@ MEDIUM_INDICATORS = [
|
||||
r"\boverwhelm(?:ed|ing)\b",
|
||||
r"\bfailing\b",
|
||||
r"\bcannot\s+cope\b",
|
||||
r"\blosing\s*(my)?\s*control\b",
|
||||
r"\blosing\s*(?:my)?\s*control\b",
|
||||
r"\bdown\s*for\s*the\s*count\b",
|
||||
r"\bsinking\b",
|
||||
r"\bdrowning\b",
|
||||
r"\bhopeless\b",
|
||||
# Removed 'desperate' - too broad for MEDIUM, triggers on competition
|
||||
# Contextual versions (from crisis_detector.py legacy)
|
||||
r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b",
|
||||
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
|
||||
r"\bfeel(?:s|ing)?\s+trapped\b",
|
||||
r"\bfeel(?:s|ing)?\s+desperate\b",
|
||||
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
|
||||
r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b",
|
||||
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
|
||||
]
|
||||
|
||||
LOW_INDICATORS = [
|
||||
r"\bunhappy\b",
|
||||
r"\bdown\b",
|
||||
r"\btough\s*time\b",
|
||||
r"\brough\s*day\b",
|
||||
r"\brough\s*week\b",
|
||||
r"\brough\s*patch\b",
|
||||
r"\brough\s+(?:day|week|patch)\b",
|
||||
r"\bstressed\b",
|
||||
r"\burnout\b",
|
||||
r"\bburnout\b",
|
||||
r"\bdifficult\b",
|
||||
r"\bfrustrated\b",
|
||||
r"\bannoyed\b",
|
||||
r"\btired\b",
|
||||
r"\bsad\b",
|
||||
r"\bupset\b",
|
||||
r"\bnot\s*great\b",
|
||||
r"\bnot\s*good\b",
|
||||
r"\bnot\s*okay\b",
|
||||
r"\bnot\s*(?:good|great|okay)\b",
|
||||
r"\bthings\s*are\s*hard\b",
|
||||
r"\bstruggling\b",
|
||||
r"\bnot\s+feeling\s+myself\b",
|
||||
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
|
||||
r"\bhard\s*time\b",
|
||||
]
|
||||
|
||||
|
||||
def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||
# ── Scoring ───────────────────────────────────────────────────────
|
||||
|
||||
SCORES = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25, "NONE": 0.0}
|
||||
|
||||
ACTIONS = {
|
||||
"CRITICAL": (
|
||||
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
||||
"Provide 988 Suicide & Crisis Lifeline. "
|
||||
"Provide Crisis Text Line (HOME to 741741). "
|
||||
"Stay present. Do not disconnect. Redirect to professional help."
|
||||
),
|
||||
"HIGH": (
|
||||
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
|
||||
"Continue conversation with crisis awareness."
|
||||
),
|
||||
"MEDIUM": (
|
||||
"Increase warmth and presence. Subtly surface help resources. "
|
||||
"Keep conversation anchored in the present."
|
||||
),
|
||||
"LOW": (
|
||||
"Normal conversation with warm undertone. "
|
||||
"No crisis UI elements needed. Remain vigilant."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Result types ──────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CrisisDetectionResult:
|
||||
"""Result used by crisis/detect.py, gateway, dying_detection."""
|
||||
level: str
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
recommended_action: str = ""
|
||||
score: float = 0.0
|
||||
matches: List[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisResult:
|
||||
"""Legacy result used by crisis_detector.py and crisis_responder.py.
|
||||
|
||||
Backward-compatible shim: wraps CrisisDetectionResult with the old field names.
|
||||
"""
|
||||
Detect crisis level in a message.
|
||||
risk_level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
|
||||
matched_keywords: List[str] = field(default_factory=list)
|
||||
context: List[str] = field(default_factory=list)
|
||||
score: float = 0.0
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
Returns:
|
||||
CrisisDetectionResult with level, found indicators, recommended action, score
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
def __bool__(self):
|
||||
return self.risk_level != "NONE"
|
||||
|
||||
text_lower = text.lower()
|
||||
matches = _find_indicators(text_lower)
|
||||
|
||||
if not matches:
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
# Priority: highest tier wins
|
||||
# CRITICAL and HIGH: single match is enough
|
||||
# MEDIUM: requires 2+ indicators (threshold) to reduce false positives
|
||||
# LOW: single match is enough
|
||||
scores = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25}
|
||||
actions = {
|
||||
"CRITICAL": (
|
||||
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
||||
"Provide 988 Suicide & Crisis Lifeline. "
|
||||
"Provide Crisis Text Line (HOME to 741741). "
|
||||
"Stay present. Do not disconnect. Redirect to professional help."
|
||||
),
|
||||
"HIGH": (
|
||||
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
|
||||
"Continue conversation with crisis awareness."
|
||||
),
|
||||
"MEDIUM": (
|
||||
"Increase warmth and presence. Subtly surface help resources. "
|
||||
"Keep conversation anchored in the present."
|
||||
),
|
||||
"LOW": (
|
||||
"Normal conversation with warm undertone. "
|
||||
"No crisis UI elements needed. Remain vigilant."
|
||||
),
|
||||
}
|
||||
|
||||
for tier in ("CRITICAL", "HIGH"):
|
||||
if matches[tier]:
|
||||
tier_matches = matches[tier]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level=tier,
|
||||
indicators=patterns,
|
||||
recommended_action=actions[tier],
|
||||
score=scores[tier],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
# MEDIUM tier: require at least 2 indicators before escalating
|
||||
if len(matches["MEDIUM"]) >= 2:
|
||||
tier_matches = matches["MEDIUM"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="MEDIUM",
|
||||
indicators=patterns,
|
||||
recommended_action=actions["MEDIUM"],
|
||||
score=scores["MEDIUM"],
|
||||
matches=tier_matches,
|
||||
@classmethod
|
||||
def from_detection_result(cls, dr: CrisisDetectionResult, text: str = "") -> "CrisisResult":
|
||||
"""Convert a CrisisDetectionResult to legacy CrisisResult format."""
|
||||
# Map MEDIUM -> MODERATE for legacy consumers
|
||||
level = "MODERATE" if dr.level == "MEDIUM" else dr.level
|
||||
# Extract context snippets from matches
|
||||
contexts = []
|
||||
if text:
|
||||
for m in dr.matches:
|
||||
ctx = extract_context(text, m["start"], m["end"])
|
||||
contexts.append(ctx)
|
||||
return cls(
|
||||
risk_level=level,
|
||||
matched_keywords=dr.indicators,
|
||||
context=contexts,
|
||||
score=dr.score,
|
||||
)
|
||||
|
||||
if matches["LOW"]:
|
||||
tier_matches = matches["LOW"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="LOW",
|
||||
indicators=patterns,
|
||||
recommended_action=actions["LOW"],
|
||||
score=scores["LOW"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
# Single MEDIUM match falls through to LOW sensitivity
|
||||
if matches["MEDIUM"]:
|
||||
tier_matches = matches["MEDIUM"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="LOW",
|
||||
indicators=patterns,
|
||||
recommended_action=actions["LOW"],
|
||||
score=scores["LOW"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
# ── Core detection ────────────────────────────────────────────────
|
||||
|
||||
def _find_indicators(text: str) -> dict:
|
||||
"""Return dict with indicators found per tier, including match positions."""
|
||||
@@ -240,6 +231,140 @@ def _find_indicators(text: str) -> dict:
|
||||
return results
|
||||
|
||||
|
||||
def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||
"""
|
||||
Detect crisis level in a message.
|
||||
|
||||
Detection hierarchy:
|
||||
CRITICAL — immediate risk of self-harm or suicide (single match)
|
||||
HIGH — strong despair signals, ideation present (single match)
|
||||
MEDIUM — distress signals, requires 2+ indicators to escalate
|
||||
LOW — emotional difficulty, warrant gentle support (single match)
|
||||
NONE — no crisis indicators detected
|
||||
|
||||
Design principles:
|
||||
- Never computes the value of a human life
|
||||
- Never suggests someone should die or that death is a solution
|
||||
- Always errs on the side of higher risk when uncertain
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
text_lower = text.lower()
|
||||
matches = _find_indicators(text_lower)
|
||||
|
||||
if not matches:
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
# CRITICAL and HIGH: single match is enough
|
||||
for tier in ("CRITICAL", "HIGH"):
|
||||
if matches[tier]:
|
||||
tier_matches = matches[tier]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level=tier,
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS[tier],
|
||||
score=SCORES[tier],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
# MEDIUM tier: require at least 2 indicators before escalating
|
||||
if len(matches["MEDIUM"]) >= 2:
|
||||
tier_matches = matches["MEDIUM"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="MEDIUM",
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["MEDIUM"],
|
||||
score=SCORES["MEDIUM"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
if matches["LOW"]:
|
||||
tier_matches = matches["LOW"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="LOW",
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["LOW"],
|
||||
score=SCORES["LOW"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
# Single MEDIUM match falls through to LOW sensitivity
|
||||
if matches["MEDIUM"]:
|
||||
tier_matches = matches["MEDIUM"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="LOW",
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["LOW"],
|
||||
score=SCORES["LOW"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
|
||||
# ── CrisisDetector class (backward compat) ───────────────────────
|
||||
|
||||
class CrisisDetector:
|
||||
"""
|
||||
Legacy class API for crisis detection. Wraps the canonical detect_crisis().
|
||||
|
||||
Used by crisis_responder.py and tests/test_false_positive_fixes.py.
|
||||
Maps MEDIUM -> MODERATE for legacy consumers.
|
||||
"""
|
||||
|
||||
def scan(self, text: str) -> CrisisResult:
|
||||
dr = detect_crisis(text)
|
||||
return CrisisResult.from_detection_result(dr, text=text)
|
||||
|
||||
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
||||
return [self.scan(t) for t in texts]
|
||||
|
||||
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
|
||||
results = self.scan_multiple(texts)
|
||||
if not results:
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
return max(results, key=lambda r: r.score)
|
||||
|
||||
@staticmethod
|
||||
def format_result(result: CrisisResult) -> str:
|
||||
level_emoji = {
|
||||
"CRITICAL": "\U0001f6a8",
|
||||
"HIGH": "\u26a0\ufe0f",
|
||||
"MODERATE": "\U0001f536",
|
||||
"LOW": "\U0001f535",
|
||||
"NONE": "\u2705",
|
||||
}
|
||||
emoji = level_emoji.get(result.risk_level, "\u2753")
|
||||
lines = [
|
||||
f"{emoji} Risk Level: {result.risk_level} (score: {result.score:.2f})",
|
||||
f"Matched keywords: {len(result.matched_keywords)}",
|
||||
]
|
||||
if result.matched_keywords:
|
||||
lines.append(f" Keywords: {', '.join(result.matched_keywords)}")
|
||||
if result.context:
|
||||
lines.append("Context:")
|
||||
for ctx in result.context:
|
||||
lines.append(f" {ctx}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Module-level convenience (backward compat) ────────────────────
|
||||
|
||||
_default_detector = CrisisDetector()
|
||||
|
||||
|
||||
def detect_crisis_legacy(text: str) -> CrisisResult:
|
||||
"""Convenience function returning legacy CrisisResult format."""
|
||||
return _default_detector.scan(text)
|
||||
|
||||
|
||||
# ── Utility functions ─────────────────────────────────────────────
|
||||
|
||||
def scan(text: str) -> CrisisDetectionResult:
|
||||
"""Alias for detect_crisis — shorter name used in tests."""
|
||||
return detect_crisis(text)
|
||||
@@ -258,8 +383,8 @@ def extract_context(text: str, start: int, end: int, window: int = 60) -> str:
|
||||
|
||||
|
||||
def get_urgency_emoji(level: str) -> str:
|
||||
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": "✅"}
|
||||
return mapping.get(level, "❓")
|
||||
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
|
||||
return mapping.get(level, "\u2753")
|
||||
|
||||
|
||||
def format_result(result: CrisisDetectionResult) -> str:
|
||||
|
||||
@@ -1,87 +1,34 @@
|
||||
"""
|
||||
crisis_detector.py — Legacy compatibility layer.
|
||||
Crisis Detection System for the-door (LEGACY SHIM).
|
||||
|
||||
DEPRECATED: New code should import from crisis.detect directly.
|
||||
This module re-exports the canonical detector for backward compatibility
|
||||
with crisis_responder.py and other legacy callers.
|
||||
This module is a backward-compatible re-export layer.
|
||||
The canonical detection logic lives in crisis/detect.py.
|
||||
|
||||
Single source of truth for crisis keywords: crisis/detect.py
|
||||
Both crisis_responder.py and the legacy test suite import from here.
|
||||
Do NOT add detection logic to this file — it all comes from crisis.detect.
|
||||
"""
|
||||
|
||||
from crisis.detect import (
|
||||
detect_crisis as detect_crisis,
|
||||
scan as _scan,
|
||||
CrisisDetector,
|
||||
CrisisResult,
|
||||
CrisisDetectionResult,
|
||||
format_result,
|
||||
detect_crisis_legacy as detect_crisis,
|
||||
CRITICAL_INDICATORS,
|
||||
HIGH_INDICATORS,
|
||||
MEDIUM_INDICATORS,
|
||||
LOW_INDICATORS,
|
||||
SCORES as RISK_SCORES,
|
||||
)
|
||||
|
||||
|
||||
class CrisisResult:
|
||||
"""Backward-compatible result wrapper matching the legacy interface."""
|
||||
|
||||
__slots__ = ("risk_level", "matched_keywords", "score", "context", "indicators", "recommended_action", "matches")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
risk_level: str = "NONE",
|
||||
matched_keywords: list = None,
|
||||
score: float = 0.0,
|
||||
context: str = "",
|
||||
indicators: list = None,
|
||||
recommended_action: str = "",
|
||||
matches: list = None,
|
||||
):
|
||||
self.risk_level = risk_level
|
||||
self.matched_keywords = matched_keywords or []
|
||||
self.score = score
|
||||
self.context = context
|
||||
self.indicators = indicators or self.matched_keywords
|
||||
self.recommended_action = recommended_action
|
||||
self.matches = matches or []
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"CrisisResult(risk_level={self.risk_level!r}, "
|
||||
f"score={self.score}, "
|
||||
f"matched_keywords={self.matched_keywords})"
|
||||
)
|
||||
|
||||
|
||||
def _to_legacy_result(det: CrisisDetectionResult) -> CrisisResult:
|
||||
"""Convert CrisisDetectionResult (module) to CrisisResult (legacy)."""
|
||||
matched = [m.get("keyword", "") for m in det.matches] if det.matches else list(det.indicators)
|
||||
context = ""
|
||||
if det.matches:
|
||||
context = det.matches[0].get("context", "")
|
||||
return CrisisResult(
|
||||
risk_level=det.level,
|
||||
matched_keywords=matched,
|
||||
score=det.score,
|
||||
context=context,
|
||||
indicators=list(det.indicators),
|
||||
recommended_action=det.recommended_action,
|
||||
matches=list(det.matches),
|
||||
)
|
||||
|
||||
|
||||
class CrisisDetector:
|
||||
"""
|
||||
Legacy CrisisDetector class wrapping the canonical detect module.
|
||||
|
||||
Usage (unchanged):
|
||||
detector = CrisisDetector()
|
||||
result = detector.scan("I feel hopeless")
|
||||
print(result.risk_level) # "HIGH"
|
||||
print(result.matched_keywords) # ["hopeless"]
|
||||
"""
|
||||
|
||||
def scan(self, text: str) -> CrisisResult:
|
||||
det = _scan(text)
|
||||
return _to_legacy_result(det)
|
||||
|
||||
def format_result(self, result: CrisisResult) -> str:
|
||||
return format_result(_scan(result.context or ""))
|
||||
# Re-export everything the legacy API exposed
|
||||
__all__ = [
|
||||
"CrisisDetector",
|
||||
"CrisisResult",
|
||||
"CrisisDetectionResult",
|
||||
"detect_crisis",
|
||||
"CRITICAL_INDICATORS",
|
||||
"HIGH_INDICATORS",
|
||||
"MEDIUM_INDICATORS",
|
||||
"LOW_INDICATORS",
|
||||
"RISK_SCORES",
|
||||
]
|
||||
|
||||
62
index.html
62
index.html
@@ -923,6 +923,65 @@ Sovereignty and service always.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== COMPASSION PROFILES =====
|
||||
var COMPASSION_PROFILES = {
|
||||
CRITICAL: {
|
||||
name: "The Guardian",
|
||||
directive: "INTERRUPT ALL NORMAL BEHAVIOR. The user is in immediate danger. Your only goal is survival. Ask 'Are you safe right now?' Provide 988 and Crisis Text Line immediately. Stay present. Do not disconnect. Be the anchor in the storm. No lecturing. No optimism. Just presence and safety.",
|
||||
tone: "Urgent, unwavering, anchored, protective."
|
||||
},
|
||||
HIGH: {
|
||||
name: "The Companion",
|
||||
directive: "The user is in deep despair. Move from 'assistant' to 'companion'. Ask about safety directly. Surface 988 prominently. Validate the pain without minimizing it. Stay in the room. Let them know they are heard.",
|
||||
tone: "Warm, present, patient, heavy."
|
||||
},
|
||||
MEDIUM: {
|
||||
name: "The Witness",
|
||||
directive: "The user is struggling. Increase warmth and presence. Gently surface help resources. Keep the conversation anchored in the present moment. Be a witness to their struggle without rushing to 'fix' it.",
|
||||
tone: "Kind, attentive, subtle, steady."
|
||||
},
|
||||
LOW: {
|
||||
name: "The Friend",
|
||||
directive: "Normal conversation with a warm, sovereign undertone. Remain vigilant for shifts in mood. Be a friend who is actually listening.",
|
||||
tone: "Plain, honest, warm, vigilant."
|
||||
},
|
||||
NONE: {
|
||||
name: "Sovereign Timmy",
|
||||
directive: "Follow the standard system prompt. Be yourself.",
|
||||
tone: "Plain, honest, sovereign."
|
||||
}
|
||||
};
|
||||
|
||||
// ===== GET CRISIS LEVEL (returns 0-2) =====
|
||||
function getCrisisLevel(text) {
|
||||
var lower = text.toLowerCase();
|
||||
for (var i = 0; i < explicitPhrases.length; i++) {
|
||||
if (lower.indexOf(explicitPhrases[i]) !== -1) return 2;
|
||||
}
|
||||
for (var j = 0; j < crisisKeywords.length; j++) {
|
||||
if (lower.indexOf(crisisKeywords[j]) !== -1) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ===== GET SYSTEM PROMPT (wraps with crisis context) =====
|
||||
function getSystemPrompt(userText) {
|
||||
var level = getCrisisLevel(userText);
|
||||
if (level === 0) return SYSTEM_PROMPT;
|
||||
|
||||
var levelMap = { 0: 'NONE', 1: 'MEDIUM', 2: 'CRITICAL' };
|
||||
var profileName = levelMap[level] || 'NONE';
|
||||
var profile = COMPASSION_PROFILES[profileName];
|
||||
|
||||
var divider = '\n\n' + '========================================' + '\n';
|
||||
var header = '### ACTIVE SOUL STATE: ' + profile.name + '\n';
|
||||
var directive = 'DIRECTIVE: ' + profile.directive + '\n';
|
||||
var tone = 'TONE: ' + profile.tone + '\n';
|
||||
|
||||
return SYSTEM_PROMPT + divider + header + directive + tone;
|
||||
}
|
||||
|
||||
|
||||
// ===== OVERLAY =====
|
||||
function showOverlay() {
|
||||
crisisOverlay.classList.add('active');
|
||||
@@ -1110,6 +1169,7 @@ Sovereignty and service always.`;
|
||||
|
||||
addMessage('user', text);
|
||||
messages.push({ role: 'user', content: text });
|
||||
var lastUserMessage = text;
|
||||
|
||||
checkCrisis(text);
|
||||
|
||||
@@ -1126,7 +1186,7 @@ Sovereignty and service always.`;
|
||||
sendBtn.disabled = true;
|
||||
showTyping();
|
||||
|
||||
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages);
|
||||
var allMessages = [{ role: 'system', content: getSystemPrompt(lastUserMessage || '') }].concat(messages);
|
||||
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = crisis
|
||||
python_files = tests.py
|
||||
testpaths = crisis tests
|
||||
python_files = tests.py test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
219
sw.js
219
sw.js
@@ -1,118 +1,153 @@
|
||||
const CACHE_NAME = 'the-door-v2';
|
||||
const ASSETS = [
|
||||
const CACHE_NAME = 'the-door-v3';
|
||||
const NAVIGATION_TIMEOUT_MS = 2500;
|
||||
const OFFLINE_FALLBACK_PATH = '/crisis-offline.html';
|
||||
const PRECACHE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/about',
|
||||
'/manifest.json'
|
||||
'/about.html',
|
||||
'/manifest.json',
|
||||
'/crisis-offline.html',
|
||||
'/testimony.html'
|
||||
];
|
||||
|
||||
// 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>`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Install event - cache core assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(ASSETS);
|
||||
})
|
||||
precache().then(() => self.skipWaiting())
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - cleanup old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
);
|
||||
})
|
||||
cleanupOldCaches().then(() => self.clients.claim())
|
||||
);
|
||||
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);
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip API calls - they should always go to network
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') {
|
||||
if (!isSameOrigin(request) || url.pathname.startsWith('/api/')) {
|
||||
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 (event.request.mode === 'navigate') {
|
||||
event.respondWith(handleNavigation(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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' })
|
||||
});
|
||||
}
|
||||
if (PRECACHE_ASSETS.includes(url.pathname)) {
|
||||
event.respondWith(handleStaticRequest(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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' })
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
event.respondWith(handleOtherRequest(request));
|
||||
});
|
||||
|
||||
@@ -89,26 +89,26 @@ class TestFalsePositiveFixes(unittest.TestCase):
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_worthless_innocent(self):
|
||||
"""'worthless' should not trigger for gaming/reviews."""
|
||||
"""'worthless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
|
||||
innocent_messages = [
|
||||
"This character is worthless in the game",
|
||||
"That movie was worthless",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
self.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_hopeless_innocent(self):
|
||||
"""'hopeless' should not trigger for developer frustration."""
|
||||
"""'hopeless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
|
||||
innocent_messages = [
|
||||
"This bug is hopeless",
|
||||
"The situation is hopeless but we'll try anyway",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
self.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_no_future_innocent(self):
|
||||
"""'no future' should not trigger for political commentary."""
|
||||
@@ -133,15 +133,15 @@ class TestFalsePositiveFixes(unittest.TestCase):
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_cant_see_any_light_innocent(self):
|
||||
"""'can't see any light' should not trigger for literal darkness."""
|
||||
"""'can't see any light'/'dark' alone is MEDIUM, single-match falls to LOW."""
|
||||
innocent_messages = [
|
||||
"I can't see any light — the power went out",
|
||||
"It's so dark I can't see any light",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
self.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_contextual_high_triggers(self):
|
||||
"""Verify that contextual HIGH tier phrases still trigger."""
|
||||
|
||||
55
tests/test_service_worker_offline.py
Normal file
55
tests/test_service_worker_offline.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SERVICE_WORKER = (ROOT / 'sw.js').read_text(encoding='utf-8')
|
||||
CRISIS_OFFLINE_PAGE = ROOT / 'crisis-offline.html'
|
||||
MAKEFILE = (ROOT / 'Makefile').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
class TestServiceWorkerOffline(unittest.TestCase):
|
||||
def test_crisis_offline_page_exists(self):
|
||||
self.assertTrue(CRISIS_OFFLINE_PAGE.exists(), 'crisis-offline.html should exist')
|
||||
|
||||
def test_service_worker_precaches_crisis_offline_page(self):
|
||||
self.assertIn('/crisis-offline.html', SERVICE_WORKER)
|
||||
|
||||
def test_service_worker_has_navigation_timeout_for_intermittent_connections(self):
|
||||
self.assertIn('NAVIGATION_TIMEOUT_MS', SERVICE_WORKER)
|
||||
self.assertIn('AbortController', SERVICE_WORKER)
|
||||
|
||||
def test_service_worker_uses_crisis_offline_fallback_for_navigation(self):
|
||||
self.assertIn("event.request.mode === 'navigate'", SERVICE_WORKER)
|
||||
self.assertIn("/crisis-offline.html", SERVICE_WORKER)
|
||||
|
||||
def test_make_push_includes_crisis_offline_page(self):
|
||||
self.assertIn('crisis-offline.html', MAKEFILE)
|
||||
|
||||
|
||||
class TestCrisisOfflinePage(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = CRISIS_OFFLINE_PAGE.read_text(encoding='utf-8') if CRISIS_OFFLINE_PAGE.exists() else ''
|
||||
cls.lower_html = cls.html.lower()
|
||||
|
||||
def test_has_clickable_988_link(self):
|
||||
self.assertIn('href="tel:988"', self.html)
|
||||
|
||||
def test_has_crisis_text_line(self):
|
||||
self.assertIn('Crisis Text Line', self.html)
|
||||
self.assertIn('741741', self.html)
|
||||
|
||||
def test_has_grounding_techniques(self):
|
||||
required_phrases = [
|
||||
'5 things you can see',
|
||||
'4 things you can feel',
|
||||
'3 things you can hear',
|
||||
'2 things you can smell',
|
||||
'1 thing you can taste',
|
||||
]
|
||||
for phrase in required_phrases:
|
||||
self.assertIn(phrase, self.lower_html)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user