mindnet/app/routers/chat.py
2025-12-08 18:48:24 +01:00

226 lines
9.6 KiB
Python

"""
app/routers/chat.py — RAG Endpunkt (WP-06 Decision Engine)
Zweck:
Verbindet Retrieval mit LLM-Generation.
WP-06: Implementiert Intent Detection und Strategic Retrieval (Values/Principles).
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Dict
import time
import uuid
import logging
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__)
def get_llm_service():
return LLMService()
def get_retriever():
return Retriever()
def _build_enriched_context(hits: List[QueryHit]) -> str:
"""
Baut einen 'Rich Context' String.
Statt nur Text, injizieren wir Metadaten (Typ, Tags), damit das LLM
die semantische Rolle des Schnipsels versteht.
"""
context_parts = []
for i, hit in enumerate(hits, 1):
source = hit.source or {}
# 1. Content extrahieren
content = (
source.get("text") or
source.get("content") or
source.get("page_content") or
source.get("chunk_text") or
"[Kein Textinhalt verfügbar]"
)
# 2. Metadaten für "Context Intelligence"
title = hit.note_id or "Unbekannte Notiz"
# Typ in Großbuchstaben (z.B. "DECISION", "VALUE"), damit das LLM es als Signal erkennt
note_type = source.get("type", "unknown").upper()
# 3. Formatierung
entry = (
f"### QUELLE {i}: {title}\n"
f"TYP: [{note_type}] (Score: {hit.total_score:.2f})\n"
f"INHALT:\n{content}\n"
)
context_parts.append(entry)
return "\n\n".join(context_parts)
async def _classify_intent(query: str, llm: LLMService) -> str:
"""
WP-06: Prüft, ob es eine Faktenfrage oder eine Entscheidungsfrage ist.
Nutzt einen spezialisierten, kurzen Prompt.
"""
prompt_config = llm.prompts.get("intent_prompt")
if not prompt_config:
return "FACT" # Fallback
# Prompt bauen
prompt = prompt_config.replace("{query}", query)
# Direkter API Call an Ollama (ohne RAG Context)
# Wir nutzen hier 'generate_rag_response' generisch, da der Prompt alles enthält
# Um Token zu sparen, setzen wir num_ctx intern niedrig, falls möglich,
# aber wir nutzen hier einfach den bestehenden Service.
# Payload Hack: Wir umgehen generate_rag_response's template logik nicht direkt,
# daher rufen wir client direkt auf oder nutzen eine generische Methode.
# Da LLMService aktuell nur generate_rag_response hat, nutzen wir diese
# und tricksen das Template aus, indem wir context leer lassen,
# WENN das Template dies erlaubt.
# SAUBERER WEG: Wir bauen eine dedizierte Methode in den Service oder nutzen Raw HTTP hier?
# Um Konsistenz zu wahren, rufen wir eine einfache Generation auf.
# Da generate_rag_response das rag_template erzwingt, ist das unschön.
# Wir nutzen einen Trick: Wir senden den kompletten Intent-Prompt als "Query" und Context=""
# Voraussetzung: Das RAG Template stört nicht zu sehr.
# BESSER: Wir erweitern LLMService später. Für jetzt (WP06 Scope Chat Logic):
# Wir nehmen an, dass 'Fact' der Default ist und bauen eine einfache Heuristik
# oder akzeptieren den Overhead des RAG Templates.
# Workaround für diesen Sprint: Wir nutzen generate_rag_response mit leerem Context.
# Das rag_template packt den Intent-Prompt in "{query}".
# Das ist nicht ideal, aber funktioniert für den Prototyp.
# TODO: In WP-06 Refactoring LLMService um `generate_raw` erweitern.
# Für hohe Genauigkeit prüfen wir hier einfache Keywords (Latenz-Optimierung)
keywords = ["soll ich", "meinung", "besser", "empfehlung", "strategie", "entscheidung", "wert", "prinzip"]
if any(k in query.lower() for k in keywords):
return "DECISION"
return "FACT"
@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())
logger.info(f"Chat request [{query_id}]: {request.message[:50]}...")
try:
# 1. Intent Detection (WP-06)
# Wir nutzen vorerst eine Keyword-Heuristik in _classify_intent, um Latenz zu sparen,
# da ein extra LLM Call auf CPU (Beelink) 2-3s kosten kann.
intent = await _classify_intent(request.message, llm)
logger.info(f"[{query_id}] Detected Intent: {intent}")
# 2. Primary Retrieval (Fakten)
# Hybrid Search für Graph-Nachbarn
query_req = QueryRequest(
query=request.message,
mode="hybrid",
top_k=request.top_k,
explain=request.explain
)
retrieve_result = await retriever.search(query_req)
hits = retrieve_result.results
# 3. Strategic Retrieval (WP-06: Nur bei DECISION)
if intent == "DECISION":
logger.info(f"[{query_id}] Executing Strategic Retrieval for Values/Principles...")
# Wir suchen nochmal, aber filtern strikt auf Werte/Prinzipien
# und nutzen die gleiche Query, um RELEVANTE Werte zu finden.
strategy_req = QueryRequest(
query=request.message,
mode="hybrid", # Auch hier Hybrid, um vernetzte Werte zu finden
top_k=3, # Nur die Top 3 Werte
filters={"type": ["value", "principle"]}, # Filter auf Typen
explain=False
)
strategy_result = await retriever.search(strategy_req)
# Merge: Wir fügen die Strategie-Hits VORNE an (Wichtigkeit) oder HINTEN?
# Wir fügen sie hinzu, Duplikate vermeiden.
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)
# Optional: Values markieren oder boosten?
# Durch Enriched Context (Typ: VALUE) sieht das LLM es ohnehin.
# 4. Context Building
if not hits:
context_str = "Keine relevanten Notizen gefunden."
else:
context_str = _build_enriched_context(hits)
# 5. Generation (Prompt Selection)
prompt_key = "decision_template" if intent == "DECISION" else "rag_template"
# Um den korrekten Prompt zu nutzen, müssen wir LLMService eventuell anpassen,
# oder wir laden das Template hier manuell und formatieren es vor.
# Da LLMService.generate_rag_response fest "rag_template" nutzt,
# lesen wir das Template hier aus dem Service (public prop) oder übergeben einen Parameter.
# FIX: Wir laden das Template direkt aus der Config des Services
template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}")
system_prompt = llm.prompts.get("system_prompt", "")
# Manuelles Formatting, um die Flexibilität zu haben
final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message)
# Wir rufen direkt den internen Client auf oder nutzen eine neue Methode.
# Da wir den Service Code nicht brechen wollen, nutzen wir den bestehenden Call
# und tricksen etwas: Wir übergeben den bereits formatierten Prompt als "query"
# und context="" (da wir das Template schon aufgelöst haben).
# ABER: generate_rag_response wendet das Template NOCHMAL an.
# Workaround: Wir müssen das LLM bitten, den "Raw Prompt" zu nutzen.
# Da generate_rag_response hardcoded ist, erweitern wir LLMService idealerweise.
# Da ich LLMService nicht ändern darf (nicht angefordert), nutzen wir den Standard-Flow,
# aber wir überschreiben das Template temporär im Memory-Objekt des Services? Nein, thread-unsafe.
# Pragmatische Lösung für WP-06 ohne LLMService Rewrite:
# Wir nutzen generate_rag_response. Wenn Intent=Decision, schreiben wir in den Context
# explizit eine Anweisung rein.
# ODER: Wir erkennen, dass LLMService im 'Handover' Kontext editierbar war?
# Nein, ich habe LLMService als "Input" bekommen, darf ihn aber als "Lead Dev" anpassen,
# solange ich "Konsistenz respektiere".
# Ich entscheide: Ich lasse LLMService so (Standard RAG) und injiziere die Entscheidungslogik
# über den 'context_str'.
if intent == "DECISION":
# Wir prependen die Instruktion in den Context, das ist robust genug für Phi-3
context_str = (
"!!! ENTSCHEIDUNGS-MODUS !!!\n"
"BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE AB:\n\n"
f"{context_str}"
)
logger.info(f"[{query_id}] Sending to LLM (Intent: {intent})...")
answer_text = await llm.generate_rag_response(
query=request.message,
context_str=context_str
)
# 6. Response
duration_ms = int((time.time() - start_time) * 1000)
return ChatResponse(
query_id=query_id, # Neue ID nehmen oder die vom Search Result? Besser Request ID.
answer=answer_text,
sources=hits,
latency_ms=duration_ms,
intent=intent
)
except Exception as e:
logger.error(f"Error in chat endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))