From 86464cec113ad9bf2b7172353a2ee300b2f9fd53 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Dec 2025 13:48:13 +0100 Subject: [PATCH] erster Entwurf WP07 --- app/routers/chat.py | 188 ++++++++++++++++++++++++--------- config/decision_engine.yaml | 60 ++++++++++- config/prompts.yaml | 45 +++++++- tests/test_interview_intent.py | 48 +++++++++ 4 files changed, 289 insertions(+), 52 deletions(-) create mode 100644 tests/test_interview_intent.py diff --git a/app/routers/chat.py b/app/routers/chat.py index 5ab2d3a..45cb679 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -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 diff --git a/config/decision_engine.yaml b/config/decision_engine.yaml index f0f9e2d..406ec25 100644 --- a/config/decision_engine.yaml +++ b/config/decision_engine.yaml @@ -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 \ No newline at end of file + 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." \ No newline at end of file diff --git a/config/prompts.yaml b/config/prompts.yaml index b36b12c..c913f1f 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -93,4 +93,47 @@ technical_template: | FORMAT: - Kurze Erklärung des Ansatzes. - Markdown Code-Block (Copy-Paste fertig). - - Wichtige Edge-Cases. \ No newline at end of file + - 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 + ... \ No newline at end of file diff --git a/tests/test_interview_intent.py b/tests/test_interview_intent.py new file mode 100644 index 0000000..f61b611 --- /dev/null +++ b/tests/test_interview_intent.py @@ -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", "") \ No newline at end of file