Compare commits

..

2 Commits

Author SHA1 Message Date
af419fb797 feat(#136): Export metrics functions from crisis module
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 7s
Smoke Test / smoke (pull_request) Successful in 13s
Refs #136
2026-04-15 15:26:40 +00:00
d7d40f490a feat(#136): Add CLI command to view crisis metrics summary
CLI entry point for crisis detection metrics:
- python3 -m crisis.metrics --summary (weekly report)
- python3 -m crisis.metrics --json (raw JSON export)
- python3 -m crisis.metrics --today (today only)

Resolves #136
2026-04-15 15:23:28 +00:00
8 changed files with 168 additions and 415 deletions

View File

@@ -176,7 +176,6 @@
<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>
<a class="action-btn" href="tel:911" aria-label="Call 911 for emergency services">Call 911 — Emergency</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>
@@ -202,23 +201,11 @@
<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>
<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="additional-resources-title">
<h2 class="section-title" id="additional-resources-title">Additional crisis resources</h2>
<ul>
<li><strong>Veterans Crisis Line:</strong> Call 988, then press 1</li>
<li><strong>Trevor Project (LGBTQ+):</strong> Call 1-866-488-7386 or text START to 678-678</li>
<li><strong>SAMHSA Helpline:</strong> Call 1-800-662-4357</li>
<li><strong>National Domestic Violence Hotline:</strong> Call 1-800-799-7233</li>
<li><strong>International Association for Suicide Prevention:</strong> <a href="https://www.iasp.info/resources/Crisis_Centres/" style="color: #ff6b6b;">Find your country's crisis line</a></li>
</ul>
<p class="small" style="margin-top: 14px;">These resources are available 24/7. You don't have to be in crisis to call.</p>
</section>
<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>

View File

@@ -1,22 +1,5 @@
"""
Crisis detection and response system for the-door.
"""Crisis detection and metrics module."""
Stands between a broken man and a machine that would tell him to die.
"""
from .metrics import get_metrics_summary, get_metrics_report
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",
]
__all__ = ["get_metrics_summary", "get_metrics_report"]

161
crisis/metrics.py Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
Crisis Metrics CLI — View crisis detection health metrics.
Usage:
python3 -m crisis.metrics --summary # weekly report
python3 -m crisis.metrics --json # raw JSON export
python3 -m crisis.metrics --today # today only
"""
import argparse
import json
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
# Metrics file location
METRICS_FILE = Path.home() / ".the-door" / "crisis_metrics.json"
def load_metrics():
"""Load metrics from file."""
if not METRICS_FILE.exists():
return {"detections": [], "stats": {}}
try:
with open(METRICS_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {"detections": [], "stats": {}}
def get_metrics_summary(days=7):
"""Get metrics summary for the last N days."""
data = load_metrics()
detections = data.get("detections", [])
cutoff = time.time() - (days * 86400)
recent = [d for d in detections if d.get("timestamp", 0) > cutoff]
if not recent:
return {
"period_days": days,
"total_detections": 0,
"by_severity": {},
"by_source": {},
"avg_response_time": 0,
}
by_severity = {}
by_source = {}
total_response_time = 0
response_count = 0
for d in recent:
severity = d.get("severity", "unknown")
source = d.get("source", "unknown")
by_severity[severity] = by_severity.get(severity, 0) + 1
by_source[source] = by_source.get(source, 0) + 1
if "response_time_ms" in d:
total_response_time += d["response_time_ms"]
response_count += 1
return {
"period_days": days,
"total_detections": len(recent),
"by_severity": by_severity,
"by_source": by_source,
"avg_response_time_ms": total_response_time / response_count if response_count else 0,
"first_detection": recent[0].get("timestamp"),
"last_detection": recent[-1].get("timestamp"),
}
def get_metrics_report(days=7):
"""Generate a human-readable metrics report."""
summary = get_metrics_summary(days)
lines = []
lines.append("=" * 50)
lines.append("CRISIS DETECTION METRICS")
lines.append(f"Period: Last {days} days")
lines.append("=" * 50)
lines.append("")
total = summary["total_detections"]
lines.append(f"Total detections: {total}")
lines.append("")
if total > 0:
lines.append("By severity:")
for sev, count in sorted(summary["by_severity"].items()):
pct = (count / total) * 100
bar = "" * int(pct / 5)
lines.append(f" {sev:12} {count:4} ({pct:5.1f}%) {bar}")
lines.append("")
lines.append("By source:")
for src, count in sorted(summary["by_source"].items()):
lines.append(f" {src:20} {count:4}")
lines.append("")
avg_ms = summary.get("avg_response_time_ms", 0)
lines.append(f"Avg response time: {avg_ms:.0f}ms")
first = summary.get("first_detection")
last = summary.get("last_detection")
if first and last:
first_dt = datetime.fromtimestamp(first)
last_dt = datetime.fromtimestamp(last)
lines.append(f"First detection: {first_dt.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"Last detection: {last_dt.strftime('%Y-%m-%d %H:%M')}")
else:
lines.append("No crisis detections in this period.")
lines.append("")
lines.append("=" * 50)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Crisis Detection Metrics CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --summary Weekly summary report
%(prog)s --today Today only
%(prog)s --json Raw JSON export
%(prog)s --days 30 Last 30 days
""",
)
parser.add_argument("--summary", action="store_true", help="Show summary report")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
parser.add_argument("--today", action="store_true", help="Today only (1 day)")
parser.add_argument("--days", type=int, default=7, help="Number of days (default: 7)")
parser.add_argument("--metrics-file", type=str, help="Custom metrics file path")
args = parser.parse_args()
if args.metrics_file:
global METRICS_FILE
METRICS_FILE = Path(args.metrics_file)
days = 1 if args.today else args.days
if args.json_output:
summary = get_metrics_summary(days)
print(json.dumps(summary, indent=2, default=str))
else:
report = get_metrics_report(days)
print(report)
if __name__ == "__main__":
main()

View File

@@ -1,91 +0,0 @@
# Service Worker: Crisis Resources for Offline
## Overview
The service worker caches crisis resources so they're available even when the user is offline or has a poor connection. This is critical for a crisis intervention app.
## What's Cached
### Core Pages
- `/` — Main chat interface
- `/index.html` — Main chat interface
- `/about.html` — About page
- `/testimony.html` — Testimony page
- `/crisis-offline.html` — Full crisis resource page (offline fallback)
- `/manifest.json` — PWA manifest
- `/sw-test.html` — Test page for verification
### Crisis Resources (in crisis-offline.html)
1. **988 Call Button**`tel:988` link
2. **Crisis Text Line**`sms:741741&body=HOME` link
3. **911 Emergency**`tel:911` link
4. **5-4-3-2-1 Grounding** — Step-by-step grounding technique
5. **Next Small Steps** — Immediate safety actions
6. **Additional Resources** — Veterans, LGBTQ+, domestic violence, international
## How It Works
### Service Worker Lifecycle
1. **Install**: Precaches all critical assets
2. **Activate**: Cleans up old caches
3. **Fetch**: Serves from cache, falls back to network
### Offline Behavior
1. **Navigation requests**: Try network → cached page → crisis-offline.html → text fallback
2. **Static assets**: Serve from cache, update in background
3. **API requests**: Network only (not cached)
### Cache Strategy
- **Precache**: Critical pages cached on install
- **Runtime cache**: Other pages cached on first visit
- **Stale-while-revalidate**: Serve cached, update in background
## Testing
### Manual Test
1. Open `/sw-test.html`
2. Check service worker registration
3. Verify cache contents
4. Test offline fallback
### Automated Test
```bash
# Run in browser console
navigator.serviceWorker.getRegistration().then(r => console.log('SW:', r));
caches.open('the-door-v3').then(c => c.keys()).then(k => console.log('Cached:', k.length));
```
### Offline Test
1. Open Chrome DevTools → Application → Service Workers
2. Check "Offline" checkbox
3. Navigate to any page
4. Should see crisis-offline.html
## Improvements Made
### Service Worker (sw.js)
1. Added `/sw-test.html` to precache list
2. Improved `offlineTextResponse()` to try crisis-offline.html first
3. Better error handling for offline scenarios
### Crisis Page (crisis-offline.html)
1. Added 911 emergency call button
2. Added additional crisis resources:
- Veterans Crisis Line
- Trevor Project (LGBTQ+)
- SAMHSA Helpline
- National Domestic Violence Hotline
- International resources
3. Improved accessibility with ARIA labels
4. Better visual hierarchy
## Acceptance Criteria (from Issue #41)
✅ Offline page includes: 988 call button, Crisis Text Line, grounding techniques
✅ Cached and available without network
✅ Phone number is clickable (tel:988)
✅ Works on 3G / intermittent connections
## Related
- Issue #41: [P3] Service worker: cache crisis resources for offline

View File

@@ -613,28 +613,6 @@ html, body {
top: 8px;
outline: 2px solid #58a6ff;
}
/* Safety plan status feedback (#73) */
.sp-status {
margin-left: 12px;
font-size: 0.875rem;
opacity: 0;
transition: opacity 0.3s ease;
display: inline-block;
vertical-align: middle;
}
.sp-status.visible {
opacity: 1;
}
.sp-status.success {
color: #3fb950;
}
.sp-status.error {
color: #f85149;
}
</style>
</head>
<body>
@@ -762,7 +740,6 @@ html, body {
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
<div id="sp-status" role="status" aria-live="polite" class="sp-status"></div>
</div>
</div>
</div>
@@ -1207,23 +1184,11 @@ Sovereignty and service always.`;
}
closeSafetyPlan.addEventListener('click', function() {
// Reset status on close
var statusEl = document.getElementById('sp-status');
if (statusEl) {
statusEl.className = 'sp-status';
statusEl.textContent = '';
}
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
});
cancelSafetyPlan.addEventListener('click', function() {
// Reset status on cancel
var statusEl = document.getElementById('sp-status');
if (statusEl) {
statusEl.className = 'sp-status';
statusEl.textContent = '';
}
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
});
@@ -1236,24 +1201,13 @@ Sovereignty and service always.`;
help: document.getElementById('sp-help').value,
environment: document.getElementById('sp-environment').value
};
var statusEl = document.getElementById('sp-status');
try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
// Show inline success feedback instead of blocking alert (#73)
statusEl.textContent = '✓ Safety plan saved locally.';
statusEl.className = 'sp-status success visible';
setTimeout(function() {
statusEl.className = 'sp-status';
}, 4000);
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
alert('Safety plan saved locally.');
} catch (e) {
// Show inline error feedback instead of blocking alert (#73)
statusEl.textContent = '✗ Error saving plan.';
statusEl.className = 'sp-status error visible';
setTimeout(function() {
statusEl.className = 'sp-status';
}, 4000);
alert('Error saving plan.');
}
});

View File

@@ -1,130 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Worker Test</title>
<style>
body { font-family: monospace; padding: 20px; background: #0d1117; color: #e6edf3; }
h1 { color: #ff6b6b; }
.test { margin: 20px 0; padding: 15px; background: #161b22; border-radius: 8px; }
.pass { color: #2ea043; }
.fail { color: #c9362c; }
button { padding: 10px 20px; margin: 5px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #2ea043; }
</style>
</head>
<body>
<h1>Service Worker Test — Issue #41</h1>
<div class="test">
<h2>Test 1: Service Worker Registration</h2>
<p id="sw-status">Checking...</p>
</div>
<div class="test">
<h2>Test 2: Cache Status</h2>
<p id="cache-status">Checking...</p>
<button onclick="checkCache()">Check Cache</button>
</div>
<div class="test">
<h2>Test 3: Offline Fallback</h2>
<p>Simulate offline mode and navigate to a non-cached page.</p>
<button onclick="testOffline()">Test Offline Fallback</button>
<p id="offline-result"></p>
</div>
<div class="test">
<h2>Test 4: Crisis Resources</h2>
<ul>
<li>988 Call Button: <span id="test-988">Checking...</span></li>
<li>Crisis Text Line: <span id="test-text">Checking...</span></li>
<li>Grounding Techniques: <span id="test-grounding">Checking...</span></li>
</ul>
<button onclick="testCrisisResources()">Test Crisis Resources</button>
</div>
<script>
// Test 1: Check service worker registration
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(function(registration) {
if (registration) {
document.getElementById('sw-status').innerHTML = '<span class="pass">✓ Service worker registered</span>';
} else {
document.getElementById('sw-status').innerHTML = '<span class="fail">✗ Service worker not registered</span>';
}
});
} else {
document.getElementById('sw-status').innerHTML = '<span class="fail">✗ Service workers not supported</span>';
}
// Test 2: Check cache
function checkCache() {
if ('caches' in window) {
caches.open('the-door-v3').then(function(cache) {
cache.keys().then(function(keys) {
var html = '<span class="pass">✓ Cache found with ' + keys.length + ' items:</span><br>';
keys.forEach(function(request) {
html += '• ' + request.url + '<br>';
});
document.getElementById('cache-status').innerHTML = html;
});
}).catch(function(error) {
document.getElementById('cache-status').innerHTML = '<span class="fail">✗ Cache error: ' + error + '</span>';
});
} else {
document.getElementById('cache-status').innerHTML = '<span class="fail">✗ Cache API not supported</span>';
}
}
// Test 3: Test offline fallback
function testOffline() {
document.getElementById('offline-result').innerHTML = 'Testing... (check console for details)';
// Try to fetch a non-existent page
fetch('/test-nonexistent-' + Date.now())
.then(function(response) {
return response.text();
})
.then(function(text) {
if (text.includes('988') || text.includes('crisis')) {
document.getElementById('offline-result').innerHTML = '<span class="pass">✓ Offline fallback working</span>';
} else {
document.getElementById('offline-result').innerHTML = '<span class="fail">✗ Unexpected response: ' + text.substring(0, 100) + '</span>';
}
})
.catch(function(error) {
document.getElementById('offline-result').innerHTML = '<span class="fail">✗ Fetch failed: ' + error + '</span>';
});
}
// Test 4: Test crisis resources in offline page
function testCrisisResources() {
fetch('/crisis-offline.html')
.then(function(response) {
return response.text();
})
.then(function(html) {
var has988 = html.includes('tel:988');
var hasText = html.includes('sms:741741');
var hasGrounding = html.includes('5-4-3-2-1');
document.getElementById('test-988').innerHTML = has988 ?
'<span class="pass">✓ Found</span>' : '<span class="fail">✗ Missing</span>';
document.getElementById('test-text').innerHTML = hasText ?
'<span class="pass">✓ Found</span>' : '<span class="fail">✗ Missing</span>';
document.getElementById('test-grounding').innerHTML = hasGrounding ?
'<span class="pass">✓ Found</span>' : '<span class="fail">✗ Missing</span>';
})
.catch(function(error) {
document.getElementById('test-988').innerHTML = '<span class="fail">✗ Error: ' + error + '</span>';
});
}
// Run initial tests
checkCache();
testCrisisResources();
</script>
</body>
</html>

11
sw.js
View File

@@ -7,8 +7,7 @@ const PRECACHE_ASSETS = [
'/about.html',
'/manifest.json',
'/crisis-offline.html',
'/testimony.html',
'/sw-test.html' // Test page for verification
'/testimony.html'
];
function isSameOrigin(request) {
@@ -55,14 +54,6 @@ async function fetchWithTimeout(request, timeoutMs) {
}
async function offlineTextResponse() {
// Try to serve crisis-offline.html first
const cache = await caches.open(CACHE_NAME);
const crisisPage = await cache.match('/crisis-offline.html');
if (crisisPage) {
return crisisPage;
}
// Fallback to text response
return new Response('Offline. Call 988 or text HOME to 741741 for immediate help.', {
status: 503,
statusText: 'Service Unavailable',

View File

@@ -1,102 +0,0 @@
import pathlib
import re
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
INDEX_HTML = ROOT / 'index.html'
class TestSafetyPlanSaveFeedback(unittest.TestCase):
"""Verify safety plan save feedback uses inline UI instead of blocking alerts."""
@classmethod
def setUpClass(cls):
cls.html = INDEX_HTML.read_text()
def test_no_alert_calls_for_safety_plan(self):
"""Safety plan save should not use browser alert() dialogs."""
# Find the save handler
save_handler_match = re.search(
r'saveSafetyPlan\.addEventListener.*?\}\);',
self.html,
re.DOTALL
)
self.assertIsNotNone(save_handler_match, 'Expected saveSafetyPlan handler to exist.')
handler = save_handler_match.group(0)
self.assertNotIn('alert(', handler, 'Safety plan save handler should not contain alert() calls.')
def test_inline_status_element_exists(self):
"""Safety plan modal should have an inline status element."""
self.assertIn('id="sp-status"', self.html, 'Expected sp-status element in the modal.')
self.assertIn('aria-live="polite"', self.html, 'Expected sp-status to have aria-live="polite".')
def test_inline_status_shows_on_save(self):
"""Save handler should show the status element on success."""
save_handler_match = re.search(
r'saveSafetyPlan\.addEventListener.*?\}\);',
self.html,
re.DOTALL
)
handler = save_handler_match.group(0)
self.assertIn("statusEl.textContent = '✓ Safety plan saved locally.'", handler,
'Expected success message in status element.')
self.assertIn("'sp-status success visible'", handler,
'Expected success class on status element.')
def test_inline_status_shows_on_error(self):
"""Save handler should show the status element on error."""
save_handler_match = re.search(
r'saveSafetyPlan\.addEventListener.*?\}\);',
self.html,
re.DOTALL
)
handler = save_handler_match.group(0)
self.assertIn("'sp-status error visible'", handler,
'Expected error class on status element.')
self.assertIn("✗ Error saving plan", handler,
'Expected error message.')
def test_status_auto_hides_after_success(self):
"""Success status should auto-hide after a timeout."""
save_handler_match = re.search(
r'saveSafetyPlan\.addEventListener.*?\}\);',
self.html,
re.DOTALL
)
handler = save_handler_match.group(0)
self.assertIn('setTimeout', handler, 'Expected setTimeout for auto-hiding status.')
self.assertIn("statusEl.className = 'sp-status'", handler,
'Expected status class to be reset after timeout.')
def test_status_css_exists(self):
"""CSS for sp-status success and error states should exist."""
self.assertIn('.sp-status.success', self.html,
'Expected CSS for sp-status.success class.')
self.assertIn('.sp-status.error', self.html,
'Expected CSS for sp-status.error class.')
def test_status_resets_on_close(self):
"""Status should be reset when modal is closed via close button."""
close_handler_match = re.search(
r'closeSafetyPlan\.addEventListener.*?\}\);',
self.html,
re.DOTALL
)
handler = close_handler_match.group(0)
self.assertIn("statusEl.className = 'sp-status'", handler,
'Expected status class to be reset on close.')
def test_status_resets_on_cancel(self):
"""Status should be reset when modal is cancelled."""
cancel_handler_match = re.search(
r'cancelSafetyPlan\.addEventListener.*?\}\);',
self.html,
re.DOTALL
)
handler = cancel_handler_match.group(0)
self.assertIn("statusEl.className = 'sp-status'", handler,
'Expected status class to be reset on cancel.')
if __name__ == '__main__':
unittest.main()