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:
ARIA
2026-07-20 16:17:29 +00:00
parent 128c11b49d
commit 9ea72886dd
8 changed files with 735 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Absichtlich (fast) leer -- macht "patterns/" zu einem regulaeren
# Python-Package, damit __init__.py (eine Ebene hoeher) die einzelnen
# Skill-Dateien hier drin per "from .patterns import <modname>" laden kann.
#
# Neue Fast-Path-Skills: einfach eine neue Datei hier reinlegen (z.B.
# lights.py, heating.py) -- KEIN Eintrag hier noetig, der Loader in
# ../__init__.py findet jede *.py-Datei automatisch (ausser Dateien mit
# fuehrendem Unterstrich wie _base.py, das ist Infrastruktur, kein Skill).
+110
View File
@@ -0,0 +1,110 @@
"""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())
+288
View File
@@ -0,0 +1,288 @@
"""Fast-Path-Skill: Spotify.
Deckt reine Steuerbefehle (pause/weiter/next/previous/lauter/leiser/
Lautstaerke setzen/aktueller Titel) OHNE LLM-Roundtrip ab, plus als
Kuer "spiel Playlist X [auf Geraet Y] ab" per Fuzzy-Match (kein LLM
noetig fuer den Freitext-Playlistnamen -- difflib reicht).
Nutzt bewusst NICHT die injizierten <tool_call>-Tools (die laufen ueber
den Proxy -> Claude -> zurueck, genau der Roundtrip den wir hier
umgehen wollen), sondern importiert Hermes' eigenen SpotifyClient direkt
-- gleiche OAuth-Session, gleicher Auto-Refresh, kein Code doppelt
gepflegt (siehe plugins/spotify/client.py im Hermes-Sourcecode).
Sicherheitsnetz: jeder Handler gibt None zurueck, sobald er sich nicht
sicher genug ist (z.B. Playlist-Name mehrdeutig) -- dann uebernimmt ganz
normal das LLM, es wird NIE geraten.
"""
from __future__ import annotations
import difflib
import logging
from typing import Any, Dict, List, Optional
from ._base import pattern
logger = logging.getLogger("hermes_plugins.fast_paths.spotify")
def _client():
# Lazy-Import: erst bei tatsaechlichem Bedarf, damit ein Import-Fehler
# hier (z.B. Spotify-Plugin bei einem Hermes-Update umbenannt) nicht das
# Laden ALLER Fast-Paths verhindert -- siehe ../__init__.py, der jede
# Skill-Datei einzeln try/except importiert.
from plugins.spotify.client import SpotifyClient
return SpotifyClient()
def _friendly_error(exc: Exception) -> str:
# str(exc) ist bei SpotifyAuthRequiredError/SpotifyAPIError bereits die
# aufbereitete, menschenlesbare Meldung (siehe _friendly_spotify_error_message
# in plugins/spotify/client.py) -- 1:1 zitieren, nicht neu formulieren
# oder raten (siehe ARIAs eigene Anti-Halluzinations-Regel).
return f"⚠️ Spotify: {exc}"
def _devices(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
return list((payload or {}).get("devices") or [])
def _active_device_id(payload: Dict[str, Any]) -> Optional[str]:
for d in _devices(payload):
if d.get("is_active"):
return d.get("id")
return None
def _best_match(query: str, candidates: List[str], cutoff: float = 0.55) -> Optional[int]:
"""Index des besten Fuzzy-Treffers, oder None wenn zu unsicher.
"Zu unsicher" heisst: kein Treffer ueber cutoff, ODER die besten zwei
Treffer liegen zu nah beieinander (< 0.08 Differenz) -- dann lieber ans
LLM abgeben statt eine von zwei aehnlich benannten Playlists zu raten.
Exakt das Verhalten, das ARIA bei "mehrere Substring-Matches" auch
einfordert: nachfragen statt raten.
"""
if not candidates:
return None
scored = sorted(
(
(i, difflib.SequenceMatcher(None, query.lower(), (c or "").lower()).ratio())
for i, c in enumerate(candidates)
),
key=lambda pair: pair[1],
reverse=True,
)
if not scored or scored[0][1] < cutoff:
return None
if len(scored) > 1 and (scored[0][1] - scored[1][1]) < 0.08:
return None
return scored[0][0]
# ---------------------------------------------------------------------------
# Reine Steuerbefehle
# ---------------------------------------------------------------------------
def _handle_pause(match, event, gateway) -> Optional[str]:
try:
_client().pause_playback()
except Exception as exc:
return _friendly_error(exc)
return "⏸ Pausiert."
def _handle_resume(match, event, gateway) -> Optional[str]:
try:
_client().start_playback()
except Exception as exc:
return _friendly_error(exc)
return "▶️ Weiter."
def _handle_next(match, event, gateway) -> Optional[str]:
try:
_client().skip_next()
except Exception as exc:
return _friendly_error(exc)
return "⏭ Nächster Titel."
def _handle_previous(match, event, gateway) -> Optional[str]:
try:
_client().skip_previous()
except Exception as exc:
return _friendly_error(exc)
return "⏮ Vorheriger Titel."
def _handle_volume_relative(match, event, gateway) -> Optional[str]:
direction = (match.group("dir") or "").lower()
delta = 10 if direction.startswith("laut") else -10
client = _client()
try:
devices_payload = client.get_devices()
device_id = _active_device_id(devices_payload)
current = 50
for d in _devices(devices_payload):
if d.get("id") == device_id:
current = int(d.get("volume_percent") or 50)
break
new_volume = max(0, min(100, current + delta))
client.set_volume(volume_percent=new_volume, device_id=device_id)
except Exception as exc:
return _friendly_error(exc)
return f"🔊 Lautstärke: {new_volume}%"
def _handle_volume_set(match, event, gateway) -> Optional[str]:
try:
percent = max(0, min(100, int(match.group("pct"))))
except (TypeError, ValueError):
return None # Zahl nicht sauber parsebar -> lieber ans LLM
try:
_client().set_volume(volume_percent=percent)
except Exception as exc:
return _friendly_error(exc)
return f"🔊 Lautstärke: {percent}%"
def _handle_current(match, event, gateway) -> Optional[str]:
try:
payload = _client().get_currently_playing()
except Exception as exc:
return _friendly_error(exc)
if not payload or payload.get("empty") or not payload.get("item"):
return "⏸ Aktuell läuft nichts auf Spotify."
item = payload["item"]
name = item.get("name", "?")
artists = ", ".join(a.get("name", "?") for a in item.get("artists", []) or [])
device = (payload.get("device") or {}).get("name")
suffix = f" ({device})" if device else ""
return f"🎵 Läuft gerade: {name} – {artists}{suffix}"
# ---------------------------------------------------------------------------
# "spiel Playlist X [auf Geraet Y] ab" -- Freitext-Namen per Fuzzy-Match,
# kein LLM noetig. Sicherheitsnetz: bei Unsicherheit -> None -> LLM uebernimmt.
# ---------------------------------------------------------------------------
def _handle_play_playlist(match, event, gateway) -> Optional[str]:
playlist_query = (match.group("playlist") or "").strip().strip("\"“”'")
device_query = ""
if "device" in match.groupdict():
device_query = (match.group("device") or "").strip()
if not playlist_query:
return None
client = _client()
# ALLE Playlists einsammeln bevor gematcht wird -- nicht auf
# Teilergebnissen raten (ARIAs eigene Pagination-Regel).
try:
items: List[Dict[str, Any]] = []
offset = 0
while len(items) < 300:
page = client.get_my_playlists(limit=50, offset=offset)
page_items = page.get("items") or []
items.extend(page_items)
if not page.get("next") or not page_items:
break
offset += len(page_items)
except Exception as exc:
return _friendly_error(exc)
names = [p.get("name", "") for p in items]
idx = _best_match(playlist_query, names)
if idx is None:
logger.info(
"fast_paths/spotify: Playlist '%s' nicht eindeutig unter %d Playlists -> LLM uebernimmt",
playlist_query, len(names),
)
return None
playlist = items[idx]
try:
devices_payload = client.get_devices()
except Exception as exc:
return _friendly_error(exc)
devices = _devices(devices_payload)
if not devices:
return "⚠️ Spotify: kein Gerät gefunden — öffne Spotify auf irgendeinem Gerät und versuch's nochmal."
device_id = None
if device_query:
device_names = [d.get("name", "") for d in devices]
d_idx = _best_match(device_query, device_names, cutoff=0.4)
if d_idx is None:
logger.info(
"fast_paths/spotify: Geraet '%s' nicht eindeutig unter %s -> LLM uebernimmt",
device_query, device_names,
)
return None
device_id = devices[d_idx].get("id")
else:
device_id = _active_device_id(devices_payload) or devices[0].get("id")
try:
client.start_playback(device_id=device_id, context_uri=playlist.get("uri"))
except Exception as exc:
return _friendly_error(exc)
device_name = next((d.get("name") for d in devices if d.get("id") == device_id), "?")
return f"▶️ Spiele Playlist „{playlist.get('name')}“ auf {device_name}."
PATTERNS = [
pattern(
"pause",
r"^(?:spotify\s+)?(?:pause|pausier(?:e|en)?|stop|stopp|halt|anhalten)"
# * statt ? -- deckt auch mehrere aneinandergereihte Fuellwoerter ab
# ("pause bitte spotify"), nicht nur genau ein einzelnes.
r"(?:\s+(?:mal|bitte|spotify|die\s+musik))*$",
_handle_pause,
),
pattern(
"resume",
r"^(?:spotify\s+)?(?:weiter|weiterspielen|fortsetzen|play|abspielen|los)"
r"(?:\s+(?:mal|bitte|spotify|die\s+musik))*$",
_handle_resume,
),
pattern(
"next",
r"^(?:spotify\s+)?(?:n(?:ä|ae)chste[rs]?(?:\s+(?:lied|song|titel))?"
r"|weiter(?:er)?\s+(?:lied|song|titel)|skip|next)(?:\s+(?:mal|bitte|spotify))*$",
_handle_next,
),
pattern(
"previous",
r"^(?:spotify\s+)?(?:vorherige[rs]?(?:\s+(?:lied|song|titel))?"
r"|zur(?:ü|ue)ck(?:\s+zum\s+vorherigen)?|previous)(?:\s+(?:mal|bitte|spotify))*$",
_handle_previous,
),
pattern(
"volume_relative",
r"^(?:spotify\s+)?(?:mach\s+(?:es\s+)?)?(?P<dir>lauter|leiser)(?:\s+(?:mal|bitte|spotify))*$",
_handle_volume_relative,
),
pattern(
"volume_set",
r"^(?:spotify\s+)?(?:lautst(?:ä|ae)rke|volume)\s+(?:auf\s+)?(?P<pct>\d{1,3})\s*%?$",
_handle_volume_set,
),
pattern(
"current",
r"^(?:spotify\s+)?(?:was\s+l(?:ä|ae)uft(?:\s+(?:gerade|jetzt))?"
r"|welches\s+lied\s+l(?:ä|ae)uft|aktueller\s+(?:song|titel)|current(?:ly\s+playing)?)"
r"(?:\s+(?:mal|bitte))*$",
_handle_current,
),
pattern(
"play_playlist",
r"^spiel(?:e)?\s+(?:mal\s+|bitte\s+)?(?:meine\s+)?playlist\s+"
r"[\"“]?(?P<playlist>.+?)[\"”]?(?:\s+auf\s+(?:mein(?:em|er)?\s+)?(?P<device>.+?))?"
r"\s*(?:ab)?$",
_handle_play_playlist,
),
]