feat(fast-paths): Universeller Fast-Path-Layer -- Steuerbefehle ohne LLM-Roundtrip
Motivation: selbst mit allen bisherigen Proxy-Fixes braucht jede Nachricht mindestens einen vollen LLM-Turn (mehrere Sekunden). Fuer "pause" oder "naechstes Lied" unnoetig langsam -- Alexa & Co. loesen sowas ohne generatives Modell per Intent-Klassifikation in Millisekunden. Neues Hermes-Plugin fast-paths/, haengt am pre_gateway_dispatch-Hook (feuert bei JEDER eingehenden Nachricht auf JEDER Plattform, noch VOR Auth/Pairing und Agent-Dispatch -- gefunden im echten Hermes-Sourcecode, hermes_cli/plugins.py + gateway/run.py). Matcht der normalisierte Text gegen eine Regex, wird der Handler direkt ausgefuehrt und das LLM komplett uebersprungen. Ein File pro Skill unter patterns/ (analog zu ARIAs eigenen fast_patterns), erster Skill: patterns/spotify.py (pause/next/previous/ volume/current, plus "spiel Playlist X auf Geraet Y ab" per Fuzzy-Match ohne LLM fuer den Freitext-Namen). Sicherheit: pre_gateway_dispatch feuert VOR Hermes' eigener Autorisierung -- der Loader prueft deshalb explizit gateway._is_user_authorized() vor jedem Pattern-Treffer, sonst koennte ein nicht autorisierter Absender per Fast-Path an Hermes vorbei Aktionen ausloesen. Handler koennen sich zudem bewusst per Rueckgabe None zurueckziehen (z.B. mehrdeutiger Playlist-Name) und faellen dann normal ans LLM zurueck statt zu raten. Lokal verifiziert (Testharness auf aria-wohnung, Hermes' eigenen _load_directory_module-Lademechanismus nachgebaut, gemockter SpotifyClient): Matching, Sicherheits-Check bei nicht autorisiertem Sender, Fuzzy-Match fuer Playlist+Geraet, und mehrere Regression-Faelle gegen False-Positives bei laengeren/anderen Saetzen -- alle Faelle bestehen. docker-compose.yml: neuer verschachtelter Read-Only-Mount ./fast-paths -> /opt/data/plugins/fast-paths (User-Plugin-Pfad unter $HERMES_HOME). Muss zusaetzlich einmalig in config.yaml unter plugins.enabled aktiviert werden (Hermes-Plugins sind Opt-in) -- siehe README.md und fast-paths/README.md.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""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/<skill>.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)
|
||||
Reference in New Issue
Block a user