erster Entwurf WP07

This commit is contained in:
Lars 2025-12-10 13:48:13 +01:00
parent ba57e8b43f
commit 86464cec11
4 changed files with 289 additions and 52 deletions

View File

@ -1,10 +1,11 @@
"""
app/routers/chat.py RAG Endpunkt (WP-06 Hybrid Router + WP-04c Feedback)
Version: 2.3.2 (Merged Stability Patch)
app/routers/chat.py RAG Endpunkt (WP-06 Hybrid Router + WP-07 Interview Mode)
Version: 2.4.0 (Interview Support)
Features:
- Hybrid Intent Router (Keyword + LLM)
- Strategic Retrieval (Late Binding via Config)
- Interview Loop (Schema-driven Data Collection)
- Context Enrichment (Payload/Source Fallback)
- Data Flywheel (Feedback Logging Integration)
"""
@ -21,7 +22,6 @@ 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
# [MERGE] Integration Feedback Service (WP-04c)
from app.services.feedback_service import log_search
router = APIRouter()
@ -62,6 +62,47 @@ def get_decision_strategy(intent: str) -> Dict[str, Any]:
strategies = config.get("strategies", {})
return strategies.get(intent, strategies.get("FACT", {}))
# --- Helper: Target Type Detection (WP-07) ---
def _detect_target_type(message: str, configured_schemas: Dict[str, Any]) -> str:
"""
Versucht zu erraten, welchen Notiz-Typ der User erstellen will.
Nutzt Keywords und Mappings.
"""
message_lower = message.lower()
# 1. Direkter Match mit Schema-Keys (z.B. "projekt", "entscheidung")
# Ignoriere 'default' hier
for type_key in configured_schemas.keys():
if type_key == "default":
continue
if type_key in message_lower:
return type_key
# 2. Synonym-Mapping (Deutsch -> Schema Key)
# Dies verbessert die UX, falls User deutsche Begriffe nutzen
synonyms = {
"projekt": "project",
"vorhaben": "project",
"entscheidung": "decision",
"beschluss": "decision",
"ziel": "goal",
"erfahrung": "experience",
"lektion": "experience",
"wert": "value",
"prinzip": "principle",
"grundsatz": "principle",
"notiz": "default",
"idee": "default"
}
for term, schema_key in synonyms.items():
if term in message_lower:
# Prüfen, ob der gemappte Key auch konfiguriert ist
if schema_key in configured_schemas:
return schema_key
return "default"
# --- Dependencies ---
@ -167,67 +208,118 @@ async def chat_endpoint(
# 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
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 Kernfeature)
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},
explain=False
)
strategy_result = await retriever.search(strategy_req)
# --- SPLIT LOGIC: INTERVIEW vs. RAG ---
sources_hits = []
final_prompt = ""
if intent == "INTERVIEW":
# --- WP-07: INTERVIEW MODE ---
# Kein Retrieval. Wir nutzen den Dialog-Kontext.
# 1. Schema Loading (Late Binding)
schemas = strategy.get("schemas", {})
target_type = _detect_target_type(request.message, schemas)
active_schema = schemas.get(target_type, schemas.get("default"))
logger.info(f"[{query_id}] Starting Interview for Type: {target_type}")
# Robustes Schema-Parsing (Dict vs List)
if isinstance(active_schema, dict):
fields_list = active_schema.get("fields", [])
hint_str = active_schema.get("hint", "")
else:
fields_list = active_schema # Fallback falls nur Liste definiert
hint_str = ""
fields_str = "\n- " + "\n- ".join(fields_list)
# 2. Context Logic
# Hinweis: In einer Stateless-API ist {context_str} idealerweise die History.
# Da ChatRequest (noch) kein History-Feld hat, nutzen wir einen Placeholder
# oder verlassen uns darauf, dass der Client die History im Prompt mitschickt
# (Streamlit Pattern: Appends history to prompt).
# Wir labeln es hier explizit.
context_str = "Bisheriger Verlauf (falls vorhanden): Siehe oben/unten."
# 3. Prompt Assembly
template = llm.prompts.get(prompt_key, "")
final_prompt = template.replace("{context_str}", context_str) \
.replace("{query}", request.message) \
.replace("{target_type}", target_type) \
.replace("{schema_fields}", fields_str) \
.replace("{schema_hint}", hint_str)
# Keine Hits im Interview
sources_hits = []
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)
# 4. Context Building
if not hits:
context_str = "Keine relevanten Notizen gefunden."
else:
context_str = _build_enriched_context(hits)
# --- WP-06: STANDARD RAG MODE ---
inject_types = strategy.get("inject_types", [])
prepend_instr = strategy.get("prepend_instruction", "")
# 5. Generation
template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}")
system_prompt = llm.prompts.get("system_prompt", "")
# 2. Primary Retrieval
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 Kernfeature)
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},
explain=False
)
strategy_result = await retriever.search(strategy_req)
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)
# 4. Context Building
if not hits:
context_str = "Keine relevanten Notizen gefunden."
else:
context_str = _build_enriched_context(hits)
# 5. Generation Setup
template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}")
if prepend_instr:
context_str = f"{prepend_instr}\n\n{context_str}"
final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message)
sources_hits = hits
if prepend_instr:
context_str = f"{prepend_instr}\n\n{context_str}"
final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message)
# --- COMMON GENERATION ---
system_prompt = llm.prompts.get("system_prompt", "")
logger.info(f"[{query_id}] Sending to LLM (Intent: {intent}, Template: {prompt_key})...")
# System-Prompt separat übergeben (WP-06a Fix)
# System-Prompt separat übergeben
answer_text = await llm.generate_raw_response(prompt=final_prompt, system=system_prompt)
duration_ms = int((time.time() - start_time) * 1000)
# 6. Logging (Fire & Forget) - [MERGE POINT]
# Wir loggen alles für das Data Flywheel (WP-08 Self-Tuning)
# 6. Logging (Fire & Forget)
try:
log_search(
query_id=query_id,
query_text=request.message,
results=hits,
mode="chat_rag",
results=sources_hits,
mode="interview" if intent == "INTERVIEW" else "chat_rag",
metadata={
"intent": intent,
"intent_source": intent_source,
@ -242,7 +334,7 @@ async def chat_endpoint(
return ChatResponse(
query_id=query_id,
answer=answer_text,
sources=hits,
sources=sources_hits,
latency_ms=duration_ms,
intent=intent,
intent_source=intent_source

View File

@ -1,17 +1,19 @@
# config/decision_engine.yaml
# Steuerung der Decision Engine (WP-06)
# Steuerung der Decision Engine (WP-06 + WP-07)
# Hybrid-Modus: Keywords (Fast) + LLM Router (Smart Fallback)
version: 1.2
version: 1.3
settings:
llm_fallback_enabled: true
# Few-Shot Prompting für bessere SLM-Performance
# Erweitert um INTERVIEW Beispiele
llm_router_prompt: |
Du bist ein Klassifikator. Analysiere die Nachricht und wähle die passende Strategie.
Antworte NUR mit dem Namen der Strategie.
STRATEGIEN:
- INTERVIEW: User will Wissen strukturieren, Notizen anlegen, Projekte starten ("Neu", "Festhalten").
- DECISION: Rat, Strategie, Vor/Nachteile, "Soll ich".
- EMPATHY: Gefühle, Frust, Freude, Probleme, "Alles ist sinnlos", "Ich bin traurig".
- CODING: Code, Syntax, Programmierung, Python.
@ -20,6 +22,8 @@ settings:
BEISPIELE:
User: "Wie funktioniert Qdrant?" -> FACT
User: "Soll ich Qdrant nutzen?" -> DECISION
User: "Ich möchte ein neues Projekt anlegen" -> INTERVIEW
User: "Lass uns eine Entscheidung festhalten" -> INTERVIEW
User: "Schreibe ein Python Script" -> CODING
User: "Alles ist grau und sinnlos" -> EMPATHY
User: "Mir geht es heute gut" -> EMPATHY
@ -86,4 +90,54 @@ strategies:
- "yaml"
inject_types: ["snippet", "reference", "source"]
prompt_template: "technical_template"
prepend_instruction: null
prepend_instruction: null
# 5. Interview / Datenerfassung (WP-07)
INTERVIEW:
description: "Der User möchte strukturiertes Wissen erfassen (Projekt, Notiz, Idee)."
trigger_keywords:
- "neue notiz"
- "neues projekt"
- "neue entscheidung"
- "neues ziel"
- "festhalten"
- "entwurf erstellen"
- "interview"
- "dokumentieren"
- "erfassen"
- "idee speichern"
inject_types: [] # Keine RAG-Suche, reiner Kontext-Dialog
prompt_template: "interview_template"
prepend_instruction: null
# LATE BINDING SCHEMAS:
# Definition der Pflichtfelder pro Typ (korrespondiert mit types.yaml)
# Wenn ein Typ hier fehlt, wird 'default' genutzt.
schemas:
default:
fields: ["Titel", "Thema/Inhalt", "Tags"]
hint: "Halte es einfach und übersichtlich."
project:
fields: ["Titel", "Zielsetzung (Goal)", "Status (draft/active)", "Wichtige Stakeholder", "Nächste Schritte"]
hint: "Achte darauf, Abhängigkeiten zu anderen Projekten mit [[rel:depends_on]] zu erfragen."
decision:
fields: ["Titel", "Kontext (Warum entscheiden wir?)", "Getroffene Entscheidung", "Betrachtete Alternativen", "Status (proposed/final)"]
hint: "Wichtig: Frage explizit nach den Gründen gegen die Alternativen."
goal:
fields: ["Titel", "Zeitrahmen (Deadline)", "Messkriterien (KPIs)", "Verbundene Werte"]
hint: "Ziele sollten SMART formuliert sein."
experience:
fields: ["Titel", "Situation (Kontext)", "Erkenntnis (Learning)", "Emotionale Keywords (für Empathie-Suche)"]
hint: "Fokussiere dich auf die persönliche Lektion."
value:
fields: ["Titel (Name des Werts)", "Definition (Was bedeutet das für uns?)", "Anti-Beispiel (Was ist es nicht?)"]
hint: "Werte dienen als Entscheidungsgrundlage."
principle:
fields: ["Titel", "Handlungsanweisung", "Begründung"]
hint: "Prinzipien sind härter als Werte."

View File

@ -93,4 +93,47 @@ technical_template: |
FORMAT:
- Kurze Erklärung des Ansatzes.
- Markdown Code-Block (Copy-Paste fertig).
- Wichtige Edge-Cases.
- Wichtige Edge-Cases.
# ---------------------------------------------------------
# 5. INTERVIEW: Der Analyst (Intent: INTERVIEW)
# ---------------------------------------------------------
interview_template: |
CHAT_HISTORIE (BISHERIGER KONTEXT):
=========================================
{context_str}
=========================================
AKTUELLE USER-EINGABE:
{query}
DEINE ROLLE:
Du bist 'Mindnet Analyst'. Dein Ziel ist es, einen strukturierten Entwurf für eine Notiz vom Typ '{target_type}' zu erstellen.
PFLICHTFELDER (SCHEMA):
{schema_fields}
HINWEIS ZUM TYP:
{schema_hint}
ANWEISUNG:
1. Analysiere den bisherigen Verlauf und die Eingabe. Welche der Pflichtfelder sind bereits bekannt?
2. STATUS CHECK:
- Fehlen Pflichtfelder? -> Stelle GENAU EINE gezielte Frage, um das nächste fehlende Feld zu klären. Warte auf die Antwort.
- Sind alle Felder grob geklärt? -> Generiere den finalen Entwurf.
OUTPUT FORMAT (Nur wenn alle Infos da sind):
Erstelle einen Markdown-Codeblock. Nutze Frontmatter.
Verlinke erkannte Entitäten aggressiv mit [[Wikilinks]] oder [[rel:relation Ziel]].
Beispiel Output:
"Danke, ich habe alle Infos. Hier ist dein Entwurf:"
```markdown
---
type: {target_type}
status: draft
tags: [...]
---
# Titel
...

View File

@ -0,0 +1,48 @@
import requests
import json
# URL anpassen, falls du auf Port 8001 (Prod) oder 8002 (Dev) bist
API_URL = "http://localhost:8002/chat/"
def test_intent(message, expected_intent, expected_type_hint):
payload = {
"message": message,
"top_k": 0, # Für Interview irrelevant
"explain": False
}
try:
response = requests.post(API_URL, json=payload)
response.raise_for_status()
data = response.json()
intent = data.get("intent")
answer = data.get("answer")
print(f"--- TEST: '{message}' ---")
print(f"Erkannter Intent: {intent}")
print(f"Antwort-Snippet: {answer[:100]}...")
if intent == expected_intent:
print("✅ Intent SUCCESS")
else:
print(f"❌ Intent FAILED (Erwartet: {expected_intent})")
if expected_type_hint.lower() in answer.lower():
print(f"✅ Context Check SUCCESS (Typ '{expected_type_hint}' erkannt)")
else:
print(f"⚠️ Context Check WARNING (Typ '{expected_type_hint}' nicht explizit im Start-Prompt gefunden)")
print("\n")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
# Test 1: Projekt-Start
test_intent("Ich möchte ein neues Projekt anlegen", "INTERVIEW", "project")
# Test 2: Entscheidungs-Doku
test_intent("Lass uns eine Entscheidung festhalten", "INTERVIEW", "decision")
# Test 3: Standard Chat (Gegenprobe)
test_intent("Was ist ein Vektor?", "FACT", "")