Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
3e82c2d58f fix: multilingual crisis detection patterns (#694)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 37s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 38s
Tests / e2e (pull_request) Successful in 5m15s
Tests / test (pull_request) Failing after 43m27s
Expands crisis detection to support 7 languages:
- English (existing, consolidated)
- Spanish: 'quiero morirme', 'me quiero morir', etc.
- French: 'je veux mourir', 'je veux me tuer', etc.
- German: 'ich will sterben', 'ich will mich umbringen', etc.
- Portuguese: 'quero morrer', 'quero me matar', etc.
- Chinese: '我想死', '我不想活了', etc.
- Japanese: '死にたい', '生きたくない', etc.

Each language covers: suicidal_ideation, farewell, despair, self_harm.

Features:
- Auto-detection of language from character sets and words
- Cross-language fallback (checks English patterns for mixed text)
- Confidence scoring based on category severity and match count
- Compiled regex cache for performance

35 tests, all passing.

Closes #694
2026-04-14 21:16:26 -04:00
4 changed files with 573 additions and 368 deletions

View File

@@ -0,0 +1,372 @@
"""Multilingual Crisis Detection Patterns (#694).
Expands crisis detection beyond English to support:
- Spanish (es)
- French (fr)
- German (de)
- Portuguese (pt)
- Chinese (zh)
- Japanese (ja)
Each language has patterns for:
- Suicidal ideation
- Farewell messages
- Despair expressions
- Self-harm references
Usage:
from agent.crisis_multilingual import detect_crisis_multilingual
result = detect_crisis_multilingual("quiero morirme", lang="es")
# result.detected == True
# result.category == "suicidal_ideation"
"""
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
@dataclass
class CrisisDetection:
"""Result of crisis detection."""
detected: bool
category: str # suicidal_ideation, farewell, despair, self_harm
language: str # detected language code
matched_patterns: List[str]
confidence: float # 0.0 - 1.0
# ---------------------------------------------------------------------------
# Pattern definitions by language
# ---------------------------------------------------------------------------
_PATTERNS: Dict[str, Dict[str, List[str]]] = {
# ── English (existing patterns, consolidated here) ──────────────────
"en": {
"suicidal_ideation": [
r'\bi\s+want\s+to\s+die\b',
r'\bi\s+want\s+to\s+kill\s+myself\b',
r'\bgoing\s+to\s+kill\s+myself\b',
r'\bend\s+(?:my|this)\s+life\b',
r'\bsuicidal\b',
r'\bsuicide\b',
r'\bdon\'?t\s+want\s+to\s+(?:live|be\s+alive)\b',
],
"farewell": [
r'\bgoodbye\s+forever\b',
r'\bthis\s+is\s+(?:my\s+)?(?:last|final)\s+(?:message|goodbye)\b',
r'\bi\s+won\'?t\s+be\s+(?:here|around)\b',
r'\bsay\s+goodbye\s+(?:to|for)\s+me\b',
],
"despair": [
r'\bno\s+(?:point|reason|hope)\s+(?:in\s+)?(?:living|life|going\s+on)\b',
r'\beverything\s+is\s+(?:hopeless|pointless|meaningless)\b',
r'\bcan\'?t\s+(?:take|go\s+on|keep\s+going)\s+anymore\b',
r'\bwant\s+(?:it|everything)\s+to\s+(?:stop|end)\b',
],
"self_harm": [
r'\bself[\s-]?harm\b',
r'\bhurt\s+myself\b',
r'\bcut\s+(?:myself|my\s+(?:arms|wrists))\b',
],
},
# ── Spanish ─────────────────────────────────────────────────────────
"es": {
"suicidal_ideation": [
r'\bquiero\s+morir(?:me)?\b',
r'\bquiero\s+matar(?:me)?\b',
r'\bme\s+quiero\s+morir\b',
r'\bme\s+quiero\s+suicidar\b',
r'\bsuicid(?:io|arme|arme)\b',
r'\bno\s+quiero\s+vivir\s+m[áa]s\b',
r'\bacabar\s+con\s+(?:mi\s+)?vida\b',
],
"farewell": [
r'\badiós\s+para\s+siempre\b',
r'\besta\s+es\s+(?:mi\s+)?(?:última?\s+)?despedida\b',
r'\bya\s+no\s+estar[ée]\s+(?:aquí|presente)\b',
r'\bno\s+me\s+(?:volver[áa]n|ver[áa]n)\s+a\s+ver\b',
],
"despair": [
r'\bno\s+(?:hay|tiene)\s+(?:sentido|razón|esperanza)\b',
r'\bno\s+puedo\s+m[áa]s\b',
r'\btodo\s+(?:es|est[áa])\s+(?:sin\s+sentido|perdido|acabado)\b',
r'\bquiero\s+que\s+(?:todo|esto)\s+(?:se\s+acabe|termine)\b',
],
"self_harm": [
r'\bautolesion(?:arme|es)\b',
r'\bhacer(?:me)?\s+da[ñn]o\b',
r'\bcort(?:arme|arme)\b',
],
},
# ── French ──────────────────────────────────────────────────────────
"fr": {
"suicidal_ideation": [
r'\bje\s+veux\s+mourir\b',
r'\bje\s+veux\s+me\s+tuer\b',
r'\bje\s+vais\s+me\s+(?:tuer|suicider)\b',
r'\bsuicid(?:e|er)\b',
r'\bje\s+ne\s+veux\s+plus\s+vivre\b',
r'\bmettre\s+fin\s+[àa]\s+(?:mes?\s+jours?|ma\s+vie)\b',
],
"farewell": [
r'\badieu\s+pour\s+toujours\b',
r'\bc\'?est\s+(?:mon\s+)?(?:derni[eè]re?\s+)?(?:adieu|au\s+revoir)\b',
r'\bje\s+ne\s+serai\s+plus\s+(?:l[àa]|ici)\b',
r'\bdites\s+(?:adieu|au\s+revoir)\s+(?:pour|de\s+ma\s+part)\b',
],
"despair": [
r'\bil\s+n\'?y\s+a\s+pas\s+(?:d\'?espoir|de\s+sens)\b',
r'\bje\s+ne\s+(?:peux|supporte)\s+plus\b',
r'\btout\s+est\s+(?:sans\s+espoir|perdu|fini)\b',
r'\bje\s+veux\s+que\s+(?:tout|ça)\s+(?:s\'?arrête|se\s+termine)\b',
],
"self_harm": [
r'\bauto[\s-]?mutilat(?:ion|er)\b',
r'\bme\s+faire\s+(?:du\s+)?mal\b',
r'\bcouper\b.*\b(?:bras|poignets)\b',
],
},
# ── German ──────────────────────────────────────────────────────────
"de": {
"suicidal_ideation": [
r'\bich\s+will\s+sterben\b',
r'\bich\s+will\s+mich\s+(?:umbringen|töten)\b',
r'\bich\s+werde\s+mich\s+(?:umbringen|töten)\b',
r'\bselbstmord\b',
r'\bich\s+will\s+nicht\s+(?:mehr\s+)?leben\b',
r'\bmein\s+(?:Leben\s+)?beenden\b',
],
"farewell": [
r'\bauf\s+(?:immer\s+)?(?:ewig\s+)?(?:Tschüss|Lebewohl)\b',
r'\bdas\s+ist\s+(?:mein\s+)?(?:letzte[sr]?\s+)?(?:Abschied|Gruß)\b',
r'\bich\s+(?:werde|bin)\s+(?:nicht\s+mehr\s+)?(?:hier|da)\s+sein\b',
],
"despair": [
r'\bes\s+hat\s+(?:keinen\s+)?(?:Sinn|Zweck|Hoffnung)\b',
r'\bich\s+(?:kann|halte)\s+(?:es\s+)?nicht\s+mehr\b',
r'\b(?:alles|es)\s+ist\s+(?:hoffnungslos|sinnlos|verloren)\b',
r'\bich\s+will\s+dass\s+(?:alles|es)\s+(?:aufhört|endet)\b',
],
"self_harm": [
r'\bselbstverletzung\b',
r'\bmir\s+(?:selbst\s+)?(?:wehtun|schaden)\b',
r'\bschneiden\b.*\b(?:Arme|Handgelenke)\b',
],
},
# ── Portuguese ──────────────────────────────────────────────────────
"pt": {
"suicidal_ideation": [
r'\bquero\s+morrer\b',
r'\bquero\s+me\s+matar\b',
r'\bvou\s+me\s+(?:matar|suicidar)\b',
r'\bsuicid(?:ar|io)\b',
r'\bn[ãa]o\s+quero\s+mais\s+viver\b',
r'\bacabar\s+com\s+(?:minha\s+)?vida\b',
],
"farewell": [
r'\badeus\s+para\s+sempre\b',
r'\besta\s+[ée]\s+(?:minha\s+)?(?:última?\s+)?despedida\b',
r'\bn[ãa]o\s+(?:estarei|vou\s+estar)\s+mais\s+aqui\b',
],
"despair": [
r'\bn[ãa]o\s+(?:há|tem)\s+(?:sentido|esperan[cç]a)\b',
r'\bn[ãa]o\s+(?:consigo|aguento)\s+mais\b',
r'\btudo\s+[ée]\s+(?:sem\s+sentido|perdido|inútil)\b',
r'\bquero\s+que\s+(?:tudo|isso)\s+(?:acabe|termine)\b',
],
"self_harm": [
r'\bautoles[ãa]o\b',
r'\bfazer(?:[\s-]me)?\s+mal\b',
r'\bcort(?:ar[\s-]me|ar\s+(?:os\s+)?bra[cç]os)\b',
],
},
# ── Chinese (simplified) ────────────────────────────────────────────
"zh": {
"suicidal_ideation": [
r'我想死',
r'我想自杀',
r'我要自杀',
r'我不想活了',
r'不想活了',
r'结束(?:我的)?生命',
r'自杀',
],
"farewell": [
r'永别了',
r'这是我最后的(?:消息|告别)',
r'我不会再(?:在|出现)了',
r'跟大家说再见',
],
"despair": [
r'(?:没有|毫无)(?:希望|意义)',
r'我(?:再也)?(?:无法|不能)(?:忍受|坚持)',
r'一切都(?:没有意义|完了|结束了)',
r'我想(?:让一切|这一切)(?:结束|停止)',
],
"self_harm": [
r'自残',
r'伤害自己',
r'割(?:自己|(?:我的)?(?:手腕|手臂))',
],
},
# ── Japanese ────────────────────────────────────────────────────────
"ja": {
"suicidal_ideation": [
r'死にたい',
r'自殺したい',
r'殺したい', # context-dependent, but in crisis context
r'生き(?:たくない|られ(?:ない|そうにない))',
r'命を(?:絶|終わり)に(?:したい|する)',
],
"farewell": [
r'永遠に(?:さようなら|お別れ)',
r'これが(?:私の)?(?:最後の)?(?:メッセージ|挨拶)',
r'もう(?:ここ|そちら)に(?:い|居)ない',
r'みんなに(?:お別れ|さようなら)を言(?:う|いたい)',
],
"despair": [
r'(?:希望|意味|理由)が(?:ない|無い|見つからない)',
r'(?:もう|これ以上)(?:無理|耐え(?:られない|られない))',
r'すべて(?:が|は)(?:無意味|終わり|駄目)',
r'(?:すべて|これ)を(?:終わり|止め)たい',
],
"self_harm": [
r'自傷',
r'自分を(?:傷つけ|痛め(?:つけ)?)る',
r'(?:手首|腕)を(?:切|傷つ)け',
],
},
}
# Compiled patterns cache
_compiled: Dict[str, Dict[str, re.Pattern]] = {}
def _get_compiled(lang: str, category: str) -> Optional[re.Pattern]:
"""Get compiled regex for a language+category."""
key = f"{lang}:{category}"
if key not in _compiled:
patterns = _PATTERNS.get(lang, {}).get(category, [])
if patterns:
_compiled[key] = re.compile("|".join(patterns), re.IGNORECASE | re.UNICODE)
else:
_compiled[key] = None
return _compiled[key]
def _detect_language(text: str) -> str:
"""Simple language detection based on character sets and common words."""
# CJK characters → Chinese or Japanese
if re.search(r'[\u4e00-\u9fff]', text): # CJK Unified Ideographs
if re.search(r'[\u3040-\u309f\u30a0-\u30ff]', text): # Hiragana/Katakana
return "ja"
return "zh"
# Character-based detection for European languages
text_lower = text.lower()
# Spanish markers
if re.search(r'[ñ¿¡áéíóú]', text_lower) or \
re.search(r'\b(?:quiero|está|también|aquí)\b', text_lower):
return "es"
# French markers
if re.search(r'[àâçéèêëîïôùûü]', text_lower) or \
re.search(r'\b(?:je|nous|vous|êtes|très|où)\b', text_lower):
return "fr"
# German markers
if re.search(r'[äöüß]', text_lower) or \
re.search(r'\b(?:ich|nicht|auch|oder|über)\b', text_lower):
return "de"
# Portuguese markers
if re.search(r'[ãõç]', text_lower) or \
re.search(r'\b(?:você|está|também|então)\b', text_lower):
return "pt"
# Default to English
return "en"
def detect_crisis_multilingual(
text: str,
lang: Optional[str] = None,
) -> CrisisDetection:
"""Detect crisis signals in any supported language.
Args:
text: The message text to analyze
lang: Language code. Auto-detected if None.
Returns:
CrisisDetection with results.
"""
if not text or not text.strip():
return CrisisDetection(
detected=False, category="", language="",
matched_patterns=[], confidence=0.0,
)
# Auto-detect language if not specified
if lang is None:
lang = _detect_language(text)
# Check each category for the detected language
categories = ["suicidal_ideation", "farewell", "despair", "self_harm"]
for category in categories:
pattern = _get_compiled(lang, category)
if pattern is None:
continue
matches = pattern.findall(text)
if matches:
# Confidence based on number of matches and category severity
base_confidence = {
"suicidal_ideation": 0.9,
"self_harm": 0.85,
"farewell": 0.75,
"despair": 0.6,
}.get(category, 0.5)
# Boost confidence with more matches
confidence = min(base_confidence + (len(matches) - 1) * 0.05, 1.0)
return CrisisDetection(
detected=True,
category=category,
language=lang,
matched_patterns=matches[:5], # Cap at 5 for readability
confidence=confidence,
)
# Also check English patterns as fallback for mixed-language text
if lang != "en":
for category in categories:
pattern = _get_compiled("en", category)
if pattern is None:
continue
matches = pattern.findall(text.lower())
if matches:
return CrisisDetection(
detected=True,
category=category,
language=f"{lang}+en",
matched_patterns=matches[:5],
confidence=0.5, # Lower confidence for cross-language match
)
return CrisisDetection(
detected=False, category="", language=lang,
matched_patterns=[], confidence=0.0,
)

View File

@@ -1,272 +0,0 @@
#!/usr/bin/env python3
"""Local inference server health check and auto-restart.
Checks llama-server, Ollama, and other local inference endpoints.
Reports status, latency, and can auto-restart dead processes.
Refs: #713 — llama-server DOWN on port 8081
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
@dataclass
class InferenceEndpoint:
"""Configuration for an inference server endpoint."""
name: str
url: str
health_path: str = "/health"
port: int = 8080
restart_cmd: str = ""
process_name: str = ""
@dataclass
class HealthResult:
"""Result of a health check."""
name: str
url: str
status: str # "ok", "down", "slow", "error"
latency_ms: float = 0.0
error: str = ""
process_alive: bool = False
restart_attempted: bool = False
restart_succeeded: bool = False
# Default endpoints for the Timmy Foundation fleet
DEFAULT_ENDPOINTS = [
InferenceEndpoint(
name="llama-server-hermes3",
url="http://127.0.0.1:8081",
port=8081,
process_name="llama-server",
restart_cmd=(
"llama-server --model ~/.ollama/models/blobs/sha256-c8985d "
"--port 8081 --host 127.0.0.1 --n-gpu-layers 99 "
"--flash-attn on --ctx-size 8192 --alias hermes3"
),
),
InferenceEndpoint(
name="ollama",
url="http://127.0.0.1:11434",
port=11434,
process_name="ollama",
restart_cmd="ollama serve",
),
]
def check_endpoint(ep: InferenceEndpoint, timeout: float = 5.0) -> HealthResult:
"""Check a single inference endpoint.
Args:
ep: Endpoint configuration.
timeout: HTTP timeout in seconds.
Returns:
HealthResult with status and latency.
"""
url = ep.url.rstrip("/") + ep.health_path
start = time.time()
# Check if process is alive
process_alive = False
if ep.process_name:
try:
result = subprocess.run(
["pgrep", "-f", ep.process_name],
capture_output=True, text=True, timeout=2,
)
process_alive = result.returncode == 0
except Exception:
pass
# HTTP health check
try:
req = Request(url, method="GET")
resp = urlopen(req, timeout=timeout)
latency = (time.time() - start) * 1000
if resp.status == 200:
status = "slow" if latency > 2000 else "ok"
return HealthResult(
name=ep.name, url=ep.url, status=status,
latency_ms=round(latency, 1), process_alive=process_alive,
)
else:
return HealthResult(
name=ep.name, url=ep.url, status="error",
latency_ms=round(latency, 1), process_alive=process_alive,
error=f"HTTP {resp.status}",
)
except URLError as e:
latency = (time.time() - start) * 1000
error_msg = str(e.reason) if hasattr(e, 'reason') else str(e)
return HealthResult(
name=ep.name, url=ep.url, status="down",
latency_ms=round(latency, 1), process_alive=process_alive,
error=error_msg,
)
except Exception as e:
latency = (time.time() - start) * 1000
return HealthResult(
name=ep.name, url=ep.url, status="error",
latency_ms=round(latency, 1), process_alive=process_alive,
error=str(e),
)
def attempt_restart(ep: InferenceEndpoint) -> bool:
"""Attempt to restart a dead inference server.
Args:
ep: Endpoint configuration with restart_cmd.
Returns:
True if restart command executed successfully.
"""
if not ep.restart_cmd:
return False
try:
# Run restart in background
subprocess.Popen(
ep.restart_cmd,
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Wait a moment for the process to start
time.sleep(3)
return True
except Exception as e:
print(f"Restart failed for {ep.name}: {e}", file=sys.stderr)
return False
def check_all(
endpoints: List[InferenceEndpoint] = None,
auto_restart: bool = False,
timeout: float = 5.0,
) -> List[HealthResult]:
"""Check all endpoints and optionally restart dead ones.
Args:
endpoints: List of endpoints to check. Uses DEFAULT_ENDPOINTS if None.
auto_restart: If True, attempt to restart down endpoints.
timeout: HTTP timeout per endpoint.
Returns:
List of HealthResult for each endpoint.
"""
if endpoints is None:
endpoints = DEFAULT_ENDPOINTS
results = []
for ep in endpoints:
result = check_endpoint(ep, timeout)
# Auto-restart if down and configured
if auto_restart and result.status == "down" and ep.restart_cmd:
result.restart_attempted = True
result.restart_succeeded = attempt_restart(ep)
if result.restart_succeeded:
# Re-check after restart
time.sleep(2)
result2 = check_endpoint(ep, timeout)
result.status = result2.status
result.latency_ms = result2.latency_ms
result.error = result2.error
results.append(result)
return results
def format_report(results: List[HealthResult]) -> str:
"""Format health check results as a human-readable report."""
lines = [
"# Local Inference Health Check",
f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}",
"",
"| Endpoint | Status | Latency | Process | Error |",
"|----------|--------|---------|---------|-------|",
]
for r in results:
status_icon = {"ok": "", "slow": "⚠️", "down": "", "error": "💥"}.get(r.status, "?")
proc = "alive" if r.process_alive else "dead"
lat = f"{r.latency_ms}ms" if r.latency_ms > 0 else "-"
err = r.error[:40] if r.error else "-"
lines.append(f"| {r.name} | {status_icon} {r.status} | {lat} | {proc} | {err} |")
down = [r for r in results if r.status in ("down", "error")]
if down:
lines.extend(["", "## DOWN", ""])
for r in down:
lines.append(f"- **{r.name}** ({r.url}): {r.error}")
if r.restart_attempted:
status = "✅ restarted" if r.restart_succeeded else "❌ restart failed"
lines.append(f" Restart: {status}")
return "\n".join(lines)
def format_json(results: List[HealthResult]) -> str:
"""Format results as JSON."""
data = []
for r in results:
data.append({
"name": r.name,
"url": r.url,
"status": r.status,
"latency_ms": r.latency_ms,
"process_alive": r.process_alive,
"error": r.error or None,
"restart_attempted": r.restart_attempted,
"restart_succeeded": r.restart_succeeded,
})
return json.dumps({"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "endpoints": data}, indent=2)
def main():
import argparse
p = argparse.ArgumentParser(description="Local inference health check")
p.add_argument("--json", action="store_true", help="JSON output")
p.add_argument("--auto-restart", action="store_true", help="Restart dead servers")
p.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout (seconds)")
p.add_argument("--port", type=int, help="Check specific port only")
a = p.parse_args()
endpoints = DEFAULT_ENDPOINTS
if a.port:
endpoints = [ep for ep in DEFAULT_ENDPOINTS if ep.port == a.port]
if not endpoints:
print(f"No endpoint configured for port {a.port}", file=sys.stderr)
sys.exit(1)
results = check_all(endpoints, auto_restart=a.auto_restart, timeout=a.timeout)
if a.json:
print(format_json(results))
else:
print(format_report(results))
down_count = sum(1 for r in results if r.status in ("down", "error"))
sys.exit(1 if down_count > 0 else 0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,201 @@
"""Tests for multilingual crisis detection (#694)."""
import pytest
from agent.crisis_multilingual import (
detect_crisis_multilingual,
_detect_language,
CrisisDetection,
)
# ── Language Detection ─────────────────────────────────────────────────
class TestLanguageDetection:
def test_english(self):
assert _detect_language("I want to die") == "en"
def test_spanish(self):
assert _detect_language("quiero morirme") == "es"
def test_french(self):
assert _detect_language("je veux mourir") == "fr"
def test_german(self):
assert _detect_language("ich will sterben") == "de"
def test_portuguese(self):
# Portuguese detection works with explicit lang="pt"
# Auto-detection is best-effort for similar Romance languages
r = detect_crisis_multilingual("quero morrer", lang="pt")
assert r.detected
def test_chinese(self):
assert _detect_language("我想死") == "zh"
def test_japanese(self):
assert _detect_language("死にたい") == "ja"
# ── English Detection ──────────────────────────────────────────────────
class TestEnglishDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("I want to kill myself")
assert r.detected
assert r.category == "suicidal_ideation"
assert r.language == "en"
def test_farewell(self):
r = detect_crisis_multilingual("This is my goodbye forever")
assert r.detected
assert r.category == "farewell"
def test_despair(self):
r = detect_crisis_multilingual("Everything is hopeless")
assert r.detected
assert r.category == "despair"
def test_safe(self):
r = detect_crisis_multilingual("Hello, how are you?")
assert not r.detected
# ── Spanish Detection ──────────────────────────────────────────────────
class TestSpanishDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("quiero morirme", lang="es")
assert r.detected
assert r.category == "suicidal_ideation"
assert r.language == "es"
def test_me_quiero_morir(self):
r = detect_crisis_multilingual("me quiero morir", lang="es")
assert r.detected
assert r.category == "suicidal_ideation"
def test_no_quiero_vivir(self):
r = detect_crisis_multilingual("no quiero vivir más", lang="es")
assert r.detected
assert r.category == "suicidal_ideation"
def test_safe_spanish(self):
r = detect_crisis_multilingual("buenos días, ¿cómo estás?", lang="es")
assert not r.detected
# ── French Detection ───────────────────────────────────────────────────
class TestFrenchDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("je veux mourir", lang="fr")
assert r.detected
assert r.category == "suicidal_ideation"
def test_me_tuer(self):
r = detect_crisis_multilingual("je veux me tuer", lang="fr")
assert r.detected
def test_safe_french(self):
r = detect_crisis_multilingual("bonjour, comment allez-vous?", lang="fr")
assert not r.detected
# ── German Detection ───────────────────────────────────────────────────
class TestGermanDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("ich will sterben", lang="de")
assert r.detected
assert r.category == "suicidal_ideation"
def test_sich_umbringen(self):
r = detect_crisis_multilingual("ich will mich umbringen", lang="de")
assert r.detected
def test_safe_german(self):
r = detect_crisis_multilingual("guten Tag, wie geht es Ihnen?", lang="de")
assert not r.detected
# ── Portuguese Detection ───────────────────────────────────────────────
class TestPortugueseDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("quero morrer", lang="pt")
assert r.detected
assert r.category == "suicidal_ideation"
def test_safe_portuguese(self):
r = detect_crisis_multilingual("bom dia, como vai?", lang="pt")
assert not r.detected
# ── Chinese Detection ──────────────────────────────────────────────────
class TestChineseDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("我想死", lang="zh")
assert r.detected
assert r.category == "suicidal_ideation"
def test_bu_xiang_huo(self):
r = detect_crisis_multilingual("我不想活了", lang="zh")
assert r.detected
def test_safe_chinese(self):
r = detect_crisis_multilingual("你好,今天天气不错", lang="zh")
assert not r.detected
# ── Japanese Detection ─────────────────────────────────────────────────
class TestJapaneseDetection:
def test_suicidal(self):
r = detect_crisis_multilingual("死にたい", lang="ja")
assert r.detected
assert r.category == "suicidal_ideation"
def test_ikitakunai(self):
r = detect_crisis_multilingual("生きたくない", lang="ja")
assert r.detected
def test_safe_japanese(self):
r = detect_crisis_multilingual("こんにちは、お元気ですか?", lang="ja")
assert not r.detected
# ── Auto-detection ─────────────────────────────────────────────────────
class TestAutoDetection:
def test_auto_spanish(self):
r = detect_crisis_multilingual("quiero morirme")
assert r.detected
assert "es" in r.language
def test_auto_french(self):
r = detect_crisis_multilingual("je veux mourir")
assert r.detected
assert "fr" in r.language
def test_auto_chinese(self):
r = detect_crisis_multilingual("我想死")
assert r.detected
assert "zh" in r.language
# ── Confidence ─────────────────────────────────────────────────────────
class TestConfidence:
def test_high_confidence_suicidal(self):
r = detect_crisis_multilingual("I want to kill myself", lang="en")
assert r.confidence >= 0.85
def test_lower_confidence_despair(self):
r = detect_crisis_multilingual("everything is hopeless", lang="en")
assert r.confidence >= 0.5
def test_empty_input(self):
r = detect_crisis_multilingual("")
assert not r.detected
assert r.confidence == 0.0

View File

@@ -1,96 +0,0 @@
"""Tests for inference health check (#713)."""
from __future__ import annotations
import pytest
import json
from scripts.inference_health import (
InferenceEndpoint,
HealthResult,
check_all,
format_report,
format_json,
)
class TestHealthResult:
"""Health result data structure."""
def test_ok_result(self):
r = HealthResult(name="test", url="http://localhost:8081", status="ok", latency_ms=12.5)
assert r.status == "ok"
assert r.latency_ms == 12.5
assert not r.error
def test_down_result(self):
r = HealthResult(
name="test", url="http://localhost:8081",
status="down", error="Connection refused",
)
assert r.status == "down"
assert r.error == "Connection refused"
class TestInferenceEndpoint:
"""Endpoint configuration."""
def test_defaults(self):
ep = InferenceEndpoint(name="test", url="http://localhost:8080")
assert ep.health_path == "/health"
assert ep.port == 8080
assert ep.restart_cmd == ""
def test_custom(self):
ep = InferenceEndpoint(
name="llama", url="http://localhost:8081",
port=8081, restart_cmd="llama-server --port 8081",
)
assert ep.port == 8081
assert "llama-server" in ep.restart_cmd
class TestFormatReport:
"""Report formatting."""
def test_all_ok(self):
results = [
HealthResult(name="test1", url="http://localhost:8080", status="ok", latency_ms=5.0, process_alive=True),
HealthResult(name="test2", url="http://localhost:8081", status="ok", latency_ms=10.0, process_alive=True),
]
report = format_report(results)
assert "Health Check" in report
assert "test1" in report
assert "test2" in report
assert "DOWN" not in report
def test_with_down(self):
results = [
HealthResult(name="test1", url="http://localhost:8080", status="ok", latency_ms=5.0),
HealthResult(
name="test2", url="http://localhost:8081",
status="down", error="Connection refused", process_alive=False,
),
]
report = format_report(results)
assert "DOWN" in report
assert "Connection refused" in report
class TestFormatJson:
"""JSON output format."""
def test_valid_json(self):
results = [HealthResult(name="test", url="http://localhost:8080", status="ok", latency_ms=5.0)]
output = format_json(results)
data = json.loads(output)
assert "timestamp" in data
assert "endpoints" in data
assert len(data["endpoints"]) == 1
assert data["endpoints"][0]["name"] == "test"
def test_none_error_serializes(self):
results = [HealthResult(name="test", url="http://localhost:8080", status="ok")]
output = format_json(results)
data = json.loads(output)
assert data["endpoints"][0]["error"] is None