feat(brain): lokale Modelle bewusst waehlen — pro Chat (🧠) oder "@lokal …"

Bisher gab es nur den Schalter "leichte Fragen lokal / alles lokal". Jetzt
waehlt Stefan selbst, wo ein lokales Modell antwortet:
- Pro Chat: der Modell-Picker (🧠, context-model) bietet zusaetzlich die
  gerade geladenen lokalen Modelle als "local:<modell>" (Gruppe "Lokal").
  Steht ein Chat darauf, laeuft JEDER Turn dort lokal — z.B. ein Pentest-
  Projekt auf einem unzensierten Modell. Claude ist in dem Chat raus.
- Eine Frage: "@lokal …" (geladenes Modell) oder "@lokal:<modell> …".
- Lehnt Claude ab, nennt ARIA beide Wege; sie leitet NICHT selbst weiter.

Lokaler Turn: Identitaet + Hinweis "keine Werkzeuge, nichts behaupten",
Notizzettel + Fenster nach Kontext (local_compact), <think> entfernt,
Metrik. Fehler (Modell nicht geladen …) werden angezeigt, aber nicht als
ARIAs Antwort in den Verlauf geschrieben. Antwort-Quelle "local" mit
Modellname und Grund ("pro-chat" / "einmalig").

Getestet: Erkennung von @lokal (auch mit Modell, nach GPS-Hinweis, nicht
bei "@lokalisierung"). Durchlauf mit echtem Modell noch nicht (Karte im
Gegenlauf belegt, Brain nicht neu gebaut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:33:23 +01:00
co-authored by Claude Opus 5.5
parent d289d9404a
commit c8911316d7
3 changed files with 98 additions and 2 deletions
+81
View File
@@ -48,6 +48,11 @@ BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war # Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten. # (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
_NO_MODEL = {"model": "", "requested": "", "reason": ""} _NO_MODEL = {"model": "", "requested": "", "reason": ""}
# Lokale Modelle bewusst waehlen: pro Chat ("local:<modell>" im Modell-Override)
# oder fuer eine Frage ("@lokal …" / "@lokal:<modell> …").
LOCAL_MODEL_PREFIX = "local:"
_AT_LOCAL_RE = re.compile(r"^\s*@lokal(?::([\w.\-]+))?(?=[\s:,]|$)[\s:,]*(.*)$", re.IGNORECASE | re.DOTALL)
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b). # SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/") SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start # FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -2512,6 +2517,61 @@ class Agent:
converse = flags["converse"] converse = flags["converse"]
return speak, converse return speak, converse
def _local_area_route(self, user_message: str, project_id: str):
"""(modell, frage, begruendung) wenn dieser Turn bewusst lokal laufen
soll, sonst None. modell "" = das gerade geladene lokale Modell."""
msg = router_mod._strip_leading_hint_blocks(user_message)
m = _AT_LOCAL_RE.match(msg)
if m:
return (m.group(1) or "").strip(), (m.group(2) or "").strip() or msg, "einmalig"
ov = context_model.get(project_id)
if ov.startswith(LOCAL_MODEL_PREFIX):
return ov[len(LOCAL_MODEL_PREFIX):], msg, context_model.REASON_PER_CHAT
return None
def _local_area_turn(self, question: str, project_id: str, model: str) -> tuple:
"""Ein ganzer Turn auf dem lokalen Modell — ohne Werkzeuge, ohne Claude.
Gibt (antwort, benutztes_modell). Fehler werden NICHT als ARIAs Antwort
in den Verlauf geschrieben (nur angezeigt)."""
sys_prompt = "\n".join([
IDENTITY_ANCHOR.strip(), "",
"## LOKALES MODELL",
"Du laeufst gerade als lokales Modell auf Stefans eigener Hardware — er hat "
"dieses Modell fuer diese Frage bzw. diesen Chat bewusst gewaehlt. Antworte "
"vollstaendig und sachlich auf Deutsch, als ARIA. Du hast hier KEINE "
"Werkzeuge (kein Internet, kein Gedaechtnis, keine Aktionen): behaupte nie, "
"etwas nachgesehen oder ausgefuehrt zu haben. Weisst du etwas nicht sicher, "
"sag das. Normaler Text, kein <voice>, kein [FILE:], keine Tool-Aufrufe.",
])
notes = local_compact.summary_block(project_id)
if notes:
sys_prompt += "\n\n" + notes
all_turns = self.conversation.window(project_id=project_id)
ctx = local_compact.model_ctx(model) if model else local_compact.DEFAULT_CTX * 4
budget = int(ctx * 0.6) - 2500 - local_compact.est_tokens(sys_prompt)
window = local_compact.fit_window(all_turns, max(budget, 512))
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
if question and len(messages) > 1 and messages[-1]["role"] == "user":
messages[-1]["content"] = question # ohne "@lokal" davor
res = local_llm_chat(messages, max_tokens=2000, temperature=0.6,
model=model or None, timeout=600)
used = res.get("model") or model or "lokales Modell"
if not res.get("ok"):
logger.warning("[lokal] %s nicht erreichbar: %s", model or "(geladenes)", res.get("error"))
return (f"⚠ Das lokale Modell {model or ''} hat nicht geantwortet: "
f"{res.get('error', 'unbekannt')}. Liegt es auf einer Karte? "
"(Diagnostic → LLM-Karten)").replace(" ", " "), used
try:
metrics.log_local_call(used, messages, res.get("content") or "", res.get("usage"))
except Exception:
pass
reply = re.sub(r"<think>[\s\S]*?</think>", "", res.get("content") or "")
reply = re.sub(r"</?voice>", "", reply).strip() or "[Das lokale Modell lieferte keine Antwort.]"
self.conversation.add("assistant", reply, project_id=project_id)
logger.info("[lokal] %s beantwortet (%s)", used, project_id or "Hauptchat")
return reply, used
def _try_local_fast_lane(self, user_message: str, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config() cfg = router_mod.load_config()
@@ -2809,6 +2869,19 @@ class Agent:
if active_project_id: if active_project_id:
projects_mod.touch_project(active_project_id) projects_mod.touch_project(active_project_id)
# Lokales Modell bewusst gewaehlt — Claude ist fuer diesen Turn raus:
# * "@lokal …" / "@lokal:<modell> …" nur fuer diese eine Frage
# * Chat steht (🧠, pro Chat) auf "local:<modell>"
# Stefan entscheidet das selbst (z.B. ein Pentest-Projekt auf einem
# unzensierten Modell); nichts wird automatisch umgeleitet.
area = self._local_area_route(user_message, active_project_id)
if area is not None:
model, question, why = area
reply, used = self._local_area_turn(question, active_project_id, model)
converse = not _wants_end
return reply, "local", True, converse, False, {
"model": used, "requested": model or "lokal", "reason": why}
# Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber # Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher # /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der # ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
@@ -2881,6 +2954,14 @@ class Agent:
oauth_callback_host=oauth_host, oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port, oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls) oauth_callback_tls=oauth_tls)
# Bei einer Ablehnung: Stefan kann es bewusst an ein lokales Modell geben.
system_prompt += (
"\n\n## LOKALE MODELLE ALS AUSWEG\n"
"Lehnst du eine Anfrage ab (Richtlinien), sag Stefan kurz, dass er sie selbst "
"an ein lokales Modell auf seiner eigenen Hardware geben kann: mit "
"`@lokal <Frage>` fuer diese eine Frage, oder indem er den Chat oben (🧠) "
"auf ein Modell unter „Lokal“ stellt. Leite NICHT selbst weiter und gib "
"den Inhalt nicht selbst an ein Werkzeug — die Entscheidung trifft er.")
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine # Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt- # echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter # Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
+15 -1
View File
@@ -1343,11 +1343,25 @@ def _context_model_rows() -> dict:
"reason": reason, "reason": reason,
"blocked": sorted(blocked), "blocked": sorted(blocked),
}) })
choices = _model_choices() choices = _model_choices() + _local_model_choices()
names = {c["id"]: c["name"] for c in choices} names = {c["id"]: c["name"] for c in choices}
return {"default": default, "rows": rows, "choices": choices, "names": names} return {"default": default, "rows": rows, "choices": choices, "names": names}
def _local_model_choices() -> list:
"""Lokale Modelle, die gerade auf einer Karte liegen (ARIA-Cluster ueber
die Bridge) — als "local:<modell>" waehlbar pro Chat. Fail-soft: []."""
try:
import httpx as _httpx
base = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090").rstrip("/")
data = _httpx.get(f"{base}/internal/llm-models", timeout=4).json()
return [{"id": "local:" + m["model"], "name": "🖥 lokal: " + m["model"], "section": "local"}
for m in (data.get("models") or []) if m.get("model")]
except Exception as exc:
logger.info("[context-model] lokale Modelle nicht abrufbar: %s", exc)
return []
@app.get("/context-model/list") @app.get("/context-model/list")
def context_model_list(): def context_model_list():
return _context_model_rows() return _context_model_rows()
+2 -1
View File
@@ -3605,7 +3605,8 @@
const row = ctxModelRow(focusedContextId); const row = ctxModelRow(focusedContextId);
if (!row) { bar.innerHTML = ''; return; } if (!row) { bar.innerHTML = ''; return; }
const def = prettyModel(diagCtxModels.default) || diagCtxModels.default || '?'; const def = prettyModel(diagCtxModels.default) || diagCtxModels.default || '?';
const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen' }; const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen',
local: 'Lokal (eigene Hardware, ohne Claude)' };
let opts = `<option value="">Einstellung (${escapeHtml(def)})</option>`; let opts = `<option value="">Einstellung (${escapeHtml(def)})</option>`;
for (const [sec, label] of Object.entries(groups)) { for (const [sec, label] of Object.entries(groups)) {
const inSec = (diagCtxModels.choices || []).filter(c => (c.section || 'tier') === sec); const inSec = (diagCtxModels.choices || []).filter(c => (c.section || 'tier') === sec);