WP06 #4

Merged
Lars merged 15 commits from WP06 into main 2025-12-09 17:14:52 +01:00
3 changed files with 101 additions and 101 deletions
Showing only changes of commit 2fa24cb1bd - Show all commits

View File

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

View File

@ -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,

View 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: