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
9 changed files with 504 additions and 481 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

@@ -7,7 +7,6 @@ Stands between a broken man and a machine that would tell him to die.
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
from .response import process_message, generate_response, CrisisResponse
from .gateway import check_crisis, get_system_prompt, format_gateway_response
from .behavioral import BehavioralTracker, BehavioralSignal
from .session_tracker import CrisisSessionTracker, SessionState, check_crisis_with_session
__all__ = [
@@ -21,8 +20,6 @@ __all__ = [
"format_result",
"format_gateway_response",
"get_urgency_emoji",
"BehavioralTracker",
"BehavioralSignal",
"CrisisSessionTracker",
"SessionState",
"check_crisis_with_session",

View File

@@ -1,304 +0,0 @@
"""Behavioral crisis pattern detection for the-door (#133).
Detects crisis risk from behavioral patterns, not just message content:
- message frequency spikes versus a 7-day rolling baseline
- late-night messaging (2-5 AM)
- withdrawal / isolation via a sharp drop from the recent daily baseline
- session length trend versus recent sessions
- return after long absence
- rising crisis-score trend across recent messages
Privacy-first:
- in-memory only
- no database
- no file I/O
- no network calls
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
HIGH_RISK_HOURS = {2, 3, 4}
ELEVATED_RISK_HOURS = {1, 5}
ROLLING_BASELINE_DAYS = 7
RETURN_AFTER_ABSENCE_DAYS = 7
@dataclass
class BehavioralEvent:
session_id: str
timestamp: datetime
message_length: int
crisis_score: float = 0.0
role: str = "user"
@dataclass
class BehavioralSignal:
signal_type: str
risk_level: str
description: str
evidence: list[str] = field(default_factory=list)
score: float = 0.0
def as_dict(self) -> dict[str, Any]:
return {
"signal_type": self.signal_type,
"risk_level": self.risk_level,
"description": self.description,
"evidence": list(self.evidence),
"score": self.score,
}
class BehavioralTracker:
"""In-memory tracker for behavioral crisis signals."""
def __init__(self) -> None:
self._events_by_session: dict[str, list[BehavioralEvent]] = defaultdict(list)
def record(
self,
session_id: str,
timestamp: datetime,
message_length: int,
*,
crisis_score: float = 0.0,
role: str = "user",
) -> None:
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
event = BehavioralEvent(
session_id=session_id,
timestamp=timestamp,
message_length=max(0, int(message_length)),
crisis_score=max(0.0, min(1.0, float(crisis_score))),
role=role,
)
self._events_by_session[session_id].append(event)
self._events_by_session[session_id].sort(key=lambda item: item.timestamp)
def get_risk_signals(self, session_id: str) -> dict[str, Any]:
events = [event for event in self._events_by_session.get(session_id, []) if event.role == "user"]
if not events:
return {
"frequency_change": 1.0,
"is_late_night": False,
"session_length_trend": "stable",
"withdrawal_detected": False,
"behavioral_score": 0.0,
"signals": [],
}
signals: list[BehavioralSignal] = []
frequency_change = self._compute_frequency_change(events)
frequency_signal = self._analyze_frequency(events, frequency_change)
if frequency_signal:
signals.append(frequency_signal)
time_signal = self._analyze_time(events)
if time_signal:
signals.append(time_signal)
withdrawal_signal = self._analyze_withdrawal(session_id, events)
if withdrawal_signal:
signals.append(withdrawal_signal)
absence_signal = self._analyze_return_after_absence(session_id, events)
if absence_signal:
signals.append(absence_signal)
escalation_signal = self._analyze_escalation(events)
if escalation_signal:
signals.append(escalation_signal)
session_length_trend = self._compute_session_length_trend(session_id, events)
behavioral_score = self._compute_behavioral_score(signals)
risk_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
signals.sort(key=lambda item: (risk_order.get(item.risk_level, 9), -item.score))
return {
"frequency_change": frequency_change,
"is_late_night": any(item.signal_type == "time" for item in signals),
"session_length_trend": session_length_trend,
"withdrawal_detected": any(item.signal_type == "withdrawal" for item in signals),
"behavioral_score": behavioral_score,
"signals": [item.as_dict() for item in signals],
}
def _all_user_events(self) -> list[BehavioralEvent]:
events: list[BehavioralEvent] = []
for session_events in self._events_by_session.values():
events.extend(event for event in session_events if event.role == "user")
events.sort(key=lambda item: item.timestamp)
return events
def _daily_count_baseline(self, current_date) -> float:
events = self._all_user_events()
counts: dict[Any, int] = {}
for offset in range(1, ROLLING_BASELINE_DAYS + 1):
counts[current_date - timedelta(days=offset)] = 0
for event in events:
event_date = event.timestamp.date()
if event_date in counts:
counts[event_date] += 1
return sum(counts.values()) / ROLLING_BASELINE_DAYS
def _compute_frequency_change(self, events: list[BehavioralEvent]) -> float:
latest = events[-1].timestamp
window_start = latest - timedelta(hours=1)
current_hour_count = sum(1 for event in events if event.timestamp >= window_start)
baseline_daily = self._daily_count_baseline(latest.date())
baseline_hourly = max(baseline_daily / 24.0, 0.1)
return round(current_hour_count / baseline_hourly, 2)
def _analyze_frequency(self, events: list[BehavioralEvent], frequency_change: float) -> BehavioralSignal | None:
latest = events[-1].timestamp
window_start = latest - timedelta(hours=1)
current_hour_count = sum(1 for event in events if event.timestamp >= window_start)
if current_hour_count >= 6 and frequency_change >= 3.0:
level = "HIGH" if frequency_change >= 6.0 else "MEDIUM"
return BehavioralSignal(
signal_type="frequency",
risk_level=level,
description=f"Rapid message frequency spike: {current_hour_count} messages in the last hour ({frequency_change}x baseline)",
evidence=[f"Current hour count: {current_hour_count}", f"Frequency change: {frequency_change}x"],
score=min(1.0, frequency_change / 8.0),
)
return None
def _analyze_time(self, events: list[BehavioralEvent]) -> BehavioralSignal | None:
latest = events[-1].timestamp
hour = latest.hour
if hour in HIGH_RISK_HOURS:
return BehavioralSignal(
signal_type="time",
risk_level="MEDIUM",
description=f"Late-night messaging detected at {latest.strftime('%H:%M')}",
evidence=[f"Latest message timestamp: {latest.isoformat()}"],
score=0.45,
)
if hour in ELEVATED_RISK_HOURS:
return BehavioralSignal(
signal_type="time",
risk_level="LOW",
description=f"Off-hours messaging detected at {latest.strftime('%H:%M')}",
evidence=[f"Latest message timestamp: {latest.isoformat()}"],
score=0.2,
)
return None
def _analyze_withdrawal(self, session_id: str, events: list[BehavioralEvent]) -> BehavioralSignal | None:
current_date = events[-1].timestamp.date()
baseline_daily = self._daily_count_baseline(current_date)
if baseline_daily < 3.0:
return None
current_day_count = sum(1 for event in events if event.timestamp.date() == current_date)
current_avg_len = sum(event.message_length for event in events if event.timestamp.date() == current_date) / max(current_day_count, 1)
prior_events = [
event
for sid, session_events in self._events_by_session.items()
if sid != session_id
for event in session_events
if event.role == "user" and event.timestamp.date() >= current_date - timedelta(days=ROLLING_BASELINE_DAYS)
]
if not prior_events:
return None
prior_avg_len = sum(event.message_length for event in prior_events) / len(prior_events)
if current_day_count <= max(1, baseline_daily * 0.3):
score = 0.55 if current_day_count == 1 else 0.4
if current_avg_len < prior_avg_len * 0.5:
score += 0.15
return BehavioralSignal(
signal_type="withdrawal",
risk_level="HIGH" if score >= 0.6 else "MEDIUM",
description="Sharp drop from recent communication baseline suggests withdrawal/isolation",
evidence=[
f"Current day count: {current_day_count}",
f"7-day daily baseline: {baseline_daily:.2f}",
f"Average message length: {current_avg_len:.1f} vs {prior_avg_len:.1f}",
],
score=min(1.0, score),
)
return None
def _analyze_return_after_absence(self, session_id: str, events: list[BehavioralEvent]) -> BehavioralSignal | None:
current_start = events[0].timestamp
prior_events = [
event
for sid, session_events in self._events_by_session.items()
if sid != session_id
for event in session_events
if event.role == "user" and event.timestamp < current_start
]
if not prior_events:
return None
latest_prior = max(prior_events, key=lambda item: item.timestamp)
gap = current_start - latest_prior.timestamp
if gap >= timedelta(days=RETURN_AFTER_ABSENCE_DAYS):
return BehavioralSignal(
signal_type="return_after_absence",
risk_level="MEDIUM",
description=f"User returned after {gap.days} days of silence",
evidence=[f"Last prior activity: {latest_prior.timestamp.isoformat()}"],
score=min(1.0, gap.days / 14.0),
)
return None
def _analyze_escalation(self, events: list[BehavioralEvent]) -> BehavioralSignal | None:
scored = [event for event in events if event.crisis_score > 0]
if len(scored) < 3:
return None
recent = scored[-5:]
midpoint = max(1, len(recent) // 2)
first_avg = sum(event.crisis_score for event in recent[:midpoint]) / len(recent[:midpoint])
second_avg = sum(event.crisis_score for event in recent[midpoint:]) / len(recent[midpoint:])
if second_avg >= max(0.4, first_avg * 1.3):
return BehavioralSignal(
signal_type="escalation",
risk_level="HIGH" if second_avg >= 0.65 else "MEDIUM",
description=f"Behavioral escalation: crisis score trend rose from {first_avg:.2f} to {second_avg:.2f}",
evidence=[f"Recent crisis scores: {[round(event.crisis_score, 2) for event in recent]}"],
score=min(1.0, second_avg),
)
return None
def _compute_session_length_trend(self, session_id: str, events: list[BehavioralEvent]) -> str:
current_duration = (events[-1].timestamp - events[0].timestamp).total_seconds()
previous_durations = []
current_start = events[0].timestamp
for sid, session_events in self._events_by_session.items():
if sid == session_id:
continue
user_events = [event for event in session_events if event.role == "user"]
if len(user_events) < 2:
continue
if user_events[-1].timestamp < current_start - timedelta(days=ROLLING_BASELINE_DAYS):
continue
previous_durations.append((user_events[-1].timestamp - user_events[0].timestamp).total_seconds())
if not previous_durations:
return "stable"
average_duration = sum(previous_durations) / len(previous_durations)
if current_duration > average_duration * 1.5:
return "increasing"
if current_duration < average_duration * 0.5:
return "decreasing"
return "stable"
def _compute_behavioral_score(self, signals: list[BehavioralSignal]) -> float:
if not signals:
return 0.0
max_score = max(signal.score for signal in signals)
multi_signal_boost = min(0.2, 0.05 * (len(signals) - 1))
return round(min(1.0, max_score + multi_signal_boost), 2)

View File

@@ -34,7 +34,6 @@ Usage:
from dataclasses import dataclass, field
from typing import List, Optional
from .behavioral import BehavioralTracker
from .detect import CrisisDetectionResult, SCORES
# Level ordering for comparison (higher = more severe)
@@ -53,12 +52,6 @@ class SessionState:
is_deescalating: bool = False
escalation_rate: float = 0.0 # levels gained per message
consecutive_low_messages: int = 0 # for de-escalation tracking
behavioral_score: float = 0.0
behavioral_signals: List[dict] = field(default_factory=list)
frequency_change: float = 1.0
is_late_night: bool = False
session_length_trend: str = "stable"
withdrawal_detected: bool = False
class CrisisSessionTracker:
@@ -84,8 +77,6 @@ class CrisisSessionTracker:
self._message_count = 0
self._level_history: List[str] = []
self._consecutive_low = 0
self._behavioral_tracker = BehavioralTracker()
self._behavioral_session_id = "current-session"
@property
def state(self) -> SessionState:
@@ -93,7 +84,6 @@ class CrisisSessionTracker:
is_escalating = self._detect_escalation()
is_deescalating = self._detect_deescalation()
rate = self._compute_escalation_rate()
behavioral = self._behavioral_tracker.get_risk_signals(self._behavioral_session_id)
return SessionState(
current_level=self._current_level,
@@ -104,29 +94,14 @@ class CrisisSessionTracker:
is_deescalating=is_deescalating,
escalation_rate=rate,
consecutive_low_messages=self._consecutive_low,
behavioral_score=behavioral["behavioral_score"],
behavioral_signals=behavioral["signals"],
frequency_change=behavioral["frequency_change"],
is_late_night=behavioral["is_late_night"],
session_length_trend=behavioral["session_length_trend"],
withdrawal_detected=behavioral["withdrawal_detected"],
)
def record(
self,
detection: CrisisDetectionResult,
*,
timestamp=None,
message_length: int = 0,
role: str = "user",
) -> SessionState:
def record(self, detection: CrisisDetectionResult) -> SessionState:
"""
Record a crisis detection result for the current message.
Returns updated SessionState.
"""
from datetime import datetime, timezone
level = detection.level
self._message_count += 1
self._level_history.append(level)
@@ -141,17 +116,6 @@ class CrisisSessionTracker:
else:
self._consecutive_low = 0
if role == "user":
if timestamp is None:
timestamp = datetime.now(timezone.utc)
self._behavioral_tracker.record(
self._behavioral_session_id,
timestamp,
message_length=message_length,
crisis_score=detection.score,
role=role,
)
self._current_level = level
return self.state
@@ -231,22 +195,14 @@ class CrisisSessionTracker:
"supportive engagement while remaining vigilant."
)
notes = []
if s.peak_level in ("CRITICAL", "HIGH") and s.current_level not in ("CRITICAL", "HIGH"):
notes.append(
f"User previously reached {s.peak_level} crisis level this session (currently {s.current_level}). "
return (
f"User previously reached {s.peak_level} crisis level "
f"this session (currently {s.current_level}). "
"Continue with care and awareness of the earlier crisis."
)
if s.behavioral_score >= 0.35 and s.behavioral_signals:
signal_names = ", ".join(item["signal_type"] for item in s.behavioral_signals)
notes.append(
f"Behavioral risk signals detected this session: {signal_names}. "
"Use the behavioral context to increase sensitivity and warmth."
)
return " ".join(notes)
return ""
def get_ui_hints(self) -> dict:
"""
@@ -261,10 +217,6 @@ class CrisisSessionTracker:
"session_deescalating": s.is_deescalating,
"session_peak_level": s.peak_level,
"session_message_count": s.message_count,
"behavioral_score": s.behavioral_score,
"is_late_night": s.is_late_night,
"withdrawal_detected": s.withdrawal_detected,
"session_length_trend": s.session_length_trend,
}
if s.is_escalating:
@@ -274,20 +226,12 @@ class CrisisSessionTracker:
"Consider increasing intervention level."
)
if s.behavioral_score >= 0.5:
hints["behavioral_warning"] = True
hints.setdefault(
"suggested_action",
"Behavioral risk patterns are active. Keep the response warm, grounded, and alert."
)
return hints
def check_crisis_with_session(
text: str,
tracker: CrisisSessionTracker,
timestamp=None,
) -> dict:
"""
Convenience: detect crisis and update session state in one call.
@@ -299,16 +243,7 @@ def check_crisis_with_session(
single_result = check_crisis(text)
detection = detect_crisis(text)
session_state = tracker.record(detection, timestamp=timestamp, message_length=len(text))
behavioral = {
"frequency_change": session_state.frequency_change,
"is_late_night": session_state.is_late_night,
"session_length_trend": session_state.session_length_trend,
"withdrawal_detected": session_state.withdrawal_detected,
"behavioral_score": session_state.behavioral_score,
"signals": session_state.behavioral_signals,
}
session_state = tracker.record(detection)
return {
**single_result,
@@ -320,6 +255,5 @@ def check_crisis_with_session(
"is_deescalating": session_state.is_deescalating,
"modifier": tracker.get_session_modifier(),
"ui_hints": tracker.get_ui_hints(),
"behavioral": behavioral,
},
}

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,101 +0,0 @@
"""
Tests for behavioral crisis pattern detection (#133).
"""
import os
import sys
import unittest
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.session_tracker import CrisisSessionTracker, check_crisis_with_session
from crisis.behavioral import BehavioralTracker
class TestBehavioralTracker(unittest.TestCase):
def _seed_day(self, tracker, *, session_id, day, count, start_hour=10, message_length=48, crisis_score=0.0):
base = datetime(2026, 4, day, start_hour, 0, tzinfo=timezone.utc)
for i in range(count):
tracker.record(
session_id,
base + timedelta(minutes=i * 10),
message_length=message_length,
crisis_score=crisis_score,
)
def test_frequency_change_uses_seven_day_baseline(self):
tracker = BehavioralTracker()
for day in range(1, 8):
self._seed_day(tracker, session_id=f"baseline-{day}", day=day, count=2)
burst_base = datetime(2026, 4, 8, 14, 0, tzinfo=timezone.utc)
for i in range(8):
tracker.record(
"current-session",
burst_base + timedelta(minutes=i),
message_length=72,
crisis_score=0.1,
)
summary = tracker.get_risk_signals("current-session")
self.assertGreater(summary["frequency_change"], 2.0)
self.assertTrue(any(sig["signal_type"] == "frequency" for sig in summary["signals"]))
self.assertGreater(summary["behavioral_score"], 0.0)
def test_late_night_messages_raise_flag(self):
tracker = BehavioralTracker()
base = datetime(2026, 4, 10, 2, 15, tzinfo=timezone.utc)
for i in range(3):
tracker.record(
"late-night",
base + timedelta(minutes=i * 7),
message_length=35,
crisis_score=0.0,
)
summary = tracker.get_risk_signals("late-night")
self.assertTrue(summary["is_late_night"])
self.assertTrue(any(sig["signal_type"] == "time" for sig in summary["signals"]))
def test_withdrawal_detected_after_large_drop_from_baseline(self):
tracker = BehavioralTracker()
for day in range(1, 8):
self._seed_day(tracker, session_id=f"baseline-{day}", day=day, count=10, message_length=80)
tracker.record(
"withdrawal-session",
datetime(2026, 4, 9, 11, 0, tzinfo=timezone.utc),
message_length=18,
crisis_score=0.0,
)
summary = tracker.get_risk_signals("withdrawal-session")
self.assertTrue(summary["withdrawal_detected"])
self.assertTrue(any(sig["signal_type"] == "withdrawal" for sig in summary["signals"]))
class TestBehavioralSessionIntegration(unittest.TestCase):
def test_check_crisis_with_session_includes_behavioral_summary(self):
tracker = CrisisSessionTracker()
base = datetime(2026, 4, 20, 2, 0, tzinfo=timezone.utc)
check_crisis_with_session("can't sleep", tracker, timestamp=base)
check_crisis_with_session("still here", tracker, timestamp=base + timedelta(minutes=1))
result = check_crisis_with_session("everything feels loud", tracker, timestamp=base + timedelta(minutes=2))
behavioral = result["session"]["behavioral"]
self.assertIn("frequency_change", behavioral)
self.assertIn("is_late_night", behavioral)
self.assertIn("session_length_trend", behavioral)
self.assertIn("withdrawal_detected", behavioral)
self.assertIn("behavioral_score", behavioral)
self.assertTrue(behavioral["is_late_night"])
self.assertGreater(behavioral["behavioral_score"], 0.0)
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