Files
hermes-agent/fast-paths/patterns/spotify.py
T
ARIA 9ea72886dd 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.
2026-07-20 16:17:29 +00:00

289 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
),
]