mindnet/app/routers/chat.py

199 lines
6.4 KiB
Python
Raw Normal View History

2025-12-08 10:32:57 +01:00
"""
2025-12-08 19:36:12 +01:00
app/routers/chat.py — RAG Endpunkt (WP-06 Decision Engine - Late Binding Refactor)
2025-12-08 13:39:16 +01:00
Zweck:
Verbindet Retrieval mit LLM-Generation.
2025-12-08 19:36:12 +01:00
WP-06: Implementiert Intent Detection und Strategic Retrieval.
Update: Konfiguration via decision_engine.yaml (Late Binding).
2025-12-08 10:32:57 +01:00
"""
from fastapi import APIRouter, HTTPException, Depends
2025-12-08 19:36:12 +01:00
from typing import List, Dict, Any
2025-12-08 10:32:57 +01:00
import time
import uuid
import logging
2025-12-08 19:36:12 +01:00
import yaml
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
from app.core.retriever import Retriever
router = APIRouter()
logger = logging.getLogger(__name__)
2025-12-08 19:36:12 +01:00
# --- Helper: Config Loader ---
def _load_decision_config() -> Dict[str, Any]:
"""Lädt die Decision-Engine Konfiguration (Late Binding)."""
settings = get_settings()
path = Path(settings.DECISION_CONFIG_PATH)
default_config = {
"strategies": {
"FACT": {"inject_types": [], "prompt_template": "rag_template"},
"DECISION": {"inject_types": ["value", "principle"], "prompt_template": "decision_template"}
}
}
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
# Cache für die Config (damit wir nicht bei jedem Request lesen)
_DECISION_CONFIG_CACHE = None
def get_decision_strategy(intent: str) -> Dict[str, Any]:
global _DECISION_CONFIG_CACHE
if _DECISION_CONFIG_CACHE is None:
_DECISION_CONFIG_CACHE = _load_decision_config()
strategies = _DECISION_CONFIG_CACHE.get("strategies", {})
# Fallback auf FACT, wenn Intent unbekannt
return strategies.get(intent, strategies.get("FACT", {}))
# --- 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
"""
2025-12-08 13:39:16 +01:00
Baut einen 'Rich Context' String.
2025-12-08 10:32:57 +01:00
"""
context_parts = []
2025-12-08 13:39:16 +01:00
2025-12-08 10:32:57 +01:00
for i, hit in enumerate(hits, 1):
source = hit.source or {}
2025-12-08 13:39:16 +01:00
2025-12-08 18:48:24 +01:00
# 1. Content extrahieren
2025-12-08 11:09:52 +01:00
content = (
source.get("text") or
source.get("content") or
source.get("page_content") or
2025-12-08 11:15:16 +01:00
source.get("chunk_text") or
"[Kein Textinhalt verfügbar]"
2025-12-08 11:09:52 +01:00
)
2025-12-08 10:32:57 +01:00
2025-12-08 13:39:16 +01:00
# 2. Metadaten für "Context Intelligence"
title = hit.note_id or "Unbekannte Notiz"
note_type = source.get("type", "unknown").upper()
2025-12-08 14:27:17 +01:00
2025-12-08 18:48:24 +01:00
# 3. Formatierung
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
2025-12-08 18:48:24 +01:00
async def _classify_intent(query: str, llm: LLMService) -> str:
"""
2025-12-08 19:36:12 +01:00
WP-06: Intent Detection (Simple Keyword Heuristic for Speed).
TODO: Move keywords to config if needed later.
2025-12-08 18:48:24 +01:00
"""
2025-12-08 19:36:12 +01:00
# Performance-Optimierung: Keywords statt LLM Call
2025-12-08 18:48:24 +01:00
keywords = ["soll ich", "meinung", "besser", "empfehlung", "strategie", "entscheidung", "wert", "prinzip"]
if any(k in query.lower() for k in keywords):
return "DECISION"
return "FACT"
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:
2025-12-08 19:36:12 +01:00
# 1. Intent Detection
2025-12-08 18:48:24 +01:00
intent = await _classify_intent(request.message, llm)
logger.info(f"[{query_id}] Detected Intent: {intent}")
2025-12-08 19:36:12 +01:00
# Lade Strategie aus Config (Late Binding)
strategy = get_decision_strategy(intent)
inject_types = strategy.get("inject_types", [])
prompt_key = strategy.get("prompt_template", "rag_template")
prepend_instr = strategy.get("prepend_instruction", "")
2025-12-08 18:48:24 +01:00
# 2. Primary Retrieval (Fakten)
2025-12-08 10:32:57 +01:00
query_req = QueryRequest(
query=request.message,
2025-12-08 18:48:24 +01:00
mode="hybrid",
2025-12-08 10:32:57 +01:00
top_k=request.top_k,
2025-12-08 14:27:17 +01:00
explain=request.explain
2025-12-08 10:32:57 +01:00
)
retrieve_result = await retriever.search(query_req)
hits = retrieve_result.results
2025-12-08 18:48:24 +01:00
2025-12-08 19:36:12 +01:00
# 3. Strategic Retrieval (Konfigurierbar)
if inject_types:
logger.info(f"[{query_id}] Executing Strategic Retrieval for types: {inject_types}...")
2025-12-08 18:48:24 +01:00
strategy_req = QueryRequest(
query=request.message,
2025-12-08 19:36:12 +01:00
mode="hybrid",
top_k=3,
filters={"type": inject_types}, # Dynamische Liste aus YAML
2025-12-08 18:48:24 +01:00
explain=False
)
strategy_result = await retriever.search(strategy_req)
2025-12-08 19:36:12 +01:00
# Merge Results (Deduplication via node_id)
2025-12-08 18:48:24 +01:00
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-08 18:48:24 +01:00
# 4. Context Building
2025-12-08 10:32:57 +01:00
if not hits:
context_str = "Keine relevanten Notizen gefunden."
else:
2025-12-08 13:39:16 +01:00
context_str = _build_enriched_context(hits)
2025-12-08 10:32:57 +01:00
2025-12-08 19:36:12 +01:00
# 5. Generation Setup
2025-12-08 18:48:24 +01:00
template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}")
2025-12-08 19:36:12 +01:00
# Injection der Instruktion (falls konfiguriert)
if prepend_instr:
context_str = f"{prepend_instr}\n\n{context_str}"
2025-12-08 18:48:24 +01:00
2025-12-08 19:36:12 +01:00
logger.info(f"[{query_id}] Sending to LLM (Intent: {intent}, Template: {prompt_key})...")
2025-12-08 10:32:57 +01:00
answer_text = await llm.generate_rag_response(
query=request.message,
context_str=context_str
)
2025-12-08 18:48:24 +01:00
# 6. Response
2025-12-08 10:32:57 +01:00
duration_ms = int((time.time() - start_time) * 1000)
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,
sources=hits,
2025-12-08 18:48:24 +01:00
latency_ms=duration_ms,
intent=intent
2025-12-08 10:32:57 +01:00
)
except Exception as e:
logger.error(f"Error in chat endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))