"""fast-paths -- universeller Fast-Path-Layer fuer Hermes. Faengt einfache Steuerbefehle per Regex VOR dem eigentlichen LLM-Aufruf ab und fuehrt sie direkt aus -- kein Claude-Proxy-Roundtrip noetig. Motivation und Architektur-Entscheidung: siehe README.md daneben. Funktionsweise in Kurzform: 1. Hermes' Plugin-System laedt dieses Verzeichnis als kind="standalone"- Plugin (siehe plugin.yaml) und ruft register(ctx) einmal beim Start auf. 2. register() sammelt alle Fast-Path-Regeln aus patterns/*.py ein (ein File pro Skill, z.B. patterns/spotify.py -- neue Skills: einfach eine neue Datei dort reinlegen, siehe patterns/_base.py fuer die Konventionen) und registriert EINEN Callback auf den "pre_gateway_dispatch"-Hook. 3. Dieser Hook feuert bei JEDER eingehenden Nachricht, auf JEDER Plattform (TUI, Telegram, Discord, ...) -- noch VOR Auth/Pairing und noch VOR dem eigentlichen Agent-Dispatch (siehe gateway/run.py _handle_message). Genau der richtige Abfangpunkt: universell fuer alle Plattformen, aber weit genug vorne dass wir dem LLM wirklich zuvorkommen. 4. Matcht der normalisierte Text gegen eine der gesammelten Regeln, wird der zugehoerige Handler synchron aufgerufen. Antwortet der Handler mit einem String, wird der per adapter.send() direkt an den User geschickt (siehe patterns/_base.py send_reply()) und die Nachricht per {"action": "skip"} aus der normalen Pipeline genommen -- das LLM sieht diese Nachricht nie. 5. Matcht nichts, ODER lehnt ein Handler bewusst ab (Rueckgabe None, z.B. weil ein Playlist-Name mehrdeutig war), faellt alles ganz normal auf den LLM-Turn zurueck -- als waere dieses Plugin gar nicht da. Sicherheitshinweis (wichtig, nicht optional): "pre_gateway_dispatch" feuert VOR Hermes' eigener Autorisierungspruefung. Ohne expliziten Check wuerde ein Fast-Path-Treffer also auch fuer NICHT autorisierte Absender ausgefuehrt -- z.B. koennte irgendwer, der Hermes' Telegram-Bot anschreibt, ohne Autorisierung "pause" tippen und tatsaechlich Spotify pausieren. Deshalb prueft der Hook-Callback unten explizit gateway._is_user_authorized(...) BEVOR irgendein Pattern ueberhaupt versucht wird. """ from __future__ import annotations import importlib import logging import re import time from pathlib import Path from typing import Any, List, Optional logger = logging.getLogger("hermes_plugins.fast_paths") _PATTERNS: List[Any] = [] def _load_pattern_modules() -> None: """Importiert jede patterns/.py und sammelt ihre PATTERNS-Liste.""" global _PATTERNS loaded: List[Any] = [] patterns_dir = Path(__file__).resolve().parent / "patterns" for py_file in sorted(patterns_dir.glob("*.py")): mod_name = py_file.stem if mod_name.startswith("_") or mod_name == "__init__": continue # _base.py etc. -- Infrastruktur, kein Skill try: mod = importlib.import_module(f"{__package__}.patterns.{mod_name}") except Exception: logger.exception( "fast_paths: Skill-Datei '%s.py' konnte nicht geladen werden -- uebersprungen", mod_name, ) continue skill_patterns = getattr(mod, "PATTERNS", None) if not skill_patterns: logger.warning("fast_paths: '%s.py' hat keine PATTERNS-Liste -- uebersprungen", mod_name) continue for p in skill_patterns: p.skill = mod_name loaded.append(p) logger.info("fast_paths: %d Pattern(s) aus '%s.py' geladen", len(skill_patterns), mod_name) _PATTERNS = loaded skill_count = len({p.skill for p in _PATTERNS}) logger.info("fast_paths: insgesamt %d Pattern(s) aus %d Skill-Datei(en) aktiv", len(_PATTERNS), skill_count) def _normalize(text: str) -> str: """Wie ARIAs eigene fast_patterns-Konvention: lowercase, Satzzeichen am Ende weg, Mehrfach-Leerzeichen zusammenfassen -- damit die Regexe in den Skill-Dateien einfach bleiben.""" t = (text or "").strip().lower() t = re.sub(r"[.!?]+$", "", t).strip() t = re.sub(r"\s+", " ", t) return t def _on_pre_gateway_dispatch(event=None, gateway=None, session_store=None, **_kw) -> Optional[dict]: if event is None or gateway is None: return None if getattr(event, "internal", False): return None # System-generierte Events (z.B. Background-Notifications) nie fast-pathen text = _normalize(getattr(event, "text", "") or "") if not text: return None # Erst pruefen ob ueberhaupt ein Pattern matcht -- die (minimal teurere) # Autorisierungspruefung erst danach, damit der ganz normale Nachrichten- # Strom (der zu 99% NICHT matcht) keinen zusaetzlichen Call pro Turn zahlt. for fp in _PATTERNS: m = fp.regex.match(text) if not m: continue # SICHERHEITSKRITISCH: pre_gateway_dispatch feuert VOR Hermes' eigener # Auth-Pruefung. Ein Fast-Path-Treffer darf NIEMALS fuer einen nicht # autorisierten Absender ausgefuehrt werden -- sonst koennte z.B. ein # fremder Telegram-User ungefragt "pause" tippen und wirklich Spotify # steuern. Bei fehlender Autorisierung: exakt wie "kein Match" # behandeln, normale Pipeline (inkl. Pairing-Flow) uebernimmt. try: authorized = gateway._is_user_authorized(event.source) except Exception: logger.exception("fast_paths: Autorisierungspruefung fehlgeschlagen -- Pattern wird NICHT ausgefuehrt") return None if not authorized: logger.info( "fast_paths: Pattern '%s' haette gematcht, Absender aber nicht autorisiert -- normale Pipeline uebernimmt", fp.name, ) return None t0 = time.monotonic() try: reply = fp.handler(m, event, gateway) except Exception: logger.exception( "fast_paths: Handler '%s' (skill=%s) hat eine Exception geworfen -- Fallback aufs LLM", fp.name, fp.skill, ) return None elapsed_ms = int((time.monotonic() - t0) * 1000) if reply is None: # Handler ist sich bewusst nicht sicher (z.B. mehrdeutiger # Playlist-Name) -- NICHT raten, normale LLM-Verarbeitung uebernimmt. logger.info( "fast_paths: '%s' (skill=%s) hat gematcht, Handler aber abgelehnt (%dms) -- Fallback aufs LLM", fp.name, fp.skill, elapsed_ms, ) return None logger.info( "fast_paths: '%s' (skill=%s) in %dms erledigt, LLM-Aufruf uebersprungen", fp.name, fp.skill, elapsed_ms, ) if reply: from .patterns._base import send_reply send_reply(gateway, event, reply) return {"action": "skip", "reason": f"fast_path:{fp.skill}:{fp.name}"} return None def register(ctx) -> None: _load_pattern_modules() ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch)