feat(local-llm): B1b — lokale Tools (web_search via SearXNG, Timer, Spotify, Memory)
Das lokale Tier kann jetzt Werkzeuge nutzen — Wetter/News/Fakten laufen lokal
statt ueber Claudes langsamen Web-Fetch.
- SearXNG-Container (aria VM, aria-net): self-hosted Meta-Suche, keyless,
JSON-API aktiviert (aria-data/searxng/settings.yml), Rate-Limiter aus.
Brain-Env SEARXNG_URL.
- web_search-Tool in META_TOOLS + _dispatch_tool (_web_search fragt SearXNG,
gibt Titel/Snippet/URL der Top-Treffer). Steht auch Claude zur Verfuegung.
- Lokaler Tool-Loop (_try_local_fast_lane): kuratiertes Set web_search /
memory_search / trigger_timer / run_spotify; max 3 Runden, sonst → Claude.
Break-/Escalate-Guard bleibt.
- Router: should_try_local schliesst nur noch CLAUDE-ONLY-Themen aus (Bild,
Skill, Projekt, OAuth, Licht, Kalender/Mail). Wetter/Timer/News/Musik/Memory
gehen jetzt lokal. Verifiziert (alle Testfaelle korrekt).
- Schlanker Local-Prompt mit Tool-Guidance ("nur nutzen wenn noetig, sonst
Smalltalk direkt beantworten"). Skill-BAUEN bleibt Claude-only.
Deploy: ARIA-VM brain+bridge+searxng, Gamebox llm-adapter (tool-passthrough).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+141
-32
@@ -21,6 +21,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
@@ -37,6 +38,8 @@ import oauth as oauth_mod
|
||||
import projects as projects_mod
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
|
||||
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
|
||||
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
|
||||
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
|
||||
@@ -66,6 +69,38 @@ def _load_flux_config() -> dict:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _web_search(query: str, max_results: int = 5) -> str:
|
||||
"""Fragt die self-hosted SearXNG-Instanz (JSON-API) und gibt die Top-Treffer
|
||||
als kompakten Text zurueck (Titel + Snippet + URL). Nie werfen — Fehler als
|
||||
Text-Resultat, damit der Tool-Loop weitermachen kann."""
|
||||
try:
|
||||
params = urllib.parse.urlencode({
|
||||
"q": query, "format": "json", "language": "de", "safesearch": "0",
|
||||
})
|
||||
req = urllib.request.Request(
|
||||
f"{SEARXNG_URL}/search?{params}",
|
||||
headers={"User-Agent": "ARIA/1.0", "Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
logger.warning("web_search (SearXNG) fehlgeschlagen: %s", exc)
|
||||
return f"FEHLER: Websuche nicht verfuegbar ({exc})."
|
||||
results = (data.get("results") or [])[:max_results]
|
||||
if not results:
|
||||
answers = data.get("answers") or []
|
||||
if answers:
|
||||
return "Direkte Antwort: " + " | ".join(str(a) for a in answers[:3])
|
||||
return f"Keine Web-Treffer fuer '{query}'."
|
||||
lines = [f"{len(results)} Web-Treffer fuer '{query}':"]
|
||||
for i, r in enumerate(results, 1):
|
||||
title = (r.get("title") or "").strip()
|
||||
url = (r.get("url") or "").strip()
|
||||
snippet = (r.get("content") or "").strip()
|
||||
lines.append(f"\n{i}. {title}\n {snippet[:300]}\n Quelle: {url}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Meta-Tool: ARIA kann selbst neue Skills bauen
|
||||
META_TOOLS = [
|
||||
{
|
||||
@@ -811,6 +846,29 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": (
|
||||
"Durchsuche das Web (via SearXNG) nach AKTUELLEN, nachschlagbaren "
|
||||
"Infos: Wetter, News, Fakten, Oeffnungszeiten, Preise, Definitionen "
|
||||
"usw. Nutze das immer, wenn die Antwort aktuelles Wissen braucht, "
|
||||
"das weder im Gedaechtnis noch in deinem Training steht (oder "
|
||||
"veraltet sein koennte). Gib eine praezise Suchanfrage wie bei "
|
||||
"Google. Ergebnis = Titel + Snippet + URL der Top-Treffer; fasse "
|
||||
"daraus knapp die Antwort zusammen und nenne bei Bedarf die Quelle."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Suchanfrage (praezise, Suchmaschinen-Stil)"},
|
||||
"max_results": {"type": "integer", "description": "Anzahl Treffer (Default 5, max 10)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
|
||||
{
|
||||
"type": "function",
|
||||
@@ -1055,62 +1113,104 @@ class Agent:
|
||||
return reply
|
||||
return None
|
||||
|
||||
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a) ──
|
||||
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a/B1b) ──
|
||||
#
|
||||
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns beantwortet
|
||||
# das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber
|
||||
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das
|
||||
# lokale Qwen in <1 s. Seit B1b mit kuratierten Tools (web_search,
|
||||
# memory_search, trigger_timer, Spotify). Gated ueber
|
||||
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
|
||||
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
|
||||
|
||||
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
|
||||
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
|
||||
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
|
||||
|
||||
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b). Namen aus META_TOOLS +
|
||||
# der Spotify-Skill. Bewusst klein (Speed + Sicherheit); alles andere → Claude.
|
||||
_LOCAL_TOOL_NAMES = {"web_search", "memory_search", "trigger_timer"}
|
||||
|
||||
def _build_local_tools(self) -> list:
|
||||
tools = [t for t in META_TOOLS
|
||||
if t.get("function", {}).get("name") in self._LOCAL_TOOL_NAMES]
|
||||
for s in skills_mod.list_skills(active_only=False):
|
||||
if s.get("name") == "spotify" and s.get("active", True):
|
||||
tools.append(_skill_to_tool(s))
|
||||
break
|
||||
return tools
|
||||
|
||||
def _try_local_fast_lane(self, user_message: str,
|
||||
active_project_id: str) -> Optional[str]:
|
||||
cfg = router_mod.load_config()
|
||||
if not router_mod.should_try_local(user_message, cfg):
|
||||
return None
|
||||
local_only = bool(cfg.get("localOnly"))
|
||||
tools = self._build_local_tools() # B1b: kuratierte Tools
|
||||
|
||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR)
|
||||
# Nur die letzten paar Turns ans lokale Modell — es ist fuer kurze
|
||||
# Plauder-Turns da. Volles Fenster (bis 50) wuerde das Prefill aufblaehen
|
||||
# und den Speed-Vorteil auffressen (gemessen: 12 Turns → ~2,6s statt ~0,8s).
|
||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
||||
has_tools=bool(tools))
|
||||
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
|
||||
# wuerde das Prefill aufblaehen).
|
||||
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
|
||||
messages = [{"role": "system", "content": sys_prompt}]
|
||||
messages += [{"role": t.role, "content": t.content} for t in window]
|
||||
|
||||
res = local_llm_chat(messages, max_tokens=400, temperature=0.5)
|
||||
local_only = bool(cfg.get("localOnly"))
|
||||
|
||||
if not res.get("ok"):
|
||||
logger.info("[router] lokal fehlgeschlagen (%s) — %s",
|
||||
res.get("error"), "kein Fallback (localOnly)" if local_only else "→ Claude")
|
||||
if local_only:
|
||||
# Eval-Modus: KEIN Claude. Ehrliche Fehlermeldung statt Stille.
|
||||
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
|
||||
return None
|
||||
|
||||
content = (res.get("content") or "").strip()
|
||||
# Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/
|
||||
# Spotify aufrufen. Ergebnisse zurueck, bis es final (ohne tool_calls) antwortet.
|
||||
final = ""
|
||||
for _ in range(self._LOCAL_TOOL_ITERATIONS):
|
||||
res = local_llm_chat(messages, max_tokens=500, temperature=0.5, tools=tools)
|
||||
if not res.get("ok"):
|
||||
logger.info("[router] lokal fehlgeschlagen (%s) — %s", res.get("error"),
|
||||
"kein Fallback (localOnly)" if local_only else "→ Claude")
|
||||
if local_only:
|
||||
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
|
||||
return None
|
||||
tcs = res.get("tool_calls")
|
||||
if tcs:
|
||||
messages.append({"role": "assistant",
|
||||
"content": res.get("content") or "",
|
||||
"tool_calls": tcs})
|
||||
for tc in tcs:
|
||||
fn = tc.get("function") or {}
|
||||
tname = fn.get("name") or ""
|
||||
try:
|
||||
targs = json.loads(fn.get("arguments") or "{}")
|
||||
except Exception:
|
||||
targs = {}
|
||||
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
|
||||
", ".join(targs.keys()))
|
||||
tresult = self._dispatch_tool(tname, targs)
|
||||
messages.append({"role": "tool",
|
||||
"tool_call_id": tc.get("id") or "",
|
||||
"name": tname,
|
||||
"content": (tresult or "")[:6000]})
|
||||
continue # naechste Runde mit Tool-Ergebnissen
|
||||
final = (res.get("content") or "").strip()
|
||||
break
|
||||
else:
|
||||
logger.info("[router] lokal Tool-Loop-Limit → %s",
|
||||
"Fallback (localOnly)" if local_only else "Claude")
|
||||
if not local_only:
|
||||
return None
|
||||
final = final or "[Lokales LLM: Tool-Loop-Limit erreicht.]"
|
||||
|
||||
if local_only:
|
||||
# Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen.
|
||||
content = content.replace(router_mod.ESCALATE_MARKER, "").strip()
|
||||
if not content:
|
||||
final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
|
||||
if not final:
|
||||
return "[Lokales LLM lieferte keine Antwort.]"
|
||||
logger.info("[router] lokal (localOnly) %sms", res.get("elapsedMs"))
|
||||
self.conversation.add("assistant", content, project_id=active_project_id)
|
||||
return content
|
||||
logger.info("[router] lokal (localOnly) beantwortet")
|
||||
self.conversation.add("assistant", final, project_id=active_project_id)
|
||||
return final
|
||||
|
||||
# Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
|
||||
# Identity-Break → Claude uebernehmen.
|
||||
if (not content or router_mod.ESCALATE_MARKER in content
|
||||
or looks_like_identity_break(content)):
|
||||
if (not final or router_mod.ESCALATE_MARKER in final
|
||||
or looks_like_identity_break(final)):
|
||||
logger.info("[router] lokal eskaliert → Claude")
|
||||
return None
|
||||
|
||||
logger.info("[router] lokal beantwortet in %sms (%d Zeichen)",
|
||||
res.get("elapsedMs"), len(content))
|
||||
self.conversation.add("assistant", content, project_id=active_project_id)
|
||||
return content
|
||||
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
|
||||
self.conversation.add("assistant", final, project_id=active_project_id)
|
||||
return final
|
||||
|
||||
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
||||
|
||||
@@ -1808,6 +1908,15 @@ class Agent:
|
||||
except Exception as e:
|
||||
logger.exception("memory_search fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "web_search":
|
||||
query = (arguments.get("query") or "").strip()
|
||||
if not query:
|
||||
return "FEHLER: query ist Pflicht."
|
||||
try:
|
||||
n = int(arguments.get("max_results", 5))
|
||||
except (TypeError, ValueError):
|
||||
n = 5
|
||||
return _web_search(query, max(1, min(n, 10)))
|
||||
if name == "memory_update":
|
||||
pid = (arguments.get("id") or "").strip()
|
||||
if not pid:
|
||||
|
||||
Reference in New Issue
Block a user