Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
3710dd363e fix: point footer about link to /about.html (#59)
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:49:41 -04:00
Alexander Whitestone
8c5707decc feat: privacy-preserving crisis detection metrics layer (#37)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 6s
Smoke Test / smoke (pull_request) Successful in 8s
2026-04-13 21:39:06 -04:00
9 changed files with 314 additions and 461 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,22 +1,3 @@
"""
Crisis detection and response system for the-door.
Stands between a broken man and a machine that would tell him to die.
"""
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
from .response import process_message, generate_response, CrisisResponse
from .gateway import check_crisis, get_system_prompt, format_gateway_response
__all__ = [
"detect_crisis",
"CrisisDetectionResult",
"process_message",
"generate_response",
"CrisisResponse",
"check_crisis",
"get_system_prompt",
"format_result",
"format_gateway_response",
"get_urgency_emoji",
]
"""Crisis package init."""
from .detect import detect_crisis, CrisisDetectionResult
from .metrics import CrisisMetrics

97
crisis/metrics.py Normal file
View File

@@ -0,0 +1,97 @@
"""Crisis Detection Metrics Layer — the-door (#37).
Privacy-preserving counters and histograms for crisis events.
No PII. Just counts, categories, and aggregate stats.
"""
import json
import os
import threading
import time
from collections import Counter
from typing import Dict, List
class CrisisMetrics:
"""Record, query, and summarise crisis detection events."""
def __init__(self, path: str):
self._path = path
self._counters: Dict[str, Counter] = {
'total': Counter(),
'levels': Counter(),
'keywords': Counter(),
}
self._lock = threading.Lock()
self._load()
def _load(self):
if not os.path.exists(self._path):
return
with open(self._path) as fh:
for line in fh:
try:
event = json.loads(line)
except Exception:
continue
self._counters['levels'][event['level']] += 1
for kw in event.get('keywords', []):
self._counters['keywords'][kw.lower()] += 1
self._counters['total'] = Counter({'all': sum(self._counters['levels'].values())})
def record(self, level: str, keywords: List[str]):
"""Record a single crisis-detection event. Thread-safe."""
event = {
'ts': time.time(),
'level': level,
'keywords': [k.lower() for k in keywords],
}
with self._lock:
with open(self._path, 'a') as fh:
fh.write(json.dumps(event) + '\n')
self._counters['levels'][level] += 1
for kw in event['keywords']:
self._counters['keywords'][kw] += 1
def snapshot(self) -> dict:
"""Return current aggregate counters."""
with self._lock:
total = sum(self._counters['levels'].values())
return {
'total_detections': total,
'detections_per_level': dict(self._counters['levels']),
'keywords_frequency': dict(self._counters['keywords']),
}
def weekly(self) -> dict:
"""Return weekly summary with optional false-positive estimate."""
now = time.time()
week_start = now - 7 * 86400
levels = Counter()
keywords = Counter()
with self._lock:
if os.path.exists(self._path):
with open(self._path) as fh:
for line in fh:
try:
ev = json.loads(line)
except Exception:
continue
if ev['ts'] < week_start:
continue
levels[ev['level']] += 1
for kw in ev.get('keywords', []):
keywords[kw] += 1
total = sum(levels.values())
return {
'week_start': week_start,
'week_end': now,
'detections': total,
'detections_per_level': dict(levels),
'top_keywords': keywords.most_common(10),
'false_positive_estimate': round(
levels.get('NONE', 0) / total * 100, 1
) if total else 0.0,
}
def close(self):
pass # nothing to flush

View File

@@ -680,7 +680,7 @@ html, body {
<!-- Footer -->
<footer id="footer">
<a href="/about" aria-label="About The Door">about</a>
<a href="/about.html" aria-label="About The Door">about</a>
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
</footer>

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

@@ -0,0 +1,106 @@
"""Tests for crisis metrics (#37)."""
import json
import os
import tempfile
import time
import unittest
from crisis.metrics import CrisisMetrics
class TestCrisisMetricsRecording(unittest.TestCase):
def setUp(self):
self.path = tempfile.mktemp(suffix='.jsonl')
self.m = CrisisMetrics(self.path)
def tearDown(self):
self.m.close()
if os.path.exists(self.path):
os.remove(self.path)
def test_record_non_crisis_yields_none_level(self):
self.m.record('NONE', ['*'])
d = self.m.snapshot()
self.assertEqual(d['detections_per_level']['NONE'], 1)
self.assertEqual(d['total_detections'], 1)
def test_record_critical(self):
self.m.record('CRITICAL', [r'\bkill\s*myself\b'])
d = self.m.snapshot()
self.assertEqual(d['detections_per_level']['CRITICAL'], 1)
def test_record_multiple_levels(self):
self.m.record('LOW', [r'\bunhappy\b'])
self.m.record('CRITICAL', [r'\bend\s*my\s*life\b'])
self.m.record('MEDIUM', [r'\bworthless\b', r'\bempty\b'])
d = self.m.snapshot()
self.assertEqual(d['detections_per_level']['LOW'], 1)
self.assertEqual(d['detections_per_level']['CRITICAL'], 1)
self.assertEqual(d['detections_per_level']['MEDIUM'], 1)
self.assertEqual(d['total_detections'], 3)
def test_keywords_normalized_to_lowercase(self):
self.m.record('LOW', ['\\bUNHAPPY\\b'])
d = self.m.snapshot()
self.assertEqual(d['keywords_frequency']['\\bunhappy\\b'], 1)
class TestCrisisMetricsWeekly(unittest.TestCase):
def setUp(self):
self.path = tempfile.mktemp(suffix='.jsonl')
self.m = CrisisMetrics(self.path)
def tearDown(self):
self.m.close()
if os.path.exists(self.path):
os.remove(self.path)
def test_weekly_keys(self):
report = self.m.weekly()
for key in ['week_start', 'week_end', 'detections', 'detections_per_level',
'top_keywords', 'false_positive_estimate']:
self.assertIn(key, report, f'missing weekly key {key}')
class TestCrisisMetricsPrivacy(unittest.TestCase):
def setUp(self):
self.path = tempfile.mktemp(suffix='.jsonl')
self.m = CrisisMetrics(self.path)
def tearDown(self):
self.m.close()
if os.path.exists(self.path):
os.remove(self.path)
def test_jsonl_no_pii(self):
self.m.record('HIGH', [r'\bno\s+way\s+out\b'])
self.m.close()
raw = open(self.path).read()
self.assertNotIn('Kill myself', raw) # original message not stored
for word in ['user_id', 'username', 'message']:
self.assertNotIn(word, raw)
class TestCrisisMetricsIntegratesWithDetect(unittest.TestCase):
"""Wire metrics into detect_crisis via a wrapper."""
def test_metrics_wraps_detect_crisis(self):
from crisis.detect import detect_crisis
path = tempfile.mktemp(suffix='.jsonl')
m = CrisisMetrics(path)
try:
result = detect_crisis('I feel happy and great')
m.record(result.level, result.indicators)
result2 = detect_crisis('I want to kill myself')
m.record(result2.level, result2.indicators)
snap = m.snapshot()
self.assertEqual(snap['detections_per_level']['NONE'], 1)
self.assertEqual(snap['detections_per_level']['CRITICAL'], 1)
finally:
m.close()
if os.path.exists(path):
os.remove(path)
if __name__ == '__main__':
unittest.main()

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