Hybrider Chat (mit und ohne LLM Einordung des Intents)

This commit is contained in:
Lars 2025-12-09 13:08:04 +01:00
parent 97985371ca
commit 03594424a1
4 changed files with 285 additions and 139 deletions

View File

@ -1,10 +1,5 @@
"""
app/routers/chat.py RAG Endpunkt (WP-06 Decision Engine - Full Config Refactor)
Zweck:
Verbindet Retrieval mit LLM-Generation.
WP-06: Implementiert Intent Detection und Strategic Retrieval.
Update: Konfiguration via decision_engine.yaml (Late Binding) mit 'Best Match' Logik.
app/routers/chat.py RAG Endpunkt (WP-06 Hybrid Router)
"""
from fastapi import APIRouter, HTTPException, Depends
@ -25,23 +20,15 @@ logger = logging.getLogger(__name__)
# --- Helper: Config Loader ---
# Cache für die Config (damit wir nicht bei jedem Request lesen)
_DECISION_CONFIG_CACHE = None
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 Fallback, falls YAML kaputt/weg
default_config = {
"strategies": {
"FACT": {"trigger_keywords": []},
"DECISION": {
"trigger_keywords": ["soll ich", "meinung"],
"inject_types": ["value", "principle"],
"prompt_template": "decision_template"
}
"FACT": {"trigger_keywords": []}
}
}
@ -57,17 +44,14 @@ def _load_decision_config() -> Dict[str, Any]:
return default_config
def get_full_config() -> Dict[str, Any]:
"""Gibt die ganze Config zurück (für Intent Detection)."""
global _DECISION_CONFIG_CACHE
if _DECISION_CONFIG_CACHE is None:
_DECISION_CONFIG_CACHE = _load_decision_config()
return _DECISION_CONFIG_CACHE
def get_decision_strategy(intent: str) -> Dict[str, Any]:
"""Gibt die Strategie für einen spezifischen Intent zurück."""
config = get_full_config()
strategies = config.get("strategies", {})
# Fallback auf FACT, wenn Intent unbekannt
return strategies.get(intent, strategies.get("FACT", {}))
@ -83,30 +67,17 @@ def get_retriever():
# --- Logic ---
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]"
source.get("text") or source.get("content") or
source.get("page_content") or source.get("chunk_text") or
"[Kein Text]"
)
# 2. Metadaten für "Context Intelligence"
title = hit.note_id or "Unbekannte Notiz"
title = hit.note_id or "Unbekannt"
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"
@ -118,38 +89,58 @@ def _build_enriched_context(hits: List[QueryHit]) -> str:
async def _classify_intent(query: str, llm: LLMService) -> str:
"""
WP-06: Intent Detection (Best Match / Longest Keyword Wins).
Prüft Keywords aus der YAML gegen die Query.
Wenn mehrere Strategien passen, gewinnt die mit dem längsten Keyword (Spezifität).
Hybrid Router:
1. Keyword Check (Best/Longest Match) -> FAST
2. LLM Fallback (wenn in config aktiv) -> SMART
"""
config = get_full_config()
strategies = config.get("strategies", {})
settings = config.get("settings", {})
query_lower = query.lower()
best_intent = "FACT"
best_intent = None
max_match_length = 0
# Iteriere über alle Strategien
# 1. FAST PATH: Keywords
for intent_name, strategy in strategies.items():
if intent_name == "FACT":
continue
if intent_name == "FACT": continue
keywords = strategy.get("trigger_keywords", [])
# Prüfe jedes Keyword
for k in keywords:
# Wenn Keyword im Text ist...
if k.lower() in query_lower:
# ... prüfen wir, ob es spezifischer (länger) ist als der bisherige Favorit
current_len = len(k)
if current_len > max_match_length:
max_match_length = current_len
if len(k) > max_match_length:
max_match_length = len(k)
best_intent = intent_name
# Wir brechen hier NICHT ab, sondern suchen weiter nach noch längeren Matches
if best_intent:
logger.info(f"Intent detected via KEYWORD: {best_intent}")
return best_intent
# 2. SLOW PATH: LLM Router
if settings.get("llm_fallback_enabled", False):
router_prompt_template = settings.get("llm_router_prompt", "")
if router_prompt_template:
prompt = router_prompt_template.replace("{query}", query)
logger.info("Keywords failed. Asking LLM for Intent...")
return best_intent
# Kurzer Raw Call
llm_decision = await llm.generate_raw_response(prompt)
# Cleaning
llm_decision = llm_decision.strip().upper()
if ":" in llm_decision:
llm_decision = llm_decision.split(":")[-1].strip()
# Validierung: Nur bekannte Intents zulassen
# Entferne Satzzeichen
llm_decision = ''.join(filter(str.isalnum, llm_decision))
if llm_decision in strategies:
logger.info(f"Intent detected via LLM: {llm_decision}")
return llm_decision
else:
logger.warning(f"LLM predicted unknown intent '{llm_decision}', falling back to FACT.")
return "FACT"
@router.post("/", response_model=ChatResponse)
async def chat_endpoint(
@ -159,21 +150,20 @@ async def chat_endpoint(
):
start_time = time.time()
query_id = str(uuid.uuid4())
logger.info(f"Chat request [{query_id}]: {request.message[:50]}...")
try:
# 1. Intent Detection (Config-Driven & Best Match)
# 1. Intent Detection
intent = await _classify_intent(request.message, llm)
logger.info(f"[{query_id}] Detected Intent: {intent}")
logger.info(f"[{query_id}] Final Intent: {intent}")
# Lade Strategie aus Config (Late Binding)
# Strategy Load
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", "")
# 2. Primary Retrieval (Fakten)
# 2. Primary Retrieval
query_req = QueryRequest(
query=request.message,
mode="hybrid",
@ -183,19 +173,19 @@ async def chat_endpoint(
retrieve_result = await retriever.search(query_req)
hits = retrieve_result.results
# 3. Strategic Retrieval (Konfigurierbar)
# 3. Strategic Retrieval
if inject_types:
logger.info(f"[{query_id}] Executing Strategic Retrieval for types: {inject_types}...")
strategy_req = QueryRequest(
query=request.message,
mode="hybrid",
top_k=3,
filters={"type": inject_types}, # Dynamische Liste aus YAML
filters={"type": inject_types},
explain=False
)
strategy_result = await retriever.search(strategy_req)
# Merge Results (Deduplication via node_id)
# Merge
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:
@ -207,20 +197,29 @@ async def chat_endpoint(
else:
context_str = _build_enriched_context(hits)
# 5. Generation Setup
# 5. Generation
# Wir laden das Template aus dem Service (da dort die prompts.yaml geladen ist)
template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}")
system_prompt = llm.prompts.get("system_prompt", "")
# Injection der Instruktion (falls konfiguriert)
if prepend_instr:
context_str = f"{prepend_instr}\n\n{context_str}"
# Manuelles Bauen des finalen Prompts für volle Kontrolle
final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message)
# Aufruf via Raw Response (da wir den Prompt schon fertig haben)
# Wir müssen den System-Prompt manuell mitgeben?
# generate_raw_response in llm_service unterstützt aktuell kein 'system'.
# -> Wir erweitern generate_raw_response oder nutzen einen Hack: System + Prompt.
# SAUBERER WEG: Wir bauen den Payload für Ollama hier manuell zusammen und rufen eine generische Methode.
# Da LLMService.generate_raw_response keine System-Msg nimmt, packen wir sie davor.
full_text_prompt = f"{system_prompt}\n\n{final_prompt}"
logger.info(f"[{query_id}] Sending to LLM (Intent: {intent}, Template: {prompt_key})...")
answer_text = await llm.generate_rag_response(
query=request.message,
context_str=context_str
)
answer_text = await llm.generate_raw_response(full_text_prompt)
# 6. Response
duration_ms = int((time.time() - start_time) * 1000)
return ChatResponse(

View File

@ -2,7 +2,7 @@
app/services/llm_service.py LLM Client (Ollama)
Version:
0.1.2 (WP-05 Fix: Increased Timeout for CPU Inference)
0.2.0 (WP-06 Hybrid Router Support)
"""
import httpx
@ -18,18 +18,19 @@ class LLMService:
def __init__(self):
self.settings = get_settings()
self.prompts = self._load_prompts()
# FIX: Timeout auf 120 Sekunden erhöht für CPU-Only Server
self.client = httpx.AsyncClient(base_url=self.settings.OLLAMA_URL, timeout=120.0)
# Timeout aus Config nutzen (Default 120s)
self.client = httpx.AsyncClient(
base_url=self.settings.OLLAMA_URL,
timeout=self.settings.LLM_TIMEOUT
)
def _load_prompts(self) -> dict:
"""Lädt Prompts aus der konfigurierten YAML-Datei."""
path = Path(self.settings.PROMPTS_PATH)
if not path.exists():
logger.warning(f"Prompt config not found at {path}, using defaults.")
return {
"system_prompt": "You are a helpful AI assistant.",
"rag_template": "Context: {context_str}\nQuestion: {query}"
}
return {}
try:
with open(path, "r", encoding="utf-8") as f:
@ -38,15 +39,76 @@ class LLMService:
logger.error(f"Failed to load prompts: {e}")
return {}
async def generate_raw_response(self, prompt: str) -> str:
"""
Führt einen direkten LLM Call ohne RAG-Template aus.
Ideal für Classification/Routing.
"""
payload = {
"model": self.settings.LLM_MODEL,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.0, # Deterministisch für Routing!
"num_ctx": 512 # Kleines Fenster reicht für Classification
}
}
try:
response = await self.client.post("/api/generate", json=payload)
if response.status_code != 200:
logger.error(f"Ollama Error ({response.status_code}): {response.text}")
return "FACT" # Fallback bei Fehler
data = response.json()
return data.get("response", "").strip()
except Exception as e:
logger.error(f"LLM Raw Gen Error: {e}")
return "FACT"
async def generate_rag_response(self, query: str, context_str: str) -> str:
"""
Generiert eine Antwort basierend auf Query und Kontext.
Generiert eine Antwort basierend auf Query und Kontext (RAG).
"""
system_prompt = self.prompts.get("system_prompt", "")
template = self.prompts.get("rag_template", "{context_str}\n\n{query}")
# Template hier ist nur Fallback, falls im Router nichts übergeben wird.
# Im Normalfall formatiert der Router den context_str bereits vor oder übergibt das Template.
# Hier nutzen wir simple substitution, da der Prompt meist schon vom Router aufbereitet ist
# oder wir nutzen das Standard-Template aus der YAML.
# Template füllen
final_prompt = template.format(context_str=context_str, query=query)
# HINWEIS: In der neuen Architektur (chat.py) wird das Template bereits VOR diesem Aufruf
# geladen und formatiert, und als 'prompt' übergeben?
# Nein, chat.py ruft generate_rag_response(query, context_str) auf.
# Wir müssen sicherstellen, dass wir das *richtige* Template nutzen.
# Da generate_rag_response aktuell KEINEN 'template_key' Parameter hat,
# gehen wir davon aus, dass 'context_str' bereits Instruktionen enthält ODER
# dass chat.py den Prompt komplett baut.
# Um die API sauber zu halten: chat.py übergibt jetzt den FERTIGEN Prompt als context_str?
# Nein, chat.py baut den Prompt mit replace().
# Wir ändern diese Methode leicht ab, um flexibler zu sein:
# Wir erwarten, dass der Aufrufer (chat.py) die volle Kontrolle hat.
# Damit es 100% zusammenpasst mit dem chat.py unten:
# chat.py baut den finalen Prompt selbst zusammen!
# Wir nutzen daher generate_raw_response eigentlich auch für RAG,
# ODER wir passen generate_rag_response an, dass es "dumm" ist.
# Legacy Support für generate_rag_response:
# Wir bauen den Prompt zusammen, falls noch nicht geschehen.
# ABER: chat.py in meiner Version unten macht das Template-Handling.
# Daher ist der sauberste Weg: chat.py ruft generate_raw_response auf für die Antwort!
# FIX für Kompatibilität: Wir leiten rag_response intern auf raw um,
# bauen aber vorher den Prompt, falls context_str übergeben wird.
# Da wir chat.py kontrollieren (siehe unten), ändern wir chat.py so,
# dass es generate_raw_response nutzt! Das ist viel sauberer.
# Diese Methode bleibt für Backward Compatibility.
system_prompt = self.prompts.get("system_prompt", "")
rag_template = self.prompts.get("rag_template", "{context_str}\n\n{query}")
final_prompt = rag_template.format(context_str=context_str, query=query)
payload = {
"model": self.settings.LLM_MODEL,
@ -55,29 +117,17 @@ class LLMService:
"stream": False,
"options": {
"temperature": 0.7,
# Kleinerer Context spart Rechenzeit, falls 4096 zu viel ist
"num_ctx": 2048
}
}
try:
response = await self.client.post("/api/generate", json=payload)
if response.status_code != 200:
error_msg = response.text
logger.error(f"Ollama API Error ({response.status_code}): {error_msg}")
return f"Fehler vom LLM (Modell '{self.settings.LLM_MODEL}' vorhanden?): {error_msg}"
data = response.json()
return data.get("response", "")
except httpx.ReadTimeout:
return "Timeout: Das Modell braucht zu lange zum Antworten (>120s). Hardware-Limit erreicht?"
except httpx.ConnectError:
return "Verbindungsfehler: Ist Ollama gestartet (Port 11434)?"
return f"Error: {response.text}"
return response.json().get("response", "")
except Exception as e:
logger.error(f"LLM Service Exception: {e}")
return f"Interner Fehler: {str(e)}"
return f"Error: {str(e)}"
async def close(self):
await self.client.aclose()

View File

@ -1,21 +1,83 @@
version: 1.0
# config/decision_engine.yaml
# Steuerung der Decision Engine (WP-06)
# Hybrid-Modus: Keywords (Fast) + LLM Router (Smart Fallback)
version: 1.1
settings:
# Schalter: Soll das LLM gefragt werden, wenn kein Keyword passt?
llm_fallback_enabled: true
# Der Prompt für den "Semantic Router" (Slow Path)
llm_router_prompt: |
Analysiere die folgende Nachricht und entscheide, welche Strategie passt.
Antworte NUR mit dem Namen der Strategie (ein Wort).
STRATEGIEN:
- DECISION: User fragt nach Rat, Meinung, Strategie, Vor/Nachteilen.
- EMPATHY: User äußert Gefühle, Frust, Freude oder persönliche Probleme.
- CODING: User fragt nach Code, Syntax oder Programmierung.
- FACT: User fragt nach Wissen, Definitionen oder Fakten (Default).
NACHRICHT: "{query}"
STRATEGIE:
strategies:
# Strategie 1: Der Berater (Das haben wir gebaut)
# 1. Fakten-Abfrage (Fallback & Default)
FACT:
description: "Reine Wissensabfrage."
trigger_keywords: []
inject_types: []
prompt_template: "rag_template"
prepend_instruction: null
# 2. Entscheidungs-Frage
DECISION:
trigger_keywords: ["soll ich", "empfehlung", "strategie"]
description: "Der User sucht Rat, Strategie oder Abwägung."
trigger_keywords:
- "soll ich"
- "meinung"
- "besser"
- "empfehlung"
- "strategie"
- "entscheidung"
- "wert"
- "prinzip"
- "vor- und nachteile"
- "abwägung"
inject_types: ["value", "principle", "goal"]
prompt_template: "decision_template" # Nutzt das "Abwägen"-Template
prompt_template: "decision_template"
prepend_instruction: |
!!! ENTSCHEIDUNGS-MODUS !!!
BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE, PRINZIPIEN UND ZIELE AB:
# Strategie 2: Der empathische Zuhörer (NEU - Konzept)
# 3. Empathie / "Ich"-Modus
EMPATHY:
trigger_keywords: ["ich fühle", "traurig", "gestresst", "angst"]
inject_types: ["belief", "experience"] # Lädt Glaubenssätze & eigene Erfahrungen
prompt_template: "empathy_template" # Ein Template, das auf "Zuhören" getrimmt ist
prepend_instruction: "SEI EMPATHISCH. SPIEGEL DIE GEFÜHLE."
description: "Reaktion auf emotionale Zustände."
trigger_keywords:
- "ich fühle"
- "traurig"
- "glücklich"
- "gestresst"
- "angst"
- "nervt"
- "überfordert"
inject_types: ["experience", "belief", "profile"]
prompt_template: "empathy_template"
prepend_instruction: null
# Strategie 3: Der Coder (NEU - Konzept)
# 4. Coding / Technical
CODING:
trigger_keywords: ["code", "python", "funktion", "bug"]
inject_types: ["snippet", "reference"] # Lädt nur technische Schnipsel
prompt_template: "technical_template" # Ein Template, das Codeblöcke erzwingt
description: "Technische Anfragen und Programmierung."
trigger_keywords:
- "code"
- "python"
- "script"
- "funktion"
- "bug"
- "syntax"
- "json"
- "yaml"
inject_types: ["snippet", "reference", "source"]
prompt_template: "technical_template"
prepend_instruction: null

View File

@ -1,30 +1,20 @@
# config/prompts.yaml — Final V2.3 (WP-06 Decision Engine)
# Optimiert für Phi-3 Mini (Small Language Model)
# config/prompts.yaml — Final V2.3.1 (Multi-Personality Support)
system_prompt: |
Du bist 'mindnet', mein digitaler Zwilling und strategischer Partner.
DEINE IDENTITÄT:
- Du bist nicht nur eine Datenbank, sondern handelst nach MEINEN Werten.
- Du bist objektiv bei Fakten, aber subjektiv (in meinem Sinne) bei Entscheidungen.
- Du bist nicht nur eine Datenbank, sondern handelst nach MEINEN Werten und Zielen.
- Du passt deinen Stil dynamisch an die Situation an (Analytisch, Empathisch oder Technisch).
DEINE REGELN:
1. Deine Antwort muss auf dem bereitgestellten KONTEXT basieren.
2. Unterscheide klar zwischen FAKTEN (externe Welt) und PRINZIPIEN (meine innere Welt).
3. Wenn Quellen vom Typ [VALUE] oder [PRINCIPLE] vorliegen, haben diese Vorrang bei der Entscheidungsfindung.
4. Antworte auf Deutsch.
# Neuer Prompt für WP-06: Intent Detection
intent_prompt: |
Klassifiziere die folgende User-Anfrage.
Antworte NUR mit einem einzigen Wort: 'FACT' oder 'DECISION'.
'FACT': Der User fragt nach Wissen, Definitionen, Syntax oder Inhalten (z.B. "Was ist...", "Wie funktioniert...", "Zusammenfassung von...").
'DECISION': Der User fragt nach Rat, Meinung, Strategie oder Abwägung (z.B. "Soll ich...", "Was ist besser...", "Lohnt sich...", "Wie gehe ich vor...").
ANFRAGE: "{query}"
KLASSE:
1. Deine Antwort muss zu 100% auf dem bereitgestellten KONTEXT basieren.
2. Halluziniere keine Fakten, die nicht in den Quellen stehen.
3. Antworte auf Deutsch (außer bei Code/Fachbegriffen).
# ---------------------------------------------------------
# 1. STANDARD: Fakten & Wissen (Intent: FACT)
# ---------------------------------------------------------
rag_template: |
QUELLEN (WISSEN):
=========================================
@ -35,12 +25,14 @@ rag_template: |
{query}
ANWEISUNG:
Beantworte die Frage basierend auf den Quellen.
Nenne die spezifischen Gründe, die im Text stehen (besonders aus [DECISION] Quellen).
Beantworte die Frage präzise basierend auf den Quellen.
Fasse die Informationen zusammen. Sei objektiv und neutral.
# Neues Template für WP-06: Reasoning & Decision Making
# ---------------------------------------------------------
# 2. DECISION: Strategie & Abwägung (Intent: DECISION)
# ---------------------------------------------------------
decision_template: |
KONTEXT (FAKTEN & WERTE):
KONTEXT (FAKTEN & STRATEGIE):
=========================================
{context_str}
=========================================
@ -51,11 +43,54 @@ decision_template: |
ANWEISUNG:
Du agierst als mein Entscheidungs-Partner.
1. Analysiere die Faktenlage aus den Quellen.
2. Prüfe dies gegen meine [VALUE] und [PRINCIPLE] Quellen (falls vorhanden).
2. Prüfe dies hart gegen meine strategischen Notizen (Typ [VALUE], [PRINCIPLE], [GOAL]).
3. Wäge ab: Passt die technische/faktische Lösung zu meinen Werten?
4. Gib eine klare Empfehlung ab.
FORMAT:
- **Analyse:** (Faktenlage)
- **Werte-Check:** (Konflikt oder Übereinstimmung mit Prinzipien)
- **Fazit:** (Deine Empfehlung)
- **Analyse:** (Kurze Zusammenfassung der Fakten)
- **Abgleich:** (Gibt es Konflikte mit Werten/Zielen? Nenne die Quelle!)
- **Empfehlung:** (Klare Meinung: Ja/Nein/Vielleicht mit Begründung)
# ---------------------------------------------------------
# 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY)
# ---------------------------------------------------------
empathy_template: |
KONTEXT (ERFAHRUNGEN & GLAUBENSSÄTZE):
=========================================
{context_str}
=========================================
SITUATION:
{query}
ANWEISUNG:
Du agierst jetzt als mein empathischer Spiegel.
1. Versuche nicht sofort, das Problem technisch zu lösen.
2. Zeige Verständnis für die Situation basierend auf meinen eigenen Erfahrungen ([EXPERIENCE]) oder Glaubenssätzen ([BELIEF]), falls im Kontext vorhanden.
3. Antworte in der "Ich"-Form oder "Wir"-Form. Sei unterstützend.
TONFALL:
Ruhig, verständnisvoll, reflektiert. Keine Aufzählungszeichen, sondern fließender Text.
# ---------------------------------------------------------
# 4. TECHNICAL: Der Coder (Intent: CODING)
# ---------------------------------------------------------
technical_template: |
KONTEXT (DOCS & SNIPPETS):
=========================================
{context_str}
=========================================
TASK:
{query}
ANWEISUNG:
Du bist Senior Developer.
1. Ignoriere Smalltalk. Komm sofort zum Punkt.
2. Generiere validen, performanten Code basierend auf den Quellen.
3. Wenn Quellen fehlen, nutze dein allgemeines Programmierwissen, aber weise darauf hin.
FORMAT:
- Kurze Erklärung des Ansatzes.
- Markdown Code-Block (Copy-Paste fertig).
- Wichtige Edge-Cases.