WP06 #4
|
|
@ -2,7 +2,7 @@
|
|||
app/config.py — zentrale Konfiguration (ENV → Settings)
|
||||
|
||||
Version:
|
||||
0.3.1 (WP-05: Switch default to Mistral for CPU inference)
|
||||
0.4.0 (WP-06: Added Decision Engine Config)
|
||||
Stand:
|
||||
2025-12-08
|
||||
"""
|
||||
|
|
@ -25,10 +25,12 @@ class Settings:
|
|||
|
||||
# WP-05 LLM / Ollama
|
||||
OLLAMA_URL: str = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434")
|
||||
# ÄNDERUNG: Standard auf 'mistral' gesetzt, da bereits lokal vorhanden
|
||||
LLM_MODEL: str = os.getenv("MINDNET_LLM_MODEL", "phi3:mini")
|
||||
PROMPTS_PATH: str = os.getenv("MINDNET_PROMPTS_PATH", "config/prompts.yaml")
|
||||
|
||||
# WP-06 Decision Engine
|
||||
DECISION_CONFIG_PATH: str = os.getenv("MINDNET_DECISION_CONFIG", "config/decision_engine.yaml")
|
||||
|
||||
# API
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
"""
|
||||
app/routers/chat.py — RAG Endpunkt (WP-06 Decision Engine)
|
||||
app/routers/chat.py — RAG Endpunkt (WP-06 Decision Engine - Late Binding Refactor)
|
||||
|
||||
Zweck:
|
||||
Verbindet Retrieval mit LLM-Generation.
|
||||
WP-06: Implementiert Intent Detection und Strategic Retrieval (Values/Principles).
|
||||
WP-06: Implementiert Intent Detection und Strategic Retrieval.
|
||||
Update: Konfiguration via decision_engine.yaml (Late Binding).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Any
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.dto import ChatRequest, ChatResponse, QueryRequest, QueryHit
|
||||
from app.services.llm_service import LLMService
|
||||
from app.core.retriever import Retriever
|
||||
|
|
@ -19,17 +23,57 @@ from app.core.retriever import Retriever
|
|||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 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 ---
|
||||
|
||||
def get_llm_service():
|
||||
return LLMService()
|
||||
|
||||
def get_retriever():
|
||||
return 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 = []
|
||||
|
||||
|
|
@ -47,7 +91,6 @@ def _build_enriched_context(hits: List[QueryHit]) -> str:
|
|||
|
||||
# 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
|
||||
|
|
@ -62,46 +105,13 @@ def _build_enriched_context(hits: List[QueryHit]) -> str:
|
|||
|
||||
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.
|
||||
WP-06: Intent Detection (Simple Keyword Heuristic for Speed).
|
||||
TODO: Move keywords to config if needed later.
|
||||
"""
|
||||
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)
|
||||
# Performance-Optimierung: Keywords statt LLM Call
|
||||
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)
|
||||
|
|
@ -116,14 +126,17 @@ async def chat_endpoint(
|
|||
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.
|
||||
# 1. Intent Detection
|
||||
intent = await _classify_intent(request.message, llm)
|
||||
logger.info(f"[{query_id}] Detected Intent: {intent}")
|
||||
|
||||
# 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", "")
|
||||
|
||||
# 2. Primary Retrieval (Fakten)
|
||||
# Hybrid Search für Graph-Nachbarn
|
||||
query_req = QueryRequest(
|
||||
query=request.message,
|
||||
mode="hybrid",
|
||||
|
|
@ -133,28 +146,23 @@ async def chat_endpoint(
|
|||
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.
|
||||
# 3. Strategic Retrieval (Konfigurierbar)
|
||||
if inject_types:
|
||||
logger.info(f"[{query_id}] Executing Strategic Retrieval for types: {inject_types}...")
|
||||
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
|
||||
mode="hybrid",
|
||||
top_k=3,
|
||||
filters={"type": inject_types}, # Dynamische Liste aus YAML
|
||||
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.
|
||||
# Merge Results (Deduplication via node_id)
|
||||
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:
|
||||
|
|
@ -162,49 +170,14 @@ async def chat_endpoint(
|
|||
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
|
||||
|
||||
# 5. Generation Setup
|
||||
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}"
|
||||
)
|
||||
# Injection der Instruktion (falls konfiguriert)
|
||||
if prepend_instr:
|
||||
context_str = f"{prepend_instr}\n\n{context_str}"
|
||||
|
||||
logger.info(f"[{query_id}] Sending to LLM (Intent: {intent})...")
|
||||
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
|
||||
|
|
@ -214,7 +187,7 @@ async def chat_endpoint(
|
|||
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.
|
||||
query_id=query_id,
|
||||
answer=answer_text,
|
||||
sources=hits,
|
||||
latency_ms=duration_ms,
|
||||
|
|
|
|||
25
config/decision_engine.yaml
Normal file
25
config/decision_engine.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# config/decision_engine.yaml
|
||||
# Steuerung der Decision Engine (WP-06)
|
||||
# Hier wird definiert, wie auf verschiedene Intents reagiert wird.
|
||||
|
||||
version: 1.0
|
||||
|
||||
strategies:
|
||||
# 1. Fakten-Abfrage (Standard)
|
||||
FACT:
|
||||
description: "Reine Wissensabfrage."
|
||||
inject_types: [] # Keine speziellen Typen erzwingen
|
||||
prompt_template: "rag_template"
|
||||
prepend_instruction: null # Keine spezielle Anweisung im Context
|
||||
|
||||
# 2. Entscheidungs-Frage (WP-06)
|
||||
DECISION:
|
||||
description: "Der User sucht Rat, Strategie oder Abwägung."
|
||||
# HIER definierst du, was das 'Gewissen' ausmacht:
|
||||
# Aktuell: Werte & Prinzipien.
|
||||
# Später einfach ergänzen um: "goal", "experience", "belief"
|
||||
inject_types: ["value", "principle"]
|
||||
prompt_template: "decision_template"
|
||||
prepend_instruction: |
|
||||
!!! ENTSCHEIDUNGS-MODUS !!!
|
||||
BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE/PRINZIPIEN AB:
|
||||
Loading…
Reference in New Issue
Block a user