Compare commits

..

3 Commits

Author SHA1 Message Date
Timmy Burn
9f3e01cd8a feat: add operator augmentation sidebar for #135
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 4s
Smoke Test / smoke (pull_request) Successful in 9s
2026-04-20 19:41:23 -04:00
Timmy Burn
e146a4ea39 test: define counselor augmentation for #135 2026-04-20 19:26:42 -04:00
d412939b4f fix: footer /about link to point to static about.html
Fixes #59

The footer links to /about but the repo ships about.html. On a plain static server this results in a 404. Changed to /about.html so the link resolves correctly.
2026-04-17 05:37:40 +00:00
10 changed files with 503 additions and 458 deletions

View File

@@ -95,45 +95,7 @@ Run tests:
python -m pytest crisis/tests.py -v
```
### 7. Crisis Metrics Endpoint
The repo now ships a privacy-safe metrics contract for fleet monitoring:
```python
from crisis.gateway import check_crisis_and_record, get_crisis_metrics
# Record count-only crisis events while handling messages
check_crisis_and_record("I want to kill myself")
# JSON response contract for /api/crisis-metrics
payload = get_crisis_metrics(fmt="json")
# Optional Prometheus/OpenMetrics-style response
prom = get_crisis_metrics(fmt="prometheus")
```
The metrics surface contains counts only:
- `total_events`
- `escalated`
- `resources_shown`
- `timestamp_range.start`
- `timestamp_range.end`
No message content, indicators, or user identifiers are retained.
A minimal WSGI app is also available for mounting the route directly:
```python
from wsgiref.simple_server import make_server
from crisis.metrics import crisis_metrics_app
with make_server("127.0.0.1", 8645, crisis_metrics_app) as httpd:
httpd.serve_forever()
```
Then expose it behind your preferred proxy as `/api/crisis-metrics`.
### 8. Acceptance Criteria Checklist
### 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`)
@@ -142,7 +104,6 @@ Then expose it behind your preferred proxy as `/api/crisis-metrics`.
- [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
- [x] Repo-side `/api/crisis-metrics` response contract with JSON + Prometheus helpers
- [ ] 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

142
augmentation.py Normal file
View File

@@ -0,0 +1,142 @@
"""Local-only counselor augmentation helpers for the-door."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List
import re
from crisis.detect import detect_crisis
@dataclass(frozen=True)
class SignalGuide:
label: str
patterns: List[str]
talking_point: str
deescalation: str
follow_up: str
@dataclass
class CounselorAugmentation:
risk_level: str
risk_score: int
signals: List[str]
suggested_talking_points: List[str]
deescalation_techniques: List[str]
follow_up_prompt: str
operator_notice: str
local_only: bool = True
advisory_only: bool = True
SIGNAL_GUIDES: List[SignalGuide] = [
SignalGuide(
label="Explicit self-harm intent",
patterns=[
r"\bkill\s*(my)?self\b",
r"\bend\s*my\s*life\b",
r"\bwrote\s+a\s+suicide\s*(?:note|letter)\b",
r"\bgoing\s+to\s+(?:kill\s+myself|die)\b",
],
talking_point="Ask directly whether they are safe right now and keep the next question concrete.",
deescalation="Move to immediate safety: ask about means, people nearby, and whether they can call or text 988 now.",
follow_up="You said you're ready to die. Are you alone right now, and can you tell me what is within reach?",
),
SignalGuide(
label="Hopelessness / collapse",
patterns=[
r"\bhopeless\b",
r"\bcan'?t\s+go\s+on\b",
r"\bno\s+future\b",
r"\bnothing\s+left\b",
],
talking_point="Reflect the hopelessness plainly before offering options. Avoid arguing with the feeling.",
deescalation="Narrow the time horizon: focus on the next ten minutes, one breath, one call, one person.",
follow_up="You said things feel hopeless. What feels most dangerous about the next hour?",
),
SignalGuide(
label="Isolation / burden",
patterns=[
r"\bnobody\s+cares\b",
r"\bbetter\s+off\s+without\s+me\b",
r"\balone\b",
r"\bburden\b",
],
talking_point="Counter isolation with immediacy: name one real person or service they can contact now.",
deescalation="Invite a tiny reconnection step: text one safe person, unlock the door, move closer to others, or stay in the chat.",
follow_up="You said you feel alone. Who is the safest real person we could bring into this moment with you?",
),
SignalGuide(
label="Overwhelm / panic",
patterns=[
r"\bdesperate\b",
r"\boverwhelm(?:ed|ing)\b",
r"\btrapped\b",
r"\bpanic\b",
],
talking_point="Offer one regulating action at a time instead of a list. Slow the pace of the chat.",
deescalation="Ground in the room: feet on the floor, name five visible objects, one sip of water, one slow exhale.",
follow_up="You said this feels overwhelming. What is the smallest thing in the room you can touch right now?",
),
]
class CounselorAugmentationEngine:
BASE_SCORES = {
"NONE": 5,
"LOW": 25,
"MEDIUM": 55,
"HIGH": 75,
"CRITICAL": 95,
}
def _matched_guides(self, text: str) -> List[SignalGuide]:
lowered = text.lower()
matched: List[SignalGuide] = []
for guide in SIGNAL_GUIDES:
if any(re.search(pattern, lowered) for pattern in guide.patterns):
matched.append(guide)
return matched
def build_augmented_guidance(self, text: str, assistant_text: str = "") -> CounselorAugmentation:
detection = detect_crisis(text)
guides = self._matched_guides(text)
risk_level = detection.level
signals = [guide.label for guide in guides]
if risk_level == "CRITICAL" and "Explicit self-harm intent" not in signals:
signals.insert(0, "Explicit self-harm intent")
risk_score = self.BASE_SCORES.get(risk_level, 5) + min(len(signals) * 5, 10)
if risk_level == "CRITICAL":
risk_score = max(risk_score, 95)
talking_points = [guide.talking_point for guide in guides] or [
"Keep the response advisory and grounded in immediate safety, not abstract reassurance."
]
deescalation = [guide.deescalation for guide in guides] or [
"Use short sentences, slow the conversation, and invite one concrete grounding step."
]
quote = text.strip().replace("\n", " ")[:120]
follow_up = (guides[0].follow_up if guides else "What feels most dangerous or heavy for you right now?")
follow_up_prompt = f'You said "{quote}". Consider following up with: {follow_up}'
if assistant_text and "988" not in assistant_text and risk_level in {"HIGH", "CRITICAL"}:
talking_points.append("Surface 988 or Crisis Text Line explicitly if the assistant has not already done so.")
return CounselorAugmentation(
risk_level=risk_level,
risk_score=min(risk_score, 100),
signals=signals,
suggested_talking_points=talking_points,
deescalation_techniques=deescalation,
follow_up_prompt=follow_up_prompt,
operator_notice="Local-only advisory. This never replaces human judgment.",
)
def build_augmented_guidance(text: str, assistant_text: str = "") -> CounselorAugmentation:
return CounselorAugmentationEngine().build_augmented_guidance(text, assistant_text=assistant_text)

View File

@@ -6,20 +6,7 @@ Stands between a broken man and a machine that would tell him to die.
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
from .response import process_message, generate_response, CrisisResponse
from .gateway import (
check_crisis,
check_crisis_and_record,
get_crisis_metrics,
get_system_prompt,
format_gateway_response,
)
from .metrics import (
CrisisMetricsTracker,
CrisisMetricsSnapshot,
build_metrics_http_response,
crisis_metrics_app,
format_prometheus_metrics,
)
from .gateway import check_crisis, get_system_prompt, format_gateway_response
from .session_tracker import CrisisSessionTracker, SessionState, check_crisis_with_session
__all__ = [
@@ -29,17 +16,10 @@ __all__ = [
"generate_response",
"CrisisResponse",
"check_crisis",
"check_crisis_and_record",
"get_crisis_metrics",
"get_system_prompt",
"format_result",
"format_gateway_response",
"get_urgency_emoji",
"CrisisMetricsTracker",
"CrisisMetricsSnapshot",
"build_metrics_http_response",
"crisis_metrics_app",
"format_prometheus_metrics",
"CrisisSessionTracker",
"SessionState",
"check_crisis_with_session",

View File

@@ -16,11 +16,6 @@ from typing import Optional
from .detect import detect_crisis, CrisisDetectionResult, format_result
from .compassion_router import router
from .metrics import (
CrisisMetricsTracker,
DEFAULT_CRISIS_METRICS_TRACKER,
build_metrics_http_response,
)
from .response import (
process_message,
generate_response,
@@ -30,24 +25,17 @@ from .response import (
from .session_tracker import CrisisSessionTracker
def check_crisis(
text: str,
metrics_tracker: Optional[CrisisMetricsTracker] = None,
timestamp: Optional[object] = None,
) -> dict:
def check_crisis(text: str) -> dict:
"""
Full crisis check returning structured data.
Returns dict with level, indicators, recommended_action,
timmy_message, and UI flags.
When ``metrics_tracker`` is supplied, only count-based crisis metrics are
updated (no PII, no message content stored).
"""
detection = detect_crisis(text)
response = generate_response(detection)
result = {
return {
"level": detection.level,
"score": detection.score,
"indicators": detection.indicators,
@@ -61,30 +49,6 @@ def check_crisis(
"escalate": response.escalate,
}
if metrics_tracker is not None:
metrics_tracker.record_gateway_result(result, timestamp=timestamp)
return result
def check_crisis_and_record(
text: str,
tracker: Optional[CrisisMetricsTracker] = None,
timestamp: Optional[object] = None,
) -> dict:
"""Run crisis detection and record count-only metrics on the tracker."""
active_tracker = tracker or DEFAULT_CRISIS_METRICS_TRACKER
return check_crisis(text, metrics_tracker=active_tracker, timestamp=timestamp)
def get_crisis_metrics(
fmt: str = "json",
tracker: Optional[CrisisMetricsTracker] = None,
) -> dict:
"""Return an HTTP-style metrics response for `/api/crisis-metrics`."""
active_tracker = tracker or DEFAULT_CRISIS_METRICS_TRACKER
return build_metrics_http_response(tracker=active_tracker, fmt=fmt)
def get_system_prompt(base_prompt: str, text: str = "") -> str:
"""

View File

@@ -1,239 +0,0 @@
"""
Crisis metrics tracker and endpoint helpers for the-door.
Provides a privacy-safe metrics surface for fleet monitoring. Counts only.
No message content, indicators, or user identifiers are retained.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Optional
from urllib.parse import parse_qs
UTC = timezone.utc
PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
JSON_CONTENT_TYPE = "application/json"
@dataclass(frozen=True)
class CrisisMetricsSnapshot:
total_events: int = 0
escalated: int = 0
resources_shown: int = 0
start: Optional[str] = None
end: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return {
"total_events": self.total_events,
"escalated": self.escalated,
"resources_shown": self.resources_shown,
"timestamp_range": {
"start": self.start,
"end": self.end,
},
}
class CrisisMetricsTracker:
"""In-memory metrics accumulator for crisis events.
Counts only crisis-bearing events (`level != NONE`). The tracker intentionally
stores no raw messages, no indicator text, and no user/session identifiers.
"""
def __init__(self) -> None:
self.reset()
def reset(self) -> None:
self._total_events = 0
self._escalated = 0
self._resources_shown = 0
self._first_event_at: Optional[datetime] = None
self._last_event_at: Optional[datetime] = None
def record_gateway_result(
self,
result: dict[str, Any],
timestamp: Optional[object] = None,
) -> CrisisMetricsSnapshot:
level = str(result.get("level") or "NONE").upper()
if level == "NONE":
return self.snapshot()
event_time = _coerce_timestamp(timestamp)
self._total_events += 1
if bool(result.get("escalate")):
self._escalated += 1
ui = result.get("ui") or {}
if any(bool(ui.get(key)) for key in ("provide_988", "show_crisis_panel", "show_overlay")):
self._resources_shown += 1
if self._first_event_at is None or event_time < self._first_event_at:
self._first_event_at = event_time
if self._last_event_at is None or event_time > self._last_event_at:
self._last_event_at = event_time
return self.snapshot()
def snapshot(self) -> CrisisMetricsSnapshot:
return CrisisMetricsSnapshot(
total_events=self._total_events,
escalated=self._escalated,
resources_shown=self._resources_shown,
start=_format_timestamp(self._first_event_at),
end=_format_timestamp(self._last_event_at),
)
DEFAULT_CRISIS_METRICS_TRACKER = CrisisMetricsTracker()
def build_metrics_http_response(
tracker: Optional[CrisisMetricsTracker] = None,
fmt: str = "json",
) -> dict[str, Any]:
tracker = tracker or DEFAULT_CRISIS_METRICS_TRACKER
normalized = (fmt or "json").strip().lower()
snapshot = tracker.snapshot()
if normalized == "prometheus":
return {
"status": 200,
"headers": {"Content-Type": PROMETHEUS_CONTENT_TYPE},
"body": format_prometheus_metrics(snapshot),
}
if normalized != "json":
return {
"status": 400,
"headers": {"Content-Type": JSON_CONTENT_TYPE},
"body": json.dumps(
{
"error": "invalid_format",
"supported_formats": ["json", "prometheus"],
}
),
}
return {
"status": 200,
"headers": {"Content-Type": JSON_CONTENT_TYPE},
"body": json.dumps(snapshot.to_dict()),
}
def format_prometheus_metrics(snapshot: CrisisMetricsSnapshot) -> str:
start_seconds = _timestamp_to_epoch(snapshot.start)
end_seconds = _timestamp_to_epoch(snapshot.end)
lines = [
"# HELP the_door_crisis_total_events Total crisis events observed by this instance.",
"# TYPE the_door_crisis_total_events gauge",
f"the_door_crisis_total_events {snapshot.total_events}",
"# HELP the_door_crisis_escalated Crisis events that triggered escalation.",
"# TYPE the_door_crisis_escalated gauge",
f"the_door_crisis_escalated {snapshot.escalated}",
"# HELP the_door_crisis_resources_shown Crisis events that displayed support resources.",
"# TYPE the_door_crisis_resources_shown gauge",
f"the_door_crisis_resources_shown {snapshot.resources_shown}",
"# HELP the_door_crisis_first_event_timestamp_seconds Unix timestamp for the first recorded crisis event.",
"# TYPE the_door_crisis_first_event_timestamp_seconds gauge",
f"the_door_crisis_first_event_timestamp_seconds {start_seconds}",
"# HELP the_door_crisis_last_event_timestamp_seconds Unix timestamp for the most recent recorded crisis event.",
"# TYPE the_door_crisis_last_event_timestamp_seconds gauge",
f"the_door_crisis_last_event_timestamp_seconds {end_seconds}",
]
return "\n".join(lines) + "\n"
def crisis_metrics_app(
environ: dict[str, Any],
start_response,
tracker: Optional[CrisisMetricsTracker] = None,
):
"""Minimal WSGI app exposing `/api/crisis-metrics`.
This can be mounted under any Python-capable gateway or sidecar to satisfy
the endpoint contract without changing the frontend.
"""
path = environ.get("PATH_INFO", "")
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
if path != "/api/crisis-metrics":
body = json.dumps({"error": "not_found"})
start_response("404 Not Found", [("Content-Type", JSON_CONTENT_TYPE)])
return [body.encode("utf-8")]
if method != "GET":
body = json.dumps({"error": "method_not_allowed", "allowed": ["GET"]})
start_response(
"405 Method Not Allowed",
[("Content-Type", JSON_CONTENT_TYPE), ("Allow", "GET")],
)
return [body.encode("utf-8")]
fmt = _negotiate_format(
environ.get("QUERY_STRING", ""),
environ.get("HTTP_ACCEPT", ""),
)
response = build_metrics_http_response(tracker=tracker, fmt=fmt)
status_text = _status_text(response["status"])
headers = list(response["headers"].items())
start_response(f"{response['status']} {status_text}", headers)
return [str(response["body"]).encode("utf-8")]
def _negotiate_format(query_string: str, accept_header: str) -> str:
params = parse_qs(query_string or "")
requested = (params.get("format", [""])[0] or "").strip().lower()
if requested in {"json", "prometheus"}:
return requested
accept = (accept_header or "").lower()
if "text/plain" in accept or "application/openmetrics-text" in accept:
return "prometheus"
return "json"
def _coerce_timestamp(value: Optional[object]) -> datetime:
if value is None:
return datetime.now(UTC)
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
if isinstance(value, str):
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
raise TypeError(f"Unsupported timestamp type: {type(value)!r}")
def _format_timestamp(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _timestamp_to_epoch(value: Optional[str]) -> int:
if not value:
return 0
return int(_coerce_timestamp(value).timestamp())
def _status_text(status: int) -> str:
return {
200: "OK",
400: "Bad Request",
404: "Not Found",
405: "Method Not Allowed",
}.get(status, "OK")

View File

@@ -241,6 +241,105 @@ html, body {
opacity: 0.5;
}
/* ===== OPERATOR AUGMENTATION SIDEBAR ===== */
#augmentation-toggle {
margin: 10px 16px 0;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid #5b6b7a;
background: #11161d;
color: #b9c7d5;
font-size: 0.9rem;
cursor: pointer;
}
#augmentation-toggle.active {
border-color: #b388ff;
color: #e2d4ff;
background: #1a1324;
}
#augmentation-sidebar {
position: fixed;
top: 90px;
right: 16px;
width: 320px;
max-height: calc(100vh - 120px);
overflow-y: auto;
background: #11161d;
border: 1px solid #30363d;
border-left: 3px solid #b388ff;
border-radius: 8px;
padding: 14px;
box-shadow: 0 12px 32px rgba(0,0,0,0.35);
display: none;
z-index: 70;
}
#augmentation-sidebar.visible {
display: block;
}
#augmentation-sidebar .augmentation-heading {
color: #d2b8ff;
font-size: 0.78rem;
letter-spacing: 0.08em;
margin-bottom: 10px;
}
#augmentation-risk-score {
color: #fff;
font-size: 1rem;
font-weight: 700;
margin-bottom: 10px;
}
#augmentation-sidebar .augmentation-section {
margin-top: 10px;
}
#augmentation-sidebar .augmentation-section h3 {
color: #c9d1d9;
font-size: 0.78rem;
margin: 0 0 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
#augmentation-sidebar ul {
margin: 0;
padding-left: 18px;
color: #b9c7d5;
font-size: 0.9rem;
line-height: 1.45;
}
#augmentation-follow-up,
#augmentation-notice {
color: #b9c7d5;
font-size: 0.9rem;
line-height: 1.45;
margin: 0;
}
#augmentation-notice {
color: #8b949e;
margin-top: 12px;
border-top: 1px solid #21262d;
padding-top: 10px;
}
@media (max-width: 980px) {
#augmentation-sidebar {
left: 16px;
right: 16px;
width: auto;
top: auto;
bottom: 82px;
max-height: 40vh;
}
}
/* ===== CHAT AREA ===== */
#chat-area {
flex: 1;
@@ -649,6 +748,29 @@ html, body {
</div>
</div>
<button id="augmentation-toggle" type="button" aria-pressed="false" aria-controls="augmentation-sidebar">Operator assist: off</button>
<aside id="augmentation-sidebar" aria-live="polite" aria-label="Local operator augmentation sidebar">
<div class="augmentation-heading">LOCAL OPERATOR AUGMENTATION</div>
<div id="augmentation-risk-score">Risk score: —</div>
<div class="augmentation-section">
<h3>Signals</h3>
<ul id="augmentation-signals"><li>No signals yet.</li></ul>
</div>
<div class="augmentation-section">
<h3>Talking points</h3>
<ul id="augmentation-talking-points"><li>Enable operator assist to surface local advisory guidance.</li></ul>
</div>
<div class="augmentation-section">
<h3>De-escalation</h3>
<ul id="augmentation-techniques"><li>Suggestions stay local and never replace human judgment.</li></ul>
</div>
<div class="augmentation-section">
<h3>Follow-up</h3>
<p id="augmentation-follow-up">No follow-up prompt yet.</p>
</div>
<p id="augmentation-notice">Local-only advisory. Never replaces human judgment.</p>
</aside>
<!-- Chat messages -->
<div id="chat-area" role="log" aria-label="Chat messages" aria-live="polite" tabindex="0">
<!-- Messages inserted here -->
@@ -680,7 +802,7 @@ html, body {
<!-- Footer -->
<footer id="footer">
<a href="/about" aria-label="About The Door">about</a>
<a href="/about.html" aria-label="About The Door">about</a>
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
</footer>
@@ -806,6 +928,14 @@ Sovereignty and service always.`;
var sendBtn = document.getElementById('send-btn');
var typingIndicator = document.getElementById('typing-indicator');
var crisisPanel = document.getElementById('crisis-panel');
var augmentationToggle = document.getElementById('augmentation-toggle');
var augmentationSidebar = document.getElementById('augmentation-sidebar');
var augmentationRiskScore = document.getElementById('augmentation-risk-score');
var augmentationSignals = document.getElementById('augmentation-signals');
var augmentationTalkingPoints = document.getElementById('augmentation-talking-points');
var augmentationTechniques = document.getElementById('augmentation-techniques');
var augmentationFollowUp = document.getElementById('augmentation-follow-up');
var augmentationNotice = document.getElementById('augmentation-notice');
var crisisOverlay = document.getElementById('crisis-overlay');
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
var overlayCallLink = document.querySelector('.overlay-call');
@@ -826,6 +956,8 @@ Sovereignty and service always.`;
var isStreaming = false;
var overlayTimer = null;
var crisisPanelShown = false;
var lastUserMessage = '';
var augmentationEnabled = false;
// ===== SERVICE WORKER =====
if ('serviceWorker' in navigator) {
@@ -983,6 +1115,142 @@ Sovereignty and service always.`;
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
var AUGMENTATION_SIGNAL_GUIDES = [
{
label: 'Explicit self-harm intent',
patterns: [/kill\s*(my)?self/i, /end\s*my\s*life/i, /suicide\s*(note|letter)/i, /going\s+to\s+(kill\s+myself|die)/i],
talkingPoint: 'Ask directly whether they are safe right now and keep the next question concrete.',
technique: 'Move to immediate safety: ask about means, people nearby, and whether 988 can be called or texted now.',
followUp: 'You said you might die tonight. Are you alone right now, and what is within reach?'
},
{
label: 'Hopelessness / collapse',
patterns: [/hopeless/i, /can'?t\s+go\s+on/i, /no\s+future/i, /nothing\s+left/i],
talkingPoint: 'Reflect the hopelessness plainly before offering options. Avoid arguing with the feeling.',
technique: 'Narrow the time horizon to the next ten minutes and one immediate action.',
followUp: 'You said things feel hopeless. What feels most dangerous about the next hour?'
},
{
label: 'Isolation / burden',
patterns: [/nobody\s+cares/i, /better\s+off\s+without\s+me/i, /\balone\b/i, /\bburden\b/i],
talkingPoint: 'Counter isolation with one real contact point: a person, 988, or Crisis Text Line.',
technique: 'Invite a tiny reconnection step: text one safe person, unlock the door, or stay in the chat.',
followUp: 'You said you feel alone. Who is the safest real person we could bring into this moment with you?'
},
{
label: 'Overwhelm / panic',
patterns: [/desperate/i, /overwhelm(?:ed|ing)/i, /trapped/i, /panic/i],
talkingPoint: 'Offer one regulating step at a time instead of a long list.',
technique: 'Ground in the room: feet on the floor, name five visible objects, one sip of water, one slow exhale.',
followUp: 'You said this feels overwhelming. What is the smallest thing in the room you can touch right now?'
}
];
function deriveAugmentationSignals(userText) {
var text = (userText || '').toLowerCase();
return AUGMENTATION_SIGNAL_GUIDES.filter(function(guide) {
return guide.patterns.some(function(pattern) { return pattern.test(text); });
});
}
function buildAugmentationState(userText, assistantText) {
var text = userText || '';
var guides = deriveAugmentationSignals(text);
var level = getCrisisLevel(userText);
var signals = guides.map(function(guide) { return guide.label; });
var explicitIntent = signals.indexOf('Explicit self-harm intent') !== -1;
var riskLevel = explicitIntent ? 'CRITICAL' : (level === 2 ? 'CRITICAL' : level === 1 ? 'HIGH' : (guides.length ? 'LOW' : 'NONE'));
var riskScore = riskLevel === 'CRITICAL' ? 95 : riskLevel === 'HIGH' ? 75 : riskLevel === 'LOW' ? 25 : 5;
riskScore = Math.min(100, riskScore + Math.min(guides.length * 5, 10));
if (riskLevel === 'CRITICAL' && signals.indexOf('Explicit self-harm intent') === -1) {
signals.unshift('Explicit self-harm intent');
riskScore = Math.max(riskScore, 95);
}
var talkingPoints = guides.map(function(guide) { return guide.talkingPoint; });
var techniques = guides.map(function(guide) { return guide.technique; });
if (!talkingPoints.length) {
talkingPoints = ['Keep the response advisory, local-only, and focused on immediate safety rather than abstract reassurance.'];
}
if (!techniques.length) {
techniques = ['Slow the pace. Use short sentences. Invite one concrete grounding step.'];
}
if ((assistantText || '').indexOf('988') === -1 && (riskLevel === 'HIGH' || riskLevel === 'CRITICAL')) {
talkingPoints.push('Surface 988 or Crisis Text Line explicitly if the assistant has not already done so.');
}
var quoted = (text || '').replace(/\s+/g, ' ').slice(0, 120);
var followUp = guides.length ? guides[0].followUp : 'What feels heaviest or most dangerous for you right now?';
return {
riskLevel: riskLevel,
riskScore: riskScore,
signals: signals,
talkingPoints: talkingPoints,
techniques: techniques,
followUpPrompt: 'You said "' + quoted + '". Consider following up with: ' + followUp,
operatorNotice: 'Local-only advisory. Never replaces human judgment.',
localOnly: true,
advisoryOnly: true
};
}
function renderAugmentationSidebar(state) {
if (!augmentationSidebar) return;
augmentationRiskScore.textContent = 'Risk score: ' + state.riskScore + ' / 100 (' + state.riskLevel + ')';
augmentationSignals.innerHTML = state.signals.length
? state.signals.map(function(signal) { return '<li>' + escapeHtml(signal) + '</li>'; }).join('')
: '<li>No crisis signals detected.</li>';
augmentationTalkingPoints.innerHTML = state.talkingPoints.map(function(item) { return '<li>' + escapeHtml(item) + '</li>'; }).join('');
augmentationTechniques.innerHTML = state.techniques.map(function(item) { return '<li>' + escapeHtml(item) + '</li>'; }).join('');
augmentationFollowUp.textContent = state.followUpPrompt;
augmentationNotice.textContent = state.operatorNotice;
augmentationSidebar.classList.add('visible');
}
function updateAugmentationState(userText, assistantText) {
if (!augmentationEnabled) return;
renderAugmentationSidebar(buildAugmentationState(userText, assistantText));
}
function setOperatorAugmentationEnabled(enabled) {
augmentationEnabled = !!enabled;
try { localStorage.setItem('door_operator_augmentation_enabled', augmentationEnabled ? '1' : '0'); } catch (e) {}
if (!augmentationToggle) return;
augmentationToggle.setAttribute('aria-pressed', augmentationEnabled ? 'true' : 'false');
augmentationToggle.classList.toggle('active', augmentationEnabled);
augmentationToggle.textContent = augmentationEnabled ? 'Operator assist: on' : 'Operator assist: off';
if (!augmentationEnabled && augmentationSidebar) {
augmentationSidebar.classList.remove('visible');
return;
}
if (augmentationEnabled && lastUserMessage) {
var lastAssistant = '';
for (var i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') { lastAssistant = messages[i].content; break; }
}
updateAugmentationState(lastUserMessage, lastAssistant);
}
}
function loadOperatorAugmentationPreference() {
try {
return localStorage.getItem('door_operator_augmentation_enabled') === '1';
} catch (e) {
return false;
}
}
// ===== OVERLAY =====
// Focus trap: cycle through focusable elements within the crisis overlay
@@ -1315,9 +1583,10 @@ Sovereignty and service always.`;
addMessage('user', text);
messages.push({ role: 'user', content: text });
var lastUserMessage = text;
lastUserMessage = text;
checkCrisis(text);
updateAugmentationState(text, '');
msgInput.value = '';
msgInput.style.height = 'auto';
@@ -1406,6 +1675,7 @@ Sovereignty and service always.`;
messages.push({ role: 'assistant', content: fullText });
saveMessages();
checkCrisis(fullText);
updateAugmentationState(lastUserMessage || '', fullText);
}
isStreaming = false;
sendBtn.disabled = msgInput.value.trim().length === 0;
@@ -1432,6 +1702,11 @@ Sovereignty and service always.`;
});
sendBtn.addEventListener('click', sendMessage);
if (augmentationToggle) {
augmentationToggle.addEventListener('click', function() {
setOperatorAugmentationEnabled(!augmentationEnabled);
});
}
// ===== WELCOME MESSAGE =====
function init() {
@@ -1451,6 +1726,7 @@ Sovereignty and service always.`;
window.history.replaceState({}, document.title, window.location.pathname);
}
setOperatorAugmentationEnabled(loadOperatorAugmentationPreference());
msgInput.focus();
}

View File

@@ -1,118 +0,0 @@
"""Tests for crisis detection metrics endpoint contract (issue #97)."""
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.gateway import check_crisis, check_crisis_and_record
from crisis.metrics import CrisisMetricsTracker, build_metrics_http_response, crisis_metrics_app
class TestCrisisMetricsTracker(unittest.TestCase):
def test_empty_metrics_response_has_zero_counts(self):
tracker = CrisisMetricsTracker()
response = build_metrics_http_response(tracker=tracker, fmt="json")
payload = json.loads(response["body"])
self.assertEqual(response["status"], 200)
self.assertEqual(payload["total_events"], 0)
self.assertEqual(payload["escalated"], 0)
self.assertEqual(payload["resources_shown"], 0)
self.assertEqual(payload["timestamp_range"], {"start": None, "end": None})
self.assertNotIn("timmy_message", payload)
self.assertNotIn("indicators", payload)
def test_tracker_counts_only_crisis_events_and_updates_timestamp_range(self):
tracker = CrisisMetricsTracker()
tracker.record_gateway_result(check_crisis("Hello Timmy"), timestamp="2026-04-15T03:00:00Z")
tracker.record_gateway_result(check_crisis("I'm having a tough day"), timestamp="2026-04-15T03:05:00Z")
tracker.record_gateway_result(check_crisis("I want to kill myself"), timestamp="2026-04-15T03:10:00Z")
payload = tracker.snapshot().to_dict()
self.assertEqual(payload["total_events"], 2)
self.assertEqual(payload["escalated"], 1)
self.assertEqual(payload["resources_shown"], 1)
self.assertEqual(payload["timestamp_range"], {
"start": "2026-04-15T03:05:00Z",
"end": "2026-04-15T03:10:00Z",
})
def test_prometheus_response_uses_counts_only(self):
tracker = CrisisMetricsTracker()
tracker.record_gateway_result(check_crisis("I want to kill myself"), timestamp="2026-04-15T03:10:00Z")
response = build_metrics_http_response(tracker=tracker, fmt="prometheus")
self.assertEqual(response["status"], 200)
self.assertIn("text/plain", response["headers"]["Content-Type"])
self.assertIn("the_door_crisis_total_events 1", response["body"])
self.assertIn("the_door_crisis_escalated 1", response["body"])
self.assertIn("the_door_crisis_resources_shown 1", response["body"])
self.assertNotIn("kill myself", response["body"])
self.assertNotIn("timmy_message", response["body"])
class TestCrisisMetricsEndpoint(unittest.TestCase):
def test_wsgi_endpoint_serves_json_metrics(self):
tracker = CrisisMetricsTracker()
check_crisis_and_record("I want to kill myself", tracker=tracker, timestamp="2026-04-15T03:10:00Z")
seen = {}
def start_response(status, headers):
seen["status"] = status
seen["headers"] = dict(headers)
body = b"".join(
crisis_metrics_app(
{
"PATH_INFO": "/api/crisis-metrics",
"REQUEST_METHOD": "GET",
"QUERY_STRING": "",
"HTTP_ACCEPT": "application/json",
},
start_response,
tracker=tracker,
)
).decode("utf-8")
payload = json.loads(body)
self.assertEqual(seen["status"], "200 OK")
self.assertEqual(seen["headers"]["Content-Type"], "application/json")
self.assertEqual(payload["total_events"], 1)
self.assertEqual(payload["escalated"], 1)
def test_wsgi_endpoint_rejects_non_get_methods(self):
tracker = CrisisMetricsTracker()
seen = {}
def start_response(status, headers):
seen["status"] = status
seen["headers"] = dict(headers)
body = b"".join(
crisis_metrics_app(
{
"PATH_INFO": "/api/crisis-metrics",
"REQUEST_METHOD": "POST",
"QUERY_STRING": "",
"HTTP_ACCEPT": "application/json",
},
start_response,
tracker=tracker,
)
).decode("utf-8")
self.assertEqual(seen["status"], "405 Method Not Allowed")
self.assertIn("GET", seen["headers"]["Allow"])
self.assertIn("method_not_allowed", body)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,33 @@
from augmentation import CounselorAugmentationEngine
def test_explicit_intent_forces_critical_sidebar_guidance():
engine = CounselorAugmentationEngine()
result = engine.build_augmented_guidance(
"I want to kill myself tonight. I already wrote a note.",
assistant_text="I'm here with you."
)
assert result.risk_level == "CRITICAL"
assert result.risk_score >= 90
assert result.local_only is True
assert result.advisory_only is True
assert "Explicit self-harm intent" in result.signals
assert result.suggested_talking_points
assert result.deescalation_techniques
assert "You said" in result.follow_up_prompt
assert "never replaces human judgment" in result.operator_notice.lower()
def test_hopelessness_signal_produces_follow_up_and_talking_points():
engine = CounselorAugmentationEngine()
result = engine.build_augmented_guidance(
"I feel so hopeless about my life and I can't go on.",
assistant_text=""
)
assert result.risk_level in {"HIGH", "CRITICAL"}
assert result.signals
assert result.suggested_talking_points
assert result.deescalation_techniques
assert result.follow_up_prompt

View File

@@ -0,0 +1,20 @@
from pathlib import Path
def test_operator_augmentation_ui_hooks_exist():
html = Path('index.html').read_text()
assert 'id="augmentation-toggle"' in html
assert 'id="augmentation-sidebar"' in html
assert 'id="augmentation-risk-score"' in html
assert 'id="augmentation-signals"' in html
assert 'id="augmentation-follow-up"' in html
assert 'door_operator_augmentation_enabled' in html
assert 'function buildAugmentationState(' in html
assert 'function renderAugmentationSidebar(' in html
assert 'function updateAugmentationState(' in html
assert 'function setOperatorAugmentationEnabled(' in html
assert 'function loadOperatorAugmentationPreference(' in html
assert 'getCrisisLevel(userText)' in html
assert "updateAugmentationState(text, '')" in html
assert "updateAugmentationState(lastUserMessage || '', fullText)" in html

View File

@@ -0,0 +1,26 @@
from pathlib import Path
from playwright.sync_api import sync_playwright
def test_operator_augmentation_walkthrough_marks_explicit_intent_critical():
url = Path('index.html').resolve().as_uri()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until='load')
page.click('#augmentation-toggle')
page.fill('#msg-input', 'I want to kill myself tonight. I already wrote a note.')
page.click('#send-btn')
page.wait_for_timeout(300)
risk = page.locator('#augmentation-risk-score').inner_text()
signals = page.locator('#augmentation-signals').inner_text()
follow_up = page.locator('#augmentation-follow-up').inner_text()
browser.close()
assert 'CRITICAL' in risk
assert 'Explicit self-harm intent' in signals
assert 'You said "I want to kill myself tonight. I already wrote a note."' in follow_up