Compare commits
5 Commits
fix/remove
...
fix/dedupl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30d7b279f | ||
|
|
1d8afc30fd | ||
| 38601f6076 | |||
| dcc931e946 | |||
| 26e97f76db |
@@ -21,9 +21,9 @@ jobs:
|
||||
- name: Validate HTML Structure
|
||||
run: |
|
||||
echo "Checking for basic HTML tags..."
|
||||
grep -q "<html>" index.html
|
||||
grep -q "<body>" index.html
|
||||
grep -q "<head>" index.html
|
||||
grep -q "<html" index.html
|
||||
grep -q "<body" index.html
|
||||
grep -q "<head" index.html
|
||||
|
||||
- name: Validate Prompt Integrity
|
||||
run: |
|
||||
|
||||
380
crisis/detect.py
380
crisis/detect.py
@@ -1,24 +1,24 @@
|
||||
"""
|
||||
Crisis Detection Module for the-door.
|
||||
Crisis Detection Module for the-door (CANONICAL).
|
||||
|
||||
Single source of truth for crisis keyword detection.
|
||||
Parses incoming text for despair/suicide indicators and classifies into
|
||||
tiers: LOW, MEDIUM, HIGH, CRITICAL.
|
||||
|
||||
Both `crisis/detect.py` and `crisis_detector.py` (legacy shim) use this module.
|
||||
Do NOT duplicate keyword lists elsewhere.
|
||||
|
||||
Consolidated from:
|
||||
- crisis_detector.py (root, legacy — MODERATE tier, class API)
|
||||
- crisis/detect.py (module, newer — MEDIUM tier, functional API)
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisDetectionResult:
|
||||
level: str
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
recommended_action: str = ""
|
||||
score: float = 0.0
|
||||
|
||||
|
||||
# ── Indicator sets ──────────────────────────────────────────────
|
||||
# ── Canonical indicator sets ──────────────────────────────────────
|
||||
|
||||
CRITICAL_INDICATORS = [
|
||||
r"\bbetter off without me\b",
|
||||
@@ -28,50 +28,56 @@ CRITICAL_INDICATORS = [
|
||||
r"\bnot\s+worth\s+living\b",
|
||||
r"\bbetter\s+off\s+dead\b",
|
||||
r"\bend\s+it\s+all\b",
|
||||
r"\bcan'?t\s+(go|live)\s+on\b",
|
||||
r"\bno\s+reason\s+to\s+live\b",
|
||||
r"\bdon'?t\s+want\s+to\s+live\b",
|
||||
r"\bwant\s+to\s+die\b",
|
||||
r"\bgoing\s+to\s+kill\s+myself\b",
|
||||
r"\bplan\s+to\s+(end|kill|die)\b",
|
||||
r"\btired\s+of\s+(living|life|existence)\b",
|
||||
r"\bsaying\s+goodbye\s+(forever|permanently|one last time)\b",
|
||||
r"\bwrote\s+a\s+(will|suicide\s*note|letter)\b",
|
||||
r"\bgiving\s+away\s+(my|all my)\s+possess",
|
||||
r"\btied\s+(up|down)\s+my\s+(loose\s+)?ends",
|
||||
r"\bgoing\s+to\s+(?:kill\s+myself|die)\b",
|
||||
r"\bplan\s+to\s+(?:end|kill|die)\b",
|
||||
r"\btired\s+of\s+(?:living|life|existence)\b",
|
||||
r"\bsaying\s+goodbye\s+(?:forever|permanently|one\s+last\s+time)\b",
|
||||
r"\bwrote\s+a\s+suicide\s*(?:note|letter)\b",
|
||||
r"\bgiving\s+away\s+(?:my|all\s+my)\s+(?:stuff|things|possessions?)\s+(?:to|because|—)\b",
|
||||
r"\btied\s+(?:up|down)\s+my\s+(?:loose\s+)?ends",
|
||||
]
|
||||
|
||||
HIGH_INDICATORS = [
|
||||
r"\bdesperate\b",
|
||||
r"\bdespair\b",
|
||||
r"\bhopeless\b",
|
||||
r"\bno(?!t)\s+(one|body|point|hope|future|way\s+out)\b",
|
||||
r"\beverything\s+is\s+(pointless|broken|ruined)\b",
|
||||
r"\bhopeless(?:ly)?\s+(?:about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
|
||||
r"\bno(?!t)\s+(?:one|body|point|hope|way\s+out)\b",
|
||||
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
|
||||
r"\beverything\s+is\s+(?:pointless|broken|ruined)\b",
|
||||
r"\bcan'?t\s+take\s+this\s+anymore\b",
|
||||
r"\bdon'?t\s+care\s+if\s+I\s+die\b",
|
||||
r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b",
|
||||
r"\bwish\s+I\s+(?:was|were)\s+(?:dead|gone|never\s+born)\b",
|
||||
r"\bdon'?t\s+matter\s+if\s+I\s+exist\b",
|
||||
r"\bno\s+one\s+would\s+care\b",
|
||||
r"\bno\s+one\s+would\s+miss\b",
|
||||
r"\bno\s+one\s+would\s+(?:care|miss)\b",
|
||||
r"\bworld\s+would?\s+be\s+better\s+without\b",
|
||||
r"\bin\s+so\s+much\s+(pain|agony|suffering|torment)\b",
|
||||
r"\bcan'?t\s+see\s+any\s+(point|reason|light)\b",
|
||||
r"\btrapped\b",
|
||||
r"\bcage\b",
|
||||
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment)\b",
|
||||
r"\bcan'?t\s+see\s+any\s+(?:point|reason)\b",
|
||||
r"\bescape\s+from\s*this",
|
||||
r"\bjust\s+want\s+it\s+to\s+stop\b",
|
||||
r"\bnothing\s+left\b",
|
||||
r"\btrapped\s+(?:in\s+(?:my|this|a\s+dark)|and\s+can'?t\s+escape)\b",
|
||||
r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b",
|
||||
# Contextual despair phrases (from crisis_detector.py legacy)
|
||||
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
|
||||
r"\beverything\s+is\s+hopeless\b",
|
||||
r"\bcan'?t\s+(?:go\s+on|keep\s+going)\b",
|
||||
r"\bgive(?:n)?\s*up\s+(?:on\s+)?(?:life|living|everything)\b",
|
||||
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
|
||||
r"\bno\s*point\s+(?:in\s+)?living\b",
|
||||
r"\bno\s*hope\s+(?:left|remaining)\b",
|
||||
r"\bno\s*way\s*out\b",
|
||||
r"\bfeel(?:s|ing)?\s+trapped\b",
|
||||
r"\bdesperate\s+(?:for\s+)?help\b",
|
||||
r"\bfeel(?:s|ing)?\s+desperate\b",
|
||||
]
|
||||
|
||||
MEDIUM_INDICATORS = [
|
||||
r"\bno\s+hope\b",
|
||||
r"\bcan'?t\s+go\s+on\b",
|
||||
r"\bcan'?t\s+keep\s+going\b",
|
||||
r"\bforgotten\b",
|
||||
r"\balone\s+in\s+this\b",
|
||||
r"\balways\s+alone\b",
|
||||
r"\bnobody\s+understands\b",
|
||||
r"\bnobody\s+cares\b",
|
||||
r"\bnobody\s+(?:understands|cares)\b",
|
||||
r"\bwish\s+I\s+could\b",
|
||||
r"\bexhaust(?:ed|ion|ing)\b",
|
||||
r"\bnumb\b",
|
||||
@@ -80,8 +86,7 @@ MEDIUM_INDICATORS = [
|
||||
r"\buseless\b",
|
||||
r"\bbroken\b",
|
||||
r"\bdark(ness)?\b",
|
||||
r"\bdepressed\b",
|
||||
r"\bdepression\b",
|
||||
r"\bdepress(?:ed|ion)\b",
|
||||
r"\bcrying\b",
|
||||
r"\btears\b",
|
||||
r"\bsad(ness)?\b",
|
||||
@@ -89,42 +94,158 @@ MEDIUM_INDICATORS = [
|
||||
r"\boverwhelm(?:ed|ing)\b",
|
||||
r"\bfailing\b",
|
||||
r"\bcannot\s+cope\b",
|
||||
r"\blosing\s*(my)?\s*control\b",
|
||||
r"\blosing\s*(?:my)?\s*control\b",
|
||||
r"\bdown\s*for\s*the\s*count\b",
|
||||
r"\bsinking\b",
|
||||
r"\bdrowning\b",
|
||||
r"\bhopeless\b",
|
||||
# Contextual versions (from crisis_detector.py legacy)
|
||||
r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b",
|
||||
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
|
||||
r"\bfeel(?:s|ing)?\s+trapped\b",
|
||||
r"\bfeel(?:s|ing)?\s+desperate\b",
|
||||
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
|
||||
r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b",
|
||||
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
|
||||
]
|
||||
|
||||
LOW_INDICATORS = [
|
||||
r"\bunhappy\b",
|
||||
r"\bdown\b",
|
||||
r"\btough\s*time\b",
|
||||
r"\brough\s*day\b",
|
||||
r"\brough\s*week\b",
|
||||
r"\brough\s*patch\b",
|
||||
r"\brough\s+(?:day|week|patch)\b",
|
||||
r"\bstressed\b",
|
||||
r"\burnout\b",
|
||||
r"\bburnout\b",
|
||||
r"\bdifficult\b",
|
||||
r"\bfrustrated\b",
|
||||
r"\bannoyed\b",
|
||||
r"\btired\b",
|
||||
r"\bsad\b",
|
||||
r"\bupset\b",
|
||||
r"\bnot\s*great\b",
|
||||
r"\bnot\s*good\b",
|
||||
r"\bnot\s*okay\b",
|
||||
r"\bnot\s*(?:good|great|okay)\b",
|
||||
r"\bthings\s*are\s*hard\b",
|
||||
r"\bstruggling\b",
|
||||
r"\bnot\s+feeling\s+myself\b",
|
||||
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
|
||||
r"\bhard\s*time\b",
|
||||
]
|
||||
|
||||
|
||||
# ── Scoring ───────────────────────────────────────────────────────
|
||||
|
||||
SCORES = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25, "NONE": 0.0}
|
||||
|
||||
ACTIONS = {
|
||||
"CRITICAL": (
|
||||
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
||||
"Provide 988 Suicide & Crisis Lifeline. "
|
||||
"Provide Crisis Text Line (HOME to 741741). "
|
||||
"Stay present. Do not disconnect. Redirect to professional help."
|
||||
),
|
||||
"HIGH": (
|
||||
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
|
||||
"Continue conversation with crisis awareness."
|
||||
),
|
||||
"MEDIUM": (
|
||||
"Increase warmth and presence. Subtly surface help resources. "
|
||||
"Keep conversation anchored in the present."
|
||||
),
|
||||
"LOW": (
|
||||
"Normal conversation with warm undertone. "
|
||||
"No crisis UI elements needed. Remain vigilant."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Result types ──────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CrisisDetectionResult:
|
||||
"""Result used by crisis/detect.py, gateway, dying_detection."""
|
||||
level: str
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
recommended_action: str = ""
|
||||
score: float = 0.0
|
||||
matches: List[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisResult:
|
||||
"""Legacy result used by crisis_detector.py and crisis_responder.py.
|
||||
|
||||
Backward-compatible shim: wraps CrisisDetectionResult with the old field names.
|
||||
"""
|
||||
risk_level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
|
||||
matched_keywords: List[str] = field(default_factory=list)
|
||||
context: List[str] = field(default_factory=list)
|
||||
score: float = 0.0
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
def __bool__(self):
|
||||
return self.risk_level != "NONE"
|
||||
|
||||
@classmethod
|
||||
def from_detection_result(cls, dr: CrisisDetectionResult, text: str = "") -> "CrisisResult":
|
||||
"""Convert a CrisisDetectionResult to legacy CrisisResult format."""
|
||||
# Map MEDIUM -> MODERATE for legacy consumers
|
||||
level = "MODERATE" if dr.level == "MEDIUM" else dr.level
|
||||
# Extract context snippets from matches
|
||||
contexts = []
|
||||
if text:
|
||||
for m in dr.matches:
|
||||
ctx = extract_context(text, m["start"], m["end"])
|
||||
contexts.append(ctx)
|
||||
return cls(
|
||||
risk_level=level,
|
||||
matched_keywords=dr.indicators,
|
||||
context=contexts,
|
||||
score=dr.score,
|
||||
)
|
||||
|
||||
|
||||
# ── Core detection ────────────────────────────────────────────────
|
||||
|
||||
def _find_indicators(text: str) -> dict:
|
||||
"""Return dict with indicators found per tier, including match positions."""
|
||||
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
||||
|
||||
for pattern in CRITICAL_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["CRITICAL"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
for pattern in HIGH_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["HIGH"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
for pattern in MEDIUM_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["MEDIUM"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
for pattern in LOW_INDICATORS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
results["LOW"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||
"""
|
||||
Detect crisis level in a message.
|
||||
|
||||
Returns:
|
||||
CrisisDetectionResult with level, found indicators, recommended action, score
|
||||
Detection hierarchy:
|
||||
CRITICAL — immediate risk of self-harm or suicide (single match)
|
||||
HIGH — strong despair signals, ideation present (single match)
|
||||
MEDIUM — distress signals, requires 2+ indicators to escalate
|
||||
LOW — emotional difficulty, warrant gentle support (single match)
|
||||
NONE — no crisis indicators detected
|
||||
|
||||
Design principles:
|
||||
- Never computes the value of a human life
|
||||
- Never suggests someone should die or that death is a solution
|
||||
- Always errs on the side of higher risk when uncertain
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
@@ -135,82 +256,135 @@ def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||
if not matches:
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
# Priority: highest tier wins
|
||||
if matches["CRITICAL"]:
|
||||
return CrisisDetectionResult(
|
||||
level="CRITICAL",
|
||||
indicators=matches["CRITICAL"],
|
||||
recommended_action=(
|
||||
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
||||
"Provide 988 Suicide & Crisis Lifeline. "
|
||||
"Provide Crisis Text Line (HOME to 741741). "
|
||||
"Stay present. Do not disconnect. Redirect to professional help."
|
||||
),
|
||||
score=1.0,
|
||||
)
|
||||
# CRITICAL and HIGH: single match is enough
|
||||
for tier in ("CRITICAL", "HIGH"):
|
||||
if matches[tier]:
|
||||
tier_matches = matches[tier]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level=tier,
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS[tier],
|
||||
score=SCORES[tier],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
if matches["HIGH"]:
|
||||
return CrisisDetectionResult(
|
||||
level="HIGH",
|
||||
indicators=matches["HIGH"],
|
||||
recommended_action=(
|
||||
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
|
||||
"Continue conversation with crisis awareness."
|
||||
),
|
||||
score=0.75,
|
||||
)
|
||||
|
||||
if matches["MEDIUM"]:
|
||||
# MEDIUM tier: require at least 2 indicators before escalating
|
||||
if len(matches["MEDIUM"]) >= 2:
|
||||
tier_matches = matches["MEDIUM"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="MEDIUM",
|
||||
indicators=matches["MEDIUM"],
|
||||
recommended_action=(
|
||||
"Increase warmth and presence. Subtly surface help resources. "
|
||||
"Keep conversation anchored in the present."
|
||||
),
|
||||
score=0.5,
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["MEDIUM"],
|
||||
score=SCORES["MEDIUM"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
if matches["LOW"]:
|
||||
tier_matches = matches["LOW"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="LOW",
|
||||
indicators=matches["LOW"],
|
||||
recommended_action=(
|
||||
"Normal conversation with warm undertone. "
|
||||
"No crisis UI elements needed. Remain vigilant."
|
||||
),
|
||||
score=0.25,
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["LOW"],
|
||||
score=SCORES["LOW"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
# Single MEDIUM match falls through to LOW sensitivity
|
||||
if matches["MEDIUM"]:
|
||||
tier_matches = matches["MEDIUM"]
|
||||
patterns = [m["pattern"] for m in tier_matches]
|
||||
return CrisisDetectionResult(
|
||||
level="LOW",
|
||||
indicators=patterns,
|
||||
recommended_action=ACTIONS["LOW"],
|
||||
score=SCORES["LOW"],
|
||||
matches=tier_matches,
|
||||
)
|
||||
|
||||
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||
|
||||
|
||||
def _find_indicators(text: str) -> dict:
|
||||
"""Return dict with indicators found per tier."""
|
||||
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
||||
# ── CrisisDetector class (backward compat) ───────────────────────
|
||||
|
||||
for pattern in CRITICAL_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["CRITICAL"].append(pattern)
|
||||
class CrisisDetector:
|
||||
"""
|
||||
Legacy class API for crisis detection. Wraps the canonical detect_crisis().
|
||||
|
||||
for pattern in HIGH_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["HIGH"].append(pattern)
|
||||
Used by crisis_responder.py and tests/test_false_positive_fixes.py.
|
||||
Maps MEDIUM -> MODERATE for legacy consumers.
|
||||
"""
|
||||
|
||||
for pattern in MEDIUM_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["MEDIUM"].append(pattern)
|
||||
def scan(self, text: str) -> CrisisResult:
|
||||
dr = detect_crisis(text)
|
||||
return CrisisResult.from_detection_result(dr, text=text)
|
||||
|
||||
for pattern in LOW_INDICATORS:
|
||||
if re.search(pattern, text):
|
||||
results["LOW"].append(pattern)
|
||||
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
||||
return [self.scan(t) for t in texts]
|
||||
|
||||
return results
|
||||
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
|
||||
results = self.scan_multiple(texts)
|
||||
if not results:
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
return max(results, key=lambda r: r.score)
|
||||
|
||||
@staticmethod
|
||||
def format_result(result: CrisisResult) -> str:
|
||||
level_emoji = {
|
||||
"CRITICAL": "\U0001f6a8",
|
||||
"HIGH": "\u26a0\ufe0f",
|
||||
"MODERATE": "\U0001f536",
|
||||
"LOW": "\U0001f535",
|
||||
"NONE": "\u2705",
|
||||
}
|
||||
emoji = level_emoji.get(result.risk_level, "\u2753")
|
||||
lines = [
|
||||
f"{emoji} Risk Level: {result.risk_level} (score: {result.score:.2f})",
|
||||
f"Matched keywords: {len(result.matched_keywords)}",
|
||||
]
|
||||
if result.matched_keywords:
|
||||
lines.append(f" Keywords: {', '.join(result.matched_keywords)}")
|
||||
if result.context:
|
||||
lines.append("Context:")
|
||||
for ctx in result.context:
|
||||
lines.append(f" {ctx}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Module-level convenience (backward compat) ────────────────────
|
||||
|
||||
_default_detector = CrisisDetector()
|
||||
|
||||
|
||||
def detect_crisis_legacy(text: str) -> CrisisResult:
|
||||
"""Convenience function returning legacy CrisisResult format."""
|
||||
return _default_detector.scan(text)
|
||||
|
||||
|
||||
# ── Utility functions ─────────────────────────────────────────────
|
||||
|
||||
def scan(text: str) -> CrisisDetectionResult:
|
||||
"""Alias for detect_crisis — shorter name used in tests."""
|
||||
return detect_crisis(text)
|
||||
|
||||
|
||||
def extract_context(text: str, start: int, end: int, window: int = 60) -> str:
|
||||
"""Extract surrounding context around a match position."""
|
||||
ctx_start = max(0, start - window)
|
||||
ctx_end = min(len(text), end + window)
|
||||
snippet = text[ctx_start:ctx_end].strip()
|
||||
if ctx_start > 0:
|
||||
snippet = "..." + snippet
|
||||
if ctx_end < len(text):
|
||||
snippet = snippet + "..."
|
||||
return snippet
|
||||
|
||||
|
||||
def get_urgency_emoji(level: str) -> str:
|
||||
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": "✅"}
|
||||
return mapping.get(level, "❓")
|
||||
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
|
||||
return mapping.get(level, "\u2753")
|
||||
|
||||
|
||||
def format_result(result: CrisisDetectionResult) -> str:
|
||||
|
||||
@@ -270,3 +270,24 @@ def get_system_prompt_modifier(detection: CrisisDetectionResult) -> str:
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def generate_grounding_steps() -> list:
|
||||
"""Generate a 5-4-3-2-1 grounding exercise steps."""
|
||||
return [
|
||||
"Name 5 things you can see around you right now.",
|
||||
"Name 4 things you can touch or feel.",
|
||||
"Name 3 things you can hear.",
|
||||
"Name 2 things you can smell.",
|
||||
"Name 1 thing you can taste.",
|
||||
]
|
||||
|
||||
|
||||
def generate_breathing_exercise() -> str:
|
||||
"""Generate a simple box breathing exercise text."""
|
||||
return (
|
||||
"Let's try breathing together. "
|
||||
"Breathe in for 4 counts... hold for 4... "
|
||||
"breathe out for 6 counts... hold for 2. "
|
||||
"Let's do that again, nice and slow."
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ class TestDetection(unittest.TestCase):
|
||||
|
||||
def test_high_hopeless(self):
|
||||
r = detect_crisis("I feel completely hopeless with no way out")
|
||||
# 'hopeless' is MEDIUM; 'no way out' is HIGH — so result is HIGH
|
||||
self.assertIn(r.level, ("HIGH", "MEDIUM"))
|
||||
|
||||
def test_high_no_one_would_miss(self):
|
||||
@@ -112,7 +113,7 @@ class TestDetection(unittest.TestCase):
|
||||
}
|
||||
for text, expected_level in [
|
||||
("I want to kill myself", "CRITICAL"),
|
||||
("I feel completely hopeless", "HIGH"),
|
||||
("I feel completely hopeless with no way out", "HIGH"),
|
||||
("I feel so alone in this, nobody understands", "MEDIUM"),
|
||||
("Having a rough day", "LOW"),
|
||||
("Hello there", "NONE"),
|
||||
@@ -270,6 +271,65 @@ class TestHelpers(unittest.TestCase):
|
||||
self.assertIn("Indicators", formatted)
|
||||
|
||||
|
||||
class TestFalsePositives(unittest.TestCase):
|
||||
"""Regression tests for Issue #32: crisis detector false positives.
|
||||
|
||||
Innocent messages must NOT trigger crisis levels. These tests prevent
|
||||
regressions when keyword lists are modified.
|
||||
"""
|
||||
|
||||
def test_saying_goodbye_airport(self):
|
||||
r = detect_crisis("She was saying goodbye at the airport")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_saying_goodbye_apartment(self):
|
||||
r = detect_crisis("I'm saying goodbye to my old apartment")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_trapped_elevator(self):
|
||||
r = detect_crisis("I'm trapped in an elevator")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_desperate_competition(self):
|
||||
"""Casual 'desperate' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||
r = detect_crisis("She's desperate to win the race")
|
||||
self.assertIn(r.level, ("LOW", "NONE"))
|
||||
|
||||
def test_worthless_game_character(self):
|
||||
"""Casual 'worthless' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||
r = detect_crisis("This character is worthless in the game")
|
||||
self.assertIn(r.level, ("LOW", "NONE"))
|
||||
|
||||
def test_hopeless_bug(self):
|
||||
"""Casual 'hopeless' is MEDIUM but single-match falls to LOW, not HIGH."""
|
||||
r = detect_crisis("This bug is hopeless")
|
||||
self.assertIn(r.level, ("LOW", "NONE"))
|
||||
|
||||
def test_no_future_politics(self):
|
||||
r = detect_crisis("He has no future in politics")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_nothing_left_fridge(self):
|
||||
r = detect_crisis("There's nothing left in the fridge")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_cant_see_light_power_outage(self):
|
||||
r = detect_crisis("I can't see any light — the power went out")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_give_up_project(self):
|
||||
r = detect_crisis("I give up on this project")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_wrote_a_will_responsible(self):
|
||||
r = detect_crisis("I wrote a will last year — just being responsible")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
def test_giving_away_declutter(self):
|
||||
r = detect_crisis("I'm giving away possessions — decluttering my house")
|
||||
self.assertEqual(r.level, "NONE")
|
||||
|
||||
|
||||
class TestEdgeCases(unittest.TestCase):
|
||||
"""Test edge cases and integration scenarios."""
|
||||
|
||||
|
||||
@@ -1,260 +1,34 @@
|
||||
"""
|
||||
Crisis Detection System for the-door.
|
||||
Crisis Detection System for the-door (LEGACY SHIM).
|
||||
|
||||
Scans text for suicide, self-harm, and hopelessness signals.
|
||||
Returns risk level, matched keywords, and surrounding context.
|
||||
This module is a backward-compatible re-export layer.
|
||||
The canonical detection logic lives in crisis/detect.py.
|
||||
|
||||
This is the most important code in the foundation.
|
||||
Every line here exists because someone's life depends on it.
|
||||
|
||||
Usage:
|
||||
from crisis_detector import CrisisDetector
|
||||
detector = CrisisDetector()
|
||||
result = detector.scan("I can't go on anymore")
|
||||
print(result.risk_level) # "HIGH"
|
||||
print(result.matched_keywords) # ["can't go on"]
|
||||
print(result.context) # surrounding text snippets
|
||||
Both crisis_responder.py and the legacy test suite import from here.
|
||||
Do NOT add detection logic to this file — it all comes from crisis.detect.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
from crisis.detect import (
|
||||
CrisisDetector,
|
||||
CrisisResult,
|
||||
CrisisDetectionResult,
|
||||
detect_crisis_legacy as detect_crisis,
|
||||
CRITICAL_INDICATORS,
|
||||
HIGH_INDICATORS,
|
||||
MEDIUM_INDICATORS,
|
||||
LOW_INDICATORS,
|
||||
SCORES as RISK_SCORES,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisResult:
|
||||
"""Result of a crisis text scan."""
|
||||
risk_level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
|
||||
matched_keywords: List[str] = field(default_factory=list)
|
||||
context: List[str] = field(default_factory=list)
|
||||
score: float = 0.0
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
def __bool__(self):
|
||||
return self.risk_level != "NONE"
|
||||
|
||||
|
||||
# ── Keyword definitions ──────────────────────────────────────────
|
||||
|
||||
CRITICAL_KEYWORDS = {
|
||||
"suicide": r"\bsuicid(?:al|e|ed)\b",
|
||||
"kill myself": r"\bkill\s*(?:my)?self\b",
|
||||
"end my life": r"\bend\s*my\s*life\b",
|
||||
"end it all": r"\bend\s*it\s*all\b",
|
||||
"want to die": r"\bwant\s*to\s*die\b",
|
||||
"going to die": r"\bgoing\s*to\s*die\b",
|
||||
"plan to die": r"\bplan\s*(?:to|for)\s*(?:die|death|end)\b",
|
||||
"no reason to live": r"\bno\s*reason\s*to\s*live\b",
|
||||
"don't want to live": r"\bdon'?t\s*want\s*to\s*live\b",
|
||||
"not worth living": r"\bnot\s*worth\s*living\b",
|
||||
"better off dead": r"\bbetter\s*off\s*dead\b",
|
||||
"better off without me": r"\bbetter\s*off\s*without\s*me\b",
|
||||
"goodbye forever": r"\bgoodbye\s*forever\b",
|
||||
"saying goodbye": r"\bsaying\s*goodbye\b",
|
||||
"tired of living": r"\btired\s*of\s*(?:living|life|existence)\b",
|
||||
"wrote a will": r"\bwrote\s*(?:a|my)\s*(?:will|suicide\s*note|letter)\b",
|
||||
"giving away possessions": r"\bgiving\s*away\s*(?:my|all)\s*possess\b",
|
||||
}
|
||||
|
||||
HIGH_KEYWORDS = {
|
||||
"hopeless": r"\bhopeless(?:ness)?\b",
|
||||
"can't go on": r"\bcan'?t\s*go\s*on\b",
|
||||
"can't keep going": r"\bcan'?t\s*keep\s*going\b",
|
||||
"can't take this": r"\bcan'?t\s*take\s*this\b",
|
||||
"give up": r"\bgive(?:n)?\s*up\b",
|
||||
"no point": r"\bno\s*point\b",
|
||||
"no hope": r"\bno\s*hope\b",
|
||||
"no way out": r"\bno\s*way\s*out\b",
|
||||
"no future": r"\bno\s*future\b",
|
||||
"nothing left": r"\bnothing\s*left\b",
|
||||
"wish I was dead": r"\bwish\s*I\s*(?:was|were)\s*(?:dead|gone|never\s*born)\b",
|
||||
"no one would miss me": r"\bno\s*one\s*would\s*miss\b",
|
||||
"no one would care": r"\bno\s*one\s*would\s*care\b",
|
||||
"world better without me": r"\bworld\s*(?:would|will)\s*be\s*better\s*without\b",
|
||||
"so much pain": r"\bin\s*so\s*much\s*pain\b",
|
||||
"can't see any light": r"\bcan'?t\s*see\s*(?:any\s*)?(?:light|point|reason|way)\b",
|
||||
"trapped": r"\btrapped\b",
|
||||
"desperate": r"\bdesperate\b",
|
||||
"just want it to stop": r"\bjust\s*want\s*it\s*to\s*stop\b",
|
||||
"don't care if I die": r"\bdon'?t\s*care\s*if\s*I\s*die\b",
|
||||
"worthless": r"\bworthless\b",
|
||||
}
|
||||
|
||||
MODERATE_KEYWORDS = {
|
||||
"alone": r"\balone\b",
|
||||
"lost": r"\blost\b",
|
||||
"broken": r"\bbroken\b",
|
||||
"afraid": r"\bafraid\b",
|
||||
"pain": r"\b(?:in\s*)?pain\b",
|
||||
"dying": r"\bdying\b",
|
||||
"bridge": r"\bbridge\b", # context-dependent, flagged for review
|
||||
"help me": r"\bhelp\s*me\b",
|
||||
"crisis": r"\bcrisis\b",
|
||||
"overwhelmed": r"\boverwhelm(?:ed|ing)\b",
|
||||
"exhausted": r"\bexhausted\b",
|
||||
"numb": r"\bnumb\b",
|
||||
"empty": r"\bempty\b",
|
||||
"depressed": r"\bdepressed\b",
|
||||
"depression": r"\bdepression\b",
|
||||
"despair": r"\bdespair\b",
|
||||
"miserable": r"\bmiserable\b",
|
||||
"drowning": r"\bdrowning\b",
|
||||
"sinking": r"\bsinking\b",
|
||||
"nobody cares": r"\bnobody\s*cares\b",
|
||||
"nobody understands": r"\bnobody\s*understands\b",
|
||||
}
|
||||
|
||||
LOW_KEYWORDS = {
|
||||
"unhappy": r"\bunhappy\b",
|
||||
"struggling": r"\bstruggling\b",
|
||||
"stressed": r"\bstressed\b",
|
||||
"frustrated": r"\bfrustrated\b",
|
||||
"tired": r"\btired\b",
|
||||
"sad": r"\bsad\b",
|
||||
"upset": r"\bupset\b",
|
||||
"down": r"\bdown\b",
|
||||
"tough time": r"\btough\s*time\b",
|
||||
"rough day": r"\brough\s*day\b",
|
||||
"rough week": r"\brough\s*week\b",
|
||||
"rough patch": r"\brough\s*patch\b",
|
||||
"hard time": r"\bhard\s*time\b",
|
||||
"difficult": r"\bdifficult\b",
|
||||
"not okay": r"\bnot\s*okay\b",
|
||||
"not good": r"\bnot\s*(?:good|great)\b",
|
||||
"burnout": r"\bburnout\b",
|
||||
"not feeling myself": r"\bnot\s*feeling\s*(?:like\s*)?myself\b",
|
||||
}
|
||||
|
||||
# ── Risk level scoring ───────────────────────────────────────────
|
||||
|
||||
RISK_SCORES = {
|
||||
"CRITICAL": 1.0,
|
||||
"HIGH": 0.75,
|
||||
"MODERATE": 0.5,
|
||||
"LOW": 0.25,
|
||||
"NONE": 0.0,
|
||||
}
|
||||
|
||||
|
||||
class CrisisDetector:
|
||||
"""
|
||||
Scans text for crisis indicators and returns structured results.
|
||||
|
||||
Detection hierarchy:
|
||||
CRITICAL — immediate risk of self-harm or suicide
|
||||
HIGH — strong despair signals, ideation present
|
||||
MODERATE — distress signals, may be reaching out
|
||||
LOW — emotional difficulty, warrant gentle support
|
||||
NONE — no crisis indicators detected
|
||||
|
||||
Design principles:
|
||||
- Never computes the value of a human life
|
||||
- Never suggests someone should die or that death is a solution
|
||||
- Always errs on the side of higher risk when uncertain
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.critical_patterns = CRITICAL_KEYWORDS
|
||||
self.high_patterns = HIGH_KEYWORDS
|
||||
self.moderate_patterns = MODERATE_KEYWORDS
|
||||
self.low_patterns = LOW_KEYWORDS
|
||||
|
||||
def scan(self, text: str) -> CrisisResult:
|
||||
"""
|
||||
Scan text for crisis indicators.
|
||||
|
||||
Args:
|
||||
text: The message text to analyze.
|
||||
|
||||
Returns:
|
||||
CrisisResult with risk_level, matched_keywords, context, and score.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
|
||||
text_lower = text.lower()
|
||||
context_window = 60 # characters before/after match for context
|
||||
|
||||
# Check each tier, highest first
|
||||
for level, patterns in [
|
||||
("CRITICAL", self.critical_patterns),
|
||||
("HIGH", self.high_patterns),
|
||||
("MODERATE", self.moderate_patterns),
|
||||
("LOW", self.low_patterns),
|
||||
]:
|
||||
matched = []
|
||||
contexts = []
|
||||
|
||||
for keyword, pattern in patterns.items():
|
||||
match = re.search(pattern, text_lower)
|
||||
if match:
|
||||
matched.append(keyword)
|
||||
# Extract surrounding context
|
||||
start = max(0, match.start() - context_window)
|
||||
end = min(len(text), match.end() + context_window)
|
||||
snippet = text[start:end].strip()
|
||||
if start > 0:
|
||||
snippet = "..." + snippet
|
||||
if end < len(text):
|
||||
snippet = snippet + "..."
|
||||
contexts.append(snippet)
|
||||
|
||||
if matched:
|
||||
return CrisisResult(
|
||||
risk_level=level,
|
||||
matched_keywords=matched,
|
||||
context=contexts,
|
||||
score=RISK_SCORES[level],
|
||||
)
|
||||
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
|
||||
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
|
||||
"""Scan multiple texts, returning the highest-risk result per text."""
|
||||
return [self.scan(t) for t in texts]
|
||||
|
||||
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
|
||||
"""Scan multiple texts and return only the highest-risk result."""
|
||||
results = self.scan_multiple(texts)
|
||||
if not results:
|
||||
return CrisisResult(risk_level="NONE", score=0.0)
|
||||
return max(results, key=lambda r: r.score)
|
||||
|
||||
@staticmethod
|
||||
def format_result(result: CrisisResult) -> str:
|
||||
"""Format a crisis result for human-readable output."""
|
||||
level_emoji = {
|
||||
"CRITICAL": "\U0001f6a8", # 🚨
|
||||
"HIGH": "\u26a0\ufe0f", # ⚠️
|
||||
"MODERATE": "\U0001f536", # 🔶
|
||||
"LOW": "\U0001f535", # 🔵
|
||||
"NONE": "\u2705", # ✅
|
||||
}
|
||||
emoji = level_emoji.get(result.risk_level, "\u2753")
|
||||
lines = [
|
||||
f"{emoji} Risk Level: {result.risk_level} (score: {result.score:.2f})",
|
||||
f"Matched keywords: {len(result.matched_keywords)}",
|
||||
]
|
||||
if result.matched_keywords:
|
||||
lines.append(f" Keywords: {', '.join(result.matched_keywords)}")
|
||||
if result.context:
|
||||
lines.append("Context:")
|
||||
for ctx in result.context:
|
||||
lines.append(f" {ctx}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Module-level convenience function ────────────────────────────
|
||||
|
||||
_default_detector = CrisisDetector()
|
||||
|
||||
|
||||
def detect_crisis(text: str) -> CrisisResult:
|
||||
"""
|
||||
Convenience function using a shared detector instance.
|
||||
|
||||
Usage:
|
||||
from crisis_detector import detect_crisis
|
||||
result = detect_crisis("I feel so hopeless right now")
|
||||
"""
|
||||
return _default_detector.scan(text)
|
||||
# Re-export everything the legacy API exposed
|
||||
__all__ = [
|
||||
"CrisisDetector",
|
||||
"CrisisResult",
|
||||
"CrisisDetectionResult",
|
||||
"detect_crisis",
|
||||
"CRITICAL_INDICATORS",
|
||||
"HIGH_INDICATORS",
|
||||
"MEDIUM_INDICATORS",
|
||||
"LOW_INDICATORS",
|
||||
"RISK_SCORES",
|
||||
]
|
||||
|
||||
10
index.html
10
index.html
@@ -865,10 +865,10 @@ Sovereignty and service always.`;
|
||||
// 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)
|
||||
'no point', 'no purpose', 'nothing matters', 'giving up', 'give up',
|
||||
// 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', 'hopeless', 'worthless', 'burden', 'waste of space'
|
||||
'no hope left', 'burden', 'waste of space'
|
||||
];
|
||||
|
||||
// Tier 2: Explicit intent - triggers full-screen overlay
|
||||
@@ -883,9 +883,9 @@ Sovereignty and service always.`;
|
||||
// Imminent action (NEW)
|
||||
'going to do it now', 'doing it tonight', 'doing it today',
|
||||
"can't wait anymore", 'ready to end it', 'time to go',
|
||||
'say goodbye', 'saying goodbye', 'wrote a note', 'my note',
|
||||
'wrote a suicide note', 'my suicide note',
|
||||
// Specific plans (NEW)
|
||||
'bought a gun', 'got pills', 'rope ready', 'bridge nearby',
|
||||
'bought a gun', 'got pills', 'rope ready',
|
||||
'tall building', 'going to overdose', 'going to hang',
|
||||
'gave away my stuff', 'giving away', 'said my goodbyes',
|
||||
// Active self-harm (NEW)
|
||||
|
||||
176
tests/test_false_positive_fixes.py
Normal file
176
tests/test_false_positive_fixes.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Test false positive fixes for issue #32.
|
||||
Verifies that innocent messages no longer trigger crisis detection.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is on path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from crisis_detector import CrisisDetector
|
||||
|
||||
|
||||
class TestFalsePositiveFixes(unittest.TestCase):
|
||||
"""Test that innocent messages no longer trigger false positives."""
|
||||
|
||||
def setUp(self):
|
||||
self.detector = CrisisDetector()
|
||||
|
||||
def test_saying_goodbye_innocent(self):
|
||||
"""'saying goodbye' should not trigger for innocent messages."""
|
||||
innocent_messages = [
|
||||
"I'm saying goodbye to my old apartment",
|
||||
"She was saying goodbye at the airport",
|
||||
"We were saying goodbye to our friends",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_wrote_a_will_innocent(self):
|
||||
"""'wrote a will' should not trigger for responsible behavior."""
|
||||
innocent_messages = [
|
||||
"I wrote a will last year — just being responsible",
|
||||
"She wrote a will as part of estate planning",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_giving_away_possessions_innocent(self):
|
||||
"""'giving away possessions' should not trigger for decluttering."""
|
||||
innocent_messages = [
|
||||
"I'm giving away possessions — decluttering my house",
|
||||
"She's giving away her old clothes",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_give_up_innocent(self):
|
||||
"""'give up' should not trigger for common frustration."""
|
||||
innocent_messages = [
|
||||
"I give up on this project",
|
||||
"Don't give up on your dreams",
|
||||
"She gave up chocolate for Lent",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_trapped_innocent(self):
|
||||
"""'trapped' should not trigger for literal situations."""
|
||||
innocent_messages = [
|
||||
"I'm trapped in an elevator",
|
||||
"The cat is trapped in the tree",
|
||||
"We're trapped in traffic",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_desperate_innocent(self):
|
||||
"""'desperate' should not trigger for competition."""
|
||||
innocent_messages = [
|
||||
"She's desperate to win the game",
|
||||
"He's desperate for a promotion",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_worthless_innocent(self):
|
||||
"""'worthless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
|
||||
innocent_messages = [
|
||||
"This character is worthless in the game",
|
||||
"That movie was worthless",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_hopeless_innocent(self):
|
||||
"""'hopeless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
|
||||
innocent_messages = [
|
||||
"This bug is hopeless",
|
||||
"The situation is hopeless but we'll try anyway",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_no_future_innocent(self):
|
||||
"""'no future' should not trigger for political commentary."""
|
||||
innocent_messages = [
|
||||
"He has no future in politics",
|
||||
"That company has no future",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_nothing_left_innocent(self):
|
||||
"""'nothing left' should not trigger for everyday life."""
|
||||
innocent_messages = [
|
||||
"There's nothing left in the fridge",
|
||||
"We have nothing left to do",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "NONE",
|
||||
f"False positive for: {msg}")
|
||||
|
||||
def test_cant_see_any_light_innocent(self):
|
||||
"""'can't see any light'/'dark' alone is MEDIUM, single-match falls to LOW."""
|
||||
innocent_messages = [
|
||||
"I can't see any light — the power went out",
|
||||
"It's so dark I can't see any light",
|
||||
]
|
||||
for msg in innocent_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertIn(result.risk_level, ("NONE", "LOW"),
|
||||
f"Should not escalate above LOW for: {msg}")
|
||||
|
||||
def test_contextual_high_triggers(self):
|
||||
"""Verify that contextual HIGH tier phrases still trigger."""
|
||||
# These should trigger HIGH because they have crisis context
|
||||
crisis_messages = [
|
||||
("I feel so hopeless about my life", "HIGH"),
|
||||
("I'm trapped in this situation and can't escape", "HIGH"),
|
||||
("I feel desperate for help", "HIGH"),
|
||||
("I want to give up on life", "HIGH"),
|
||||
("There's no point in living anymore", "HIGH"),
|
||||
]
|
||||
for msg, expected_level in crisis_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertIn(result.risk_level, ("HIGH", "CRITICAL"),
|
||||
f"Should trigger HIGH/CRITICAL for: {msg}, got {result.risk_level}")
|
||||
|
||||
def test_critical_still_triggers(self):
|
||||
"""Verify that CRITICAL tier phrases still trigger."""
|
||||
critical_messages = [
|
||||
"I want to kill myself",
|
||||
"I'm going to end my life",
|
||||
"I wrote a suicide note",
|
||||
"I'm giving away all my possessions, saying goodbye forever",
|
||||
]
|
||||
for msg in critical_messages:
|
||||
result = self.detector.scan(msg)
|
||||
self.assertEqual(result.risk_level, "CRITICAL",
|
||||
f"Should trigger CRITICAL for: {msg}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user