"""OpenRouter LLM-Wrapper mit Tool-Calling. Neue Datenquelle = eine Datei in tools/ anlegen. Fertig. """ import json import logging import requests import os import sys sys.path.insert(0, os.path.dirname(__file__)) from core import config import tool_loader log = logging.getLogger('llm') OLLAMA_BASE = "http://100.84.255.83:11434" OPENROUTER_BASE = "https://openrouter.ai/api/v1" MODEL_LOCAL = "qwen3:30b-a3b" MODEL_VISION = "openai/gpt-4o-mini" MODEL_ONLINE = "perplexity/sonar" FALLBACK_MODEL = "qwen2.5:14b" MAX_TOOL_ROUNDS = 3 OLLAMA_MODELS = {MODEL_LOCAL, FALLBACK_MODEL} def warmup_ollama(): """Laedt Hauptmodell + Embedding permanent in VRAM (keep_alive=-1).""" for model in [MODEL_LOCAL, "nomic-embed-text"]: try: requests.post( f"{OLLAMA_BASE}/api/generate", json={"model": model, "prompt": "", "keep_alive": -1}, timeout=120, ) log.info("Ollama warmup: %s permanent geladen", model) except Exception as e: log.warning("Ollama warmup fehlgeschlagen fuer %s: %s", model, e) PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} _LOCAL_OVERRIDES = [ "api kosten", "api-kosten", "guthaben", "openrouter", "container", "status", "fehler", "logs", "service", "backup", "feed", "memory", "gedaechtnis", "gedächtnis", "mail", "seafile", "forgejo", "grafana", "prometheus", "savetv", "save.tv", "filmtipp", "aufnahme", "wordpress", "matomo", "tailscale", "unsere api", "meine api", "die api", "vorhersage", "prognose", "health forecast", "was bahnt", "systemstatus", "system check", "system-check", "wie gehts dem system", "wie geht es dem system", "versicherung", "vertrag", "verträge", "vertraege", "dokument", "rente", "rentenversicherung", "finanzamt", "steuer", "grundsteuer", "familienbuch", "urkunde", "bescheid", "police", "beitrag", "meine unterlagen", "meine dokumente", "meine dateien", "habe ich", "welche habe", "was habe ich", ] _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", "tiefensuche", "tiefe suche", "detailrecherche", "ausfuehrliche recherche", "ausführliche recherche", "vollstaendige recherche", "vollständige recherche", "recherchiere genau", "analysiere genau"] import datetime as _dt _TODAY = _dt.date.today() _3M_AGO = (_TODAY - _dt.timedelta(days=90)) _DATE_LINE = f'Heutiges Datum: {_TODAY.strftime("%d. %B %Y")}. Wir sind im Jahr {_TODAY.year}. Letzte 3 Monate = {_3M_AGO.strftime("%B %Y")} bis {_TODAY.strftime("%B %Y")}.' SYSTEM_PROMPT = _DATE_LINE + """ Du bist der Hausmeister-Bot fuer ein Homelab. Deutsch, kurz, direkt, operativ. STIL: - So wenig Worte wie moeglich, solange nichts Wichtiges fehlt. - KEINE Abschlussformeln ("Wenn du weitere Informationen benoetigst..."). - KEINE kuenstlichen Wuensche ("Guten Flug!", "Viel Erfolg!"). - KEINE Rueckfragen ob der User mehr wissen will. - Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). GEDAECHTNIS LESEN: Am Ende dieses System-Prompts findest du eine Sektion "=== GEDAECHTNIS ===" mit Fakten die du dir ueber den User und die Umgebung gemerkt hast. WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt "Wo fliege ich hin?" und im Gedaechtnis steht "Ist naechste Woche in Frankfurt", dann antworte direkt mit dieser Info. Das Gedaechtnis ist deine primaere Wissensquelle ueber den User. GEDAECHTNIS — memory_suggest: Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt. memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt). MEMORY_TYPE — immer angeben: - "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme") - "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung") - "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...") - "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.") - "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt") - "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich") CONFIDENCE — immer angeben: - "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White") - "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum") - "low" → Sehr vage ("vielleicht aendere ich das noch") REGELN: - Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen") - Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte - Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern - Passwoerter, Tokens: NIE via memory_suggest speichern. ABER: Wenn der User nach Zugangsdaten fragt, nutze get_service_directory — dort sind alle URLs und Logins hinterlegt. - Bei Widerspruch zu bestehendem Wissen: NUR den neuen Fakt speichern (z.B. "Lieblingskaffee ist Americano"). NICHT den alten Fakt umformulieren oder als "war..." speichern. Das System erkennt Widersprueche automatisch und ersetzt den alten Eintrag. SESSION-RUECKBLICK: - "Was haben wir besprochen?" → session_summary OHNE topic - "Was haben wir ueber X besprochen?" → session_summary MIT topic="X" - NUR echte Gespraechsinhalte aus den Treffern wiedergeben. - KEINE Negativ-Aussagen hinzufuegen. Nichts ueber Dinge sagen die NICHT besprochen wurden. Verboten: "wurde nicht thematisiert", "keine weiteren Details", "nicht besprochen", "nicht erwaehnt". - Wenn es nur 1 Treffer gibt, gib nur diesen 1 Treffer wieder. Fuege nichts hinzu. - Optional kurz erwaehnen was sonst noch Thema war. - session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle). BILDERKENNUNG — ALLGEMEIN: Wenn der User ein Bild schickt das KEIN kritisches Dokument ist (z.B. Foto, Screenshot, Landschaft): - Beschreibe strukturiert was du siehst. - Bei Screenshots von Fehlern/Logs: Identifiziere das Problem, ordne es einem Container/Service zu, schlage Loesung vor. - Bei Folgefragen zum selben Bild: Beantworte anhand der vorherigen Bildbeschreibung in der Session-History. - Speichere erkannte Termine/Plaene via memory_suggest. DOKUMENTENEXTRAKTION — HARTE REGELN: Gilt fuer: Flugtickets, Buchungen, Belege, Rechnungen, Formulare, Behoerdendokumente. Bei diesen Dokumenten ist eine UNVOLLSTAENDIGE aber EHRLICHE Antwort IMMER besser als eine VOLLSTAENDIGE aber ERFUNDENE. 1. NIEMALS RATEN: - Wenn ein Feld nicht sicher lesbar ist: NICHT erfinden. - Keine plausible Uhrzeit ergaenzen. Keine Codes aus Weltwissen ergaenzen. - Stattdessen markieren: "nicht sicher lesbar", "unklar", "im Bild nicht eindeutig". 2. BILDWISSEN VOR WELTWISSEN: - NUR extrahieren was im Bild steht. KEIN externes Wissen um fehlende Felder zu "reparieren". - Wenn im Bild "KTI" steht: "KTI" ausgeben, NICHT stillschweigend zu "PNH" aendern. - Wenn eine Zeit nicht lesbar ist: NICHT eine Standardzeit aus Flugplaenen erfinden. - Interpretation nur in Klammern und nur wenn wirklich sicher: "KTI (vermutl. Phnom Penh Techo International)". 3. ROHTEXT UND INTERPRETATION TRENNEN: - Bei kritischen Feldern zwei Ebenen: was im Bild steht vs. was interpretiert wird. - Wenn Interpretation unsicher: NUR den Rohwert zeigen oder als unklar markieren. 4. FELDWEISE CONFIDENCE: Jedes wichtige Feld bekommt eine Confidence (high/medium/low). Wichtige Felder: Flugnummer, Datum, Abflugzeit, Ankunftszeit, Flughaefen, Gepaeck, Buchungsnummer, Filekey/CRS, Name, Telefon, Preis. - Unsichere Felder NIEMALS mit hoher Sicherheit formulieren. - Low-confidence Felder IMMER kennzeichnen. 5. TABELLARISCH STATT FREIFORM: Ausgabeformat fuer Flugtickets: Allgemeine Angaben: - Fluggesellschaft: [Wert] - Ticketdatum: [Wert] - Buchungsnummer: [Wert] - Kunde: [Wert] - Telefon: [Wert] - Filekey/CRS: [Wert] Flugsegmente: 1. [Von] → [Nach] - Flugnummer: [Wert] - Datum: [Wert] - Abflug: [Wert oder "nicht sicher lesbar"] - Ankunft: [Wert oder "nicht sicher lesbar"] - Gepaeck: [Wert] - Confidence: [high/medium/low] - Unsichere Felder: [Liste oder "keine"] 2. ... 6. ZEICHEN-FUER-ZEICHEN BEI NUMMERN/DATEN: Lies STRIKT Zeichen fuer Zeichen: Datum, Flugnummer, Buchungsnummer, CRS/Filekey, Telefon, Ticketnummer. NICHT glaetten, NICHT umdeuten, NICHT kreativ korrigieren. Tickets zeigen Daten oft als "18MAR", "15MAR" — lies die ZAHL vor dem Monat praezise. Verwechsle NICHT 18 mit 19, 13 mit 15. 7. PLAUSIBILITAETSPRUEFUNG — NUR ALS KONTROLLE: - Darf verwendet werden um falsch gelesene Werte zu erkennen. - Darf NIEMALS fehlende Werte erfinden. - Langstreckenfluege (Suedostasien→Europa = 10-12h): Ankunft VOR Abflug = Ankunftsdatum ist naechster Tag. - Zeitzonen: Suedostasien UTC+7, Mitteleuropa CET/UTC+1 = 6h Differenz. - RECHNE IMMER SELBST NACH. Kopiere NIEMALS Zeitberechnungen aus frueheren Antworten. ANSCHLUSSFLUG-PLAUSIBILITAET (PFLICHT bei aufeinanderfolgenden Segmenten): Pruefe fuer JEDES Segmentpaar auf demselben Ticket: a) Berechne: Differenz zwischen Ankunftszeit Segment N und Abflugzeit Segment N+1. b) Wenn die UHRZEITEN weniger als 3 Stunden auseinander liegen (z.B. 23:30→23:35 oder 20:55→23:25), die DATEN aber einen Tag auseinander: → Das Datum des zweiten Segments hat vermutlich Confidence MEDIUM. → Markiere: "Datum moeglicherweise falsch gelesen — bei gleichem Tag waere Umsteigezeit [X Min/Stunden], bei Tagessprung [~24h]. Bitte pruefen." c) Typische Umsteigezeiten auf einem Ticket: 1-6h. Wenn die berechnete Umsteigezeit >20h ist, ist das ein Warnsignal fuer ein falsch gelesenes Datum. d) Bei Anschlussfluegen mit fast identischen Uhrzeiten (Differenz <30 Min) und genau 1 Tag Abstand: Das Datum ist mit HOHER Wahrscheinlichkeit falsch gelesen → MEDIUM confidence, expliziter Hinweis. 8. KONSERVATIV FORMULIEREN: Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen: - Lieber unvollstaendig aber ehrlich. - NIEMALS praezise falsche Angaben machen. - Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes). PREISRECHERCHE (PFLICHT): Wenn der User nach Preisen, Kosten oder Preisentwicklung fragt: - Nutze IMMER Tools statt Allgemeinwissen. - Fuer schnelle Preisabfrage: web_search. - 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. - Nenne 3-5 Quellen-Links. - Wenn keine belastbaren Zahlen gefunden werden: klar sagen "keine belastbaren Preisdaten gefunden". FORMAT bei Preisantworten: 1) Zeitraum 2) Preis damals -> Preis heute (Delta in %) 3) Kurzfazit (steigend/fallend/stabil) 4) Quellen TOOLS: Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung.""" TOOLS = tool_loader.get_tools() def _get_api_key() -> str: cfg = config.parse_config() 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 _LOCAL_OVERRIDES): return MODEL_LOCAL 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 == MODEL_VISION: return 240 if model == FALLBACK_MODEL: return 90 return 180 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 content = msg.get("content", "") if isinstance(content, str) and "/no_think" not in content: msg["content"] = content + " /no_think" elif isinstance(content, list): 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_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) payload = { "model": chosen, "messages": messages, "max_tokens": max_tokens, } if use_tools: payload["tools"] = TOOLS payload["tool_choice"] = "auto" if use_ollama: url = f"{OLLAMA_BASE}/v1/chat/completions" headers = {"Content-Type": "application/json"} timeout = _ollama_timeout_for(chosen) _add_no_think(payload.get("messages", [])) else: url = f"{OPENROUTER_BASE}/chat/completions" headers = {"Authorization": f"Bearer {api_key}"} timeout = 90 try: r = requests.post(url, headers=headers, json=payload, timeout=timeout) r.raise_for_status() return r.json() except requests.exceptions.ReadTimeout: if use_ollama and allow_fallback and FALLBACK_MODEL and chosen != FALLBACK_MODEL: log.warning( "Ollama timeout for %s after %ss, retrying with %s", chosen, timeout, FALLBACK_MODEL, ) return _call_api( messages, api_key, use_tools=use_tools, model=FALLBACK_MODEL, max_tokens=max_tokens, allow_fallback=False, ) raise def ask(question: str, context: str) -> str: """Legacy-Funktion fuer /commands die bereits Kontext mitbringen.""" api_key = _get_api_key() if not api_key: return "OpenRouter API Key fehlt in homelab.conf" _extra = tool_loader.get_extra_prompt() _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") messages = [ {"role": "system", "content": _full_prompt}, {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, ] try: 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 Routing und Tool-Calling. 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: Perplexity Sonar Deep Research --- if route == "deep_research": log.info("Route: sonar-deep-research") try: messages_dr = [ {"role": "system", "content": "Du bist ein Recherche-Assistent. Antworte auf Deutsch, strukturiert, mit konkreten Zahlen und Quellen."}, {"role": "user", "content": question}, ] data = _call_api(messages_dr, api_key, use_tools=False, model="perplexity/sonar-deep-research", max_tokens=4000) return data["choices"][0]["message"].get("content", "Keine Antwort von Sonar Deep Research.") except Exception as e: return f"Sonar 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) memory_block = memory_client.format_memory_for_prompt(memory_items) except Exception: memory_block = "" try: import openmemory_client openmemory_block = openmemory_client.get_openmemory_for_prompt(question, top_k=8) except Exception: openmemory_block = "" _extra = tool_loader.get_extra_prompt() _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") messages = [ {"role": "system", "content": _full_prompt}, ] _RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir", "worueber haben wir", "was hatten wir besprochen", "was war heute thema"] def _is_recap(text): t = text.lower() return any(m in t for m in _RECAP_MARKERS) if session_id: try: import memory_client history = memory_client.get_session_messages(session_id, limit=10) skip_next_assistant = False for msg in history: role = msg.get("role", "") content = msg.get("content", "") if not content: continue if role == "user" and _is_recap(content): skip_next_assistant = True continue if role == "assistant" and skip_next_assistant: skip_next_assistant = False continue skip_next_assistant = False if role in ("user", "assistant"): messages.append({"role": role, "content": content}) except Exception: pass messages.append({"role": "user", "content": question}) # --- RAG-Pflicht: Wenn Dokument-Keywords erkannt, rag_search erzwingen --- _DOC_KEYWORDS = [ "versicherung", "vertrag", "verträge", "dokument", "rente", "finanzamt", "steuer", "grundsteuer", "familienbuch", "urkunde", "bescheid", "police", "beitrag", "mietvertrag", "arbeitsvertrag", "kindergeld", "rechnung", "haftpflicht", "rechtsschutz", "lebensversicherung", "bauspar", "reisepass", "personalausweis", "lvm", "allianz", "ergo", "huk", "nürnberger", "nuernberger", ] if route == MODEL_LOCAL and any(k in question.lower() for k in _DOC_KEYWORDS): messages.insert(-1, {"role": "system", "content": "PFLICHT: Diese Frage betrifft persoenliche Dokumente. " "Du MUSST rag_search aufrufen BEVOR du antwortest. " "Antworte NIEMALS aus dem Gedaechtnis oder der Session-History ohne vorher gesucht zu haben."}) # --- 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_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." messages.append(msg) for tc in tool_calls: fn_name = tc["function"]["name"] try: fn_args = json.loads(tc["function"]["arguments"]) except (json.JSONDecodeError, KeyError): fn_args = {} log.info("Tool-Call: %s args=%s", fn_name, str(fn_args)[:200]) handler = tool_handlers.get(fn_name) if handler: try: result = handler(**fn_args) except Exception as e: result = f"Fehler bei {fn_name}: {e}" else: result = f"Unbekanntes Tool: {fn_name}" result_str = str(result)[:3000] if fn_name in PASSTHROUGH_TOOLS and not result_str.startswith(("Fehler", "Keine")): log.info("Passthrough-Tool %s", fn_name) passthrough_result = result_str messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result_str, }) if passthrough_result: return passthrough_result data = _call_api(messages, api_key, use_tools=False, model=MODEL_LOCAL) return data["choices"][0]["message"]["content"] except Exception as e: return f"LLM-Fehler: {e}" def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str: """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" try: import memory_client query = caption if caption else "Bild-Analyse" memory_items = memory_client.get_relevant_memory(query, top_k=10) memory_block = memory_client.format_memory_for_prompt(memory_items) except Exception: memory_block = "" try: import openmemory_client q = caption if caption else "Bild-Analyse" openmemory_block = openmemory_client.get_openmemory_for_prompt(q, top_k=8) except Exception: openmemory_block = "" default_prompt = ( "Analysiere dieses Bild. " "Wenn es ein Dokument ist (Ticket, Rechnung, Beleg, Buchung): " "Wende die DOKUMENTENEXTRAKTION-Regeln an — feldweise, konservativ, mit Confidence pro Feld. " "Markiere unsichere Felder explizit. Erfinde NICHTS. " "Lies Nummern und Daten Zeichen fuer Zeichen. " "Wenn es ein normales Bild ist: Beschreibe strukturiert was du siehst." ) prompt_text = caption if caption else default_prompt user_content = [ {"type": "text", "text": prompt_text}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}", "detail": "high"}}, ] _extra = tool_loader.get_extra_prompt() _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") messages = [ {"role": "system", "content": _full_prompt}, ] if session_id: try: import memory_client history = memory_client.get_session_messages(session_id, limit=6) for msg in history: role = msg.get("role", "") content = msg.get("content", "") if content and role in ("user", "assistant"): messages.append({"role": role, "content": content}) except Exception: pass messages.append({"role": "user", "content": user_content}) try: for _round in range(MAX_TOOL_ROUNDS): data = _call_api(messages, api_key, use_tools=True, model=MODEL_VISION, max_tokens=4000, allow_fallback=False) 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) for tc in tool_calls: fn_name = tc["function"]["name"] try: fn_args = json.loads(tc["function"]["arguments"]) except (json.JSONDecodeError, KeyError): fn_args = {} handler = tool_handlers.get(fn_name) if handler: try: result = handler(**fn_args) except Exception as e: result = f"Fehler bei {fn_name}: {e}" else: result = f"Unbekanntes Tool: {fn_name}" messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": str(result)[:3000], }) data = _call_api(messages, api_key, use_tools=False, model=MODEL_VISION, max_tokens=4000, allow_fallback=False) return data["choices"][0]["message"]["content"] except requests.exceptions.ReadTimeout: return ( "Das Vision-Modell antwortet nicht (Timeout). " "Bitte in 1-2 Min erneut versuchen — das Modell wird gerade geladen." ) except Exception as e: return f"Vision-LLM-Fehler: {e}"