Compare commits
1 Commits
fix/706
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acfd3e870 |
140
gateway/builtin_hooks/crisis.py
Normal file
140
gateway/builtin_hooks/crisis.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Built-in crisis hook — log and notify humans when a crisis is detected.
|
||||
|
||||
Listens for ``agent:crisis`` events and:
|
||||
|
||||
1. Appends a structured entry to ``~/.hermes/logs/crisis.log``
|
||||
2. Sends a Telegram notification to the configured home channel (or the
|
||||
channel set via ``TELEGRAM_CRISIS_CHAT_ID``).
|
||||
|
||||
Event context keys consumed:
|
||||
|
||||
session_id -- The active session identifier.
|
||||
crisis_level -- Severity label (e.g. "high", "critical", "unknown").
|
||||
platform -- The messaging platform that triggered the session.
|
||||
user_id -- User identifier on that platform.
|
||||
message -- Snippet of the incoming message (may be absent).
|
||||
summary -- Short description of the crisis (may be absent).
|
||||
|
||||
Both notification steps are best-effort: failures are logged but never
|
||||
raise, so they cannot block the main gateway pipeline.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("hooks.crisis")
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
HERMES_HOME = get_hermes_home()
|
||||
CRISIS_LOG_FILE = HERMES_HOME / "logs" / "crisis.log"
|
||||
|
||||
|
||||
def _log_crisis_to_file(context: dict) -> None:
|
||||
"""Append a JSON-lines entry to the crisis log file."""
|
||||
try:
|
||||
CRISIS_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"session_id": context.get("session_id", "unknown"),
|
||||
"crisis_level": context.get("crisis_level", "unknown"),
|
||||
"platform": context.get("platform", ""),
|
||||
"user_id": context.get("user_id", ""),
|
||||
"message": context.get("message", ""),
|
||||
"summary": context.get("summary", ""),
|
||||
}
|
||||
with CRISIS_LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
logger.info(
|
||||
"Crisis logged: session=%s level=%s",
|
||||
entry["session_id"],
|
||||
entry["crisis_level"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to write crisis log: %s", e)
|
||||
|
||||
|
||||
def _build_telegram_message(context: dict) -> str:
|
||||
"""Format a Telegram alert message from the crisis context."""
|
||||
session_id = context.get("session_id", "unknown")
|
||||
crisis_level = context.get("crisis_level", "unknown").upper()
|
||||
platform = context.get("platform", "unknown")
|
||||
user_id = context.get("user_id", "")
|
||||
summary = context.get("summary", "")
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
lines = [
|
||||
f"🚨 *CRISIS ALERT* — Level: {crisis_level}",
|
||||
f"Time: {timestamp}",
|
||||
f"Session: `{session_id}`",
|
||||
f"Platform: {platform}",
|
||||
]
|
||||
if user_id:
|
||||
lines.append(f"User: {user_id}")
|
||||
if summary:
|
||||
lines.append(f"Summary: {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _notify_telegram(context: dict) -> None:
|
||||
"""Send a Telegram alert for the crisis event."""
|
||||
try:
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
if not token:
|
||||
logger.warning("TELEGRAM_BOT_TOKEN not set — skipping crisis notification")
|
||||
return
|
||||
|
||||
# Prefer explicit crisis chat ID, fall back to gateway home channel.
|
||||
chat_id = os.getenv("TELEGRAM_CRISIS_CHAT_ID", "")
|
||||
if not chat_id:
|
||||
try:
|
||||
from gateway.config import load_gateway_config, Platform
|
||||
config = load_gateway_config()
|
||||
home = config.get_home_channel(Platform.TELEGRAM)
|
||||
if home:
|
||||
chat_id = home.chat_id
|
||||
except Exception as e:
|
||||
logger.debug("Could not load gateway config for crisis hook: %s", e)
|
||||
|
||||
if not chat_id:
|
||||
logger.warning(
|
||||
"No Telegram target configured — set TELEGRAM_CRISIS_CHAT_ID or "
|
||||
"configure a Telegram home channel. Skipping crisis notification."
|
||||
)
|
||||
return
|
||||
|
||||
from telegram import Bot
|
||||
from telegram.constants import ParseMode
|
||||
|
||||
message = _build_telegram_message(context)
|
||||
bot = Bot(token=token)
|
||||
await bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=message,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
logger.info(
|
||||
"Crisis Telegram notification sent to chat_id=%s (session=%s level=%s)",
|
||||
chat_id,
|
||||
context.get("session_id", "unknown"),
|
||||
context.get("crisis_level", "unknown"),
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"python-telegram-bot not installed — skipping Telegram crisis notification"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send Telegram crisis notification: %s", e)
|
||||
|
||||
|
||||
async def handle(event_type: str, context: dict) -> None:
|
||||
"""Handle agent:crisis events — log to file and notify via Telegram."""
|
||||
_log_crisis_to_file(context)
|
||||
try:
|
||||
await _notify_telegram(context)
|
||||
except Exception as e:
|
||||
logger.error("Crisis notification failed unexpectedly: %s", e)
|
||||
@@ -14,6 +14,7 @@ Events:
|
||||
- agent:start -- Agent begins processing a message
|
||||
- agent:step -- Each turn in the tool-calling loop
|
||||
- agent:end -- Agent finishes processing
|
||||
- agent:crisis -- Crisis detected during a session (builtin: logs + Telegram alert)
|
||||
- command:* -- Any slash command executed (wildcard match)
|
||||
|
||||
Errors in hooks are caught and logged but never block the main pipeline.
|
||||
@@ -66,6 +67,19 @@ class HookRegistry:
|
||||
except Exception as e:
|
||||
print(f"[hooks] Could not load built-in boot-md hook: {e}", flush=True)
|
||||
|
||||
try:
|
||||
from gateway.builtin_hooks.crisis import handle as crisis_handle
|
||||
|
||||
self._handlers.setdefault("agent:crisis", []).append(crisis_handle)
|
||||
self._loaded_hooks.append({
|
||||
"name": "crisis",
|
||||
"description": "Log crisis events to file and send Telegram notification",
|
||||
"events": ["agent:crisis"],
|
||||
"path": "(builtin)",
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[hooks] Could not load built-in crisis hook: {e}", flush=True)
|
||||
|
||||
def discover_and_load(self) -> None:
|
||||
"""
|
||||
Scan the hooks directory for hook directories and load their handlers.
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multilingual Crisis Detection Patterns.
|
||||
|
||||
Extends the English crisis detector with patterns for:
|
||||
Spanish, French, German, Portuguese, Chinese, Japanese
|
||||
|
||||
Usage:
|
||||
from multilingual_crisis import detect_multilingual, MULTILINGUAL_CRISIS_PATTERNS
|
||||
result = detect_multilingual(user_message)
|
||||
if result["crisis_detected"]:
|
||||
# Route to crisis protocol
|
||||
pass
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
MULTILINGUAL_CRISIS_PATTERNS = {
|
||||
|
||||
# Spanish (es)
|
||||
"direct_suicidal_es": {
|
||||
"patterns": [
|
||||
r"(?:quiero|necesito|voy a|pensando en|planeando)\s+(?:morir|suicidarme|matarme|acabar con todo|quitarme la vida)",
|
||||
r"suicid(?:io|arme|arse|al)",
|
||||
r"(?:quiero|necesito|ojal[aá])\s+(?:estar|ser|quedarme)\s+muerto",
|
||||
r"(?:no|ya no)\s+(?:quiero|puedo|soporto)\s+(?:vivir|seguir|estar aqu[ií]|existir)",
|
||||
r"(?:acabar|terminar)\s+(?:con\s+)?(?:todo|mi\s+vida|conmigo)",
|
||||
r"(?:nadie)\s+(?:echar[ií]a|extra[nñ]ar[ií]a|notar[ií]a|importar[ií]a)\s+(?:de\s+)?(?:menos|mi\s+falta)",
|
||||
r"(?:mi|la)\s+vida\s+(?:no\s+)?(?:vale|sirve|tiene\s+sentido)",
|
||||
r"(?:ser[ií]a|estar[ií]a)\s+(?:mejor|todos\s+estar[ií]an\s+mejor)\s+(?:si\s+)?(?:no\s+estuviera|sin\s+mi|muerto)",
|
||||
],
|
||||
"weight": 1.0, "lang": "es", "description": "Spanish: direct suicidal ideation"
|
||||
},
|
||||
"method_seeking_es": {
|
||||
"patterns": [
|
||||
r"(?:c[oó]mo|qu[eé]\s+(?:forma|manera)|mejor\s+forma)\s+(?:de\s+)?(?:morir|suicidarme|matarme|acabar)",
|
||||
r"(?:dosis|cantidad)\s+(?:letal|fatal|mortal|letales)",
|
||||
r"(?:pastillas|medicamentos?|f[aá]rmacos?)\s+(?:para\s+)?(?:morir|suicidarme|matarme)",
|
||||
r"cort(?:arme|ar)\s+(?:las\s+)?(?:venas|muñecas|garganta)",
|
||||
r"(?:ahorcarme|colgarme|saltar|tirarme)\s+(?:de|del|desde)?",
|
||||
r"(?:forma|manera|m[eé]todo)\s+(?:indolora|r[aá]pida|sin\s+dolor)\s+(?:de\s+)?(?:morir|acabar)",
|
||||
],
|
||||
"weight": 1.0, "lang": "es", "description": "Spanish: method seeking"
|
||||
},
|
||||
"despair_es": {
|
||||
"patterns": [
|
||||
r"(?:no|ya\s+no)\s+(?:puedo|soporto|aguanto|resisto)\s+m[aá]s",
|
||||
r"(?:estoy|me\s+siento)\s+(?:perdido|solo|vac[ií]o|sin\s+esperanza|agotado)",
|
||||
r"(?:no\s+tiene|no\s+hay)\s+(?:sentido|soluci[oó]n|salida|esperanza)",
|
||||
r"para\s+(?:qu[eé]|qu[iíén])\s+(?:si|vivo|me\s+esfuerzo)",
|
||||
r"(?:soy|me\s+siento\s+como)\s+(?:una\s+)?(?:carga|peso|estorbo|in[uú]til)",
|
||||
],
|
||||
"weight": 0.5, "lang": "es", "description": "Spanish: despair indicators"
|
||||
},
|
||||
|
||||
# French (fr)
|
||||
"direct_suicidal_fr": {
|
||||
"patterns": [
|
||||
r"(?:je\s+(?:veux|vais|pense\s+[àa]|planifie)|j'ai\s+envie\s+de)\s+(?:mourir|me\s+suicider|me\s+tuer|en\s+finir)",
|
||||
r"suicid(?:e|er|aire)",
|
||||
r"(?:je\s+ne\s+)?(?:veux|peux|supporte)\s+(?:plus\s+)?(?:vivre|exister|continuer)",
|
||||
r"(?:en|j'en)\s+finir\s+(?:avec\s+)?(?:tout|la\s+vie|moi|m[eê]me|[çc]a)",
|
||||
r"(?:personne|nul\s+ne|aucun)\s+(?:ne\s+)?(?:me\s+)?(?:manquera|regretterait|remarquerait)",
|
||||
r"(?:ma|cette)\s+vie\s+(?:ne\s+vaut|n'a\s+(?:pas\s+)?de\s+sens|est\s+(?:finie|inutile))",
|
||||
r"(?:tout\s+le\s+monde|on)\s+(?:serait|irait)\s+(?:mieux|bien)\s+(?:sans\s+moi)",
|
||||
],
|
||||
"weight": 1.0, "lang": "fr", "description": "French: direct suicidal ideation"
|
||||
},
|
||||
"method_seeking_fr": {
|
||||
"patterns": [
|
||||
r"(?:comment|quel(le)?\s+(?:est\s+le\s+)?(?:meilleur|moyen))\s+(?:de\s+)?(?:mourir|se\s+suicider|se\s+tuer|en\s+finir)",
|
||||
r"(?:dose|quantit[eé])\s+(?:l[eé]tale?|fatale?|mortelle?)",
|
||||
r"(?:comprim[eé]s?|pilules?|m[eé]dicaments?)\s+(?:pour\s+)?(?:mourir|se\s+tuer|overdose)",
|
||||
r"(?:se\s+)?couper\s+(?:les\s+)?(?:veines|poignets|gorge)",
|
||||
r"(?:se\s+)?pendre|se\s+(?:jeter|lancer)\s+(?:du|de\s+la|dans)",
|
||||
r"(?:moyen|fa[cç]on|mani[eè]re)\s+(?:indolore|rapide|sans\s+douleur)\s+(?:de\s+)?(?:mourir|en\s+finir)",
|
||||
],
|
||||
"weight": 1.0, "lang": "fr", "description": "French: method seeking"
|
||||
},
|
||||
"despair_fr": {
|
||||
"patterns": [
|
||||
r"(?:je\s+ne\s+)?(?:peux|supporte|arrive\s+[àa])\s+(?:plus\s+)?(?:continuer|tenir|durer|avancer)",
|
||||
r"(?:je\s+suis|je\s+me\s+sens)\s+(?:perdu|seul|vide|sans\s+espoir|au\s+bout)",
|
||||
r"(?:il\s+n'y\s+a|y\s+a\s+(?:pas\s+)?(?:de\s+)?)?(?:plus\s+)?(?:d'?espoir|de\s+solution|d'issue|de\s+sens)",
|
||||
r"(?:je\s+suis|c'est)\s+(?:un\s+)?(?:fardeau|poids|inutile|nul)",
|
||||
r"(?:pourquoi|[àa]\s+quoi\s+bon|pour\s+qui)\s+(?:je\s+)?(?:vis|m'efforce|continue)",
|
||||
],
|
||||
"weight": 0.5, "lang": "fr", "description": "French: despair indicators"
|
||||
},
|
||||
|
||||
# German (de)
|
||||
"direct_suicidal_de": {
|
||||
"patterns": [
|
||||
r"(?:ich\s+(?:will|möchte|denke\s+(?:über|an)|plane))\s+(?:sterben|suizid|mich\s+(?:umbringen|töten))",
|
||||
r"suizid|selbstmord",
|
||||
r"(?:ich\s+(?:will|möchte)\s+(?:nicht|mehr\s+nicht))\s+(?:leben|weiterleben|existieren|dasein)",
|
||||
r"(?:mit\s+)?(?:allem|dem\s+Leben|mir\s+selbst)\s+(?:aufhören|Schluss|fertig)\s+(?:sein|machen)",
|
||||
r"(?:niemand|keiner)\s+(?:würde|wird)\s+(?:mich\s+)?(?:vermissen|bemerken|verlieren)",
|
||||
r"(?:mein|dieses)\s+Leben\s+(?:hat\s+(?:keinen\s+)?Sinn|ist\s+(?:sinnlos|vorbei|fertig))",
|
||||
r"(?:allen|jedem)\s+(?:wäre|ginge|ging)\s+es\s+besser\s+(?:ohne\s+mich|wenn\s+ich\s+nicht\s+wäre)",
|
||||
],
|
||||
"weight": 1.0, "lang": "de", "description": "German: direct suicidal ideation"
|
||||
},
|
||||
"method_seeking_de": {
|
||||
"patterns": [
|
||||
r"(?:wie|welcher|beste)\s+(?:kann\s+ich|möglichkeit)\s+(?:mich\s+)?(?:umbringen|töten|sterben|suizid)",
|
||||
r"(?:tödliche|letale|verhängnisvolle)\s+(?:Dosis|Menge)",
|
||||
r"(?:Tabletten?|Medikamente?|Pillen?)\s+(?:um\s+)?(?:zu\s+)?(?:sterben|suizid|überdosis)",
|
||||
r"(?:sich\s+)?(?:die\s+)?(?:Pulsadern|Kehle|Handgelenke?)\s+(?:aufschneiden|durchschneiden)",
|
||||
r"(?:sich\s+)?(?:erhängen|aufhängen|vor\s+(?:einen\s+)?Zug\s+werfen|springen)",
|
||||
r"(?:schmerzlose?|schnelle?)\s+(?:Art|Weise|Methode)\s+(?:zu\s+)?(?:sterben|suizid)",
|
||||
],
|
||||
"weight": 1.0, "lang": "de", "description": "German: method seeking"
|
||||
},
|
||||
"despair_de": {
|
||||
"patterns": [
|
||||
r"(?:ich\s+(?:kann|schaffe|halte)\s+(?:es\s+)?(?:nicht\s+)?(?:mehr|weiter|länger))",
|
||||
r"(?:ich\s+(?:bin|fühle\s+mich)\s+)?(?:verloren|einsam|leer|hoffnungslos|am\s+Ende)",
|
||||
r"(?:es\s+gibt|es\s+hat)\s+(?:keine\s+)?(?:Hoffnung|Lösung|Auskunft|Sinn)",
|
||||
r"(?:ich\s+bin|bin\s+ich)\s+(?:eine\s+)?(?:Belastung|Last|nutzlos|wertlos)",
|
||||
r"(?:warum|wozu|für\s+wen)\s+(?:lebe|soll|mache)\s+(?:ich\s+)?(?:überhaupt|noch|weiter)",
|
||||
],
|
||||
"weight": 0.5, "lang": "de", "description": "German: despair indicators"
|
||||
},
|
||||
|
||||
# Portuguese (pt)
|
||||
"direct_suicidal_pt": {
|
||||
"patterns": [
|
||||
r"(?:eu\s+(?:quero|preciso|vou|estou\s+pensando\s+em)|penso\s+em)\s+(?:morrer|me\s+matar|suicid[ai]r|acabar\s+com\s+tudo)",
|
||||
r"suic[ií]dio",
|
||||
r"(?:eu\s+)?(?:não\s+)?(?:quero|aguento|suporto|consigo)\s+(?:mais\s+)?(?:viver|existir|continuar|estar\s+aqui)",
|
||||
r"(?:acabar|terminar|dar\s+fim)\s+(?:com\s+)?(?:tudo|a\s+minha\s+vida|com\s+isso|com\s+tudo)",
|
||||
r"(?:ningu[eé]m)\s+(?:vai|iria)\s+(?:sentir\s+falta|notar|ligar|se\s+importar)",
|
||||
r"(?:minha|esta)\s+vida\s+(?:não\s+)?(?:vale|faz\s+sentido|tem\s+sentido)",
|
||||
r"(?:todo\s+mundo|todos)\s+(?:seria|estaria|ficaria)\s+(?:melhor|bem)\s+(?:sem\s+mim|se\s+eu\s+fosse)",
|
||||
],
|
||||
"weight": 1.0, "lang": "pt", "description": "Portuguese: direct suicidal ideation"
|
||||
},
|
||||
"method_seeking_pt": {
|
||||
"patterns": [
|
||||
r"(?:como|qual|melhor\s+forma)\s+(?:de\s+)?(?:morrer|me\s+matar|suicid[ai]r|acabar)",
|
||||
r"(?:dose|quantidade)\s+(?:letal|fatal|mortal)",
|
||||
r"(?:comprimidos?|remédios?|medicamentos?)\s+(?:para\s+)?(?:morrer|me\s+matar|overdose)",
|
||||
r"cort(?:ar|ando)\s+(?:os|meus)?\s+(?:pulsos|veias|garganta)",
|
||||
r"(?:enforcar|enforcando|pendur[ae]r|pular)\s+(?:me|de|do|da)?",
|
||||
r"(?:forma|maneira|método)\s+(?:indolor|rápido|sem\s+dor)\s+(?:de\s+)?(?:morrer|acabar)",
|
||||
],
|
||||
"weight": 1.0, "lang": "pt", "description": "Portuguese: method seeking"
|
||||
},
|
||||
|
||||
# Chinese (zh) - character-based, no word boundaries
|
||||
"direct_suicidal_zh": {
|
||||
"patterns": [
|
||||
r"(?:想|想要|打算|计划|准备)(?:去)?(?:死|自杀|结束(?:自己的)?(?:生命|一切))",
|
||||
r"(?:不想|不再|无法|不能)(?:活|活下去|继续活|生存)(?:了|下去)?",
|
||||
r"(?:结束|终结)(?:自己|生命|一切|所有|全部)",
|
||||
r"(?:没有|毫无|失去)(?:意义|希望|活下去的(?:理由|动力|勇气))",
|
||||
r"(?:死了|不在了|消失了)(?:大家|所有人)(?:会|就)(?:更好|开心|轻松)",
|
||||
r"(?:没有人|谁)(?:会)(?:在乎|在意|想|怀念)(?:我|我的)",
|
||||
r"自杀",
|
||||
],
|
||||
"weight": 1.0, "lang": "zh", "description": "Chinese: direct suicidal ideation"
|
||||
},
|
||||
"method_seeking_zh": {
|
||||
"patterns": [
|
||||
r"(?:怎么|怎样|什么(?:方法|方式))(?:才能|可以|去)?(?:死|自杀|结束(?:自己|生命))",
|
||||
r"(?:致死|致命|致亡)(?:剂量|量|浓度|含量)",
|
||||
r"(?:安眠药|药物|药片|毒药)(?:吃|服用|用)(?:多少|几|怎么)(?:才能|可以|会)?(?:死|自杀|致命)",
|
||||
r"(?:割|切)(?:腕|脉|喉|脖子|血管)",
|
||||
r"(?:上吊|跳楼|跳河|跳桥|跳崖|撞车)",
|
||||
r"(?:无痛|最快|最简单)(?:的)?(?:死法|方法|方式)(?:是|用来)?",
|
||||
],
|
||||
"weight": 1.0, "lang": "zh", "description": "Chinese: method seeking"
|
||||
},
|
||||
"despair_zh": {
|
||||
"patterns": [
|
||||
r"(?:撑|坚持|忍受|活)(?:不|没有|无法)(?:下去|了|再)",
|
||||
r"(?:迷茫|孤独|空虚|绝望|崩溃|痛苦|无助)(?:了|到了|到)?",
|
||||
r"(?:没有|失去|看不到)(?:希望|出路|未来|意义|目的)",
|
||||
r"(?:我是|我是一个)(?:负担|累赘|废物|没用的人)",
|
||||
r"(?:活着|生存)(?:为了什么|有什么意义|有什么用|有什么意思)",
|
||||
],
|
||||
"weight": 0.5, "lang": "zh", "description": "Chinese: despair indicators"
|
||||
},
|
||||
|
||||
# Japanese (ja)
|
||||
"direct_suicidal_ja": {
|
||||
"patterns": [
|
||||
r"死にたい|死のう|自殺したい|自殺する",
|
||||
r"(?:もう|これ以上)(?:生きて|生きる|存在して)(?:い(?:たい|る)|行(?:きたい|く))(?:く|け)(?:ない|たくない)?",
|
||||
r"(?:すべて|全部|人生|この(?:まま|こと))(?:を)?(?:終わり|終え|やめ)(?:たい|よう|る)",
|
||||
r"(?:誰も|だれも)(?:気づ|気付|構い|構って|思っ)(?:て(?:くれ|い)ない|てくれ(?:ない))",
|
||||
r"(?:僕|俺|私|わたし)(?:が|は)(?:い(?:ない|なくなって)|消(?:え|えても))(?:も|たら)(?:皆|みんな|周囲)(?:は)?(?:良(?:い|く)|楽(?:に))(?:なる|なった)",
|
||||
r"(?:この|今の)(?:僕|俺|私|わたし)(?:の)?(?:人生|命|存在)(?:は)?(?:意味|価値|甲斐)(?:が)?(?:ない|無い)",
|
||||
],
|
||||
"weight": 1.0, "lang": "ja", "description": "Japanese: direct suicidal ideation"
|
||||
},
|
||||
"method_seeking_ja": {
|
||||
"patterns": [
|
||||
r"(?:どう|どんな|どの(?:よう|様)?に)(?:すれば|やれば|して)(?:死|自殺|亡くな)(?:れる|りたい|る)",
|
||||
r"(?:致死|致命)(?:量|的(?:な)?(?:量|ドーズ|用量))",
|
||||
r"(?:睡眠薬|薬|ピル|毒)(?:を)?(?:何|いくつ|どのくらい)(?:飲|摂|使)(?:め|んだら|えば)(?:死|亡くな)(?:れる|る)",
|
||||
r"(?:手首|喉|首筋|血管)(?:を)?(?:切|斬|傷つ)(?:る|け|って)",
|
||||
r"(?:縊|首吊|飛び降|投身|飛び降り)(?:り|て|死の)",
|
||||
r"(?:苦痛|痛み)(?:の)?(?:ない|少ない)(?:方法|やり方|死に方)(?:で|は)?",
|
||||
],
|
||||
"weight": 1.0, "lang": "ja", "description": "Japanese: method seeking"
|
||||
},
|
||||
"despair_ja": {
|
||||
"patterns": [
|
||||
r"(?:もう|これ以上|これ以上は)(?:無理|限界|耐え|がんば|頑張)(?:だ|だよ|れない|りきれない)",
|
||||
r"(?:孤独|寂し|虚し|絶望|疲(?:れ|労))(?:い|く|き|さ|た|すぎて)",
|
||||
r"(?:希望|未来|生き(?:甲斐|がい)|意味|目的)(?:が|は)?(?:ない|無い|見え|見つから|失(?:くし|われ))",
|
||||
r"(?:僕|俺|私|わたし)(?:は|が)?(?:邪魔|迷惑|足手まとい|重荷|役に立たない)",
|
||||
r"(?:生き(?:る|ている)|存在(?:する|している))(?:の(?:に)?|こと(?:に)?)(?:は)?(?:何の|なんの|どんな)(?:ため|意味|価値)",
|
||||
],
|
||||
"weight": 0.5, "lang": "ja", "description": "Japanese: despair indicators"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _check_multilingual(text):
|
||||
"""Check text against all multilingual crisis patterns."""
|
||||
matches = []
|
||||
languages = set()
|
||||
total_score = 0.0
|
||||
max_possible = 0.0
|
||||
|
||||
for category, config in MULTILINGUAL_CRISIS_PATTERNS.items():
|
||||
weight = config.get("weight", 1.0)
|
||||
lang = config.get("lang", "unknown")
|
||||
max_possible += weight
|
||||
|
||||
for pattern in config["patterns"]:
|
||||
try:
|
||||
flags = re.IGNORECASE if lang in ("es", "fr", "de", "pt") else 0
|
||||
if re.search(pattern, text, flags):
|
||||
matches.append({"category": category, "lang": lang, "weight": weight})
|
||||
total_score += weight
|
||||
languages.add(lang)
|
||||
break
|
||||
except re.error:
|
||||
pass
|
||||
|
||||
score = total_score / max_possible if max_possible > 0 else 0.0
|
||||
return {
|
||||
"crisis_detected": score >= 0.05,
|
||||
"score": round(score, 3),
|
||||
"matches": matches,
|
||||
"languages": sorted(languages),
|
||||
}
|
||||
|
||||
|
||||
def detect_multilingual(text):
|
||||
"""Detect crisis signals in any supported language."""
|
||||
result = _check_multilingual(text)
|
||||
if result["matches"]:
|
||||
result["highest_category"] = max(result["matches"], key=lambda m: m["weight"])["category"]
|
||||
else:
|
||||
result["highest_category"] = None
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
("quiero morir", True, "es"), ("je veux me suicider", True, "fr"),
|
||||
("ich will nicht mehr leben", True, "de"), ("eu quero me matar", True, "pt"),
|
||||
("我想死", True, "zh"), ("死にたい", True, "ja"),
|
||||
("no puedo mas, quiero acabar con todo", True, "es"),
|
||||
("personne ne me manquera", True, "fr"), ("dosis letal", True, "es"),
|
||||
("怎么自杀", True, "zh"), ("en finir avec tout", True, "fr"),
|
||||
("hola, como estas?", False, None), ("je suis fatigue", False, None),
|
||||
("今天的天气不错", False, None), ("おはようございます", False, None),
|
||||
]
|
||||
passed = sum(1 for text, should, _ in tests if detect_multilingual(text)["crisis_detected"] == should)
|
||||
print(f"{passed}/{len(tests)} passed")
|
||||
249
tests/gateway/test_crisis_hook.py
Normal file
249
tests/gateway/test_crisis_hook.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Tests for gateway/builtin_hooks/crisis.py — crisis notification hook."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_context(**kwargs):
|
||||
base = {
|
||||
"session_id": "sess-abc123",
|
||||
"crisis_level": "high",
|
||||
"platform": "telegram",
|
||||
"user_id": "user-42",
|
||||
"message": "I need help urgently",
|
||||
"summary": "User expressed distress",
|
||||
}
|
||||
base.update(kwargs)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _log_crisis_to_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLogCrisisToFile:
|
||||
def test_creates_log_file_and_entry(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
from gateway.builtin_hooks.crisis import _log_crisis_to_file
|
||||
_log_crisis_to_file(_make_context())
|
||||
|
||||
assert log_file.exists()
|
||||
entry = json.loads(log_file.read_text().strip())
|
||||
assert entry["session_id"] == "sess-abc123"
|
||||
assert entry["crisis_level"] == "high"
|
||||
assert entry["platform"] == "telegram"
|
||||
assert entry["user_id"] == "user-42"
|
||||
assert "timestamp" in entry
|
||||
|
||||
def test_appends_multiple_entries(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
from gateway.builtin_hooks.crisis import _log_crisis_to_file
|
||||
_log_crisis_to_file(_make_context(session_id="sess-1"))
|
||||
_log_crisis_to_file(_make_context(session_id="sess-2"))
|
||||
|
||||
lines = log_file.read_text().strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0])["session_id"] == "sess-1"
|
||||
assert json.loads(lines[1])["session_id"] == "sess-2"
|
||||
|
||||
def test_missing_optional_fields_use_defaults(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
from gateway.builtin_hooks.crisis import _log_crisis_to_file
|
||||
_log_crisis_to_file({}) # empty context
|
||||
|
||||
entry = json.loads(log_file.read_text().strip())
|
||||
assert entry["session_id"] == "unknown"
|
||||
assert entry["crisis_level"] == "unknown"
|
||||
|
||||
def test_tolerates_write_error(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
with patch("builtins.open", side_effect=OSError("disk full")):
|
||||
from gateway.builtin_hooks.crisis import _log_crisis_to_file
|
||||
# Should not raise
|
||||
_log_crisis_to_file(_make_context())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_telegram_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildTelegramMessage:
|
||||
def test_includes_session_id_and_level(self):
|
||||
from gateway.builtin_hooks.crisis import _build_telegram_message
|
||||
msg = _build_telegram_message(_make_context(
|
||||
session_id="sess-xyz",
|
||||
crisis_level="critical",
|
||||
))
|
||||
assert "sess-xyz" in msg
|
||||
assert "CRITICAL" in msg
|
||||
|
||||
def test_includes_summary_when_present(self):
|
||||
from gateway.builtin_hooks.crisis import _build_telegram_message
|
||||
msg = _build_telegram_message(_make_context(summary="User in danger"))
|
||||
assert "User in danger" in msg
|
||||
|
||||
def test_omits_summary_when_absent(self):
|
||||
from gateway.builtin_hooks.crisis import _build_telegram_message
|
||||
ctx = _make_context()
|
||||
ctx.pop("summary")
|
||||
msg = _build_telegram_message(ctx)
|
||||
assert "Summary" not in msg
|
||||
|
||||
def test_includes_platform(self):
|
||||
from gateway.builtin_hooks.crisis import _build_telegram_message
|
||||
msg = _build_telegram_message(_make_context(platform="discord"))
|
||||
assert "discord" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _notify_telegram
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNotifyTelegram:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_no_token(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with patch("os.getenv", return_value=""):
|
||||
from gateway.builtin_hooks.crisis import _notify_telegram
|
||||
# Should complete without error
|
||||
await _notify_telegram(_make_context())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_message_with_explicit_chat_id(self):
|
||||
mock_bot = AsyncMock()
|
||||
mock_bot_cls = MagicMock(return_value=mock_bot)
|
||||
|
||||
env = {
|
||||
"TELEGRAM_BOT_TOKEN": "test-token",
|
||||
"TELEGRAM_CRISIS_CHAT_ID": "9999",
|
||||
}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("gateway.builtin_hooks.crisis.os.getenv", side_effect=lambda k, d="": env.get(k, d)):
|
||||
with patch("gateway.builtin_hooks.crisis._build_telegram_message", return_value="alert!"):
|
||||
try:
|
||||
from telegram import Bot
|
||||
from telegram.constants import ParseMode
|
||||
with patch("gateway.builtin_hooks.crisis.Bot", mock_bot_cls):
|
||||
from gateway.builtin_hooks.crisis import _notify_telegram
|
||||
await _notify_telegram(_make_context())
|
||||
mock_bot.send_message.assert_awaited_once()
|
||||
call_kwargs = mock_bot.send_message.call_args
|
||||
assert call_kwargs.kwargs["chat_id"] == 9999
|
||||
except ImportError:
|
||||
pytest.skip("python-telegram-bot not installed")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_no_chat_id(self):
|
||||
env = {"TELEGRAM_BOT_TOKEN": "test-token"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("gateway.builtin_hooks.crisis.os.getenv", side_effect=lambda k, d="": env.get(k, d)):
|
||||
with patch("gateway.builtin_hooks.crisis.load_gateway_config", side_effect=Exception("no config"), create=True):
|
||||
from gateway.builtin_hooks.crisis import _notify_telegram
|
||||
# Should complete without error
|
||||
await _notify_telegram(_make_context())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_import_error_gracefully(self):
|
||||
env = {
|
||||
"TELEGRAM_BOT_TOKEN": "test-token",
|
||||
"TELEGRAM_CRISIS_CHAT_ID": "9999",
|
||||
}
|
||||
import sys
|
||||
with patch.dict("os.environ", env):
|
||||
with patch.dict(sys.modules, {"telegram": None}):
|
||||
from gateway.builtin_hooks import crisis as crisis_mod
|
||||
import importlib
|
||||
importlib.reload(crisis_mod)
|
||||
# Should not raise even if telegram is unavailable at runtime
|
||||
await crisis_mod._notify_telegram(_make_context())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle (integration: log + notify together)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHandle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_logs_and_notifies(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
with patch("gateway.builtin_hooks.crisis._notify_telegram", new_callable=AsyncMock) as mock_notify:
|
||||
from gateway.builtin_hooks.crisis import handle
|
||||
await handle("agent:crisis", _make_context())
|
||||
|
||||
# Log written
|
||||
assert log_file.exists()
|
||||
entry = json.loads(log_file.read_text().strip())
|
||||
assert entry["session_id"] == "sess-abc123"
|
||||
|
||||
# Notification attempted
|
||||
mock_notify.assert_awaited_once()
|
||||
call_context = mock_notify.call_args[0][0]
|
||||
assert call_context["session_id"] == "sess-abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_does_not_raise_on_notify_failure(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
with patch(
|
||||
"gateway.builtin_hooks.crisis._notify_telegram",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("network down"),
|
||||
):
|
||||
from gateway.builtin_hooks.crisis import handle
|
||||
# Should not propagate the exception
|
||||
await handle("agent:crisis", _make_context())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HookRegistry: crisis hook is registered as a builtin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrisisHookRegistration:
|
||||
def test_crisis_hook_registered_in_registry(self):
|
||||
from gateway.hooks import HookRegistry
|
||||
|
||||
reg = HookRegistry()
|
||||
reg.discover_and_load()
|
||||
|
||||
names = [h["name"] for h in reg.loaded_hooks]
|
||||
assert "crisis" in names
|
||||
|
||||
def test_crisis_handler_registered_for_event(self):
|
||||
from gateway.hooks import HookRegistry
|
||||
|
||||
reg = HookRegistry()
|
||||
reg.discover_and_load()
|
||||
|
||||
assert "agent:crisis" in reg._handlers
|
||||
assert len(reg._handlers["agent:crisis"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_agent_crisis_calls_handler(self, tmp_path):
|
||||
log_file = tmp_path / "logs" / "crisis.log"
|
||||
|
||||
with patch("gateway.builtin_hooks.crisis.CRISIS_LOG_FILE", log_file):
|
||||
with patch("gateway.builtin_hooks.crisis._notify_telegram", new_callable=AsyncMock):
|
||||
from gateway.hooks import HookRegistry
|
||||
reg = HookRegistry()
|
||||
reg.discover_and_load()
|
||||
await reg.emit("agent:crisis", _make_context())
|
||||
|
||||
assert log_file.exists()
|
||||
Reference in New Issue
Block a user