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
6 changed files with 500 additions and 78 deletions

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

@@ -241,46 +241,103 @@ html, body {
opacity: 0.5;
}
/* ===== CHAT HEADER ===== */
#chat-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid #21262d;
background: #11161d;
}
.chat-header-title {
font-size: 0.85rem;
color: #8b949e;
font-weight: 600;
letter-spacing: 0.02em;
}
#chat-safety-plan-btn {
display: inline-flex;
align-items: center;
gap: 6px;
/* ===== OPERATOR AUGMENTATION SIDEBAR ===== */
#augmentation-toggle {
margin: 10px 16px 0;
padding: 8px 12px;
min-height: 36px;
border: 1px solid #30363d;
border-radius: 999px;
background: transparent;
color: #c9d1d9;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid #5b6b7a;
background: #11161d;
color: #b9c7d5;
font-size: 0.9rem;
cursor: pointer;
}
#chat-safety-plan-btn:hover,
#chat-safety-plan-btn:focus {
border-color: #58a6ff;
background: rgba(88, 166, 255, 0.12);
outline: 2px solid #58a6ff;
outline-offset: 2px;
#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 ===== */
@@ -691,13 +748,28 @@ html, body {
</div>
</div>
<div id="chat-header">
<div class="chat-header-title" aria-hidden="true">Conversation</div>
<button id="chat-safety-plan-btn" type="button" aria-label="Open My Safety Plan from chat header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
My Safety Plan
</button>
</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">
@@ -730,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>
@@ -856,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');
@@ -864,7 +944,6 @@ Sovereignty and service always.`;
// Safety Plan Elements
var safetyPlanBtn = document.getElementById('safety-plan-btn');
var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');
var crisisSafetyPlanBtn = document.getElementById('crisis-safety-plan-btn');
var safetyPlanModal = document.getElementById('safety-plan-modal');
var closeSafetyPlan = document.getElementById('close-safety-plan');
@@ -877,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) {
@@ -1034,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
@@ -1336,25 +1553,19 @@ Sovereignty and service always.`;
_spTriggerEl = null;
}
function openSafetyPlan(triggerEl) {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(triggerEl || document.activeElement);
}
// Wire open buttons to activate focus trap
safetyPlanBtn.addEventListener('click', function() {
openSafetyPlan(safetyPlanBtn);
});
chatSafetyPlanBtn.addEventListener('click', function() {
openSafetyPlan(chatSafetyPlanBtn);
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(safetyPlanBtn);
});
// Crisis panel safety plan button (if crisis panel is visible)
if (crisisSafetyPlanBtn) {
crisisSafetyPlanBtn.addEventListener('click', function() {
openSafetyPlan(crisisSafetyPlanBtn);
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(crisisSafetyPlanBtn);
});
}
@@ -1372,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';
@@ -1463,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;
@@ -1489,6 +1702,11 @@ Sovereignty and service always.`;
});
sendBtn.addEventListener('click', sendMessage);
if (augmentationToggle) {
augmentationToggle.addEventListener('click', function() {
setOperatorAugmentationEnabled(!augmentationEnabled);
});
}
// ===== WELCOME MESSAGE =====
function init() {
@@ -1501,11 +1719,14 @@ Sovereignty and service always.`;
// Check for URL params (e.g., ?safetyplan=true for PWA shortcut)
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('safetyplan') === 'true') {
openSafetyPlan(chatSafetyPlanBtn || safetyPlanBtn);
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(safetyPlanBtn);
// Clean up URL
window.history.replaceState({}, document.title, window.location.pathname);
}
setOperatorAugmentationEnabled(loadOperatorAugmentationPreference());
msgInput.focus();
}

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

View File

@@ -1,20 +0,0 @@
from pathlib import Path
INDEX = Path("index.html")
def test_chat_header_has_persistent_safety_plan_button():
html = INDEX.read_text()
assert 'id="chat-header"' in html
assert 'id="chat-safety-plan-btn"' in html
assert 'aria-label="Open My Safety Plan from chat header"' in html
assert 'My Safety Plan' in html
def test_chat_header_button_opens_existing_safety_plan_modal():
html = INDEX.read_text()
assert "var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');" in html
assert "chatSafetyPlanBtn.addEventListener('click'" in html
assert "function openSafetyPlan(triggerEl)" in html
assert "safetyPlanModal.classList.add('active');" in html
assert "openSafetyPlan(chatSafetyPlanBtn);" in html