Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
e1e6788237 fix(#1601): align JS crisis detection with Python 5-tier system
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 20s
Smoke Test / smoke (pull_request) Successful in 36s
Split crisisKeywords into highKeywords (suicidal ideation, self-harm)
and mediumKeywords (distress, hopelessness). Updated getCrisisLevel()
to return 0-4 (NONE/LOW/MEDIUM/HIGH/CRITICAL) matching the Python
backend and the existing COMPASSION_PROFILES.

Part of #1601
2026-04-15 22:04:57 -04:00
2 changed files with 22 additions and 107 deletions

View File

@@ -854,21 +854,23 @@ Sovereignty and service always.`;
// ===== CRISIS KEYWORDS =====
// Tier 1: General crisis indicators - triggers enhanced 988 panel
var crisisKeywords = [
// Original keywords
// HIGH: suicidal ideation, self-harm — triggers level 3
var highKeywords = [
'suicide', 'kill myself', 'end it all', 'no reason to live',
'want to die', "can't go on", 'nobody cares', 'better off without me',
'goodbye forever', 'end my life', 'not worth living', 'no way out',
// Self-harm (NEW)
'want to die', 'end my life', 'not worth living', 'goodbye forever',
'better off without me',
'hurt myself', 'self harm', 'self-harm', 'cutting myself', 'cut myself',
'burn myself', 'scratch myself', 'hitting myself', 'harm myself',
// Passive suicidal ideation (NEW)
"don't want to exist", 'not exist anymore', 'disappear forever',
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
// Hopelessness (NEW) - context-aware phrases to reduce false positives
'no purpose', 'nothing matters', 'giving up on life',
'cant go on', 'cannot go on', "can't take it", 'too much pain',
'no hope left', 'burden', 'waste of space'
'never wake up', 'sleep forever', 'end the pain'
];
// MEDIUM: distress, hopelessness — triggers level 2
var mediumKeywords = [
'no way out', "can't go on", 'cant go on', 'cannot go on',
'nobody cares', 'nothing matters', 'no purpose', 'giving up on life',
"can't take it", 'too much pain', 'no hope left', 'burden',
'waste of space', 'stop the pain'
];
// Tier 2: Explicit intent - triggers full-screen overlay
@@ -956,10 +958,13 @@ Sovereignty and service always.`;
function getCrisisLevel(text) {
var lower = text.toLowerCase();
for (var i = 0; i < explicitPhrases.length; i++) {
if (lower.indexOf(explicitPhrases[i]) !== -1) return 2;
if (lower.indexOf(explicitPhrases[i]) !== -1) return 4;
}
for (var j = 0; j < crisisKeywords.length; j++) {
if (lower.indexOf(crisisKeywords[j]) !== -1) return 1;
for (var j = 0; j < highKeywords.length; j++) {
if (lower.indexOf(highKeywords[j]) !== -1) return 3;
}
for (var k = 0; k < mediumKeywords.length; k++) {
if (lower.indexOf(mediumKeywords[k]) !== -1) return 2;
}
return 0;
}
@@ -969,7 +974,7 @@ Sovereignty and service always.`;
var level = getCrisisLevel(userText);
if (level === 0) return SYSTEM_PROMPT;
var levelMap = { 0: 'NONE', 1: 'MEDIUM', 2: 'CRITICAL' };
var levelMap = { 0: 'NONE', 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH', 4: 'CRITICAL' };
var profileName = levelMap[level] || 'NONE';
var profile = COMPASSION_PROFILES[profileName];
@@ -1050,43 +1055,11 @@ Sovereignty and service always.`;
}
}, 1000);
// Focus the Call 988 link — always enabled, most important action
var callLink = crisisOverlay.querySelector('.overlay-call');
if (callLink) {
callLink.focus();
}
overlayDismissBtn.focus();
}
// Crisis overlay Escape key handler
function trapCrisisOverlayEscape(e) {
if (e.key !== 'Escape') return;
if (!crisisOverlay.classList.contains('active')) return;
if (overlayDismissBtn.disabled) return; // Don't escape during countdown
// Dismiss the overlay
crisisOverlay.classList.remove('active');
if (overlayTimer) {
clearInterval(overlayTimer);
overlayTimer = null;
}
// Re-enable background interaction
var mainApp = document.querySelector('.app');
if (mainApp) mainApp.removeAttribute('inert');
var chatSection = document.getElementById('chat');
if (chatSection) chatSection.removeAttribute('aria-hidden');
var footerEl = document.querySelector('footer');
if (footerEl) footerEl.removeAttribute('aria-hidden');
// Restore focus to chat input
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
_preOverlayFocusElement.focus();
} else {
msgInput.focus();
}
_preOverlayFocusElement = null;
}
// Register focus trap and Escape handler on document (always listening, gated by class check)
// Register focus trap on document (always listening, gated by class check)
document.addEventListener('keydown', trapFocusInOverlay);
document.addEventListener('keydown', trapCrisisOverlayEscape);
overlayDismissBtn.addEventListener('click', function() {
if (!overlayDismissBtn.disabled) {

View File

@@ -52,64 +52,6 @@ class TestCrisisOverlayFocusTrap(unittest.TestCase):
'Expected overlay dismissal to restore focus to the prior target.',
)
def test_overlay_registers_escape_key_handler(self):
self.assertRegex(
self.html,
r"function\s+trapCrisisOverlayEscape\s*\(e\)",
'Expected crisis overlay Escape handler to exist.',
)
self.assertRegex(
self.html,
r"if\s*\(e\.key\s*!==\s*'Escape'\)\s*return;",
'Expected Escape handler to guard on Escape key events.',
)
self.assertRegex(
self.html,
r"document\.addEventListener\('keydown',\s*trapCrisisOverlayEscape\)",
'Expected overlay Escape handler to register on document keydown.',
)
def test_overlay_escape_returns_focus_to_chat_input(self):
self.assertIn(
'msgInput.focus()',
self.html,
'Expected Escape to fall back to msgInput.focus() when no pre-overlay element.',
)
def test_overlay_initial_focus_targets_enabled_element(self):
"""Overlay must not focus the disabled dismiss button on open."""
self.assertRegex(
self.html,
r'overlayDismissBtn\.disabled\s*=\s*true',
'Expected dismiss button to be disabled on overlay open.',
)
# In showOverlay body, overlayDismissBtn.focus() must not appear
show_overlay_match = re.search(
r'function showOverlay\(\)(.*?)(?=\nfunction |\n\s+// Register)',
self.html,
re.DOTALL,
)
self.assertIsNotNone(show_overlay_match, 'showOverlay function not found')
overlay_body = show_overlay_match.group(1)
self.assertNotIn(
'overlayDismissBtn.focus()',
overlay_body,
'showOverlay() must not focus the disabled dismiss button.',
)
def test_overlay_focuses_call_link(self):
"""Overlay should focus the .overlay-call link on open."""
self.assertIn(
'.overlay-call',
self.html,
'Expected .overlay-call element to exist in the overlay.',
)
self.assertRegex(
self.html,
r"querySelector\('\.overlay-call'\)",
'Expected showOverlay to query for .overlay-call element.',
)
if __name__ == '__main__':
unittest.main()