From a403d8baf65e5e191417f4fce295befe18ecbd4f Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 12 Dec 2025 13:53:54 +0100 Subject: [PATCH] neue Wartelogik, neuer Prompt --- app/services/llm_service.py | 102 +++++++++++++++++++++--------- app/services/semantic_analyzer.py | 30 +++++---- config/prompts.yaml | 26 ++++---- 3 files changed, 105 insertions(+), 53 deletions(-) diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 2431557..e145598 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,37 +1,40 @@ """ app/services/llm_service.py — LLM Client (Ollama) -Version: 0.3.0 (Fix: JSON Format Enforcement) +Version: 0.5.1 (Full: Retry Strategy + Chat Support + JSON Mode) """ import httpx import yaml import logging import os +import asyncio from pathlib import Path -# ANNAHME: app.config ist verfügbar -# from app.config import get_settings +from typing import Optional, Dict, Any logger = logging.getLogger(__name__) -# --- Mock get_settings für die Vollständigkeit --- class Settings: OLLAMA_URL = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") + # Timeout für den einzelnen Request (nicht für den gesamten Retry-Zyklus) LLM_TIMEOUT = float(os.getenv("MINDNET_LLM_TIMEOUT", 300.0)) LLM_MODEL = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") PROMPTS_PATH = os.getenv("MINDNET_PROMPTS_PATH", "./config/prompts.yaml") def get_settings(): return Settings() -# ----------------------------------------------- class LLMService: def __init__(self): self.settings = get_settings() self.prompts = self._load_prompts() + # Connection Limits erhöhen für Parallelität im Import + limits = httpx.Limits(max_keepalive_connections=5, max_connections=10) + self.client = httpx.AsyncClient( base_url=self.settings.OLLAMA_URL, - timeout=self.settings.LLM_TIMEOUT + timeout=self.settings.LLM_TIMEOUT, + limits=limits ) def _load_prompts(self) -> dict: @@ -45,53 +48,92 @@ class LLMService: logger.error(f"Failed to load prompts: {e}") return {} - async def generate_raw_response(self, prompt: str, system: str = None, force_json: bool = False) -> str: + async def generate_raw_response( + self, + prompt: str, + system: str = None, + force_json: bool = False, + max_retries: int = 0, # Standard: 0 (Chat failt sofort, Import nutzt >0) + base_delay: float = 5.0 # Start-Wartezeit für Backoff + ) -> str: """ Führt einen LLM Call aus. - force_json: NEUER OPTIONALER PARAMETER zur Erzwingung des Ollama JSON-Modus. + Features: + - JSON Mode (für Semantic Analyzer) + - System Prompt (für Persona) + - Aggressive Retry (für robusten Import bei Überlast) """ - payload = { + payload: Dict[str, Any] = { "model": self.settings.LLM_MODEL, "prompt": prompt, "stream": False, "options": { - "temperature": 0.7, - "num_ctx": 2048 + # JSON braucht niedrige Temperature für valide Syntax + "temperature": 0.1 if force_json else 0.7, + "num_ctx": 4096 } } - # NEU: Ollama Format Erzwingung (wichtig für Semantic Chunking) if force_json: payload["format"] = "json" - # WICHTIG: System-Prompt separat übergeben if system: payload["system"] = system - 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 "Fehler bei der Generierung." - - data = response.json() - return data.get("response", "").strip() - - except Exception as e: - logger.error(f"LLM Raw Gen Error: {e}") - return "Interner LLM Fehler." + attempt = 0 + + # RETRY LOOP + while True: + try: + response = await self.client.post("/api/generate", json=payload) + + if response.status_code == 200: + data = response.json() + return data.get("response", "").strip() + else: + # HTTP Fehler simulieren, um in den except-Block zu springen + response.raise_for_status() + + except Exception as e: + # CATCH-ALL: Wir fangen Timeouts, Connection Errors UND Protokollfehler + attempt += 1 + + # Check: Haben wir noch Versuche? + if attempt > max_retries: + # Finaler Fehler (wird im Chat oder Log angezeigt) + logger.error(f"LLM Final Error (Versuch {attempt}): {e}") + return "Interner LLM Fehler." + + # Backoff berechnen (5s, 10s, 20s, 40s...) + wait_time = base_delay * (2 ** (attempt - 1)) + error_msg = str(e) if str(e) else repr(e) + + logger.warning( + f"⚠️ LLM Fehler ({attempt}/{max_retries}). " + f"Warte {wait_time}s zur Abkühlung... Grund: {error_msg}" + ) + + # Warten und Loop wiederholen + await asyncio.sleep(wait_time) async def generate_rag_response(self, query: str, context_str: str) -> str: """ - Legacy Support: Wird vom Chat und Intent Router genutzt. - Ruft generate_raw_response OHNE force_json auf. + WICHTIG FÜR CHAT: + Generiert eine Antwort basierend auf RAG-Kontext. + Nutzt KEINE Retries (User will nicht warten), KEIN JSON. """ 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) - # Aufruf bleibt im Standard-Modus (force_json=False Default) - return await self.generate_raw_response(final_prompt, system=system_prompt) + # Chat-Call: force_json=False, max_retries=0 + return await self.generate_raw_response( + final_prompt, + system=system_prompt, + max_retries=0 + ) async def close(self): - await self.client.aclose() \ No newline at end of file + if self.client: + await self.client.aclose() \ No newline at end of file diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index e6e5476..cefe15e 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -1,11 +1,11 @@ """ app/services/semantic_analyzer.py — Edge Validation & Filtering -Version: 1.2 (Extended Observability & Debugging) +Version: 1.4 (Merged: Retry Strategy + Extended Observability) """ import json import logging -from typing import List, Optional, Any +from typing import List, Optional from dataclasses import dataclass # Importe @@ -21,7 +21,10 @@ class SemanticAnalyzer: """ Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM. Das LLM filtert heraus, welche Kanten für diesen Chunk relevant sind. - Enthält erweitertes Logging für Debugging. + + Features: + - Retry Strategy: Wartet bei Überlastung (max_retries=5). + - Observability: Loggt Input-Größe, Raw-Response und Parsing-Details. """ if not all_edges: return [] @@ -30,7 +33,7 @@ class SemanticAnalyzer: prompt_template = self.llm.prompts.get("edge_allocation_template") if not prompt_template: - logger.warning("⚠️ Prompt 'edge_allocation_template' fehlt. Nutze Fallback-Prompt.") + logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' fehlt. Nutze Fallback.") prompt_template = ( "TASK: Wähle aus den Kandidaten die relevanten Kanten für den Text.\n" "TEXT: {chunk_text}\n" @@ -41,23 +44,27 @@ class SemanticAnalyzer: # 2. Kandidaten-Liste formatieren edges_str = "\n".join([f"- {e}" for e in all_edges]) - # LOG: Request Info + # LOG: Request Info (Wichtig für Debugging) logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.") # 3. Prompt füllen final_prompt = prompt_template.format( - chunk_text=chunk_text[:3000], + chunk_text=chunk_text[:3500], # Etwas mehr Kontext als früher (3000 -> 3500) edge_list=edges_str ) try: - # 4. LLM Call mit JSON Erzwingung + # 4. LLM Call mit JSON Erzwingung UND Retry-Logik (Merged V1.3) + # max_retries=5 bedeutet: 5s -> 10s -> 20s -> 40s -> 80s Pause. response_json = await self.llm.generate_raw_response( prompt=final_prompt, - force_json=True + force_json=True, + max_retries=5, + base_delay=5.0 ) - # LOG: Raw Response (nur die ersten 200 Zeichen, um Log nicht zu fluten, außer bei Fehler) + # LOG: Raw Response Preview (Wichtig um zu sehen, was das LLM liefert) + # Zeigt nur die ersten 200 Zeichen, um Log nicht zu fluten logger.debug(f"📥 [SemanticAnalyzer] Raw Response (Preview): {response_json[:200]}...") # 5. Parsing & Cleaning @@ -73,7 +80,7 @@ class SemanticAnalyzer: # LOG: Detaillierter Fehlerbericht für den User logger.error(f"❌ [SemanticAnalyzer] JSON Decode Error.") logger.error(f" Grund: {json_err}") - logger.error(f" Empfangener String: {clean_json}") + logger.error(f" Empfangener String: {clean_json[:500]}") # Zeige max 500 chars des Fehlers logger.info(" -> Workaround: Fallback auf 'Alle Kanten' (durch Chunker).") return [] @@ -85,7 +92,7 @@ class SemanticAnalyzer: valid_edges = [str(e) for e in data if isinstance(e, str) and ":" in e] elif isinstance(data, dict): - # Abweichende Formate behandeln + # Abweichende Formate behandeln (Extended Logging V1.2) logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur. Keys: {list(data.keys())}") for key, val in data.items(): @@ -108,6 +115,7 @@ class SemanticAnalyzer: # LOG: Ergebnis if final_result: + # Nur Info, wenn wirklich was gefunden wurde, sonst spammt es bei leeren Chunks logger.info(f"✅ [SemanticAnalyzer] Success. {len(final_result)} Kanten zugewiesen.") else: logger.debug(" [SemanticAnalyzer] Keine spezifischen Kanten erkannt (Empty Result).") diff --git a/config/prompts.yaml b/config/prompts.yaml index 3605d3e..9e55e1b 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -143,25 +143,27 @@ interview_template: | # --------------------------------------------------------- # 6. EDGE_ALLOCATION: Kantenfilter (Intent: OFFLINE_FILTER) # --------------------------------------------------------- -edge_allocation_template: | -edge_allocation_template: | +eedge_allocation_template: | TASK: - Du bist ein semantischer Filter für einen Knowledge Graph. - Ordne die unten stehenden "Kandidaten-Kanten" dem vorliegenden Textabschnitt zu. + Du bist ein JSON-Filter. Deine Aufgabe ist es, aus einer Liste von "Kandidaten" nur jene Strings auszuwählen, die inhaltlich zum "Textabschnitt" passen. TEXTABSCHNITT: """ {chunk_text} """ - KANDIDATEN-KANTEN (Gefunden im gesamten Dokument): + KANDIDATEN (Liste): {edge_list} - ANWEISUNG: - 1. Welche der Kandidaten-Kanten sind für das Verständnis DIESES spezifischen Textabschnitts relevant? - 2. Gib NUR die relevanten Kanten als JSON-Liste von Strings zurück. - 3. Verändere den Wortlaut der Kanten nicht. - 4. Wenn keine Kante passt, gib eine leere Liste [] zurück. + REGELN: + 1. Wähle nur Kanten, die für den Textabschnitt relevant sind. + 2. Gib das Ergebnis als flache JSON-Liste zurück. + 3. Verändere die Strings nicht. + 4. KEINE Objekte, KEINE Keys wie "edges" oder "kanten". Nur die Liste. - OUTPUT FORMAT (JSON): - ["kind:Target", "kind:Target"] \ No newline at end of file + BEISPIEL: + Input Kandidaten: ["uses:ToolA", "references:DocB", "related_to:ThemaC"] + Text erwähnt ToolA aber nicht DocB. + Output: ["uses:ToolA"] + + DEIN OUTPUT (JSON): \ No newline at end of file