385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""OpenRouter LLM-Wrapper mit Tool-Calling.
|
|
|
|
Neue Datenquelle = eine Datei in tools/ anlegen. Fertig.
|
|
"""
|
|
|
|
import json
|
|
import requests
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from core import config
|
|
import tool_loader
|
|
|
|
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 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).
|
|
|
|
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 = 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"
|
|
|
|
_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:
|
|
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"}},
|
|
]
|
|
|
|
_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=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}"
|