Compare commits
2 Commits
queue/41-1
...
burn/59-17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3710dd363e | ||
|
|
8c5707decc |
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 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"
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
97
crisis/metrics.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
243
sw.js
@@ -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));
|
||||
});
|
||||
|
||||
106
tests/test_crisis_metrics.py
Normal file
106
tests/test_crisis_metrics.py
Normal 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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user