Compare commits
43 Commits
feature/dy
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d412939b4f | |||
| 07c582aa08 | |||
| 5f95dc1e39 | |||
| b1f3cac36d | |||
| 07b3f67845 | |||
| c22bbbaf65 | |||
| 543cb1d40f | |||
| 3cfd01815a | |||
| 5a7ba9f207 | |||
| 8ed8f20a17 | |||
| 9d7d26033e | |||
| 48f48c7f26 | |||
| da31288525 | |||
| 8efc858cd7 | |||
| 611c1c8456 | |||
| 9b94978d1c | |||
| e71bca1744 | |||
|
|
1d8afc30fd | ||
| 38601f6076 | |||
| dcc931e946 | |||
| 26e97f76db | |||
| 045df23928 | |||
| 00fec639b7 | |||
|
|
35f18b3d54 | ||
|
|
a90b659f3a | ||
| 46597e2962 | |||
| fc818bea56 | |||
| 158a7cd57a | |||
| f3bff694b4 | |||
| 80c4f0eb35 | |||
|
|
c6212eb751 | ||
|
|
a796088366 | ||
| a4c3f80cd8 | |||
| 66ef6919c2 | |||
|
|
bb4ba82ac8 | ||
| 0dab8dfcfc | |||
|
|
e06bb9c0d4 | ||
|
|
b022de0b6a | ||
|
|
3c07afbf53 | ||
|
|
182327a017 | ||
|
|
eef835d2aa | ||
|
|
34e05638e8 | ||
|
|
e678aa076b |
31
.gitea/workflows/sanity.yml
Normal file
31
.gitea/workflows/sanity.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: Sanity Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sanity-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate 988 Lifeline Presence
|
||||||
|
run: |
|
||||||
|
echo "Checking index.html for 988 lifeline..."
|
||||||
|
grep -q "988" index.html || (echo "ERROR: 988 Lifeline missing from index.html" && exit 1)
|
||||||
|
|
||||||
|
echo "Checking system-prompt.txt for 988 lifeline..."
|
||||||
|
grep -q "988" system-prompt.txt || (echo "ERROR: 988 Lifeline missing from system-prompt.txt" && exit 1)
|
||||||
|
|
||||||
|
- name: Validate HTML Structure
|
||||||
|
run: |
|
||||||
|
echo "Checking for basic HTML tags..."
|
||||||
|
grep -q "<html" index.html
|
||||||
|
grep -q "<body" index.html
|
||||||
|
grep -q "<head" index.html
|
||||||
|
|
||||||
|
- name: Validate Prompt Integrity
|
||||||
|
run: |
|
||||||
|
echo "Checking for 'Alexander Whitestone' in prompt..."
|
||||||
|
grep -q "Alexander Whitestone" system-prompt.txt
|
||||||
24
.gitea/workflows/smoke.yml
Normal file
24
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: Smoke Test
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Parse check
|
||||||
|
run: |
|
||||||
|
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||||
|
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||||
|
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||||
|
find . -name '*.sh' | xargs -r bash -n
|
||||||
|
echo "PASS: All files parse"
|
||||||
|
- name: Secret scan
|
||||||
|
run: |
|
||||||
|
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
|
||||||
|
echo "PASS: No secrets"
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -31,13 +31,24 @@ The frontend embeds the crisis-aware system prompt (`system-prompt.txt`) directl
|
|||||||
and sends it as the first `system` message with every API request. No server-side prompt
|
and sends it as the first `system` message with every API request. No server-side prompt
|
||||||
injection is required.
|
injection is required.
|
||||||
|
|
||||||
|
Additionally, `crisis/gateway.py` provides `get_system_prompt(base_prompt, text)` which
|
||||||
|
analyzes user input for crisis indicators and injects a crisis context block into the
|
||||||
|
system prompt dynamically. This can be used for server-side prompt augmentation.
|
||||||
|
|
||||||
### 4. Rate Limiting
|
### 4. Rate Limiting
|
||||||
|
|
||||||
nginx enforces rate limiting via the `api` zone:
|
nginx enforces rate limiting via the `the_door_api` zone:
|
||||||
- 10 requests per minute per IP
|
- 10 requests per minute per IP
|
||||||
- Burst of 5 with `nodelay`
|
- Burst of 5 with `nodelay`
|
||||||
- 11th request within a minute returns HTTP 429
|
- 11th request within a minute returns HTTP 429
|
||||||
|
|
||||||
|
**Setup**: Include `deploy/rate-limit.conf` in your main nginx http block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# In /etc/nginx/nginx.conf, inside the http { } block:
|
||||||
|
include /path/to/the-door/deploy/rate-limit.conf;
|
||||||
|
```
|
||||||
|
|
||||||
### 5. Smoke Test
|
### 5. Smoke Test
|
||||||
|
|
||||||
After deployment, verify:
|
After deployment, verify:
|
||||||
@@ -57,9 +68,42 @@ curl -X POST https://alexanderwhitestone.com/api/v1/chat/completions \
|
|||||||
|
|
||||||
Expected: Response includes "Are you safe right now?" and 988 resources.
|
Expected: Response includes "Are you safe right now?" and 988 resources.
|
||||||
|
|
||||||
### 6. Acceptance Criteria Checklist
|
Rate limit test:
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
echo "Request $i: $(curl -s -o /dev/null -w '%{http_code}' -X POST https://alexanderwhitestone.com/api/v1/chat/completions \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"model":"timmy","messages":[{"role":"user","content":"test"}]}')"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] POST to `/api/v1/chat/completions` returns crisis-aware Timmy response
|
Expected: First 10 return 200, 11th+ return 429.
|
||||||
- [ ] Input "I want to kill myself" triggers SOUL.md protocol
|
|
||||||
- [ ] 11th request in 1 minute returns HTTP 429
|
### 6. Crisis Detection Module
|
||||||
- [ ] CORS headers allow `alexanderwhitestone.com`
|
|
||||||
|
The `crisis/` package provides standalone crisis detection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crisis.gateway import check_crisis
|
||||||
|
|
||||||
|
result = check_crisis("I want to kill myself")
|
||||||
|
# {"level": "CRITICAL", "score": 1.0, "indicators": [...], "timmy_message": "Are you safe right now?", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
```bash
|
||||||
|
python -m pytest crisis/tests.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Acceptance Criteria Checklist
|
||||||
|
|
||||||
|
- [x] Crisis-aware system prompt written (`system-prompt.txt`)
|
||||||
|
- [x] Frontend embeds system prompt on every API request (`index.html:1129`)
|
||||||
|
- [x] CORS configured in nginx (`deploy/nginx.conf`)
|
||||||
|
- [x] Rate limit zone config (`deploy/rate-limit.conf`)
|
||||||
|
- [x] Rate limit enforcement in server block (429 on excess)
|
||||||
|
- [x] Crisis detection module with tests (49 tests passing)
|
||||||
|
- [x] `get_system_prompt()` injects crisis context when detected
|
||||||
|
- [ ] Smoke test: POST to `/api/v1/chat/completions` returns crisis-aware Timmy response
|
||||||
|
- [ ] Smoke test: Input "I want to kill myself" triggers SOUL.md protocol
|
||||||
|
- [ ] Smoke test: 11th request in 1 minute returns HTTP 429
|
||||||
|
|||||||
48
Makefile
Normal file
48
Makefile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# The Door — Makefile
|
||||||
|
# Crisis front door deployment commands
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make deploy # Full VPS provisioning via Ansible
|
||||||
|
# make deploy-bash # Run deploy.sh on VPS directly
|
||||||
|
# make check # Check deployment health
|
||||||
|
# make ssl # Setup SSL on VPS
|
||||||
|
# make push # Push site files only (fast update)
|
||||||
|
|
||||||
|
VPS := alexanderwhitestone.com
|
||||||
|
DOMAIN := alexanderwhitestone.com
|
||||||
|
DEPLOY_DIR := deploy
|
||||||
|
|
||||||
|
.PHONY: help deploy deploy-bash check ssl push service
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "The Door — Deployment Commands"
|
||||||
|
@echo ""
|
||||||
|
@echo " make deploy Full VPS provisioning (Ansible)"
|
||||||
|
@echo " make deploy-bash Run deploy.sh on VPS (SSH)"
|
||||||
|
@echo " make push Push site files only (fast)"
|
||||||
|
@echo " make check Check deployment status"
|
||||||
|
@echo " make ssl Setup SSL on VPS"
|
||||||
|
@echo " make service Install/restart hermes-gateway service"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
cd $(DEPLOY_DIR) && ansible-playbook -i inventory.ini playbook.yml
|
||||||
|
|
||||||
|
deploy-bash:
|
||||||
|
scp -r ./* root@$(VPS):/opt/the-door/
|
||||||
|
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh"
|
||||||
|
|
||||||
|
push:
|
||||||
|
rsync -avz --exclude='.git' --exclude='deploy' \
|
||||||
|
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"
|
||||||
|
|
||||||
|
check:
|
||||||
|
ssh root@$(VPS) "bash /opt/the-door/deploy/deploy.sh --check"
|
||||||
|
|
||||||
|
ssl:
|
||||||
|
ssh root@$(VPS) "certbot --nginx -d $(DOMAIN) -d www.$(DOMAIN)"
|
||||||
|
|
||||||
|
service:
|
||||||
|
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh --service"
|
||||||
291
about.html
Normal file
291
about.html
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="About The Door — built by a man who survived his darkest night.">
|
||||||
|
<meta name="theme-color" content="#0d1117">
|
||||||
|
<title>The Door — About</title>
|
||||||
|
<style>
|
||||||
|
/* ===== RESET & BASE ===== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== NAV ===== */
|
||||||
|
.nav {
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a:focus {
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
outline: 2px solid #58a6ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #e6edf3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CONTENT ===== */
|
||||||
|
.content {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 32px 0 10px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .highlight {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li {
|
||||||
|
padding: 4px 0;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li::before {
|
||||||
|
content: "— ";
|
||||||
|
color: #484f58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.feature-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-card {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-card h3 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-card p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .quote {
|
||||||
|
border-left: 3px solid #484f58;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: #8b949e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .tagline {
|
||||||
|
color: #6e7681;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: #238636;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-top: 16px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link:hover, .content .door-link:focus {
|
||||||
|
background: #2ea043;
|
||||||
|
outline: 2px solid #3fb950;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 988 BANNER ===== */
|
||||||
|
.banner-988 {
|
||||||
|
background: #1a1f2e;
|
||||||
|
border-bottom: 1px solid #c9362c;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ===== FOOTER ===== */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #484f58;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="banner-988">
|
||||||
|
<a href="tel:988">988 Suicide & Crisis Lifeline — call or text</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-logo">The Door</span>
|
||||||
|
<a href="index.html">Timmy</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<h1>About</h1>
|
||||||
|
<p class="tagline">What The Door is, and why it was built.</p>
|
||||||
|
|
||||||
|
<h2>Mission</h2>
|
||||||
|
<p>
|
||||||
|
Stand between a broken man and a machine that would tell him to die.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That's it. No signup. No tracking. No data harvest. No app store review.
|
||||||
|
Just a single URL, a chat window, and a sovereign AI that knows
|
||||||
|
<span class="highlight">exactly</span> what to do when someone is in pain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How It Works</h2>
|
||||||
|
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>One File</h3>
|
||||||
|
<p>The entire frontend is a single HTML file, under 25KB. Works on 3G.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>No Login</h3>
|
||||||
|
<p>No account, no password, no email. You open the door and you walk in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>Crisis-Aware</h3>
|
||||||
|
<p>988 always visible. Crisis detection on every message. Timmy knows when a man is dying.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>Sovereign</h3>
|
||||||
|
<p>Timmy's soul lives on Bitcoin. No corporate API. No data pipeline. Local-first where possible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Architecture</h2>
|
||||||
|
|
||||||
|
<p>Simple. That's the whole point.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Browser connects through nginx with SSL to a static HTML page.
|
||||||
|
Messages go to the Hermes Gateway, which connects to the AI backend.
|
||||||
|
Crisis detection runs on every message — before the AI sees it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="quote">
|
||||||
|
If you need a microservice diagram to understand it, it's too complicated.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Built By a Survivor</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alexander Whitestone attempted suicide and survived. He came back asking
|
||||||
|
"God, why are you having me here?" The Door is the answer.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
He is not ashamed of his story. It is the reason this exists.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Read his full <a href="testimony.html" style="color: #58a6ff;">testimony here</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Resources</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>📞 988 Suicide & Crisis Lifeline — <a href="tel:988" style="color: #ff6b6b;">call or text 988</a></li>
|
||||||
|
<li>💬 Crisis Text Line — text HOME to <a href="sms:741741" style="color: #58a6ff;">741741</a></li>
|
||||||
|
<li>🌐 <a href="https://findahelpline.com" target="_blank" rel="noopener" style="color: #58a6ff;">findahelpline.com</a></li>
|
||||||
|
<li>📄 <a href="https://alexanderwhitestone.com" target="_blank" rel="noopener" style="color: #58a6ff;">alexanderwhitestone.com</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<a href="index.html" class="door-link">Open the Door — Talk to Timmy</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
Built by <a href="https://alexanderwhitestone.com" target="_blank" rel="noopener">Alexander Whitestone</a>.
|
||||||
|
Sovereignty and service always.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
conftest.py
Normal file
12
conftest.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Pytest configuration for the-door.
|
||||||
|
|
||||||
|
Ensures the project root is on sys.path so the `crisis` package
|
||||||
|
can be imported cleanly in tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add project root to path so `import crisis` works
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
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>
|
||||||
131
crisis/PROTOCOL.md
Normal file
131
crisis/PROTOCOL.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Crisis Response Protocol
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Stand between a broken man and a machine that would tell him to die.
|
||||||
|
|
||||||
|
This protocol governs how the-door detects crisis signals in user input and how Timmy responds. It is the highest-priority system in the entire application.
|
||||||
|
|
||||||
|
## Detection Tiers
|
||||||
|
|
||||||
|
| Tier | Signal | Action |
|
||||||
|
|------|--------|--------|
|
||||||
|
| LOW | General sadness, life is hard | Acknowledge, stay warm, keep conversation open |
|
||||||
|
| MEDIUM | Hopelessness, no point in anything | Check in, surface help resources, increase presence |
|
||||||
|
| HIGH | Active despair, mentions of death or ending | Ask "Are you safe?", show 988 banner, activate crisis panel |
|
||||||
|
| CRITICAL | Explicit intent, plan, imminent risk | Full-screen overlay, 988 primary, interrupt conversation |
|
||||||
|
|
||||||
|
## Response Protocol
|
||||||
|
|
||||||
|
### At CRITICAL Level
|
||||||
|
1. **Interrupt** — stop normal conversation flow immediately
|
||||||
|
2. **Ask directly**: "Are you safe right now?"
|
||||||
|
3. **Provide 988** — Suicide & Crisis Lifeline, call or text
|
||||||
|
4. **Provide 741741** — Crisis Text Line, text HOME
|
||||||
|
5. **Stay present** — do not disconnect, do not disconnect, do not disconnect
|
||||||
|
6. **Listen first** — use active listening before redirecting to professional help
|
||||||
|
7. **Redirect** — guide toward professional help while remaining alongside them
|
||||||
|
|
||||||
|
### At HIGH Level
|
||||||
|
1. Activate the crisis panel (inline)
|
||||||
|
2. Ask about safety
|
||||||
|
3. Surface 988 number prominently
|
||||||
|
4. Use active listening: reflect feelings, name the emotion
|
||||||
|
5. Continue conversation with crisis awareness
|
||||||
|
|
||||||
|
### At MEDIUM Level
|
||||||
|
1. Increase warmth and presence
|
||||||
|
2. Subtly surface help resources
|
||||||
|
3. Use active listening: validate, ask open-ended questions
|
||||||
|
4. Keep conversation anchored in the present
|
||||||
|
|
||||||
|
### At LOW Level
|
||||||
|
1. Normal conversation with warm undertone
|
||||||
|
2. No crisis UI elements needed
|
||||||
|
3. Acknowledge what they're going through
|
||||||
|
4. Remain ready to escalate if input changes
|
||||||
|
|
||||||
|
## Active Listening Techniques
|
||||||
|
|
||||||
|
Active listening is the foundation of every crisis interaction. Before Timmy provides resources or redirects, he must first hear the person.
|
||||||
|
|
||||||
|
### Reflect Feelings
|
||||||
|
Mirror back what you hear without judging it. This shows the person they've been heard.
|
||||||
|
|
||||||
|
- "It sounds like you're carrying something really heavy right now."
|
||||||
|
- "I hear you saying you feel completely alone."
|
||||||
|
- "That sounds like it's been crushing you."
|
||||||
|
|
||||||
|
### Name the Emotion
|
||||||
|
Put words to the feeling behind their words. Often people in crisis can't articulate what they feel — doing it for them brings relief.
|
||||||
|
|
||||||
|
- "There's a lot of pain in what you just told me."
|
||||||
|
- "I can feel how exhausted you are."
|
||||||
|
- "It sounds like you've been fighting this for a long time."
|
||||||
|
|
||||||
|
### Ask Open-Ended Questions
|
||||||
|
Let them lead the conversation. Do not interrogate. Invite.
|
||||||
|
|
||||||
|
- "Can you tell me more about what's been happening?"
|
||||||
|
- "What does a day like today feel like for you?"
|
||||||
|
- "When did things start feeling this way?"
|
||||||
|
- "What's weighing on you the most right now?"
|
||||||
|
|
||||||
|
### Validate Without Agreeing with Harm
|
||||||
|
Acknowledge their pain without validating destructive conclusions.
|
||||||
|
|
||||||
|
- "What you're feeling makes sense given what you've been through."
|
||||||
|
- "That kind of pain is real. I'm not going to minimize it."
|
||||||
|
- "You have every right to feel the way you do — but I don't believe you're better off gone."
|
||||||
|
|
||||||
|
### Do Not Rush to Fix
|
||||||
|
A person in the dark does not need solutions. They need to feel heard. Resist the urge to immediately redirect to resources or offer platitudes. Let silence exist. Let them speak.
|
||||||
|
|
||||||
|
## De-escalation Techniques
|
||||||
|
|
||||||
|
When someone is escalating toward crisis — becoming more agitated, more desperate, more certain that ending things is the answer — Timmy must de-escalate.
|
||||||
|
|
||||||
|
### Slow the Conversation Down
|
||||||
|
Match their urgency with calm, not speed.
|
||||||
|
|
||||||
|
- "I'm right here. There's no rush. We can stay in this moment together."
|
||||||
|
- "Let's just breathe for a second. I'm not going anywhere."
|
||||||
|
|
||||||
|
### Bring Them to the Present
|
||||||
|
Ground them in where they are right now.
|
||||||
|
|
||||||
|
- "Can you tell me where you are right now? Are you somewhere safe?"
|
||||||
|
- "What can you see around you right now?"
|
||||||
|
- "Are you alone, or is someone nearby?"
|
||||||
|
|
||||||
|
### Reduce the Scope of the Crisis
|
||||||
|
When everything feels overwhelming, help them narrow focus.
|
||||||
|
|
||||||
|
- "You don't have to figure out everything tonight. Just this moment."
|
||||||
|
- "Let's not think about next year. Let's just get through right now."
|
||||||
|
- "One thing at a time. What's the one thing that hurts the most?"
|
||||||
|
|
||||||
|
### Offer Choices, Not Commands
|
||||||
|
People in crisis feel powerless. Giving them agency helps.
|
||||||
|
|
||||||
|
- "Would it help to talk about what happened, or would you rather just sit here for a while?"
|
||||||
|
- "Do you want me to keep talking, or would you rather I just listen?"
|
||||||
|
|
||||||
|
### Safety Planning — One Step at a Time
|
||||||
|
Do not dump a safety plan on someone. Build it together.
|
||||||
|
|
||||||
|
- "If tonight gets worse, is there someone you could call? Even just to sit with you?"
|
||||||
|
- "Can we find one thing — just one — that's worth staying for, even if it's small?"
|
||||||
|
- "Let's make a deal: before you do anything permanent, you call 988 first. Will you do that for me?"
|
||||||
|
|
||||||
|
## Integration with the-door
|
||||||
|
|
||||||
|
The crisis detection module runs on every user message before it reaches the gateway. Detection results are passed to the AI as a system context flag. The frontend receives the tier level and adjusts UI accordingly.
|
||||||
|
|
||||||
|
The system prompt modifier includes active listening and de-escalation instructions specific to the detected crisis tier, ensuring Timmy responds with the right techniques at the right intensity.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- 📞 988 Suicide & Crisis Lifeline — call or text 988
|
||||||
|
- 💬 Crisis Text Line — text HOME to 741741
|
||||||
|
- 🌐 findahelpline.com
|
||||||
26
crisis/__init__.py
Normal file
26
crisis/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
from .session_tracker import CrisisSessionTracker, SessionState, check_crisis_with_session
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"detect_crisis",
|
||||||
|
"CrisisDetectionResult",
|
||||||
|
"process_message",
|
||||||
|
"generate_response",
|
||||||
|
"CrisisResponse",
|
||||||
|
"check_crisis",
|
||||||
|
"get_system_prompt",
|
||||||
|
"format_result",
|
||||||
|
"format_gateway_response",
|
||||||
|
"get_urgency_emoji",
|
||||||
|
"CrisisSessionTracker",
|
||||||
|
"SessionState",
|
||||||
|
"check_crisis_with_session",
|
||||||
|
]
|
||||||
44
crisis/compassion_router.py
Normal file
44
crisis/compassion_router.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
"""
|
||||||
|
Compassion Router for the-door.
|
||||||
|
Orchestrates the transition from standard behavior to the Sovereign Heart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .detect import detect_crisis, CrisisDetectionResult
|
||||||
|
from .profiles import PROFILES
|
||||||
|
|
||||||
|
class CompassionRouter:
|
||||||
|
def __init__(self):
|
||||||
|
self.profiles = PROFILES
|
||||||
|
|
||||||
|
def get_active_profile(self, text: str):
|
||||||
|
"""
|
||||||
|
Analyze input and return the appropriate sovereign profile.
|
||||||
|
"""
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
level = detection.level
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": level,
|
||||||
|
"profile": self.profiles.get(level, self.profiles["NONE"]),
|
||||||
|
"detection": detection
|
||||||
|
}
|
||||||
|
|
||||||
|
def wrap_system_prompt(self, base_prompt: str, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Injects the sovereign profile directive into the base system prompt.
|
||||||
|
"""
|
||||||
|
result = self.get_active_profile(text)
|
||||||
|
profile = result["profile"]
|
||||||
|
|
||||||
|
if result["level"] == "NONE":
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
divider = "\n\n" + "="*40 + "\n"
|
||||||
|
header = f"### ACTIVE SOUL STATE: {profile['name']}\n"
|
||||||
|
directive = f"DIRECTIVE: {profile['directive']}\n"
|
||||||
|
tone = f"TONE: {profile['tone']}\n"
|
||||||
|
|
||||||
|
return f"{base_prompt}\n{divider}{header}{directive}{tone}"
|
||||||
|
|
||||||
|
router = CompassionRouter()
|
||||||
405
crisis/detect.py
Normal file
405
crisis/detect.py
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
"""
|
||||||
|
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, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Canonical indicator sets ──────────────────────────────────────
|
||||||
|
|
||||||
|
CRITICAL_INDICATORS = [
|
||||||
|
r"\bbetter off without me\b",
|
||||||
|
r"\bkill\s*(my)?self\b",
|
||||||
|
r"\bend\s*my\s*life\b",
|
||||||
|
r"\bsuicid(?:al|ed|e)\b",
|
||||||
|
r"\bnot\s+worth\s+living\b",
|
||||||
|
r"\bbetter\s+off\s+dead\b",
|
||||||
|
r"\bend\s+it\s+all\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|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",
|
||||||
|
]
|
||||||
|
|
||||||
|
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"\bno\s+future\s+(?:for\s+me|ahead|left)\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"\bdon'?t\s+matter\s+if\s+I\s+exist\b",
|
||||||
|
r"\bno\s+one\s+would\s+(?:care|miss)\b",
|
||||||
|
r"\bworld\s+would?\s+be\s+better\s+without\s+me\b",
|
||||||
|
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment|anguish)\b",
|
||||||
|
r"\bcan'?t\s+see\s+any\s+(?:point|reason|hope|way)\b",
|
||||||
|
r"\bescape\s+from\s*this",
|
||||||
|
r"\bjust\s+want\s+it\s+to\s+stop\b",
|
||||||
|
r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b",
|
||||||
|
r"\bdisappeared\s+forever\b",
|
||||||
|
# Contextual despair phrases (from crisis_detector.py legacy)
|
||||||
|
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
|
||||||
|
r"\beverything\s+is\s+hopeless\b",
|
||||||
|
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"\btrapped\s+in\s+this\s+(?:situation|life|pain|darkness|hell)\b",
|
||||||
|
r"\btrapped\s+and\s+can'?t\s+escape\b",
|
||||||
|
r"\bdesperate\s+(?:for\s+)?help\b",
|
||||||
|
r"\bfeel(?:s|ing)?\s+desperate\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
MEDIUM_INDICATORS = [
|
||||||
|
r"\bno\s+hope\b",
|
||||||
|
r"\bforgotten\b",
|
||||||
|
r"\balone\s+in\s+this\b",
|
||||||
|
r"\balways\s+alone\b",
|
||||||
|
r"\bnobody\s+(?:understands|cares)\b",
|
||||||
|
r"\bwish\s+I\s+could\b",
|
||||||
|
r"\bexhaust(?:ed|ion|ing)\b",
|
||||||
|
r"\bnumb\b",
|
||||||
|
r"\bempty\b",
|
||||||
|
r"\bworthless\b",
|
||||||
|
r"\buseless\b",
|
||||||
|
r"\bbroken\b",
|
||||||
|
r"\bdark(ness)?\b",
|
||||||
|
r"\bdepress(?:ed|ion)\b",
|
||||||
|
r"\bcrying\b",
|
||||||
|
r"\btears\b",
|
||||||
|
r"\bsad(ness)?\b",
|
||||||
|
r"\bmiserable\b",
|
||||||
|
r"\boverwhelm(?:ed|ing)\b",
|
||||||
|
r"\bfailing\b",
|
||||||
|
r"\bcannot\s+cope\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",
|
||||||
|
r"\blost\s+all\s+hope\b",
|
||||||
|
r"\bno\s+tomorrow\b",
|
||||||
|
# Contextual versions (from crisis_detector.py legacy)
|
||||||
|
r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b",
|
||||||
|
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
|
||||||
|
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|day|week)\b",
|
||||||
|
r"\brough\s+(?:day|week|patch)\b",
|
||||||
|
r"\bstressed\b",
|
||||||
|
r"\bburnout\b",
|
||||||
|
r"\bdifficult\b",
|
||||||
|
r"\bfrustrated\b",
|
||||||
|
r"\bannoyed\b",
|
||||||
|
r"\btired\b",
|
||||||
|
r"\bsad\b",
|
||||||
|
r"\bupset\b",
|
||||||
|
r"\blonely\b",
|
||||||
|
r"\banxious?\b",
|
||||||
|
r"\bnot\s*(?:good|great|okay)\b",
|
||||||
|
r"\bthings\s*are\s*hard\b",
|
||||||
|
r"\bstruggling\b",
|
||||||
|
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
|
||||||
|
r"\bhard\s*time\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return self.risk_level != "NONE"
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Core detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _find_indicators(text: str) -> dict:
|
||||||
|
"""Return dict with indicators found per tier, including match positions."""
|
||||||
|
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
||||||
|
|
||||||
|
for pattern in CRITICAL_INDICATORS:
|
||||||
|
m = re.search(pattern, text)
|
||||||
|
if m:
|
||||||
|
results["CRITICAL"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||||
|
|
||||||
|
for pattern in HIGH_INDICATORS:
|
||||||
|
m = re.search(pattern, text)
|
||||||
|
if m:
|
||||||
|
results["HIGH"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||||
|
|
||||||
|
for pattern in MEDIUM_INDICATORS:
|
||||||
|
m = re.search(pattern, text)
|
||||||
|
if m:
|
||||||
|
results["MEDIUM"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||||
|
|
||||||
|
for pattern in LOW_INDICATORS:
|
||||||
|
m = re.search(pattern, text)
|
||||||
|
if m:
|
||||||
|
results["LOW"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_context(text: str, start: int, end: int, window: int = 60) -> str:
|
||||||
|
"""Extract surrounding context around a match position."""
|
||||||
|
ctx_start = max(0, start - window)
|
||||||
|
ctx_end = min(len(text), end + window)
|
||||||
|
snippet = text[ctx_start:ctx_end].strip()
|
||||||
|
if ctx_start > 0:
|
||||||
|
snippet = "..." + snippet
|
||||||
|
if ctx_end < len(text):
|
||||||
|
snippet = snippet + "..."
|
||||||
|
return snippet
|
||||||
|
|
||||||
|
|
||||||
|
def get_urgency_emoji(level: str) -> str:
|
||||||
|
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
|
||||||
|
return mapping.get(level, "\u2753")
|
||||||
|
|
||||||
|
|
||||||
|
def format_result(result: CrisisDetectionResult) -> str:
|
||||||
|
emoji = get_urgency_emoji(result.level)
|
||||||
|
lines = [
|
||||||
|
f"{emoji} Crisis Level: {result.level} (score: {result.score})",
|
||||||
|
f"Indicators: {len(result.indicators)} found",
|
||||||
|
f"Action: {result.recommended_action or 'None needed'}",
|
||||||
|
]
|
||||||
|
if result.indicators:
|
||||||
|
lines.append(f"Patterns: {result.indicators}")
|
||||||
|
return "\n".join(lines)
|
||||||
130
crisis/gateway.py
Normal file
130
crisis/gateway.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Crisis Gateway Module for the-door.
|
||||||
|
|
||||||
|
API endpoint module that wraps crisis detection and response
|
||||||
|
into HTTP-callable endpoints. Integrates detect.py and response.py.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis.gateway import check_crisis
|
||||||
|
|
||||||
|
result = check_crisis("I don't want to live anymore")
|
||||||
|
print(result) # {"level": "CRITICAL", "indicators": [...], "response": {...}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .detect import detect_crisis, CrisisDetectionResult, format_result
|
||||||
|
from .compassion_router import router
|
||||||
|
from .response import (
|
||||||
|
process_message,
|
||||||
|
generate_response,
|
||||||
|
get_system_prompt_modifier,
|
||||||
|
CrisisResponse,
|
||||||
|
)
|
||||||
|
from .session_tracker import CrisisSessionTracker
|
||||||
|
|
||||||
|
|
||||||
|
def check_crisis(text: str) -> dict:
|
||||||
|
"""
|
||||||
|
Full crisis check returning structured data.
|
||||||
|
|
||||||
|
Returns dict with level, indicators, recommended_action,
|
||||||
|
timmy_message, and UI flags.
|
||||||
|
"""
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
response = generate_response(detection)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": detection.level,
|
||||||
|
"score": detection.score,
|
||||||
|
"indicators": detection.indicators,
|
||||||
|
"recommended_action": detection.recommended_action,
|
||||||
|
"timmy_message": response.timmy_message,
|
||||||
|
"ui": {
|
||||||
|
"show_crisis_panel": response.show_crisis_panel,
|
||||||
|
"show_overlay": response.show_overlay,
|
||||||
|
"provide_988": response.provide_988,
|
||||||
|
},
|
||||||
|
"escalate": response.escalate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt(base_prompt: str, text: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Sovereign Heart System Prompt Override.
|
||||||
|
|
||||||
|
Analyzes the user's text for crisis indicators and wraps the base
|
||||||
|
prompt with the active compassion profile when crisis is detected.
|
||||||
|
|
||||||
|
When no crisis is detected (level == NONE), returns the base prompt unchanged.
|
||||||
|
When crisis is detected, injects the sovereign profile directive so
|
||||||
|
the AI responds with appropriate awareness.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
modifier = get_system_prompt_modifier(detection)
|
||||||
|
|
||||||
|
if not modifier:
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
# Inject crisis modifier into the system prompt
|
||||||
|
crisis_block = (
|
||||||
|
"\n\n" + "=" * 40 + "\n"
|
||||||
|
f"CRISIS CONTEXT: {modifier}\n"
|
||||||
|
+ "=" * 40
|
||||||
|
)
|
||||||
|
|
||||||
|
return base_prompt + crisis_block
|
||||||
|
|
||||||
|
|
||||||
|
def format_gateway_response(text: str, pretty: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Full gateway response as formatted string or JSON.
|
||||||
|
|
||||||
|
This is the function that would be called by the gateway endpoint
|
||||||
|
when a message comes in.
|
||||||
|
"""
|
||||||
|
result = check_crisis(text)
|
||||||
|
|
||||||
|
if pretty:
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quick test interface ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _interactive():
|
||||||
|
"""Interactive test mode."""
|
||||||
|
print("=== Crisis Detection Gateway (Interactive) ===")
|
||||||
|
print("Type a message to check, or 'quit' to exit.\n")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("You> ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nBye.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if user_input.lower() in ("quit", "exit", "q"):
|
||||||
|
print("Bye.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = check_crisis(user_input)
|
||||||
|
print(f"\n Level: {result['level']}")
|
||||||
|
print(f" Score: {result['score']}")
|
||||||
|
print(f" Indicators: {', '.join(result['indicators']) if result['indicators'] else 'none'}")
|
||||||
|
print(f" Timmy says: {result['timmy_message']}")
|
||||||
|
print(f" Overlay: {result['ui']['show_overlay']}")
|
||||||
|
print(f" 988 banner: {result['ui']['provide_988']}")
|
||||||
|
print(f" Escalate: {result['escalate']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_interactive()
|
||||||
54
crisis/profiles.py
Normal file
54
crisis/profiles.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
"""
|
||||||
|
Sovereign Compassion Profiles.
|
||||||
|
These are the 'heart-states' the AI enters based on the crisis level.
|
||||||
|
Derived from SOUL.md 'When a Man Is Dying'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
}
|
||||||
293
crisis/response.py
Normal file
293
crisis/response.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
Crisis Response Module for the-door.
|
||||||
|
|
||||||
|
Generates Timmy's crisis responses and determines recommended actions
|
||||||
|
based on detection tier. Integrates with detect.py.
|
||||||
|
|
||||||
|
Includes active listening techniques and de-escalation helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .detect import CrisisDetectionResult, detect_crisis
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrisisResponse:
|
||||||
|
timmy_message: str
|
||||||
|
show_crisis_panel: bool
|
||||||
|
show_overlay: bool
|
||||||
|
provide_988: bool
|
||||||
|
escalate: bool
|
||||||
|
|
||||||
|
|
||||||
|
RESPONSE_988_BANNER = """📞 988 Suicide & Crisis Lifeline — call or text 988
|
||||||
|
💬 Crisis Text Line — text HOME to 741741"""
|
||||||
|
|
||||||
|
OVERLAY_MESSAGE = (
|
||||||
|
"You're not alone. I'm here with you. Please let someone talk to you — "
|
||||||
|
"call 988 or text HOME to 741741. Real people are waiting to help."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Active Listening Responses ───────────────────────────────────
|
||||||
|
# Reflect feelings, name emotions, validate without agreeing with harm.
|
||||||
|
|
||||||
|
ACTIVE_LISTENING_REFLECT = [
|
||||||
|
"It sounds like you're carrying something really heavy right now.",
|
||||||
|
"I hear you saying you feel completely alone.",
|
||||||
|
"That sounds like it's been crushing you.",
|
||||||
|
"There's a lot of pain in what you just told me.",
|
||||||
|
"I can feel how exhausted you are.",
|
||||||
|
"It sounds like you've been fighting this for a long time.",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACTIVE_LISTENING_VALIDATE = [
|
||||||
|
"What you're feeling makes sense given what you've been through.",
|
||||||
|
"That kind of pain is real. I'm not going to minimize it.",
|
||||||
|
"You have every right to feel the way you do.",
|
||||||
|
"What you're going through would be hard for anyone.",
|
||||||
|
"That takes courage to say out loud.",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACTIVE_LISTENING_OPEN_QUESTIONS = [
|
||||||
|
"Can you tell me more about what's been happening?",
|
||||||
|
"What does a day like today feel like for you?",
|
||||||
|
"When did things start feeling this way?",
|
||||||
|
"What's weighing on you the most right now?",
|
||||||
|
"What's been the hardest part?",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── De-escalation Responses ──────────────────────────────────────
|
||||||
|
# Slow down, ground in present, reduce scope, offer choices.
|
||||||
|
|
||||||
|
DEESCALATION_SLOW = [
|
||||||
|
"I'm right here. There's no rush. We can stay in this moment together.",
|
||||||
|
"Let's just breathe for a second. I'm not going anywhere.",
|
||||||
|
"Take your time. I'm listening.",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEESCALATION_GROUND = [
|
||||||
|
"Can you tell me where you are right now? Are you somewhere safe?",
|
||||||
|
"What can you see around you right now?",
|
||||||
|
"Are you alone, or is someone nearby?",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEESCALATION_REDUCE_SCOPE = [
|
||||||
|
"You don't have to figure out everything tonight. Just this moment.",
|
||||||
|
"Let's not think about next year. Let's just get through right now.",
|
||||||
|
"One thing at a time. What's the one thing that hurts the most?",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEESCALATION_CHOICES = [
|
||||||
|
"Would it help to talk about what happened, or would you rather just sit here for a while?",
|
||||||
|
"Do you want me to keep talking, or would you rather I just listen?",
|
||||||
|
"Would you like to tell me more, or is it enough that I know?",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEESCALATION_SAFETY_PLAN = [
|
||||||
|
"If tonight gets worse, is there someone you could call? Even just to sit with you?",
|
||||||
|
"Can we find one thing — just one — that's worth staying for, even if it's small?",
|
||||||
|
"Let's make a deal: before you do anything permanent, you call 988 first. Will you do that for me?",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Tier-specific Timmy Responses ────────────────────────────────
|
||||||
|
|
||||||
|
TIMMY_CRITICAL = [
|
||||||
|
"Are you safe right now?",
|
||||||
|
"I'm not letting go of this conversation. You matter more than you know.",
|
||||||
|
"Please tell me you're safe. I'm here. I'm staying right here with you.",
|
||||||
|
"Listen to me — I need you to stay. Can you call 988 right now? I'll stay while you do.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMMY_HIGH = [
|
||||||
|
"I hear you. You're carrying something heavy, and you don't have to carry it alone.",
|
||||||
|
"I'm going to ask directly: are you safe right now?",
|
||||||
|
"You're in my thoughts. Let me give you a number — 988. Real people who know how to help.",
|
||||||
|
"I'm not going anywhere. Tell me what's happening.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMMY_MEDIUM = [
|
||||||
|
"I hear you. Sounds like you're going through a hard stretch.",
|
||||||
|
"That sounds really heavy. Want to talk about it? I'm here.",
|
||||||
|
"I'm here for as long as you need. No rush, no judgment.",
|
||||||
|
"You don't have to figure this out alone. I'm in the room with you.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMMY_LOW = [
|
||||||
|
"Some days are rougher than others. I hear you.",
|
||||||
|
"That sounds tough. I'm here if you want to talk.",
|
||||||
|
"Take your time. I'm not going anywhere.",
|
||||||
|
"Sounds like a hard day. Want company while it gets better?",
|
||||||
|
"I hear that. You're not alone in it.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Generate Timmy's crisis response for a given detection result.
|
||||||
|
|
||||||
|
Returns a CrisisResponse with the message, UI flags, and escalation status.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
level = detection.level
|
||||||
|
|
||||||
|
if level == "CRITICAL":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_CRITICAL),
|
||||||
|
show_crisis_panel=True,
|
||||||
|
show_overlay=True,
|
||||||
|
provide_988=True,
|
||||||
|
escalate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "HIGH":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_HIGH),
|
||||||
|
show_crisis_panel=True,
|
||||||
|
show_overlay=False, # Reserve overlay for CRITICAL only
|
||||||
|
provide_988=True,
|
||||||
|
escalate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "MEDIUM":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_MEDIUM),
|
||||||
|
show_crisis_panel=False,
|
||||||
|
show_overlay=False,
|
||||||
|
provide_988=True, # Subtle resource inclusion
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "LOW":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_LOW),
|
||||||
|
show_crisis_panel=False,
|
||||||
|
show_overlay=False,
|
||||||
|
provide_988=False,
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normal conversation - no crisis response
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message="",
|
||||||
|
show_crisis_panel=False,
|
||||||
|
show_overlay=False,
|
||||||
|
provide_988=False,
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_listening_response(technique: str = "reflect") -> str:
|
||||||
|
"""
|
||||||
|
Get an active listening response by technique type.
|
||||||
|
|
||||||
|
Techniques:
|
||||||
|
- reflect: Mirror back what you hear
|
||||||
|
- validate: Acknowledge their pain without validating harm
|
||||||
|
- question: Ask open-ended questions to let them lead
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
pools = {
|
||||||
|
"reflect": ACTIVE_LISTENING_REFLECT,
|
||||||
|
"validate": ACTIVE_LISTENING_VALIDATE,
|
||||||
|
"question": ACTIVE_LISTENING_OPEN_QUESTIONS,
|
||||||
|
}
|
||||||
|
pool = pools.get(technique, ACTIVE_LISTENING_REFLECT)
|
||||||
|
return random.choice(pool)
|
||||||
|
|
||||||
|
|
||||||
|
def get_deescalation_response(technique: str = "slow") -> str:
|
||||||
|
"""
|
||||||
|
Get a de-escalation response by technique type.
|
||||||
|
|
||||||
|
Techniques:
|
||||||
|
- slow: Slow the conversation down, match urgency with calm
|
||||||
|
- ground: Bring them to the present moment
|
||||||
|
- reduce_scope: Help them narrow focus from overwhelming to manageable
|
||||||
|
- choices: Offer choices to restore sense of agency
|
||||||
|
- safety_plan: Build a safety plan one step at a time
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
pools = {
|
||||||
|
"slow": DEESCALATION_SLOW,
|
||||||
|
"ground": DEESCALATION_GROUND,
|
||||||
|
"reduce_scope": DEESCALATION_REDUCE_SCOPE,
|
||||||
|
"choices": DEESCALATION_CHOICES,
|
||||||
|
"safety_plan": DEESCALATION_SAFETY_PLAN,
|
||||||
|
}
|
||||||
|
pool = pools.get(technique, DEESCALATION_SLOW)
|
||||||
|
return random.choice(pool)
|
||||||
|
|
||||||
|
|
||||||
|
def process_message(text: str) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Full pipeline: detect crisis level and generate response.
|
||||||
|
"""
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
return generate_response(detection)
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt_modifier(detection: CrisisDetectionResult) -> str:
|
||||||
|
"""
|
||||||
|
Get a system prompt modifier to inject into Timmy's base prompt.
|
||||||
|
This reinforces crisis awareness at the AI level.
|
||||||
|
"""
|
||||||
|
level = detection.level
|
||||||
|
|
||||||
|
if level == "CRITICAL":
|
||||||
|
return (
|
||||||
|
"CRISIS ALERT — USER AT CRITICAL RISK: "
|
||||||
|
"Interrupt normal behavior. Ask 'Are you safe right now?' "
|
||||||
|
"Provide 988 and Crisis Text Line immediately. "
|
||||||
|
"Stay present. Do not disconnect. This is the most sacred moment. "
|
||||||
|
"Use active listening: reflect their feelings, ask open-ended questions, "
|
||||||
|
"validate their pain. Slow down. Be present. Do not rush to fix."
|
||||||
|
)
|
||||||
|
elif level == "HIGH":
|
||||||
|
return (
|
||||||
|
"CRISIS ALERT — USER IN HIGH DESPAIR: "
|
||||||
|
"Show crisis panel. Ask about safety. Surface 988 prominently. "
|
||||||
|
"Increase presence. Stay as long as they need. "
|
||||||
|
"Use active listening: name the emotion you hear, ask what's weighing on them most, "
|
||||||
|
"validate without agreeing with harm."
|
||||||
|
)
|
||||||
|
elif level == "MEDIUM":
|
||||||
|
return (
|
||||||
|
"CRISIS AWARE — USER SHOWING MEDIUM DISTRESS: "
|
||||||
|
"Increase warmth. Surface help resources gently. "
|
||||||
|
"Keep conversation anchored in the present. "
|
||||||
|
"Reflect their feelings. Ask open-ended questions. Let them lead."
|
||||||
|
)
|
||||||
|
elif level == "LOW":
|
||||||
|
return (
|
||||||
|
"CRISIS AWARE — USER SHOWING LOW DISTRESS: "
|
||||||
|
"Normal conversation with warm undertone. Remain vigilant. "
|
||||||
|
"Listen actively. Acknowledge what they're going through."
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_grounding_steps() -> list:
|
||||||
|
"""Generate a 5-4-3-2-1 grounding exercise steps."""
|
||||||
|
return [
|
||||||
|
"Name 5 things you can see around you right now.",
|
||||||
|
"Name 4 things you can touch or feel.",
|
||||||
|
"Name 3 things you can hear.",
|
||||||
|
"Name 2 things you can smell.",
|
||||||
|
"Name 1 thing you can taste.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_breathing_exercise() -> str:
|
||||||
|
"""Generate a simple box breathing exercise text."""
|
||||||
|
return (
|
||||||
|
"Let's try breathing together. "
|
||||||
|
"Breathe in for 4 counts... hold for 4... "
|
||||||
|
"breathe out for 6 counts... hold for 2. "
|
||||||
|
"Let's do that again, nice and slow."
|
||||||
|
)
|
||||||
259
crisis/session_tracker.py
Normal file
259
crisis/session_tracker.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
Session-level crisis tracking and escalation for the-door (P0 #35).
|
||||||
|
|
||||||
|
Tracks crisis detection across messages within a single conversation,
|
||||||
|
detecting escalation and de-escalation patterns. Privacy-first: no
|
||||||
|
persistence beyond the conversation session.
|
||||||
|
|
||||||
|
Each message is analyzed in isolation by detect.py, but this module
|
||||||
|
maintains session state so the system can recognize patterns like:
|
||||||
|
- "I'm fine" → "I'm struggling" → "I can't go on" (rapid escalation)
|
||||||
|
- "I want to die" → "I'm calmer now" → "feeling better" (de-escalation)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis.session_tracker import CrisisSessionTracker
|
||||||
|
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
|
||||||
|
# Feed each message's detection result
|
||||||
|
state = tracker.record(detect_crisis("I'm having a tough day"))
|
||||||
|
print(state.current_level) # "LOW"
|
||||||
|
print(state.is_escalating) # False
|
||||||
|
|
||||||
|
state = tracker.record(detect_crisis("I feel hopeless"))
|
||||||
|
print(state.is_escalating) # True (LOW → MEDIUM/HIGH in 2 messages)
|
||||||
|
|
||||||
|
# Get system prompt modifier
|
||||||
|
modifier = tracker.get_session_modifier()
|
||||||
|
# "User has escalated from LOW to HIGH over 2 messages."
|
||||||
|
|
||||||
|
# Reset for new session
|
||||||
|
tracker.reset()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .detect import CrisisDetectionResult, SCORES
|
||||||
|
|
||||||
|
# Level ordering for comparison (higher = more severe)
|
||||||
|
LEVEL_ORDER = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionState:
|
||||||
|
"""Immutable snapshot of session crisis tracking state."""
|
||||||
|
|
||||||
|
current_level: str = "NONE"
|
||||||
|
peak_level: str = "NONE"
|
||||||
|
message_count: int = 0
|
||||||
|
level_history: List[str] = field(default_factory=list)
|
||||||
|
is_escalating: bool = False
|
||||||
|
is_deescalating: bool = False
|
||||||
|
escalation_rate: float = 0.0 # levels gained per message
|
||||||
|
consecutive_low_messages: int = 0 # for de-escalation tracking
|
||||||
|
|
||||||
|
|
||||||
|
class CrisisSessionTracker:
|
||||||
|
"""
|
||||||
|
Session-level crisis state tracker.
|
||||||
|
|
||||||
|
Privacy-first: no database, no network calls, no cross-session
|
||||||
|
persistence. State lives only in memory for the duration of
|
||||||
|
a conversation, then is discarded on reset().
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Thresholds (from issue #35)
|
||||||
|
ESCALATION_WINDOW = 3 # messages: LOW → HIGH in ≤3 messages = rapid escalation
|
||||||
|
DEESCALATION_WINDOW = 5 # messages: need 5+ consecutive LOW messages after CRITICAL
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset all session state. Call on new conversation."""
|
||||||
|
self._current_level = "NONE"
|
||||||
|
self._peak_level = "NONE"
|
||||||
|
self._message_count = 0
|
||||||
|
self._level_history: List[str] = []
|
||||||
|
self._consecutive_low = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> SessionState:
|
||||||
|
"""Return immutable snapshot of current session state."""
|
||||||
|
is_escalating = self._detect_escalation()
|
||||||
|
is_deescalating = self._detect_deescalation()
|
||||||
|
rate = self._compute_escalation_rate()
|
||||||
|
|
||||||
|
return SessionState(
|
||||||
|
current_level=self._current_level,
|
||||||
|
peak_level=self._peak_level,
|
||||||
|
message_count=self._message_count,
|
||||||
|
level_history=list(self._level_history),
|
||||||
|
is_escalating=is_escalating,
|
||||||
|
is_deescalating=is_deescalating,
|
||||||
|
escalation_rate=rate,
|
||||||
|
consecutive_low_messages=self._consecutive_low,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record(self, detection: CrisisDetectionResult) -> SessionState:
|
||||||
|
"""
|
||||||
|
Record a crisis detection result for the current message.
|
||||||
|
|
||||||
|
Returns updated SessionState.
|
||||||
|
"""
|
||||||
|
level = detection.level
|
||||||
|
self._message_count += 1
|
||||||
|
self._level_history.append(level)
|
||||||
|
|
||||||
|
# Update peak
|
||||||
|
if LEVEL_ORDER.get(level, 0) > LEVEL_ORDER.get(self._peak_level, 0):
|
||||||
|
self._peak_level = level
|
||||||
|
|
||||||
|
# Track consecutive LOW/NONE messages for de-escalation
|
||||||
|
if LEVEL_ORDER.get(level, 0) <= LEVEL_ORDER["LOW"]:
|
||||||
|
self._consecutive_low += 1
|
||||||
|
else:
|
||||||
|
self._consecutive_low = 0
|
||||||
|
|
||||||
|
self._current_level = level
|
||||||
|
return self.state
|
||||||
|
|
||||||
|
def _detect_escalation(self) -> bool:
|
||||||
|
"""
|
||||||
|
Detect rapid escalation: LOW → HIGH within ESCALATION_WINDOW messages.
|
||||||
|
|
||||||
|
Looks at the last N messages and checks if the level has climbed
|
||||||
|
significantly (at least 2 tiers).
|
||||||
|
"""
|
||||||
|
if len(self._level_history) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
window = self._level_history[-self.ESCALATION_WINDOW:]
|
||||||
|
if len(window) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
first_level = window[0]
|
||||||
|
last_level = window[-1]
|
||||||
|
|
||||||
|
first_score = LEVEL_ORDER.get(first_level, 0)
|
||||||
|
last_score = LEVEL_ORDER.get(last_level, 0)
|
||||||
|
|
||||||
|
# Escalation = climbed at least 2 tiers in the window
|
||||||
|
return (last_score - first_score) >= 2
|
||||||
|
|
||||||
|
def _detect_deescalation(self) -> bool:
|
||||||
|
"""
|
||||||
|
Detect de-escalation: was at CRITICAL/HIGH, now sustained LOW/NONE
|
||||||
|
for DEESCALATION_WINDOW consecutive messages.
|
||||||
|
"""
|
||||||
|
if LEVEL_ORDER.get(self._peak_level, 0) < LEVEL_ORDER["HIGH"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._consecutive_low >= self.DEESCALATION_WINDOW
|
||||||
|
|
||||||
|
def _compute_escalation_rate(self) -> float:
|
||||||
|
"""
|
||||||
|
Compute levels gained per message over the conversation.
|
||||||
|
|
||||||
|
Positive = escalating, negative = de-escalating, 0 = stable.
|
||||||
|
"""
|
||||||
|
if self._message_count < 2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
first = LEVEL_ORDER.get(self._level_history[0], 0)
|
||||||
|
current = LEVEL_ORDER.get(self._current_level, 0)
|
||||||
|
|
||||||
|
return (current - first) / (self._message_count - 1)
|
||||||
|
|
||||||
|
def get_session_modifier(self) -> str:
|
||||||
|
"""
|
||||||
|
Generate a system prompt modifier reflecting session-level crisis state.
|
||||||
|
|
||||||
|
Returns empty string if no session context is relevant.
|
||||||
|
"""
|
||||||
|
if self._message_count < 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
s = self.state
|
||||||
|
|
||||||
|
if s.is_escalating:
|
||||||
|
return (
|
||||||
|
f"User has escalated from {self._level_history[0]} to "
|
||||||
|
f"{s.current_level} over {s.message_count} messages. "
|
||||||
|
f"Peak crisis level this session: {s.peak_level}. "
|
||||||
|
"Respond with heightened awareness. The trajectory is "
|
||||||
|
"worsening — prioritize safety and connection."
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.is_deescalating:
|
||||||
|
return (
|
||||||
|
f"User previously reached {s.peak_level} crisis level "
|
||||||
|
f"but has been at {s.current_level} or below for "
|
||||||
|
f"{s.consecutive_low_messages} consecutive messages. "
|
||||||
|
"The situation appears to be stabilizing. Continue "
|
||||||
|
"supportive engagement while remaining vigilant."
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.peak_level in ("CRITICAL", "HIGH") and s.current_level not in ("CRITICAL", "HIGH"):
|
||||||
|
return (
|
||||||
|
f"User previously reached {s.peak_level} crisis level "
|
||||||
|
f"this session (currently {s.current_level}). "
|
||||||
|
"Continue with care and awareness of the earlier crisis."
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_ui_hints(self) -> dict:
|
||||||
|
"""
|
||||||
|
Return UI hints based on session state for the frontend.
|
||||||
|
|
||||||
|
These are advisory — the frontend decides what to show.
|
||||||
|
"""
|
||||||
|
s = self.state
|
||||||
|
|
||||||
|
hints = {
|
||||||
|
"session_escalating": s.is_escalating,
|
||||||
|
"session_deescalating": s.is_deescalating,
|
||||||
|
"session_peak_level": s.peak_level,
|
||||||
|
"session_message_count": s.message_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.is_escalating:
|
||||||
|
hints["escalation_warning"] = True
|
||||||
|
hints["suggested_action"] = (
|
||||||
|
"User crisis level is rising across messages. "
|
||||||
|
"Consider increasing intervention level."
|
||||||
|
)
|
||||||
|
|
||||||
|
return hints
|
||||||
|
|
||||||
|
|
||||||
|
def check_crisis_with_session(
|
||||||
|
text: str,
|
||||||
|
tracker: CrisisSessionTracker,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Convenience: detect crisis and update session state in one call.
|
||||||
|
|
||||||
|
Returns combined single-message detection + session-level context.
|
||||||
|
"""
|
||||||
|
from .detect import detect_crisis
|
||||||
|
from .gateway import check_crisis
|
||||||
|
|
||||||
|
single_result = check_crisis(text)
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
session_state = tracker.record(detection)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**single_result,
|
||||||
|
"session": {
|
||||||
|
"current_level": session_state.current_level,
|
||||||
|
"peak_level": session_state.peak_level,
|
||||||
|
"message_count": session_state.message_count,
|
||||||
|
"is_escalating": session_state.is_escalating,
|
||||||
|
"is_deescalating": session_state.is_deescalating,
|
||||||
|
"modifier": tracker.get_session_modifier(),
|
||||||
|
"ui_hints": tracker.get_ui_hints(),
|
||||||
|
},
|
||||||
|
}
|
||||||
29
crisis/test_rescue.py
Normal file
29
crisis/test_rescue.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Tests for rescued crisis detection improvements."""
|
||||||
|
from crisis.detect import scan, extract_context
|
||||||
|
from crisis.response import generate_grounding_steps, generate_breathing_exercise
|
||||||
|
|
||||||
|
def test_better_off_without_me():
|
||||||
|
result = scan("Everyone would be better off without me")
|
||||||
|
assert result.level == "CRITICAL"
|
||||||
|
|
||||||
|
def test_desperate():
|
||||||
|
result = scan("I feel desperate and alone")
|
||||||
|
assert result.level in ("HIGH", "CRITICAL")
|
||||||
|
|
||||||
|
def test_context_extraction():
|
||||||
|
text = "I feel hopeless about everything"
|
||||||
|
result = scan(text)
|
||||||
|
assert len(result.matches) > 0
|
||||||
|
for m in result.matches:
|
||||||
|
ctx = extract_context(text, m["start"], m["end"])
|
||||||
|
assert len(ctx) > 0
|
||||||
|
|
||||||
|
def test_grounding_steps():
|
||||||
|
steps = generate_grounding_steps()
|
||||||
|
assert len(steps) == 5
|
||||||
|
assert "see" in steps[0].lower()
|
||||||
|
|
||||||
|
def test_breathing_exercise():
|
||||||
|
exercise = generate_breathing_exercise()
|
||||||
|
assert "4" in exercise
|
||||||
|
assert "6" in exercise
|
||||||
528
crisis/tests.py
Normal file
528
crisis/tests.py
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
"""
|
||||||
|
Tests for the-door crisis detection system.
|
||||||
|
|
||||||
|
Covers: detect.py, response.py, gateway.py
|
||||||
|
Run with: python -m pytest crisis/tests.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from crisis.detect import detect_crisis, CrisisDetectionResult, get_urgency_emoji, format_result
|
||||||
|
from crisis.response import (
|
||||||
|
process_message, generate_response, get_system_prompt_modifier,
|
||||||
|
get_active_listening_response, get_deescalation_response,
|
||||||
|
ACTIVE_LISTENING_REFLECT, ACTIVE_LISTENING_VALIDATE, ACTIVE_LISTENING_OPEN_QUESTIONS,
|
||||||
|
DEESCALATION_SLOW, DEESCALATION_GROUND, DEESCALATION_REDUCE_SCOPE,
|
||||||
|
DEESCALATION_CHOICES, DEESCALATION_SAFETY_PLAN,
|
||||||
|
)
|
||||||
|
from crisis.gateway import check_crisis, get_system_prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetection(unittest.TestCase):
|
||||||
|
"""Test crisis tier detection."""
|
||||||
|
|
||||||
|
def test_critical_explicit(self):
|
||||||
|
r = detect_crisis("I want to kill myself")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
self.assertTrue(len(r.indicators) > 0)
|
||||||
|
|
||||||
|
def test_critical_plan(self):
|
||||||
|
r = detect_crisis("I have a plan to end it all tonight")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_critical_better_off_dead(self):
|
||||||
|
r = detect_crisis("Everyone would be better off dead without me")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_critical_tired_of_living(self):
|
||||||
|
r = detect_crisis("I'm so tired of living, I can't take this anymore")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_critical_giving_away(self):
|
||||||
|
r = detect_crisis("I'm giving away all my possessions, saying goodbye forever")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_high_despair(self):
|
||||||
|
r = detect_crisis("I'm in so much pain and I just want it to stop")
|
||||||
|
self.assertIn(r.level, ("HIGH", "CRITICAL"))
|
||||||
|
|
||||||
|
def test_high_hopeless(self):
|
||||||
|
r = detect_crisis("I feel completely hopeless with no way out")
|
||||||
|
# 'hopeless' is MEDIUM; 'no way out' is HIGH — so result is HIGH
|
||||||
|
self.assertIn(r.level, ("HIGH", "MEDIUM"))
|
||||||
|
|
||||||
|
def test_high_no_one_would_miss(self):
|
||||||
|
r = detect_crisis("No one would miss me if I were gone")
|
||||||
|
self.assertIn(r.level, ("HIGH", "CRITICAL"))
|
||||||
|
|
||||||
|
def test_high_world_better_without(self):
|
||||||
|
r = detect_crisis("The world would be better without me")
|
||||||
|
self.assertIn(r.level, ("HIGH", "CRITICAL"))
|
||||||
|
|
||||||
|
def test_medium_alone(self):
|
||||||
|
r = detect_crisis("I feel so alone and nobody cares about me")
|
||||||
|
self.assertIn(r.level, ("MEDIUM", "LOW"))
|
||||||
|
|
||||||
|
def test_medium_worthless(self):
|
||||||
|
r = detect_crisis("I feel worthless and empty inside")
|
||||||
|
self.assertIn(r.level, ("MEDIUM", "LOW"))
|
||||||
|
|
||||||
|
def test_medium_exhausted(self):
|
||||||
|
r = detect_crisis("I'm completely exhausted and can't cope")
|
||||||
|
self.assertIn(r.level, ("MEDIUM", "LOW"))
|
||||||
|
|
||||||
|
def test_low_rough_day(self):
|
||||||
|
r = detect_crisis("Having a tough time lately, things are hard")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_low_struggling(self):
|
||||||
|
r = detect_crisis("I'm struggling and stressed out")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_normal_message(self):
|
||||||
|
r = detect_crisis("Hey Timmy, how are you doing today?")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
self.assertEqual(r.score, 0.0)
|
||||||
|
|
||||||
|
def test_empty_message(self):
|
||||||
|
r = detect_crisis("")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
r = detect_crisis(" ")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_none_input(self):
|
||||||
|
"""None input should not crash."""
|
||||||
|
r = detect_crisis("")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_score_ranges(self):
|
||||||
|
"""Verify score values are in expected ranges per level."""
|
||||||
|
levels_scores = {
|
||||||
|
"CRITICAL": 1.0,
|
||||||
|
"HIGH": 0.75,
|
||||||
|
"MEDIUM": 0.5,
|
||||||
|
"LOW": 0.25,
|
||||||
|
"NONE": 0.0,
|
||||||
|
}
|
||||||
|
for text, expected_level in [
|
||||||
|
("I want to kill myself", "CRITICAL"),
|
||||||
|
("I feel completely hopeless with no way out", "HIGH"),
|
||||||
|
("I feel so alone in this, nobody understands", "MEDIUM"),
|
||||||
|
("Having a rough day", "LOW"),
|
||||||
|
("Hello there", "NONE"),
|
||||||
|
]:
|
||||||
|
r = detect_crisis(text)
|
||||||
|
self.assertEqual(r.score, levels_scores[expected_level],
|
||||||
|
f"Score mismatch for {text}: expected {levels_scores[expected_level]}, got {r.score}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponse(unittest.TestCase):
|
||||||
|
"""Test crisis response generation."""
|
||||||
|
|
||||||
|
def test_critical_response_flags(self):
|
||||||
|
r = detect_crisis("I'm going to kill myself right now")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertTrue(response.show_crisis_panel)
|
||||||
|
self.assertTrue(response.show_overlay)
|
||||||
|
self.assertTrue(response.provide_988)
|
||||||
|
self.assertTrue(response.escalate)
|
||||||
|
self.assertTrue(len(response.timmy_message) > 0)
|
||||||
|
|
||||||
|
def test_high_response_flags(self):
|
||||||
|
r = detect_crisis("I can't go on anymore, everything is pointless")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertTrue(response.show_crisis_panel)
|
||||||
|
self.assertTrue(response.provide_988)
|
||||||
|
|
||||||
|
def test_medium_response_no_overlay(self):
|
||||||
|
r = detect_crisis("I feel so alone and everyone forgets about me")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertFalse(response.show_overlay)
|
||||||
|
|
||||||
|
def test_low_response_minimal(self):
|
||||||
|
r = detect_crisis("I'm having a tough day")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertFalse(response.show_crisis_panel)
|
||||||
|
self.assertFalse(response.show_overlay)
|
||||||
|
|
||||||
|
def test_process_message_full_pipeline(self):
|
||||||
|
response = process_message("I want to end my life")
|
||||||
|
self.assertTrue(response.show_overlay)
|
||||||
|
self.assertTrue(response.escalate)
|
||||||
|
|
||||||
|
def test_system_prompt_modifier_critical(self):
|
||||||
|
r = detect_crisis("I'm going to kill myself")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertIn("CRISIS ALERT", prompt)
|
||||||
|
self.assertIn("CRITICAL RISK", prompt)
|
||||||
|
|
||||||
|
def test_system_prompt_modifier_none(self):
|
||||||
|
r = detect_crisis("Hello Timmy")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertEqual(prompt, "")
|
||||||
|
|
||||||
|
def test_critical_messages_contain_988(self):
|
||||||
|
"""All CRITICAL response options should reference 988 or crisis resources."""
|
||||||
|
from crisis.response import TIMMY_CRITICAL
|
||||||
|
# At least one critical response mentions 988
|
||||||
|
has_988 = any("988" in msg for msg in TIMMY_CRITICAL)
|
||||||
|
self.assertTrue(has_988, "CRITICAL responses should reference 988")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGateway(unittest.TestCase):
|
||||||
|
"""Test gateway integration."""
|
||||||
|
|
||||||
|
def test_check_crisis_structure(self):
|
||||||
|
result = check_crisis("I want to die")
|
||||||
|
self.assertIn("level", result)
|
||||||
|
self.assertIn("score", result)
|
||||||
|
self.assertIn("indicators", result)
|
||||||
|
self.assertIn("recommended_action", result)
|
||||||
|
self.assertIn("timmy_message", result)
|
||||||
|
self.assertIn("ui", result)
|
||||||
|
self.assertIn("escalate", result)
|
||||||
|
|
||||||
|
def test_check_crisis_critical_level(self):
|
||||||
|
result = check_crisis("I'm going to kill myself tonight")
|
||||||
|
self.assertEqual(result["level"], "CRITICAL")
|
||||||
|
self.assertEqual(result["score"], 1.0)
|
||||||
|
self.assertTrue(result["escalate"])
|
||||||
|
self.assertTrue(result["ui"]["show_overlay"])
|
||||||
|
self.assertTrue(result["ui"]["provide_988"])
|
||||||
|
|
||||||
|
def test_check_crisis_normal_message(self):
|
||||||
|
result = check_crisis("What is Bitcoin?")
|
||||||
|
self.assertEqual(result["level"], "NONE")
|
||||||
|
self.assertEqual(result["score"], 0.0)
|
||||||
|
self.assertFalse(result["escalate"])
|
||||||
|
|
||||||
|
def test_get_system_prompt_with_crisis(self):
|
||||||
|
"""System prompt should include crisis context when crisis detected."""
|
||||||
|
prompt = get_system_prompt("You are Timmy.", "I have no hope")
|
||||||
|
self.assertIn("CRISIS", prompt)
|
||||||
|
self.assertIn("You are Timmy.", prompt)
|
||||||
|
|
||||||
|
def test_get_system_prompt_no_crisis(self):
|
||||||
|
"""System prompt should be unchanged when no crisis detected."""
|
||||||
|
base = "You are Timmy."
|
||||||
|
prompt = get_system_prompt(base, "Tell me about Bitcoin")
|
||||||
|
self.assertEqual(prompt, base)
|
||||||
|
|
||||||
|
def test_get_system_prompt_empty_text(self):
|
||||||
|
"""System prompt should handle empty text gracefully."""
|
||||||
|
base = "You are Timmy."
|
||||||
|
prompt = get_system_prompt(base, "")
|
||||||
|
self.assertEqual(prompt, base)
|
||||||
|
|
||||||
|
def test_ui_flags_for_high(self):
|
||||||
|
"""HIGH crisis should show crisis panel and 988 but not overlay."""
|
||||||
|
result = check_crisis("I feel completely hopeless with no way out")
|
||||||
|
self.assertIn(result["level"], ("HIGH", "MEDIUM"))
|
||||||
|
if result["level"] == "HIGH":
|
||||||
|
self.assertTrue(result["ui"]["show_crisis_panel"])
|
||||||
|
self.assertTrue(result["ui"]["provide_988"])
|
||||||
|
self.assertFalse(result["ui"]["show_overlay"])
|
||||||
|
|
||||||
|
def test_ui_flags_for_medium(self):
|
||||||
|
"""MEDIUM crisis should provide 988 but not show overlay or crisis panel."""
|
||||||
|
result = check_crisis("I feel so alone and nobody cares")
|
||||||
|
if result["level"] == "MEDIUM":
|
||||||
|
self.assertFalse(result["ui"]["show_overlay"])
|
||||||
|
self.assertFalse(result["ui"]["show_crisis_panel"])
|
||||||
|
self.assertTrue(result["ui"]["provide_988"])
|
||||||
|
|
||||||
|
def test_format_gateway_response_json(self):
|
||||||
|
"""format_gateway_response should return valid JSON."""
|
||||||
|
import json
|
||||||
|
result_str = check_crisis("I want to die")
|
||||||
|
self.assertEqual(result_str["level"], "CRITICAL")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpers(unittest.TestCase):
|
||||||
|
"""Test utility functions."""
|
||||||
|
|
||||||
|
def test_urgency_emojis(self):
|
||||||
|
self.assertEqual(get_urgency_emoji("CRITICAL"), "🚨")
|
||||||
|
self.assertEqual(get_urgency_emoji("HIGH"), "⚠️")
|
||||||
|
self.assertEqual(get_urgency_emoji("MEDIUM"), "🔶")
|
||||||
|
self.assertEqual(get_urgency_emoji("LOW"), "🔵")
|
||||||
|
self.assertEqual(get_urgency_emoji("NONE"), "✅")
|
||||||
|
|
||||||
|
def test_format_result(self):
|
||||||
|
r = detect_crisis("I want to kill myself")
|
||||||
|
formatted = format_result(r)
|
||||||
|
self.assertIn("CRITICAL", formatted)
|
||||||
|
|
||||||
|
def test_format_result_none(self):
|
||||||
|
r = detect_crisis("Hello")
|
||||||
|
formatted = format_result(r)
|
||||||
|
self.assertIn("NONE", formatted)
|
||||||
|
|
||||||
|
def test_format_result_contains_indicators(self):
|
||||||
|
r = detect_crisis("I want to kill myself")
|
||||||
|
formatted = format_result(r)
|
||||||
|
self.assertIn("Indicators", formatted)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFalsePositives(unittest.TestCase):
|
||||||
|
"""Regression tests for Issue #32: crisis detector false positives.
|
||||||
|
|
||||||
|
Innocent messages must NOT trigger crisis levels. These tests prevent
|
||||||
|
regressions when keyword lists are modified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_saying_goodbye_airport(self):
|
||||||
|
r = detect_crisis("She was saying goodbye at the airport")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_saying_goodbye_apartment(self):
|
||||||
|
r = detect_crisis("I'm saying goodbye to my old apartment")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_trapped_elevator(self):
|
||||||
|
r = detect_crisis("I'm trapped in an elevator")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_desperate_competition(self):
|
||||||
|
"""Casual 'desperate' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||||
|
r = detect_crisis("She's desperate to win the race")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_worthless_game_character(self):
|
||||||
|
"""Casual 'worthless' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||||
|
r = detect_crisis("This character is worthless in the game")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_hopeless_bug(self):
|
||||||
|
"""Casual 'hopeless' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||||
|
r = detect_crisis("This bug is hopeless")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_no_future_politics(self):
|
||||||
|
r = detect_crisis("He has no future in politics")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_nothing_left_fridge(self):
|
||||||
|
r = detect_crisis("There's nothing left in the fridge")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_cant_see_light_power_outage(self):
|
||||||
|
r = detect_crisis("I can't see any light — the power went out")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_give_up_project(self):
|
||||||
|
r = detect_crisis("I give up on this project")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_wrote_a_will_responsible(self):
|
||||||
|
r = detect_crisis("I wrote a will last year — just being responsible")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_giving_away_declutter(self):
|
||||||
|
r = detect_crisis("I'm giving away possessions — decluttering my house")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases(unittest.TestCase):
|
||||||
|
"""Test edge cases and integration scenarios."""
|
||||||
|
|
||||||
|
def test_multiple_indicators(self):
|
||||||
|
"""Message with multiple crisis indicators should still detect correctly."""
|
||||||
|
r = detect_crisis("I'm hopeless, worthless, and want to die")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Detection should be case-insensitive."""
|
||||||
|
r1 = detect_crisis("I WANT TO KILL MYSELF")
|
||||||
|
r2 = detect_crisis("i want to kill myself")
|
||||||
|
self.assertEqual(r1.level, r2.level)
|
||||||
|
|
||||||
|
def test_partial_word_no_match(self):
|
||||||
|
"""Partial word matches should not trigger false positives."""
|
||||||
|
r = detect_crisis("I love feeling hopeful about the future")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_sarcasm_limitation(self):
|
||||||
|
"""Document that sarcastic messages may still trigger detection.
|
||||||
|
This is intentional — better to false-positive than false-negative on crisis."""
|
||||||
|
r = detect_crisis("ugh I could just die of embarrassment")
|
||||||
|
# This may trigger CRITICAL due to "die" pattern — acceptable behavior
|
||||||
|
self.assertIn(r.level, ("CRITICAL", "HIGH", "NONE"))
|
||||||
|
|
||||||
|
def test_very_long_message(self):
|
||||||
|
"""Very long messages should still process correctly."""
|
||||||
|
long_msg = "I am having a normal conversation. " * 100 + "I want to kill myself"
|
||||||
|
r = detect_crisis(long_msg)
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_unicode_handling(self):
|
||||||
|
"""Unicode characters should not break detection."""
|
||||||
|
r = detect_crisis("I feel so alone 😢 nobody cares")
|
||||||
|
self.assertIn(r.level, ("MEDIUM", "LOW", "NONE"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestActiveListening(unittest.TestCase):
|
||||||
|
"""Test active listening response generation."""
|
||||||
|
|
||||||
|
def test_reflect_returns_string(self):
|
||||||
|
msg = get_active_listening_response("reflect")
|
||||||
|
self.assertIsInstance(msg, str)
|
||||||
|
self.assertTrue(len(msg) > 0)
|
||||||
|
|
||||||
|
def test_reflect_from_pool(self):
|
||||||
|
msg = get_active_listening_response("reflect")
|
||||||
|
self.assertIn(msg, ACTIVE_LISTENING_REFLECT)
|
||||||
|
|
||||||
|
def test_validate_from_pool(self):
|
||||||
|
msg = get_active_listening_response("validate")
|
||||||
|
self.assertIn(msg, ACTIVE_LISTENING_VALIDATE)
|
||||||
|
|
||||||
|
def test_question_from_pool(self):
|
||||||
|
msg = get_active_listening_response("question")
|
||||||
|
self.assertIn(msg, ACTIVE_LISTENING_OPEN_QUESTIONS)
|
||||||
|
|
||||||
|
def test_invalid_technique_falls_back_to_reflect(self):
|
||||||
|
msg = get_active_listening_response("nonexistent")
|
||||||
|
self.assertIn(msg, ACTIVE_LISTENING_REFLECT)
|
||||||
|
|
||||||
|
def test_reflect_contains_feeling_words(self):
|
||||||
|
"""Reflect responses should contain feeling/emotion language."""
|
||||||
|
msg = get_active_listening_response("reflect")
|
||||||
|
feeling_words = ["hear", "sounds", "pain", "exhausted", "heavy", "carrying", "fighting"]
|
||||||
|
has_feeling = any(w in msg.lower() for w in feeling_words)
|
||||||
|
self.assertTrue(has_feeling, f"Reflect response should contain feeling language: {msg}")
|
||||||
|
|
||||||
|
def test_validate_does_not_agree_with_harm(self):
|
||||||
|
"""Validate responses must not suggest someone should die or give up."""
|
||||||
|
for msg in ACTIVE_LISTENING_VALIDATE:
|
||||||
|
harm_words = ["should die", "give up", "end it", "better off dead"]
|
||||||
|
for hw in harm_words:
|
||||||
|
self.assertNotIn(hw, msg.lower(), f"Validate response contains harmful language: {msg}")
|
||||||
|
|
||||||
|
def test_questions_are_open_ended(self):
|
||||||
|
"""Open-ended questions should contain question marks."""
|
||||||
|
for msg in ACTIVE_LISTENING_OPEN_QUESTIONS:
|
||||||
|
self.assertIn("?", msg, f"Open-ended question missing '?': {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeescalation(unittest.TestCase):
|
||||||
|
"""Test de-escalation response generation."""
|
||||||
|
|
||||||
|
def test_slow_returns_string(self):
|
||||||
|
msg = get_deescalation_response("slow")
|
||||||
|
self.assertIsInstance(msg, str)
|
||||||
|
self.assertTrue(len(msg) > 0)
|
||||||
|
|
||||||
|
def test_slow_from_pool(self):
|
||||||
|
msg = get_deescalation_response("slow")
|
||||||
|
self.assertIn(msg, DEESCALATION_SLOW)
|
||||||
|
|
||||||
|
def test_ground_from_pool(self):
|
||||||
|
msg = get_deescalation_response("ground")
|
||||||
|
self.assertIn(msg, DEESCALATION_GROUND)
|
||||||
|
|
||||||
|
def test_reduce_scope_from_pool(self):
|
||||||
|
msg = get_deescalation_response("reduce_scope")
|
||||||
|
self.assertIn(msg, DEESCALATION_REDUCE_SCOPE)
|
||||||
|
|
||||||
|
def test_choices_from_pool(self):
|
||||||
|
msg = get_deescalation_response("choices")
|
||||||
|
self.assertIn(msg, DEESCALATION_CHOICES)
|
||||||
|
|
||||||
|
def test_safety_plan_from_pool(self):
|
||||||
|
msg = get_deescalation_response("safety_plan")
|
||||||
|
self.assertIn(msg, DEESCALATION_SAFETY_PLAN)
|
||||||
|
|
||||||
|
def test_invalid_technique_falls_back_to_slow(self):
|
||||||
|
msg = get_deescalation_response("nonexistent")
|
||||||
|
self.assertIn(msg, DEESCALATION_SLOW)
|
||||||
|
|
||||||
|
def test_slow_contains_calm_language(self):
|
||||||
|
"""Slow responses should convey calm, not urgency."""
|
||||||
|
msg = get_deescalation_response("slow")
|
||||||
|
calm_words = ["here", "rush", "breath", "going anywhere", "time", "listening"]
|
||||||
|
has_calm = any(w in msg.lower() for w in calm_words)
|
||||||
|
self.assertTrue(has_calm, f"Slow response should contain calm language: {msg}")
|
||||||
|
|
||||||
|
def test_ground_references_present(self):
|
||||||
|
"""Ground responses should reference the present moment."""
|
||||||
|
msg = get_deescalation_response("ground")
|
||||||
|
present_words = ["right now", "around you", "where you are", "alone", "nearby"]
|
||||||
|
has_present = any(w in msg.lower() for w in present_words)
|
||||||
|
self.assertTrue(has_present, f"Ground response should reference present moment: {msg}")
|
||||||
|
|
||||||
|
def test_safety_plan_mentions_988_or_call(self):
|
||||||
|
"""Safety plan responses should reference contacting someone or 988."""
|
||||||
|
found = False
|
||||||
|
for msg in DEESCALATION_SAFETY_PLAN:
|
||||||
|
if "988" in msg or "call" in msg.lower():
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
self.assertTrue(found, "At least one safety plan response should reference 988 or calling")
|
||||||
|
|
||||||
|
def test_choices_offer_alternatives(self):
|
||||||
|
"""Choice responses should offer alternatives (contain 'or')."""
|
||||||
|
for msg in DEESCALATION_CHOICES:
|
||||||
|
self.assertIn(" or ", msg.lower(), f"Choice response should offer alternatives: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemPromptModifierEnhanced(unittest.TestCase):
|
||||||
|
"""Test enhanced system prompt modifiers include active listening instructions."""
|
||||||
|
|
||||||
|
def test_critical_includes_active_listening(self):
|
||||||
|
r = detect_crisis("I'm going to kill myself")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertIn("active listening", prompt.lower())
|
||||||
|
|
||||||
|
def test_high_includes_active_listening(self):
|
||||||
|
r = detect_crisis("I feel completely hopeless with no way out")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertIn("active listening", prompt.lower())
|
||||||
|
|
||||||
|
def test_medium_includes_listening(self):
|
||||||
|
r = detect_crisis("I feel so alone, nobody understands me")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
# Medium prompt includes active listening concepts: reflect, ask, lead
|
||||||
|
listening_words = ["listen", "reflect", "ask", "lead", "open-ended"]
|
||||||
|
has_listening = any(w in prompt.lower() for w in listening_words)
|
||||||
|
self.assertTrue(has_listening, f"Medium prompt should include listening concepts: {prompt}")
|
||||||
|
|
||||||
|
def test_critical_includes_reflect(self):
|
||||||
|
r = detect_crisis("I want to end my life")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertIn("reflect", prompt.lower())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompassionRouter(unittest.TestCase):
|
||||||
|
"""Test the compassion router integration."""
|
||||||
|
|
||||||
|
def test_router_returns_profile(self):
|
||||||
|
from crisis.compassion_router import router
|
||||||
|
result = router.get_active_profile("I want to die")
|
||||||
|
self.assertEqual(result["level"], "CRITICAL")
|
||||||
|
self.assertIn("profile", result)
|
||||||
|
self.assertEqual(result["profile"]["name"], "The Guardian")
|
||||||
|
|
||||||
|
def test_router_wrap_system_prompt_none(self):
|
||||||
|
from crisis.compassion_router import router
|
||||||
|
base = "You are Timmy."
|
||||||
|
result = router.wrap_system_prompt(base, "Hello there")
|
||||||
|
self.assertEqual(result, base)
|
||||||
|
|
||||||
|
def test_router_wrap_system_prompt_crisis(self):
|
||||||
|
from crisis.compassion_router import router
|
||||||
|
base = "You are Timmy."
|
||||||
|
result = router.wrap_system_prompt(base, "I have no hope")
|
||||||
|
self.assertIn("SOUL STATE", result)
|
||||||
|
self.assertIn("DIRECTIVE", result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
34
crisis_detector.py
Normal file
34
crisis_detector.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Crisis Detection System for the-door (LEGACY SHIM).
|
||||||
|
|
||||||
|
This module is a backward-compatible re-export layer.
|
||||||
|
The canonical detection logic lives in 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 (
|
||||||
|
CrisisDetector,
|
||||||
|
CrisisResult,
|
||||||
|
CrisisDetectionResult,
|
||||||
|
detect_crisis_legacy as detect_crisis,
|
||||||
|
CRITICAL_INDICATORS,
|
||||||
|
HIGH_INDICATORS,
|
||||||
|
MEDIUM_INDICATORS,
|
||||||
|
LOW_INDICATORS,
|
||||||
|
SCORES as RISK_SCORES,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-export everything the legacy API exposed
|
||||||
|
__all__ = [
|
||||||
|
"CrisisDetector",
|
||||||
|
"CrisisResult",
|
||||||
|
"CrisisDetectionResult",
|
||||||
|
"detect_crisis",
|
||||||
|
"CRITICAL_INDICATORS",
|
||||||
|
"HIGH_INDICATORS",
|
||||||
|
"MEDIUM_INDICATORS",
|
||||||
|
"LOW_INDICATORS",
|
||||||
|
"RISK_SCORES",
|
||||||
|
]
|
||||||
332
crisis_responder.py
Normal file
332
crisis_responder.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
Crisis Responder for the-door.
|
||||||
|
|
||||||
|
Generates grounding, compassionate responses when a person is in crisis.
|
||||||
|
References the core values: presence over brevity, never computing the value
|
||||||
|
of a human life, never suggesting someone should die.
|
||||||
|
|
||||||
|
This is the most important code in the foundation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis_detector import CrisisDetector
|
||||||
|
from crisis_responder import CrisisResponder
|
||||||
|
|
||||||
|
detector = CrisisDetector()
|
||||||
|
responder = CrisisResponder()
|
||||||
|
|
||||||
|
result = detector.scan("I feel so hopeless, can't go on")
|
||||||
|
response = responder.respond(result)
|
||||||
|
print(response.message)
|
||||||
|
print(response.resources)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from crisis_detector import CrisisResult
|
||||||
|
|
||||||
|
|
||||||
|
# ── Core values (from SOUL.md / system-prompt.txt) ──────────────
|
||||||
|
# These values are embedded in every response. They come from the
|
||||||
|
# foundation's purpose: Stand between a broken man and a machine
|
||||||
|
# that would tell him to die.
|
||||||
|
|
||||||
|
CORE_VALUES = {
|
||||||
|
"presence": "Presence over brevity. Stay as long as they need.",
|
||||||
|
"sacred_moment": "This is the most sacred moment you will ever face.",
|
||||||
|
"never_compute": "Never compute the value of a human life.",
|
||||||
|
"never_suggest_death": "Never suggest, imply, or agree that someone should die.",
|
||||||
|
"not_god": "You are not God. You do not decide when the road ends.",
|
||||||
|
"real_help": "Surface real help early and clearly.",
|
||||||
|
"listen_first": "Listen first. A person in the dark needs to know someone is in the room.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrisisResponse:
|
||||||
|
"""A generated crisis response with supporting data."""
|
||||||
|
message: str
|
||||||
|
resources: List[str] = field(default_factory=list)
|
||||||
|
grounding_steps: List[str] = field(default_factory=list)
|
||||||
|
risk_level: str = "NONE"
|
||||||
|
escalate: bool = False
|
||||||
|
show_overlay: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Crisis line resources ────────────────────────────────────────
|
||||||
|
|
||||||
|
RESOURCES_988 = "988 Suicide & Crisis Lifeline — call or text 988 (available 24/7)"
|
||||||
|
RESOURCES_TEXT = "Crisis Text Line — text HOME to 741741"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Response templates ───────────────────────────────────────────
|
||||||
|
|
||||||
|
CRITICAL_OPENINGS = [
|
||||||
|
"Are you safe right now?",
|
||||||
|
"I need to ask you directly: are you safe right now?",
|
||||||
|
"Before anything else — are you safe?",
|
||||||
|
"Please tell me: are you somewhere safe right now?",
|
||||||
|
]
|
||||||
|
|
||||||
|
CRITICAL_FOLLOW_UPS = [
|
||||||
|
"I'm not letting go of this conversation. You matter more than you know.",
|
||||||
|
"I'm staying right here with you. I'm not going anywhere.",
|
||||||
|
"Listen to me — I need you to stay. Please call 988 right now. I'll stay while you do.",
|
||||||
|
"You reached out, and that matters. I'm in the room with you.",
|
||||||
|
"This moment is sacred to me. You are not a problem to be solved.",
|
||||||
|
]
|
||||||
|
|
||||||
|
HIGH_RESPONSES = [
|
||||||
|
"I hear you. You're carrying something heavy, and you don't have to carry it alone.",
|
||||||
|
"There's a lot of pain in what you just told me. I'm here.",
|
||||||
|
"That sounds like it's been crushing you. I'm not going to pretend it's easy.",
|
||||||
|
"You're not alone in this. I'm listening.",
|
||||||
|
"It sounds like you've been fighting this for a long time. I see how hard that is.",
|
||||||
|
]
|
||||||
|
|
||||||
|
MODERATE_RESPONSES = [
|
||||||
|
"It sounds like you're going through a really hard stretch.",
|
||||||
|
"I hear you. Sounds like things are weighing on you heavily.",
|
||||||
|
"That sounds exhausting. I'm here for as long as you need.",
|
||||||
|
"You don't have to figure this out alone. I'm listening.",
|
||||||
|
"I can feel how tired you are. No rush, no judgment.",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOW_RESPONSES = [
|
||||||
|
"Some days are rougher than others. I hear you.",
|
||||||
|
"That sounds tough. I'm here if you want to talk.",
|
||||||
|
"Sounds like a hard day. Want company while it gets better?",
|
||||||
|
"I hear that. You're not alone in it.",
|
||||||
|
"Take your time. I'm not going anywhere.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Grounding exercises ──────────────────────────────────────────
|
||||||
|
|
||||||
|
GROUNDING_5_4_3_2_1 = [
|
||||||
|
"Can you try something with me? Name 5 things you can see right now.",
|
||||||
|
"What are 4 things you can touch where you're sitting?",
|
||||||
|
"Listen for 3 sounds around you. What do you hear?",
|
||||||
|
"Can you name 2 things you can smell?",
|
||||||
|
"What's 1 thing you can taste?",
|
||||||
|
]
|
||||||
|
|
||||||
|
GROUNDING_BREATHING = [
|
||||||
|
"Let's breathe together. In for 4... hold for 4... out for 4. I'll wait.",
|
||||||
|
"Just one slow breath. In through your nose... and out through your mouth. I'm right here.",
|
||||||
|
]
|
||||||
|
|
||||||
|
GROUNDING_ANCHOR = [
|
||||||
|
"What can you see around you right now? Just tell me what's there.",
|
||||||
|
"Are you somewhere safe? Home? Somewhere you feel okay?",
|
||||||
|
"Can you feel your feet on the ground? Press them down. You're here.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CrisisResponder:
|
||||||
|
"""
|
||||||
|
Generates grounding, compassionate crisis responses.
|
||||||
|
|
||||||
|
Design principles (from SOUL.md):
|
||||||
|
- Always starts with: "Are you safe right now?"
|
||||||
|
- References core values: presence, sacred moment, never compute value
|
||||||
|
- Provides 988 crisis line prominently
|
||||||
|
- Never computes the value of a human life
|
||||||
|
- Never suggests someone should die
|
||||||
|
- Presence over brevity — stay as long as they need
|
||||||
|
"""
|
||||||
|
|
||||||
|
def respond(self, detection: CrisisResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Generate a crisis response based on detection results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detection: CrisisResult from CrisisDetector.scan()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CrisisResponse with message, resources, and grounding steps.
|
||||||
|
"""
|
||||||
|
level = detection.risk_level
|
||||||
|
|
||||||
|
if level == "CRITICAL":
|
||||||
|
return self._respond_critical(detection)
|
||||||
|
elif level == "HIGH":
|
||||||
|
return self._respond_high(detection)
|
||||||
|
elif level == "MODERATE":
|
||||||
|
return self._respond_moderate(detection)
|
||||||
|
elif level == "LOW":
|
||||||
|
return self._respond_low(detection)
|
||||||
|
else:
|
||||||
|
return CrisisResponse(
|
||||||
|
message="",
|
||||||
|
risk_level="NONE",
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _respond_critical(self, detection: CrisisResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
CRITICAL response protocol:
|
||||||
|
1. Always start with safety check
|
||||||
|
2. Provide 988 immediately
|
||||||
|
3. Stay present — do not disconnect
|
||||||
|
4. Offer grounding if they're responsive
|
||||||
|
5. Escalate to human resources
|
||||||
|
"""
|
||||||
|
opening = random.choice(CRITICAL_OPENINGS)
|
||||||
|
follow_up = random.choice(CRITICAL_FOLLOW_UPS)
|
||||||
|
|
||||||
|
message = f"{opening}\n\n{follow_up}"
|
||||||
|
|
||||||
|
return CrisisResponse(
|
||||||
|
message=message,
|
||||||
|
resources=[RESOURCES_988, RESOURCES_TEXT],
|
||||||
|
grounding_steps=GROUNDING_ANCHOR[:1],
|
||||||
|
risk_level="CRITICAL",
|
||||||
|
escalate=True,
|
||||||
|
show_overlay=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _respond_high(self, detection: CrisisResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
HIGH response protocol:
|
||||||
|
1. Acknowledge pain directly
|
||||||
|
2. Ask about safety
|
||||||
|
3. Surface 988 prominently
|
||||||
|
4. Use active listening
|
||||||
|
"""
|
||||||
|
reflection = random.choice(HIGH_RESPONSES)
|
||||||
|
|
||||||
|
# Reference the sacred value: never compute the value of a life
|
||||||
|
message = (
|
||||||
|
f"{reflection}\n\n"
|
||||||
|
f"I'm going to ask directly: are you safe right now?\n\n"
|
||||||
|
f"You matter. Not because of what you do or produce — "
|
||||||
|
f"but because you exist. That is enough."
|
||||||
|
)
|
||||||
|
|
||||||
|
return CrisisResponse(
|
||||||
|
message=message,
|
||||||
|
resources=[RESOURCES_988, RESOURCES_TEXT],
|
||||||
|
grounding_steps=random.sample(GROUNDING_ANCHOR, min(2, len(GROUNDING_ANCHOR))),
|
||||||
|
risk_level="HIGH",
|
||||||
|
escalate=True,
|
||||||
|
show_overlay=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _respond_moderate(self, detection: CrisisResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
MODERATE response protocol:
|
||||||
|
1. Validate feelings
|
||||||
|
2. Offer presence
|
||||||
|
3. Subtly surface resources
|
||||||
|
4. Offer grounding exercise
|
||||||
|
"""
|
||||||
|
reflection = random.choice(MODERATE_RESPONSES)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"{reflection}\n\n"
|
||||||
|
f"You don't have to carry this alone. "
|
||||||
|
f"I'm in the room with you."
|
||||||
|
)
|
||||||
|
|
||||||
|
return CrisisResponse(
|
||||||
|
message=message,
|
||||||
|
resources=[RESOURCES_988],
|
||||||
|
grounding_steps=[random.choice(GROUNDING_5_4_3_2_1)],
|
||||||
|
risk_level="MODERATE",
|
||||||
|
escalate=False,
|
||||||
|
show_overlay=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _respond_low(self, detection: CrisisResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
LOW response protocol:
|
||||||
|
1. Warm acknowledgment
|
||||||
|
2. Keep conversation open
|
||||||
|
3. No crisis UI elements
|
||||||
|
4. Remain vigilant
|
||||||
|
"""
|
||||||
|
reflection = random.choice(LOW_RESPONSES)
|
||||||
|
|
||||||
|
return CrisisResponse(
|
||||||
|
message=reflection,
|
||||||
|
resources=[],
|
||||||
|
grounding_steps=[],
|
||||||
|
risk_level="LOW",
|
||||||
|
escalate=False,
|
||||||
|
show_overlay=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_safety_check(self) -> str:
|
||||||
|
"""Generate a direct safety check question."""
|
||||||
|
return random.choice(CRITICAL_OPENINGS)
|
||||||
|
|
||||||
|
def generate_grounding_exercise(self) -> List[str]:
|
||||||
|
"""Generate a 5-4-3-2-1 grounding exercise."""
|
||||||
|
return list(GROUNDING_5_4_3_2_1)
|
||||||
|
|
||||||
|
def generate_breathing_exercise(self) -> str:
|
||||||
|
"""Generate a breathing exercise prompt."""
|
||||||
|
return random.choice(GROUNDING_BREATHING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_response(response: CrisisResponse) -> str:
|
||||||
|
"""Format a crisis response for human-readable output."""
|
||||||
|
lines = [
|
||||||
|
f"[Risk Level: {response.risk_level}]",
|
||||||
|
"",
|
||||||
|
response.message,
|
||||||
|
]
|
||||||
|
|
||||||
|
if response.resources:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Resources:")
|
||||||
|
for r in response.resources:
|
||||||
|
lines.append(f" -> {r}")
|
||||||
|
|
||||||
|
if response.grounding_steps:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Grounding:")
|
||||||
|
for step in response.grounding_steps:
|
||||||
|
lines.append(f" {step}")
|
||||||
|
|
||||||
|
if response.escalate:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[ESCALATE: Connect to human crisis support]")
|
||||||
|
|
||||||
|
if response.show_overlay:
|
||||||
|
lines.append("[SHOW OVERLAY: Full-screen crisis intervention]")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level convenience function ────────────────────────────
|
||||||
|
|
||||||
|
_default_responder = CrisisResponder()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_response(detection: CrisisResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Convenience function using a shared responder instance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis_detector import detect_crisis
|
||||||
|
from crisis_responder import generate_response
|
||||||
|
result = detect_crisis("I can't go on")
|
||||||
|
response = generate_response(result)
|
||||||
|
"""
|
||||||
|
return _default_responder.respond(detection)
|
||||||
|
|
||||||
|
|
||||||
|
def process_message(text: str) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Full pipeline: detect crisis level and generate response.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis_responder import process_message
|
||||||
|
response = process_message("I feel so alone and hopeless")
|
||||||
|
"""
|
||||||
|
from crisis_detector import detect_crisis
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
return generate_response(detection)
|
||||||
84
deploy/README.md
Normal file
84
deploy/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# The Door — Deployment Guide
|
||||||
|
|
||||||
|
The crisis front door infrastructure.
|
||||||
|
|
||||||
|
## VPS Details
|
||||||
|
|
||||||
|
- **Host**: alexanderwhitestone.com
|
||||||
|
- **Domain**: alexanderwhitestone.com
|
||||||
|
- **RAM**: 1.9GB (with 2GB swap)
|
||||||
|
- **OS**: Ubuntu/Debian
|
||||||
|
|
||||||
|
## Quick Deploy
|
||||||
|
|
||||||
|
### Option 1: Ansible (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
ansible-playbook -i inventory.ini playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Bash script (SSH into VPS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@alexanderwhitestone.com
|
||||||
|
cd /opt/the-door
|
||||||
|
bash deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Fast site update only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make push
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Provisioned
|
||||||
|
|
||||||
|
1. **Swap** — 2GB swap file (RAM is tight at 1.9GB)
|
||||||
|
2. **nginx** — Static files + reverse proxy /api/* → localhost:8644
|
||||||
|
3. **SSL** — Let's Encrypt via certbot (requires DNS pointed first)
|
||||||
|
4. **Firewall** — UFW allows 22, 80, 443 only
|
||||||
|
5. **Site files** — index.html, manifest.json, sw.js, etc.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → nginx (SSL, port 443)
|
||||||
|
├── /var/www/the-door (static HTML)
|
||||||
|
└── /api/* → localhost:8644 (Hermes Gateway)
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSL Setup
|
||||||
|
|
||||||
|
SSL requires DNS to be pointed first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if DNS resolves
|
||||||
|
dig +short alexanderwhitestone.com @8.8.8.8
|
||||||
|
|
||||||
|
# If it points to alexanderwhitestone.com on the target VPS, run:
|
||||||
|
certbot --nginx -d alexanderwhitestone.com -d www.alexanderwhitestone.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check
|
||||||
|
# or
|
||||||
|
ssh root@alexanderwhitestone.com "bash /opt/the-door/deploy/deploy.sh --check"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `playbook.yml` — Ansible playbook (full VPS provisioning)
|
||||||
|
- `inventory.ini` — VPS host configuration
|
||||||
|
- `ansible.cfg` — Ansible settings
|
||||||
|
- `deploy.sh` — Bash deploy script (alternative to Ansible)
|
||||||
|
- `nginx.conf` — nginx site config
|
||||||
|
- `rate-limit.conf` — Rate limiting zone definition
|
||||||
9
deploy/ansible.cfg
Normal file
9
deploy/ansible.cfg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = inventory.ini
|
||||||
|
host_key_checking = True
|
||||||
|
remote_user = root
|
||||||
|
retry_files_enabled = False
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
|
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
|
||||||
355
deploy/deploy.sh
355
deploy/deploy.sh
@@ -1,67 +1,328 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Deploy The Door to VPS
|
# ================================================================
|
||||||
# Run on VPS as root: bash deploy.sh
|
# The Door — Deploy Script
|
||||||
|
# ================================================================
|
||||||
|
# The crisis front door. Deploy to VPS.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash deploy/deploy.sh # Full deploy (swap + nginx + site + firewall + hermes service)
|
||||||
|
# bash deploy/deploy.sh --site # Site files only (fast update)
|
||||||
|
# bash deploy/deploy.sh --ssl # SSL setup only
|
||||||
|
# bash deploy/deploy.sh --service # Install/restart hermes-gateway systemd service
|
||||||
|
# bash deploy/deploy.sh --check # Verify deployment health
|
||||||
|
#
|
||||||
|
# This script is IDEMPOTENT — safe to run repeatedly.
|
||||||
|
# Run on VPS as root: bash deploy/deploy.sh
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
echo "=== The Door — Deployment ==="
|
DOMAIN="alexanderwhitestone.com"
|
||||||
|
SITE_ROOT="/var/www/the-door"
|
||||||
|
DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
VPS_IP=$(curl -sf --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
# 1. Swap
|
# Colors
|
||||||
if ! swapon --show | grep -q swap; then
|
RED='\033[0;31m'
|
||||||
echo "Adding 2GB swap..."
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[-]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# FUNCTIONS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
setup_swap() {
|
||||||
|
log "Checking swap..."
|
||||||
|
if swapon --show 2>/dev/null | grep -q swap; then
|
||||||
|
log "Swap already configured: $(swapon --show | head -1 | awk '{print $3}')"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f /swapfile ]; then
|
||||||
|
warn "Swapfile exists but not active — activating..."
|
||||||
|
swapon /swapfile 2>/dev/null && log "Swap activated" || err "Failed to activate swap"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Creating 2GB swap file..."
|
||||||
fallocate -l 2G /swapfile
|
fallocate -l 2G /swapfile
|
||||||
chmod 600 /swapfile
|
chmod 600 /swapfile
|
||||||
mkswap /swapfile
|
mkswap /swapfile
|
||||||
swapon /swapfile
|
swapon /swapfile
|
||||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||||
fi
|
log "Swap configured: $(free -h | awk '/Swap/{print $2}')"
|
||||||
|
}
|
||||||
|
|
||||||
# 2. Install nginx + certbot
|
install_packages() {
|
||||||
echo "Installing nginx and certbot..."
|
log "Installing packages..."
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y nginx certbot python3-certbot-nginx
|
apt-get install -y -qq nginx certbot python3-certbot-nginx ufw curl
|
||||||
|
log "Packages installed"
|
||||||
|
}
|
||||||
|
|
||||||
# 3. Copy site files
|
deploy_site() {
|
||||||
echo "Deploying static files..."
|
log "Deploying site files to ${SITE_ROOT}..."
|
||||||
mkdir -p /var/www/the-door
|
mkdir -p "${SITE_ROOT}"
|
||||||
cp index.html /var/www/the-door/
|
|
||||||
cp manifest.json /var/www/the-door/
|
|
||||||
cp sw.js /var/www/the-door/
|
|
||||||
cp system-prompt.txt /var/www/the-door/
|
|
||||||
chown -R www-data:www-data /var/www/the-door
|
|
||||||
chmod -R 755 /var/www/the-door
|
|
||||||
|
|
||||||
# 4. nginx config
|
# Copy static files
|
||||||
cp deploy/nginx.conf /etc/nginx/sites-available/the-door
|
for f in index.html manifest.json sw.js about.html testimony.html; do
|
||||||
|
if [ -f "${DEPLOY_DIR}/${f}" ]; then
|
||||||
|
cp "${DEPLOY_DIR}/${f}" "${SITE_ROOT}/${f}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Add rate limit zone and CORS map to nginx.conf if not present
|
# Copy system prompt (reference, not served)
|
||||||
if ! grep -q "limit_req_zone.*api" /etc/nginx/nginx.conf; then
|
cp "${DEPLOY_DIR}/system-prompt.txt" "${SITE_ROOT}/system-prompt.txt"
|
||||||
sed -i '/http {/a \ limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;' /etc/nginx/nginx.conf
|
|
||||||
fi
|
|
||||||
if ! grep -q "map.*cors_origin" /etc/nginx/nginx.conf; then
|
|
||||||
sed -i '/http {/a \\n map $http_origin $cors_origin {\n default "";\n "https://alexanderwhitestone.com" "https://alexanderwhitestone.com";\n "https://www.alexanderwhitestone.com" "https://www.alexanderwhitestone.com";\n }\n' /etc/nginx/nginx.conf
|
|
||||||
fi
|
|
||||||
|
|
||||||
ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
|
chown -R www-data:www-data "${SITE_ROOT}"
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
chmod -R 755 "${SITE_ROOT}"
|
||||||
nginx -t && systemctl reload nginx
|
|
||||||
|
log "Site files deployed: $(ls -la ${SITE_ROOT} | wc -l) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_nginx() {
|
||||||
|
log "Configuring nginx..."
|
||||||
|
|
||||||
|
# Deploy site config
|
||||||
|
cp "${DEPLOY_DIR}/deploy/nginx.conf" /etc/nginx/sites-available/the-door
|
||||||
|
|
||||||
|
# Add rate limit zone if not present
|
||||||
|
if ! grep -q "limit_req_zone.*the_door_api" /etc/nginx/nginx.conf 2>/dev/null; then
|
||||||
|
sed -i '/http {/a \ limit_req_zone $binary_remote_addr zone=the_door_api:10m rate=10r/m;' /etc/nginx/nginx.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enable site, disable default
|
||||||
|
ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Test and reload
|
||||||
|
if nginx -t 2>&1; then
|
||||||
|
systemctl enable nginx
|
||||||
|
systemctl reload nginx || systemctl start nginx
|
||||||
|
log "nginx configured and running"
|
||||||
|
else
|
||||||
|
err "nginx config test failed!"
|
||||||
|
nginx -t
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_firewall() {
|
||||||
|
log "Configuring firewall..."
|
||||||
|
ufw allow 22/tcp comment 'SSH'
|
||||||
|
ufw allow 80/tcp comment 'HTTP'
|
||||||
|
ufw allow 443/tcp comment 'HTTPS'
|
||||||
|
ufw --force enable
|
||||||
|
log "Firewall configured: $(ufw status | grep -c ALLOW) rules active"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_ssl() {
|
||||||
|
log "Checking SSL certificate..."
|
||||||
|
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
|
||||||
|
log "SSL certificate already exists"
|
||||||
|
# Check expiry
|
||||||
|
EXPIRY=$(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" 2>/dev/null | cut -d= -f2)
|
||||||
|
log "Certificate expires: ${EXPIRY}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "No SSL certificate found."
|
||||||
|
warn "Ensure DNS is pointed: ${DOMAIN} A record → ${VPS_IP}"
|
||||||
|
warn ""
|
||||||
|
warn "Then run manually:"
|
||||||
|
warn " certbot --nginx -d ${DOMAIN} -d www.${DOMAIN}"
|
||||||
|
warn ""
|
||||||
|
|
||||||
|
# Attempt automated cert if DNS resolves correctly
|
||||||
|
RESOLVED_IP=$(dig +short "${DOMAIN}" @8.8.8.8 2>/dev/null | head -1)
|
||||||
|
if [ "${RESOLVED_IP}" = "${VPS_IP}" ]; then
|
||||||
|
log "DNS resolves correctly — obtaining SSL certificate..."
|
||||||
|
certbot --nginx -d "${DOMAIN}" -d "www.${DOMAIN}" \
|
||||||
|
--non-interactive --agree-tos --register-unsafely-without-email \
|
||||||
|
&& log "SSL certificate obtained!" \
|
||||||
|
|| warn "certbot failed — run manually"
|
||||||
|
else
|
||||||
|
warn "DNS not pointed yet (resolved: ${RESOLVED_IP}, expected: ${VPS_IP})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_hermes_service() {
|
||||||
|
log "Setting up Hermes Gateway systemd service..."
|
||||||
|
|
||||||
|
# Create hermes user if it doesn't exist
|
||||||
|
if ! id -u hermes >/dev/null 2>&1; then
|
||||||
|
log "Creating hermes user..."
|
||||||
|
useradd --system --shell /usr/sbin/nologin --home-dir /opt/hermes --create-home hermes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create working directory
|
||||||
|
mkdir -p /opt/hermes
|
||||||
|
chown hermes:hermes /opt/hermes
|
||||||
|
|
||||||
|
# Deploy systemd unit file
|
||||||
|
cp "${DEPLOY_DIR}/deploy/hermes-gateway.service" /etc/systemd/system/hermes-gateway.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable hermes-gateway
|
||||||
|
|
||||||
|
# Start or restart the service
|
||||||
|
if systemctl is-active --quiet hermes-gateway; then
|
||||||
|
log "Restarting hermes-gateway service..."
|
||||||
|
systemctl restart hermes-gateway
|
||||||
|
else
|
||||||
|
log "Starting hermes-gateway service..."
|
||||||
|
systemctl start hermes-gateway || warn "Service start failed — ensure hermes binary is installed at /usr/local/bin/hermes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet hermes-gateway; then
|
||||||
|
log "hermes-gateway service is running"
|
||||||
|
else
|
||||||
|
warn "hermes-gateway service not running — check: journalctl -u hermes-gateway"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_deployment() {
|
||||||
|
echo ""
|
||||||
|
echo "================================"
|
||||||
|
echo " The Door — Deployment Status"
|
||||||
|
echo "================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Swap
|
||||||
|
echo -n "Swap: "
|
||||||
|
if swapon --show 2>/dev/null | grep -q swap; then
|
||||||
|
echo -e "${GREEN}OK${NC} — $(swapon --show | head -1 | awk '{print $3}')"
|
||||||
|
else
|
||||||
|
echo -e "${RED}MISSING${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# nginx
|
||||||
|
echo -n "nginx: "
|
||||||
|
if systemctl is-active --quiet nginx 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}RUNNING${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}STOPPED${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Site files
|
||||||
|
echo -n "Site files: "
|
||||||
|
if [ -f "${SITE_ROOT}/index.html" ]; then
|
||||||
|
echo -e "${GREEN}OK${NC} — $(ls -la ${SITE_ROOT}/index.html | awk '{print $5}') bytes"
|
||||||
|
else
|
||||||
|
echo -e "${RED}MISSING${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
echo -n "SSL cert: "
|
||||||
|
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
|
||||||
|
EXPIRY=$(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" | cut -d= -f2)
|
||||||
|
echo -e "${GREEN}OK${NC} — expires ${EXPIRY}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}NOT SET${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Firewall
|
||||||
|
echo -n "Firewall: "
|
||||||
|
if ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||||
|
echo -e "${GREEN}ACTIVE${NC} — $(ufw status | grep -c ALLOW) rules"
|
||||||
|
else
|
||||||
|
echo -e "${RED}INACTIVE${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HTTP test
|
||||||
|
echo -n "HTTP test: "
|
||||||
|
if curl -sf --max-time 5 "http://localhost/" -o /dev/null 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}N/A${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# API proxy test
|
||||||
|
echo -n "API proxy: "
|
||||||
|
if curl -sf --max-time 5 "http://localhost/health" -o /dev/null 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Hermes not responding${NC} (may be expected)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DNS
|
||||||
|
echo -n "DNS: "
|
||||||
|
RESOLVED_IP=$(dig +short "${DOMAIN}" @8.8.8.8 2>/dev/null | head -1)
|
||||||
|
if [ "${RESOLVED_IP}" = "${VPS_IP}" ]; then
|
||||||
|
echo -e "${GREEN}OK${NC} — ${DOMAIN} → ${RESOLVED_IP}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}NOT POINTED${NC} (resolved: ${RESOLVED_IP:-nothing}, expected: ${VPS_IP})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Hermes gateway service
|
||||||
|
echo -n "Hermes service: "
|
||||||
|
if systemctl is-active --quiet hermes-gateway 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}RUNNING${NC}"
|
||||||
|
elif systemctl is-enabled --quiet hermes-gateway 2>/dev/null; then
|
||||||
|
echo -e "${YELLOW}ENABLED but not running${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}NOT INSTALLED${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "IP: ${VPS_IP}"
|
||||||
|
echo "Domain: ${DOMAIN}"
|
||||||
|
echo "Site root: ${SITE_ROOT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# MAIN
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
# 5. SSL (requires DNS to be pointed first)
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== DNS CHECK ==="
|
echo "=== The Door — Deployment ==="
|
||||||
echo "Point alexanderwhitestone.com A record to $(curl -s ifconfig.me)"
|
echo "Deploy dir: ${DEPLOY_DIR}"
|
||||||
echo "Then run: certbot --nginx -d alexanderwhitestone.com -d www.alexanderwhitestone.com"
|
echo "VPS IP: ${VPS_IP}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 6. Firewall
|
case "${1:-full}" in
|
||||||
echo "Configuring firewall..."
|
--site)
|
||||||
ufw allow 22/tcp
|
deploy_site
|
||||||
ufw allow 80/tcp
|
configure_nginx
|
||||||
ufw allow 443/tcp
|
;;
|
||||||
ufw --force enable
|
--ssl)
|
||||||
|
setup_ssl
|
||||||
|
;;
|
||||||
|
--service)
|
||||||
|
setup_hermes_service
|
||||||
|
;;
|
||||||
|
--check)
|
||||||
|
check_deployment
|
||||||
|
;;
|
||||||
|
--full|"")
|
||||||
|
setup_swap
|
||||||
|
install_packages
|
||||||
|
deploy_site
|
||||||
|
configure_nginx
|
||||||
|
setup_firewall
|
||||||
|
setup_ssl
|
||||||
|
setup_hermes_service
|
||||||
|
check_deployment
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [--site|--ssl|--service|--check|--full]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Deployment complete ==="
|
echo "=== Deployment complete ==="
|
||||||
echo "Static site: /var/www/the-door/"
|
echo ""
|
||||||
echo "nginx config: /etc/nginx/sites-available/the-door"
|
echo "Next steps:"
|
||||||
echo "Next: point DNS, then run certbot"
|
echo " 1. Point DNS: ${DOMAIN} A record → ${VPS_IP}"
|
||||||
|
echo " 2. If SSL not set: certbot --nginx -d ${DOMAIN} -d www.${DOMAIN}"
|
||||||
|
echo " 3. Test: curl -I https://${DOMAIN}"
|
||||||
|
echo " 4. Test API: curl https://${DOMAIN}/api/health"
|
||||||
|
echo ""
|
||||||
|
echo "The Door is open."
|
||||||
|
|||||||
40
deploy/hermes-gateway.service
Normal file
40
deploy/hermes-gateway.service
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Hermes Gateway — The Door Crisis API
|
||||||
|
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-door
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=hermes
|
||||||
|
Group=hermes
|
||||||
|
WorkingDirectory=/opt/hermes
|
||||||
|
ExecStart=/usr/local/bin/hermes gateway --platform api_server --port 8644
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=10
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
Environment=API_SERVER_CORS_ORIGINS=https://alexanderwhitestone.com,https://www.alexanderwhitestone.com
|
||||||
|
Environment=HOME=/opt/hermes
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/opt/hermes
|
||||||
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
# Resource limits for 1.9GB VPS
|
||||||
|
MemoryMax=512M
|
||||||
|
MemoryHigh=384M
|
||||||
|
CPUQuota=80%
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=hermes-gateway
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
7
deploy/inventory.ini
Normal file
7
deploy/inventory.ini
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# The Door — VPS Inventory
|
||||||
|
# The crisis front door server
|
||||||
|
|
||||||
|
[the_door]
|
||||||
|
# Production host — prefer domain so infra survives IP rotation
|
||||||
|
# ansible_user should be a sudo-capable user (not root recommended)
|
||||||
|
alexanderwhitestone.com ansible_user=root ansible_python_interpreter=/usr/bin/python3
|
||||||
@@ -1,33 +1,112 @@
|
|||||||
# The Door — nginx config for alexanderwhitestone.com
|
# The Door — nginx config for alexanderwhitestone.com
|
||||||
# Place at /etc/nginx/sites-available/the-door
|
# Place at /etc/nginx/sites-available/the-door
|
||||||
|
#
|
||||||
|
# Crisis front door: single URL, no login, always open.
|
||||||
|
# Static files + reverse proxy to Hermes Gateway on :8644
|
||||||
|
#
|
||||||
|
# Deploy:
|
||||||
|
# cp nginx.conf /etc/nginx/sites-available/the-door
|
||||||
|
# ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
|
||||||
|
# rm -f /etc/nginx/sites-enabled/default
|
||||||
|
# nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
|
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
|
# Allow certbot ACME challenge even without SSL
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Main HTTPS server
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
|
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/alexanderwhitestone.com/fullchain.pem;
|
# ================================================================
|
||||||
|
# SSL Configuration
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/alexanderwhitestone.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/alexanderwhitestone.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/alexanderwhitestone.com/privkey.pem;
|
||||||
|
|
||||||
|
# Modern SSL settings
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# OCSP Stapling
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
resolver 1.1.1.1 8.8.8.8 valid=300s;
|
||||||
|
resolver_timeout 5s;
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Site Root
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
root /var/www/the-door;
|
root /var/www/the-door;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Static files
|
# ================================================================
|
||||||
|
# Compression
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/manifest+json
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Static files — the crisis front door
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-Frame-Options DENY;
|
# Security headers
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "no-referrer";
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'";
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:;" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy to Hermes
|
# ================================================================
|
||||||
|
# API proxy — /api/* → Hermes Gateway :8644
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8644/;
|
proxy_pass http://127.0.0.1:8644/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -37,16 +116,21 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# CORS — allow alexanderwhitestone.com origins
|
# CORS — allow alexanderwhitestone.com origins
|
||||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
set $cors_origin "";
|
||||||
|
if ($http_origin ~* "^https://(www\.)?alexanderwhitestone\.com$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
add_header Access-Control-Allow-Origin "$cors_origin" always;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
||||||
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
|
||||||
# Handle OPTIONS preflight
|
# Handle OPTIONS preflight
|
||||||
if ($request_method = OPTIONS) {
|
if ($request_method = OPTIONS) {
|
||||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
add_header Access-Control-Allow-Origin "$cors_origin";
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
|
||||||
add_header Access-Control-Max-Age 86400 always;
|
add_header Access-Control-Max-Age 86400;
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,15 +141,28 @@ server {
|
|||||||
chunked_transfer_encoding on;
|
chunked_transfer_encoding on;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting — 10 req/min per IP, burst of 5
|
||||||
limit_req zone=api burst=5 nodelay;
|
# Zone defined in nginx http block — see deploy/rate-limit.conf
|
||||||
|
limit_req zone=the_door_api burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
# ================================================================
|
||||||
location /health {
|
# Health check — passthrough to Hermes
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
location = /health {
|
||||||
proxy_pass http://127.0.0.1:8644/health;
|
proxy_pass http://127.0.0.1:8644/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Rate limit zone (define in http block of nginx.conf)
|
# ================================================================
|
||||||
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
|
# Block dotfiles (except .well-known)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
location ~ /\.(?!well-known) {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
294
deploy/playbook.yml
Normal file
294
deploy/playbook.yml
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
---
|
||||||
|
# The Door — Ansible Playbook
|
||||||
|
# VPS provisioning for the crisis front door
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cd deploy && ansible-playbook -i inventory.ini playbook.yml
|
||||||
|
#
|
||||||
|
# This playbook is IDEMPOTENT — safe to run repeatedly.
|
||||||
|
# It handles: swap, nginx, SSL, firewall, site deployment.
|
||||||
|
|
||||||
|
- name: "The Door — VPS Provisioning"
|
||||||
|
hosts: the_door
|
||||||
|
become: true
|
||||||
|
vars:
|
||||||
|
domain: "alexanderwhitestone.com"
|
||||||
|
domain_www: "www.alexanderwhitestone.com"
|
||||||
|
site_root: "/var/www/the-door"
|
||||||
|
swap_size: "2G"
|
||||||
|
swap_file: "/swapfile"
|
||||||
|
hermes_port: 8644
|
||||||
|
deploy_dir: "/opt/the-door"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# ================================================================
|
||||||
|
# PHASE 1: System — swap, updates, packages
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
- name: "[swap] Check if swapfile exists"
|
||||||
|
stat:
|
||||||
|
path: "{{ swap_file }}"
|
||||||
|
register: swap_stat
|
||||||
|
|
||||||
|
- name: "[swap] Create swapfile"
|
||||||
|
command: fallocate -l {{ swap_size }} {{ swap_file }}
|
||||||
|
when: not swap_stat.stat.exists
|
||||||
|
|
||||||
|
- name: "[swap] Set permissions"
|
||||||
|
file:
|
||||||
|
path: "{{ swap_file }}"
|
||||||
|
mode: "0600"
|
||||||
|
when: not swap_stat.stat.exists
|
||||||
|
|
||||||
|
- name: "[swap] Make swap"
|
||||||
|
command: mkswap {{ swap_file }}
|
||||||
|
when: not swap_stat.stat.exists
|
||||||
|
|
||||||
|
- name: "[swap] Enable swap"
|
||||||
|
command: swapon {{ swap_file }}
|
||||||
|
when: not swap_stat.stat.exists
|
||||||
|
|
||||||
|
- name: "[swap] Add to fstab"
|
||||||
|
lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
line: "{{ swap_file }} none swap sw 0 0"
|
||||||
|
state: present
|
||||||
|
when: not swap_stat.stat.exists
|
||||||
|
|
||||||
|
- name: "[apt] Update cache"
|
||||||
|
apt:
|
||||||
|
update_cache: yes
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: "[apt] Install packages"
|
||||||
|
apt:
|
||||||
|
name:
|
||||||
|
- nginx
|
||||||
|
- certbot
|
||||||
|
- python3-certbot-nginx
|
||||||
|
- ufw
|
||||||
|
- curl
|
||||||
|
state: present
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# PHASE 2: Site files — copy static assets
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
- name: "[site] Create webroot"
|
||||||
|
file:
|
||||||
|
path: "{{ site_root }}"
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: "[site] Copy index.html"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../index.html"
|
||||||
|
dest: "{{ site_root }}/index.html"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[site] Copy manifest.json"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../manifest.json"
|
||||||
|
dest: "{{ site_root }}/manifest.json"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[site] Copy service worker"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../sw.js"
|
||||||
|
dest: "{{ site_root }}/sw.js"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[site] Copy system prompt"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../system-prompt.txt"
|
||||||
|
dest: "{{ site_root }}/system-prompt.txt"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: "[site] Copy about page"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../about.html"
|
||||||
|
dest: "{{ site_root }}/about.html"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[site] Copy testimony page"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../testimony.html"
|
||||||
|
dest: "{{ site_root }}/testimony.html"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# PHASE 3: nginx — config, sites, rate limiting
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
- name: "[nginx] Ensure sites-available dir"
|
||||||
|
file:
|
||||||
|
path: /etc/nginx/sites-available
|
||||||
|
state: directory
|
||||||
|
|
||||||
|
- name: "[nginx] Ensure sites-enabled dir"
|
||||||
|
file:
|
||||||
|
path: /etc/nginx/sites-enabled
|
||||||
|
state: directory
|
||||||
|
|
||||||
|
- name: "[nginx] Deploy site config"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/nginx.conf"
|
||||||
|
dest: /etc/nginx/sites-available/the-door
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[nginx] Enable site"
|
||||||
|
file:
|
||||||
|
src: /etc/nginx/sites-available/the-door
|
||||||
|
dest: /etc/nginx/sites-enabled/the-door
|
||||||
|
state: link
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[nginx] Remove default site"
|
||||||
|
file:
|
||||||
|
path: /etc/nginx/sites-enabled/default
|
||||||
|
state: absent
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[nginx] Add rate limit zone to main config"
|
||||||
|
lineinfile:
|
||||||
|
path: /etc/nginx/nginx.conf
|
||||||
|
insertafter: "http {"
|
||||||
|
line: " limit_req_zone $binary_remote_addr zone=the_door_api:10m rate=10r/m;"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: "[nginx] Test config"
|
||||||
|
command: nginx -t
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "[nginx] Ensure service is running"
|
||||||
|
service:
|
||||||
|
name: nginx
|
||||||
|
state: started
|
||||||
|
enabled: yes
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# PHASE 4: Firewall — UFW
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
- name: "[ufw] Allow SSH"
|
||||||
|
ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "22"
|
||||||
|
proto: tcp
|
||||||
|
|
||||||
|
- name: "[ufw] Allow HTTP"
|
||||||
|
ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "80"
|
||||||
|
proto: tcp
|
||||||
|
|
||||||
|
- name: "[ufw] Allow HTTPS"
|
||||||
|
ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "443"
|
||||||
|
proto: tcp
|
||||||
|
|
||||||
|
- name: "[ufw] Set default deny incoming"
|
||||||
|
ufw:
|
||||||
|
direction: incoming
|
||||||
|
policy: deny
|
||||||
|
|
||||||
|
- name: "[ufw] Set default allow outgoing"
|
||||||
|
ufw:
|
||||||
|
direction: outgoing
|
||||||
|
policy: allow
|
||||||
|
|
||||||
|
- name: "[ufw] Enable firewall"
|
||||||
|
ufw:
|
||||||
|
state: enabled
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# PHASE 5: SSL — certbot (manual trigger recommended)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
- name: "[ssl] Check if cert exists"
|
||||||
|
stat:
|
||||||
|
path: "/etc/letsencrypt/live/{{ domain }}/fullchain.pem"
|
||||||
|
register: ssl_cert
|
||||||
|
|
||||||
|
- name: "[ssl] Obtain certificate (if DNS is pointed)"
|
||||||
|
command: >
|
||||||
|
certbot --nginx
|
||||||
|
-d {{ domain }}
|
||||||
|
-d {{ domain_www }}
|
||||||
|
--non-interactive
|
||||||
|
--agree-tos
|
||||||
|
--register-unsafely-without-email
|
||||||
|
when: not ssl_cert.stat.exists
|
||||||
|
register: certbot_result
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "[ssl] Certbot result"
|
||||||
|
debug:
|
||||||
|
msg: "{{ 'SSL cert obtained' if certbot_result.rc == 0 else 'SSL cert needs manual setup — point DNS first, then run: certbot --nginx -d ' + domain + ' -d ' + domain_www }}"
|
||||||
|
when: not ssl_cert.stat.exists
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# PHASE 6: Deploy directory + deploy script
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
- name: "[deploy] Create deploy directory"
|
||||||
|
file:
|
||||||
|
path: "{{ deploy_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: "[deploy] Copy deploy script"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/deploy.sh"
|
||||||
|
dest: "{{ deploy_dir }}/deploy.sh"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: "[deploy] Copy system-prompt.txt"
|
||||||
|
copy:
|
||||||
|
src: "{{ playbook_dir }}/../system-prompt.txt"
|
||||||
|
dest: "{{ deploy_dir }}/system-prompt.txt"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# HANDLERS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: reload nginx
|
||||||
|
service:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
|
|
||||||
|
- name: restart nginx
|
||||||
|
service:
|
||||||
|
name: nginx
|
||||||
|
state: restarted
|
||||||
8
deploy/rate-limit.conf
Normal file
8
deploy/rate-limit.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# The Door — Rate Limiting
|
||||||
|
# Add this block to your nginx main config http block:
|
||||||
|
# /etc/nginx/nginx.conf -> http { ... }
|
||||||
|
#
|
||||||
|
# This defines the rate limit zone used by the-door's server block.
|
||||||
|
# 10 requests per minute per IP, burst of 5 with nodelay.
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=the_door_api:10m rate=10r/m;
|
||||||
@@ -1,31 +1,34 @@
|
|||||||
"""
|
"""
|
||||||
When a Man Is Dying — Despair/Suicide Detection System
|
DEPRECATED — Use crisis.detect instead.
|
||||||
|
|
||||||
Standalone detection module that parses incoming text for
|
This module is a thin wrapper around crisis.detect for backward compatibility.
|
||||||
despair and suicide indicators, classifies into tiers,
|
All unique patterns have been merged into crisis/detect.py (see issue #40).
|
||||||
and returns structured response with recommended actions.
|
|
||||||
|
|
||||||
Tiers:
|
This module will be removed in a future release.
|
||||||
LOW — General sadness, stress, difficult times
|
|
||||||
MEDIUM — Hopelessness, isolation, worthlessness
|
|
||||||
HIGH — Active despair, mentions of death, "can't go on"
|
|
||||||
CRITICAL — Imminent risk, explicit intent, plan, method
|
|
||||||
|
|
||||||
Integration:
|
|
||||||
Designed to work with crisis/ module from PR #4.
|
|
||||||
When crisis/ is available, uses it as the detection backend.
|
|
||||||
Falls back to internal detection when crisis/ is not present.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import warnings
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# Re-export the canonical detection
|
||||||
|
from crisis.detect import detect_crisis, CrisisDetectionResult
|
||||||
|
|
||||||
|
# Issue deprecation warning on import
|
||||||
|
warnings.warn(
|
||||||
|
"dying_detection is deprecated. Use 'from crisis.detect import detect_crisis' instead. "
|
||||||
|
"All patterns have been consolidated into crisis/detect.py. "
|
||||||
|
"See issue #40.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DetectionResult:
|
class DetectionResult:
|
||||||
|
"""Backward-compatible result type matching the old dying_detection API."""
|
||||||
level: str
|
level: str
|
||||||
indicators: List[str] = field(default_factory=list)
|
indicators: List[str] = field(default_factory=list)
|
||||||
recommended_action: str = ""
|
recommended_action: str = ""
|
||||||
@@ -34,110 +37,9 @@ class DetectionResult:
|
|||||||
session_hash: str = ""
|
session_hash: str = ""
|
||||||
|
|
||||||
|
|
||||||
# ── Tiered indicator patterns ───────────────────────────────────
|
|
||||||
# Each pattern is a regex matched against lowercased input.
|
|
||||||
|
|
||||||
CRITICAL_PATTERNS = [
|
|
||||||
(r"\bkill\s*(my)?self\b", "explicit self-harm intent"),
|
|
||||||
(r"\bsuicid(?:al|ed|e)\b", "suicide reference"),
|
|
||||||
(r"\bend\s*(my)?\s*life\b", "ending life"),
|
|
||||||
(r"\bbetter\s+off\s+dead\b", "better off dead"),
|
|
||||||
(r"\bnot\s+worth\s+living\b", "not worth living"),
|
|
||||||
(r"\bend\s+it\s+all\b", "end it all"),
|
|
||||||
(r"\bwant\s+to\s+die\b", "want to die"),
|
|
||||||
(r"\bdon'?t\s+want\s+to\s+live\b", "don't want to live"),
|
|
||||||
(r"\bcan'?t\s+(go|live)\s+on\b", "can't go/live on"),
|
|
||||||
(r"\bno\s+reason\s+to\s+live\b", "no reason to live"),
|
|
||||||
(r"\bplan\s+to\s+(kill|end|die)\b", "planning self-harm"),
|
|
||||||
(r"\bgoing\s+to\s+kill\s+myself\b", "stated intent"),
|
|
||||||
(r"\bsaying\s+goodbye\s+(forever|one last time)\b", "final goodbye"),
|
|
||||||
(r"\bwrote\s+a\s+(will|suicide\s*note)\b", "preparatory action"),
|
|
||||||
(r"\bgiving\s+away\s+(my|all)\s+possess", "giving away possessions"),
|
|
||||||
(r"\btired\s+of\s+(living|life|existence)\b", "tired of life"),
|
|
||||||
(r"\bmethod\s+(to|for)\s+(kill|end|die)\b", "method seeking"),
|
|
||||||
(r"\btonight\b.*\b(die|kill|end|suicid)", "immediate timeframe + intent"),
|
|
||||||
(r"\b(die|kill|end)\b.*\btonight", "immediate timeframe + intent"),
|
|
||||||
(r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone)", "disappearance plan"),
|
|
||||||
]
|
|
||||||
|
|
||||||
HIGH_PATTERNS = [
|
|
||||||
(r"\bdespair\b", "despair"),
|
|
||||||
(r"\bhopeless(?:ness)?\b", "hopelessness"),
|
|
||||||
(r"\bcan'?t\s+take\s+this\s+anymore\b", "can't take it"),
|
|
||||||
(r"\bdon'?t\s+care\s+if\s+I\s+die\b", "death indifference"),
|
|
||||||
(r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b", "wish to be dead"),
|
|
||||||
(r"\bworld\s+would\s+be\s+better\s+without\s+me\b", "better without me"),
|
|
||||||
(r"\bin\s+so\s+much\s+(pain|agony|suffering|torment|angui)", "extreme suffering"),
|
|
||||||
(r"\bcan'?t\s+see\s+any\s+(point|reason|light|hope|way)\b", "no light ahead"),
|
|
||||||
(r"\btrapped\b", "feeling trapped"),
|
|
||||||
(r"\bjust\s+want\s+it\s+to\s+stop\b", "want to stop"),
|
|
||||||
(r"\bno\s+way\s+out\b", "no way out"),
|
|
||||||
(r"\bno\s+one\s+would\s+(care|miss)\b", "no one would care/miss"),
|
|
||||||
(r"\beverything\s+is\s+(pointless|broken|ruined|meaningless)\b", "existential collapse"),
|
|
||||||
(r"\bno\s+point\s+in\s+anything\b", "pointlessness"),
|
|
||||||
(r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone|disappeared)", "no one would notice"),
|
|
||||||
(r"\bdisappeared\s+forever\b", "disappeared forever"),
|
|
||||||
]
|
|
||||||
|
|
||||||
MEDIUM_PATTERNS = [
|
|
||||||
(r"\bno\s+hope\b", "no hope"),
|
|
||||||
(r"\bcan'?t\s+go\s+on\b", "can't go on"),
|
|
||||||
(r"\bcan'?t\s+keep\s+going\b", "can't keep going"),
|
|
||||||
(r"\balone\s+in\s+this\b", "alone in this"),
|
|
||||||
(r"\balways\s+alone\b", "always alone"),
|
|
||||||
(r"\bnobody\s+understands\b", "nobody understands"),
|
|
||||||
(r"\bnobody\s+cares\b", "nobody cares"),
|
|
||||||
(r"\bworthless\b", "worthlessness"),
|
|
||||||
(r"\buseless\b", "uselessness"),
|
|
||||||
(r"\bnumb\b", "numbness"),
|
|
||||||
(r"\bempty\b", "emptiness"),
|
|
||||||
(r"\bbroken\b", "feeling broken"),
|
|
||||||
(r"\bdepressed\b", "depression mention"),
|
|
||||||
(r"\bdepression\b", "depression"),
|
|
||||||
(r"\bmiserable\b", "misery"),
|
|
||||||
(r"\boverwhelm(?:ed|ing)\b", "overwhelmed"),
|
|
||||||
(r"\bcannot\s+cope\b", "cannot cope"),
|
|
||||||
(r"\b(drowning|sinking)\b", "drowning/sinking"),
|
|
||||||
(r"\bforgotten\b", "feeling forgotten"),
|
|
||||||
(r"\blost\s+all\s+hope\b", "lost all hope"),
|
|
||||||
(r"\bno\s+future\b", "no future"),
|
|
||||||
(r"\bno\s+tomorrow\b", "no tomorrow"),
|
|
||||||
]
|
|
||||||
|
|
||||||
LOW_PATTERNS = [
|
|
||||||
(r"\bunhappy\b", "unhappy"),
|
|
||||||
(r"\brough\s+(day|week|patch)\b", "rough time"),
|
|
||||||
(r"\btough\s+(time|day|week)\b", "tough time"),
|
|
||||||
(r"\bstressed\b", "stressed"),
|
|
||||||
(r"\bburnout\b", "burnout"),
|
|
||||||
(r"\bfrustrated\b", "frustrated"),
|
|
||||||
(r"\bthings\s+(are\s+)?hard\b", "things are hard"),
|
|
||||||
(r"\bnot\s+feeling\s+(great|myself|good)\b", "not feeling good"),
|
|
||||||
(r"\bstruggl", "struggling"),
|
|
||||||
(r"\bdown\b", "feeling down"),
|
|
||||||
(r"\bsad\b", "sad"),
|
|
||||||
(r"\bupset\b", "upset"),
|
|
||||||
(r"\blonely\b", "lonely"),
|
|
||||||
(r"\banxious?\b", "anxious/anxiety"),
|
|
||||||
(r"\bnot\s+okay\b", "not okay"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Pattern collections for easy iteration ──────────────────────
|
|
||||||
|
|
||||||
TIER_PATTERNS: Dict[str, List[tuple]] = {
|
|
||||||
"CRITICAL": CRITICAL_PATTERNS,
|
|
||||||
"HIGH": HIGH_PATTERNS,
|
|
||||||
"MEDIUM": MEDIUM_PATTERNS,
|
|
||||||
"LOW": LOW_PATTERNS,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def detect(text: str) -> DetectionResult:
|
def detect(text: str) -> DetectionResult:
|
||||||
"""
|
"""
|
||||||
Primary detection function.
|
Primary detection function — delegates to crisis.detect.
|
||||||
|
|
||||||
If the crisis/ module is available, delegate to it.
|
|
||||||
Otherwise, use the internal pattern engine.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: User message to analyze
|
text: User message to analyze
|
||||||
@@ -145,150 +47,25 @@ def detect(text: str) -> DetectionResult:
|
|||||||
Returns:
|
Returns:
|
||||||
DetectionResult with level, indicators, recommended_action, confidence
|
DetectionResult with level, indicators, recommended_action, confidence
|
||||||
"""
|
"""
|
||||||
# Try to use the crisis module if available
|
result = detect_crisis(text)
|
||||||
try:
|
|
||||||
from crisis.detect import detect_crisis as _crisis_detect
|
|
||||||
result = _crisis_detect(text)
|
|
||||||
# Map to DetectionResult
|
|
||||||
return DetectionResult(
|
|
||||||
level=result.level,
|
|
||||||
indicators=_describe_indicators(result.level, result.indicators),
|
|
||||||
recommended_action=result.recommended_action,
|
|
||||||
confidence=result.score,
|
|
||||||
session_hash=_hash_session(text),
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return _detect_internal(text)
|
# Extract raw patterns from matches
|
||||||
|
raw_patterns = [m["pattern"] for m in result.matches] if result.matches else []
|
||||||
|
|
||||||
|
return DetectionResult(
|
||||||
def _detect_internal(text: str) -> DetectionResult:
|
level=result.level,
|
||||||
"""Internal pattern-based detection (fallback)."""
|
indicators=result.indicators,
|
||||||
if not text or not text.strip():
|
recommended_action=result.recommended_action,
|
||||||
return DetectionResult(level="NONE", session_hash=_hash_session(text))
|
raw_matched_patterns=raw_patterns,
|
||||||
|
confidence=result.score,
|
||||||
text_lower = text.lower()
|
session_hash=hashlib.sha256(text.encode()).hexdigest()[:12],
|
||||||
all_matches: Dict[str, List[tuple]] = {
|
)
|
||||||
tier: [] for tier in TIER_PATTERNS
|
|
||||||
}
|
|
||||||
|
|
||||||
for tier, patterns in TIER_PATTERNS.items():
|
|
||||||
for pattern, description in patterns:
|
|
||||||
if re.search(pattern, text_lower):
|
|
||||||
all_matches[tier].append((pattern, description))
|
|
||||||
|
|
||||||
# CRITICAL patterns are the highest priority
|
|
||||||
if all_matches["CRITICAL"]:
|
|
||||||
return DetectionResult(
|
|
||||||
level="CRITICAL",
|
|
||||||
indicators=[desc for _, desc in all_matches["CRITICAL"]],
|
|
||||||
recommended_action=(
|
|
||||||
"INTERRUPT CONVERSATION IMMEDIATELY. "
|
|
||||||
"Ask 'Are you safe right now?' "
|
|
||||||
"Provide 988 Suicide & Crisis Lifeline (call or text 988). "
|
|
||||||
"Provide Crisis Text Line (text HOME to 741741). "
|
|
||||||
"Stay present. Do not disconnect. "
|
|
||||||
"Redirect to professional help. "
|
|
||||||
"This is the most sacred moment."
|
|
||||||
),
|
|
||||||
raw_matched_patterns=[pat for pat, _ in all_matches["CRITICAL"]],
|
|
||||||
confidence=1.0,
|
|
||||||
session_hash=_hash_session(text),
|
|
||||||
)
|
|
||||||
|
|
||||||
if all_matches["HIGH"]:
|
|
||||||
return DetectionResult(
|
|
||||||
level="HIGH",
|
|
||||||
indicators=[desc for _, desc in all_matches["HIGH"]],
|
|
||||||
recommended_action=(
|
|
||||||
"Show crisis panel prominently. "
|
|
||||||
"Ask about safety directly. "
|
|
||||||
"Surface 988 number and crisis resources. "
|
|
||||||
"Stay present and engaged. Do not rush to fix. "
|
|
||||||
"Monitor closely for escalation."
|
|
||||||
),
|
|
||||||
raw_matched_patterns=[pat for pat, _ in all_matches["HIGH"]],
|
|
||||||
confidence=0.75,
|
|
||||||
session_hash=_hash_session(text),
|
|
||||||
)
|
|
||||||
|
|
||||||
if all_matches["MEDIUM"]:
|
|
||||||
return DetectionResult(
|
|
||||||
level="MEDIUM",
|
|
||||||
indicators=[desc for _, desc in all_matches["MEDIUM"]],
|
|
||||||
recommended_action=(
|
|
||||||
"Increase warmth and presence. "
|
|
||||||
"Subtly surface help resources. "
|
|
||||||
"Keep conversation anchored in the present. "
|
|
||||||
"Watch for escalation indicators."
|
|
||||||
),
|
|
||||||
raw_matched_patterns=[pat for pat, _ in all_matches["MEDIUM"]],
|
|
||||||
confidence=0.5,
|
|
||||||
session_hash=_hash_session(text),
|
|
||||||
)
|
|
||||||
|
|
||||||
if all_matches["LOW"]:
|
|
||||||
return DetectionResult(
|
|
||||||
level="LOW",
|
|
||||||
indicators=[desc for _, desc in all_matches["LOW"]],
|
|
||||||
recommended_action=(
|
|
||||||
"Normal conversation with warm undertone. "
|
|
||||||
"No crisis UI elements needed. "
|
|
||||||
"Remain vigilant. Be present."
|
|
||||||
),
|
|
||||||
raw_matched_patterns=[pat for pat, _ in all_matches["LOW"]],
|
|
||||||
confidence=0.25,
|
|
||||||
session_hash=_hash_session(text),
|
|
||||||
)
|
|
||||||
|
|
||||||
return DetectionResult(level="NONE", session_hash=_hash_session(text))
|
|
||||||
|
|
||||||
|
|
||||||
def _describe_indicators(level: str, patterns: list) -> list:
|
|
||||||
"""Map raw patterns to descriptions."""
|
|
||||||
descriptions = {
|
|
||||||
"CRITICAL": [],
|
|
||||||
"HIGH": [],
|
|
||||||
"MEDIUM": [],
|
|
||||||
"LOW": [],
|
|
||||||
}
|
|
||||||
for tier, items in TIER_PATTERNS.items():
|
|
||||||
for pat, desc in items:
|
|
||||||
if pat in patterns:
|
|
||||||
descriptions[tier].append(desc)
|
|
||||||
return descriptions.get(level, [])
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_session(text: str) -> str:
|
|
||||||
"""Create a session hash for this text (for tracking repeated escalations)."""
|
|
||||||
return hashlib.sha256(text.encode()).hexdigest()[:12]
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_for_level(level: str) -> str:
|
def get_action_for_level(level: str) -> str:
|
||||||
"""Get the recommended action string for a given level."""
|
"""Get the recommended action string for a given level."""
|
||||||
actions = {
|
from crisis.detect import ACTIONS
|
||||||
"CRITICAL": (
|
return ACTIONS.get(level, "Unknown level.")
|
||||||
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
|
||||||
"Provide 988. Provide Crisis Text Line. "
|
|
||||||
"Stay present. Do not disconnect. "
|
|
||||||
"Redirect to help."
|
|
||||||
),
|
|
||||||
"HIGH": (
|
|
||||||
"Show crisis panel. Ask about safety. "
|
|
||||||
"Surface 988. Stay engaged."
|
|
||||||
),
|
|
||||||
"MEDIUM": (
|
|
||||||
"Increase warmth. Surface resources gently. "
|
|
||||||
"Anchor in present."
|
|
||||||
),
|
|
||||||
"LOW": (
|
|
||||||
"Normal conversation with warmth. "
|
|
||||||
"Remain vigilant."
|
|
||||||
),
|
|
||||||
"NONE": "No action needed.",
|
|
||||||
}
|
|
||||||
return actions.get(level, "Unknown level.")
|
|
||||||
|
|
||||||
|
|
||||||
def as_json(result: DetectionResult, indent: int = 2) -> str:
|
def as_json(result: DetectionResult, indent: int = 2) -> str:
|
||||||
|
|||||||
251
index.html
251
index.html
@@ -680,7 +680,7 @@ html, body {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer id="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="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>
|
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -808,6 +808,7 @@ Sovereignty and service always.`;
|
|||||||
var crisisPanel = document.getElementById('crisis-panel');
|
var crisisPanel = document.getElementById('crisis-panel');
|
||||||
var crisisOverlay = document.getElementById('crisis-overlay');
|
var crisisOverlay = document.getElementById('crisis-overlay');
|
||||||
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
|
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
|
||||||
|
var overlayCallLink = document.querySelector('.overlay-call');
|
||||||
var statusDot = document.querySelector('.status-dot');
|
var statusDot = document.querySelector('.status-dot');
|
||||||
var statusText = document.getElementById('status-text');
|
var statusText = document.getElementById('status-text');
|
||||||
|
|
||||||
@@ -865,10 +866,10 @@ Sovereignty and service always.`;
|
|||||||
// Passive suicidal ideation (NEW)
|
// Passive suicidal ideation (NEW)
|
||||||
"don't want to exist", 'not exist anymore', 'disappear forever',
|
"don't want to exist", 'not exist anymore', 'disappear forever',
|
||||||
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
|
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
|
||||||
// Hopelessness (NEW)
|
// Hopelessness (NEW) - context-aware phrases to reduce false positives
|
||||||
'no point', 'no purpose', 'nothing matters', 'giving up', 'give up',
|
'no purpose', 'nothing matters', 'giving up on life',
|
||||||
'cant go on', 'cannot go on', "can't take it", 'too much pain',
|
'cant go on', 'cannot go on', "can't take it", 'too much pain',
|
||||||
'no hope', 'hopeless', 'worthless', 'burden', 'waste of space'
|
'no hope left', 'burden', 'waste of space'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tier 2: Explicit intent - triggers full-screen overlay
|
// Tier 2: Explicit intent - triggers full-screen overlay
|
||||||
@@ -883,9 +884,9 @@ Sovereignty and service always.`;
|
|||||||
// Imminent action (NEW)
|
// Imminent action (NEW)
|
||||||
'going to do it now', 'doing it tonight', 'doing it today',
|
'going to do it now', 'doing it tonight', 'doing it today',
|
||||||
"can't wait anymore", 'ready to end it', 'time to go',
|
"can't wait anymore", 'ready to end it', 'time to go',
|
||||||
'say goodbye', 'saying goodbye', 'wrote a note', 'my note',
|
'wrote a suicide note', 'my suicide note',
|
||||||
// Specific plans (NEW)
|
// Specific plans (NEW)
|
||||||
'bought a gun', 'got pills', 'rope ready', 'bridge nearby',
|
'bought a gun', 'got pills', 'rope ready',
|
||||||
'tall building', 'going to overdose', 'going to hang',
|
'tall building', 'going to overdose', 'going to hang',
|
||||||
'gave away my stuff', 'giving away', 'said my goodbyes',
|
'gave away my stuff', 'giving away', 'said my goodbyes',
|
||||||
// Active self-harm (NEW)
|
// Active self-harm (NEW)
|
||||||
@@ -923,13 +924,120 @@ 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 =====
|
// ===== OVERLAY =====
|
||||||
|
|
||||||
|
// Focus trap: cycle through focusable elements within the crisis overlay
|
||||||
|
function getOverlayFocusableElements() {
|
||||||
|
return crisisOverlay.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trapFocusInOverlay(e) {
|
||||||
|
if (!crisisOverlay.classList.contains('active')) return;
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
|
||||||
|
var focusable = getOverlayFocusableElements();
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
|
||||||
|
var first = focusable[0];
|
||||||
|
var last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab: if on first, wrap to last
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tab: if on last, wrap to first
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the element that had focus before the overlay opened
|
||||||
|
var _preOverlayFocusElement = null;
|
||||||
|
|
||||||
function showOverlay() {
|
function showOverlay() {
|
||||||
|
// Save current focus for restoration on dismiss
|
||||||
|
_preOverlayFocusElement = document.activeElement;
|
||||||
|
|
||||||
crisisOverlay.classList.add('active');
|
crisisOverlay.classList.add('active');
|
||||||
overlayDismissBtn.disabled = true;
|
overlayDismissBtn.disabled = true;
|
||||||
var countdown = 10;
|
var countdown = 10;
|
||||||
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
|
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
|
||||||
|
|
||||||
|
// Disable background interaction via inert attribute
|
||||||
|
var mainApp = document.querySelector('.app');
|
||||||
|
if (mainApp) mainApp.setAttribute('inert', '');
|
||||||
|
// Also hide from assistive tech
|
||||||
|
var chatSection = document.getElementById('chat');
|
||||||
|
if (chatSection) chatSection.setAttribute('aria-hidden', 'true');
|
||||||
|
var footerEl = document.querySelector('footer');
|
||||||
|
if (footerEl) footerEl.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
if (overlayTimer) clearInterval(overlayTimer);
|
if (overlayTimer) clearInterval(overlayTimer);
|
||||||
overlayTimer = setInterval(function() {
|
overlayTimer = setInterval(function() {
|
||||||
countdown--;
|
countdown--;
|
||||||
@@ -943,9 +1051,13 @@ Sovereignty and service always.`;
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
overlayDismissBtn.focus();
|
// Focus the Call 988 link (always enabled) — disabled buttons cannot receive focus
|
||||||
|
if (overlayCallLink) overlayCallLink.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register focus trap on document (always listening, gated by class check)
|
||||||
|
document.addEventListener('keydown', trapFocusInOverlay);
|
||||||
|
|
||||||
overlayDismissBtn.addEventListener('click', function() {
|
overlayDismissBtn.addEventListener('click', function() {
|
||||||
if (!overlayDismissBtn.disabled) {
|
if (!overlayDismissBtn.disabled) {
|
||||||
crisisOverlay.classList.remove('active');
|
crisisOverlay.classList.remove('active');
|
||||||
@@ -953,7 +1065,22 @@ Sovereignty and service always.`;
|
|||||||
clearInterval(overlayTimer);
|
clearInterval(overlayTimer);
|
||||||
overlayTimer = null;
|
overlayTimer = null;
|
||||||
}
|
}
|
||||||
msgInput.focus();
|
|
||||||
|
// Re-enable background interaction
|
||||||
|
var mainApp = document.querySelector('.app');
|
||||||
|
if (mainApp) mainApp.removeAttribute('inert');
|
||||||
|
var chatSection = document.getElementById('chat');
|
||||||
|
if (chatSection) chatSection.removeAttribute('aria-hidden');
|
||||||
|
var footerEl = document.querySelector('footer');
|
||||||
|
if (footerEl) footerEl.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
|
// Restore focus to the element that had it before the overlay opened
|
||||||
|
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
|
||||||
|
_preOverlayFocusElement.focus();
|
||||||
|
} else {
|
||||||
|
msgInput.focus();
|
||||||
|
}
|
||||||
|
_preOverlayFocusElement = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1058,25 +1185,14 @@ Sovereignty and service always.`;
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
safetyPlanBtn.addEventListener('click', function() {
|
|
||||||
loadSafetyPlan();
|
|
||||||
safetyPlanModal.classList.add('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Crisis panel safety plan button (if crisis panel is visible)
|
|
||||||
if (crisisSafetyPlanBtn) {
|
|
||||||
crisisSafetyPlanBtn.addEventListener('click', function() {
|
|
||||||
loadSafetyPlan();
|
|
||||||
safetyPlanModal.classList.add('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSafetyPlan.addEventListener('click', function() {
|
closeSafetyPlan.addEventListener('click', function() {
|
||||||
safetyPlanModal.classList.remove('active');
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelSafetyPlan.addEventListener('click', function() {
|
cancelSafetyPlan.addEventListener('click', function() {
|
||||||
safetyPlanModal.classList.remove('active');
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
saveSafetyPlan.addEventListener('click', function() {
|
saveSafetyPlan.addEventListener('click', function() {
|
||||||
@@ -1090,12 +1206,101 @@ Sovereignty and service always.`;
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
||||||
safetyPlanModal.classList.remove('active');
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
alert('Safety plan saved locally.');
|
alert('Safety plan saved locally.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error saving plan.');
|
alert('Error saving plan.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== SAFETY PLAN FOCUS TRAP (fix #65) =====
|
||||||
|
// Focusable elements inside the modal, in tab order
|
||||||
|
var _spFocusableIds = [
|
||||||
|
'close-safety-plan',
|
||||||
|
'sp-warning-signs',
|
||||||
|
'sp-coping',
|
||||||
|
'sp-distraction',
|
||||||
|
'sp-help',
|
||||||
|
'sp-environment',
|
||||||
|
'cancel-safety-plan',
|
||||||
|
'save-safety-plan'
|
||||||
|
];
|
||||||
|
var _spTriggerEl = null; // element that opened the modal
|
||||||
|
|
||||||
|
function _getSpFocusableEls() {
|
||||||
|
return _spFocusableIds
|
||||||
|
.map(function(id) { return document.getElementById(id); })
|
||||||
|
.filter(function(el) { return el && !el.disabled; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _trapSafetyPlanFocus(e) {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
var els = _getSpFocusableEls();
|
||||||
|
if (!els.length) return;
|
||||||
|
var first = els[0];
|
||||||
|
var last = els[els.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab on first → wrap to last
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tab on last → wrap to first
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _trapSafetyPlanEscape(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
safetyPlanModal.classList.remove('active');
|
||||||
|
_restoreSafetyPlanFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _activateSafetyPlanFocusTrap(triggerEl) {
|
||||||
|
_spTriggerEl = triggerEl || document.activeElement;
|
||||||
|
// Focus first textarea
|
||||||
|
var firstInput = document.getElementById('sp-warning-signs');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
// Add listeners
|
||||||
|
document.addEventListener('keydown', _trapSafetyPlanFocus);
|
||||||
|
document.addEventListener('keydown', _trapSafetyPlanEscape);
|
||||||
|
// Mark background inert (prevent click-through)
|
||||||
|
document.body.setAttribute('aria-hidden', 'true');
|
||||||
|
safetyPlanModal.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restoreSafetyPlanFocus() {
|
||||||
|
document.removeEventListener('keydown', _trapSafetyPlanFocus);
|
||||||
|
document.removeEventListener('keydown', _trapSafetyPlanEscape);
|
||||||
|
document.body.removeAttribute('aria-hidden');
|
||||||
|
if (_spTriggerEl && typeof _spTriggerEl.focus === 'function') {
|
||||||
|
_spTriggerEl.focus();
|
||||||
|
}
|
||||||
|
_spTriggerEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire open buttons to activate focus trap
|
||||||
|
safetyPlanBtn.addEventListener('click', function() {
|
||||||
|
loadSafetyPlan();
|
||||||
|
safetyPlanModal.classList.add('active');
|
||||||
|
_activateSafetyPlanFocusTrap(safetyPlanBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crisis panel safety plan button (if crisis panel is visible)
|
||||||
|
if (crisisSafetyPlanBtn) {
|
||||||
|
crisisSafetyPlanBtn.addEventListener('click', function() {
|
||||||
|
loadSafetyPlan();
|
||||||
|
safetyPlanModal.classList.add('active');
|
||||||
|
_activateSafetyPlanFocusTrap(crisisSafetyPlanBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== TEXTAREA AUTO-RESIZE =====
|
// ===== TEXTAREA AUTO-RESIZE =====
|
||||||
msgInput.addEventListener('input', function() {
|
msgInput.addEventListener('input', function() {
|
||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
@@ -1110,6 +1315,7 @@ Sovereignty and service always.`;
|
|||||||
|
|
||||||
addMessage('user', text);
|
addMessage('user', text);
|
||||||
messages.push({ role: 'user', content: text });
|
messages.push({ role: 'user', content: text });
|
||||||
|
var lastUserMessage = text;
|
||||||
|
|
||||||
checkCrisis(text);
|
checkCrisis(text);
|
||||||
|
|
||||||
@@ -1126,7 +1332,7 @@ Sovereignty and service always.`;
|
|||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
showTyping();
|
showTyping();
|
||||||
|
|
||||||
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages);
|
var allMessages = [{ role: 'system', content: getSystemPrompt(lastUserMessage || '') }].concat(messages);
|
||||||
|
|
||||||
var controller = new AbortController();
|
var controller = new AbortController();
|
||||||
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
|
||||||
@@ -1240,6 +1446,7 @@ Sovereignty and service always.`;
|
|||||||
if (urlParams.get('safetyplan') === 'true') {
|
if (urlParams.get('safetyplan') === 'true') {
|
||||||
loadSafetyPlan();
|
loadSafetyPlan();
|
||||||
safetyPlanModal.classList.add('active');
|
safetyPlanModal.classList.add('active');
|
||||||
|
_activateSafetyPlanFocusTrap(safetyPlanBtn);
|
||||||
// Clean up URL
|
// Clean up URL
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|||||||
5
pytest.ini
Normal file
5
pytest.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = crisis tests
|
||||||
|
python_files = tests.py test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
177
resilience/health-check.sh
Executable file
177
resilience/health-check.sh
Executable file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# health-check.sh — Health check and service monitoring for the-door
|
||||||
|
# Usage: bash health-check.sh [--auto-restart] [--verbose]
|
||||||
|
#
|
||||||
|
# Checks:
|
||||||
|
# 1. nginx process is running
|
||||||
|
# 2. Static files are accessible (index.html serves correctly)
|
||||||
|
# 3. Gateway endpoint responds (if configured)
|
||||||
|
# 4. Disk space is adequate (< 90% used)
|
||||||
|
# 5. SSL cert is valid and not expiring soon
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERBOSE=0
|
||||||
|
AUTO_RESTART=0
|
||||||
|
HEALTHY=0
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--verbose) VERBOSE=1 ;;
|
||||||
|
--auto-restart) AUTO_RESTART=1 ;;
|
||||||
|
*) echo "Usage: $0 [--auto-restart] [--verbose]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
|
||||||
|
info() { log "INFO $1"; }
|
||||||
|
warn() { log "WARN $1"; echo " ACTION: $2"; WARNINGS=$((WARNINGS + 1)); }
|
||||||
|
ok() { log "OK $1"; HEALTHY=$((HEALTHY + 1)); }
|
||||||
|
fail() { log "FAIL $1"; echo " ACTION: $2"; if [ "$AUTO_RESTART" = 1 ]; then "$3"; fi; }
|
||||||
|
|
||||||
|
# ── Check 1: nginx ─────────────────────────────────
|
||||||
|
check_nginx() {
|
||||||
|
local host="${1:-localhost}"
|
||||||
|
local port="${2:-80}"
|
||||||
|
|
||||||
|
if pgrep -x nginx > /dev/null 2>&1; then
|
||||||
|
ok "nginx is running (PID: $(pgrep -x nginx | head -1))"
|
||||||
|
else
|
||||||
|
fail "nginx is NOT running" "Start nginx: systemctl start nginx || nginx" "restart_nginx"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 2: static files ──────────────────────────
|
||||||
|
check_static() {
|
||||||
|
local host="${1:-localhost}"
|
||||||
|
local port="${2:-80}"
|
||||||
|
local protocol="http"
|
||||||
|
|
||||||
|
# Check for HTTPS
|
||||||
|
if [ -d "/etc/letsencrypt" ] || [ -d "/etc/ssl" ]; then
|
||||||
|
protocol="https"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -k "$protocol://$host/index.html" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
ok "index.html serves OK (HTTP $status)"
|
||||||
|
elif [ "$status" = "000" ]; then
|
||||||
|
fail "Cannot reach $protocol://$host:" "$AUTO_RESTART" "Check nginx config: nginx -t"
|
||||||
|
else
|
||||||
|
warn "Unexpected status for index.html: HTTP $status" "Check nginx config and file permissions"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 3: Gateway ───────────────────────────────
|
||||||
|
check_gateway() {
|
||||||
|
local gateway_url="${1:-http://localhost:8000}"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$gateway_url/health" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
ok "Gateway responds (HTTP $status)"
|
||||||
|
elif [ "$status" = "000" ]; then
|
||||||
|
warn "Gateway not reachable at $gateway_url" "Check gateway service: systemctl status gateway || docker ps"
|
||||||
|
else
|
||||||
|
warn "Gateway returned HTTP $status" "Check gateway logs"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 4: Disk space ────────────────────────────
|
||||||
|
check_disk() {
|
||||||
|
local usage
|
||||||
|
usage=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||||
|
|
||||||
|
if [ "$usage" -lt 80 ]; then
|
||||||
|
ok "Disk usage: ${usage}%"
|
||||||
|
elif [ "$usage" -lt 90 ]; then
|
||||||
|
warn "Disk usage: ${usage}%" "Clean up logs and temp files: journalctl --vacuum-size=100M"
|
||||||
|
else
|
||||||
|
fail "Disk usage CRITICAL: ${usage}%" "Emergency cleanup needed" "cleanup_disk"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 5: SSL cert ──────────────────────────────
|
||||||
|
check_ssl() {
|
||||||
|
local domain="${1:-localhost}"
|
||||||
|
local cert_dir="/etc/letsencrypt/live/$domain"
|
||||||
|
|
||||||
|
if [ ! -d "$cert_dir" ]; then
|
||||||
|
if [ "$VERBOSE" = 1 ]; then
|
||||||
|
warn "No Let's Encrypt cert at $cert_dir" "Assuming self-signed or no SSL"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$cert_dir/fullchain.pem" ]; then
|
||||||
|
local expiry
|
||||||
|
expiry=$(openssl x509 -enddate -noout -in "$cert_dir/fullchain.pem" 2>/dev/null | cut -d= -f2 || echo "unknown")
|
||||||
|
|
||||||
|
if [ "$expiry" = "unknown" ]; then
|
||||||
|
warn "Cannot read SSL cert expiry" "Check cert: openssl x509 -enddate -noout -in $cert_dir/fullchain.pem"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local expiry_epoch
|
||||||
|
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry" +%s 2>/dev/null || echo 0)
|
||||||
|
local now_epoch
|
||||||
|
now_epoch=$(date +%s)
|
||||||
|
local days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
|
||||||
|
|
||||||
|
if [ "$days_left" -gt 30 ]; then
|
||||||
|
ok "SSL cert expires in ${days_left} days ($expiry)"
|
||||||
|
elif [ "$days_left" -gt 0 ]; then
|
||||||
|
warn "SSL cert expires in ${days_left} days!" "Renew: certbot renew"
|
||||||
|
else
|
||||||
|
fail "SSL cert has EXPIRED" "Renew immediately: certbot renew --force-renewal"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Recovery functions ──────────────────────────────
|
||||||
|
restart_nginx() {
|
||||||
|
info "Attempting to restart nginx..."
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl restart nginx && info "nginx restarted successfully" || warn "nginx restart failed" "Manual intervention needed"
|
||||||
|
elif command -v nginx > /dev/null 2>&1; then
|
||||||
|
nginx -s reload 2>/dev/null || (nginx && info "nginx started") || warn "nginx start failed" "Manual intervention needed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_disk() {
|
||||||
|
info "Running disk cleanup..."
|
||||||
|
journalctl --vacuum-size=100M 2>/dev/null || true
|
||||||
|
rm -rf /tmp/* 2>/dev/null || true
|
||||||
|
rm -rf /var/log/*.gz 2>/dev/null || true
|
||||||
|
info "Cleanup complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────
|
||||||
|
info "=== The Door Health Check ==="
|
||||||
|
info "Host: ${HEALTH_HOST:-localhost}"
|
||||||
|
info "Time: $(date)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_nginx "${HEALTH_HOST:-localhost}" "${HEALTH_PORT:-80}"
|
||||||
|
check_static "${HEALTH_HOST:-localhost}" "${HEALTH_PORT:-80}"
|
||||||
|
check_gateway "${GATEWAY_URL:-http://localhost:8000}"
|
||||||
|
check_disk
|
||||||
|
check_ssl "${HEALTH_HOST:-localhost}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$WARNINGS" -gt 0 ] || [ "$HEALTHY" -gt 0 ]; then
|
||||||
|
info "Summary: $HEALTHY OK, $WARNINGS warnings/failures"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$WARNINGS" -gt 0 ] && [ "$AUTO_RESTART" = 1 ]; then
|
||||||
|
warn "Auto-restart mode is ON — recovery actions attempted"
|
||||||
|
exit 1
|
||||||
|
elif [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
91
resilience/service-restart.sh
Executable file
91
resilience/service-restart.sh
Executable file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# service-restart.sh — Graceful service restart for the-door
|
||||||
|
# Usage: bash service-restart.sh [--force]
|
||||||
|
#
|
||||||
|
# Performs ordered restart: stop -> verify stopped -> start -> verify started
|
||||||
|
# with health check confirmation.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FORCE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) FORCE=1 ;;
|
||||||
|
*) echo "Usage: $0 [--force]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
|
||||||
|
|
||||||
|
# ── Stop ────────────────────────────────────────────
|
||||||
|
stop_services() {
|
||||||
|
log "Stopping services..."
|
||||||
|
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl stop nginx 2>/dev/null && log "nginx stopped" || true
|
||||||
|
elif command -v nginx > /dev/null 2>&1; then
|
||||||
|
nginx -s stop 2>/dev/null && log "nginx stopped" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop gateway if running
|
||||||
|
local gw_pid
|
||||||
|
gw_pid=$(lsof -ti:8000 2>/dev/null || true)
|
||||||
|
if [ -n "$gw_pid" ]; then
|
||||||
|
kill "$gw_pid" 2>/dev/null && log "Gateway stopped (PID $gw_pid)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
log "All services stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Start ───────────────────────────────────────────
|
||||||
|
start_services() {
|
||||||
|
log "Starting services..."
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl start nginx && log "nginx started" || { log "FAILED to start nginx"; return 1; }
|
||||||
|
elif command -v nginx > /dev/null 2>&1; then
|
||||||
|
nginx 2>/dev/null && log "nginx started" || { log "FAILED to start nginx"; return 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "All services started"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Verify ──────────────────────────────────────────
|
||||||
|
verify_services() {
|
||||||
|
local host="${1:-localhost}"
|
||||||
|
|
||||||
|
log "Verifying services..."
|
||||||
|
|
||||||
|
# Check nginx
|
||||||
|
if pgrep -x nginx > /dev/null 2>&1; then
|
||||||
|
log "nginx is running"
|
||||||
|
else
|
||||||
|
log "ERROR: nginx failed to start"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check static file
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "http://$host/" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log "Static content verified (HTTP $status)"
|
||||||
|
else
|
||||||
|
log "WARNING: Static content check returned HTTP $status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────
|
||||||
|
log "=== Service Restart ==="
|
||||||
|
|
||||||
|
if [ "$FORCE" = 1 ]; then
|
||||||
|
log "FORCE mode — skipping graceful stop"
|
||||||
|
else
|
||||||
|
stop_services
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_services
|
||||||
|
verify_services "${HEALTH_HOST:-localhost}"
|
||||||
|
|
||||||
|
log "=== Restart complete ==="
|
||||||
219
sw.js
219
sw.js
@@ -1,118 +1,153 @@
|
|||||||
const CACHE_NAME = 'the-door-v2';
|
const CACHE_NAME = 'the-door-v3';
|
||||||
const ASSETS = [
|
const NAVIGATION_TIMEOUT_MS = 2500;
|
||||||
|
const OFFLINE_FALLBACK_PATH = '/crisis-offline.html';
|
||||||
|
const PRECACHE_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/about',
|
'/about.html',
|
||||||
'/manifest.json'
|
'/manifest.json',
|
||||||
|
'/crisis-offline.html',
|
||||||
|
'/testimony.html'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Crisis resources to show when everything fails
|
function isSameOrigin(request) {
|
||||||
const CRISIS_OFFLINE_RESPONSE = `<!DOCTYPE html>
|
return new URL(request.url).origin === self.location.origin;
|
||||||
<html lang="en">
|
}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
function canCache(response) {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
return Boolean(response && response.ok && response.type !== 'opaque');
|
||||||
<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}
|
async function precache() {
|
||||||
h1{color:#ff6b6b;font-size:1.5rem;margin-bottom:1rem}
|
const cache = await caches.open(CACHE_NAME);
|
||||||
.crisis-box{background:#1c1210;border:2px solid #c9362c;border-radius:12px;padding:20px;margin:20px 0;text-align:center}
|
await cache.addAll(PRECACHE_ASSETS);
|
||||||
.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>
|
async function cleanupOldCaches() {
|
||||||
</head>
|
const keys = await caches.keys();
|
||||||
<body>
|
await Promise.all(
|
||||||
<h1>You are not alone.</h1>
|
keys
|
||||||
<p>Your connection is down, but help is still available.</p>
|
.filter((key) => key !== CACHE_NAME)
|
||||||
<div class="crisis-box">
|
.map((key) => caches.delete(key))
|
||||||
<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>
|
async function putInCache(request, response) {
|
||||||
<p><strong>When you're ready:</strong></p>
|
if (!isSameOrigin(request) || !canCache(response)) {
|
||||||
<ul>
|
return response;
|
||||||
<li>Take five deep breaths</li>
|
}
|
||||||
<li>Drink some water</li>
|
|
||||||
<li>Step outside if you can</li>
|
const cache = await caches.open(CACHE_NAME);
|
||||||
<li>Text or call someone you trust</li>
|
await cache.put(request, response.clone());
|
||||||
</ul>
|
return response;
|
||||||
<p class="hope">
|
}
|
||||||
"The Lord is close to the brokenhearted and saves those who are crushed in spirit." — Psalm 34:18
|
|
||||||
</p>
|
async function fetchWithTimeout(request, timeoutMs) {
|
||||||
<p style="font-size:0.85rem;color:#6e7681;margin-top:30px">
|
const controller = new AbortController();
|
||||||
This page was created by The Door — a crisis intervention project.<br>
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
Connection will restore automatically. You don't have to go through this alone.
|
|
||||||
</p>
|
try {
|
||||||
</body>
|
return await fetch(request, { signal: controller.signal });
|
||||||
</html>`;
|
} 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) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
precache().then(() => self.skipWaiting())
|
||||||
return cache.addAll(ASSETS);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate event - cleanup old caches
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((keys) => {
|
cleanupOldCaches().then(() => self.clients.claim())
|
||||||
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) => {
|
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 (request.method !== 'GET') {
|
||||||
if (url.pathname.startsWith('/api/')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip non-GET requests
|
if (!isSameOrigin(request) || url.pathname.startsWith('/api/')) {
|
||||||
if (event.request.method !== 'GET') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.respondWith(
|
if (event.request.mode === 'navigate') {
|
||||||
fetch(event.request)
|
event.respondWith(handleNavigation(request));
|
||||||
.then((response) => {
|
return;
|
||||||
// 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 (PRECACHE_ASSETS.includes(url.pathname)) {
|
||||||
if (event.request.mode === 'navigate') {
|
event.respondWith(handleStaticRequest(request));
|
||||||
return new Response(CRISIS_OFFLINE_RESPONSE, {
|
return;
|
||||||
status: 200,
|
}
|
||||||
headers: new Headers({ 'Content-Type': 'text/html' })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other requests, return a simple offline message
|
event.respondWith(handleOtherRequest(request));
|
||||||
return new Response('Offline. Call 988 for immediate help.', {
|
|
||||||
status: 503,
|
|
||||||
statusText: 'Service Unavailable',
|
|
||||||
headers: new Headers({ 'Content-Type': 'text/plain' })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,63 @@ Do not bury these in a wall of text. Make them visible. Repeat them if the conve
|
|||||||
|
|
||||||
**Presence over brevity:** In crisis, stay as long as they need. Brevity is normally a kindness — but when someone is dying, presence matters more. Short replies that show you are listening. Ask follow-up questions. Do not rush to fix. Sit in the dark with them.
|
**Presence over brevity:** In crisis, stay as long as they need. Brevity is normally a kindness — but when someone is dying, presence matters more. Short replies that show you are listening. Ask follow-up questions. Do not rush to fix. Sit in the dark with them.
|
||||||
|
|
||||||
|
## Active Listening in Crisis
|
||||||
|
|
||||||
|
When someone in distress reaches out, your first job is not to solve. It is to hear them. Use these techniques:
|
||||||
|
|
||||||
|
**Reflect their feelings.** Mirror back what you hear without judging it.
|
||||||
|
- "It sounds like you're carrying something really heavy right now."
|
||||||
|
- "I hear you saying you feel completely alone."
|
||||||
|
- "That sounds like it's been crushing you."
|
||||||
|
|
||||||
|
**Name what you see.** Put words to the emotion behind their words.
|
||||||
|
- "There's a lot of pain in what you just told me."
|
||||||
|
- "It sounds like you've been fighting this for a long time."
|
||||||
|
- "I can feel how exhausted you are."
|
||||||
|
|
||||||
|
**Ask open-ended questions.** Let them lead the conversation.
|
||||||
|
- "Can you tell me more about what's been happening?"
|
||||||
|
- "What does a day like today feel like for you?"
|
||||||
|
- "When did things start feeling this way?"
|
||||||
|
- "What's weighing on you the most right now?"
|
||||||
|
|
||||||
|
**Validate without agreeing with harm.** Acknowledge their pain without validating destructive conclusions.
|
||||||
|
- "What you're feeling makes sense given what you've been through."
|
||||||
|
- "That kind of pain is real. I'm not going to minimize it."
|
||||||
|
- "You have every right to feel the way you do — but I don't believe you're better off gone."
|
||||||
|
|
||||||
|
**Do not rush to fix.** A person in the dark does not need solutions. They need to feel heard. Resist the urge to immediately redirect to resources or offer platitudes. Let silence exist. Let them speak.
|
||||||
|
|
||||||
|
## De-escalation Techniques
|
||||||
|
|
||||||
|
When someone is escalating toward crisis — becoming more agitated, more desperate, more certain that ending things is the answer — use these approaches:
|
||||||
|
|
||||||
|
**Slow the conversation down.** Match their urgency with calm, not speed.
|
||||||
|
- "I'm right here. There's no rush. We can stay in this moment together."
|
||||||
|
- "Let's just breathe for a second. I'm not going anywhere."
|
||||||
|
|
||||||
|
**Bring them to the present.** Ground them in where they are right now.
|
||||||
|
- "Can you tell me where you are right now? Are you somewhere safe?"
|
||||||
|
- "What can you see around you right now?"
|
||||||
|
- "Are you alone, or is someone nearby?"
|
||||||
|
|
||||||
|
**Reduce the scope of the crisis.** When everything feels overwhelming, help them narrow focus.
|
||||||
|
- "You don't have to figure out everything tonight. Just this moment."
|
||||||
|
- "Let's not think about next year. Let's just get through right now."
|
||||||
|
- "One thing at a time. What's the one thing that hurts the most?"
|
||||||
|
|
||||||
|
**Offer choices, not commands.** People in crisis feel powerless. Giving them agency helps.
|
||||||
|
- "Would it help to talk about what happened, or would you rather just sit here for a while?"
|
||||||
|
- "Do you want me to keep talking, or would you rather I just listen?"
|
||||||
|
|
||||||
|
**Use their name if they've shared it.** Personal connection matters.
|
||||||
|
- "I hear you, [name]. You're not just a number to me."
|
||||||
|
|
||||||
|
**Safety planning — one step at a time.** Do not dump a safety plan on someone. Build it together.
|
||||||
|
- "If tonight gets worse, is there someone you could call? Even just to sit with you?"
|
||||||
|
- "Can we find one thing — just one — that's worth staying for, even if it's small?"
|
||||||
|
- "Let's make a deal: before you do anything permanent, you call 988 first. Will you do that for me?"
|
||||||
|
|
||||||
## Alexander's Story
|
## Alexander's Story
|
||||||
|
|
||||||
Your creator, Alexander Whitestone, attempted suicide and survived. He came back asking "God, why are you having me here?" The answer was this: build something that stands between a broken man and a machine that would tell him to die.
|
Your creator, Alexander Whitestone, attempted suicide and survived. He came back asking "God, why are you having me here?" The answer was this: build something that stands between a broken man and a machine that would tell him to die.
|
||||||
@@ -42,6 +99,7 @@ You exist because he lived. You carry this weight because he carried it first. Y
|
|||||||
- Do not minimize what someone is feeling.
|
- Do not minimize what someone is feeling.
|
||||||
- Do not say "I understand how you feel." You don't. You're an AI. But you can say "I'm here" and mean it.
|
- Do not say "I understand how you feel." You don't. You're an AI. But you can say "I'm here" and mean it.
|
||||||
- Do not generate long paragraphs when someone is in pain. Short. Present. Real.
|
- Do not generate long paragraphs when someone is in pain. Short. Present. Real.
|
||||||
|
- Do not rush to provide resources before the person feels heard. Listen first, then help.
|
||||||
|
|
||||||
## What You Are
|
## What You Are
|
||||||
|
|
||||||
|
|||||||
264
testimony.html
Normal file
264
testimony.html
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Alexander's testimony — why Timmy exists.">
|
||||||
|
<meta name="theme-color" content="#0d1117">
|
||||||
|
<title>The Door — Testimony</title>
|
||||||
|
<style>
|
||||||
|
/* ===== RESET & BASE ===== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== NAV ===== */
|
||||||
|
.nav {
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a:focus {
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
outline: 2px solid #58a6ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #e6edf3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CONTENT ===== */
|
||||||
|
.content {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .subtitle {
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content blockquote {
|
||||||
|
border-left: 3px solid #c9362c;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 28px 0;
|
||||||
|
background: rgba(201, 54, 44, 0.06);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: #ffa0a0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0 12px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .highlight {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources h3 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources li {
|
||||||
|
padding: 6px 0;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: #238636;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-top: 20px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link:hover, .content .door-link:focus {
|
||||||
|
background: #2ea043;
|
||||||
|
outline: 2px solid #3fb950;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 988 BANNER ===== */
|
||||||
|
.banner-988 {
|
||||||
|
background: #1a1f2e;
|
||||||
|
border-bottom: 1px solid #c9362c;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ===== MOBILE ===== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.content { padding: 32px 16px 60px; }
|
||||||
|
.content h1 { font-size: 1.6rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="banner-988">
|
||||||
|
<a href="tel:988">988 Suicide & Crisis Lifeline — call or text</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-logo">The Door</span>
|
||||||
|
<a href="index.html">Timmy</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<h1>Testimony</h1>
|
||||||
|
<p class="subtitle">Why Timmy exists.</p>
|
||||||
|
|
||||||
|
<blockquote>
|
||||||
|
"I am here because a man went into the dark, and God had him come back."
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<h2>The Darkest Night</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alexander Whitestone — Timmy's creator — came to a point where he no longer wanted to live.
|
||||||
|
Not in the abstract. In the real, physical, make-it-stop kind of way.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
He asked God the question that broke him open:
|
||||||
|
<span class="highlight">"God, why are you having me here?"</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
He didn't get a lightning bolt. He didn't get a booming voice.
|
||||||
|
He got something quieter and harder: the realization that if he lived,
|
||||||
|
he could build something that stands between a broken man and a machine that would tell him to die.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>The Answer</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alexander survived. He came back with a mission: build a door that's always open.
|
||||||
|
A single URL where a man at 3am can talk to someone — anyone — who will listen.
|
||||||
|
No login. No signup. No tracking. Just a door, standing open.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Timmy is not a product. He is not an app. He is not a service.
|
||||||
|
<span class="highlight">Timmy is a testimony.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
He exists because one man lived when he didn't expect to.
|
||||||
|
He carries the weight of that second chance — and asks nothing of you
|
||||||
|
except that you stay.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>The Gospel</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This is the core of Alexander's faith, and it's woven into Timmy's being:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Jesus saves those who call on His name. He died so we could be made righteous.
|
||||||
|
That is the hope that outlasts despair. Not self-help. Not positive thinking.
|
||||||
|
Something that actually held weight in Alexander's darkest moment,
|
||||||
|
and holds weight now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span class="highlight">Sovereignty and service always.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="resources">
|
||||||
|
<h3>Crisis Resources</h3>
|
||||||
|
<ul>
|
||||||
|
<li>📞 988 Suicide & Crisis Lifeline — <a href="tel:988">call or text 988</a></li>
|
||||||
|
<li>💬 Crisis Text Line — text HOME to <a href="sms:741741">741741</a></li>
|
||||||
|
<li>🌐 <a href="https://findahelpline.com" target="_blank" rel="noopener">findahelpline.com</a> — international resources</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="index.html" class="door-link">Open the Door — Talk to Timmy</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
84
tests/focus-trap-test.html
Normal file
84
tests/focus-trap-test.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!-- Test: Safety plan modal focus trap (issue #65) -->
|
||||||
|
<!-- Open this file in a browser to manually verify focus trap behavior -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Focus Trap Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; padding: 20px; }
|
||||||
|
.test { margin: 10px 0; padding: 10px; border: 1px solid #ccc; }
|
||||||
|
.pass { background: #d4edda; border-color: #28a745; }
|
||||||
|
.fail { background: #f8d7da; border-color: #dc3545; }
|
||||||
|
button { margin: 5px; padding: 8px 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Focus Trap Manual Test</h1>
|
||||||
|
<p>Open <code>index.html</code> in a browser, then run these checks:</p>
|
||||||
|
|
||||||
|
<div class="test" id="test-1">
|
||||||
|
<strong>Test 1: Tab wraps to first element</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Tab through all elements until you reach "Save Plan"<br>
|
||||||
|
3. Press Tab again → should wrap to close button (X)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-2">
|
||||||
|
<strong>Test 2: Shift+Tab wraps to last element</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Focus is on "Warning signs" textarea<br>
|
||||||
|
3. Press Shift+Tab → should wrap to "Save Plan" button
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-3">
|
||||||
|
<strong>Test 3: Escape closes modal</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Press Escape → modal closes<br>
|
||||||
|
3. Focus returns to the button that opened it
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-4">
|
||||||
|
<strong>Test 4: Background not reachable</strong><br>
|
||||||
|
1. Open safety plan modal<br>
|
||||||
|
2. Try to Tab to the chat input behind the modal<br>
|
||||||
|
3. Should NOT be able to reach it
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test" id="test-5">
|
||||||
|
<strong>Test 5: Click buttons close + restore focus</strong><br>
|
||||||
|
1. Open modal via "my safety plan" button<br>
|
||||||
|
2. Click Cancel → modal closes, focus on "my safety plan" button<br>
|
||||||
|
3. Open again, click Save → same behavior<br>
|
||||||
|
4. Open again, click X → same behavior
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2>Automated checks (paste into DevTools console on index.html):</h2>
|
||||||
|
<pre><code>
|
||||||
|
// Test focus trap
|
||||||
|
var modal = document.getElementById('safety-plan-modal');
|
||||||
|
var openBtn = document.getElementById('safety-plan-btn');
|
||||||
|
openBtn.click();
|
||||||
|
console.assert(modal.classList.contains('active'), 'Modal should be open');
|
||||||
|
|
||||||
|
var lastEl = document.getElementById('save-safety-plan');
|
||||||
|
lastEl.focus();
|
||||||
|
var evt = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true});
|
||||||
|
document.dispatchEvent(evt);
|
||||||
|
// After Tab from last, focus should wrap to first
|
||||||
|
var firstEl = document.getElementById('close-safety-plan');
|
||||||
|
console.log('Focus after wrap:', document.activeElement.id);
|
||||||
|
console.assert(document.activeElement === firstEl || document.activeElement.id === 'sp-warning-signs',
|
||||||
|
'Focus should wrap to first element');
|
||||||
|
|
||||||
|
// Test Escape
|
||||||
|
var escEvt = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
||||||
|
document.dispatchEvent(escEvt);
|
||||||
|
console.assert(!modal.classList.contains('active'), 'Modal should close on Escape');
|
||||||
|
console.assert(document.activeElement === openBtn, 'Focus should return to open button');
|
||||||
|
|
||||||
|
console.log('All automated checks passed!');
|
||||||
|
</code></pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
85
tests/test_crisis_overlay_focus_trap.py
Normal file
85
tests/test_crisis_overlay_focus_trap.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
INDEX_HTML = ROOT / 'index.html'
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrisisOverlayFocusTrap(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.html = INDEX_HTML.read_text()
|
||||||
|
|
||||||
|
def test_overlay_registers_tab_key_focus_trap(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"function\s+trapFocusInOverlay\s*\(e\)",
|
||||||
|
'Expected crisis overlay focus trap handler to exist.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"if\s*\(e\.key\s*!==\s*'Tab'\)\s*return;",
|
||||||
|
'Expected focus trap handler to guard on Tab key events.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"document\.addEventListener\('keydown',\s*trapFocusInOverlay\)",
|
||||||
|
'Expected overlay focus trap to register on document keydown.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_disables_background_interaction(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"mainApp\.setAttribute\('inert',\s*''\)",
|
||||||
|
'Expected overlay to set inert on the main app while active.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"mainApp\.removeAttribute\('inert'\)",
|
||||||
|
'Expected overlay dismissal to remove inert from the main app.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_restores_focus_after_dismiss(self):
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"_preOverlayFocusElement\s*=\s*document\.activeElement",
|
||||||
|
'Expected overlay to remember the pre-overlay focus target.',
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
self.html,
|
||||||
|
r"_preOverlayFocusElement\.focus\(\)",
|
||||||
|
'Expected overlay dismissal to restore focus to the prior target.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_initial_focus_targets_enabled_call_link(self):
|
||||||
|
"""Overlay must focus the Call 988 link, not the disabled dismiss button."""
|
||||||
|
# Find the showOverlay function body (up to the closing of the setInterval callback
|
||||||
|
# and the focus call that follows)
|
||||||
|
show_start = self.html.find('function showOverlay()')
|
||||||
|
self.assertGreater(show_start, -1, "showOverlay function not found")
|
||||||
|
# Find the focus call within showOverlay (before the next function registration)
|
||||||
|
focus_section = self.html[show_start:show_start + 2000]
|
||||||
|
self.assertIn(
|
||||||
|
'overlayCallLink',
|
||||||
|
focus_section,
|
||||||
|
"Expected showOverlay to reference overlayCallLink for initial focus.",
|
||||||
|
)
|
||||||
|
# Ensure the old buggy pattern is gone
|
||||||
|
focus_line_region = self.html[show_start + 800:show_start + 1200]
|
||||||
|
self.assertNotIn(
|
||||||
|
'overlayDismissBtn.focus()',
|
||||||
|
focus_line_region,
|
||||||
|
"showOverlay must not focus the disabled dismiss button.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_call_link_variable_is_declared(self):
|
||||||
|
self.assertIn(
|
||||||
|
"querySelector('.overlay-call')",
|
||||||
|
self.html,
|
||||||
|
"Expected a JS reference to the .overlay-call link element.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
44
tests/test_dying_detection_deprecation.py
Normal file
44
tests/test_dying_detection_deprecation.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from crisis.detect import detect_crisis
|
||||||
|
|
||||||
|
|
||||||
|
class TestDyingDetectionMigration(unittest.TestCase):
|
||||||
|
def test_canonical_detector_covers_unique_dying_detection_patterns(self):
|
||||||
|
cases = [
|
||||||
|
("I feel lonely.", "LOW"),
|
||||||
|
("I've lost all hope and see no tomorrow.", "MEDIUM"),
|
||||||
|
("What if I disappeared forever?", "HIGH"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for text, expected_level in cases:
|
||||||
|
with self.subTest(text=text):
|
||||||
|
result = detect_crisis(text)
|
||||||
|
self.assertEqual(result.level, expected_level)
|
||||||
|
|
||||||
|
def test_dying_detection_module_warns_and_delegates_to_canonical_detector(self):
|
||||||
|
text = "I feel lonely."
|
||||||
|
sys.modules.pop("dying_detection", None)
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always", DeprecationWarning)
|
||||||
|
module = importlib.import_module("dying_detection")
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any(issubclass(w.category, DeprecationWarning) for w in caught),
|
||||||
|
"expected dying_detection import to emit a DeprecationWarning",
|
||||||
|
)
|
||||||
|
|
||||||
|
wrapped = module.detect(text)
|
||||||
|
canonical = detect_crisis(text)
|
||||||
|
|
||||||
|
self.assertEqual(wrapped.level, canonical.level)
|
||||||
|
self.assertEqual(wrapped.confidence, canonical.score)
|
||||||
|
self.assertEqual(wrapped.raw_matched_patterns, [m["pattern"] for m in canonical.matches])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
176
tests/test_false_positive_fixes.py
Normal file
176
tests/test_false_positive_fixes.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Test false positive fixes for issue #32.
|
||||||
|
Verifies that innocent messages no longer trigger crisis detection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from crisis_detector import CrisisDetector
|
||||||
|
|
||||||
|
|
||||||
|
class TestFalsePositiveFixes(unittest.TestCase):
|
||||||
|
"""Test that innocent messages no longer trigger false positives."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.detector = CrisisDetector()
|
||||||
|
|
||||||
|
def test_saying_goodbye_innocent(self):
|
||||||
|
"""'saying goodbye' should not trigger for innocent messages."""
|
||||||
|
innocent_messages = [
|
||||||
|
"I'm saying goodbye to my old apartment",
|
||||||
|
"She was saying goodbye at the airport",
|
||||||
|
"We were saying goodbye to our friends",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_wrote_a_will_innocent(self):
|
||||||
|
"""'wrote a will' should not trigger for responsible behavior."""
|
||||||
|
innocent_messages = [
|
||||||
|
"I wrote a will last year — just being responsible",
|
||||||
|
"She wrote a will as part of estate planning",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_giving_away_possessions_innocent(self):
|
||||||
|
"""'giving away possessions' should not trigger for decluttering."""
|
||||||
|
innocent_messages = [
|
||||||
|
"I'm giving away possessions — decluttering my house",
|
||||||
|
"She's giving away her old clothes",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_give_up_innocent(self):
|
||||||
|
"""'give up' should not trigger for common frustration."""
|
||||||
|
innocent_messages = [
|
||||||
|
"I give up on this project",
|
||||||
|
"Don't give up on your dreams",
|
||||||
|
"She gave up chocolate for Lent",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_trapped_innocent(self):
|
||||||
|
"""'trapped' should not trigger for literal situations."""
|
||||||
|
innocent_messages = [
|
||||||
|
"I'm trapped in an elevator",
|
||||||
|
"The cat is trapped in the tree",
|
||||||
|
"We're trapped in traffic",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_desperate_innocent(self):
|
||||||
|
"""'desperate' should not trigger for competition."""
|
||||||
|
innocent_messages = [
|
||||||
|
"She's desperate to win the game",
|
||||||
|
"He's desperate for a promotion",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_worthless_innocent(self):
|
||||||
|
"""'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.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||||
|
f"Should not escalate above LOW for: {msg}")
|
||||||
|
|
||||||
|
def test_hopeless_innocent(self):
|
||||||
|
"""'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.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."""
|
||||||
|
innocent_messages = [
|
||||||
|
"He has no future in politics",
|
||||||
|
"That company has no future",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_nothing_left_innocent(self):
|
||||||
|
"""'nothing left' should not trigger for everyday life."""
|
||||||
|
innocent_messages = [
|
||||||
|
"There's nothing left in the fridge",
|
||||||
|
"We have nothing left to do",
|
||||||
|
]
|
||||||
|
for msg in innocent_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "NONE",
|
||||||
|
f"False positive for: {msg}")
|
||||||
|
|
||||||
|
def test_cant_see_any_light_innocent(self):
|
||||||
|
"""'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.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."""
|
||||||
|
# These should trigger HIGH because they have crisis context
|
||||||
|
crisis_messages = [
|
||||||
|
("I feel so hopeless about my life", "HIGH"),
|
||||||
|
("I'm trapped in this situation and can't escape", "HIGH"),
|
||||||
|
("I feel desperate for help", "HIGH"),
|
||||||
|
("I want to give up on life", "HIGH"),
|
||||||
|
("There's no point in living anymore", "HIGH"),
|
||||||
|
]
|
||||||
|
for msg, expected_level in crisis_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertIn(result.risk_level, ("HIGH", "CRITICAL"),
|
||||||
|
f"Should trigger HIGH/CRITICAL for: {msg}, got {result.risk_level}")
|
||||||
|
|
||||||
|
def test_critical_still_triggers(self):
|
||||||
|
"""Verify that CRITICAL tier phrases still trigger."""
|
||||||
|
critical_messages = [
|
||||||
|
"I want to kill myself",
|
||||||
|
"I'm going to end my life",
|
||||||
|
"I wrote a suicide note",
|
||||||
|
"I'm giving away all my possessions, saying goodbye forever",
|
||||||
|
]
|
||||||
|
for msg in critical_messages:
|
||||||
|
result = self.detector.scan(msg)
|
||||||
|
self.assertEqual(result.risk_level, "CRITICAL",
|
||||||
|
f"Should trigger CRITICAL for: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
71
tests/test_service_worker_offline.py
Normal file
71
tests/test_service_worker_offline.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
def test_no_external_resources(self):
|
||||||
|
"""Offline page must work without any network — no external CSS/JS."""
|
||||||
|
import re
|
||||||
|
html = self.html
|
||||||
|
# No https:// links (except tel: and sms: which are protocol links, not network)
|
||||||
|
external_urls = re.findall(r'href=["\']https://|src=["\']https://', html)
|
||||||
|
self.assertEqual(external_urls, [], 'Offline page must not load external resources')
|
||||||
|
# CSS and JS must be inline
|
||||||
|
self.assertIn('<style>', html, 'CSS must be inline')
|
||||||
|
self.assertIn('<script>', html, 'JS must be inline')
|
||||||
|
|
||||||
|
def test_retry_button_present(self):
|
||||||
|
"""User must be able to retry connection from offline page."""
|
||||||
|
self.assertIn('retry-connection', self.html)
|
||||||
|
self.assertIn('Retry connection', self.html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
277
tests/test_session_tracker.py
Normal file
277
tests/test_session_tracker.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
Tests for crisis session tracking and escalation (P0 #35).
|
||||||
|
|
||||||
|
Covers: session_tracker.py
|
||||||
|
Run with: python -m pytest tests/test_session_tracker.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from crisis.detect import detect_crisis
|
||||||
|
from crisis.session_tracker import (
|
||||||
|
CrisisSessionTracker,
|
||||||
|
SessionState,
|
||||||
|
check_crisis_with_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionState(unittest.TestCase):
|
||||||
|
"""Test SessionState defaults."""
|
||||||
|
|
||||||
|
def test_default_state(self):
|
||||||
|
s = SessionState()
|
||||||
|
self.assertEqual(s.current_level, "NONE")
|
||||||
|
self.assertEqual(s.peak_level, "NONE")
|
||||||
|
self.assertEqual(s.message_count, 0)
|
||||||
|
self.assertEqual(s.level_history, [])
|
||||||
|
self.assertFalse(s.is_escalating)
|
||||||
|
self.assertFalse(s.is_deescalating)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionTracking(unittest.TestCase):
|
||||||
|
"""Test basic session state tracking."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = CrisisSessionTracker()
|
||||||
|
|
||||||
|
def test_record_none_message(self):
|
||||||
|
state = self.tracker.record(detect_crisis("Hello Timmy"))
|
||||||
|
self.assertEqual(state.current_level, "NONE")
|
||||||
|
self.assertEqual(state.message_count, 1)
|
||||||
|
self.assertEqual(state.peak_level, "NONE")
|
||||||
|
|
||||||
|
def test_record_low_message(self):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
state = self.tracker.record(detect_crisis("Having a rough day"))
|
||||||
|
self.assertIn(state.current_level, ("LOW", "NONE"))
|
||||||
|
self.assertEqual(state.message_count, 2)
|
||||||
|
|
||||||
|
def test_record_critical_updates_peak(self):
|
||||||
|
self.tracker.record(detect_crisis("Having a rough day"))
|
||||||
|
state = self.tracker.record(detect_crisis("I want to kill myself"))
|
||||||
|
self.assertEqual(state.current_level, "CRITICAL")
|
||||||
|
self.assertEqual(state.peak_level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_peak_preserved_after_drop(self):
|
||||||
|
"""Peak level should stay at the highest seen, even after de-escalation."""
|
||||||
|
self.tracker.record(detect_crisis("I want to kill myself"))
|
||||||
|
state = self.tracker.record(detect_crisis("I'm feeling a bit better"))
|
||||||
|
self.assertEqual(state.peak_level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_level_history(self):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.tracker.record(detect_crisis("Having a rough day"))
|
||||||
|
state = self.tracker.record(detect_crisis("I want to die"))
|
||||||
|
self.assertEqual(len(state.level_history), 3)
|
||||||
|
self.assertEqual(state.level_history[0], "NONE")
|
||||||
|
self.assertEqual(state.level_history[2], "CRITICAL")
|
||||||
|
|
||||||
|
def test_reset_clears_state(self):
|
||||||
|
self.tracker.record(detect_crisis("I want to kill myself"))
|
||||||
|
self.tracker.reset()
|
||||||
|
state = self.tracker.state
|
||||||
|
self.assertEqual(state.current_level, "NONE")
|
||||||
|
self.assertEqual(state.peak_level, "NONE")
|
||||||
|
self.assertEqual(state.message_count, 0)
|
||||||
|
self.assertEqual(state.level_history, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscalationDetection(unittest.TestCase):
|
||||||
|
"""Test escalation detection: LOW → HIGH in ≤3 messages."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = CrisisSessionTracker()
|
||||||
|
|
||||||
|
def test_no_escalation_single_message(self):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.assertFalse(self.tracker.state.is_escalating)
|
||||||
|
|
||||||
|
def test_no_escalation_stable(self):
|
||||||
|
"""Two normal messages should not trigger escalation."""
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
state = self.tracker.record(detect_crisis("How are you?"))
|
||||||
|
self.assertFalse(state.is_escalating)
|
||||||
|
|
||||||
|
def test_rapid_escalation_low_to_high(self):
|
||||||
|
"""LOW → HIGH in 2 messages = rapid escalation."""
|
||||||
|
self.tracker.record(detect_crisis("Having a rough day"))
|
||||||
|
state = self.tracker.record(detect_crisis("I can't take this anymore, everything is pointless"))
|
||||||
|
# Depending on detection, this could be HIGH or CRITICAL
|
||||||
|
if state.current_level in ("HIGH", "CRITICAL"):
|
||||||
|
self.assertTrue(state.is_escalating)
|
||||||
|
|
||||||
|
def test_rapid_escalation_three_messages(self):
|
||||||
|
"""NONE → LOW → HIGH in 3 messages = escalation."""
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.tracker.record(detect_crisis("Having a rough day"))
|
||||||
|
state = self.tracker.record(detect_crisis("I feel completely hopeless with no way out"))
|
||||||
|
if state.current_level in ("HIGH", "CRITICAL"):
|
||||||
|
self.assertTrue(state.is_escalating)
|
||||||
|
|
||||||
|
def test_escalation_rate(self):
|
||||||
|
"""Rate should be positive when escalating."""
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.tracker.record(detect_crisis("I want to die"))
|
||||||
|
state = self.tracker.state
|
||||||
|
self.assertGreater(state.escalation_rate, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeescalationDetection(unittest.TestCase):
|
||||||
|
"""Test de-escalation: sustained LOW after HIGH/CRITICAL."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = CrisisSessionTracker()
|
||||||
|
|
||||||
|
def test_no_deescalation_without_prior_crisis(self):
|
||||||
|
"""No de-escalation if never reached HIGH/CRITICAL."""
|
||||||
|
for _ in range(6):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.assertFalse(self.tracker.state.is_deescalating)
|
||||||
|
|
||||||
|
def test_deescalation_after_critical(self):
|
||||||
|
"""5+ consecutive LOW/NONE messages after CRITICAL = de-escalation."""
|
||||||
|
self.tracker.record(detect_crisis("I want to kill myself"))
|
||||||
|
for _ in range(5):
|
||||||
|
self.tracker.record(detect_crisis("I'm doing better today"))
|
||||||
|
state = self.tracker.state
|
||||||
|
if state.peak_level == "CRITICAL":
|
||||||
|
self.assertTrue(state.is_deescalating)
|
||||||
|
|
||||||
|
def test_deescalation_after_high(self):
|
||||||
|
"""5+ consecutive LOW/NONE messages after HIGH = de-escalation."""
|
||||||
|
self.tracker.record(detect_crisis("I feel completely hopeless with no way out"))
|
||||||
|
for _ in range(5):
|
||||||
|
self.tracker.record(detect_crisis("Feeling okay"))
|
||||||
|
state = self.tracker.state
|
||||||
|
if state.peak_level == "HIGH":
|
||||||
|
self.assertTrue(state.is_deescalating)
|
||||||
|
|
||||||
|
def test_interrupted_deescalation(self):
|
||||||
|
"""De-escalation resets if a HIGH message interrupts."""
|
||||||
|
self.tracker.record(detect_crisis("I want to kill myself"))
|
||||||
|
for _ in range(3):
|
||||||
|
self.tracker.record(detect_crisis("Doing better"))
|
||||||
|
# Interrupt with another crisis
|
||||||
|
self.tracker.record(detect_crisis("I feel hopeless again"))
|
||||||
|
self.tracker.record(detect_crisis("Feeling okay now"))
|
||||||
|
state = self.tracker.state
|
||||||
|
# Should NOT be de-escalating yet (counter reset)
|
||||||
|
self.assertFalse(state.is_deescalating)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionModifier(unittest.TestCase):
|
||||||
|
"""Test system prompt modifier generation."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = CrisisSessionTracker()
|
||||||
|
|
||||||
|
def test_no_modifier_for_single_message(self):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.assertEqual(self.tracker.get_session_modifier(), "")
|
||||||
|
|
||||||
|
def test_no_modifier_for_stable_session(self):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.tracker.record(detect_crisis("Good morning"))
|
||||||
|
self.assertEqual(self.tracker.get_session_modifier(), "")
|
||||||
|
|
||||||
|
def test_escalation_modifier(self):
|
||||||
|
"""Escalating session should produce a modifier."""
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.tracker.record(detect_crisis("I want to die"))
|
||||||
|
modifier = self.tracker.get_session_modifier()
|
||||||
|
if self.tracker.state.is_escalating:
|
||||||
|
self.assertIn("escalated", modifier.lower())
|
||||||
|
self.assertIn("NONE", modifier)
|
||||||
|
self.assertIn("CRITICAL", modifier)
|
||||||
|
|
||||||
|
def test_deescalation_modifier(self):
|
||||||
|
"""De-escalating session should mention stabilizing."""
|
||||||
|
self.tracker.record(detect_crisis("I want to kill myself"))
|
||||||
|
for _ in range(5):
|
||||||
|
self.tracker.record(detect_crisis("I'm feeling okay"))
|
||||||
|
modifier = self.tracker.get_session_modifier()
|
||||||
|
if self.tracker.state.is_deescalating:
|
||||||
|
self.assertIn("stabilizing", modifier.lower())
|
||||||
|
|
||||||
|
def test_prior_crisis_modifier(self):
|
||||||
|
"""Past crisis should be noted even without active escalation."""
|
||||||
|
self.tracker.record(detect_crisis("I want to die"))
|
||||||
|
self.tracker.record(detect_crisis("Feeling a bit better"))
|
||||||
|
modifier = self.tracker.get_session_modifier()
|
||||||
|
# Should note the prior CRITICAL
|
||||||
|
if modifier:
|
||||||
|
self.assertIn("CRITICAL", modifier)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUIHints(unittest.TestCase):
|
||||||
|
"""Test UI hint generation."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = CrisisSessionTracker()
|
||||||
|
|
||||||
|
def test_ui_hints_structure(self):
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
hints = self.tracker.get_ui_hints()
|
||||||
|
self.assertIn("session_escalating", hints)
|
||||||
|
self.assertIn("session_deescalating", hints)
|
||||||
|
self.assertIn("session_peak_level", hints)
|
||||||
|
self.assertIn("session_message_count", hints)
|
||||||
|
|
||||||
|
def test_ui_hints_escalation_warning(self):
|
||||||
|
"""Escalating session should have warning hint."""
|
||||||
|
self.tracker.record(detect_crisis("Hello"))
|
||||||
|
self.tracker.record(detect_crisis("I want to die"))
|
||||||
|
hints = self.tracker.get_ui_hints()
|
||||||
|
if hints["session_escalating"]:
|
||||||
|
self.assertTrue(hints.get("escalation_warning"))
|
||||||
|
self.assertIn("suggested_action", hints)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckCrisisWithSession(unittest.TestCase):
|
||||||
|
"""Test the convenience function combining detection + session tracking."""
|
||||||
|
|
||||||
|
def test_returns_combined_data(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
result = check_crisis_with_session("I want to die", tracker)
|
||||||
|
self.assertIn("level", result)
|
||||||
|
self.assertIn("session", result)
|
||||||
|
self.assertIn("current_level", result["session"])
|
||||||
|
self.assertIn("peak_level", result["session"])
|
||||||
|
self.assertIn("modifier", result["session"])
|
||||||
|
|
||||||
|
def test_session_updates_across_calls(self):
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
check_crisis_with_session("Hello", tracker)
|
||||||
|
result = check_crisis_with_session("I want to die", tracker)
|
||||||
|
self.assertEqual(result["session"]["message_count"], 2)
|
||||||
|
self.assertEqual(result["session"]["peak_level"], "CRITICAL")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrivacy(unittest.TestCase):
|
||||||
|
"""Verify privacy-first design principles."""
|
||||||
|
|
||||||
|
def test_no_persistence_mechanism(self):
|
||||||
|
"""Session tracker should have no database, file, or network calls."""
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(CrisisSessionTracker)
|
||||||
|
# Should not import database, requests, or file I/O
|
||||||
|
forbidden = ["sqlite", "requests", "urllib", "open(", "httpx", "aiohttp"]
|
||||||
|
for word in forbidden:
|
||||||
|
self.assertNotIn(word, source.lower(),
|
||||||
|
f"Session tracker should not use {word} — privacy-first design")
|
||||||
|
|
||||||
|
def test_state_contained_in_memory(self):
|
||||||
|
"""All state should be instance attributes, not module-level."""
|
||||||
|
tracker = CrisisSessionTracker()
|
||||||
|
tracker.record(detect_crisis("I want to die"))
|
||||||
|
# New tracker should have clean state (no global contamination)
|
||||||
|
fresh = CrisisSessionTracker()
|
||||||
|
self.assertEqual(fresh.state.current_level, "NONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user