homelab-brain/homelab-ai-bot/llm.py

471 lines
20 KiB
Python

"""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 = "qwen3-vl:32b"
VISION_MODEL = "qwen3-vl:32b"
MAX_TOOL_ROUNDS = 3
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.
- 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.
- 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 _call_openrouter(messages: list, api_key: str, use_tools: bool = True,
model: str = None, max_tokens: int = 4000) -> dict:
chosen = model or MODEL
use_ollama = (chosen == MODEL)
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 = 120
for msg in reversed(payload.get("messages", [])):
if msg.get("role") == "user":
content = msg.get("content", "")
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"
break
break
else:
url = f"{OPENROUTER_BASE}/chat/completions"
headers = {"Authorization": f"Bearer {api_key}"}
timeout = 90
r = requests.post(url, headers=headers, json=payload, timeout=timeout)
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"
_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_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 = ""
_extra = tool_loader.get_extra_prompt()
_full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block
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})
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:
content = msg.get("content") or ""
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}"
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
_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"}},
]
_extra = tool_loader.get_extra_prompt()
_full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block
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_openrouter(messages, api_key, use_tools=True,
model=VISION_MODEL, 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 ""
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_openrouter(messages, api_key, use_tools=False,
model=VISION_MODEL, max_tokens=4000)
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"Vision-LLM-Fehler: {e}"