diff --git a/README.md b/README.md index 62b62cc..26827ea 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,32 @@ oben), nicht an Spotify selbst — dann `docker logs hermes-proxy` waehrend der Reproduktion pruefen. Gleiches Nutzungsmuster wie `scripts/spotify_manual_auth.py` (Login-Skript weiter oben). +## Fast-Paths (Steuerbefehle ohne LLM-Roundtrip) + +Selbst mit allen Fixes oben braucht jede Nachricht mindestens einen vollen +LLM-Turn (mehrere Sekunden) — fuer "pause" oder "naechstes Lied" unnoetig +langsam. `fast-paths/` ist ein eigenes, universelles Plugin (ein File pro +Skill, siehe `fast-paths/README.md`), das einfache Steuerbefehle per Regex +VOR dem LLM-Aufruf abfaengt und direkt ausfuehrt — analog zu ARIAs eigenen +`fast_patterns`. Aktuell drin: `patterns/spotify.py` +(pause/weiter/next/previous/lauter/leiser/Lautstaerke/aktueller Titel, plus +"spiel Playlist X auf Geraet Y ab" per Fuzzy-Match ohne LLM). + +Einmalig aktivieren (Plugins sind bei Hermes Opt-in) — in +`hermes-data/agent-home/config.yaml`: +```yaml +plugins: + enabled: + - fast-paths +``` +Dann: +```bash +git pull +docker-compose up -d --build hermes-agent +``` +Volle Details, Sicherheitsmodell (Autorisierungspruefung VOR jedem +Fast-Path-Treffer!) und Anleitung fuer neue Skills: `fast-paths/README.md`. + ## Mobiler Client (Android)? Zwei GRUNDVERSCHIEDENE Richtungen, nicht verwechseln: diff --git a/docker-compose.yml b/docker-compose.yml index 9ffe8b9..8531515 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -220,6 +220,17 @@ services: # Start einmalig hierher kopiert (siehe README) und dann von Hermes # NICHT mehr angefasst (nur geseedet wenn's fehlt). - ./hermes-data/agent-home:/opt/data + # Unser eigener Fast-Path-Layer (siehe fast-paths/README.md): faengt + # einfache Steuerbefehle wie "pause"/"naechstes Lied" per Regex VOR dem + # LLM-Aufruf ab. Wird als Hermes-USER-Plugin unter $HERMES_HOME/plugins/ + # erwartet ($HERMES_HOME=/opt/data, siehe ENV oben) -- ein verschachtelter + # Bind-Mount UEBER dem agent-home-Mount oben, versioniert in unserem + # eigenen Git-Repo statt im gitignorten hermes-data/. Read-only, damit + # Hermes (oder ein kompromittierter Plugin-Code) den Code hier nie + # veraendern kann. MUSS zusaetzlich in config.yaml unter + # "plugins: enabled: [fast-paths]" eingetragen werden (siehe README) -- + # Plugins sind bei Hermes standardmaessig opt-in. + - ./fast-paths:/opt/data/plugins/fast-paths:ro environment: - HERMES_UID=${HERMES_UID:-10000} - HERMES_GID=${HERMES_GID:-10000} diff --git a/fast-paths/README.md b/fast-paths/README.md new file mode 100644 index 0000000..49ea5bd --- /dev/null +++ b/fast-paths/README.md @@ -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 + .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/.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). diff --git a/fast-paths/__init__.py b/fast-paths/__init__.py new file mode 100644 index 0000000..45524c0 --- /dev/null +++ b/fast-paths/__init__.py @@ -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/.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) diff --git a/fast-paths/patterns/__init__.py b/fast-paths/patterns/__init__.py new file mode 100644 index 0000000..c65ed0d --- /dev/null +++ b/fast-paths/patterns/__init__.py @@ -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 " 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). diff --git a/fast-paths/patterns/_base.py b/fast-paths/patterns/_base.py new file mode 100644 index 0000000..04f8009 --- /dev/null +++ b/fast-paths/patterns/_base.py @@ -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()) diff --git a/fast-paths/patterns/spotify.py b/fast-paths/patterns/spotify.py new file mode 100644 index 0000000..a52bf0d --- /dev/null +++ b/fast-paths/patterns/spotify.py @@ -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 -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+)?)?(?Plauter|leiser)(?:\s+(?:mal|bitte|spotify))*$", + _handle_volume_relative, + ), + pattern( + "volume_set", + r"^(?:spotify\s+)?(?:lautst(?:ä|ae)rke|volume)\s+(?:auf\s+)?(?P\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.+?)[\"”]?(?:\s+auf\s+(?:mein(?:em|er)?\s+)?(?P.+?))?" + r"\s*(?:ab)?$", + _handle_play_playlist, + ), +] diff --git a/fast-paths/plugin.yaml b/fast-paths/plugin.yaml new file mode 100644 index 0000000..c7761cc --- /dev/null +++ b/fast-paths/plugin.yaml @@ -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