mindnet/app/routers/chat.py

395 lines
15 KiB
Python
Raw Normal View History

2025-12-08 10:32:57 +01:00
"""
2025-12-15 15:40:39 +01:00
FILE: app/routers/chat.py
DESCRIPTION: Haupt-Chat-Interface (RAG & Interview). Enthält Intent-Router (Keywords/LLM) und Prompt-Construction.
VERSION: 2.7.8 (Full Unabridged Stability Edition)
2025-12-15 15:40:39 +01:00
STATUS: Active
FIX:
1. Implementiert Context-Throttling für Ollama (MAX_OLLAMA_CHARS).
2. Deaktiviert LLM-Retries für den Chat (max_retries=0).
3. Behebt Double-Fallback-Schleifen und Silent Refusals.
2025-12-08 10:32:57 +01:00
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Dict, Any, Optional
2025-12-08 10:32:57 +01:00
import time
import uuid
import logging
2025-12-08 19:36:12 +01:00
import yaml
import os
2025-12-08 19:36:12 +01:00
from pathlib import Path
2025-12-08 10:32:57 +01:00
2025-12-08 19:36:12 +01:00
from app.config import get_settings
2025-12-08 10:32:57 +01:00
from app.models.dto import ChatRequest, ChatResponse, QueryRequest, QueryHit
from app.services.llm_service import LLMService
2025-12-28 11:04:40 +01:00
from app.core.retrieval.retriever import Retriever
2025-12-09 22:07:58 +01:00
from app.services.feedback_service import log_search
2025-12-08 10:32:57 +01:00
router = APIRouter()
logger = logging.getLogger(__name__)
2025-12-08 19:36:12 +01:00
# --- Helper: Config Loader ---
2025-12-09 09:58:35 +01:00
_DECISION_CONFIG_CACHE = None
_TYPES_CONFIG_CACHE = None
2025-12-09 09:58:35 +01:00
2025-12-08 19:36:12 +01:00
def _load_decision_config() -> Dict[str, Any]:
settings = get_settings()
path = Path(settings.DECISION_CONFIG_PATH)
default_config = {
"strategies": {
2025-12-26 05:11:48 +01:00
"FACT": {"trigger_keywords": [], "preferred_provider": "openrouter"}
2025-12-08 19:36:12 +01:00
}
}
if not path.exists():
logger.warning(f"Decision config not found at {path}, using defaults.")
return default_config
try:
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
except Exception as e:
logger.error(f"Failed to load decision config: {e}")
return default_config
def _load_types_config() -> Dict[str, Any]:
"""Lädt die types.yaml für Keyword-Erkennung."""
path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml")
try:
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
2025-12-09 09:58:35 +01:00
def get_full_config() -> Dict[str, Any]:
2025-12-08 19:36:12 +01:00
global _DECISION_CONFIG_CACHE
if _DECISION_CONFIG_CACHE is None:
_DECISION_CONFIG_CACHE = _load_decision_config()
2025-12-09 09:58:35 +01:00
return _DECISION_CONFIG_CACHE
def get_types_config() -> Dict[str, Any]:
global _TYPES_CONFIG_CACHE
if _TYPES_CONFIG_CACHE is None:
_TYPES_CONFIG_CACHE = _load_types_config()
return _TYPES_CONFIG_CACHE
2025-12-09 09:58:35 +01:00
def get_decision_strategy(intent: str) -> Dict[str, Any]:
config = get_full_config()
strategies = config.get("strategies", {})
2025-12-08 19:36:12 +01:00
return strategies.get(intent, strategies.get("FACT", {}))
2025-12-10 13:48:13 +01:00
# --- Helper: Target Type Detection (WP-07) ---
def _detect_target_type(message: str, configured_schemas: Dict[str, Any]) -> str:
"""
Versucht zu erraten, welchen Notiz-Typ der User erstellen will.
Nutzt Keywords aus types.yaml UND Mappings.
2025-12-10 13:48:13 +01:00
"""
message_lower = message.lower()
# 1. Check types.yaml detection_keywords (Priority!)
types_cfg = get_types_config()
types_def = types_cfg.get("types", {})
for type_name, type_data in types_def.items():
keywords = type_data.get("detection_keywords", [])
for kw in keywords:
if kw.lower() in message_lower:
return type_name
# 2. Direkter Match mit Schema-Keys
2025-12-10 13:48:13 +01:00
for type_key in configured_schemas.keys():
if type_key == "default": continue
2025-12-10 13:48:13 +01:00
if type_key in message_lower:
return type_key
# 3. Synonym-Mapping (Legacy Fallback)
2025-12-10 13:48:13 +01:00
synonyms = {
"projekt": "project", "vorhaben": "project",
"entscheidung": "decision", "beschluss": "decision",
2025-12-10 13:48:13 +01:00
"ziel": "goal",
"erfahrung": "experience", "lektion": "experience",
2025-12-10 13:48:13 +01:00
"wert": "value",
"prinzip": "principle",
"notiz": "default", "idee": "default"
2025-12-10 13:48:13 +01:00
}
for term, schema_key in synonyms.items():
if term in message_lower:
return schema_key
2025-12-10 13:48:13 +01:00
return "default"
2025-12-08 19:36:12 +01:00
# --- Dependencies ---
2025-12-08 10:32:57 +01:00
def get_llm_service():
return LLMService()
def get_retriever():
return Retriever()
2025-12-08 19:36:12 +01:00
# --- Logic ---
2025-12-08 13:39:16 +01:00
def _build_enriched_context(hits: List[QueryHit]) -> str:
2025-12-08 10:32:57 +01:00
context_parts = []
for i, hit in enumerate(hits, 1):
source = hit.source or {}
2025-12-08 11:09:52 +01:00
content = (
source.get("text") or source.get("content") or
source.get("page_content") or source.get("chunk_text") or
"[Kein Text]"
2025-12-08 11:09:52 +01:00
)
title = hit.note_id or "Unbekannt"
2025-12-09 18:11:25 +01:00
payload = hit.payload or {}
note_type = payload.get("type") or source.get("type", "unknown")
note_type = str(note_type).upper()
2025-12-08 14:27:17 +01:00
2025-12-08 10:32:57 +01:00
entry = (
2025-12-08 14:27:17 +01:00
f"### QUELLE {i}: {title}\n"
f"TYP: [{note_type}] (Score: {hit.total_score:.2f})\n"
f"INHALT:\n{content}\n"
2025-12-08 10:32:57 +01:00
)
context_parts.append(entry)
2025-12-08 13:39:16 +01:00
return "\n\n".join(context_parts)
2025-12-08 10:32:57 +01:00
def _is_question(query: str) -> bool:
"""Prüft, ob der Input wahrscheinlich eine Frage ist."""
q = query.strip().lower()
if "?" in q: return True
2025-12-26 05:11:48 +01:00
# W-Fragen Indikatoren
starters = ["wer", "wie", "was", "wo", "wann", "warum", "weshalb", "wozu", "welche", "bist du", "entspricht"]
if any(q.startswith(s + " ") for s in starters):
return True
return False
2025-12-09 18:02:21 +01:00
async def _classify_intent(query: str, llm: LLMService) -> tuple[str, str]:
"""
Hybrid Router v5:
1. Decision Keywords (Strategie) -> Prio 1
2025-12-26 05:11:48 +01:00
2. Type Keywords (Interview Trigger) -> Prio 2
3. LLM (Fallback) -> Prio 3
"""
2025-12-09 09:58:35 +01:00
config = get_full_config()
strategies = config.get("strategies", {})
settings = config.get("settings", {})
2025-12-09 09:58:35 +01:00
query_lower = query.lower()
2025-12-26 05:11:48 +01:00
# 1. FAST PATH A: Strategie Keywords
2025-12-09 09:58:35 +01:00
for intent_name, strategy in strategies.items():
if intent_name == "FACT": continue
2025-12-09 09:58:35 +01:00
keywords = strategy.get("trigger_keywords", [])
for k in keywords:
if k.lower() in query_lower:
return intent_name, "Keyword (Strategy)"
2025-12-26 05:11:48 +01:00
# 2. FAST PATH B: Type Keywords -> INTERVIEW
if not _is_question(query_lower):
types_cfg = get_types_config()
types_def = types_cfg.get("types", {})
for type_name, type_data in types_def.items():
keywords = type_data.get("detection_keywords", [])
for kw in keywords:
if kw.lower() in query_lower:
return "INTERVIEW", f"Keyword (Type: {type_name})"
# 3. SLOW PATH: LLM Router
if settings.get("llm_fallback_enabled", False):
2025-12-26 05:11:48 +01:00
router_prompt_template = llm.get_prompt("llm_router_prompt")
if router_prompt_template:
prompt = router_prompt_template.replace("{query}", query)
logger.info("Keywords failed (or Question detected). Asking LLM for Intent...")
try:
# FIX: Auch beim Routing keine Retries im Chat-Fluss
raw_response = await llm.generate_raw_response(prompt, priority="realtime", max_retries=0)
llm_output_upper = raw_response.upper()
if "INTERVIEW" in llm_output_upper or "CREATE" in llm_output_upper:
return "INTERVIEW", "LLM Router"
for strat_key in strategies.keys():
if strat_key in llm_output_upper:
return strat_key, "LLM Router"
except Exception as e:
logger.error(f"Router LLM failed: {e}")
2025-12-09 18:02:21 +01:00
return "FACT", "Default (No Match)"
2025-12-08 18:48:24 +01:00
2025-12-08 10:32:57 +01:00
@router.post("/", response_model=ChatResponse)
async def chat_endpoint(
request: ChatRequest,
llm: LLMService = Depends(get_llm_service),
retriever: Retriever = Depends(get_retriever)
):
start_time = time.time()
query_id = str(uuid.uuid4())
2025-12-08 11:15:16 +01:00
logger.info(f"Chat request [{query_id}]: {request.message[:50]}...")
2025-12-08 10:32:57 +01:00
try:
# 1. Intent Detection
2025-12-09 18:02:21 +01:00
intent, intent_source = await _classify_intent(request.message, llm)
logger.info(f"[{query_id}] Final Intent: {intent} via {intent_source}")
2025-12-08 18:48:24 +01:00
# Strategy Load
2025-12-08 19:36:12 +01:00
strategy = get_decision_strategy(intent)
prompt_key = strategy.get("prompt_template", "rag_template")
preferred_provider = strategy.get("preferred_provider")
2025-12-08 18:48:24 +01:00
2025-12-10 13:48:13 +01:00
sources_hits = []
final_prompt = ""
context_str = ""
2025-12-10 13:48:13 +01:00
if intent == "INTERVIEW":
# --- INTERVIEW MODE ---
target_type = _detect_target_type(request.message, strategy.get("schemas", {}))
2025-12-10 13:48:13 +01:00
types_cfg = get_types_config()
type_def = types_cfg.get("types", {}).get(target_type, {})
fields_list = type_def.get("schema", [])
2025-12-10 13:48:13 +01:00
if not fields_list:
configured_schemas = strategy.get("schemas", {})
fallback_schema = configured_schemas.get(target_type, configured_schemas.get("default"))
if isinstance(fallback_schema, dict):
fields_list = fallback_schema.get("fields", [])
else:
fields_list = fallback_schema or []
logger.info(f"[{query_id}] Interview Type: {target_type}. Fields: {len(fields_list)}")
fields_str = "\n- " + "\n- ".join(fields_list)
template = llm.get_prompt(prompt_key)
final_prompt = template.replace("{context_str}", "Dialogverlauf...") \
2025-12-10 13:48:13 +01:00
.replace("{query}", request.message) \
.replace("{target_type}", target_type) \
.replace("{schema_fields}", fields_str) \
.replace("{schema_hint}", "")
2025-12-10 13:48:13 +01:00
sources_hits = []
else:
2025-12-26 05:11:48 +01:00
# --- RAG MODE (FACT, DECISION, EMPATHY, CODING) ---
2025-12-10 13:48:13 +01:00
inject_types = strategy.get("inject_types", [])
prepend_instr = strategy.get("prepend_instruction", "")
2025-12-18 12:40:34 +01:00
edge_boosts = strategy.get("edge_boosts", {})
2025-12-10 13:48:13 +01:00
query_req = QueryRequest(
2025-12-08 18:48:24 +01:00
query=request.message,
2025-12-10 13:48:13 +01:00
mode="hybrid",
top_k=request.top_k,
2025-12-18 12:40:34 +01:00
explain=request.explain,
boost_edges=edge_boosts
2025-12-08 18:48:24 +01:00
)
2025-12-10 13:48:13 +01:00
retrieve_result = await retriever.search(query_req)
hits = retrieve_result.results
2025-12-08 18:48:24 +01:00
2025-12-10 13:48:13 +01:00
if inject_types:
strategy_req = QueryRequest(
query=request.message,
mode="hybrid",
top_k=3,
filters={"type": inject_types},
2025-12-18 12:40:34 +01:00
explain=False,
boost_edges=edge_boosts
2025-12-10 13:48:13 +01:00
)
strategy_result = await retriever.search(strategy_req)
existing_ids = {h.node_id for h in hits}
for strat_hit in strategy_result.results:
if strat_hit.node_id not in existing_ids:
hits.append(strat_hit)
2025-12-08 10:32:57 +01:00
2025-12-26 05:11:48 +01:00
context_str = _build_enriched_context(hits) if hits else "Keine relevanten Notizen gefunden."
# --- STABILITY FIX: OLLAMA CONTEXT THROTTLE ---
# Begrenzt den Text, um den "decode: cannot decode batches" Fehler zu vermeiden.
# MAX_OLLAMA_CHARS = 10000
settings = get_settings() # Falls noch nicht im Scope vorhanden
max_chars = getattr(settings, "MAX_OLLAMA_CHARS", 10000)
if preferred_provider == "ollama" and len(context_str) > max_chars:
logger.warning(f"⚠️ [{query_id}] Context zu groß für Ollama ({len(context_str)} chars). Kürze auf {max_chars}.")
context_str = context_str[:max_chars] + "\n[...gekürzt zur Stabilität...]"
2025-12-26 05:11:48 +01:00
template = llm.get_prompt(prompt_key) or "{context_str}\n\n{query}"
2025-12-10 13:48:13 +01:00
if prepend_instr:
context_str = f"{prepend_instr}\n\n{context_str}"
2025-12-08 18:48:24 +01:00
2025-12-10 13:48:13 +01:00
final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message)
sources_hits = hits
2025-12-26 05:21:15 +01:00
# --- DEBUG SPOT 1: PROMPT CONSTRUCTION ---
logger.info(f"[{query_id}] PROMPT CONSTRUCTION COMPLETE. Length: {len(final_prompt)} chars.")
if not final_prompt.strip():
logger.error(f"[{query_id}] CRITICAL: Final prompt is empty before sending to LLM!")
# --- GENERATION WITH NO-RETRY & DEEP FALLBACK ---
system_prompt = llm.get_prompt("system_prompt")
2025-12-26 05:21:15 +01:00
# --- DEBUG SPOT 2: PRIMARY CALL ---
logger.info(f"[{query_id}] PRIMARY CALL: Sending request to provider '{preferred_provider}' (No Retries)...")
2025-12-26 05:21:15 +01:00
answer_text = ""
try:
# FIX: max_retries=0 verhindert Hänger durch Retry-Kaskaden im Chat
2025-12-26 05:11:48 +01:00
answer_text = await llm.generate_raw_response(
prompt=final_prompt,
system=system_prompt,
priority="realtime",
provider=preferred_provider,
max_retries=0
2025-12-26 05:11:48 +01:00
)
except Exception as e:
logger.error(f"🛑 [{query_id}] Primary Provider '{preferred_provider}' failed: {e}")
# DEEP FALLBACK: Wenn die Antwort leer ist (Silent Refusal) oder der Primary abgestürzt ist
if not answer_text.strip() and preferred_provider != "ollama":
# --- DEBUG SPOT 3: FALLBACK TRIGGER ---
logger.warning(f"🛑 [{query_id}] PRIMARY '{preferred_provider}' returned EMPTY or FAILED. Triggering Deep Fallback to Ollama...")
try:
answer_text = await llm.generate_raw_response(
prompt=final_prompt,
system=system_prompt,
priority="realtime",
provider="ollama",
max_retries=0
)
except Exception as e:
logger.error(f"🛑 [{query_id}] Deep Fallback to Ollama also failed: {e}")
answer_text = "Entschuldigung, das System ist aktuell überlastet. Bitte versuche es in einem Moment erneut."
2025-12-26 05:11:48 +01:00
2025-12-08 10:32:57 +01:00
duration_ms = int((time.time() - start_time) * 1000)
# Logging
2025-12-09 22:07:58 +01:00
try:
log_search(
query_id=query_id,
query_text=request.message,
2025-12-10 13:48:13 +01:00
results=sources_hits,
mode="interview" if intent == "INTERVIEW" else "chat_rag",
2025-12-26 05:11:48 +01:00
metadata={"intent": intent, "source": intent_source, "provider": preferred_provider}
2025-12-09 22:07:58 +01:00
)
except: pass
2025-12-09 22:07:58 +01:00
2025-12-08 10:32:57 +01:00
return ChatResponse(
2025-12-08 19:36:12 +01:00
query_id=query_id,
2025-12-08 10:32:57 +01:00
answer=answer_text,
2025-12-10 13:48:13 +01:00
sources=sources_hits,
2025-12-08 18:48:24 +01:00
latency_ms=duration_ms,
2025-12-09 18:02:21 +01:00
intent=intent,
2025-12-09 22:07:58 +01:00
intent_source=intent_source
2025-12-08 10:32:57 +01:00
)
except Exception as e:
logger.error(f"Error in chat endpoint: {e}", exc_info=True)
# Wir geben eine benutzerfreundliche Meldung zurück, statt nur den Error-Stack
raise HTTPException(status_code=500, detail="Das System konnte die Anfrage nicht verarbeiten.")