From 36d708bee162a77a2666158215689c51d66b1b45 Mon Sep 17 00:00:00 2001 From: Cursor Date: Sat, 21 Mar 2026 12:06:00 +0100 Subject: [PATCH] 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 --- homelab-ai-bot/STATE.md | 96 +++------- .../__pycache__/llm.cpython-311.pyc | Bin 21340 -> 26528 bytes homelab-ai-bot/llm.py | 164 ++++++++++-------- homelab-ai-bot/tools/deep_research.py | 20 ++- 4 files changed, 134 insertions(+), 146 deletions(-) diff --git a/homelab-ai-bot/STATE.md b/homelab-ai-bot/STATE.md index 029e40ba..5115445f 100644 --- a/homelab-ai-bot/STATE.md +++ b/homelab-ai-bot/STATE.md @@ -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. diff --git a/homelab-ai-bot/__pycache__/llm.cpython-311.pyc b/homelab-ai-bot/__pycache__/llm.cpython-311.pyc index d4aaa64922e7c0e43ef15ad3db6eb9474205e92e..26faf0520f64f264ded98cb46d25342c9f94ac4d 100644 GIT binary patch delta 9432 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 BLI3~& delta 4562 zcmZu!dr(x@8NaW6vJYTcSU}|R5LgsNL_`~n$U{L8BVYqj*LBZjU0HU?xfd04H_*g1 z)Hq3+(@fJzY?-FDIy#wT$TYUo&X`O(Nvt!$j5DQol8l{B)3MV(>?D~<(|`JXcUgJF zGrPZY&i8%i^_}l=?###hrx*F^-?-gQ4$7UIe~dld!Et}bPNujQ8ecy30 zA;023k)IKmDPV7{%VYbhJ@rEYB_MFXEX**8^J>;bp5v~-KV8niQbR?=r37Y*VW7X- zy`29lM?6a4#Hx%u^u0<^TDBnjJF+?V=lL!#_kv@nL@6P@GceiEYS@|UL@C?fmp?fq zlRTwtSRnqh(ongBb8w`Z1e6L=sFaZ+rIHjwy-HaFQdPq)QgYTZR83X`trY$^(n@3) zSwm_}YMD|+$`?3xQUTII;8X%-4g8mKoumr5wpt?eals(-i0}K{$uT7nj%|pJX?2as zyCUp$w%4+^%u=)=Ii|5Us@7O?f%XoYuWFj?zdPgQIKw`wsA@Q>sD>>(77L9i6NY14 zRYF=a8CMO=tM0^Z#uuUmAhE?t!1hT)$sJO@-xmx%D1n6l{t^=i1}LeXtRdNgp{4wh&{%q9K?}wCVblcwM_{pN_3Dc;?nt7xeJ0R=uSDcjJ?8q zCv2HXb-V7+ZMyTSXZcoBS$futJ!8KT>^$qGfqb-O3GToAEA(KYUmU0 zzRji^Ht<$Y2rWP`Cn;qA+EmpqW4|20 z;Hm0@T3%9U4l=Ssk#Ia7Lc^fRaZRCgBar_9fAyaLQ`{YQ?(~tDm8p*T+`xtQmo~6N zjRO@ni~qBnRdb$(^lw_6_m$06Y@W+)nzqg7`loHvHum4fs*Zoyv(LL0Hx~n$I^+^->>h&HnhfqH3{Z zVm7PkZN38cZ&=j{RZ~VU3x-6KamBDjk_k;oXodtfN!h1cD&@<(sRv*kPsSpOO3|nc z+kzR>ZA@l0$3aVDG&wq^ zQ9qk$F3L95Cx+D#x(7u6%CQ^GPh^+Q*e_LI8<_L9-txBI_O`Nttyiq)z$Tw9V(Xhq z3PfF01;Y1X7!ZgU=Tnj{nHHWG(7{lzS}wwNui%!=0v;l<`j-4$-LgC$rL64pz5=f` zWgX-a(kbq=ILMviS!4g2L7V2u3;NgO$4jA>vrOJO^AZ!M>*pB zC4a7Hx@eduS!eAjCwSA$vR#_STd51MOV^5BW)XMFrDwHqhZ8Bc?tW>Y!0#c}6+#}9 z5%qqD;nlq=e}1FxA=&8&Ks^GdJ9Y2!BU6#&q+I9QK`wVe&Z4`33TvO=Z|=ZDd?&#J zbjOSnJx$g83h5eRnxBv&cH1P1+pQp05;w3tC=&WLidP(M3~nZcYFKNs zeSzg8X}(FW2s}u2Ft#uv@LV64Irb5KMWTaXC zqCxOamlb1Hv8nGD_T(p4g97iTPLR*e$ZHsJ7Rs#kf@dQbW}$GZGNseHh>? zN_4*#T%_vx%XL6RvYMS}DP{bCe^U1^XQm4D0vdF1U^WG$I>zbwzv7?gQ-MS!-tyw4 zu$kOcAt}}U5cy@j5W=~X3Jh{YOcg#^m~f}-Q+&psNEu2({5MVN{ygmW>s*uNpqGk8 z{Vm)3iu;4TS3V;w%91s&iOUZFCi%Y?`SUa1F+2Bz&90DRE;?xrx}~~-Nk%+nKvw2ax64LG0@W{ge?fov(@dp__de$eV4hQ zXb9`;4Sv+%H5ENkmZ!`-_O0WgsFF}lkI@}x8d=NkGWJe~k9V*?chqmiWD*n%OFVf> zp@tYvOu#gfhLL>$ScWHbpuM$af2gPBz~1%)ed&33!n|zb?v1>SMRsp*!#Qw=@z`ig z>!4eqOSd6>51`+0gh3Cg7EWlF1(QUNAQPV)bUVTh0Jdt+b6a3xP`e?-$lYsMFsEqF zWw=bGo2fWP(lbAd;|>4>{WO;~ckUW!MaoWu7KB{@MqX6WLYZsGJk==ru3-(2K~^V* zg@zMR#qjpDJR0in?d=X7=pESC)@LSqTE_m?S#=P-k*>i(B3x~-IGqPOk=lh|zAlo; zca18eaEGB7WMTDw?^rw;8KG?`+KynpVIE^oca>Ec;;0&3R2ZaGcxNOCg~vZ^F`c97 zQTBOPt(We`u|ohVMqgI8w`kqY^fSc+c{X$^rX5eeJLwOB|8Erhg~V~+OmSbkIBzj~ z;c))!+U~D-NvgG!v&m3lcE!w|OVR6jbH0vSzK+|z4%XRQ&%OwaS^aL`J};zO z-(SZA{>ibcv^&Us5Ud9LFh}0gAbq%z$96-Y({206DM0%p7msasOBQr)6xzGWr5j}^ zbfetawZnE}oxN+T?M9;oDO)W_DcR9d3%#2`XOAG>+$42x7jJIivAx~WBg_W+2Ce&h zcE#ePt|bwUPpFCvb}GZ89vm+mS4QQ2Fn1aBF871^)wP0G#}ZNbWGpNnjG+tH4TqKE zu_#FP;EO)Ko^EDW2LoB%aBkXz;F|q-@EGqIgf@7Sq)m(|bdc?MtbTF>kPItPv~VmQ z6zDmWehy)Y6GQhdKF#SNghl@eVb^q`*qd;bL2>CL7J2vGhaSal7~vQ~1c3ldQU#k) zgyRS?gcArO2yui_fQ~c1g~N7t_tbSL$06`-lhZzyNAb&tqK(rrgvSvc;Bk~9109U2 z2pTsDIF3#8Iq5ps+1J^-FJwN|DL&EZNrY1frx7Lqf+h4>Y)>OR$j>bB_@xsRpKJ69 z0K-F+;qZ7|TX=*TS?R}UNK@FILpg&hd{4)5Pa-U;8urPVZbzrEbrj(T2u~qAjc^v> z83c4i6`up(g~b)JywVM0qwq~ge*yygd63j3_HlFZ?CXcW=F1j*?z7sa>y7tBKChGi z4~O`biPLR&Sa)yT>=&We`LYF9`)tj|YXn5I+fWn`L;o;L;>|fuMuICO6q#F;wJtFIj_DPry{|`ifHYET6 diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 3801affd..c6c5f36d 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -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: diff --git a/homelab-ai-bot/tools/deep_research.py b/homelab-ai-bot/tools/deep_research.py index 67ae25e4..8b3edbe8 100644 --- a/homelab-ai-bot/tools/deep_research.py +++ b/homelab-ai-bot/tools/deep_research.py @@ -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():