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:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user