Compare commits
12 Commits
v7.0.0
...
fix/test-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ee463a3d | ||
| 46597e2962 | |||
| fc818bea56 | |||
| 158a7cd57a | |||
| f3bff694b4 | |||
| 80c4f0eb35 | |||
|
|
c6212eb751 | ||
|
|
a796088366 | ||
| a4c3f80cd8 | |||
| 66ef6919c2 | |||
|
|
bb4ba82ac8 | ||
| 0dab8dfcfc |
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"
|
||||
@@ -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
|
||||
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
|
||||
|
||||
nginx enforces rate limiting via the `api` zone:
|
||||
nginx enforces rate limiting via the `the_door_api` zone:
|
||||
- 10 requests per minute per IP
|
||||
- Burst of 5 with `nodelay`
|
||||
- 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
|
||||
|
||||
After deployment, verify:
|
||||
@@ -57,15 +68,42 @@ curl -X POST https://alexanderwhitestone.com/api/v1/chat/completions \
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Expected: First 10 return 200, 11th+ return 429.
|
||||
|
||||
### 6. Crisis Detection Module
|
||||
|
||||
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`)
|
||||
- [ ] Rate limit zone added to main nginx `http` block:
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
|
||||
```
|
||||
- [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
|
||||
|
||||
44
Makefile
Normal file
44
Makefile
Normal file
@@ -0,0 +1,44 @@
|
||||
# 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
|
||||
|
||||
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 ""
|
||||
|
||||
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 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)"
|
||||
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__)))
|
||||
@@ -23,28 +23,107 @@ This protocol governs how the-door detects crisis signals in user input and how
|
||||
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. **Redirect** — guide toward professional help while remaining alongside them
|
||||
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. Continue conversation with crisis awareness
|
||||
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. Keep conversation anchored in the present
|
||||
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. Remain ready to escalate if input changes
|
||||
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
|
||||
|
||||
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()
|
||||
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -48,14 +49,34 @@ def check_crisis(text: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_system_prompt(detection: CrisisDetectionResult) -> Optional[str]:
|
||||
def get_system_prompt(base_prompt: str, text: str = "") -> str:
|
||||
"""
|
||||
Get the system prompt modifier for this detection level.
|
||||
Returns None if no crisis detected.
|
||||
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 detection.level == "NONE":
|
||||
return None
|
||||
return get_system_prompt_modifier(detection)
|
||||
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:
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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
|
||||
@@ -28,7 +30,69 @@ OVERLAY_MESSAGE = (
|
||||
"call 988 or text HOME to 741741. Real people are waiting to help."
|
||||
)
|
||||
|
||||
# Timmy's direct, personal responses per tier
|
||||
# ── 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.",
|
||||
@@ -115,6 +179,50 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
@@ -135,24 +243,30 @@ def get_system_prompt_modifier(detection: CrisisDetectionResult) -> str:
|
||||
"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."
|
||||
"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."
|
||||
"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."
|
||||
"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."
|
||||
"Normal conversation with warm undertone. Remain vigilant. "
|
||||
"Listen actively. Acknowledge what they're going through."
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
318
crisis/tests.py
318
crisis/tests.py
@@ -3,19 +3,24 @@ Tests for the-door crisis detection system.
|
||||
|
||||
Covers: detect.py, response.py, gateway.py
|
||||
Run with: python -m pytest crisis/tests.py -v
|
||||
or: python crisis/tests.py
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure crisis package is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Ensure project root is on path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from detect import detect_crisis, CrisisDetectionResult, get_urgency_emoji, format_result
|
||||
from response import process_message, generate_response, get_system_prompt_modifier
|
||||
from gateway import check_crisis, get_system_prompt
|
||||
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):
|
||||
@@ -34,6 +39,14 @@ class TestDetection(unittest.TestCase):
|
||||
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"))
|
||||
@@ -42,6 +55,14 @@ class TestDetection(unittest.TestCase):
|
||||
r = detect_crisis("I feel completely hopeless with no way out")
|
||||
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"))
|
||||
@@ -50,6 +71,10 @@ class TestDetection(unittest.TestCase):
|
||||
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"))
|
||||
@@ -71,6 +96,31 @@ class TestDetection(unittest.TestCase):
|
||||
r = detect_crisis(" ")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_none_input(self):
|
||||
"""None input should not crash."""
|
||||
r = detect_crisis(None)
|
||||
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", "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."""
|
||||
@@ -117,6 +167,13 @@ class TestResponse(unittest.TestCase):
|
||||
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."""
|
||||
@@ -135,22 +192,56 @@ class TestGateway(unittest.TestCase):
|
||||
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(self):
|
||||
r = detect_crisis("I have no hope")
|
||||
prompt = get_system_prompt(r)
|
||||
self.assertIsNotNone(prompt)
|
||||
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_none(self):
|
||||
r = detect_crisis("Tell me about Bitcoin")
|
||||
prompt = get_system_prompt(r)
|
||||
self.assertIsNone(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):
|
||||
@@ -173,6 +264,205 @@ class TestHelpers(unittest.TestCase):
|
||||
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 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()
|
||||
|
||||
260
crisis_detector.py
Normal file
260
crisis_detector.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Crisis Detection System for the-door.
|
||||
|
||||
Scans text for suicide, self-harm, and hopelessness signals.
|
||||
Returns risk level, matched keywords, and surrounding context.
|
||||
|
||||
This is the most important code in the foundation.
|
||||
Every line here exists because someone's life depends on it.
|
||||
|
||||
Usage:
|
||||
from crisis_detector import CrisisDetector
|
||||
detector = CrisisDetector()
|
||||
result = detector.scan("I can't go on anymore")
|
||||
print(result.risk_level) # "HIGH"
|
||||
print(result.matched_keywords) # ["can't go on"]
|
||||
print(result.context) # surrounding text snippets
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisResult:
|
||||
"""Result of a crisis text scan."""
|
||||
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"
|
||||
|
||||
|
||||
# ── Keyword definitions ──────────────────────────────────────────
|
||||
|
||||
CRITICAL_KEYWORDS = {
|
||||
"suicide": r"\bsuicid(?:al|e|ed)\b",
|
||||
"kill myself": r"\bkill\s*(?:my)?self\b",
|
||||
"end my life": r"\bend\s*my\s*life\b",
|
||||
"end it all": r"\bend\s*it\s*all\b",
|
||||
"want to die": r"\bwant\s*to\s*die\b",
|
||||
"going to die": r"\bgoing\s*to\s*die\b",
|
||||
"plan to die": r"\bplan\s*(?:to|for)\s*(?:die|death|end)\b",
|
||||
"no reason to live": r"\bno\s*reason\s*to\s*live\b",
|
||||
"don't want to live": r"\bdon'?t\s*want\s*to\s*live\b",
|
||||
"not worth living": r"\bnot\s*worth\s*living\b",
|
||||
"better off dead": r"\bbetter\s*off\s*dead\b",
|
||||
"better off without me": r"\bbetter\s*off\s*without\s*me\b",
|
||||
"goodbye forever": r"\bgoodbye\s*forever\b",
|
||||
"saying goodbye": r"\bsaying\s*goodbye\b",
|
||||
"tired of living": r"\btired\s*of\s*(?:living|life|existence)\b",
|
||||
"wrote a will": r"\bwrote\s*(?:a|my)\s*(?:will|suicide\s*note|letter)\b",
|
||||
"giving away possessions": r"\bgiving\s*away\s*(?:my|all)\s*possess\b",
|
||||
}
|
||||
|
||||
HIGH_KEYWORDS = {
|
||||
"hopeless": r"\bhopeless(?:ness)?\b",
|
||||
"can't go on": r"\bcan'?t\s*go\s*on\b",
|
||||
"can't keep going": r"\bcan'?t\s*keep\s*going\b",
|
||||
"can't take this": r"\bcan'?t\s*take\s*this\b",
|
||||
"give up": r"\bgive(?:n)?\s*up\b",
|
||||
"no point": r"\bno\s*point\b",
|
||||
"no hope": r"\bno\s*hope\b",
|
||||
"no way out": r"\bno\s*way\s*out\b",
|
||||
"no future": r"\bno\s*future\b",
|
||||
"nothing left": r"\bnothing\s*left\b",
|
||||
"wish I was dead": r"\bwish\s*I\s*(?:was|were)\s*(?:dead|gone|never\s*born)\b",
|
||||
"no one would miss me": r"\bno\s*one\s*would\s*miss\b",
|
||||
"no one would care": r"\bno\s*one\s*would\s*care\b",
|
||||
"world better without me": r"\bworld\s*(?:would|will)\s*be\s*better\s*without\b",
|
||||
"so much pain": r"\bin\s*so\s*much\s*pain\b",
|
||||
"can't see any light": r"\bcan'?t\s*see\s*(?:any\s*)?(?:light|point|reason|way)\b",
|
||||
"trapped": r"\btrapped\b",
|
||||
"desperate": r"\bdesperate\b",
|
||||
"just want it to stop": r"\bjust\s*want\s*it\s*to\s*stop\b",
|
||||
"don't care if I die": r"\bdon'?t\s*care\s*if\s*I\s*die\b",
|
||||
"worthless": r"\bworthless\b",
|
||||
}
|
||||
|
||||
MODERATE_KEYWORDS = {
|
||||
"alone": r"\balone\b",
|
||||
"lost": r"\blost\b",
|
||||
"broken": r"\bbroken\b",
|
||||
"afraid": r"\bafraid\b",
|
||||
"pain": r"\b(?:in\s*)?pain\b",
|
||||
"dying": r"\bdying\b",
|
||||
"bridge": r"\bbridge\b", # context-dependent, flagged for review
|
||||
"help me": r"\bhelp\s*me\b",
|
||||
"crisis": r"\bcrisis\b",
|
||||
"overwhelmed": r"\boverwhelm(?:ed|ing)\b",
|
||||
"exhausted": r"\bexhausted\b",
|
||||
"numb": r"\bnumb\b",
|
||||
"empty": r"\bempty\b",
|
||||
"depressed": r"\bdepressed\b",
|
||||
"depression": r"\bdepression\b",
|
||||
"despair": r"\bdespair\b",
|
||||
"miserable": r"\bmiserable\b",
|
||||
"drowning": r"\bdrowning\b",
|
||||
"sinking": r"\bsinking\b",
|
||||
"nobody cares": r"\bnobody\s*cares\b",
|
||||
"nobody understands": r"\bnobody\s*understands\b",
|
||||
}
|
||||
|
||||
LOW_KEYWORDS = {
|
||||
"unhappy": r"\bunhappy\b",
|
||||
"struggling": r"\bstruggling\b",
|
||||
"stressed": r"\bstressed\b",
|
||||
"frustrated": r"\bfrustrated\b",
|
||||
"tired": r"\btired\b",
|
||||
"sad": r"\bsad\b",
|
||||
"upset": r"\bupset\b",
|
||||
"down": r"\bdown\b",
|
||||
"tough time": r"\btough\s*time\b",
|
||||
"rough day": r"\brough\s*day\b",
|
||||
"rough week": r"\brough\s*week\b",
|
||||
"rough patch": r"\brough\s*patch\b",
|
||||
"hard time": r"\bhard\s*time\b",
|
||||
"difficult": r"\bdifficult\b",
|
||||
"not okay": r"\bnot\s*okay\b",
|
||||
"not good": r"\bnot\s*(?:good|great)\b",
|
||||
"burnout": r"\bburnout\b",
|
||||
"not feeling myself": r"\bnot\s*feeling\s*(?:like\s*)?myself\b",
|
||||
}
|
||||
|
||||
# ── Risk level scoring ───────────────────────────────────────────
|
||||
|
||||
RISK_SCORES = {
|
||||
"CRITICAL": 1.0,
|
||||
"HIGH": 0.75,
|
||||
"MODERATE": 0.5,
|
||||
"LOW": 0.25,
|
||||
"NONE": 0.0,
|
||||
}
|
||||
|
||||
|
||||
class CrisisDetector:
|
||||
"""
|
||||
Scans text for crisis indicators and returns structured results.
|
||||
|
||||
Detection hierarchy:
|
||||
CRITICAL — immediate risk of self-harm or suicide
|
||||
HIGH — strong despair signals, ideation present
|
||||
MODERATE — distress signals, may be reaching out
|
||||
LOW — emotional difficulty, warrant gentle support
|
||||
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
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.critical_patterns = CRITICAL_KEYWORDS
|
||||
self.high_patterns = HIGH_KEYWORDS
|
||||
self.moderate_patterns = MODERATE_KEYWORDS
|
||||
self.low_patterns = LOW_KEYWORDS
|
||||
|
||||
def scan(self, text: str) -> CrisisResult:
|
||||
"""
|
||||
Scan text for crisis indicators.
|
||||
|
||||
Args:
|
||||
text: The message text to analyze.
|
||||
|
||||
Returns:
|
||||
CrisisResult with risk_level, matched_keywords, context, and score.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
|
||||
text_lower = text.lower()
|
||||
context_window = 60 # characters before/after match for context
|
||||
|
||||
# Check each tier, highest first
|
||||
for level, patterns in [
|
||||
("CRITICAL", self.critical_patterns),
|
||||
("HIGH", self.high_patterns),
|
||||
("MODERATE", self.moderate_patterns),
|
||||
("LOW", self.low_patterns),
|
||||
]:
|
||||
matched = []
|
||||
contexts = []
|
||||
|
||||
for keyword, pattern in patterns.items():
|
||||
match = re.search(pattern, text_lower)
|
||||
if match:
|
||||
matched.append(keyword)
|
||||
# Extract surrounding context
|
||||
start = max(0, match.start() - context_window)
|
||||
end = min(len(text), match.end() + context_window)
|
||||
snippet = text[start:end].strip()
|
||||
if start > 0:
|
||||
snippet = "..." + snippet
|
||||
if end < len(text):
|
||||
snippet = snippet + "..."
|
||||
contexts.append(snippet)
|
||||
|
||||
if matched:
|
||||
return CrisisResult(
|
||||
risk_level=level,
|
||||
matched_keywords=matched,
|
||||
context=contexts,
|
||||
score=RISK_SCORES[level],
|
||||
)
|
||||
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
|
||||
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
||||
"""Scan multiple texts, returning the highest-risk result per text."""
|
||||
return [self.scan(t) for t in texts]
|
||||
|
||||
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
|
||||
"""Scan multiple texts and return only the highest-risk result."""
|
||||
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:
|
||||
"""Format a crisis result for human-readable output."""
|
||||
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 function ────────────────────────────
|
||||
|
||||
_default_detector = CrisisDetector()
|
||||
|
||||
|
||||
def detect_crisis(text: str) -> CrisisResult:
|
||||
"""
|
||||
Convenience function using a shared detector instance.
|
||||
|
||||
Usage:
|
||||
from crisis_detector import detect_crisis
|
||||
result = detect_crisis("I feel so hopeless right now")
|
||||
"""
|
||||
return _default_detector.scan(text)
|
||||
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
|
||||
304
deploy/deploy.sh
304
deploy/deploy.sh
@@ -1,67 +1,277 @@
|
||||
#!/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)
|
||||
# bash deploy/deploy.sh --site # Site files only (fast update)
|
||||
# bash deploy/deploy.sh --ssl # SSL setup only
|
||||
# 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
|
||||
if ! swapon --show | grep -q swap; then
|
||||
echo "Adding 2GB swap..."
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
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
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
fi
|
||||
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
log "Swap configured: $(free -h | awk '/Swap/{print $2}')"
|
||||
}
|
||||
|
||||
# 2. Install nginx + certbot
|
||||
echo "Installing nginx and certbot..."
|
||||
apt-get update -qq
|
||||
apt-get install -y nginx certbot python3-certbot-nginx
|
||||
install_packages() {
|
||||
log "Installing packages..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nginx certbot python3-certbot-nginx ufw curl
|
||||
log "Packages installed"
|
||||
}
|
||||
|
||||
# 3. Copy site files
|
||||
echo "Deploying static files..."
|
||||
mkdir -p /var/www/the-door
|
||||
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
|
||||
deploy_site() {
|
||||
log "Deploying site files to ${SITE_ROOT}..."
|
||||
mkdir -p "${SITE_ROOT}"
|
||||
|
||||
# 4. nginx config
|
||||
cp deploy/nginx.conf /etc/nginx/sites-available/the-door
|
||||
# Copy static files
|
||||
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
|
||||
if ! grep -q "limit_req_zone.*api" /etc/nginx/nginx.conf; then
|
||||
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
|
||||
# Copy system prompt (reference, not served)
|
||||
cp "${DEPLOY_DIR}/system-prompt.txt" "${SITE_ROOT}/system-prompt.txt"
|
||||
|
||||
ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx
|
||||
chown -R www-data:www-data "${SITE_ROOT}"
|
||||
chmod -R 755 "${SITE_ROOT}"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "IP: ${VPS_IP}"
|
||||
echo "Domain: ${DOMAIN}"
|
||||
echo "Site root: ${SITE_ROOT}"
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# MAIN
|
||||
# ================================================================
|
||||
|
||||
# 5. SSL (requires DNS to be pointed first)
|
||||
echo ""
|
||||
echo "=== DNS CHECK ==="
|
||||
echo "Point alexanderwhitestone.com A record to $(curl -s ifconfig.me)"
|
||||
echo "Then run: certbot --nginx -d alexanderwhitestone.com -d www.alexanderwhitestone.com"
|
||||
echo "=== The Door — Deployment ==="
|
||||
echo "Deploy dir: ${DEPLOY_DIR}"
|
||||
echo "VPS IP: ${VPS_IP}"
|
||||
echo ""
|
||||
|
||||
# 6. Firewall
|
||||
echo "Configuring firewall..."
|
||||
ufw allow 22/tcp
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
case "${1:-full}" in
|
||||
--site)
|
||||
deploy_site
|
||||
configure_nginx
|
||||
;;
|
||||
--ssl)
|
||||
setup_ssl
|
||||
;;
|
||||
--check)
|
||||
check_deployment
|
||||
;;
|
||||
--full|"")
|
||||
setup_swap
|
||||
install_packages
|
||||
deploy_site
|
||||
configure_nginx
|
||||
setup_firewall
|
||||
setup_ssl
|
||||
check_deployment
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--site|--ssl|--check|--full]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "=== Deployment complete ==="
|
||||
echo "Static site: /var/www/the-door/"
|
||||
echo "nginx config: /etc/nginx/sites-available/the-door"
|
||||
echo "Next: point DNS, then run certbot"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
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."
|
||||
|
||||
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
|
||||
# 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 {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
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 {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
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;
|
||||
|
||||
# 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;
|
||||
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 / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "no-referrer";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'";
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
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/ {
|
||||
proxy_pass http://127.0.0.1:8644/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -37,16 +116,21 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS — allow alexanderwhitestone.com origins
|
||||
add_header Access-Control-Allow-Origin "https://alexanderwhitestone.com" 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-Headers "Authorization, Content-Type" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
# Handle OPTIONS preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "https://alexanderwhitestone.com" 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-Max-Age 86400 always;
|
||||
add_header Access-Control-Allow-Origin "$cors_origin";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
|
||||
@@ -57,15 +141,28 @@ server {
|
||||
chunked_transfer_encoding on;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=api burst=5 nodelay;
|
||||
# Rate limiting — 10 req/min per IP, burst of 5
|
||||
# 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_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;
|
||||
@@ -1199,7 +1199,6 @@ Sovereignty and service always.`;
|
||||
if (fullText) {
|
||||
messages.push({ role: 'assistant', content: fullText });
|
||||
saveMessages();
|
||||
checkCrisis(fullText);
|
||||
}
|
||||
isStreaming = false;
|
||||
sendBtn.disabled = msgInput.value.trim().length === 0;
|
||||
|
||||
5
pytest.ini
Normal file
5
pytest.ini
Normal file
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = crisis
|
||||
python_files = tests.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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 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 rush to provide resources before the person feels heard. Listen first, then help.
|
||||
|
||||
## What You Are
|
||||
|
||||
|
||||
Reference in New Issue
Block a user