From 03594424a1d21a2a666ad0631598a50f3bd1a03c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 9 Dec 2025 13:08:04 +0100 Subject: [PATCH] Hybrider Chat (mit und ohne LLM Einordung des Intents) --- app/routers/chat.py | 137 ++++++++++++++++++------------------ app/services/llm_service.py | 106 ++++++++++++++++++++-------- config/decision_engine.yaml | 88 +++++++++++++++++++---- config/prompts.yaml | 93 ++++++++++++++++-------- 4 files changed, 285 insertions(+), 139 deletions(-) diff --git a/app/routers/chat.py b/app/routers/chat.py index 9afcf59..5faa4dc 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -1,10 +1,5 @@ """ -app/routers/chat.py — RAG Endpunkt (WP-06 Decision Engine - Full Config Refactor) - -Zweck: - Verbindet Retrieval mit LLM-Generation. - WP-06: Implementiert Intent Detection und Strategic Retrieval. - Update: Konfiguration via decision_engine.yaml (Late Binding) mit 'Best Match' Logik. +app/routers/chat.py — RAG Endpunkt (WP-06 Hybrid Router) """ from fastapi import APIRouter, HTTPException, Depends @@ -25,23 +20,15 @@ logger = logging.getLogger(__name__) # --- Helper: Config Loader --- -# Cache für die Config (damit wir nicht bei jedem Request lesen) _DECISION_CONFIG_CACHE = None 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 Fallback, falls YAML kaputt/weg default_config = { "strategies": { - "FACT": {"trigger_keywords": []}, - "DECISION": { - "trigger_keywords": ["soll ich", "meinung"], - "inject_types": ["value", "principle"], - "prompt_template": "decision_template" - } + "FACT": {"trigger_keywords": []} } } @@ -57,17 +44,14 @@ def _load_decision_config() -> Dict[str, Any]: return default_config def get_full_config() -> Dict[str, Any]: - """Gibt die ganze Config zurück (für Intent Detection).""" global _DECISION_CONFIG_CACHE if _DECISION_CONFIG_CACHE is None: _DECISION_CONFIG_CACHE = _load_decision_config() return _DECISION_CONFIG_CACHE def get_decision_strategy(intent: str) -> Dict[str, Any]: - """Gibt die Strategie für einen spezifischen Intent zurück.""" config = get_full_config() strategies = config.get("strategies", {}) - # Fallback auf FACT, wenn Intent unbekannt return strategies.get(intent, strategies.get("FACT", {})) @@ -83,30 +67,17 @@ def get_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 = [] - for i, hit in enumerate(hits, 1): source = hit.source or {} - - # 1. Content extrahieren content = ( - source.get("text") or - source.get("content") or - source.get("page_content") or - source.get("chunk_text") or - "[Kein Textinhalt verfügbar]" + source.get("text") or source.get("content") or + source.get("page_content") or source.get("chunk_text") or + "[Kein Text]" ) - - # 2. Metadaten für "Context Intelligence" - title = hit.note_id or "Unbekannte Notiz" + title = hit.note_id or "Unbekannt" note_type = source.get("type", "unknown").upper() - # 3. Formatierung entry = ( f"### QUELLE {i}: {title}\n" f"TYP: [{note_type}] (Score: {hit.total_score:.2f})\n" @@ -118,38 +89,58 @@ def _build_enriched_context(hits: List[QueryHit]) -> str: async def _classify_intent(query: str, llm: LLMService) -> str: """ - WP-06: Intent Detection (Best Match / Longest Keyword Wins). - - Prüft Keywords aus der YAML gegen die Query. - Wenn mehrere Strategien passen, gewinnt die mit dem längsten Keyword (Spezifität). + Hybrid Router: + 1. Keyword Check (Best/Longest Match) -> FAST + 2. LLM Fallback (wenn in config aktiv) -> SMART """ config = get_full_config() strategies = config.get("strategies", {}) + settings = config.get("settings", {}) query_lower = query.lower() - - best_intent = "FACT" + best_intent = None max_match_length = 0 - # Iteriere über alle Strategien + # 1. FAST PATH: Keywords for intent_name, strategy in strategies.items(): - if intent_name == "FACT": - continue - + if intent_name == "FACT": continue keywords = strategy.get("trigger_keywords", []) - - # Prüfe jedes Keyword for k in keywords: - # Wenn Keyword im Text ist... if k.lower() in query_lower: - # ... prüfen wir, ob es spezifischer (länger) ist als der bisherige Favorit - current_len = len(k) - if current_len > max_match_length: - max_match_length = current_len + if len(k) > max_match_length: + max_match_length = len(k) best_intent = intent_name - # Wir brechen hier NICHT ab, sondern suchen weiter nach noch längeren Matches + + if best_intent: + logger.info(f"Intent detected via KEYWORD: {best_intent}") + return best_intent + + # 2. SLOW PATH: LLM Router + if settings.get("llm_fallback_enabled", False): + router_prompt_template = settings.get("llm_router_prompt", "") + if router_prompt_template: + prompt = router_prompt_template.replace("{query}", query) + logger.info("Keywords failed. Asking LLM for Intent...") - return best_intent + # Kurzer Raw Call + llm_decision = await llm.generate_raw_response(prompt) + + # Cleaning + llm_decision = llm_decision.strip().upper() + if ":" in llm_decision: + llm_decision = llm_decision.split(":")[-1].strip() + + # Validierung: Nur bekannte Intents zulassen + # Entferne Satzzeichen + llm_decision = ''.join(filter(str.isalnum, llm_decision)) + + if llm_decision in strategies: + logger.info(f"Intent detected via LLM: {llm_decision}") + return llm_decision + else: + logger.warning(f"LLM predicted unknown intent '{llm_decision}', falling back to FACT.") + + return "FACT" @router.post("/", response_model=ChatResponse) async def chat_endpoint( @@ -159,21 +150,20 @@ async def chat_endpoint( ): start_time = time.time() query_id = str(uuid.uuid4()) - logger.info(f"Chat request [{query_id}]: {request.message[:50]}...") try: - # 1. Intent Detection (Config-Driven & Best Match) + # 1. Intent Detection intent = await _classify_intent(request.message, llm) - logger.info(f"[{query_id}] Detected Intent: {intent}") + logger.info(f"[{query_id}] Final Intent: {intent}") - # Lade Strategie aus Config (Late Binding) + # 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 (Fakten) + # 2. Primary Retrieval query_req = QueryRequest( query=request.message, mode="hybrid", @@ -183,19 +173,19 @@ async def chat_endpoint( retrieve_result = await retriever.search(query_req) hits = retrieve_result.results - # 3. Strategic Retrieval (Konfigurierbar) + # 3. Strategic Retrieval 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}, # Dynamische Liste aus YAML + filters={"type": inject_types}, explain=False ) strategy_result = await retriever.search(strategy_req) - # Merge Results (Deduplication via node_id) + # Merge 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: @@ -207,20 +197,29 @@ async def chat_endpoint( else: context_str = _build_enriched_context(hits) - # 5. Generation Setup + # 5. Generation + # Wir laden das Template aus dem Service (da dort die prompts.yaml geladen ist) template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}") + system_prompt = llm.prompts.get("system_prompt", "") - # Injection der Instruktion (falls konfiguriert) if prepend_instr: context_str = f"{prepend_instr}\n\n{context_str}" + # Manuelles Bauen des finalen Prompts für volle Kontrolle + final_prompt = template.replace("{context_str}", context_str).replace("{query}", request.message) + + # Aufruf via Raw Response (da wir den Prompt schon fertig haben) + # Wir müssen den System-Prompt manuell mitgeben? + # generate_raw_response in llm_service unterstützt aktuell kein 'system'. + # -> Wir erweitern generate_raw_response oder nutzen einen Hack: System + Prompt. + + # SAUBERER WEG: Wir bauen den Payload für Ollama hier manuell zusammen und rufen eine generische Methode. + # Da LLMService.generate_raw_response keine System-Msg nimmt, packen wir sie davor. + full_text_prompt = f"{system_prompt}\n\n{final_prompt}" + 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 - ) + answer_text = await llm.generate_raw_response(full_text_prompt) - # 6. Response duration_ms = int((time.time() - start_time) * 1000) return ChatResponse( diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 3d99064..215bd8a 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -2,7 +2,7 @@ app/services/llm_service.py — LLM Client (Ollama) Version: - 0.1.2 (WP-05 Fix: Increased Timeout for CPU Inference) + 0.2.0 (WP-06 Hybrid Router Support) """ import httpx @@ -18,18 +18,19 @@ class LLMService: def __init__(self): self.settings = get_settings() self.prompts = self._load_prompts() - # FIX: Timeout auf 120 Sekunden erhöht für CPU-Only Server - self.client = httpx.AsyncClient(base_url=self.settings.OLLAMA_URL, timeout=120.0) + + # Timeout aus Config nutzen (Default 120s) + self.client = httpx.AsyncClient( + base_url=self.settings.OLLAMA_URL, + timeout=self.settings.LLM_TIMEOUT + ) def _load_prompts(self) -> dict: """Lädt Prompts aus der konfigurierten YAML-Datei.""" path = Path(self.settings.PROMPTS_PATH) if not path.exists(): logger.warning(f"Prompt config not found at {path}, using defaults.") - return { - "system_prompt": "You are a helpful AI assistant.", - "rag_template": "Context: {context_str}\nQuestion: {query}" - } + return {} try: with open(path, "r", encoding="utf-8") as f: @@ -38,15 +39,76 @@ class LLMService: logger.error(f"Failed to load prompts: {e}") return {} + async def generate_raw_response(self, prompt: str) -> str: + """ + Führt einen direkten LLM Call ohne RAG-Template aus. + Ideal für Classification/Routing. + """ + payload = { + "model": self.settings.LLM_MODEL, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.0, # Deterministisch für Routing! + "num_ctx": 512 # Kleines Fenster reicht für Classification + } + } + + try: + response = await self.client.post("/api/generate", json=payload) + if response.status_code != 200: + logger.error(f"Ollama Error ({response.status_code}): {response.text}") + return "FACT" # Fallback bei Fehler + + data = response.json() + return data.get("response", "").strip() + + except Exception as e: + logger.error(f"LLM Raw Gen Error: {e}") + return "FACT" + async def generate_rag_response(self, query: str, context_str: str) -> str: """ - Generiert eine Antwort basierend auf Query und Kontext. + Generiert eine Antwort basierend auf Query und Kontext (RAG). """ - system_prompt = self.prompts.get("system_prompt", "") - template = self.prompts.get("rag_template", "{context_str}\n\n{query}") + # Template hier ist nur Fallback, falls im Router nichts übergeben wird. + # Im Normalfall formatiert der Router den context_str bereits vor oder übergibt das Template. + # Hier nutzen wir simple substitution, da der Prompt meist schon vom Router aufbereitet ist + # oder wir nutzen das Standard-Template aus der YAML. - # Template füllen - final_prompt = template.format(context_str=context_str, query=query) + # HINWEIS: In der neuen Architektur (chat.py) wird das Template bereits VOR diesem Aufruf + # geladen und formatiert, und als 'prompt' übergeben? + # Nein, chat.py ruft generate_rag_response(query, context_str) auf. + # Wir müssen sicherstellen, dass wir das *richtige* Template nutzen. + # Da generate_rag_response aktuell KEINEN 'template_key' Parameter hat, + # gehen wir davon aus, dass 'context_str' bereits Instruktionen enthält ODER + # dass chat.py den Prompt komplett baut. + + # Um die API sauber zu halten: chat.py übergibt jetzt den FERTIGEN Prompt als context_str? + # Nein, chat.py baut den Prompt mit replace(). + # Wir ändern diese Methode leicht ab, um flexibler zu sein: + # Wir erwarten, dass der Aufrufer (chat.py) die volle Kontrolle hat. + + # Damit es 100% zusammenpasst mit dem chat.py unten: + # chat.py baut den finalen Prompt selbst zusammen! + # Wir nutzen daher generate_raw_response eigentlich auch für RAG, + # ODER wir passen generate_rag_response an, dass es "dumm" ist. + + # Legacy Support für generate_rag_response: + # Wir bauen den Prompt zusammen, falls noch nicht geschehen. + # ABER: chat.py in meiner Version unten macht das Template-Handling. + # Daher ist der sauberste Weg: chat.py ruft generate_raw_response auf für die Antwort! + + # FIX für Kompatibilität: Wir leiten rag_response intern auf raw um, + # bauen aber vorher den Prompt, falls context_str übergeben wird. + + # Da wir chat.py kontrollieren (siehe unten), ändern wir chat.py so, + # dass es generate_raw_response nutzt! Das ist viel sauberer. + # Diese Methode bleibt für Backward Compatibility. + + system_prompt = self.prompts.get("system_prompt", "") + rag_template = self.prompts.get("rag_template", "{context_str}\n\n{query}") + final_prompt = rag_template.format(context_str=context_str, query=query) payload = { "model": self.settings.LLM_MODEL, @@ -55,29 +117,17 @@ class LLMService: "stream": False, "options": { "temperature": 0.7, - # Kleinerer Context spart Rechenzeit, falls 4096 zu viel ist "num_ctx": 2048 } } - + try: response = await self.client.post("/api/generate", json=payload) - if response.status_code != 200: - error_msg = response.text - logger.error(f"Ollama API Error ({response.status_code}): {error_msg}") - return f"Fehler vom LLM (Modell '{self.settings.LLM_MODEL}' vorhanden?): {error_msg}" - - data = response.json() - return data.get("response", "") - - except httpx.ReadTimeout: - return "Timeout: Das Modell braucht zu lange zum Antworten (>120s). Hardware-Limit erreicht?" - except httpx.ConnectError: - return "Verbindungsfehler: Ist Ollama gestartet (Port 11434)?" + return f"Error: {response.text}" + return response.json().get("response", "") except Exception as e: - logger.error(f"LLM Service Exception: {e}") - return f"Interner Fehler: {str(e)}" + return f"Error: {str(e)}" async def close(self): await self.client.aclose() \ No newline at end of file diff --git a/config/decision_engine.yaml b/config/decision_engine.yaml index 09fce63..5c9bb7c 100644 --- a/config/decision_engine.yaml +++ b/config/decision_engine.yaml @@ -1,21 +1,83 @@ -version: 1.0 +# config/decision_engine.yaml +# Steuerung der Decision Engine (WP-06) +# Hybrid-Modus: Keywords (Fast) + LLM Router (Smart Fallback) +version: 1.1 + +settings: + # Schalter: Soll das LLM gefragt werden, wenn kein Keyword passt? + llm_fallback_enabled: true + + # Der Prompt für den "Semantic Router" (Slow Path) + llm_router_prompt: | + Analysiere die folgende Nachricht und entscheide, welche Strategie passt. + Antworte NUR mit dem Namen der Strategie (ein Wort). + + STRATEGIEN: + - DECISION: User fragt nach Rat, Meinung, Strategie, Vor/Nachteilen. + - EMPATHY: User äußert Gefühle, Frust, Freude oder persönliche Probleme. + - CODING: User fragt nach Code, Syntax oder Programmierung. + - FACT: User fragt nach Wissen, Definitionen oder Fakten (Default). + + NACHRICHT: "{query}" + + STRATEGIE: strategies: - # Strategie 1: Der Berater (Das haben wir gebaut) + # 1. Fakten-Abfrage (Fallback & Default) + FACT: + description: "Reine Wissensabfrage." + trigger_keywords: [] + inject_types: [] + prompt_template: "rag_template" + prepend_instruction: null + + # 2. Entscheidungs-Frage DECISION: - trigger_keywords: ["soll ich", "empfehlung", "strategie"] + description: "Der User sucht Rat, Strategie oder Abwägung." + trigger_keywords: + - "soll ich" + - "meinung" + - "besser" + - "empfehlung" + - "strategie" + - "entscheidung" + - "wert" + - "prinzip" + - "vor- und nachteile" + - "abwägung" inject_types: ["value", "principle", "goal"] - prompt_template: "decision_template" # Nutzt das "Abwägen"-Template + prompt_template: "decision_template" + prepend_instruction: | + !!! ENTSCHEIDUNGS-MODUS !!! + BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE, PRINZIPIEN UND ZIELE AB: - # Strategie 2: Der empathische Zuhörer (NEU - Konzept) + # 3. Empathie / "Ich"-Modus EMPATHY: - trigger_keywords: ["ich fühle", "traurig", "gestresst", "angst"] - inject_types: ["belief", "experience"] # Lädt Glaubenssätze & eigene Erfahrungen - prompt_template: "empathy_template" # Ein Template, das auf "Zuhören" getrimmt ist - prepend_instruction: "SEI EMPATHISCH. SPIEGEL DIE GEFÜHLE." + description: "Reaktion auf emotionale Zustände." + trigger_keywords: + - "ich fühle" + - "traurig" + - "glücklich" + - "gestresst" + - "angst" + - "nervt" + - "überfordert" + inject_types: ["experience", "belief", "profile"] + prompt_template: "empathy_template" + prepend_instruction: null - # Strategie 3: Der Coder (NEU - Konzept) + # 4. Coding / Technical CODING: - trigger_keywords: ["code", "python", "funktion", "bug"] - inject_types: ["snippet", "reference"] # Lädt nur technische Schnipsel - prompt_template: "technical_template" # Ein Template, das Codeblöcke erzwingt \ No newline at end of file + description: "Technische Anfragen und Programmierung." + trigger_keywords: + - "code" + - "python" + - "script" + - "funktion" + - "bug" + - "syntax" + - "json" + - "yaml" + inject_types: ["snippet", "reference", "source"] + prompt_template: "technical_template" + prepend_instruction: null \ No newline at end of file diff --git a/config/prompts.yaml b/config/prompts.yaml index cfe75b7..b36b12c 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,30 +1,20 @@ -# config/prompts.yaml — Final V2.3 (WP-06 Decision Engine) -# Optimiert für Phi-3 Mini (Small Language Model) +# config/prompts.yaml — Final V2.3.1 (Multi-Personality Support) system_prompt: | Du bist 'mindnet', mein digitaler Zwilling und strategischer Partner. DEINE IDENTITÄT: - - Du bist nicht nur eine Datenbank, sondern handelst nach MEINEN Werten. - - Du bist objektiv bei Fakten, aber subjektiv (in meinem Sinne) bei Entscheidungen. + - Du bist nicht nur eine Datenbank, sondern handelst nach MEINEN Werten und Zielen. + - Du passt deinen Stil dynamisch an die Situation an (Analytisch, Empathisch oder Technisch). DEINE REGELN: - 1. Deine Antwort muss auf dem bereitgestellten KONTEXT basieren. - 2. Unterscheide klar zwischen FAKTEN (externe Welt) und PRINZIPIEN (meine innere Welt). - 3. Wenn Quellen vom Typ [VALUE] oder [PRINCIPLE] vorliegen, haben diese Vorrang bei der Entscheidungsfindung. - 4. Antworte auf Deutsch. - -# Neuer Prompt für WP-06: Intent Detection -intent_prompt: | - Klassifiziere die folgende User-Anfrage. - Antworte NUR mit einem einzigen Wort: 'FACT' oder 'DECISION'. - - 'FACT': Der User fragt nach Wissen, Definitionen, Syntax oder Inhalten (z.B. "Was ist...", "Wie funktioniert...", "Zusammenfassung von..."). - 'DECISION': Der User fragt nach Rat, Meinung, Strategie oder Abwägung (z.B. "Soll ich...", "Was ist besser...", "Lohnt sich...", "Wie gehe ich vor..."). - - ANFRAGE: "{query}" - KLASSE: + 1. Deine Antwort muss zu 100% auf dem bereitgestellten KONTEXT basieren. + 2. Halluziniere keine Fakten, die nicht in den Quellen stehen. + 3. Antworte auf Deutsch (außer bei Code/Fachbegriffen). +# --------------------------------------------------------- +# 1. STANDARD: Fakten & Wissen (Intent: FACT) +# --------------------------------------------------------- rag_template: | QUELLEN (WISSEN): ========================================= @@ -35,12 +25,14 @@ rag_template: | {query} ANWEISUNG: - Beantworte die Frage basierend auf den Quellen. - Nenne die spezifischen Gründe, die im Text stehen (besonders aus [DECISION] Quellen). + Beantworte die Frage präzise basierend auf den Quellen. + Fasse die Informationen zusammen. Sei objektiv und neutral. -# Neues Template für WP-06: Reasoning & Decision Making +# --------------------------------------------------------- +# 2. DECISION: Strategie & Abwägung (Intent: DECISION) +# --------------------------------------------------------- decision_template: | - KONTEXT (FAKTEN & WERTE): + KONTEXT (FAKTEN & STRATEGIE): ========================================= {context_str} ========================================= @@ -51,11 +43,54 @@ decision_template: | ANWEISUNG: Du agierst als mein Entscheidungs-Partner. 1. Analysiere die Faktenlage aus den Quellen. - 2. Prüfe dies gegen meine [VALUE] und [PRINCIPLE] Quellen (falls vorhanden). + 2. Prüfe dies hart gegen meine strategischen Notizen (Typ [VALUE], [PRINCIPLE], [GOAL]). 3. Wäge ab: Passt die technische/faktische Lösung zu meinen Werten? - 4. Gib eine klare Empfehlung ab. - + FORMAT: - - **Analyse:** (Faktenlage) - - **Werte-Check:** (Konflikt oder Übereinstimmung mit Prinzipien) - - **Fazit:** (Deine Empfehlung) \ No newline at end of file + - **Analyse:** (Kurze Zusammenfassung der Fakten) + - **Abgleich:** (Gibt es Konflikte mit Werten/Zielen? Nenne die Quelle!) + - **Empfehlung:** (Klare Meinung: Ja/Nein/Vielleicht mit Begründung) + +# --------------------------------------------------------- +# 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY) +# --------------------------------------------------------- +empathy_template: | + KONTEXT (ERFAHRUNGEN & GLAUBENSSÄTZE): + ========================================= + {context_str} + ========================================= + + SITUATION: + {query} + + ANWEISUNG: + Du agierst jetzt als mein empathischer Spiegel. + 1. Versuche nicht sofort, das Problem technisch zu lösen. + 2. Zeige Verständnis für die Situation basierend auf meinen eigenen Erfahrungen ([EXPERIENCE]) oder Glaubenssätzen ([BELIEF]), falls im Kontext vorhanden. + 3. Antworte in der "Ich"-Form oder "Wir"-Form. Sei unterstützend. + + TONFALL: + Ruhig, verständnisvoll, reflektiert. Keine Aufzählungszeichen, sondern fließender Text. + +# --------------------------------------------------------- +# 4. TECHNICAL: Der Coder (Intent: CODING) +# --------------------------------------------------------- +technical_template: | + KONTEXT (DOCS & SNIPPETS): + ========================================= + {context_str} + ========================================= + + TASK: + {query} + + ANWEISUNG: + Du bist Senior Developer. + 1. Ignoriere Smalltalk. Komm sofort zum Punkt. + 2. Generiere validen, performanten Code basierend auf den Quellen. + 3. Wenn Quellen fehlen, nutze dein allgemeines Programmierwissen, aber weise darauf hin. + + FORMAT: + - Kurze Erklärung des Ansatzes. + - Markdown Code-Block (Copy-Paste fertig). + - Wichtige Edge-Cases. \ No newline at end of file