WP07 #7

Merged
Lars merged 11 commits from WP07 into main 2025-12-10 18:57:14 +01:00
4 changed files with 289 additions and 52 deletions
Showing only changes of commit 86464cec11 - Show all commits

View File

@ -1,10 +1,11 @@
""" """
app/routers/chat.py RAG Endpunkt (WP-06 Hybrid Router + WP-04c Feedback) app/routers/chat.py RAG Endpunkt (WP-06 Hybrid Router + WP-07 Interview Mode)
Version: 2.3.2 (Merged Stability Patch) Version: 2.4.0 (Interview Support)
Features: Features:
- Hybrid Intent Router (Keyword + LLM) - Hybrid Intent Router (Keyword + LLM)
- Strategic Retrieval (Late Binding via Config) - Strategic Retrieval (Late Binding via Config)
- Interview Loop (Schema-driven Data Collection)
- Context Enrichment (Payload/Source Fallback) - Context Enrichment (Payload/Source Fallback)
- Data Flywheel (Feedback Logging Integration) - 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.models.dto import ChatRequest, ChatResponse, QueryRequest, QueryHit
from app.services.llm_service import LLMService from app.services.llm_service import LLMService
from app.core.retriever import Retriever from app.core.retriever import Retriever
# [MERGE] Integration Feedback Service (WP-04c)
from app.services.feedback_service import log_search from app.services.feedback_service import log_search
router = APIRouter() router = APIRouter()
@ -62,6 +62,47 @@ def get_decision_strategy(intent: str) -> Dict[str, Any]:
strategies = config.get("strategies", {}) strategies = config.get("strategies", {})
return strategies.get(intent, strategies.get("FACT", {})) 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 --- # --- Dependencies ---
@ -167,67 +208,118 @@ async def chat_endpoint(
# Strategy Load # Strategy Load
strategy = get_decision_strategy(intent) strategy = get_decision_strategy(intent)
inject_types = strategy.get("inject_types", [])
prompt_key = strategy.get("prompt_template", "rag_template") 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) # --- SPLIT LOGIC: INTERVIEW vs. RAG ---
if inject_types:
logger.info(f"[{query_id}] Executing Strategic Retrieval for types: {inject_types}...") sources_hits = []
strategy_req = QueryRequest( final_prompt = ""
query=request.message,
mode="hybrid", if intent == "INTERVIEW":
top_k=3, # --- WP-07: INTERVIEW MODE ---
filters={"type": inject_types}, # Kein Retrieval. Wir nutzen den Dialog-Kontext.
explain=False
) # 1. Schema Loading (Late Binding)
strategy_result = await retriever.search(strategy_req) 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: 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 # 2. Primary Retrieval
template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}") query_req = QueryRequest(
system_prompt = llm.prompts.get("system_prompt", "") 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: # --- COMMON GENERATION ---
context_str = f"{prepend_instr}\n\n{context_str}"
system_prompt = llm.prompts.get("system_prompt", "")
final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message)
logger.info(f"[{query_id}] Sending to LLM (Intent: {intent}, Template: {prompt_key})...") 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) answer_text = await llm.generate_raw_response(prompt=final_prompt, system=system_prompt)
duration_ms = int((time.time() - start_time) * 1000) duration_ms = int((time.time() - start_time) * 1000)
# 6. Logging (Fire & Forget) - [MERGE POINT] # 6. Logging (Fire & Forget)
# Wir loggen alles für das Data Flywheel (WP-08 Self-Tuning)
try: try:
log_search( log_search(
query_id=query_id, query_id=query_id,
query_text=request.message, query_text=request.message,
results=hits, results=sources_hits,
mode="chat_rag", mode="interview" if intent == "INTERVIEW" else "chat_rag",
metadata={ metadata={
"intent": intent, "intent": intent,
"intent_source": intent_source, "intent_source": intent_source,
@ -242,7 +334,7 @@ async def chat_endpoint(
return ChatResponse( return ChatResponse(
query_id=query_id, query_id=query_id,
answer=answer_text, answer=answer_text,
sources=hits, sources=sources_hits,
latency_ms=duration_ms, latency_ms=duration_ms,
intent=intent, intent=intent,
intent_source=intent_source intent_source=intent_source

View File

@ -1,17 +1,19 @@
# config/decision_engine.yaml # 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) # Hybrid-Modus: Keywords (Fast) + LLM Router (Smart Fallback)
version: 1.2 version: 1.3
settings: settings:
llm_fallback_enabled: true llm_fallback_enabled: true
# Few-Shot Prompting für bessere SLM-Performance # Few-Shot Prompting für bessere SLM-Performance
# Erweitert um INTERVIEW Beispiele
llm_router_prompt: | llm_router_prompt: |
Du bist ein Klassifikator. Analysiere die Nachricht und wähle die passende Strategie. Du bist ein Klassifikator. Analysiere die Nachricht und wähle die passende Strategie.
Antworte NUR mit dem Namen der Strategie. Antworte NUR mit dem Namen der Strategie.
STRATEGIEN: STRATEGIEN:
- INTERVIEW: User will Wissen strukturieren, Notizen anlegen, Projekte starten ("Neu", "Festhalten").
- DECISION: Rat, Strategie, Vor/Nachteile, "Soll ich". - DECISION: Rat, Strategie, Vor/Nachteile, "Soll ich".
- EMPATHY: Gefühle, Frust, Freude, Probleme, "Alles ist sinnlos", "Ich bin traurig". - EMPATHY: Gefühle, Frust, Freude, Probleme, "Alles ist sinnlos", "Ich bin traurig".
- CODING: Code, Syntax, Programmierung, Python. - CODING: Code, Syntax, Programmierung, Python.
@ -20,6 +22,8 @@ settings:
BEISPIELE: BEISPIELE:
User: "Wie funktioniert Qdrant?" -> FACT User: "Wie funktioniert Qdrant?" -> FACT
User: "Soll ich Qdrant nutzen?" -> DECISION 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: "Schreibe ein Python Script" -> CODING
User: "Alles ist grau und sinnlos" -> EMPATHY User: "Alles ist grau und sinnlos" -> EMPATHY
User: "Mir geht es heute gut" -> EMPATHY User: "Mir geht es heute gut" -> EMPATHY
@ -86,4 +90,54 @@ strategies:
- "yaml" - "yaml"
inject_types: ["snippet", "reference", "source"] inject_types: ["snippet", "reference", "source"]
prompt_template: "technical_template" 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: FORMAT:
- Kurze Erklärung des Ansatzes. - Kurze Erklärung des Ansatzes.
- Markdown Code-Block (Copy-Paste fertig). - 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", "")