feat(bot): Schalter Betriebsart Unterlagen (RAG zuerst)

- Neue Tastaturzeile: Unterlagen AUS/AN zeigt Modus und schaltet um.
- document_mode in ask_with_tools: erzwingt lokales Modell und RAG-Pflicht
  wie bei Doc-Keywords (Session wird bei Suche wie bisher bereinigt).
- Optional: doku:/rag: Prefix fuer einmalige Suche ohne Modus.
- Sprache und Hilfetext ergaenzt.
This commit is contained in:
Homelab Cursor 2026-03-26 17:05:06 +01:00
parent b64e9f2acf
commit 44d80d2a9e
3 changed files with 142 additions and 22 deletions

View file

@ -332,7 +332,7 @@ def ask(question: str, context: str) -> str:
return f"LLM-Fehler: {e}" return f"LLM-Fehler: {e}"
def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -> str: def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, document_mode: bool = False) -> str:
"""Freitext-Frage mit automatischem Routing und Tool-Calling. """Freitext-Frage mit automatischem Routing und Tool-Calling.
Routing: Routing:
@ -346,6 +346,10 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
route = _route_model(question) route = _route_model(question)
if document_mode and route != "deep_research":
route = MODEL_LOCAL
log.info("Betriebsart Unterlagen: lokales Modell, keine Web-Suche")
# --- Deep Research: Perplexity Sonar Deep Research --- # --- Deep Research: Perplexity Sonar Deep Research ---
if route == "deep_research": if route == "deep_research":
log.info("Route: sonar-deep-research") log.info("Route: sonar-deep-research")
@ -426,7 +430,7 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
"monatliche kosten", "versicherungskosten", "beitragsrechnung", "monatliche kosten", "versicherungskosten", "beitragsrechnung",
] ]
_q_low = question.lower() _q_low = question.lower()
if route == MODEL_LOCAL and any(k in _q_low for k in _DOC_KW): if route == MODEL_LOCAL and (document_mode or any(k in _q_low for k in _DOC_KW)):
_rag_fn = tool_handlers.get("rag_search") _rag_fn = tool_handlers.get("rag_search")
if _rag_fn: if _rag_fn:
try: try:

View file

@ -0,0 +1,57 @@
"""Pro-Chat: Betriebsart Unterlagen zuerst (RAG vor Web)."""
from __future__ import annotations
from typing import Optional, Tuple
_active: dict[str, bool] = {}
BTN_OFF = "📁 Unterlagen: AUS"
BTN_ON = "📁 Unterlagen: AN"
def is_document_mode(channel_key: str) -> bool:
return _active.get(channel_key, False)
def set_document_mode(channel_key: str, on: bool) -> None:
if on:
_active[channel_key] = True
else:
_active.pop(channel_key, None)
def toggle_document_mode(channel_key: str) -> bool:
cur = is_document_mode(channel_key)
set_document_mode(channel_key, not cur)
return not cur
def keyboard_label(channel_key: str) -> str:
return BTN_ON if is_document_mode(channel_key) else BTN_OFF
def is_mode_button(text: str) -> bool:
t = (text or "").strip()
return t in (BTN_ON, BTN_OFF)
def handle_mode_button(text: str, channel_key: str) -> Optional[bool]:
"""Returns True if turned ON, False if OFF, None if not a mode button."""
t = (text or "").strip()
if t == BTN_OFF:
set_document_mode(channel_key, True)
return True
if t == BTN_ON:
set_document_mode(channel_key, False)
return False
return None
def strip_document_prefix(text: str) -> Tuple[str, bool]:
t = (text or "").strip()
low = t.lower()
for p in ("doku:", "rag:", "#doku"):
if low.startswith(p):
return t[len(p) :].lstrip(), True
return t, False

View file

@ -83,15 +83,22 @@ BOT_COMMANDS = [
] ]
KEYBOARD = ReplyKeyboardMarkup( def build_reply_keyboard(channel_key: str) -> ReplyKeyboardMarkup:
"""Tastatur inkl. Schalter Betriebsart Unterlagen (RAG zuerst)."""
doc_btn = KeyboardButton(rag_mode.keyboard_label(channel_key))
return ReplyKeyboardMarkup(
[ [
[KeyboardButton("📊 Status"), KeyboardButton("❌ Fehler"), KeyboardButton("📰 Feeds")], [KeyboardButton("📊 Status"), KeyboardButton("❌ Fehler"), KeyboardButton("📰 Feeds")],
[KeyboardButton("📋 Report"), KeyboardButton("🔧 Check"), KeyboardButton("🔇 Stille")], [KeyboardButton("📋 Report"), KeyboardButton("🔧 Check"), KeyboardButton("🔇 Stille")],
[doc_btn],
], ],
resize_keyboard=True, resize_keyboard=True,
is_persistent=True, is_persistent=True,
) )
KEYBOARD = build_reply_keyboard("")
BUTTON_MAP = { BUTTON_MAP = {
"📊 Status": "status", "📊 Status": "status",
"❌ Fehler": "errors", "❌ Fehler": "errors",
@ -106,6 +113,7 @@ import requests as _req
import llm import llm
import memory_client import memory_client
import action_guard import action_guard
import rag_mode
import monitor import monitor
import voice import voice
from core import config from core import config
@ -155,8 +163,8 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"/feeds — Feed-Status & Artikel\n" "/feeds — Feed-Status & Artikel\n"
"/memory — Gedaechtnis anzeigen\n\n" "/memory — Gedaechtnis anzeigen\n\n"
"📷 Foto senden = Bilderkennung\n\n" "📷 Foto senden = Bilderkennung\n\n"
"Oder einfach eine Frage stellen!", "📁 Unterlagen: Schalter in der Tastatur — AN = Dokumente zuerst (RAG).\n\nOder einfach eine Frage stellen!",
reply_markup=KEYBOARD, reply_markup=build_reply_keyboard(str(update.effective_chat.id)),
) )
@ -382,7 +390,7 @@ async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
lines.append(f"{i['content'][:90]}{conf}{exp_str}{src_tag}") lines.append(f"{i['content'][:90]}{conf}{exp_str}{src_tag}")
lines.append("") lines.append("")
text = "\n".join(lines) text = "\n".join(lines)
await update.message.reply_text(text[:4000], reply_markup=KEYBOARD) await update.message.reply_text(text[:4000], reply_markup=build_reply_keyboard(str(update.effective_chat.id)))
@ -405,17 +413,42 @@ async def handle_voice(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("Konnte die Nachricht nicht verstehen.") await update.message.reply_text("Konnte die Nachricht nicht verstehen.")
return return
channel_key = str(update.effective_chat.id)
mode_change = rag_mode.handle_mode_button(text, channel_key)
if mode_change is not None:
state = "eingeschaltet" if mode_change else "ausgeschaltet"
await update.message.reply_text(
f"🗣 \"{text}\"\n\nBetriebsart Unterlagen zuerst: {state}.",
reply_markup=build_reply_keyboard(channel_key),
)
return
work_text, doc_prefix = rag_mode.strip_document_prefix(text)
if doc_prefix and not work_text.strip():
await update.message.reply_text(
f"🗣 \"{text}\"\n\nSchreib die Frage nach doku: oder rag:, z.B. doku: Jahreskosten",
reply_markup=build_reply_keyboard(channel_key),
)
return
document_mode = doc_prefix or rag_mode.is_document_mode(channel_key)
log.info("Voice transkribiert: %s", text[:100]) log.info("Voice transkribiert: %s", text[:100])
await update.message.reply_text(f"🗣 \"{text}\"\n\n🤔 Denke nach...") await update.message.reply_text(f"🗣 \"{text}\"\n\n🤔 Denke nach...")
channel_key = str(update.effective_chat.id)
session_id = memory_client.get_or_create_session(channel_key, source="telegram") session_id = memory_client.get_or_create_session(channel_key, source="telegram")
context.last_suggest_result = {"type": None} context.last_suggest_result = {"type": None}
context.set_source_type("telegram_voice") context.set_source_type("telegram_voice")
handlers = context.get_tool_handlers(session_id=session_id) handlers = context.get_tool_handlers(session_id=session_id)
llm_task = asyncio.create_task( llm_task = asyncio.create_task(
asyncio.to_thread(llm.ask_with_tools, text, handlers, session_id=session_id) asyncio.to_thread(
llm.ask_with_tools,
work_text,
handlers,
session_id=session_id,
document_mode=document_mode,
)
) )
ACTIVE_LLM_TASKS[update.effective_chat.id] = llm_task ACTIVE_LLM_TASKS[update.effective_chat.id] = llm_task
@ -432,7 +465,7 @@ async def handle_voice(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
memory_client.log_message(session_id, "user", text) memory_client.log_message(session_id, "user", text)
memory_client.log_message(session_id, "assistant", answer) memory_client.log_message(session_id, "assistant", answer)
await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) await update.message.reply_text(answer[:4000], reply_markup=build_reply_keyboard(str(update.effective_chat.id)))
audio_out = voice.synthesize(answer[:4000]) audio_out = voice.synthesize(answer[:4000])
if audio_out: if audio_out:
@ -481,7 +514,7 @@ async def handle_photo(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
memory_client.log_message(session_id, "user", user_msg) memory_client.log_message(session_id, "user", user_msg)
memory_client.log_message(session_id, "assistant", answer) memory_client.log_message(session_id, "assistant", answer)
await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) await update.message.reply_text(answer[:4000], reply_markup=build_reply_keyboard(str(update.effective_chat.id)))
except Exception as e: except Exception as e:
log.exception("Fehler bei Foto-Analyse") log.exception("Fehler bei Foto-Analyse")
await update.message.reply_text(f"Fehler bei Bildanalyse: {e}") await update.message.reply_text(f"Fehler bei Bildanalyse: {e}")
@ -657,7 +690,7 @@ async def handle_document(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
memory_client.log_message(session_id, "user", user_msg) memory_client.log_message(session_id, "user", user_msg)
memory_client.log_message(session_id, "assistant", answer) memory_client.log_message(session_id, "assistant", answer)
await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) await update.message.reply_text(answer[:4000], reply_markup=build_reply_keyboard(str(update.effective_chat.id)))
except Exception as e: except Exception as e:
log.exception("Fehler bei Bild-Dokument") log.exception("Fehler bei Bild-Dokument")
await update.message.reply_text(f"Fehler bei Bildanalyse: {e}") await update.message.reply_text(f"Fehler bei Bildanalyse: {e}")
@ -694,7 +727,7 @@ async def handle_document(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
memory_client.log_message(session_id, "user", user_msg) memory_client.log_message(session_id, "user", user_msg)
memory_client.log_message(session_id, "assistant", answer) memory_client.log_message(session_id, "assistant", answer)
await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) await update.message.reply_text(answer[:4000], reply_markup=build_reply_keyboard(str(update.effective_chat.id)))
except Exception as e: except Exception as e:
log.exception("Fehler bei PDF-Analyse") log.exception("Fehler bei PDF-Analyse")
await update.message.reply_text(f"Fehler bei PDF: {e}") await update.message.reply_text(f"Fehler bei PDF: {e}")
@ -766,8 +799,28 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
channel_key = str(update.effective_chat.id) channel_key = str(update.effective_chat.id)
session_id = memory_client.get_or_create_session(channel_key, source="telegram") session_id = memory_client.get_or_create_session(channel_key, source="telegram")
mode_change = rag_mode.handle_mode_button(text, channel_key)
if mode_change is not None:
state = "eingeschaltet" if mode_change else "ausgeschaltet"
await update.message.reply_text(
f"Betriebsart Unterlagen zuerst: {state}.\n"
"Solange AN: Fragen laufen zuerst gegen deine Dokumente (lokal), "
"nicht gegen Web/Preis-Suche.",
reply_markup=build_reply_keyboard(channel_key),
)
return
work_text, doc_prefix = rag_mode.strip_document_prefix(text)
if doc_prefix and not work_text.strip():
await update.message.reply_text(
"Schreib die Frage nach dem Doppelpunkt, z.B. doku: Jahreskosten Versicherung",
reply_markup=build_reply_keyboard(channel_key),
)
return
document_mode = doc_prefix or rag_mode.is_document_mode(channel_key)
await update.message.reply_text("🤔 Denke nach...") await update.message.reply_text("🤔 Denke nach...")
if _likely_deep_research_request(text): if _likely_deep_research_request(work_text):
await update.message.reply_text("🔎 Deep Research gestartet. Das dauert meist 2-5 Minuten.") await update.message.reply_text("🔎 Deep Research gestartet. Das dauert meist 2-5 Minuten.")
try: try:
context.last_suggest_result = {"type": None} context.last_suggest_result = {"type": None}
@ -776,7 +829,13 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
context.get_tool_handlers(session_id=session_id), channel_key context.get_tool_handlers(session_id=session_id), channel_key
) )
llm_task = asyncio.create_task( llm_task = asyncio.create_task(
asyncio.to_thread(llm.ask_with_tools, text, handlers, session_id=session_id) asyncio.to_thread(
llm.ask_with_tools,
work_text,
handlers,
session_id=session_id,
document_mode=document_mode,
)
) )
ACTIVE_LLM_TASKS[update.effective_chat.id] = llm_task ACTIVE_LLM_TASKS[update.effective_chat.id] = llm_task
@ -796,7 +855,7 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
suggest = context.last_suggest_result suggest = context.last_suggest_result
log.info("suggest_result: type=%s", suggest.get("type")) log.info("suggest_result: type=%s", suggest.get("type"))
await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) await update.message.reply_text(answer[:4000], reply_markup=build_reply_keyboard(str(update.effective_chat.id)))
except asyncio.CancelledError: except asyncio.CancelledError:
log.info("Freitext-Lauf abgebrochen") log.info("Freitext-Lauf abgebrochen")
return return