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
+117
View File
@@ -0,0 +1,117 @@
# fast-paths — Steuerbefehle ohne LLM-Roundtrip
## Warum
Jede Nachricht an Hermes geht normalerweise durch's volle LLM: Text →
System-Prompt bauen → Claude aufrufen (mehrere Sekunden, siehe
Root-Cause-Historie im Haupt-README) → Tool-Call parsen → Tool ausfuehren →
Antwort formulieren. Fuer einfache Steuerbefehle ("pause", "naechstes Lied",
"lauter") ist das unnoetig langsam — Alexa & Co. machen sowas mit
Intent-Klassifikation in Millisekunden, ganz ohne generatives Modell.
Dieses Plugin baut das fuer Hermes nach: ein Regex-Layer, der VOR dem LLM
sitzt. Matcht der Text, wird die Aktion direkt ausgefuehrt und Claude nie
gefragt. Matcht nichts (oder ist sich ein Handler nicht sicher), laeuft
alles normal weiter — als waere das Plugin gar nicht da.
## Architektur
```
fast-paths/
plugin.yaml <- Hermes-Plugin-Manifest (kind: standalone)
__init__.py <- Loader: sammelt patterns/*.py ein, ein Hook-Callback
patterns/
__init__.py
_base.py <- FastPattern-Dataclass, pattern()-Helper, send_reply()
spotify.py <- EIN Skill = EINE Datei
<dein-neuer-skill>.py
```
Ein Skill = eine Datei unter `patterns/`. Kein zentrales Registrieren
noetig — `__init__.py` importiert automatisch jede `*.py`-Datei in
`patterns/` (ausser Dateien mit fuehrendem `_`, das ist Infrastruktur) und
sammelt deren `PATTERNS`-Liste ein.
## Neuen Fast-Path hinzufuegen
1. Neue Datei `patterns/<skill>.py` anlegen.
2. Handler-Funktion(en) schreiben: `def handler(match, event, gateway) -> str | None`.
- `event` ist ein `MessageEvent` (siehe `gateway/platforms/base.py` im
Hermes-Sourcecode) — `event.text`, `event.source.chat_id`,
`event.source.platform`, etc.
- `gateway` ist der `GatewayRunner` — u.a. `gateway._adapter_for_source(source)`
um den Platform-Adapter zu bekommen, `gateway._is_user_authorized(source)`
fuer Auth-Checks (macht der Loader schon automatisch, siehe unten).
3. Am Ende der Datei: `PATTERNS = [pattern("name", r"^regex$", handler), ...]`
mit dem Helper aus `_base.py`.
4. Fertig — kein Eintrag irgendwo sonst noetig. Container neu starten
(`docker-compose up -d --build hermes-agent`), Loader findet die Datei
automatisch beim naechsten Plugin-Discovery.
### Rueckgabewerte des Handlers (wichtig!)
| Rueckgabe | Bedeutung |
|---|---|
| `"Text"` | Wird 1:1 an den User geschickt, LLM wird uebersprungen. |
| `""` (leer) | Aktion ausgefuehrt, aber bewusst stumm. LLM trotzdem uebersprungen. |
| `None` | Handler ist sich nicht sicher genug (z.B. mehrdeutiger Name) — faellt zurueck auf die normale LLM-Verarbeitung, GENAU wie "kein Match". **Nie raten, im Zweifel `None`.** |
| *(Exception)* | Wird geloggt, faellt ebenfalls automatisch aufs LLM zurueck. |
### Regex-Konventionen
- Immer mit `^` und `$` anchorn — sonst matcht `pause` auch mitten in
"pause die Musik und erzaehl mir was" und reisst den Rest des Satzes weg.
- Mehrere Formulierungen/Synonyme + Fuellwoerter ueber Alternativen und
optionale Gruppen abdecken (`(?:mal|bitte)?`), nicht nur eine Formulierung.
- Text wird vor dem Matchen normalisiert (lowercase, Satzzeichen am Ende
weg, Mehrfach-Leerzeichen zusammengefasst) — Regexe koennen daher
durchgehend lowercase geschrieben werden.
- Lieber ein Pattern zu eng als zu weit — ein Fast-Path, der faelschlich
matcht, kapert eine Nachricht die eigentlich ans LLM sollte. Ein Pattern,
das gar nicht matcht, kostet nur die eingesparte Geschwindigkeit (die
Nachricht geht stattdessen ganz normal ans LLM, kein Schaden).
## Sicherheit
`pre_gateway_dispatch` (der Hook, an dem dieses Plugin haengt) feuert VOR
Hermes' eigener Autorisierungspruefung. Der Loader in `__init__.py` prueft
deshalb bei JEDEM Pattern-Treffer explizit `gateway._is_user_authorized(...)`
BEVOR der Handler ueberhaupt aufgerufen wird — ein nicht autorisierter
Absender (z.B. auf einer oeffentlich erreichbaren Plattform wie Telegram)
kann also nie per Fast-Path an Hermes vorbei etwas ausloesen. Diese Pruefung
lebt zentral im Loader, nicht in den einzelnen Skill-Dateien — neue Skills
muessen sich darum nicht kuemmern.
## Bekannter Trade-off
`pre_gateway_dispatch`-Callbacks laufen **synchron** (Hermes' PluginManager
awaited sie nicht, siehe `hermes_cli/plugins.py`). Handler duerfen also
ganz normal blockierende Netzwerk-Calls machen (z.B. `httpx.request` wie
`SpotifyClient`), das blockiert den Event-Loop aber kurz (typischerweise
100-500ms pro API-Call). Fuer eine Text-Antwort an den User (das *ist*
async — `adapter.send()`) gibt's `send_reply()` in `_base.py`: schedult
einen Fire-and-forget `asyncio.Task` auf dem laufenden Event-Loop. Beides
zusammen ist immer noch um Groessenordnungen schneller als der
LLM-Roundtrip (mehrere Sekunden bis >20s, siehe Haupt-README).
## Aktivieren
Plugins sind bei Hermes standardmaessig Opt-in. Einmalig in
`hermes-data/agent-home/config.yaml`:
```yaml
plugins:
enabled:
- fast-paths
```
Danach `docker-compose up -d --build hermes-agent` (Volume-Mount siehe
`docker-compose.yml`, Kommentar beim `hermes-agent`-Service).
## Bisherige Skills
- **`patterns/spotify.py`** — pause/weiter/next/previous/lauter/leiser/
Lautstaerke setzen/aktueller Titel, plus "spiel Playlist X [auf Geraet Y]
ab" per Fuzzy-Match (kein LLM fuer den Freitext-Namen noetig, siehe
`difflib`-basiertes Matching dort — bricht bei Mehrdeutigkeit bewusst ab
und gibt an das LLM ab statt zu raten).
+163
View File
@@ -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)
+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,
),
]
+12
View File
@@ -0,0 +1,12 @@
name: fast-paths
version: 1.0.0
description: >
Universeller Fast-Path-Layer: einfache Steuerbefehle (z.B. Spotify
pause/next/volume) werden per Regex VOR dem eigentlichen LLM-Aufruf
abgefangen und direkt ausgefuehrt -- kein Claude-Roundtrip noetig. Ein
eigenes File pro Skill unter patterns/, siehe README.md fuer die
Anleitung neue Fast-Paths zu ergaenzen.
author: "Stefan / ARIA"
kind: standalone
provides_hooks:
- pre_gateway_dispatch