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.
111 lines
4.8 KiB
Python
111 lines
4.8 KiB
Python
"""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())
|