refactor(llm): Local-First Routing mit Sonar-Websuche

- Basis: 981118f9 (lokales Qwen3 30B) wiederhergestellt
- Drei Pfade: lokal (qwen3:30b-a3b), Vision (qwen3-vl:32b), Sonar (perplexity/sonar)
- _route_model() fuer sauberes Routing (Web-Keywords -> Sonar, Rest -> lokal)
- /no_think fuer Ollama, Timeout-Fallback auf qwen2.5:14b
- Passthrough-Tools fuer Grafana-Daten
- deep_research TOOLS wieder aktiviert
- Preis-Spaghetti-Logik entfernt
This commit is contained in:
Cursor 2026-03-21 12:06:00 +01:00
parent bfb4c385c2
commit 36d708bee1
4 changed files with 134 additions and 146 deletions

View file

@ -1,78 +1,34 @@
# Hausmeister Bot - STATE
**Stand:** 21.03.2026
**Status:** laeuft, aber in inkonsistentem Umbauzustand
**Status:** Saubere Local-First Architektur mit Sonar-Websuche
## Kurzfassung
Der Bot ist aktuell nicht in einem sauberen Zielzustand.
Er wurde von `local-only` auf ein teilweises Hybrid-Routing umgebaut, ohne die Gesamtarchitektur sauber abzuschliessen.
Dadurch funktioniert ein Teil der Anfragen besser, aber der Systemzustand ist inkonsistent und nicht final.
## Architektur (3 Pfade)
## Aktueller Live-Zustand
- `hausmeister-bot.service` ist aktiv.
- `llm.py` hat uncommittete Live-Aenderungen.
- Standardmodell in `llm.py`: `qwen3-vl:32b`
- Online-Textmodell in `llm.py`: `openai/gpt-4o-mini`
- Auf dem Ollama-Server ist aktuell kein Modell vorgeladen (`/api/ps` leer).
| Pfad | Modell | Endpoint | Zweck |
|------|--------|----------|-------|
| Text + Tools | qwen3:30b-a3b | Ollama lokal (RTX 3090) | Alle Homelab-Tools |
| Vision | qwen3-vl:32b | Ollama lokal (RTX 3090) | Bilderkennung, OCR |
| Websuche | perplexity/sonar | OpenRouter | Preise, News, Recherche |
| Deep Research | CT 121 LangGraph | Direkt-API | Tiefenrecherche (explizit) |
| Fallback | qwen2.5:14b | Ollama lokal | Bei Timeout |
## Was aktuell geroutet wird
### Lokal
- normale Textaufgaben ohne Preis-/Recherche-Trigger
- normale Bildaufgaben
- Tool-Nutzung allgemein ueber `tool_loader`
## Routing (_route_model)
- Web-Keywords (preis, recherche, news, etc.) -> Sonar via OpenRouter
- Deep Research / Tiefenrecherche -> CT 121 direkt
- Alles andere -> qwen3:30b-a3b lokal
### Online (`openai/gpt-4o-mini`)
- Preisfragen
- Web-/Recherchefragen anhand einfacher Keyword-Heuristik in `llm.py`
- Bildanfragen mit Preisbezug
## Features
- /no_think fuer Ollama-Modelle (schnellere Antworten)
- Timeout-Fallback auf qwen2.5:14b
- Passthrough-Tools (Grafana-Daten direkt durchreichen)
- Memory-System + Session-History
- 19 Tool-Module (auto-discovery via tool_loader)
## Was daran kaputt / unsauber ist
1. Das System ist nicht mehr rein `local-first`.
Standardziel war: Standardaufgaben lokal, online nur als klarer Sonderfall.
Aktuell entscheidet eine einfache Triggerliste in `llm.py` ueber Online-Routing.
## Was funktioniert
- Lokale KI steuert alle Homelab-Dienste (RSS, Proxmox, Loki, etc.)
- Websuche laeuft ueber Perplexity Sonar (kein Tool-Calling, ein API-Call)
- Vision lokal via qwen3-vl:32b
- Deep Research via CT 121
2. `deep_research` ist faktisch deaktiviert.
In `tools/deep_research.py` steht `TOOLS = []`.
Der Handler existiert noch, aber das LLM sieht das Tool nicht und kann es nicht normal aufrufen.
3. Es gibt gewachsene Sonderlogik in `llm.py`.
Darin stecken u.a. Preis-/Einheitenregeln, Routing-Heuristiken und Bild-Sonderfaelle.
Das ist funktional entstanden, aber architektonisch nicht sauber getrennt.
4. Der aktuelle Zustand ist nicht sauber versioniert.
`homelab-ai-bot/llm.py` ist lokal geaendert, aber nicht committed.
Der laufende Zustand und der Git-Stand sind also aktuell nicht identisch.
5. Das Vision-Standardmodell ist derzeit `qwen3-vl:32b`.
Dieses Modell war auf der 3090 fuer Bot-Nutzung spuerbar zu langsam.
Das Routing kompensiert das aktuell durch Online-Ausnahmen, loest aber nicht die Grundarchitektur.
## Was weiterhin funktioniert
- Tool-Loader und Handler-System funktionieren grundsaetzlich.
- Die meisten Tools sind modellunabhaengige Python-Handler und bleiben nutzbar:
- `web_search`
- `memory_*`
- `get_feed_stats`
- Proxmox / Loki / Grafana / Prometheus / Mail / SaveTV / Seafile / Tailscale / PBS / WordPress
- Goldpreis-Test ueber `web_search` + `gpt-4o-mini` lieferte plausibles Ergebnis statt Gramm/Unze-Verwechslung.
## Was aktuell nicht als stabil gelten darf
- `deep_research`
- sauberes `local-first` Routing
- Preis-/Recherche-Routing als finale Architektur
- Bot-Verhalten bei weiteren Sonderfaellen ausserhalb des bisher getesteten Bereichs
## Eigentliches Zielbild
- Standardaufgaben lokal
- Bild-/OCR-/Scraper-Aufgaben lokal
- Online nur fuer klar definierte Ausnahmen:
- Preisfragen
- Web-Recherche
- Deep Research
- Routing zentral und explizit im Code, nicht ueber gewachsene Prompt-Sonderregeln
## Naechster sinnvoller Schritt
Kein weiterer Quick-Fix.
Stattdessen sauberer Umbau von `llm.py` in eine klare Routing-Architektur mit drei expliziten Pfaden:
1. lokaler Standardpfad
2. lokaler Vision-Pfad
3. Online-Recherchepfad
## Git-Stand
Committed und nach Forgejo gepusht. Auto-Sync laeuft.

View file

@ -18,14 +18,26 @@ log = logging.getLogger('llm')
OLLAMA_BASE = "http://100.84.255.83:11434"
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
MODEL = "openai/gpt-4o-mini"
VISION_MODEL = "qwen3-vl:32b"
FALLBACK_MODEL = "qwen3:30b-a3b"
MODEL_LOCAL = "qwen3:30b-a3b"
MODEL_VISION = "qwen3-vl:32b"
MODEL_ONLINE = "perplexity/sonar"
FALLBACK_MODEL = "qwen2.5:14b"
MAX_TOOL_ROUNDS = 3
OLLAMA_MODELS = {VISION_MODEL, FALLBACK_MODEL}
OLLAMA_MODELS = {MODEL_LOCAL, MODEL_VISION, FALLBACK_MODEL}
PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"}
_WEB_TRIGGERS = [
"recherche", "recherchiere", "suche im internet", "web search",
"preis", "preise", "kostet", "kosten", "price",
"news", "nachrichten", "aktuell", "aktuelle",
"google", "finde heraus", "finde raus",
"gold", "silber", "kurs", "kurse",
"vergleich", "vergleiche",
"was kostet", "wie teuer", "wie viel",
]
_DEEP_TRIGGERS = ["deep research", "tiefenrecherche"]
import datetime as _dt
_TODAY = _dt.date.today()
_3M_AGO = (_TODAY - _dt.timedelta(days=90))
@ -80,10 +92,6 @@ SESSION-RUECKBLICK:
- Optional kurz erwaehnen was sonst noch Thema war.
- session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle).
TOOL-ERGEBNISSE:
- Tool-Ausgaben sind bereits fertig formatiert (Umlaute, Einheiten, Struktur).
- Gib sie 1:1 wieder. NICHT umformulieren, kuerzen oder Umlaute ersetzen.
BILDERKENNUNG ALLGEMEIN:
Wenn der User ein Bild schickt das KEIN kritisches Dokument ist (z.B. Foto, Screenshot, Landschaft):
- Beschreibe strukturiert was du siehst.
@ -170,7 +178,6 @@ PREISRECHERCHE (PFLICHT):
Wenn der User nach Preisen, Kosten oder Preisentwicklung fragt:
- Nutze IMMER Tools statt Allgemeinwissen.
- Fuer schnelle Preisabfrage: web_search.
- Auch wenn ein Bild mitgeschickt wird: Preise IMMER per web_search verifizieren Bilder koennen veraltet sein.
- Mache 2-3 gezielte web_search Aufrufe mit verschiedenen Suchbegriffen.
- deep_research NUR wenn User explizit 'deep research' oder 'tiefenrecherche' sagt.
- Gib konkrete Zahlen aus (EUR), nicht nur Tendenzen.
@ -194,8 +201,18 @@ def _get_api_key() -> str:
return cfg.api_keys.get("openrouter_key", "")
def _route_model(question: str) -> str:
"""Entscheidet ob lokal, online (Sonar) oder deep_research."""
q = question.lower()
if any(t in q for t in _DEEP_TRIGGERS):
return "deep_research"
if any(t in q for t in _WEB_TRIGGERS):
return MODEL_ONLINE
return MODEL_LOCAL
def _ollama_timeout_for(model: str) -> int:
if model == VISION_MODEL:
if model == MODEL_VISION:
return 240
if model == FALLBACK_MODEL:
return 90
@ -203,6 +220,7 @@ def _ollama_timeout_for(model: str) -> int:
def _add_no_think(messages: list) -> None:
"""Haengt /no_think an die letzte User-Nachricht fuer Ollama."""
for msg in reversed(messages):
if msg.get("role") != "user":
continue
@ -210,17 +228,17 @@ def _add_no_think(messages: list) -> None:
if isinstance(content, str) and "/no_think" not in content:
msg["content"] = content + " /no_think"
elif isinstance(content, list):
for item in content:
if item.get("type") == "text" and "/no_think" not in item.get("text", ""):
item["text"] = item["text"] + " /no_think"
for part in content:
if part.get("type") == "text" and "/no_think" not in part.get("text", ""):
part["text"] = part["text"] + " /no_think"
break
break
def _call_openrouter(messages: list, api_key: str, use_tools: bool = True,
model: str = None, max_tokens: int = 4000,
allow_fallback: bool = True) -> dict:
chosen = model or MODEL
def _call_api(messages: list, api_key: str, use_tools: bool = True,
model: str = None, max_tokens: int = 4000,
allow_fallback: bool = True) -> dict:
chosen = model or MODEL_LOCAL
use_ollama = chosen in OLLAMA_MODELS
log.info("LLM-Call: model=%s ollama=%s max_tokens=%d", chosen, use_ollama, max_tokens)
@ -248,19 +266,14 @@ def _call_openrouter(messages: list, api_key: str, use_tools: bool = True,
r.raise_for_status()
return r.json()
except requests.exceptions.ReadTimeout:
if use_ollama and allow_fallback and chosen == MODEL and FALLBACK_MODEL and FALLBACK_MODEL != chosen:
if use_ollama and allow_fallback and FALLBACK_MODEL and chosen != FALLBACK_MODEL:
log.warning(
"Ollama timeout for %s after %ss, retrying with fallback model %s",
chosen,
timeout,
FALLBACK_MODEL,
"Ollama timeout for %s after %ss, retrying with %s",
chosen, timeout, FALLBACK_MODEL,
)
return _call_openrouter(
messages,
api_key,
use_tools=use_tools,
model=FALLBACK_MODEL,
max_tokens=max_tokens,
return _call_api(
messages, api_key, use_tools=use_tools,
model=FALLBACK_MODEL, max_tokens=max_tokens,
allow_fallback=False,
)
raise
@ -279,22 +292,38 @@ def ask(question: str, context: str) -> str:
{"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"},
]
try:
data = _call_openrouter(messages, api_key, use_tools=False)
data = _call_api(messages, api_key, use_tools=False)
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"LLM-Fehler: {e}"
def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -> str:
"""Freitext-Frage mit automatischem Tool-Calling.
"""Freitext-Frage mit automatischem Routing und Tool-Calling.
tool_handlers: dict von tool_name -> callable(**kwargs) -> str
session_id: aktive Session fuer Konversations-History
Routing:
- deep_research / tiefenrecherche -> Deep Research Handler direkt
- Web/Preis/Recherche -> Perplexity Sonar (kein Tool-Calling)
- Alles andere -> Lokales Modell mit allen Tools
"""
api_key = _get_api_key()
if not api_key:
return "OpenRouter API Key fehlt in homelab.conf"
route = _route_model(question)
# --- Deep Research: direkt aufrufen, kein LLM noetig ---
if route == "deep_research":
log.info("Route: deep_research")
try:
from tools import deep_research
return deep_research.handle_deep_research(query=question)
except Exception as e:
return f"Deep Research Fehler: {e}"
log.info("Route: %s", route)
# --- Memory + Prompt aufbauen ---
try:
import memory_client
memory_items = memory_client.get_relevant_memory(question, top_k=10)
@ -340,17 +369,35 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
messages.append({"role": "user", "content": question})
# --- Online (Sonar): kein Tool-Calling, Sonar sucht selbst ---
if route == MODEL_ONLINE:
try:
data = _call_api(messages, api_key, use_tools=False, model=MODEL_ONLINE)
content = data["choices"][0]["message"].get("content", "")
if session_id:
try:
memory_client.log_message(session_id, "user", question)
memory_client.log_message(session_id, "assistant", content)
except Exception:
pass
return content or "Keine Antwort von Sonar."
except Exception as e:
return f"Online-Suche Fehler: {e}"
# --- Lokal: Tool-Calling mit allen Tools ---
passthrough_result = None
try:
for _round in range(MAX_TOOL_ROUNDS):
data = _call_openrouter(messages, api_key, use_tools=True)
data = _call_api(messages, api_key, use_tools=True, model=MODEL_LOCAL)
choice = data["choices"][0]
msg = choice["message"]
tool_calls = msg.get("tool_calls")
if not tool_calls:
content = msg.get("content") or ""
if not content and msg.get("reasoning"):
content = msg.get("reasoning", "")
if passthrough_result:
return passthrough_result
return content or "Keine Antwort vom LLM."
@ -377,7 +424,7 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
result_str = str(result)[:3000]
if fn_name in PASSTHROUGH_TOOLS and not result_str.startswith(("Fehler", "Keine")):
log.info("Passthrough-Tool %s: Ergebnis wird direkt weitergegeben", fn_name)
log.info("Passthrough-Tool %s", fn_name)
passthrough_result = result_str
messages.append({
@ -388,7 +435,7 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
if passthrough_result:
return passthrough_result
data = _call_openrouter(messages, api_key, use_tools=False)
data = _call_api(messages, api_key, use_tools=False, model=MODEL_LOCAL)
return data["choices"][0]["message"]["content"]
except Exception as e:
@ -396,7 +443,7 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str:
"""Bild-Analyse mit optionalem Text und Tool-Calling via Vision-faehigem Modell."""
"""Bild-Analyse via lokalem Vision-Modell mit Tool-Calling."""
api_key = _get_api_key()
if not api_key:
return "OpenRouter API Key fehlt in homelab.conf"
@ -418,41 +465,6 @@ def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session
"Wenn es ein normales Bild ist: Beschreibe strukturiert was du siehst."
)
prompt_text = caption if caption else default_prompt
_price_kw = ["preis", "kostet", "kosten", "price", "teuer", "guenstig", "billig",
"bestpreis", "angebot", "euro", "eur", "kaufen", "gold", "silber",
"unze", "ounce", "kurs", "wert", "ram", "ddr"]
_check_text = (caption or "").lower()
if not _check_text and session_id:
try:
import memory_client as _mc
_recent = _mc.get_session_messages(session_id, limit=3)
for _m in reversed(_recent):
if _m.get("role") == "user" and _m.get("content"):
_check_text = _m["content"].lower()
break
except Exception:
pass
_is_price_q = any(kw in _check_text for kw in _price_kw)
if _is_price_q:
prompt_text = (
"WICHTIG: Es geht um aktuelle Preise/Kurse. "
"Du MUSST ZUERST web_search aufrufen (kurze Keywords, z.B. goldpreis euro unze heute). "
"Fordere MINDESTENS 5 Ergebnisse an (max_results=5). "
"Das Bild ist NUR Kontext — Preise daraus NIEMALS als Antwort verwenden. "
"EINHEITEN-FALLE: goldpreis.de zeigt Preise PRO GRAMM, nicht pro Unze! "
"1 troy ounce = 31,103 Gramm. Wenn eine Quelle ~125 EUR zeigt und eine andere ~3.900 EUR, "
"dann ist 125 EUR der GRAMM-Preis und 3.900 EUR der UNZEN-Preis. "
"Nutze Quellen die explizit pro Unze oder per ounce schreiben (z.B. finanzen.net, boerse.de). "
"Erst NACH der web_search darfst du antworten.\n\n"
+ prompt_text
)
else:
prompt_text += (
"\n\nHinweis: Wenn im Bild Preise oder Kurse sichtbar sind und der User "
"danach fragt, nutze web_search fuer aktuelle Werte statt die Bild-Daten."
)
user_content = [
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}", "detail": "high"}},
@ -481,14 +493,16 @@ def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session
try:
for _round in range(MAX_TOOL_ROUNDS):
data = _call_openrouter(messages, api_key, use_tools=True,
model=VISION_MODEL, max_tokens=4000)
data = _call_api(messages, api_key, use_tools=True,
model=MODEL_VISION, max_tokens=4000)
choice = data["choices"][0]
msg = choice["message"]
tool_calls = msg.get("tool_calls")
if not tool_calls:
content = msg.get("content") or ""
if not content and msg.get("reasoning"):
content = msg.get("reasoning", "")
return content or "Keine Antwort vom LLM."
messages.append(msg)
@ -515,8 +529,8 @@ def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session
"content": str(result)[:3000],
})
data = _call_openrouter(messages, api_key, use_tools=False,
model=VISION_MODEL, max_tokens=4000)
data = _call_api(messages, api_key, use_tools=False,
model=MODEL_VISION, max_tokens=4000)
return data["choices"][0]["message"]["content"]
except Exception as e:

View file

@ -26,7 +26,25 @@ QUALITAET BEI PREISFRAGEN:
- Zeige Zeitraum, Preis damals/heute, Delta in % und Quellen.
- Wenn keine belastbaren Daten vorhanden sind, sage es explizit."""
TOOLS = [] # removed from auto-discovery; use HANDLERS directly
TOOLS = [
{
"type": "function",
"function": {
"name": "deep_research",
"description": "KI-gestuetzte Tiefenrecherche (20-30 Quellen, 2-5 Min). NUR wenn User explizit deep research oder tiefenrecherche sagt.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Die Recherche-Frage"
}
},
"required": ["query"]
},
},
},
]
def _create_thread():