From 2fa24cb1bd1e52bea449bdfa47a31a020a7ed437 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 8 Dec 2025 19:36:12 +0100 Subject: [PATCH] Konfiguierbare Entscheidungen --- app/config.py | 6 +- app/routers/chat.py | 171 +++++++++++++++--------------------- config/decision_engine.yaml | 25 ++++++ 3 files changed, 101 insertions(+), 101 deletions(-) create mode 100644 config/decision_engine.yaml diff --git a/app/config.py b/app/config.py index a1ea619..dbffba1 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/routers/chat.py b/app/routers/chat.py index b24b2c4..81ae964 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -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, diff --git a/config/decision_engine.yaml b/config/decision_engine.yaml new file mode 100644 index 0000000..58c090d --- /dev/null +++ b/config/decision_engine.yaml @@ -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: \ No newline at end of file