""" 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))