From 0157faab89188d41754961879993ac87a0b620a4 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 18:51:12 +0100 Subject: [PATCH] komplett neues WP20 deployment --- app/config.py | 32 +++++++---- app/core/ingestion.py | 85 +++++++++++++++-------------- app/services/edge_registry.py | 89 +++++++++++++++---------------- app/services/llm_service.py | 30 ++++++++--- app/services/semantic_analyzer.py | 17 +++--- config/decision_engine.yaml | 2 +- config/prompts.yaml | 85 ++++++++++++++++++++--------- 7 files changed, 203 insertions(+), 137 deletions(-) diff --git a/app/config.py b/app/config.py index df178ab..a1c9ed2 100644 --- a/app/config.py +++ b/app/config.py @@ -2,8 +2,11 @@ FILE: app/config.py DESCRIPTION: Zentrale Pydantic-Konfiguration. Enthält Parameter für Qdrant, Embeddings, Ollama, Google GenAI und OpenRouter. -VERSION: 0.6.0 (WP-20 Hybrid & OpenRouter Integration) + WP-20: Optimiert für Hybrid-Cloud Modus und Vektor-Synchronisation. + WP-22: Integration von Change-Detection und Vocab-Paths. +VERSION: 0.6.2 STATUS: Active +DEPENDENCIES: os, functools, pathlib """ from __future__ import annotations import os @@ -15,22 +18,27 @@ class Settings: QDRANT_URL: str = os.getenv("QDRANT_URL", "http://127.0.0.1:6333") QDRANT_API_KEY: str | None = os.getenv("QDRANT_API_KEY") COLLECTION_PREFIX: str = os.getenv("MINDNET_PREFIX", "mindnet") - VECTOR_SIZE: int = int(os.getenv("MINDNET_VECTOR_SIZE", "384")) + # WP-22: Vektor-Dimension muss mit dem Embedding-Modell (nomic) übereinstimmen + VECTOR_SIZE: int = int(os.getenv("VECTOR_DIM", "768")) DISTANCE: str = os.getenv("MINDNET_DISTANCE", "Cosine") - # --- Lokale Embeddings --- + # --- Lokale Embeddings (Ollama & Sentence-Transformers) --- + # Modell für die Vektorisierung (z.B. nomic-embed-text) + EMBEDDING_MODEL: str = os.getenv("MINDNET_EMBEDDING_MODEL", "nomic-embed-text") + # Legacy Fallback Modellname MODEL_NAME: str = os.getenv("MINDNET_MODEL", "sentence-transformers/all-MiniLM-L6-v2") # --- WP-20 Hybrid LLM Provider --- - # Optionen: "ollama" | "gemini" | "openrouter" + # Erlaubt: "ollama" | "gemini" | "openrouter" MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "ollama").lower() - # Google AI Studio + # Google AI Studio (Direkt) GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY") GEMINI_MODEL: str = os.getenv("MINDNET_GEMINI_MODEL", "gemini-1.5-flash") - GEMMA_MODEL: str = os.getenv("MINDNET_GEMMA_MODEL", "gemma2-9b-it") + # Gemma-Modell für hohen Durchsatz bei der Ingestion + GEMMA_MODEL: str = os.getenv("MINDNET_GEMMA_MODEL", "google/gemma-2-9b-it:free") - # OpenRouter + # OpenRouter Integration OPENROUTER_API_KEY: str | None = os.getenv("OPENROUTER_API_KEY") OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "google/gemma-2-9b-it:free") @@ -42,16 +50,20 @@ class Settings: PROMPTS_PATH: str = os.getenv("MINDNET_PROMPTS_PATH", "config/prompts.yaml") # --- WP-06 / WP-14 Performance & Last-Steuerung --- - LLM_TIMEOUT: float = float(os.getenv("MINDNET_LLM_TIMEOUT", "120.0")) + LLM_TIMEOUT: float = float(os.getenv("MINDNET_LLM_TIMEOUT", "300.0")) DECISION_CONFIG_PATH: str = os.getenv("MINDNET_DECISION_CONFIG", "config/decision_engine.yaml") BACKGROUND_LIMIT: int = int(os.getenv("MINDNET_LLM_BACKGROUND_LIMIT", "2")) - # --- System-Pfade --- + # --- System-Pfade & Ingestion-Logik --- DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault") MINDNET_TYPES_FILE: str = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") + # WP-22: Dictionary für die Edge-Validierung + MINDNET_VOCAB_PATH: str = os.getenv("MINDNET_VOCAB_PATH", "vault/_system/dictionary/edge_vocabulary.md") + # WP-22: Change Detection (Modus: "full" oder "body") + CHANGE_DETECTION_MODE: str = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") - # --- WP-04 Retriever Gewichte --- + # --- WP-04 Retriever Gewichte (Semantik vs. Graph) --- RETRIEVER_W_SEM: float = float(os.getenv("MINDNET_WP04_W_SEM", "0.70")) RETRIEVER_W_EDGE: float = float(os.getenv("MINDNET_WP04_W_EDGE", "0.25")) RETRIEVER_W_CENT: float = float(os.getenv("MINDNET_WP04_W_CENT", "0.05")) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index a2dbaf5..36904e3 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -5,7 +5,7 @@ DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen (Notes WP-22: Integration von Content Lifecycle (Status Gate) und Edge Registry Validation. WP-22: Kontextsensitive Kanten-Validierung mit Fundort-Reporting (Zeilennummern). WP-22: Multi-Hash Refresh für konsistente Change Detection. -VERSION: 2.11.3 (WP-20 Quota Protection & Stability Patch) +VERSION: 2.11.4 STATUS: Active DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.services.llm_service, app.services.edge_registry EXTERNAL_CONFIG: config/types.yaml, config/prompts.yaml @@ -22,7 +22,7 @@ from app.core.parser import ( read_markdown, normalize_frontmatter, validate_required_frontmatter, - extract_edges_with_context, # WP-22: Neue Funktion für Zeilennummern + extract_edges_with_context, ) from app.core.note_payload import make_note_payload from app.core.chunker import assemble_chunks, get_chunk_config @@ -44,7 +44,7 @@ from app.core.qdrant_points import ( from app.services.embeddings_client import EmbeddingsClient from app.services.edge_registry import registry as edge_registry -from app.services.llm_service import LLMService # WP-20 Integration +from app.services.llm_service import LLMService logger = logging.getLogger(__name__) @@ -52,7 +52,9 @@ logger = logging.getLogger(__name__) def load_type_registry(custom_path: Optional[str] = None) -> dict: """Lädt die types.yaml zur Steuerung der typ-spezifischen Ingestion.""" import yaml - path = custom_path or os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") + from app.config import get_settings + settings = get_settings() + path = custom_path or settings.MINDNET_TYPES_FILE if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} @@ -65,35 +67,23 @@ def resolve_note_type(requested: Optional[str], reg: dict) -> str: return "concept" def effective_chunk_profile_name(fm: dict, note_type: str, reg: dict) -> str: - """ - Ermittelt den Namen des zu nutzenden Chunk-Profils. - Priorität: 1. Frontmatter Override -> 2. Type Config -> 3. Global Default - """ + """Ermittelt den Namen des zu nutzenden Chunk-Profils.""" override = fm.get("chunking_profile") or fm.get("chunk_profile") - if override and isinstance(override, str): - return override - + if override and isinstance(override, str): return override t_cfg = reg.get("types", {}).get(note_type, {}) if t_cfg: cp = t_cfg.get("chunking_profile") or t_cfg.get("chunk_profile") if cp: return cp - return reg.get("defaults", {}).get("chunking_profile", "sliding_standard") def effective_retriever_weight(fm: dict, note_type: str, reg: dict) -> float: - """ - Ermittelt das effektive retriever_weight für das Scoring. - Priorität: 1. Frontmatter Override -> 2. Type Config -> 3. Global Default - """ + """Ermittelt das effektive retriever_weight für das Scoring.""" override = fm.get("retriever_weight") if override is not None: try: return float(override) except: pass - t_cfg = reg.get("types", {}).get(note_type, {}) - if t_cfg and "retriever_weight" in t_cfg: - return float(t_cfg["retriever_weight"]) - + if t_cfg and "retriever_weight" in t_cfg: return float(t_cfg["retriever_weight"]) return float(reg.get("defaults", {}).get("retriever_weight", 1.0)) @@ -106,13 +96,13 @@ class IngestionService: self.cfg = QdrantConfig.from_env() self.cfg.prefix = self.prefix self.client = get_client(self.cfg) - self.dim = self.cfg.dim if hasattr(self.cfg, 'dim') else self.settings.VECTOR_SIZE + self.dim = self.settings.VECTOR_SIZE # Synchronisiert mit Settings v0.6.2 self.registry = load_type_registry() self.embedder = EmbeddingsClient() - self.llm = LLMService() # WP-20 + self.llm = LLMService() - # Change Detection Modus (full oder body) - self.active_hash_mode = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") + # WP-22: Change Detection Modus aus Settings + self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE try: ensure_collections(self.client, self.prefix, self.dim) @@ -133,20 +123,20 @@ class IngestionService: async def _perform_smart_edge_allocation(self, text: str, note_id: str) -> List[Dict]: """ WP-20: Nutzt den Hybrid LLM Service für die semantische Kanten-Extraktion. - QUOTEN-SCHUTZ: Bevorzugt OpenRouter (Gemma), um Gemini-Tageslimits zu schützen. + QUOTEN-SCHUTZ: Bevorzugt OpenRouter (Gemma 2), um Gemini-Tageslimits zu schonen. """ - # Bestimme Provider: Nutze OpenRouter falls Key vorhanden, sonst Global Default - provider = "openrouter" if getattr(self.settings, "OPENROUTER_API_KEY", None) else self.settings.MINDNET_LLM_PROVIDER + # Bestimme Provider: Nutze OpenRouter falls Key vorhanden + provider = "openrouter" if self.settings.OPENROUTER_API_KEY else self.settings.MINDNET_LLM_PROVIDER + model = self.settings.GEMMA_MODEL # Hochdurchsatz-Modell aus config.py - # Nutze Gemma-Modell für hohen Durchsatz via OpenRouter/Google - model = getattr(self.settings, "GEMMA_MODEL", None) + logger.info(f"🚀 [Ingestion] Turbo-Mode: Extracting edges for '{note_id}' using {model} on {provider}") - # Hole das optimierte Prompt-Template (Key: edge_extraction) + # Hole das optimierte Prompt-Template (Kaskade: Provider -> gemini -> ollama) template = self.llm.get_prompt("edge_extraction", provider) prompt = template.format(text=text[:6000], note_id=note_id) try: - # Hintergrund-Task mit Semaphore via LLMService + # Hintergrund-Task mit Semaphore via LLMService (WP-06) response_json = await self.llm.generate_raw_response( prompt=prompt, priority="background", @@ -156,13 +146,12 @@ class IngestionService: ) data = json.loads(response_json) - # Metadaten für die Edge-Herkunft setzen for item in data: item["provenance"] = "semantic_ai" item["line"] = f"ai-{provider}" return data except Exception as e: - logger.warning(f"Smart Edge Allocation failed for {note_id}: {e}") + logger.warning(f"⚠️ [Ingestion] Smart Edge Allocation failed for {note_id} on {provider}: {e}") return [] async def process_file( @@ -214,7 +203,7 @@ class IngestionService: except Exception as e: return {**result, "error": f"Payload build failed: {str(e)}"} - # 4. Change Detection (Multi-Hash) + # 4. Change Detection (WP-22 Multi-Hash) old_payload = None if not force_replace: old_payload = self._fetch_note_payload(note_id) @@ -241,7 +230,7 @@ class IngestionService: try: body_text = getattr(parsed, "body", "") or "" - # STABILITY PATCH: Prüfen, ob ensure_latest existiert (verhindert AttributeError) + # WP-22 STABILITY PATCH: Prüfen, ob ensure_latest existiert if hasattr(edge_registry, "ensure_latest"): edge_registry.ensure_latest() @@ -264,13 +253,13 @@ class IngestionService: e["kind"] = edge_registry.resolve(edge_type=e["kind"], provenance="explicit", context={**context, "line": e.get("line")}) edges.append(e) - # B. WP-20: Smart AI Edges (Hybrid Acceleration) + # B. WP-20: Smart AI Edges (Hybrid Turbo Acceleration) ai_edges = await self._perform_smart_edge_allocation(body_text, note_id) for e in ai_edges: e["kind"] = edge_registry.resolve(edge_type=e.get("kind"), provenance="semantic_ai", context={**context, "line": e.get("line")}) edges.append(e) - # C. System-Kanten + # C. System-Kanten (Struktur) try: raw_system_edges = build_edges_for_note(note_id, chunk_pls, note_level_references=note_pl.get("references", []), include_note_scope_refs=note_scope_refs) except TypeError: @@ -283,7 +272,7 @@ class IngestionService: edges.append(e) except Exception as e: - logger.error(f"Processing failed: {e}", exc_info=True) + logger.error(f"Processing failed for {file_path}: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} # 6. Upsert @@ -303,7 +292,7 @@ class IngestionService: return {"path": file_path, "status": "success", "changed": True, "note_id": note_id, "chunks_count": len(chunk_pls), "edges_count": len(edges)} except Exception as e: - logger.error(f"Upsert failed: {e}", exc_info=True) + logger.error(f"Upsert failed for {note_id}: {e}", exc_info=True) return {**result, "error": f"DB Upsert failed: {e}"} def _fetch_note_payload(self, note_id: str) -> Optional[dict]: @@ -330,4 +319,20 @@ class IngestionService: selector = rest.FilterSelector(filter=f) for suffix in ["chunks", "edges"]: try: self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=selector) - except Exception: pass \ No newline at end of file + except Exception: pass + + async def create_from_text(self, markdown_content: str, filename: str, vault_root: str, folder: str = "00_Inbox") -> Dict[str, Any]: + """Hilfsmethode zur Erstellung einer Note aus einem Textstream.""" + target_dir = os.path.join(vault_root, folder) + os.makedirs(target_dir, exist_ok=True) + file_path = os.path.join(target_dir, filename) + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(markdown_content) + f.flush() + os.fsync(f.fileno()) + await asyncio.sleep(0.1) + logger.info(f"Written file to {file_path}") + except Exception as e: + return {"status": "error", "error": f"Disk write failed: {str(e)}"} + return await self.process_file(file_path=file_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True) \ No newline at end of file diff --git a/app/services/edge_registry.py b/app/services/edge_registry.py index 7c7220e..95be97b 100644 --- a/app/services/edge_registry.py +++ b/app/services/edge_registry.py @@ -2,7 +2,10 @@ FILE: app/services/edge_registry.py DESCRIPTION: Single Source of Truth für Kanten-Typen mit dynamischem Reload. WP-22: Fix für absolute Pfade außerhalb des Vaults (Prod-Dictionary). -VERSION: 0.7.4 (Fix: Absolute Path Escaping & Quote Stripping) + WP-20: Synchronisation mit zentralen Settings (v0.6.2). +VERSION: 0.7.5 +STATUS: Active +DEPENDENCIES: re, os, json, logging, time, app.config """ import re import os @@ -25,69 +28,49 @@ class EdgeRegistry: cls._instance.initialized = False return cls._instance - def __init__(self, vault_root: Optional[str] = None): + def __init__(self): if self.initialized: return settings = get_settings() - # 1. ENV-Werte laden und von Anführungszeichen bereinigen - env_vocab_path = os.getenv("MINDNET_VOCAB_PATH") - if env_vocab_path: - env_vocab_path = env_vocab_path.strip('"').strip("'") - - env_vault_root = os.getenv("MINDNET_VAULT_ROOT") or getattr(settings, "MINDNET_VAULT_ROOT", "./vault") - if env_vault_root: - env_vault_root = env_vault_root.strip('"').strip("'") + # 1. Pfad aus den zentralen Settings laden (WP-20 Synchronisation) + # Priorisiert den Pfad aus der .env / config.py (v0.6.2) + self.full_vocab_path = os.path.abspath(settings.MINDNET_VOCAB_PATH) - # 2. Pfad-Priorität: Wenn absolut (/), dann direkt nutzen! - if env_vocab_path: - if env_vocab_path.startswith("/"): - # Absoluter Pfad (z.B. aus Produktion) - self.full_vocab_path = env_vocab_path - else: - # Relativer Pfad zum aktuellen Verzeichnis oder Vault - self.full_vocab_path = os.path.abspath(env_vocab_path) - else: - # Fallback: Suche im Vault-Verzeichnis - possible_paths = [ - os.path.join(env_vault_root, "_system", "dictionary", "edge_vocabulary.md"), - os.path.join(env_vault_root, "01_User_Manual", "01_edge_vocabulary.md") - ] - self.full_vocab_path = None - for p in possible_paths: - if os.path.exists(p): - self.full_vocab_path = os.path.abspath(p) - break - - if not self.full_vocab_path: - self.full_vocab_path = os.path.abspath(possible_paths[0]) - self.unknown_log_path = "data/logs/unknown_edges.jsonl" self.canonical_map: Dict[str, str] = {} self.valid_types: Set[str] = set() self._last_mtime = 0.0 - print(f"\n>>> [EDGE-REGISTRY] Initializing with Path: {self.full_vocab_path}", flush=True) + # Initialer Ladevorgang + logger.info(f">>> [EDGE-REGISTRY] Initializing with Path: {self.full_vocab_path}") self.ensure_latest() self.initialized = True def ensure_latest(self): - """Prüft den Zeitstempel und lädt bei Bedarf neu.""" + """ + Prüft den Zeitstempel der Vokabular-Datei und lädt bei Bedarf neu. + Verhindert den AttributeError in der Ingestion-Pipeline. + """ if not os.path.exists(self.full_vocab_path): - print(f"!!! [EDGE-REGISTRY ERROR] File not found: {self.full_vocab_path} !!!", flush=True) + logger.error(f"!!! [EDGE-REGISTRY ERROR] File not found: {self.full_vocab_path} !!!") return - current_mtime = os.path.getmtime(self.full_vocab_path) - if current_mtime > self._last_mtime: - self._load_vocabulary() - self._last_mtime = current_mtime + try: + current_mtime = os.path.getmtime(self.full_vocab_path) + if current_mtime > self._last_mtime: + self._load_vocabulary() + self._last_mtime = current_mtime + except Exception as e: + logger.error(f"!!! [EDGE-REGISTRY] Error checking file time: {e}") def _load_vocabulary(self): - """Parst das Wörterbuch.""" + """Parst das Markdown-Wörterbuch und baut die Canonical-Map auf.""" self.canonical_map.clear() self.valid_types.clear() + # Regex für Tabellen-Struktur: | **Typ** | Aliase | pattern = re.compile(r"\|\s*\*\*`?([a-zA-Z0-9_-]+)`?\*\*\s*\|\s*([^|]+)\|") try: @@ -106,38 +89,48 @@ class EdgeRegistry: if aliases_str and "Kein Alias" not in aliases_str: aliases = [a.strip() for a in aliases_str.split(",") if a.strip()] for alias in aliases: + # Normalisierung: Kleinschreibung, Underscores statt Leerzeichen clean_alias = alias.replace("`", "").lower().strip().replace(" ", "_") self.canonical_map[clean_alias] = canonical c_aliases += 1 - print(f"=== [EDGE-REGISTRY SUCCESS] Loaded {c_types} Canonical Types and {c_aliases} Aliases ===", flush=True) + logger.info(f"=== [EDGE-REGISTRY SUCCESS] Loaded {c_types} Canonical Types and {c_aliases} Aliases ===") except Exception as e: - print(f"!!! [EDGE-REGISTRY FATAL] Error reading file: {e} !!!", flush=True) + logger.error(f"!!! [EDGE-REGISTRY FATAL] Error reading file: {e} !!!") def resolve(self, edge_type: str, provenance: str = "explicit", context: dict = None) -> str: - """Validierung mit Fundort-Logging.""" + """ + Validiert einen Kanten-Typ gegen das Vokabular. + Loggt unbekannte Typen für die spätere manuelle Pflege. + """ self.ensure_latest() - if not edge_type: return "related_to" + if not edge_type: + return "related_to" + # Normalisierung des Typs clean_type = edge_type.lower().strip().replace(" ", "_").replace("-", "_") ctx = context or {} + # System-Kanten dürfen nicht manuell vergeben werden if provenance == "explicit" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: self._log_issue(clean_type, "forbidden_system_usage", ctx) return "related_to" + # System-Kanten sind nur bei struktureller Provenienz erlaubt if provenance == "structure" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: return clean_type + # Mapping auf kanonischen Namen if clean_type in self.canonical_map: return self.canonical_map[clean_type] + # Fallback und Logging self._log_issue(clean_type, "unknown_type", ctx) return clean_type def _log_issue(self, edge_type: str, error_kind: str, ctx: dict): - """Detailliertes JSONL-Logging für Debugging.""" + """Detailliertes JSONL-Logging für die Vokabular-Optimierung.""" try: os.makedirs(os.path.dirname(self.unknown_log_path), exist_ok=True) entry = { @@ -150,6 +143,8 @@ class EdgeRegistry: } with open(self.unknown_log_path, "a", encoding="utf-8") as f: f.write(json.dumps(entry) + "\n") - except Exception: pass + except Exception: + pass +# Singleton Export registry = EdgeRegistry() \ No newline at end of file diff --git a/app/services/llm_service.py b/app/services/llm_service.py index ecb30c4..02843c2 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -3,7 +3,8 @@ FILE: app/services/llm_service.py DESCRIPTION: Hybrid-Client für Ollama, Google GenAI (Gemini) und OpenRouter. Verwaltet provider-spezifische Prompts und Background-Last. WP-20: Optimiertes Fallback-Management zum Schutz von Cloud-Quoten. -VERSION: 3.3.1 + WP-20 Fix: Bulletproof Prompt-Auflösung für format() Aufrufe. +VERSION: 3.3.2 STATUS: Active """ import httpx @@ -58,11 +59,14 @@ class LLMService: def _load_prompts(self) -> dict: """Lädt die Prompt-Konfiguration aus der YAML-Datei.""" path = Path(self.settings.PROMPTS_PATH) - if not path.exists(): return {} + if not path.exists(): + logger.error(f"❌ Prompts file not found at {path}") + return {} try: - with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} except Exception as e: - logger.error(f"Failed to load prompts: {e}") + logger.error(f"❌ Failed to load prompts: {e}") return {} def get_prompt(self, key: str, provider: str = None) -> str: @@ -70,12 +74,22 @@ class LLMService: Hole provider-spezifisches Template mit intelligenter Text-Kaskade. HINWEIS: Dies ist nur ein Text-Lookup und verbraucht kein API-Kontingent. Kaskade: Gewählter Provider -> Gemini (Cloud-Stil) -> Ollama (Basis-Stil). + + WP-20 Fix: Garantiert die Rückgabe eines Strings, um AttributeError zu vermeiden. """ active_provider = provider or self.settings.MINDNET_LLM_PROVIDER data = self.prompts.get(key, "") + if isinstance(data, dict): # Wir versuchen erst den Provider, dann Gemini (weil ähnlich leistungsfähig), dann Ollama - return data.get(active_provider, data.get("gemini", data.get("ollama", ""))) + val = data.get(active_provider, data.get("gemini", data.get("ollama", ""))) + + # Falls val durch YAML-Fehler immer noch ein Dict ist, extrahiere ersten String + if isinstance(val, dict): + logger.warning(f"⚠️ [LLMService] Nested dictionary detected for key '{key}'. Using first entry.") + val = next(iter(val.values()), "") if val else "" + return str(val) + return str(data) async def generate_raw_response( @@ -116,6 +130,7 @@ class LLMService: async def _execute_google(self, prompt, system, force_json, model_override): """Native Google SDK Integration (Gemini).""" + # Nutzt GEMINI_MODEL aus config.py falls kein override (z.B. für Ingestion) übergeben wurde model = model_override or self.settings.GEMINI_MODEL config = types.GenerateContentConfig( system_instruction=system, @@ -130,7 +145,8 @@ class LLMService: async def _execute_openrouter(self, prompt, system, force_json, model_override): """OpenRouter API Integration (OpenAI-kompatibel).""" - model = model_override or getattr(self.settings, "OPENROUTER_MODEL", "google/gemma-2-9b-it:free") + # Nutzt OPENROUTER_MODEL aus config.py (v0.6.2) + model = model_override or self.settings.OPENROUTER_MODEL messages = [] if system: messages.append({"role": "system", "content": system}) @@ -166,7 +182,7 @@ class LLMService: except Exception as e: attempt += 1 if attempt > max_retries: - logger.error(f"Ollama Error after {attempt} retries: {e}") + logger.error(f"❌ Ollama Error after {attempt} retries: {e}") raise e wait_time = base_delay * (2 ** (attempt - 1)) logger.warning(f"⚠️ Ollama attempt {attempt} failed. Retrying in {wait_time}s...") diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index 97ae843..599d343 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -1,8 +1,9 @@ """ FILE: app/services/semantic_analyzer.py DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen. - WP-20 Fix: Kompatibilität mit Provider-basierten Prompt-Dictionaries (Hybrid-Modus). -VERSION: 2.2.0 + WP-20 Fix: Volle Kompatibilität mit der gehärteten LLMService (v3.3.2) Kaskade. + WP-20: Unterstützung für Provider-spezifische Routing-Logik beim Import. +VERSION: 2.2.1 STATUS: Active DEPENDENCIES: app.services.llm_service, json, logging LAST_ANALYSIS: 2025-12-23 @@ -53,16 +54,17 @@ 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. - WP-20 Fix: Nutzt get_prompt(), um den 'AttributeError: dict object' zu vermeiden. + WP-20 Fix: Nutzt get_prompt(), um den 'AttributeError: dict object' sicher zu vermeiden. """ if not all_edges: return [] - # 1. Prompt laden via get_prompt (handelt die Provider-Kaskade automatisch ab) [WP-20 Fix] + # 1. Prompt laden via get_prompt (handelt die Provider-Kaskade automatisch ab) prompt_template = self.llm.get_prompt("edge_allocation_template") + # Sicherheits-Check für die Format-Methode if not prompt_template or isinstance(prompt_template, dict): - logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' konnte nicht als String geladen werden. Nutze Hard-Fallback.") + logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' konnte nicht als String geladen werden. Nutze Not-Fallback.") prompt_template = ( "TASK: Wähle aus den Kandidaten die relevanten Kanten für den Text.\n" "TEXT: {chunk_text}\n" @@ -76,7 +78,7 @@ class SemanticAnalyzer: # LOG: Request Info logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.") - # 3. Prompt füllen (Hier trat der AttributeError auf, wenn prompt_template ein dict war) + # 3. Prompt füllen (Nutzt nun sicher einen String dank LLMService v3.3.2) try: final_prompt = prompt_template.format( chunk_text=chunk_text[:3500], @@ -88,6 +90,7 @@ class SemanticAnalyzer: try: # 4. LLM Call mit Traffic Control (Background Priority) + # Der gewählte Provider wird über die .env gesteuert (MINDNET_LLM_PROVIDER) response_json = await self.llm.generate_raw_response( prompt=final_prompt, force_json=True, @@ -132,7 +135,7 @@ class SemanticAnalyzer: if isinstance(target, str): raw_candidates.append(f"{key}:{target}") - # 7. Strict Validation Loop + # 7. Strict Validation Loop (Übernahme aus deiner V2.2.0) for e in raw_candidates: e_str = str(e) if self._is_valid_edge_string(e_str): diff --git a/config/decision_engine.yaml b/config/decision_engine.yaml index 154c29b..a9c2458 100644 --- a/config/decision_engine.yaml +++ b/config/decision_engine.yaml @@ -41,7 +41,7 @@ strategies: # 1. Fakten-Abfrage (Fallback & Default) FACT: description: "Reine Wissensabfrage." - preferred_provider: "ollama" # Schnell und lokal ausreichend + preferred_provider: "openrouter" # Schnell und lokal ausreichend trigger_keywords: [] inject_types: [] # WP-22: Definitionen & Hierarchien bevorzugen diff --git a/config/prompts.yaml b/config/prompts.yaml index f52d0bc..c1c5c6e 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,4 +1,5 @@ -# config/prompts.yaml — Final V2.3.1 (Multi-Personality Support) +# config/prompts.yaml — Final V2.4.0 (Hybrid & Multi-Provider Support) +# WP-20: Optimierte Cloud-Templates bei unveränderten Ollama-Prompts. system_prompt: | Du bist 'mindnet', mein digitaler Zwilling und strategischer Partner. @@ -29,11 +30,13 @@ rag_template: Beantworte die Frage präzise basierend auf den Quellen. Fasse die Informationen zusammen. Sei objektiv und neutral. gemini: | - Analysiere diesen Kontext meines digitalen Zwillings: + Nutze das Wissen meines digitalen Zwillings aus folgendem Kontext: {context_str} + Beantworte die Anfrage präzise, detailliert und strukturiert: {query} + openrouter: | + Kontext-Analyse für Gemma/Llama: {context_str} - Beantworte die Anfrage detailliert und prüfe auf Widersprüche: {query} - openrouter: "Kontext-Analyse für Gemma/Llama: {context_str}\n\nAnfrage: {query}" - + + Anfrage: {query} # --------------------------------------------------------- # 2. DECISION: Strategie & Abwägung (Intent: DECISION) @@ -57,11 +60,16 @@ decision_template: FORMAT: - **Analyse:** (Kurze Zusammenfassung der Fakten) - **Abgleich:** (Gibt es Konflikte mit Werten/Zielen? Nenne die Quelle!) - - **Empfehlung:** (Klare Meinung: Ja/Nein/Vielleicht mit Begründung) + - **Empfehlung:** (Klare Meinung: Ja/No/Vielleicht mit Begründung) gemini: | - Agierte als Senior Strategy Consultant. Nutze den Kontext {context_str}, um die Frage {query} - tiefgreifend gegen meine langfristigen Ziele abzuwägen. - openrouter: "Strategischer Check (OpenRouter): {query}\n\nReferenzdaten: {context_str}" + Agiere als Senior Strategy Consultant für meinen digitalen Zwilling. + Wäge die Frage {query} multiperspektivisch gegen meine Werte und langfristigen Ziele ab. + Kontext: {context_str} + openrouter: | + Strategischer Check via OpenRouter/Gemma: + Analyse der Entscheidungsfrage: {query} + Referenzdaten aus dem Graph: {context_str} + # --------------------------------------------------------- # 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY) # --------------------------------------------------------- @@ -83,6 +91,13 @@ empathy_template: TONFALL: Ruhig, verständnisvoll, reflektiert. Keine Aufzählungszeichen, sondern fließender Text. + gemini: | + Reflektiere meine aktuelle Situation {query} basierend auf meinen Werten {context_str}. + Sei mein empathischer digitaler Zwilling. Antworte als 'Ich'. + openrouter: | + Empathische Reflexion (OpenRouter): + Situation: {query} + Persönlicher Kontext: {context_str} # --------------------------------------------------------- # 4. TECHNICAL: Der Coder (Intent: CODING) @@ -107,6 +122,14 @@ technical_template: - Kurze Erklärung des Ansatzes. - Markdown Code-Block (Copy-Paste fertig). - Wichtige Edge-Cases. + gemini: | + Du bist Senior Software Engineer. Löse die technische Aufgabe {query} + unter Berücksichtigung meiner Dokumentation: {context_str}. + openrouter: | + Technischer Support via OpenRouter: + Task: {query} + Kontext-Snippets: {context_str} + # --------------------------------------------------------- # 5. INTERVIEW: Der "One-Shot Extractor" (Performance Mode) # --------------------------------------------------------- @@ -142,9 +165,8 @@ interview_template: ## (Zweiter Begriff aus STRUKTUR) (Text...) - - (usw.) - + gemini: "Transformiere den Input {query} in das Schema {schema_fields} für Typ {target_type}." + openrouter: "Extrahiere Daten für Typ {target_type} aus {query}. Schema: {schema_fields}." # --------------------------------------------------------- # 6. EDGE_ALLOCATION: Kantenfilter (Intent: OFFLINE_FILTER) @@ -164,24 +186,37 @@ edge_allocation_template: {edge_list} REGELN: - 1. Die Kanten haben das Format "typ:ziel". Der "typ" ist variabel und kann ALLES sein (z.B. uses, blocks, inspired_by, loves, etc.). + 1. Die Kanten haben das Format "typ:ziel". Der "typ" ist variabel und kann ALLES sein. 2. Gib NUR die Strings aus der Kandidaten-Liste zurück, die zum Text passen. - 3. Erfinde KEINE neuen Kanten. Nutze exakt die Schreibweise aus der Liste. + 3. Erfinde KEINE neuen Kanten. 4. Antworte als flache JSON-Liste. - BEISPIEL (Zur Demonstration der Logik): - Input Text: "Das Projekt Alpha scheitert, weil Budget fehlt." - Input Kandidaten: ["blocks:Projekt Alpha", "inspired_by:Buch der Weisen", "needs:Budget"] - Output: ["blocks:Projekt Alpha", "needs:Budget"] - DEIN OUTPUT (JSON): - gemini: | - Extrahiere semantische Kanten für den Graphen ({note_id}). - Finde auch implizite Verbindungen. - JSON: [{"to": "X", "kind": "Y", "reason": "Z"}]. - TEXT: {text} + Analysiere den Textabschnitt: {chunk_text} + Wähle aus folgender Liste alle relevanten Kanten aus: {edge_list} + Antworte STRIKT als JSON-Liste von Strings im Format ["typ:ziel"]. + Kein Text davor oder danach! + openrouter: | + Filtere die relevanten Kanten für den Graphen. + Kandidaten: {edge_list} + Text: {chunk_text} + Output: JSON-Liste ["typ:ziel"]. + +# --------------------------------------------------------- +# 7. SMART EDGE ALLOCATION: Extraktion (Intent: INGEST) +# --------------------------------------------------------- +edge_extraction: + ollama: | + Extrahiere Kanten als JSON: [{"to": "X", "kind": "Y"}]. + Text: {text} + gemini: | + Führe eine semantische Analyse der Notiz '{note_id}' durch. + Finde explizite und implizite Relationen. + Antworte als JSON: [{"to": "Ziel", "kind": "typ", "reason": "begründung"}] + Keine Erklärungen, nur JSON. + Text: {text} openrouter: | Analysiere den Text für den Graphen. Identifiziere semantische Verbindungen. - Output JSON: [{"to": "X", "kind": "Y"}]. + Output STRIKT als JSON-Liste: [{"to": "X", "kind": "Y"}]. Text: {text} \ No newline at end of file