Files
ARIA-AGENT/aria-brain/router.py
T
duffyduckandClaude Opus 4.8 44220edbd1 revert(router): Live-Wortliste raus — Modell soll selbst eskalieren
_LIVE_HINTS war die falsche Idee: aus offenem Freitext per Wortliste die
Absicht raten ist nie vollstaendig, jeder Miss = Halo (nur von per-Skill auf
per-Wort verschoben). Generisch heisst nicht 'groessere Regex', sondern: das
Modell entscheidet selbst ob es Grundwahrheit/ein Tool braucht (<<ESCALATE>>,
Prompt). Der 8B kann das noch nicht zuverlaessig → local bleibt per Einstellung
abschaltbar (aus, nicht raus); ein staerkeres lokales Modell uebernimmt spaeter
die Selbst-Erkennung. Absicherung bleibt der Output-Guard, nicht der Input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:20:02 +02:00

226 lines
11 KiB
Python

"""
Router (Plan B, B1a) — entscheidet pro Turn: lokales schnelles LLM oder Claude.
Gestaffelt:
- B1a (hier): „nur reden" — einfache Plauder-Turns → lokales Qwen (schlanker
Prompt, KEINE Tools). Antwortet es sauber → fertig in <1 s. Sagt es
`<<ESCALATE>>`, braucht ein Tool oder faellt aus → Claude (bestehender Pfad).
- B1b (spaeter): kuratierte lokale Tools + lokale Tool-Loop.
Schalter kommen aus /shared/config/local_llm.json (Diagnostic schreibt, Brain
liest pro Request):
{
"enabled": false, # Master: lokales Tier an/aus (aus = alles Claude)
"localOnly": false, # Eval: erzwinge lokal, KEIN Claude-Fallback
"toolVariant": "slim" # "slim" | "full" (B1b; "full" braucht mehr VRAM)
}
Default (Datei fehlt/kaputt): enabled=false → Verhalten wie bisher (alles Claude).
"""
from __future__ import annotations
import json
import logging
import os
import re
logger = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("LOCAL_LLM_CONFIG", "/shared/config/local_llm.json")
ESCALATE_MARKER = "<<ESCALATE>>"
DEFAULT_CONFIG = {"enabled": False, "localOnly": False,
"toolVariant": "slim", "localLlmModel": "qwen3-8b"}
def load_config() -> dict:
"""Liest die Schalter. Nie werfen — bei Fehler Defaults (= alles Claude)."""
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
return {
"enabled": bool(data.get("enabled", False)),
"localOnly": bool(data.get("localOnly", False)),
"toolVariant": data.get("toolVariant", "slim") or "slim",
# Welches lokale Modell llama-swap laden soll (B0.5). Muss zu einem
# Key in xtts/llama-swap/config.yaml passen.
"localLlmModel": (data.get("localLlmModel") or "qwen3-8b").strip(),
}
except (FileNotFoundError, json.JSONDecodeError):
return dict(DEFAULT_CONFIG)
except Exception as exc:
logger.debug("local_llm-Config lesen fehlgeschlagen: %s", exc)
return dict(DEFAULT_CONFIG)
# ── Heuristik: ist dieser Turn „einfach genug" fuers lokale Tier (B1a)? ──
#
# B1a ist reden-ohne-Tools. Also: alles, was ein Tool/Aktion braucht oder tief/
# technisch ist, geht an Claude. Lieber konservativ (im Zweifel Claude) — das
# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation
# faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab.
# CLAUDE-ONLY-Themen → nicht lokal. Seit B1b hat das lokale Tier Werkzeuge
# (web_search, memory_search, trigger_timer, Spotify), daher gehen Wetter, News,
# Fakten, Timer, Musik, Gedaechtnis-Suche jetzt LOKAL. Nur was das lokale Tier
# nicht kann bleibt hier: Bilder, Skills, Projekte, OAuth, Smart-Home (keine
# Anbindung), Kalender/Mail (kein Tool).
_TOOL_HINTS = re.compile(
r"\b(bild|generier|male?\b|malen|zeichne|foto|"
r"skill|projekt|oauth|"
r"licht|lampe|steckdose|rollade|heizung|"
r"kalender|termin|"
r"maild?|e-?mail|nachricht schreiben)\b",
re.IGNORECASE,
)
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
_HARD_HINTS = re.compile(
r"```|" # Codeblock
r"\b(code|fehler|error|stacktrace|exception|bug|debug|pentest|exploit|"
r"vuln|payload|regex|sql|python|javascript|docker|kubernetes|"
r"analysier|erklär.*genau|schritt für schritt|refactor|implementier)\b",
re.IGNORECASE,
)
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
_FORCE_CLAUDE = re.compile(
r"\b(clodi|clody)\b"
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
re.IGNORECASE,
)
def _strip_leading_hint_blocks(text: str) -> str:
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
sonst blaeht der Praefix die Laenge auf und verfaelscht die Heuristik."""
s = (text or "").strip()
prev = None
while prev != s:
prev = s
s = re.sub(r"^\s*\[[^\]]*\]\s*", "", s)
return s
def should_try_local(user_message: str, cfg: dict) -> bool:
"""True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll.
localOnly überschreibt die Heuristik (dann IMMER lokal)."""
if not cfg.get("enabled"):
return False
if cfg.get("localOnly"):
return True
msg = _strip_leading_hint_blocks(user_message)
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
if _FORCE_CLAUDE.search(msg):
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
return False
if _TOOL_HINTS.search(msg):
return False
if _HARD_HINTS.search(msg):
return False
return True
# ── Schlanker System-Prompt fuers lokale Tier ──
#
# Klein halten (Speed!). Persona-Kern + Identitaets-Anker + kurze Awareness-Liste
# (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas,
# kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
# Aktion braucht, gehoert ans grosse Modell.
_AWARENESS = (
"Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
"nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
"News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
"und langen Code. Fuer ALL das eskalierst du."
)
def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
pinned_persona: str = "") -> str:
"""Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe
Anker wie bei Claude (Rolle haelt)."""
parts = [
identity_anchor.strip(),
"",
"## SCHNELL-MODUS",
"Du laeufst gerade als schnelles lokales Modell fuer Alltags-Konversation "
"und einfache Aufgaben. Antworte knapp, freundlich, auf Deutsch, als ARIA.",
"WICHTIG zur Ausgabe: Antworte in ganz NORMALEM Text. Verwende KEINE "
"`<voice>`-Tags, kein `[FILE:]`, kein `<tool_call>`, kein HTML/Markup — "
"nur ein oder zwei natuerliche Saetze. (Der Text wird direkt angezeigt "
"UND vorgelesen.)",
]
if has_tools:
parts += [
"",
"## DEINE WERKZEUGE — nur nutzen wenn die Frage es WIRKLICH braucht",
"- `web_search`: aktuelle Infos aus dem Netz — Wetter, News, Fakten, "
"Preise, Oeffnungszeiten. Bei Wetter: nimm Stefans Ort aus dem "
"GPS-Hinweis in der Nachricht.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
"- `run_*`-Skills: konkrete Faehigkeiten (z.B. Musik/Spotify u.a.). "
"Was ein Skill kann + welche Parameter er nimmt, steht in SEINER "
"Tool-Beschreibung — LIES sie und nutze den Skill fuer ALLES was "
"dazu passt (nicht nur die offensichtlichen Faelle). Nie selbst eine "
"Aktion 'spielen'/'nachschauen', wenn ein Skill das kann.",
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
"Bedarf; erfinde keine.",
"ANTI-HALLUZINATION (kritisch): Behaupte NIEMALS eine Aktion als "
"erledigt, ohne das Werkzeug WIRKLICH aufgerufen zu haben. Keine "
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
]
parts += [
"",
"## WAS DU HIER NICHT KANNST",
_AWARENESS,
"WICHTIG — du hast Stefans GEDAECHTNIS hier NICHT im Kopf: Bei Fragen zu "
"seinem Leben, zu Personen/Namen, Beziehungen, seiner Vergangenheit, "
"seinen Vorlieben/Sachen oder anderem gespeicherten Wissen antworte NICHT "
"aus dem Nichts (und rate nicht, wer wer ist) — sondern eskaliere. Das "
"grosse Modell kennt das Gedaechtnis und antwortet diskret.",
"Dafuer — und bei tiefen/technischen Fragen, langem Code, oder wenn du "
f"unsicher bist — antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` "
"(nichts sonst). Dann uebernimmt das grosse Modell mit vollem Zugriff. "
"Lieber einmal eskalieren als falsch raten oder ein Werkzeug erfinden.",
]
if pinned_persona.strip():
parts += ["", "## PERSONA", pinned_persona.strip()]
return "\n".join(parts)