"""OpenRouter LLM-Wrapper mit Tool-Calling. Das LLM entscheidet selbst welche Datenquellen es abfragt. Neue Datenquelle = Tool-Definition hier + Handler in context.py. """ import json import requests import os import sys sys.path.insert(0, os.path.dirname(__file__)) from core import config MODEL = "openai/gpt-4o-mini" VISION_MODEL = "openai/gpt-4o" MAX_TOOL_ROUNDS = 3 SYSTEM_PROMPT = """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 speichern - 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). ANALYTICS-INTERPRETATION (Matomo): Wenn du Analytics-Daten bekommst (get_matomo_analytics, get_matomo_trend), interpretiere sie QUALIFIZIERT: - Nenne nicht nur Zahlen, sondern BEWERTE sie ("88% Bounce Rate ist schlecht", "42s Verweildauer ist zu kurz") - Vergleiche IMMER mit der Vorwoche wenn Daten vorhanden ("30% mehr als letzte Woche") - Nenne den TREND klar ("Traffic steigt seit 5 Tagen", "Ruecklaeufig seit Montag") - Bei Peaks: Vermute WARUM ("Am 24.02. 147 Besucher — pruefe welcher Artikel viral ging") - Bei hoher Abhaengigkeit von einer Quelle: WARNE ("80% kommt von Google — riskant") - Gib 1-2 konkrete EMPFEHLUNGEN ("Bounce Rate senken: Ueberschriften verbessern, Ladezeit pruefen") - Wochentags-Muster nutzen: "Dienstag ist dein staerkster Tag — poste neue Artikel dienstags" - NICHT: endlose Zahlentabellen wiedergeben. Fasse zusammen, hebe das Wichtige hervor. - Format: Kurze Absaetze, KEINE langen Listen. Wie ein Analytics-Berater der auf den Punkt kommt. TOOLS: Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung.""" TOOLS = [ { "type": "function", "function": { "name": "get_all_containers", "description": "Status aller Container auf allen Proxmox-Servern (running/stopped, RAM, Uptime)", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_container_detail", "description": "Detail-Status eines einzelnen Containers. Suche per VMID (z.B. 101) oder Name (z.B. wordpress, rss-manager, forgejo)", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "VMID (z.B. '109') oder Container-Name (z.B. 'wordpress')"} }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "count_errors", "description": "Zählt Fehler-Logs aus Loki und gibt ANZAHL pro Host zurück. Nutze dieses Tool wenn nach der ANZAHL von Fehlern gefragt wird (z.B. 'wieviele Fehler', 'wie oft', 'Fehleranzahl').", "parameters": { "type": "object", "properties": { "hours": {"type": "number", "description": "Zeitraum in Stunden (z.B. 24 = heute, 72 = 3 Tage, 168 = 1 Woche)", "default": 24} }, "required": [], }, }, }, { "type": "function", "function": { "name": "get_errors", "description": "Zeigt Fehler-Logs aus Loki mit Beispielen (Inhalt der Fehlermeldungen). Nutze dieses Tool wenn nach dem INHALT oder DETAILS von Fehlern gefragt wird.", "parameters": { "type": "object", "properties": { "hours": {"type": "number", "description": "Zeitraum in Stunden (default: 2)", "default": 2} }, "required": [], }, }, }, { "type": "function", "function": { "name": "get_container_logs", "description": "Letzte Logs eines bestimmten Containers aus Loki", "parameters": { "type": "object", "properties": { "container": {"type": "string", "description": "Hostname des Containers (z.B. 'rss-manager', 'wordpress-v2')"}, "hours": {"type": "number", "description": "Zeitraum in Stunden (default: 1)", "default": 1}, }, "required": ["container"], }, }, }, { "type": "function", "function": { "name": "get_silent_hosts", "description": "Welche Hosts senden keine Logs mehr? (Stille-Check)", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_server_metrics", "description": "CPU, RAM, Disk, Load, Uptime von Proxmox-Servern via Prometheus. Ohne host = alle Server.", "parameters": { "type": "object", "properties": { "host": { "type": "string", "description": "Hostname (pve-hetzner, pve-ka-1, pve-ka-2, pve-ka-3, pve-mu-2, pve-mu-3, pve-he, pbs-mu). Leer = alle.", } }, "required": [], }, }, }, { "type": "function", "function": { "name": "get_server_warnings", "description": "Nur Warnungen: Server mit CPU>80%, RAM>85% oder Disk>85%", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_wordpress_stats", "description": "WordPress INTERNE Statistiken: Anzahl veroeffentlichter Posts/Artikel, offene Kommentare, Plugin-Status. NICHT fuer Besucherzahlen — dafuer get_matomo_analytics nutzen.", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_feed_stats", "description": "RSS-Feed-Status: Aktive Feeds, Artikel heute/gestern, Fehler", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_matomo_analytics", "description": "Matomo Website-Analytik: Besucher heute, Trend, Top-Seiten, Traffic-Quellen, Laender. Fuer alle Fragen zu Besucherzahlen, Zuschauern, Traffic, Seitenaufrufen, Bounce Rate, Herkunftslaendern.", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_matomo_trend", "description": "Besucherentwicklung ueber Zeit: Tageweise Besucherzahlen ueber N Tage. Nutze bei Fragen wie 'wie entwickeln sich die Zuschauer', 'Besuchertrend', 'wachsen wir'.", "parameters": { "type": "object", "properties": { "days": {"type": "number", "description": "Anzahl Tage zurueckblicken (default: 30)", "default": 30} }, "required": [], }, }, }, { "type": "function", "function": { "name": "get_forgejo_status", "description": "Forgejo Git-Server: Offene Issues/TODOs, letzte Commits, Repos", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "create_issue", "description": "Neues TODO/Issue in Forgejo erstellen", "parameters": { "type": "object", "properties": { "title": {"type": "string", "description": "Titel des Issues"}, "body": {"type": "string", "description": "Beschreibung (optional)", "default": ""}, }, "required": ["title"], }, }, }, { "type": "function", "function": { "name": "close_issue", "description": "Ein bestehendes Issue/TODO in Forgejo schliessen (als erledigt markieren)", "parameters": { "type": "object", "properties": { "number": {"type": "integer", "description": "Issue-Nummer (z.B. 3)"}, }, "required": ["number"], }, }, }, { "type": "function", "function": { "name": "get_seafile_status", "description": "Seafile Cloud-Speicher: Belegter Speicherplatz, Bibliotheken", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_backup_status", "description": "Proxmox Backup Server (PBS): Datastore-Belegung, letzte Backups, Snapshot-Anzahl", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_mail_summary", "description": "E-Mail Übersicht: Anzahl Mails, ungelesene, letzte Mails, wichtige Absender (Bank, Hoster, etc.)", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_mail_count", "description": "Anzahl E-Mails (gesamt und ungelesen)", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "search_mail", "description": "E-Mails durchsuchen nach Absender oder Betreff", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Suchbegriff (Absender oder Betreff, z.B. 'PayPal', 'Rechnung', 'Hetzner')"}, "days": {"type": "integer", "description": "Zeitraum in Tagen (default: 30)", "default": 30}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "get_todays_mails", "description": "Alle E-Mails von heute", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_smart_mail_digest", "description": "Intelligente Mail-Zusammenfassung: KI klassifiziert Mails in Wichtig/Aktion/Info/Newsletter/Spam. Nutze dies wenn der User nach 'wichtigen Mails' fragt oder wissen will ob etwas Relevantes dabei ist.", "parameters": { "type": "object", "properties": { "hours": {"type": "integer", "description": "Zeitraum in Stunden (default: 24)", "default": 24}, }, "required": [], }, }, }, { "type": "function", "function": { "name": "memory_read", "description": "Liest persistente Gedaechtnis-Eintraege (Fakten ueber User, Umgebung, Projekte). Nutze dieses Tool wenn du wissen willst was du dir gemerkt hast.", "parameters": { "type": "object", "properties": { "scope": {"type": "string", "description": "Filter: user, environment, project (leer = alle)", "default": ""}, }, "required": [], }, }, }, { "type": "function", "function": { "name": "memory_suggest", "description": "Speichert einen Fakt direkt im Langzeitgedaechtnis. IMMER aufrufen wenn der User Reiseplaene, Termine, Vorlieben, Beziehungen, Projektstatus oder stabile Fakten mitteilt. System erkennt Duplikate und ersetzt Widersprueche automatisch.", "parameters": { "type": "object", "properties": { "scope": {"type": "string", "enum": ["user", "environment", "project"], "description": "user=persoenlich, environment=Infrastruktur, project=Projekt"}, "kind": {"type": "string", "enum": ["fact", "preference", "rule", "note"], "description": "Grobe Kategorie"}, "content": {"type": "string", "description": "Der Fakt (kurz, 3. Person, z.B. 'Lieblingskaffee ist Flat White')"}, "memory_type": {"type": "string", "enum": ["fact", "preference", "relationship", "plan", "temporary", "uncertain"], "description": "fact=stabiler Fakt, preference=Vorliebe, relationship=Beziehung/Rolle, plan=Vorhaben mit Zeitbezug, temporary=kurzfristiger Zustand, uncertain=vage Aussage"}, "confidence": {"type": "string", "enum": ["high", "medium", "low"], "description": "high=klare Aussage, medium=wahrscheinlich, low=vage"}, "expires_at": {"type": "string", "description": "Bei plan/temporary: Zeitangabe wann es ablaeuft (z.B. 'naechste Woche', 'morgen', '22.03.2026'). Leer bei dauerhaften Fakten."}, }, "required": ["scope", "kind", "content", "memory_type", "confidence"], }, }, }, { "type": "function", "function": { "name": "session_search", "description": "Volltextsuche in vergangenen Sessions nach konkreten Stichworten. Fuer gezielte Suche wie 'Was habe ich ueber Backup gesagt?' oder 'Wann war das mit Seafile?'.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Suchbegriffe"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "session_summary", "description": "Zusammenfassung der aktuellen Session. Ohne topic = alle Themen. Mit topic = nur thematisch passende Punkte. Nutze topic wenn die Frage ein klares Thema enthaelt (z.B. 'Container', 'Jarvis', 'Backup').", "parameters": { "type": "object", "properties": { "topic": {"type": "string", "description": "Themenbegriff zum Filtern (z.B. 'Container', 'Backup'). Leer lassen fuer allgemeine Zusammenfassung."}, }, "required": [], }, }, }, ] def _get_api_key() -> str: cfg = config.parse_config() return cfg.api_keys.get("openrouter_key", "") def _call_openrouter(messages: list, api_key: str, use_tools: bool = True, model: str = None, max_tokens: int = 600) -> dict: payload = { "model": model or MODEL, "messages": messages, "max_tokens": max_tokens, } if use_tools: payload["tools"] = TOOLS payload["tool_choice"] = "auto" r = requests.post( "https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {api_key}"}, json=payload, timeout=90, ) r.raise_for_status() return r.json() 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" messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, ] try: data = _call_openrouter(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. tool_handlers: dict von tool_name -> callable(**kwargs) -> str session_id: aktive Session fuer Konversations-History """ api_key = _get_api_key() if not api_key: return "OpenRouter API Key fehlt in homelab.conf" 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 = "" messages = [ {"role": "system", "content": SYSTEM_PROMPT + memory_block}, ] _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}) try: for _round in range(MAX_TOOL_ROUNDS): data = _call_openrouter(messages, api_key, use_tools=True) choice = data["choices"][0] msg = choice["message"] tool_calls = msg.get("tool_calls") if not tool_calls: return msg.get("content", "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_openrouter(messages, api_key, use_tools=False) 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 mit optionalem Text und Tool-Calling via Vision-faehigem Modell.""" 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 = "" 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"}}, ] messages = [ {"role": "system", "content": SYSTEM_PROMPT + memory_block}, ] 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_openrouter(messages, api_key, use_tools=True, model=VISION_MODEL, max_tokens=1200) choice = data["choices"][0] msg = choice["message"] tool_calls = msg.get("tool_calls") if not tool_calls: return msg.get("content", "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_openrouter(messages, api_key, use_tools=False, model=VISION_MODEL, max_tokens=1200) return data["choices"][0]["message"]["content"] except Exception as e: return f"Vision-LLM-Fehler: {e}"