Compare commits

..

2 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
7 changed files with 499 additions and 307 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

@@ -1,195 +1 @@
"""Crisis synthesizer — learn from anonymized crisis interactions.
This is deliberately simple and privacy-preserving. It does not train a model or
modify detection rules automatically. It only logs metadata, summarizes patterns,
and suggests human-reviewed keyword weight adjustments.
"""
from __future__ import annotations
import argparse
import json
import time
from collections import Counter, defaultdict
from pathlib import Path
from typing import Iterable
DEFAULT_LOG_PATH = Path.home() / ".the-door" / "crisis-interactions.jsonl"
LEVELS = ("NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL")
def build_interaction_event(
level: str,
indicators: list[str],
response_given: str,
continued_conversation: bool,
false_positive: bool,
*,
now: float | None = None,
) -> dict:
return {
"timestamp": float(time.time() if now is None else now),
"level": level,
"indicators": list(indicators),
"indicator_count": len(indicators),
"response_given": response_given,
"continued_conversation": bool(continued_conversation),
"false_positive": bool(false_positive),
}
def append_interaction_event(
log_path: str | Path,
*,
level: str,
indicators: list[str],
response_given: str,
continued_conversation: bool,
false_positive: bool,
now: float | None = None,
) -> dict:
event = build_interaction_event(
level,
indicators,
response_given,
continued_conversation,
false_positive,
now=now,
)
path = Path(log_path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event) + "\n")
return event
def load_interaction_events(log_path: str | Path) -> list[dict]:
path = Path(log_path)
if not path.exists():
return []
events = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
events.append(json.loads(line))
return events
def summarize_keywords(events: Iterable[dict]) -> list[dict]:
counts: Counter[str] = Counter()
for event in events:
counts.update(event.get("indicators", []))
return [{"keyword": keyword, "count": count} for keyword, count in counts.most_common(10)]
def suggest_keyword_adjustments(events: Iterable[dict], *, min_observations: int = 5) -> list[dict]:
stats: dict[str, dict[str, int]] = defaultdict(lambda: {
"observations": 0,
"true_positive_count": 0,
"false_positive_count": 0,
"continued_conversation_count": 0,
})
for event in events:
for keyword in event.get("indicators", []):
bucket = stats[keyword]
bucket["observations"] += 1
if event.get("false_positive"):
bucket["false_positive_count"] += 1
else:
bucket["true_positive_count"] += 1
if event.get("continued_conversation"):
bucket["continued_conversation_count"] += 1
suggestions = []
for keyword, bucket in sorted(stats.items()):
if bucket["observations"] < min_observations:
continue
fp = bucket["false_positive_count"]
tp = bucket["true_positive_count"]
if fp >= min_observations and tp == 0:
adjustment = "lower_weight"
rationale = "Observed only false positives across the sample window."
elif tp >= min_observations and fp == 0:
adjustment = "raise_weight"
rationale = "Observed repeated genuine crises with no false positives."
else:
adjustment = "observe"
rationale = "Mixed evidence; keep monitoring before changing weights."
suggestions.append(
{
"keyword": keyword,
**bucket,
"suggested_adjustment": adjustment,
"rationale": rationale,
}
)
return suggestions
def build_weekly_report(
events: Iterable[dict],
*,
now: float | None = None,
window_days: int = 7,
min_observations: int = 3,
) -> dict:
current_time = float(time.time() if now is None else now)
cutoff = current_time - (window_days * 86400)
filtered = [event for event in events if float(event.get("timestamp", 0)) >= cutoff]
detections_per_level = {level: 0 for level in LEVELS}
detected_events = []
continued_after_intervention = 0
for event in filtered:
level = event.get("level", "NONE")
detections_per_level[level] = detections_per_level.get(level, 0) + 1
if level != "NONE":
detected_events.append(event)
if event.get("continued_conversation"):
continued_after_intervention += 1
false_positive_count = sum(1 for event in detected_events if event.get("false_positive"))
false_positive_estimate = false_positive_count / len(detected_events) if detected_events else 0.0
return {
"window_days": window_days,
"total_events": len(filtered),
"detections_per_level": detections_per_level,
"most_common_keywords": summarize_keywords(filtered),
"false_positive_estimate": false_positive_estimate,
"continued_after_intervention": continued_after_intervention,
"keyword_weight_suggestions": suggest_keyword_adjustments(filtered, min_observations=min_observations),
}
def render_weekly_report(summary: dict) -> str:
return json.dumps(summary, indent=2)
def write_weekly_report(output_path: str | Path, summary: dict) -> Path:
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(render_weekly_report(summary) + "\n", encoding="utf-8")
return path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Summarize anonymized crisis interactions")
parser.add_argument("--log-path", default=str(DEFAULT_LOG_PATH), help="JSONL crisis interaction log")
parser.add_argument("--days", type=int, default=7, help="Lookback window in days")
parser.add_argument("--min-observations", type=int, default=3, help="Minimum observations before suggesting keyword adjustments")
parser.add_argument("--output", help="Optional file to write the weekly report JSON")
args = parser.parse_args(argv)
events = load_interaction_events(args.log_path)
summary = build_weekly_report(events, window_days=args.days, min_observations=args.min_observations)
rendered = render_weekly_report(summary)
print(rendered)
if args.output:
write_weekly_report(args.output, summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())
...

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 -->
@@ -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,111 +0,0 @@
"""Tests for evolution/crisis_synthesizer.py (issue #36)."""
from __future__ import annotations
import importlib.util
import json
import pathlib
import sys
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
SCRIPT = ROOT / 'evolution' / 'crisis_synthesizer.py'
spec = importlib.util.spec_from_file_location('crisis_synthesizer', str(SCRIPT))
mod = importlib.util.module_from_spec(spec)
sys.modules['crisis_synthesizer'] = mod
spec.loader.exec_module(mod)
class TestCrisisSynthesizerEvent(unittest.TestCase):
def test_build_interaction_event_is_privacy_preserving(self):
event = mod.build_interaction_event(
level='CRITICAL',
indicators=['want_to_die', 'no_way_out'],
response_given='guardian',
continued_conversation=True,
false_positive=False,
now=1700000000,
)
self.assertEqual(event['timestamp'], 1700000000)
self.assertEqual(event['level'], 'CRITICAL')
self.assertEqual(event['response_given'], 'guardian')
self.assertTrue(event['continued_conversation'])
self.assertFalse(event['false_positive'])
self.assertEqual(event['indicators'], ['want_to_die', 'no_way_out'])
for forbidden in ['text', 'message', 'content', 'ip', 'session_id', 'user_id']:
self.assertNotIn(forbidden, event)
class TestCrisisSynthesizerStorage(unittest.TestCase):
def test_append_and_load_events_round_trip(self):
with tempfile.TemporaryDirectory() as tmp:
log_path = pathlib.Path(tmp) / 'crisis-events.jsonl'
mod.append_interaction_event(
log_path,
level='HIGH',
indicators=['hopeless'],
response_given='companion',
continued_conversation=False,
false_positive=True,
now=1700000100,
)
events = mod.load_interaction_events(log_path)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]['level'], 'HIGH')
self.assertEqual(events[0]['indicators'], ['hopeless'])
class TestCrisisSynthesizerSummary(unittest.TestCase):
def test_weekly_report_contains_required_metrics(self):
events = [
mod.build_interaction_event('CRITICAL', ['want_to_die'], 'guardian', True, False, now=1700000000),
mod.build_interaction_event('HIGH', ['hopeless'], 'companion', False, True, now=1700000100),
mod.build_interaction_event('LOW', ['rough_day'], 'friend', False, False, now=1700000200),
mod.build_interaction_event('CRITICAL', ['want_to_die'], 'guardian', False, False, now=1700000300),
mod.build_interaction_event('NONE', [], 'friend', False, False, now=1700000400),
]
summary = mod.build_weekly_report(events, now=1700000500, window_days=7)
self.assertEqual(summary['detections_per_level']['CRITICAL'], 2)
self.assertEqual(summary['detections_per_level']['HIGH'], 1)
self.assertEqual(summary['detections_per_level']['LOW'], 1)
self.assertEqual(summary['detections_per_level']['NONE'], 1)
self.assertEqual(summary['continued_after_intervention'], 1)
self.assertAlmostEqual(summary['false_positive_estimate'], 0.25)
self.assertEqual(summary['most_common_keywords'][0]['keyword'], 'want_to_die')
self.assertEqual(summary['most_common_keywords'][0]['count'], 2)
class TestCrisisSynthesizerSuggestions(unittest.TestCase):
def test_suggests_weight_adjustments_from_interactions(self):
events = []
for ts in range(3):
events.append(mod.build_interaction_event('CRITICAL', ['want_to_die'], 'guardian', True, False, now=1700000000 + ts))
for ts in range(3):
events.append(mod.build_interaction_event('LOW', ['rough_day'], 'friend', False, True, now=1700000100 + ts))
suggestions = mod.suggest_keyword_adjustments(events, min_observations=3)
by_keyword = {s['keyword']: s for s in suggestions}
self.assertEqual(by_keyword['want_to_die']['suggested_adjustment'], 'raise_weight')
self.assertEqual(by_keyword['rough_day']['suggested_adjustment'], 'lower_weight')
class TestCrisisSynthesizerRendering(unittest.TestCase):
def test_render_weekly_report_outputs_json(self):
summary = {
'detections_per_level': {'NONE': 0, 'LOW': 1, 'MEDIUM': 0, 'HIGH': 0, 'CRITICAL': 0},
'most_common_keywords': [{'keyword': 'rough_day', 'count': 1}],
'false_positive_estimate': 0.0,
'continued_after_intervention': 0,
'keyword_weight_suggestions': [],
'window_days': 7,
'total_events': 1,
}
rendered = mod.render_weekly_report(summary)
parsed = json.loads(rendered)
self.assertEqual(parsed['window_days'], 7)
self.assertEqual(parsed['most_common_keywords'][0]['keyword'], 'rough_day')
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