refactor(brain): Fast-Path als Skill-Capability — fast_patterns im Manifest

Frueher: Spotify-spezifische Patterns hardcoded in agent.py — jeder neue
Steuer-Skill haette wieder Brain-Code-Aenderungen gebraucht.
Jetzt: jeder Skill deklariert seine eigenen Patterns im Manifest unter
fast_patterns: [{match, args, reply}]. Brain iteriert generisch, kein
Skill bekommt Sonderbehandlung.

- agent.py: _try_skill_fast_path liest aus skills.list_skills(), keine
  Spotify-Konstanten mehr. skill_create/skill_update Tool-Schema kennt
  fast_patterns (mit Beispiel + Wann-nutzen-Hinweis).
- skills.py: _normalize_fast_patterns validiert Regex + filtert kaputte
  Eintraege; create_skill/update_skill akzeptieren das Feld.
- main.py: einmalige Lifespan-Migration — wenn spotify-Skill existiert
  und kein fast_patterns hat, werden die alten Hardcoded-Patterns
  rueberkopiert. Idempotent, laeuft bei jedem Restart sicher mehrfach.
- seed_rules.py: neue Regel `seed/skill-rule/fast-patterns-for-control`
  erklaert ARIA wann sie das Feature nutzen soll (reines Steuern: ja,
  kreativer Output / Parametrisierung: nein) — mit Beispiel.

Trade-off: Volume-Patterns (lauter/leiser) fallen aus dem Fast-Path raus,
weil die Multi-Step-Logik (GET state → compute → PUT) sich nicht
deklarativ ausdruecken laesst. Wer das zurueck will: Spotify-Skill um
einen action=volume_relative-Arg erweitern der die Mathe intern macht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 08:56:15 +02:00
parent e04bbef361
commit 61c9183033
4 changed files with 231 additions and 111 deletions
+90 -111
View File
@@ -127,6 +127,25 @@ META_TOOLS = [
"items": {"type": "object"},
"description": "Argumente-Schema [{name, type, required, description}]",
},
"fast_patterns": {
"type": "array",
"items": {"type": "object"},
"description": (
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
"pause/next, Rollade hoch/runter etc.) eine Liste von "
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
"match (anchored Regex, case-insensitive) matched, ruft das "
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
"ohne Claude (~5s Latenz gespart). Match wird gegen den "
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
"Skills mit kreativem Output / parametrisierter Logik — die "
"brauchen Claude. Beispiel: "
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
"\"reply\":\"Spotify: pausiert ⏸\"}]"
),
},
},
"required": ["name", "description", "entry_code"],
},
@@ -193,6 +212,16 @@ META_TOOLS = [
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
),
},
"fast_patterns": {
"type": "array",
"items": {"type": "object"},
"description": (
"Optional komplette Fast-Path-Patterns-Liste UEBERSCHREIBEN — "
"[{match, args, reply}]. Siehe skill_create-Beschreibung fuer "
"Format. Leere Liste = alle Fast-Paths entfernen (alles geht "
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
),
},
},
"required": ["name"],
},
@@ -782,61 +811,26 @@ META_TOOLS = [
]
# ── Spotify Fast-Path ──────────────────────────────────────────────────
# ── Fast-Path (Skill-deklariert) ───────────────────────────────────────
#
# Einfache Media-Commands (nächster Track, Pause, lauter, ...) gehen
# direkt aufs spotify-Skill statt durch die volle Claude-Reasoning-Pipeline.
# Latenz: ~1-1.5s statt 5-10s. Stefan-Bug 06/2026: "ARIA braucht ewig nur
# fuer 'nächster Track'". Wenn ein Pattern nicht matcht, faellt der Call
# wie bisher in die normale chat()-Loop und Claude entscheidet — keine
# Funktionalitaet geht verloren.
# Skills koennen in ihrem Manifest `fast_patterns` deklarieren — eine Liste
# von {match: regex, args: dict, reply: str}. Wenn ein User-Text gegen
# ein Pattern matcht, ruft das Brain direkt run_skill(name, args) auf und
# returnt `reply` an den User — Claude wird komplett uebersprungen. Spart
# 5-10s LLM-Latenz pro "reines Steuern"-Befehl.
#
# Patterns sind anchored (^...$) gegen normalisierten Text (lowercase,
# Endsatzzeichen weg, Whitespace gestrafft). Bewusst eng gefasst: lieber
# einmal in Claude fallen als ein Kontextsatz wie "ich war kurz zurueck"
# faelschlich als "previous track" interpretieren.
_SPOTIFY_FAST_PATTERNS: list[tuple[str, str, str, Optional[int]]] = [
# (regex, action, http-method, volume-delta)
# NEXT
(r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$", "next", "POST", None),
(r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$", "next", "POST", None),
# PREVIOUS
(r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$", "previous", "POST", None),
(r"^(zurueck|zurück)$", "previous", "POST", None),
# PAUSE
(r"^(pause|pausiere|pausieren|stop|stopp|halt)$", "pause", "PUT", None),
(r"^(musik|spotify) (pause|aus|stop|stopp)$", "pause", "PUT", None),
# PLAY / RESUME
(r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$", "play", "PUT", None),
(r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$", "play", "PUT", None),
# VOLUME — Delta wird auf den aktuell ermittelten Volume-Wert aufaddiert
(r"^(lauter|musik lauter|spotify lauter|volume hoch|lautstärke hoch)$", "volume", "PUT", 10),
(r"^(leiser|musik leiser|spotify leiser|volume runter|lautstärke runter)$", "volume", "PUT", -10),
(r"^(viel lauter|deutlich lauter)$", "volume", "PUT", 20),
(r"^(viel leiser|deutlich leiser)$", "volume", "PUT", -20),
]
# Patterns sollten anchored (^...$) gegen den normalisierten Text (lower-
# case, Endsatzzeichen weg, Whitespace gestrafft) geschrieben sein. Lieber
# eng matchen als breit — false-positives sind teurer als ein Cache-Miss.
#
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
# skill_create / skill_update, das Brain orchestriert nur.
def _spotify_fast_match(text: str) -> Optional[tuple[str, str, Optional[int]]]:
"""Returns (action, method, volume_delta) wenn ein Pattern matcht — sonst None."""
def _normalize_for_fast_match(text: str) -> str:
norm = (text or "").strip().lower()
norm = re.sub(r"[.!?]+$", "", norm)
norm = re.sub(r"\s+", " ", norm)
if not norm:
return None
for rx, action, method, delta in _SPOTIFY_FAST_PATTERNS:
if re.match(rx, norm):
return action, method, delta
return None
def _run_spotify_call(path: str, method: str, body: Optional[dict] = None) -> dict:
"""Fuehrt einen Spotify-Skill-Call aus. Skill-Args: path, method, body (JSON-String).
Returns das run_skill-Ergebnis."""
args: dict = {"path": path, "method": method}
if body is not None:
args["body"] = json.dumps(body)
return skills_mod.run_skill("spotify", args, timeout_sec=15)
return norm
def _skill_to_tool(s: dict) -> dict:
@@ -906,71 +900,52 @@ class Agent:
self._pending_events = []
return events
def _try_spotify_fast_path(self, user_message: str) -> Optional[str]:
"""Wenn die Nachricht ein einfacher Media-Command ist, direkt aufs
spotify-Skill routen und ein kurzes Reply zurueckgeben — Claude wird
komplett uebersprungen. Returnt None wenn kein Pattern matcht oder das
spotify-Skill nicht installiert ist (dann faellt's normal in Claude)."""
m = _spotify_fast_match(user_message)
if m is None:
return None
action, method, delta = m
def _try_skill_fast_path(self, user_message: str) -> Optional[str]:
"""Iteriert ueber alle aktiven Skills und probiert deren fast_patterns
gegen den normalisierten User-Text. Erster Treffer gewinnt — Skill
wird direkt aufgerufen, Reply geht ohne Claude zurueck.
# Skill muss installiert + aktiv sein. Sonst Fall-Through zu Claude.
try:
manifest = skills_mod.read_manifest("spotify")
except Exception:
manifest = None
if not manifest or not manifest.get("active", True):
logger.info("[spotify-fast] skill nicht verfuegbar — fall through zu Claude")
Returnt None wenn kein Pattern matcht. Bei Skill-Ausfuehrungs-Fehler
(ok=False) wird eine ehrliche Fehler-Reply gegeben statt durch Claude
zu fallen — sonst kostet ein gescheiterter Fast-Path doppelt (~1s
Skill-Versuch + 5-10s Claude). Bei unerwarteter Exception fallen wir
durch zu Claude (Claude kann ggf. besser diagnostizieren)."""
norm = _normalize_for_fast_match(user_message)
if not norm:
return None
logger.info("[spotify-fast] match action=%s method=%s delta=%s msg=%r",
action, method, delta, user_message[:60])
def _err_reply(label: str, res: dict) -> str:
# ok=False kommt von 401 (nicht eingeloggt), 404 (kein aktives
# Gerät) etc. — Skill schreibt den Spotify-Error nach stderr.
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"Spotify: {label} fehlgeschlagen — {hint or 'siehe Brain-Log'}"
try:
if action == "next":
res = _run_spotify_call("/v1/me/player/next", method)
return "Spotify: nächster Track ⏭" if res.get("ok") else _err_reply("Skip", res)
if action == "previous":
res = _run_spotify_call("/v1/me/player/previous", method)
return "Spotify: vorheriger Track ⏮" if res.get("ok") else _err_reply("Zurück", res)
if action == "pause":
res = _run_spotify_call("/v1/me/player/pause", method)
return "Spotify: pausiert ⏸" if res.get("ok") else _err_reply("Pause", res)
if action == "play":
res = _run_spotify_call("/v1/me/player/play", method)
return "Spotify: spielt ▶" if res.get("ok") else _err_reply("Play", res)
if action == "volume" and delta is not None:
state = _run_spotify_call("/v1/me/player", "GET")
if not state.get("ok"):
return _err_reply("Lautstärke-Status", state)
cur_vol = 50
active_skills = [s for s in skills_mod.list_skills(active_only=False)
if s.get("active", True)]
for skill in active_skills:
patterns = skill.get("fast_patterns") or []
if not patterns:
continue
skill_name = skill.get("name") or ""
for pat in patterns:
rx = pat.get("match") or ""
if not rx:
continue
try:
out = (state.get("stdout") or "").strip()
if out:
data = json.loads(out)
dev = data.get("device") or {}
cur_vol = int(dev.get("volume_percent", 50))
if not re.match(rx, norm, re.IGNORECASE):
continue
except re.error:
# Sollte durch _normalize_fast_patterns rausgefiltert sein.
continue
args = pat.get("args") or {}
reply = pat.get("reply") or f"{skill_name}: ok"
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
skill_name, rx, user_message[:60])
try:
res = skills_mod.run_skill(skill_name, dict(args), timeout_sec=15)
except Exception as exc:
logger.warning("[spotify-fast] volume-state parse: %s", exc)
new_vol = max(0, min(100, cur_vol + delta))
res = _run_spotify_call(f"/v1/me/player/volume?volume_percent={new_vol}", "PUT")
logger.warning("[fast-path] %s exception — fall through zu Claude: %s",
skill_name, exc)
return None
if not res.get("ok"):
return _err_reply("Lautstärke", res)
arrow = "🔊" if delta > 0 else "🔉"
return f"Spotify: Lautstärke {new_vol}% {arrow}"
except Exception as exc:
logger.warning("[spotify-fast] action=%s exception — fall through zu Claude: %s",
action, exc)
return None
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
return reply
return None
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
@@ -985,9 +960,10 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Spotify Fast-Path: einfache Media-Commands ueberspringen Claude komplett.
# Spart 4-9s Latenz fuer 'naechster Track', 'Pause', 'lauter' etc.
fast_reply = self._try_spotify_fast_path(user_message)
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
fast_reply = self._try_skill_fast_path(user_message)
if fast_reply is not None:
self.conversation.add("user", user_message, source=source)
self.conversation.add("assistant", fast_reply)
@@ -1133,6 +1109,7 @@ class Agent:
args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None,
author="aria",
)
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1196,6 +1173,8 @@ class Agent:
patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
patch["config_schema"] = arguments["config_schema"]
if "fast_patterns" in arguments and isinstance(arguments["fast_patterns"], list):
patch["fast_patterns"] = arguments["fast_patterns"]
if not patch:
return "FEHLER: keine Felder zum Update angegeben."
try: