"""Gemeinsame Bausteine fuer alle Fast-Path-Skill-Dateien. Jede Skill-Datei in diesem Ordner (spotify.py, kuenftig z.B. lights.py) exportiert eine Modul-Variable ``PATTERNS: list[FastPattern]``. Der Loader in ``../__init__.py`` sammelt die automatisch ein -- siehe README.md dort fuer die Anleitung, wie man einen neuen Fast-Path ergaenzt. Wichtig fuer Handler-Autoren: def handler(match: re.Match, event: MessageEvent, gateway) -> str | None: ... - Rueckgabe ``str`` (auch nicht-leer) -> wird 1:1 als Antwort an den User geschickt, das LLM wird fuer diese Nachricht komplett uebersprungen. - Rueckgabe ``""`` (leerer String) -> Aktion wurde ausgefuehrt, aber bewusst OHNE Text-Antwort. LLM wird trotzdem uebersprungen. - Rueckgabe ``None`` -> Pattern hat zwar auf den Text gematcht, der Handler ist sich aber nicht sicher genug (z.B. Playlist- Name mehrdeutig, Geraet nicht gefunden) -- NICHT raten, sondern exakt wie "kein Match" behandeln und die Nachricht normal ans LLM durchreichen. Das ist das Sicherheitsnetz, das Fast-Paths ueberhaupt erst gefahrlos macht. - Eine Exception im Handler wird vom Loader abgefangen, geloggt, und faellt ebenfalls zurueck auf die normale LLM-Verarbeitung -- ein Bug in einem Fast-Path darf NIE eine Nachricht komplett verschlucken. Bekannter Trade-off (bewusst in Kauf genommen, siehe Root-Cause-Historie im Hermes-Projekt): ``pre_gateway_dispatch`` wird synchron aufgerufen (kein await moeglich, siehe hermes_cli/plugins.py PluginManager.invoke_hook). Handler duerfen daher ganz normal synchrone/blockierende Netzwerk-Calls machen (z.B. ueber httpx.request wie SpotifyClient das tut) -- das blockiert den Event-Loop kurz (typ. 100-500ms fuer einen API-Call), ist aber IMMER NOCH um Groessenordnungen schneller als der LLM-Roundtrip (mehrere Sekunden bis >20s, siehe Hermes-Projekt-Historie). Fuer eine Antwort an den User (adapter.send(), das IST async) siehe ``send_reply()`` unten. """ from __future__ import annotations import asyncio import logging import re from dataclasses import dataclass, field from typing import Callable, Optional logger = logging.getLogger("hermes_plugins.fast_paths") @dataclass class FastPattern: """Eine Fast-Path-Regel: eine anchored Regex -> ein Handler.""" name: str regex: "re.Pattern[str]" handler: Callable[["re.Match[str]", object, object], Optional[str]] # Wird vom Loader in ../__init__.py automatisch gesetzt (Dateiname ohne # .py) -- nicht manuell befuellen, nur fuer Logging/Debugging gedacht. skill: str = field(default="", compare=False) def pattern(name: str, regex: str, handler: Callable) -> FastPattern: """Komfort-Konstruktor: kompiliert die Regex case-insensitive. Regeln fuers Regex-Schreiben (siehe ARIAs eigene fast_patterns-Konvention, 1:1 uebernommen): - IMMER mit ^ und $ anchorn -- sonst matcht "pause" auch mitten in "pause die musik dann erzaehl mir einen witz" und reisst den Rest weg. - Mehrere Formulierungen/Synonyme + Fuellwoerter ueber Alternativen und optionale Gruppen abdecken, nicht nur die eine Formulierung. - Der eingehende Text wird VOR dem Matchen normalisiert (lowercase, Mehrfach-Leerzeichen zusammengefasst, Satzzeichen am Ende entfernt) -- siehe _normalize() in ../__init__.py. Regexe koennen daher lowercase geschrieben werden und muessen kein trailendes [.!?]* haben. """ return FastPattern(name=name, regex=re.compile(regex, re.IGNORECASE), handler=handler) def send_reply(gateway, event, text: str) -> None: """Schickt eine Antwort direkt ueber den Platform-Adapter -- OHNE LLM. Muss aus einem SYNCHRONEN pre_gateway_dispatch-Hook heraus funktionieren (PluginManager.invoke_hook awaited seine Callbacks nicht). Wir sind aber innerhalb eines laufenden Event-Loops (der Hook wird von der async GatewayRunner._handle_message() aufgerufen), also reicht ein Fire-and-forget create_task(), um adapter.send() (das IST async) trotzdem loszuschicken. """ if not text: return try: adapter = gateway._adapter_for_source(event.source) except Exception: logger.exception("fast_paths: _adapter_for_source() fehlgeschlagen") return if adapter is None: logger.warning("fast_paths: kein Adapter fuer diese Quelle gefunden -- Antwort verworfen: %r", text) return try: loop = asyncio.get_running_loop() except RuntimeError: logger.warning("fast_paths: kein laufender Event-Loop -- Antwort verworfen: %r", text) return async def _send() -> None: try: await adapter.send(event.source.chat_id, text) except Exception: logger.exception("fast_paths: adapter.send() fehlgeschlagen") loop.create_task(_send())