226 lines
9.6 KiB
Python
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)) |