When session history already contained insurance/document answers, the LLM would skip rag_search and hallucinate from memory. Now: if doc keywords detected, inject system-message reminder AFTER session history but BEFORE user question, forcing rag_search call. Fixes #51 follow-up: LVM query returned wrong answer.
609 lines
25 KiB
Python
609 lines
25 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_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}"
|