From 234949800b9dce0bb49eda135ae9c8129b647d5c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 14:33:51 +0100 Subject: [PATCH 01/28] WP20 initial --- app/config.py | 26 ++-- app/core/ingestion.py | 194 ++++++++++++++-------------- app/routers/ingest.py | 16 ++- app/services/llm_service.py | 158 ++++++++++++++--------- config/decision_engine.yaml | 48 ++++--- config/prompts.yaml | 243 +++++++++++++++++++----------------- requirements.txt | 5 +- 7 files changed, 383 insertions(+), 307 deletions(-) diff --git a/app/config.py b/app/config.py index a860f2d..5774a53 100644 --- a/app/config.py +++ b/app/config.py @@ -1,10 +1,11 @@ """ FILE: app/config.py DESCRIPTION: Zentrale Pydantic-Konfiguration (Env-Vars für Qdrant, LLM, Retriever). -VERSION: 0.4.0 + Erweitert um WP-20 Hybrid-Optionen. +VERSION: 0.5.0 STATUS: Active DEPENDENCIES: os, functools, pathlib -LAST_ANALYSIS: 2025-12-15 +LAST_ANALYSIS: 2025-12-23 """ from __future__ import annotations import os @@ -12,29 +13,38 @@ from functools import lru_cache from pathlib import Path class Settings: - # Qdrant + # Qdrant Verbindung 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")) DISTANCE: str = os.getenv("MINDNET_DISTANCE", "Cosine") - # Embeddings + # Embeddings (lokal) MODEL_NAME: str = os.getenv("MINDNET_MODEL", "sentence-transformers/all-MiniLM-L6-v2") - # WP-05 LLM / Ollama + # WP-20 Hybrid LLM Provider + # Erlaubt: "ollama" oder "gemini" + MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "ollama").lower() + GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY") + GEMINI_MODEL: str = os.getenv("MINDNET_GEMINI_MODEL", "gemini-1.5-flash") + LLM_FALLBACK_ENABLED: bool = os.getenv("MINDNET_LLM_FALLBACK", "true").lower() == "true" + + # WP-05 LLM / Ollama (Local) OLLAMA_URL: str = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") LLM_MODEL: str = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") PROMPTS_PATH: str = os.getenv("MINDNET_PROMPTS_PATH", "config/prompts.yaml") - # NEU für WP-06 + # WP-06 / WP-14 Performance & Timeouts LLM_TIMEOUT: float = float(os.getenv("MINDNET_LLM_TIMEOUT", "120.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")) - # API + # API & Debugging DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" + MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault") - # WP-04 Retriever Defaults + # 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 c7e8d05..fdd63f9 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,16 +1,17 @@ """ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen (Notes, Chunks, Edges). -FIX: Korrekte Priorisierung von Frontmatter für chunk_profile und retriever_weight. - Lade Chunk-Config basierend auf dem effektiven Profil, nicht nur dem Notiz-Typ. - WP-22: Integration von Content Lifecycle (Status Gate) und Edge Registry Validation. - WP-22: Multi-Hash Refresh für konsistente Change Detection. -VERSION: 2.8.6 (WP-22 Lifecycle & Registry) + WP-20: Integration von Smart Edge Allocation via Hybrid LLM (Gemini/Ollama). + 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.0 (WP-20 Full Integration: Hybrid Smart Edges) STATUS: Active -DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.core.derive_edges, app.core.qdrant*, app.services.embeddings_client, app.services.edge_registry -EXTERNAL_CONFIG: config/types.yaml +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 """ import os +import json import logging import asyncio import time @@ -21,6 +22,7 @@ from app.core.parser import ( read_markdown, normalize_frontmatter, validate_required_frontmatter, + extract_edges_with_context, # ) from app.core.note_payload import make_note_payload from app.core.chunker import assemble_chunks, get_chunk_config @@ -42,6 +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 # logger = logging.getLogger(__name__) @@ -66,18 +69,15 @@ 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 """ - # 1. Frontmatter Override override = fm.get("chunking_profile") or fm.get("chunk_profile") if override and isinstance(override, str): return override - # 2. Type Config 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 - # 3. Global Default return reg.get("defaults", {}).get("chunking_profile", "sliding_standard") def effective_retriever_weight(fm: dict, note_type: str, reg: dict) -> float: @@ -85,34 +85,32 @@ 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 """ - # 1. Frontmatter Override override = fm.get("retriever_weight") if override is not None: try: return float(override) except: pass - # 2. Type Config t_cfg = reg.get("types", {}).get(note_type, {}) if t_cfg and "retriever_weight" in t_cfg: return float(t_cfg["retriever_weight"]) - # 3. Global Default return float(reg.get("defaults", {}).get("retriever_weight", 1.0)) class IngestionService: def __init__(self, collection_prefix: str = None): - env_prefix = os.getenv("COLLECTION_PREFIX", "mindnet") - self.prefix = collection_prefix or env_prefix + from app.config import get_settings + self.settings = get_settings() # + self.prefix = collection_prefix or self.settings.COLLECTION_PREFIX self.cfg = QdrantConfig.from_env() self.cfg.prefix = self.prefix self.client = get_client(self.cfg) - self.dim = self.cfg.dim - self.registry = load_type_registry() + self.dim = self.cfg.VECTOR_SIZE # + self.type_registry = load_type_registry() self.embedder = EmbeddingsClient() + self.llm = LLMService() # - # Change Detection Modus (full oder body) self.active_hash_mode = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") try: @@ -122,8 +120,8 @@ class IngestionService: logger.warning(f"DB init warning: {e}") def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: - """Holt die Chunker-Parameter (max, target, overlap) für ein spezifisches Profil.""" - profiles = self.registry.get("chunking_profiles", {}) + """Holt die Chunker-Parameter für ein spezifisches Profil.""" + profiles = self.type_registry.get("chunking_profiles", {}) if profile_name in profiles: cfg = profiles[profile_name].copy() if "overlap" in cfg and isinstance(cfg["overlap"], list): @@ -131,6 +129,43 @@ class IngestionService: return cfg return get_chunk_config(note_type) + 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. + Verwendet provider-spezifische Prompts aus der config. + """ + provider = self.settings.MINDNET_LLM_PROVIDER # + + # Prompt-Lookup (Fallback auf Standard-Struktur falls Key fehlt) + prompt_data = self.llm.prompts.get("edge_extraction", {}) + if isinstance(prompt_data, dict): + template = prompt_data.get(provider, prompt_data.get("ollama", "")) + else: + template = str(prompt_data) + + if not template: + template = "Extrahiere semantische Relationen aus: {text}. Antworte als JSON: [{\"to\": \"X\", \"kind\": \"Y\"}]" + + prompt = template.format(text=text[:6000], note_id=note_id) + + try: + # Nutzt die Semaphore für Hintergrund-Tasks + response_json = await self.llm.generate_raw_response( + prompt=prompt, + priority="background", + force_json=True + ) + data = json.loads(response_json) + + # Anreicherung mit Provenance-Metadaten für WP-22 Registry + 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 skipped for {note_id}: {e}") + return [] + async def process_file( self, file_path: str, @@ -142,10 +177,7 @@ class IngestionService: hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: - """ - Verarbeitet eine Markdown-Datei und schreibt sie in den Graphen. - Folgt dem 14-Schritte-Workflow. - """ + """Verarbeitet eine Markdown-Datei und schreibt sie in den Graphen.""" result = {"path": file_path, "status": "skipped", "changed": False, "error": None} # 1. Parse & Frontmatter Validation @@ -158,42 +190,29 @@ class IngestionService: logger.error(f"Validation failed for {file_path}: {e}") return {**result, "error": f"Validation failed: {str(e)}"} - # --- WP-22: Content Lifecycle Gate (Teil A) --- + # --- WP-22: Content Lifecycle Gate --- status = fm.get("status", "draft").lower().strip() - - # Hard Skip für System- oder Archiv-Dateien if status in ["system", "template", "archive", "hidden"]: logger.info(f"Skipping file {file_path} (Status: {status})") return {**result, "status": "skipped", "reason": f"lifecycle_status_{status}"} # 2. Type & Config Resolution - note_type = resolve_note_type(fm.get("type"), self.registry) + note_type = resolve_note_type(fm.get("type"), self.type_registry) fm["type"] = note_type - effective_profile = effective_chunk_profile_name(fm, note_type, self.registry) - effective_weight = effective_retriever_weight(fm, note_type, self.registry) + effective_profile = effective_chunk_profile_name(fm, note_type, self.type_registry) + effective_weight = effective_retriever_weight(fm, note_type, self.type_registry) fm["chunk_profile"] = effective_profile fm["retriever_weight"] = effective_weight # 3. Build Note Payload (Inkl. Multi-Hash für WP-22) try: - note_pl = make_note_payload( - parsed, - vault_root=vault_root, - hash_normalize=hash_normalize, - hash_source=hash_source, - file_path=file_path - ) - # Text Body Fallback + note_pl = make_note_payload(parsed, vault_root=vault_root, hash_normalize=hash_normalize, hash_source=hash_source, file_path=file_path) if not note_pl.get("fulltext"): note_pl["fulltext"] = getattr(parsed, "body", "") or "" - - # Sicherstellen der effektiven Werte im Payload note_pl["retriever_weight"] = effective_weight note_pl["chunk_profile"] = effective_profile - # WP-22: Status speichern note_pl["status"] = status - note_id = note_pl["note_id"] except Exception as e: logger.error(f"Payload build failed: {e}") @@ -205,15 +224,12 @@ class IngestionService: old_payload = self._fetch_note_payload(note_id) has_old = old_payload is not None - # Prüfung gegen den aktuell konfigurierten Hash-Modus (body oder full) check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" - old_hashes = (old_payload or {}).get("hashes") - if isinstance(old_hashes, dict): old_hash = old_hashes.get(check_key) - elif isinstance(old_hashes, str) and self.active_hash_mode == "body": old_hash = old_hashes - else: old_hash = None - + old_hashes = (old_payload or {}).get("hashes", {}) + old_hash = old_hashes.get(check_key) if isinstance(old_hashes, dict) else None new_hash = note_pl.get("hashes", {}).get(check_key) + hash_changed = (old_hash != new_hash) chunks_missing, edges_missing = self._artifacts_missing(note_id) @@ -228,49 +244,38 @@ class IngestionService: # 5. Processing (Chunking, Embedding, Edge Generation) try: body_text = getattr(parsed, "body", "") or "" - - # Konfiguration für das spezifische Profil laden + edge_registry.ensure_latest() # + chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) - chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) - - # Chunks mit Metadaten anreichern chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) vecs = [] if chunk_pls: texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] - try: - if hasattr(self.embedder, 'embed_documents'): - vecs = await self.embedder.embed_documents(texts) - else: - for t in texts: - v = await self.embedder.embed_query(t) - vecs.append(v) - except Exception as e: - logger.error(f"Embedding failed: {e}") - raise RuntimeError(f"Embedding failed: {e}") + vecs = await self.embedder.embed_documents(texts) - # Kanten generieren - try: - raw_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: - raw_edges = build_edges_for_note(note_id, chunk_pls) - - # --- WP-22: Edge Registry Validation (Teil B) --- + # --- WP-22: Kanten-Extraktion & Validierung --- edges = [] - if raw_edges: - for edge in raw_edges: - original_kind = edge.get("kind", "related_to") - # Normalisierung über die Registry (Alias-Auflösung) - canonical_kind = edge_registry.resolve(original_kind) - edge["kind"] = canonical_kind - edges.append(edge) + context = {"file": file_path, "note_id": note_id} + + # A. Explizite User-Kanten mit Zeilennummern + explicit_edges = extract_edges_with_context(parsed) + for e in explicit_edges: + 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) + 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 (Struktur: belongs_to, next, prev) + raw_system_edges = build_edges_for_note(note_id, chunk_pls, note_level_references=note_pl.get("references", [])) + for e in raw_system_edges: + e["kind"] = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"}) + if e["kind"]: edges.append(e) except Exception as e: logger.error(f"Processing failed: {e}", exc_info=True) @@ -278,31 +283,23 @@ class IngestionService: # 6. Upsert in Qdrant try: - # Alte Fragmente löschen, um "Geister-Chunks" zu vermeiden if purge_before and has_old: self._purge_artifacts(note_id) - # Note Metadaten n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) upsert_batch(self.client, n_name, n_pts) - # Chunks (Vektoren) if chunk_pls and vecs: c_name, c_pts = points_for_chunks(self.prefix, chunk_pls, vecs) upsert_batch(self.client, c_name, c_pts) - # Kanten if edges: e_name, e_pts = points_for_edges(self.prefix, edges) upsert_batch(self.client, e_name, e_pts) return { - "path": file_path, - "status": "success", - "changed": True, - "note_id": note_id, - "chunks_count": len(chunk_pls), - "edges_count": len(edges) + "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) @@ -319,19 +316,17 @@ class IngestionService: except: return None def _artifacts_missing(self, note_id: str) -> Tuple[bool, bool]: - """Prüft, ob Chunks oder Kanten für eine Note fehlen (Integritätscheck).""" + """Prüft, ob Chunks oder Kanten für eine Note fehlen.""" from qdrant_client.http import models as rest - c_col = f"{self.prefix}_chunks" - e_col = f"{self.prefix}_edges" try: f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - c_pts, _ = self.client.scroll(collection_name=c_col, scroll_filter=f, limit=1) - e_pts, _ = self.client.scroll(collection_name=e_col, scroll_filter=f, limit=1) + c_pts, _ = self.client.scroll(collection_name=f"{self.prefix}_chunks", scroll_filter=f, limit=1) + e_pts, _ = self.client.scroll(collection_name=f"{self.prefix}_edges", scroll_filter=f, limit=1) return (not bool(c_pts)), (not bool(e_pts)) except: return True, True def _purge_artifacts(self, note_id: str): - """Löscht alle Chunks und Edges einer Note (vor dem Neu-Schreiben).""" + """Löscht alle Chunks und Edges einer Note.""" from qdrant_client.http import models as rest f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) selector = rest.FilterSelector(filter=f) @@ -341,7 +336,7 @@ class IngestionService: 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 (Editor-Save).""" + """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) @@ -351,7 +346,6 @@ class IngestionService: 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/routers/ingest.py b/app/routers/ingest.py index cfac79d..158015f 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,8 +1,9 @@ """ FILE: app/routers/ingest.py DESCRIPTION: Endpunkte für WP-11. Nimmt Markdown entgegen. -Refactored für WP-14: Nutzt BackgroundTasks für non-blocking Save. -VERSION: 0.7.0 (Fix: Timeout WP-14) + Refactored für WP-14: Nutzt BackgroundTasks für non-blocking Save. + Update WP-20: Unterstützung für Hybrid-Cloud-Analyse Feedback. +VERSION: 0.8.0 (WP-20 Hybrid Ready) STATUS: Active DEPENDENCIES: app.core.ingestion, app.services.discovery, fastapi, pydantic """ @@ -44,6 +45,7 @@ class SaveResponse(BaseModel): async def run_ingestion_task(markdown_content: str, filename: str, vault_root: str, folder: str): """ Führt die Ingestion im Hintergrund aus, damit der Request nicht blockiert. + Integrierter WP-20 Hybrid-Modus über den IngestionService. """ logger.info(f"🔄 Background Task started: Ingesting {filename}...") try: @@ -80,15 +82,17 @@ async def analyze_draft(req: AnalyzeRequest): async def save_note(req: SaveRequest, background_tasks: BackgroundTasks): """ WP-14 Fix: Startet Ingestion im Hintergrund (Fire & Forget). - Verhindert Timeouts bei aktiver Smart-Edge-Allocation (WP-15). + Verhindert Timeouts bei aktiver Smart-Edge-Allocation (WP-15) und Cloud-Hybrid-Modus (WP-20). """ try: vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") abs_vault_root = os.path.abspath(vault_root) if not os.path.exists(abs_vault_root): - try: os.makedirs(abs_vault_root, exist_ok=True) - except: pass + try: + os.makedirs(abs_vault_root, exist_ok=True) + except Exception as e: + logger.warning(f"Could not create vault root: {e}") final_filename = req.filename or f"draft_{int(time.time())}.md" @@ -109,7 +113,7 @@ async def save_note(req: SaveRequest, background_tasks: BackgroundTasks): status="queued", file_path=os.path.join(req.folder, final_filename), note_id="pending", - message="Speicherung & KI-Analyse im Hintergrund gestartet.", + message="Speicherung & Hybrid-KI-Analyse (WP-20) im Hintergrund gestartet.", stats={ "chunks": -1, # Indikator für Async "edges": -1 diff --git a/app/services/llm_service.py b/app/services/llm_service.py index cff8880..df4cdd1 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,11 +1,11 @@ """ FILE: app/services/llm_service.py -DESCRIPTION: Asynchroner Client für Ollama. Verwaltet Prompts und Background-Last (Semaphore). -VERSION: 2.8.0 +DESCRIPTION: Hybrid-Client für Ollama & Google Gemini. + Verwaltet Prompts, Background-Last (Semaphore) und Cloud-Routing. +VERSION: 3.1.0 (WP-20 Full Integration: Provider-Aware Prompting) STATUS: Active -DEPENDENCIES: httpx, yaml, asyncio, app.config +DEPENDENCIES: httpx, yaml, asyncio, google-generativeai, app.config EXTERNAL_CONFIG: config/prompts.yaml -LAST_ANALYSIS: 2025-12-15 """ import httpx @@ -13,47 +13,47 @@ import yaml import logging import os import asyncio +import json +import google.generativeai as genai from pathlib import Path from typing import Optional, Dict, Any, Literal +from app.config import get_settings logger = logging.getLogger(__name__) -class Settings: - OLLAMA_URL = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") - 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") - - # NEU: Konfigurierbares Limit für Hintergrund-Last - # Default auf 2 (konservativ), kann in .env erhöht werden. - BACKGROUND_LIMIT = int(os.getenv("MINDNET_LLM_BACKGROUND_LIMIT", "2")) - -def get_settings(): - return Settings() - class LLMService: - # GLOBALER SEMAPHOR (Lazy Initialization) - # Wir initialisieren ihn erst, wenn wir die Settings kennen. + # GLOBALER SEMAPHOR für Hintergrund-Last Steuerung (WP-06 / WP-20) _background_semaphore = None def __init__(self): self.settings = get_settings() self.prompts = self._load_prompts() - # Initialisiere Semaphore einmalig auf Klassen-Ebene basierend auf Config + # Initialisiere Semaphore einmalig auf Klassen-Ebene if LLMService._background_semaphore is None: - limit = self.settings.BACKGROUND_LIMIT + limit = getattr(self.settings, "BACKGROUND_LIMIT", 2) logger.info(f"🚦 LLMService: Initializing Background Semaphore with limit: {limit}") LLMService._background_semaphore = asyncio.Semaphore(limit) + # Ollama Setup self.timeout = httpx.Timeout(self.settings.LLM_TIMEOUT, connect=10.0) - - self.client = httpx.AsyncClient( + self.ollama_client = httpx.AsyncClient( base_url=self.settings.OLLAMA_URL, timeout=self.timeout ) + # Gemini Setup [WP-20] + if hasattr(self.settings, "GOOGLE_API_KEY") and self.settings.GOOGLE_API_KEY: + genai.configure(api_key=self.settings.GOOGLE_API_KEY) + model_name = getattr(self.settings, "GEMINI_MODEL", "gemini-1.5-flash") + self.gemini_model = genai.GenerativeModel(model_name) + logger.info(f"✨ LLMService: Gemini Cloud Mode active ({model_name})") + else: + self.gemini_model = None + logger.warning("⚠️ LLMService: No GOOGLE_API_KEY found. Gemini mode disabled.") + 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 {} try: @@ -62,86 +62,124 @@ class LLMService: logger.error(f"Failed to load prompts: {e}") return {} + def get_prompt(self, key: str, provider: str = None) -> str: + """ + Wählt das Template basierend auf dem Provider aus (WP-20). + Unterstützt sowohl flache Strings als auch Dictionary-basierte Provider-Zweige. + """ + active_provider = provider or getattr(self.settings, "MINDNET_LLM_PROVIDER", "ollama") + data = self.prompts.get(key, "") + + if isinstance(data, dict): + # Versuche den Provider-Key, Fallback auf 'ollama' + return data.get(active_provider, data.get("ollama", "")) + return str(data) + async def generate_raw_response( self, prompt: str, system: str = None, force_json: bool = False, - max_retries: int = 0, + max_retries: int = 2, base_delay: float = 2.0, - priority: Literal["realtime", "background"] = "realtime" + priority: Literal["realtime", "background"] = "realtime", + provider: Optional[str] = None ) -> str: """ - Führt einen LLM Call aus. - priority="realtime": Chat (Sofort, keine Bremse). - priority="background": Import/Analyse (Gedrosselt durch Semaphore). + Führt einen LLM Call aus mit Priority-Handling und Provider-Wahl. """ + # Bestimme Provider: Parameter-Override > Config-Default + target_provider = provider or getattr(self.settings, "MINDNET_LLM_PROVIDER", "ollama") use_semaphore = (priority == "background") if use_semaphore and LLMService._background_semaphore: async with LLMService._background_semaphore: - return await self._execute_request(prompt, system, force_json, max_retries, base_delay) + return await self._dispatch_request(target_provider, prompt, system, force_json, max_retries, base_delay) else: - # Realtime oder Fallback (falls Semaphore Init fehlschlug) - return await self._execute_request(prompt, system, force_json, max_retries, base_delay) + return await self._dispatch_request(target_provider, prompt, system, force_json, max_retries, base_delay) - async def _execute_request(self, prompt, system, force_json, max_retries, base_delay): + async def _dispatch_request(self, provider, prompt, system, force_json, max_retries, base_delay): + """Routet die Anfrage an den gewählten Provider mit Fallback-Logik.""" + try: + if provider == "gemini" and self.gemini_model: + return await self._execute_gemini(prompt, system, force_json) + else: + return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) + except Exception as e: + # Automatischer Fallback auf Ollama bei Cloud-Fehlern (WP-20) + if provider == "gemini" and getattr(self.settings, "LLM_FALLBACK_ENABLED", True): + logger.warning(f"🔄 Gemini failed: {e}. Falling back to Ollama.") + return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) + raise e + + async def _execute_gemini(self, prompt, system, force_json) -> str: + """Asynchroner Google Gemini Call (WP-20).""" + full_prompt = f"System: {system}\n\nUser: {prompt}" if system else prompt + + # Gemini JSON Mode Support + gen_config = {} + if force_json: + gen_config["response_mime_type"] = "application/json" + + response = await self.gemini_model.generate_content_async( + full_prompt, + generation_config=gen_config + ) + return response.text.strip() + + async def _execute_ollama(self, prompt, system, force_json, max_retries, base_delay) -> str: + """Ollama Call mit exponentieller Backoff-Retry-Logik.""" payload: Dict[str, Any] = { "model": self.settings.LLM_MODEL, "prompt": prompt, "stream": False, "options": { - "temperature": 0.1 if force_json else 0.7, + "temperature": 0.1 if force_json else 0.7, "num_ctx": 8192 } } - - if force_json: - payload["format"] = "json" - - if system: - payload["system"] = system + if force_json: payload["format"] = "json" + if system: payload["system"] = system attempt = 0 - while True: try: - response = await self.client.post("/api/generate", json=payload) - + response = await self.ollama_client.post("/api/generate", json=payload) if response.status_code == 200: - data = response.json() - return data.get("response", "").strip() - else: - response.raise_for_status() - + return response.json().get("response", "").strip() + response.raise_for_status() except Exception as e: attempt += 1 if attempt > max_retries: - logger.error(f"LLM Final Error (Versuch {attempt}): {e}") - raise e - + logger.error(f"Ollama Error after {attempt} retries: {e}") + raise e + # Exponentieller Backoff: base_delay * (2 ^ (attempt - 1)) wait_time = base_delay * (2 ** (attempt - 1)) - logger.warning(f"⚠️ LLM Retry ({attempt}/{max_retries}) in {wait_time}s: {e}") + logger.warning(f"⚠️ Ollama attempt {attempt} failed. Retrying in {wait_time}s...") await asyncio.sleep(wait_time) async def generate_rag_response(self, query: str, context_str: str) -> str: - """ - Chat-Wrapper: Immer Realtime. - """ - system_prompt = self.prompts.get("system_prompt", "") - rag_template = self.prompts.get("rag_template", "{context_str}\n\n{query}") + """Standard RAG Chat-Interface mit Provider-spezifischen Templates.""" + provider = getattr(self.settings, "MINDNET_LLM_PROVIDER", "ollama") + # Holen der Templates über die neue get_prompt Methode + system_prompt = self.get_prompt("system_prompt", provider) + rag_template = self.get_prompt("rag_template", provider) + + # Fallback für RAG Template Struktur + if not rag_template: + rag_template = "{context_str}\n\n{query}" + final_prompt = rag_template.format(context_str=context_str, query=query) return await self.generate_raw_response( final_prompt, system=system_prompt, - max_retries=0, - force_json=False, priority="realtime" ) async def close(self): - if self.client: - await self.client.aclose() \ No newline at end of file + """Schließt alle offenen HTTP-Verbindungen.""" + if self.ollama_client: + await self.ollama_client.aclose() \ No newline at end of file diff --git a/config/decision_engine.yaml b/config/decision_engine.yaml index 0bb75b5..154c29b 100644 --- a/config/decision_engine.yaml +++ b/config/decision_engine.yaml @@ -1,31 +1,37 @@ # config/decision_engine.yaml # Steuerung der Decision Engine (Intent Recognition & Graph Routing) -# Version: 2.5.0 (WP-22: Semantic Graph Routing) +# VERSION: 2.6.0 (WP-20: Hybrid LLM & WP-22: Semantic Graph Routing) +# STATUS: Active -version: 2.5 +version: 2.6 settings: llm_fallback_enabled: true + # Strategie für den Router selbst (Welches Modell erkennt den Intent?) + # "auto" nutzt den in MINDNET_LLM_PROVIDER gesetzten Standard. + router_provider: "auto" + # Few-Shot Prompting für den LLM-Router (Slow Path) + # Gemini 1.5 nutzt diesen Kontext für hochpräzise Intent-Erkennung. llm_router_prompt: | - Du bist ein Klassifikator. Analysiere die Nachricht und wähle die passende Strategie. + Du bist der zentrale Intent-Klassifikator für Mindnet, einen digitalen Zwilling. + Analysiere die Nachricht und wähle die passende Strategie. Antworte NUR mit dem Namen der Strategie. STRATEGIEN: - INTERVIEW: User will Wissen erfassen, Notizen anlegen oder Dinge festhalten. - - DECISION: Rat, Strategie, Vor/Nachteile, "Soll ich". - - EMPATHY: Gefühle, Frust, Freude, Probleme. - - CODING: Code, Syntax, Programmierung. - - FACT: Wissen, Fakten, Definitionen. + - DECISION: Rat, Strategie, Abwägung von Werten, "Soll ich tun X?". + - EMPATHY: Gefühle, Reflexion der eigenen Verfassung, Frust, Freude. + - CODING: Code-Erstellung, Debugging, technische Dokumentation. + - FACT: Reine Wissensabfrage, Definitionen, Suchen von Informationen. BEISPIELE: - User: "Wie funktioniert Qdrant?" -> FACT - User: "Soll ich Qdrant nutzen?" -> DECISION - User: "Ich möchte etwas notieren" -> INTERVIEW - User: "Lass uns das festhalten" -> INTERVIEW - User: "Schreibe ein Python Script" -> CODING - User: "Alles ist grau und sinnlos" -> EMPATHY + User: "Wie funktioniert die Qdrant-Vektor-DB?" -> FACT + User: "Soll ich mein Startup jetzt verkaufen?" -> DECISION + User: "Notiere mir kurz meine Gedanken zum Meeting." -> INTERVIEW + User: "Ich fühle mich heute sehr erschöpft." -> EMPATHY + User: "Schreibe eine FastAPI-Route für den Ingest." -> CODING NACHRICHT: "{query}" @@ -35,6 +41,7 @@ strategies: # 1. Fakten-Abfrage (Fallback & Default) FACT: description: "Reine Wissensabfrage." + preferred_provider: "ollama" # Schnell und lokal ausreichend trigger_keywords: [] inject_types: [] # WP-22: Definitionen & Hierarchien bevorzugen @@ -46,9 +53,10 @@ strategies: prompt_template: "rag_template" prepend_instruction: null - # 2. Entscheidungs-Frage + # 2. Entscheidungs-Frage (Power-Strategie) DECISION: description: "Der User sucht Rat, Strategie oder Abwägung." + preferred_provider: "gemini" # Nutzt Gemini's Reasoning-Power für WP-20 trigger_keywords: - "soll ich" - "meinung" @@ -68,12 +76,13 @@ strategies: impacts: 2.0 # NEU: Zeige mir alles, was von dieser Entscheidung betroffen ist! prompt_template: "decision_template" prepend_instruction: | - !!! ENTSCHEIDUNGS-MODUS !!! + !!! ENTSCHEIDUNGS-MODUS (HYBRID AI) !!! BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE, PRINZIPIEN UND ZIELE AB: - # 3. Empathie / "Ich"-Modus + # 3. Empathie / "Ich"-Modus (Privacy-Fokus) EMPATHY: description: "Reaktion auf emotionale Zustände." + preferred_provider: "ollama" # Private Emotionen bleiben lokal! trigger_keywords: - "ich fühle" - "traurig" @@ -96,6 +105,7 @@ strategies: # 4. Coding / Technical CODING: description: "Technische Anfragen und Programmierung." + preferred_provider: "gemini" # Höheres Weltwissen für moderne Libraries trigger_keywords: - "code" - "python" @@ -116,10 +126,9 @@ strategies: prepend_instruction: null # 5. Interview / Datenerfassung - # HINWEIS: Spezifische Typen (Projekt, Ziel etc.) werden automatisch - # über die types.yaml erkannt. Hier stehen nur generische Trigger. INTERVIEW: description: "Der User möchte Wissen erfassen." + preferred_provider: "ollama" # Lokale Erfassung ist performant genug trigger_keywords: - "neue notiz" - "etwas notieren" @@ -135,8 +144,7 @@ strategies: edge_boosts: {} prompt_template: "interview_template" prepend_instruction: null - # Schemas: Hier nur der Fallback. - # Spezifische Schemas (Project, Experience) kommen jetzt aus types.yaml! + # Schemas kommen aus types.yaml (WP-22) schemas: default: fields: diff --git a/config/prompts.yaml b/config/prompts.yaml index 3a06df2..5574383 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -15,148 +15,167 @@ system_prompt: | # --------------------------------------------------------- # 1. STANDARD: Fakten & Wissen (Intent: FACT) # --------------------------------------------------------- -rag_template: | - QUELLEN (WISSEN): - ========================================= - {context_str} - ========================================= +rag_template: + ollama: | + QUELLEN (WISSEN): + ========================================= + {context_str} + ========================================= - FRAGE: - {query} + FRAGE: + {query} - ANWEISUNG: - Beantworte die Frage präzise basierend auf den Quellen. - Fasse die Informationen zusammen. Sei objektiv und neutral. + ANWEISUNG: + Beantworte die Frage präzise basierend auf den Quellen. + Fasse die Informationen zusammen. Sei objektiv und neutral. + gemini: | + Analysiere diesen Kontext meines digitalen Zwillings: + {context_str} + Beantworte die Anfrage detailliert und prüfe auf Widersprüche: {query} # --------------------------------------------------------- # 2. DECISION: Strategie & Abwägung (Intent: DECISION) # --------------------------------------------------------- -decision_template: | - KONTEXT (FAKTEN & STRATEGIE): - ========================================= - {context_str} - ========================================= +decision_template: + ollama: | + KONTEXT (FAKTEN & STRATEGIE): + ========================================= + {context_str} + ========================================= - ENTSCHEIDUNGSFRAGE: - {query} + ENTSCHEIDUNGSFRAGE: + {query} - ANWEISUNG: - Du agierst als mein Entscheidungs-Partner. - 1. Analysiere die Faktenlage aus den Quellen. - 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? - - 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) + ANWEISUNG: + Du agierst als mein Entscheidungs-Partner. + 1. Analysiere die Faktenlage aus den Quellen. + 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? + + 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) + gemini: | + Agierte als Senior Strategy Consultant. Nutze den Kontext {context_str}, um die Frage {query} + tiefgreifend gegen meine langfristigen Ziele abzuwägen. # --------------------------------------------------------- # 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY) # --------------------------------------------------------- -empathy_template: | - KONTEXT (ERFAHRUNGEN & GLAUBENSSÄTZE): - ========================================= - {context_str} - ========================================= +empathy_template: + ollama: | + KONTEXT (ERFAHRUNGEN & GLAUBENSSÄTZE): + ========================================= + {context_str} + ========================================= - SITUATION: - {query} + 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. + 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} - ========================================= +technical_template: + ollama: | + KONTEXT (DOCS & SNIPPETS): + ========================================= + {context_str} + ========================================= - TASK: - {query} + 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. + 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. # --------------------------------------------------------- # 5. INTERVIEW: Der "One-Shot Extractor" (Performance Mode) # --------------------------------------------------------- -interview_template: | - TASK: - Du bist ein professioneller Ghostwriter. Verwandle den "USER INPUT" in eine strukturierte Notiz vom Typ '{target_type}'. +interview_template: + ollama: | + TASK: + Du bist ein professioneller Ghostwriter. Verwandle den "USER INPUT" in eine strukturierte Notiz vom Typ '{target_type}'. + + STRUKTUR (Nutze EXAKT diese Überschriften): + {schema_fields} + + USER INPUT: + "{query}" + + ANWEISUNG ZUM INHALT: + 1. Analysiere den Input genau. + 2. Schreibe die Inhalte unter die passenden Überschriften aus der STRUKTUR-Liste oben. + 3. STIL: Schreibe flüssig, professionell und in der Ich-Perspektive. Korrigiere Grammatikfehler, aber behalte den persönlichen Ton bei. + 4. Wenn Informationen für einen Abschnitt fehlen, schreibe nur: "[TODO: Ergänzen]". Erfinde nichts dazu. + + OUTPUT FORMAT (YAML + MARKDOWN): + --- + type: {target_type} + status: draft + title: (Erstelle einen treffenden, kurzen Titel für den Inhalt) + tags: [Tag1, Tag2] + --- + + # (Wiederhole den Titel hier) + + ## (Erster Begriff aus STRUKTUR) + (Text...) + + ## (Zweiter Begriff aus STRUKTUR) + (Text...) + + (usw.) - STRUKTUR (Nutze EXAKT diese Überschriften): - {schema_fields} - - USER INPUT: - "{query}" - - ANWEISUNG ZUM INHALT: - 1. Analysiere den Input genau. - 2. Schreibe die Inhalte unter die passenden Überschriften aus der STRUKTUR-Liste oben. - 3. STIL: Schreibe flüssig, professionell und in der Ich-Perspektive. Korrigiere Grammatikfehler, aber behalte den persönlichen Ton bei. - 4. Wenn Informationen für einen Abschnitt fehlen, schreibe nur: "[TODO: Ergänzen]". Erfinde nichts dazu. - - OUTPUT FORMAT (YAML + MARKDOWN): - --- - type: {target_type} - status: draft - title: (Erstelle einen treffenden, kurzen Titel für den Inhalt) - tags: [Tag1, Tag2] - --- - - # (Wiederhole den Titel hier) - - ## (Erster Begriff aus STRUKTUR) - (Text...) - - ## (Zweiter Begriff aus STRUKTUR) - (Text...) - - (usw.) - # --------------------------------------------------------- # 6. EDGE_ALLOCATION: Kantenfilter (Intent: OFFLINE_FILTER) # --------------------------------------------------------- -edge_allocation_template: | - TASK: - Du bist ein strikter Selektor. Du erhältst eine Liste von "Kandidaten-Kanten" (Strings). - Wähle jene aus, die inhaltlich im "Textabschnitt" vorkommen oder relevant sind. +edge_allocation_template: + ollama: | + TASK: + Du bist ein strikter Selektor. Du erhältst eine Liste von "Kandidaten-Kanten" (Strings). + Wähle jene aus, die inhaltlich im "Textabschnitt" vorkommen oder relevant sind. - TEXTABSCHNITT: - """ - {chunk_text} - """ + TEXTABSCHNITT: + """ + {chunk_text} + """ - KANDIDATEN (Auswahl-Pool): - {edge_list} + KANDIDATEN (Auswahl-Pool): + {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.). - 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. - 4. Antworte als flache JSON-Liste. + 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.). + 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. + 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"] + 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): \ No newline at end of file + 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} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 34bebcd..3e258e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,4 +34,7 @@ streamlit>=1.39.0 # Visualization (Parallelbetrieb) streamlit-agraph>=0.0.45 -st-cytoscape \ No newline at end of file +st-cytoscape + +# Google gemini API +google-generativeai>=0.8.3 \ No newline at end of file From 0ac8a14ea7b3e1edf41eaff344b55ede5511e45a Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 14:38:27 +0100 Subject: [PATCH 02/28] WP20 - parser --- app/core/parser.py | 72 ++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/app/core/parser.py b/app/core/parser.py index 9d106e8..b47aeb7 100644 --- a/app/core/parser.py +++ b/app/core/parser.py @@ -1,10 +1,11 @@ """ FILE: app/core/parser.py DESCRIPTION: Liest Markdown-Dateien fehlertolerant (Encoding-Fallback). Trennt Frontmatter (YAML) vom Body. -VERSION: 1.7.1 + WP-22 Erweiterung: Kanten-Extraktion mit Zeilennummern für die EdgeRegistry. +VERSION: 1.8.0 STATUS: Active DEPENDENCIES: yaml, re, dataclasses, json, io, os -LAST_ANALYSIS: 2025-12-15 +LAST_ANALYSIS: 2025-12-23 """ from __future__ import annotations @@ -138,13 +139,7 @@ def _read_text_with_fallback(path: str) -> Tuple[str, str, bool]: def read_markdown(path: str) -> Optional[ParsedNote]: """ - Liest eine Markdown-Datei fehlertolerant: - - Erlaubt verschiedene Encodings (UTF-8 bevorzugt, cp1252/latin-1 als Fallback). - - Schlägt NICHT mit UnicodeDecodeError fehl. - - Gibt ParsedNote(frontmatter, body, path) zurück oder None, falls die Datei nicht existiert. - - Bei Decoding-Fallback wird ein JSON-Warnhinweis geloggt: - {"path": "...", "warn": "encoding_fallback_used", "used": "cp1252"} + Liest eine Markdown-Datei fehlertolerant. """ if not os.path.exists(path): return None @@ -161,10 +156,6 @@ def validate_required_frontmatter(fm: Dict[str, Any], required: Tuple[str, ...] = ("id", "title")) -> None: """ Prüft, ob alle Pflichtfelder vorhanden sind. - Default-kompatibel: ('id', 'title'), kann aber vom Aufrufer erweitert werden, z. B.: - validate_required_frontmatter(fm, required=("id","title","type","status","created")) - - Hebt ValueError, falls Felder fehlen oder leer sind. """ if fm is None: fm = {} @@ -178,17 +169,13 @@ def validate_required_frontmatter(fm: Dict[str, Any], if missing: raise ValueError(f"Missing required frontmatter fields: {', '.join(missing)}") - # Plausibilitäten: 'tags' sollte eine Liste sein, wenn vorhanden if "tags" in fm and fm["tags"] not in (None, "") and not isinstance(fm["tags"], (list, tuple)): raise ValueError("frontmatter 'tags' must be a list of strings") def normalize_frontmatter(fm: Dict[str, Any]) -> Dict[str, Any]: """ - Sanfte Normalisierung ohne Semantikänderung: - - 'tags' → Liste von Strings (Trim) - - 'embedding_exclude' → bool - - andere Felder unverändert + Normalisierung von Tags und anderen Feldern. """ out = dict(fm or {}) if "tags" in out: @@ -205,15 +192,12 @@ def normalize_frontmatter(fm: Dict[str, Any]) -> Dict[str, Any]: # ------------------------------ Wikilinks ---------------------------- # -# Basismuster für [[...]]; die Normalisierung (id vor '#', vor '|') macht extract_wikilinks _WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]") def extract_wikilinks(text: str) -> List[str]: """ - Extrahiert Wikilinks wie [[id]], [[id#anchor]], [[id|label]], [[id#anchor|label]]. - Rückgabe sind NUR die Ziel-IDs (ohne Anchor/Label), führend/folgend getrimmt. - Keine aggressive Slug-Normalisierung (die kann später im Resolver erfolgen). + Extrahiert Wikilinks als einfache Liste von IDs. """ if not text: return [] @@ -222,12 +206,52 @@ def extract_wikilinks(text: str) -> List[str]: raw = (m.group(1) or "").strip() if not raw: continue - # Split an Pipe (Label) → links vor '|' if "|" in raw: raw = raw.split("|", 1)[0].strip() - # Split an Anchor if "#" in raw: raw = raw.split("#", 1)[0].strip() if raw: out.append(raw) return out + + +def extract_edges_with_context(parsed: ParsedNote) -> List[Dict[str, Any]]: + """ + WP-22: Extrahiert Wikilinks [[Ziel|Typ]] aus dem Body und speichert die Zeilennummer. + Gibt eine Liste von Dictionaries zurück, die direkt von der Ingestion verarbeitet werden können. + """ + edges = [] + if not parsed or not parsed.body: + return edges + + # Wir nutzen splitlines(True), um Zeilenumbrüche für die Positionsberechnung zu erhalten, + # oder einfaches splitlines() für die reine Zeilennummerierung. + lines = parsed.body.splitlines() + + for line_num, line_content in enumerate(lines, 1): + for match in _WIKILINK_RE.finditer(line_content): + raw = (match.group(1) or "").strip() + if not raw: + continue + + # Syntax: [[Ziel|Typ]] + if "|" in raw: + parts = raw.split("|", 1) + target = parts[0].strip() + kind = parts[1].strip() + else: + target = raw.strip() + kind = "related_to" # Default-Typ + + # Anchor (#) entfernen, da Relationen auf Notiz-Ebene (ID) basieren + if "#" in target: + target = target.split("#", 1)[0].strip() + + if target: + edges.append({ + "to": target, + "kind": kind, + "line": line_num, + "provenance": "explicit" + }) + return edges \ No newline at end of file From 2a98c37ca117a4dbe1f010d7db9fc1c3641be098 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 15:09:47 +0100 Subject: [PATCH 03/28] integration openrouter --- app/config.py | 33 +++--- app/services/llm_service.py | 204 ++++++++++++++++-------------------- requirements.txt | 5 +- 3 files changed, 113 insertions(+), 129 deletions(-) diff --git a/app/config.py b/app/config.py index 5774a53..2f1617b 100644 --- a/app/config.py +++ b/app/config.py @@ -1,11 +1,9 @@ """ FILE: app/config.py -DESCRIPTION: Zentrale Pydantic-Konfiguration (Env-Vars für Qdrant, LLM, Retriever). - Erweitert um WP-20 Hybrid-Optionen. -VERSION: 0.5.0 +DESCRIPTION: Zentrale Pydantic-Konfiguration. Enthält alle Parameter für Qdrant, + lokale Embeddings, Ollama, Google GenAI und OpenRouter. +VERSION: 0.6.0 (WP-20 Full Hybrid Integration) STATUS: Active -DEPENDENCIES: os, functools, pathlib -LAST_ANALYSIS: 2025-12-23 """ from __future__ import annotations import os @@ -13,38 +11,47 @@ from functools import lru_cache from pathlib import Path class Settings: - # Qdrant Verbindung + # --- Qdrant Datenbank --- 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")) DISTANCE: str = os.getenv("MINDNET_DISTANCE", "Cosine") - # Embeddings (lokal) + # --- Lokale Embeddings --- MODEL_NAME: str = os.getenv("MINDNET_MODEL", "sentence-transformers/all-MiniLM-L6-v2") - # WP-20 Hybrid LLM Provider - # Erlaubt: "ollama" oder "gemini" + # --- WP-20 Cloud Hybrid Mode (Google GenAI & OpenRouter) --- + # Erlaubt: "ollama" | "gemini" | "openrouter" MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "ollama").lower() + + # 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") # Für Ingestion-Speed + + # 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") + LLM_FALLBACK_ENABLED: bool = os.getenv("MINDNET_LLM_FALLBACK", "true").lower() == "true" - # WP-05 LLM / Ollama (Local) + # --- WP-05 Lokales LLM (Ollama) --- OLLAMA_URL: str = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") LLM_MODEL: str = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") PROMPTS_PATH: str = os.getenv("MINDNET_PROMPTS_PATH", "config/prompts.yaml") - # WP-06 / WP-14 Performance & Timeouts + # --- WP-06 / WP-14 Performance & Last-Steuerung --- LLM_TIMEOUT: float = float(os.getenv("MINDNET_LLM_TIMEOUT", "120.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")) - # API & Debugging + # --- System-Pfade --- 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-04 Retriever Gewichte (Semantik vs. Graph) + # --- 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/services/llm_service.py b/app/services/llm_service.py index df4cdd1..a0f4ac5 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,20 +1,17 @@ """ FILE: app/services/llm_service.py -DESCRIPTION: Hybrid-Client für Ollama & Google Gemini. - Verwaltet Prompts, Background-Last (Semaphore) und Cloud-Routing. -VERSION: 3.1.0 (WP-20 Full Integration: Provider-Aware Prompting) -STATUS: Active -DEPENDENCIES: httpx, yaml, asyncio, google-generativeai, app.config -EXTERNAL_CONFIG: config/prompts.yaml +DESCRIPTION: Hybrid-Client für Ollama, Google GenAI und OpenRouter. + Verwaltet provider-spezifische Prompts und Background-Last. +VERSION: 3.3.0 (Full SDK Integration) """ - import httpx import yaml import logging -import os import asyncio import json -import google.generativeai as genai +from google import genai +from google.genai import types +from openai import AsyncOpenAI # Für OpenRouter from pathlib import Path from typing import Optional, Dict, Any, Literal from app.config import get_settings @@ -22,122 +19,117 @@ from app.config import get_settings logger = logging.getLogger(__name__) class LLMService: - # GLOBALER SEMAPHOR für Hintergrund-Last Steuerung (WP-06 / WP-20) _background_semaphore = None def __init__(self): self.settings = get_settings() self.prompts = self._load_prompts() - # Initialisiere Semaphore einmalig auf Klassen-Ebene + # WP-06: Semaphore-Initialisierung if LLMService._background_semaphore is None: - limit = getattr(self.settings, "BACKGROUND_LIMIT", 2) - logger.info(f"🚦 LLMService: Initializing Background Semaphore with limit: {limit}") + limit = self.settings.BACKGROUND_LIMIT + logger.info(f"🚦 LLMService: Background Semaphore initialized with limit: {limit}") LLMService._background_semaphore = asyncio.Semaphore(limit) - # Ollama Setup - self.timeout = httpx.Timeout(self.settings.LLM_TIMEOUT, connect=10.0) + # 1. Lokaler Ollama Client self.ollama_client = httpx.AsyncClient( base_url=self.settings.OLLAMA_URL, - timeout=self.timeout + timeout=httpx.Timeout(self.settings.LLM_TIMEOUT) ) - # Gemini Setup [WP-20] - if hasattr(self.settings, "GOOGLE_API_KEY") and self.settings.GOOGLE_API_KEY: - genai.configure(api_key=self.settings.GOOGLE_API_KEY) - model_name = getattr(self.settings, "GEMINI_MODEL", "gemini-1.5-flash") - self.gemini_model = genai.GenerativeModel(model_name) - logger.info(f"✨ LLMService: Gemini Cloud Mode active ({model_name})") - else: - self.gemini_model = None - logger.warning("⚠️ LLMService: No GOOGLE_API_KEY found. Gemini mode disabled.") + # 2. Google GenAI Client (Modern SDK) + self.google_client = None + if self.settings.GOOGLE_API_KEY: + self.google_client = genai.Client(api_key=self.settings.GOOGLE_API_KEY) + logger.info("✨ LLMService: Google GenAI (Gemini) active.") + + # 3. OpenRouter Client + self.openrouter_client = None + if self.settings.OPENROUTER_API_KEY: + self.openrouter_client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=self.settings.OPENROUTER_API_KEY + ) + logger.info("🛰️ LLMService: OpenRouter Integration active.") 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 {} try: - with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) + 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}") return {} def get_prompt(self, key: str, provider: str = None) -> str: - """ - Wählt das Template basierend auf dem Provider aus (WP-20). - Unterstützt sowohl flache Strings als auch Dictionary-basierte Provider-Zweige. - """ - active_provider = provider or getattr(self.settings, "MINDNET_LLM_PROVIDER", "ollama") + """Hole provider-spezifisches Template mit Fallback-Kaskade.""" + active_provider = provider or self.settings.MINDNET_LLM_PROVIDER data = self.prompts.get(key, "") - if isinstance(data, dict): - # Versuche den Provider-Key, Fallback auf 'ollama' return data.get(active_provider, data.get("ollama", "")) return str(data) async def generate_raw_response( - self, - prompt: str, - system: str = None, - force_json: bool = False, - max_retries: int = 2, - base_delay: float = 2.0, + self, prompt: str, system: str = None, force_json: bool = False, + max_retries: int = 2, base_delay: float = 2.0, priority: Literal["realtime", "background"] = "realtime", - provider: Optional[str] = None + provider: Optional[str] = None, + model_override: Optional[str] = None ) -> str: - """ - Führt einen LLM Call aus mit Priority-Handling und Provider-Wahl. - """ - # Bestimme Provider: Parameter-Override > Config-Default - target_provider = provider or getattr(self.settings, "MINDNET_LLM_PROVIDER", "ollama") + """Einstiegspunkt mit Priority-Handling.""" + target_provider = provider or self.settings.MINDNET_LLM_PROVIDER - use_semaphore = (priority == "background") - - if use_semaphore and LLMService._background_semaphore: + if priority == "background": async with LLMService._background_semaphore: - return await self._dispatch_request(target_provider, prompt, system, force_json, max_retries, base_delay) - else: - return await self._dispatch_request(target_provider, prompt, system, force_json, max_retries, base_delay) + return await self._dispatch(target_provider, prompt, system, force_json, max_retries, base_delay, model_override) + return await self._dispatch(target_provider, prompt, system, force_json, max_retries, base_delay, model_override) - async def _dispatch_request(self, provider, prompt, system, force_json, max_retries, base_delay): - """Routet die Anfrage an den gewählten Provider mit Fallback-Logik.""" + async def _dispatch(self, provider, prompt, system, force_json, max_retries, base_delay, model_override): try: - if provider == "gemini" and self.gemini_model: - return await self._execute_gemini(prompt, system, force_json) - else: - return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) + if provider == "openrouter" and self.openrouter_client: + return await self._execute_openrouter(prompt, system, force_json, model_override) + if provider == "gemini" and self.google_client: + return await self._execute_google(prompt, system, force_json, model_override) + return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) except Exception as e: - # Automatischer Fallback auf Ollama bei Cloud-Fehlern (WP-20) - if provider == "gemini" and getattr(self.settings, "LLM_FALLBACK_ENABLED", True): - logger.warning(f"🔄 Gemini failed: {e}. Falling back to Ollama.") + if self.settings.LLM_FALLBACK_ENABLED and provider != "ollama": + logger.warning(f"🔄 Provider {provider} failed: {e}. Falling back to Ollama.") return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) raise e - async def _execute_gemini(self, prompt, system, force_json) -> str: - """Asynchroner Google Gemini Call (WP-20).""" - full_prompt = f"System: {system}\n\nUser: {prompt}" if system else prompt - - # Gemini JSON Mode Support - gen_config = {} - if force_json: - gen_config["response_mime_type"] = "application/json" - - response = await self.gemini_model.generate_content_async( - full_prompt, - generation_config=gen_config + async def _execute_google(self, prompt, system, force_json, model_override): + """Native Google SDK Integration.""" + model = model_override or self.settings.GEMINI_MODEL + config = types.GenerateContentConfig( + system_instruction=system, + response_mime_type="application/json" if force_json else "text/plain" + ) + # Synchroner SDK-Call in Thread auslagern + response = await asyncio.to_thread( + self.google_client.models.generate_content, + model=model, contents=prompt, config=config ) return response.text.strip() - async def _execute_ollama(self, prompt, system, force_json, max_retries, base_delay) -> str: - """Ollama Call mit exponentieller Backoff-Retry-Logik.""" - payload: Dict[str, Any] = { - "model": self.settings.LLM_MODEL, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.1 if force_json else 0.7, - "num_ctx": 8192 - } + async def _execute_openrouter(self, prompt, system, force_json, model_override): + """OpenRouter (OpenAI-kompatibel).""" + model = model_override or self.settings.OPENROUTER_MODEL + messages = [] + if system: messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + response = await self.openrouter_client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"} if force_json else None + ) + return response.choices[0].message.content.strip() + + async def _execute_ollama(self, prompt, system, force_json, max_retries, base_delay): + """Ollama mit exponentiellem Backoff.""" + payload = { + "model": self.settings.LLM_MODEL, "prompt": prompt, "stream": False, + "options": {"temperature": 0.1 if force_json else 0.7, "num_ctx": 8192} } if force_json: payload["format"] = "json" if system: payload["system"] = system @@ -145,41 +137,23 @@ class LLMService: attempt = 0 while True: try: - response = await self.ollama_client.post("/api/generate", json=payload) - if response.status_code == 200: - return response.json().get("response", "").strip() - response.raise_for_status() + res = await self.ollama_client.post("/api/generate", json=payload) + res.raise_for_status() + return res.json().get("response", "").strip() except Exception as e: attempt += 1 - if attempt > max_retries: - logger.error(f"Ollama Error after {attempt} retries: {e}") - raise e - # Exponentieller Backoff: base_delay * (2 ^ (attempt - 1)) - wait_time = base_delay * (2 ** (attempt - 1)) - logger.warning(f"⚠️ Ollama attempt {attempt} failed. Retrying in {wait_time}s...") - await asyncio.sleep(wait_time) + if attempt > max_retries: raise e + wait = base_delay * (2 ** (attempt - 1)) + logger.warning(f"⚠️ Ollama retry {attempt} in {wait}s...") + await asyncio.sleep(wait) async def generate_rag_response(self, query: str, context_str: str) -> str: - """Standard RAG Chat-Interface mit Provider-spezifischen Templates.""" - provider = getattr(self.settings, "MINDNET_LLM_PROVIDER", "ollama") - - # Holen der Templates über die neue get_prompt Methode - system_prompt = self.get_prompt("system_prompt", provider) - rag_template = self.get_prompt("rag_template", provider) - - # Fallback für RAG Template Struktur - if not rag_template: - rag_template = "{context_str}\n\n{query}" - - final_prompt = rag_template.format(context_str=context_str, query=query) - - return await self.generate_raw_response( - final_prompt, - system=system_prompt, - priority="realtime" - ) + """Vollständiger RAG-Wrapper.""" + provider = self.settings.MINDNET_LLM_PROVIDER + system = self.get_prompt("system_prompt", provider) + template = self.get_prompt("rag_template", provider) + final_prompt = template.format(context_str=context_str, query=query) + return await self.generate_raw_response(final_prompt, system=system, priority="realtime") async def close(self): - """Schließt alle offenen HTTP-Verbindungen.""" - if self.ollama_client: - await self.ollama_client.aclose() \ No newline at end of file + await self.ollama_client.aclose() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3e258e0..850ea6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,4 +37,7 @@ streamlit-agraph>=0.0.45 st-cytoscape # Google gemini API -google-generativeai>=0.8.3 \ No newline at end of file +google-generativeai>=0.8.3 + +# OpenAi für OpenRouter +openai>=1.50.0 \ No newline at end of file From c60aba63a4c7e23310e346be419867d73769c8a6 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 15:20:14 +0100 Subject: [PATCH 04/28] WP20 openrouter --- app/core/ingestion.py | 87 +++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index fdd63f9..1fbbf5e 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,7 +1,7 @@ """ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen (Notes, Chunks, Edges). - WP-20: Integration von Smart Edge Allocation via Hybrid LLM (Gemini/Ollama). + WP-20: Integration von Smart Edge Allocation via Hybrid LLM (Gemini/Gemma/OpenRouter). 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. @@ -22,7 +22,7 @@ from app.core.parser import ( read_markdown, normalize_frontmatter, validate_required_frontmatter, - extract_edges_with_context, # + extract_edges_with_context, # WP-22: Funktion für Zeilennummern ) 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 # +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 getattr(settings, "MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} @@ -100,17 +102,18 @@ def effective_retriever_weight(fm: dict, note_type: str, reg: dict) -> float: class IngestionService: def __init__(self, collection_prefix: str = None): from app.config import get_settings - self.settings = get_settings() # + self.settings = get_settings() self.prefix = collection_prefix or self.settings.COLLECTION_PREFIX self.cfg = QdrantConfig.from_env() self.cfg.prefix = self.prefix self.client = get_client(self.cfg) - self.dim = self.cfg.VECTOR_SIZE # - self.type_registry = load_type_registry() + self.dim = self.cfg.dim if hasattr(self.cfg, 'dim') else self.settings.VECTOR_SIZE + self.registry = load_type_registry() self.embedder = EmbeddingsClient() - self.llm = LLMService() # + self.llm = LLMService() # WP-20 Integration + # Change Detection Modus (full oder body) self.active_hash_mode = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") try: @@ -120,8 +123,8 @@ class IngestionService: logger.warning(f"DB init warning: {e}") def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: - """Holt die Chunker-Parameter für ein spezifisches Profil.""" - profiles = self.type_registry.get("chunking_profiles", {}) + """Holt die Chunker-Parameter (max, target, overlap) für ein spezifisches Profil.""" + profiles = self.registry.get("chunking_profiles", {}) if profile_name in profiles: cfg = profiles[profile_name].copy() if "overlap" in cfg and isinstance(cfg["overlap"], list): @@ -134,30 +137,24 @@ class IngestionService: WP-20: Nutzt den Hybrid LLM Service für die semantische Kanten-Extraktion. Verwendet provider-spezifische Prompts aus der config. """ - provider = self.settings.MINDNET_LLM_PROVIDER # + # Wir priorisieren Gemma für Ingestion, falls verfügbar (OpenRouter/Cloud) + model = getattr(self.settings, "GEMMA_MODEL", None) + provider = self.settings.MINDNET_LLM_PROVIDER - # Prompt-Lookup (Fallback auf Standard-Struktur falls Key fehlt) - prompt_data = self.llm.prompts.get("edge_extraction", {}) - if isinstance(prompt_data, dict): - template = prompt_data.get(provider, prompt_data.get("ollama", "")) - else: - template = str(prompt_data) - - if not template: - template = "Extrahiere semantische Relationen aus: {text}. Antworte als JSON: [{\"to\": \"X\", \"kind\": \"Y\"}]" - + template = self.llm.get_prompt("edge_extraction") prompt = template.format(text=text[:6000], note_id=note_id) try: - # Nutzt die Semaphore für Hintergrund-Tasks + # Hintergrund-Task mit Semaphore response_json = await self.llm.generate_raw_response( prompt=prompt, priority="background", - force_json=True + force_json=True, + model_override=model ) data = json.loads(response_json) - # Anreicherung mit Provenance-Metadaten für WP-22 Registry + # Provenance für die EdgeRegistry for item in data: item["provenance"] = "semantic_ai" item["line"] = f"ai-{provider}" @@ -197,16 +194,15 @@ class IngestionService: return {**result, "status": "skipped", "reason": f"lifecycle_status_{status}"} # 2. Type & Config Resolution - note_type = resolve_note_type(fm.get("type"), self.type_registry) + note_type = resolve_note_type(fm.get("type"), self.registry) fm["type"] = note_type - - effective_profile = effective_chunk_profile_name(fm, note_type, self.type_registry) - effective_weight = effective_retriever_weight(fm, note_type, self.type_registry) + effective_profile = effective_chunk_profile_name(fm, note_type, self.registry) + effective_weight = effective_retriever_weight(fm, note_type, self.registry) fm["chunk_profile"] = effective_profile fm["retriever_weight"] = effective_weight - # 3. Build Note Payload (Inkl. Multi-Hash für WP-22) + # 3. Build Note Payload try: note_pl = make_note_payload(parsed, vault_root=vault_root, hash_normalize=hash_normalize, hash_source=hash_source, file_path=file_path) if not note_pl.get("fulltext"): note_pl["fulltext"] = getattr(parsed, "body", "") or "" @@ -244,7 +240,7 @@ class IngestionService: # 5. Processing (Chunking, Embedding, Edge Generation) try: body_text = getattr(parsed, "body", "") or "" - edge_registry.ensure_latest() # + edge_registry.ensure_latest() chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) @@ -255,11 +251,11 @@ class IngestionService: texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] vecs = await self.embedder.embed_documents(texts) - # --- WP-22: Kanten-Extraktion & Validierung --- + # --- WP-22/WP-20: Kanten-Extraktion & Validierung --- edges = [] context = {"file": file_path, "note_id": note_id} - # A. Explizite User-Kanten mit Zeilennummern + # A. Explizite User-Kanten explicit_edges = extract_edges_with_context(parsed) for e in explicit_edges: e["kind"] = edge_registry.resolve(edge_type=e["kind"], provenance="explicit", context={**context, "line": e.get("line")}) @@ -271,20 +267,24 @@ class IngestionService: 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 (Struktur: belongs_to, next, prev) - raw_system_edges = build_edges_for_note(note_id, chunk_pls, note_level_references=note_pl.get("references", [])) + # C. System-Kanten + 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: + raw_system_edges = build_edges_for_note(note_id, chunk_pls) + for e in raw_system_edges: - e["kind"] = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"}) - if e["kind"]: edges.append(e) + valid_kind = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"}) + e["kind"] = valid_kind + if valid_kind: edges.append(e) except Exception as e: logger.error(f"Processing failed: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} - # 6. Upsert in Qdrant + # 6. Upsert try: - if purge_before and has_old: - self._purge_artifacts(note_id) + if purge_before and has_old: self._purge_artifacts(note_id) n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) upsert_batch(self.client, n_name, n_pts) @@ -297,10 +297,7 @@ class IngestionService: e_name, e_pts = points_for_edges(self.prefix, edges) upsert_batch(self.client, e_name, e_pts) - return { - "path": file_path, "status": "success", "changed": True, "note_id": note_id, - "chunks_count": len(chunk_pls), "edges_count": len(edges) - } + 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) return {**result, "error": f"DB Upsert failed: {e}"} @@ -331,8 +328,7 @@ class IngestionService: f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) selector = rest.FilterSelector(filter=f) for suffix in ["chunks", "edges"]: - try: - self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=selector) + try: self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=selector) except Exception: pass async def create_from_text(self, markdown_content: str, filename: str, vault_root: str, folder: str = "00_Inbox") -> Dict[str, Any]: @@ -346,6 +342,7 @@ class IngestionService: 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 From 36fb27edf0a64aee0933e1f74807b74f61d92bda Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 15:55:06 +0100 Subject: [PATCH 05/28] komplette openrouter integration --- app/config.py | 18 ++++---- app/core/ingestion.py | 32 +++++++------ app/services/llm_service.py | 89 +++++++++++++++++++++++++------------ config/prompts.yaml | 10 ++++- 4 files changed, 96 insertions(+), 53 deletions(-) diff --git a/app/config.py b/app/config.py index 2f1617b..df178ab 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,8 @@ """ FILE: app/config.py -DESCRIPTION: Zentrale Pydantic-Konfiguration. Enthält alle Parameter für Qdrant, - lokale Embeddings, Ollama, Google GenAI und OpenRouter. -VERSION: 0.6.0 (WP-20 Full Hybrid Integration) +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) STATUS: Active """ from __future__ import annotations @@ -21,16 +21,16 @@ class Settings: # --- Lokale Embeddings --- MODEL_NAME: str = os.getenv("MINDNET_MODEL", "sentence-transformers/all-MiniLM-L6-v2") - # --- WP-20 Cloud Hybrid Mode (Google GenAI & OpenRouter) --- - # Erlaubt: "ollama" | "gemini" | "openrouter" + # --- WP-20 Hybrid LLM Provider --- + # Optionen: "ollama" | "gemini" | "openrouter" MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "ollama").lower() - # Google AI Studio (Direkt) + # Google AI Studio 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") # Für Ingestion-Speed + GEMMA_MODEL: str = os.getenv("MINDNET_GEMMA_MODEL", "gemma2-9b-it") - # OpenRouter Integration + # OpenRouter OPENROUTER_API_KEY: str | None = os.getenv("OPENROUTER_API_KEY") OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "google/gemma-2-9b-it:free") @@ -51,7 +51,7 @@ class Settings: MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault") MINDNET_TYPES_FILE: str = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") - # --- WP-04 Retriever Gewichte (Semantik vs. Graph) --- + # --- WP-04 Retriever Gewichte --- 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 1fbbf5e..e042de2 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.0 (WP-20 Full Integration: Hybrid Smart Edges) +VERSION: 2.11.1 (WP-20 Quota Protection: OpenRouter Priority) 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 @@ -111,7 +111,7 @@ class IngestionService: self.dim = self.cfg.dim if hasattr(self.cfg, 'dim') else self.settings.VECTOR_SIZE self.registry = load_type_registry() self.embedder = EmbeddingsClient() - self.llm = LLMService() # WP-20 Integration + self.llm = LLMService() # Change Detection Modus (full oder body) self.active_hash_mode = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") @@ -135,32 +135,36 @@ 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. - Verwendet provider-spezifische Prompts aus der config. + QUOTEN-SCHUTZ: Priorisiert OpenRouter (Gemma), um Gemini-Tageslimits zu schonen. """ - # Wir priorisieren Gemma für Ingestion, falls verfügbar (OpenRouter/Cloud) - model = getattr(self.settings, "GEMMA_MODEL", None) - provider = self.settings.MINDNET_LLM_PROVIDER + # Bestimme den Provider für die Ingestion (OpenRouter bevorzugt, falls Key vorhanden) + provider = "openrouter" if getattr(self.settings, "OPENROUTER_API_KEY", None) else self.settings.MINDNET_LLM_PROVIDER - template = self.llm.get_prompt("edge_extraction") + # Nutze Gemma-Modell für hohe Ingestion-Quoten (14.4K RPD) via OpenRouter oder Google + model = getattr(self.settings, "GEMMA_MODEL", None) + + # Hole Prompt aus der YAML (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 + # Hintergrund-Task mit Semaphore via LLMService response_json = await self.llm.generate_raw_response( prompt=prompt, priority="background", force_json=True, + provider=provider, model_override=model ) data = json.loads(response_json) - # Provenance für die EdgeRegistry + # Provenance für die EdgeRegistry Dokumentation 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 skipped for {note_id}: {e}") + logger.warning(f"Smart Edge Allocation failed for {note_id} on {provider}: {e}") return [] async def process_file( @@ -214,7 +218,7 @@ class IngestionService: logger.error(f"Payload build failed: {e}") return {**result, "error": f"Payload build failed: {str(e)}"} - # 4. Change Detection + # 4. Change Detection (Multi-Hash) old_payload = None if not force_replace: old_payload = self._fetch_note_payload(note_id) @@ -255,7 +259,7 @@ class IngestionService: edges = [] context = {"file": file_path, "note_id": note_id} - # A. Explizite User-Kanten + # A. Explizite User-Kanten (Wiki-Links) explicit_edges = extract_edges_with_context(parsed) for e in explicit_edges: e["kind"] = edge_registry.resolve(edge_type=e["kind"], provenance="explicit", context={**context, "line": e.get("line")}) @@ -267,7 +271,7 @@ class IngestionService: 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 (Graph-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: @@ -282,7 +286,7 @@ class IngestionService: logger.error(f"Processing failed: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} - # 6. Upsert + # 6. Upsert in Qdrant try: if purge_before and has_old: self._purge_artifacts(note_id) diff --git a/app/services/llm_service.py b/app/services/llm_service.py index a0f4ac5..ecb30c4 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,8 +1,10 @@ """ FILE: app/services/llm_service.py -DESCRIPTION: Hybrid-Client für Ollama, Google GenAI und OpenRouter. +DESCRIPTION: Hybrid-Client für Ollama, Google GenAI (Gemini) und OpenRouter. Verwaltet provider-spezifische Prompts und Background-Last. -VERSION: 3.3.0 (Full SDK Integration) + WP-20: Optimiertes Fallback-Management zum Schutz von Cloud-Quoten. +VERSION: 3.3.1 +STATUS: Active """ import httpx import yaml @@ -11,7 +13,7 @@ import asyncio import json from google import genai from google.genai import types -from openai import AsyncOpenAI # Für OpenRouter +from openai import AsyncOpenAI # Für OpenRouter (OpenAI-kompatibel) from pathlib import Path from typing import Optional, Dict, Any, Literal from app.config import get_settings @@ -19,16 +21,17 @@ from app.config import get_settings logger = logging.getLogger(__name__) class LLMService: + # GLOBALER SEMAPHOR für Hintergrund-Last Steuerung (WP-06) _background_semaphore = None def __init__(self): self.settings = get_settings() self.prompts = self._load_prompts() - # WP-06: Semaphore-Initialisierung + # Initialisiere Semaphore einmalig auf Klassen-Ebene if LLMService._background_semaphore is None: - limit = self.settings.BACKGROUND_LIMIT - logger.info(f"🚦 LLMService: Background Semaphore initialized with limit: {limit}") + limit = getattr(self.settings, "BACKGROUND_LIMIT", 2) + logger.info(f"🚦 LLMService: Initializing Background Semaphore with limit: {limit}") LLMService._background_semaphore = asyncio.Semaphore(limit) # 1. Lokaler Ollama Client @@ -53,6 +56,7 @@ class LLMService: logger.info("🛰️ LLMService: OpenRouter Integration active.") 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 {} try: @@ -62,11 +66,16 @@ class LLMService: return {} def get_prompt(self, key: str, provider: str = None) -> str: - """Hole provider-spezifisches Template mit Fallback-Kaskade.""" + """ + 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). + """ active_provider = provider or self.settings.MINDNET_LLM_PROVIDER data = self.prompts.get(key, "") if isinstance(data, dict): - return data.get(active_provider, data.get("ollama", "")) + # Wir versuchen erst den Provider, dann Gemini (weil ähnlich leistungsfähig), dann Ollama + return data.get(active_provider, data.get("gemini", data.get("ollama", ""))) return str(data) async def generate_raw_response( @@ -76,35 +85,43 @@ class LLMService: provider: Optional[str] = None, model_override: Optional[str] = None ) -> str: - """Einstiegspunkt mit Priority-Handling.""" + """Haupteinstiegspunkt für LLM-Anfragen mit Priorisierung.""" target_provider = provider or self.settings.MINDNET_LLM_PROVIDER if priority == "background": async with LLMService._background_semaphore: return await self._dispatch(target_provider, prompt, system, force_json, max_retries, base_delay, model_override) + return await self._dispatch(target_provider, prompt, system, force_json, max_retries, base_delay, model_override) async def _dispatch(self, provider, prompt, system, force_json, max_retries, base_delay, model_override): + """Routet die Anfrage an den physikalischen API-Provider.""" try: if provider == "openrouter" and self.openrouter_client: return await self._execute_openrouter(prompt, system, force_json, model_override) + if provider == "gemini" and self.google_client: return await self._execute_google(prompt, system, force_json, model_override) + + # Default/Fallback zu Ollama return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) + except Exception as e: + # QUOTEN-SCHUTZ: Wenn Cloud (OpenRouter/Gemini) fehlschlägt, + # gehen wir IMMER zu Ollama, niemals von OpenRouter zu Gemini. if self.settings.LLM_FALLBACK_ENABLED and provider != "ollama": - logger.warning(f"🔄 Provider {provider} failed: {e}. Falling back to Ollama.") + logger.warning(f"🔄 Provider {provider} failed: {e}. Falling back to LOCAL OLLAMA to protect cloud quotas.") return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) raise e async def _execute_google(self, prompt, system, force_json, model_override): - """Native Google SDK Integration.""" + """Native Google SDK Integration (Gemini).""" model = model_override or self.settings.GEMINI_MODEL config = types.GenerateContentConfig( system_instruction=system, response_mime_type="application/json" if force_json else "text/plain" ) - # Synchroner SDK-Call in Thread auslagern + # SDK Call in Thread auslagern, da die Google API blocking sein kann response = await asyncio.to_thread( self.google_client.models.generate_content, model=model, contents=prompt, config=config @@ -112,10 +129,11 @@ class LLMService: return response.text.strip() async def _execute_openrouter(self, prompt, system, force_json, model_override): - """OpenRouter (OpenAI-kompatibel).""" - model = model_override or self.settings.OPENROUTER_MODEL + """OpenRouter API Integration (OpenAI-kompatibel).""" + model = model_override or getattr(self.settings, "OPENROUTER_MODEL", "google/gemma-2-9b-it:free") messages = [] - if system: messages.append({"role": "system", "content": system}) + if system: + messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) response = await self.openrouter_client.chat.completions.create( @@ -126,10 +144,15 @@ class LLMService: return response.choices[0].message.content.strip() async def _execute_ollama(self, prompt, system, force_json, max_retries, base_delay): - """Ollama mit exponentiellem Backoff.""" + """Lokaler Ollama Call mit exponentiellem Backoff.""" payload = { - "model": self.settings.LLM_MODEL, "prompt": prompt, "stream": False, - "options": {"temperature": 0.1 if force_json else 0.7, "num_ctx": 8192} + "model": self.settings.LLM_MODEL, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.1 if force_json else 0.7, + "num_ctx": 8192 + } } if force_json: payload["format"] = "json" if system: payload["system"] = system @@ -142,18 +165,28 @@ class LLMService: return res.json().get("response", "").strip() except Exception as e: attempt += 1 - if attempt > max_retries: raise e - wait = base_delay * (2 ** (attempt - 1)) - logger.warning(f"⚠️ Ollama retry {attempt} in {wait}s...") - await asyncio.sleep(wait) + if attempt > max_retries: + 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...") + await asyncio.sleep(wait_time) async def generate_rag_response(self, query: str, context_str: str) -> str: - """Vollständiger RAG-Wrapper.""" + """Vollständiges RAG Chat-Interface.""" provider = self.settings.MINDNET_LLM_PROVIDER - system = self.get_prompt("system_prompt", provider) - template = self.get_prompt("rag_template", provider) - final_prompt = template.format(context_str=context_str, query=query) - return await self.generate_raw_response(final_prompt, system=system, priority="realtime") + system_prompt = self.get_prompt("system_prompt", provider) + rag_template = self.get_prompt("rag_template", provider) + + final_prompt = rag_template.format(context_str=context_str, query=query) + + return await self.generate_raw_response( + final_prompt, + system=system_prompt, + priority="realtime" + ) async def close(self): - await self.ollama_client.aclose() \ No newline at end of file + """Schließt die HTTP-Verbindungen.""" + if self.ollama_client: + await self.ollama_client.aclose() \ No newline at end of file diff --git a/config/prompts.yaml b/config/prompts.yaml index 5574383..f52d0bc 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -32,6 +32,8 @@ rag_template: Analysiere diesen Kontext meines digitalen Zwillings: {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}" + # --------------------------------------------------------- # 2. DECISION: Strategie & Abwägung (Intent: DECISION) @@ -59,7 +61,7 @@ decision_template: 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}" # --------------------------------------------------------- # 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY) # --------------------------------------------------------- @@ -178,4 +180,8 @@ edge_allocation_template: Extrahiere semantische Kanten für den Graphen ({note_id}). Finde auch implizite Verbindungen. JSON: [{"to": "X", "kind": "Y", "reason": "Z"}]. - TEXT: {text} \ No newline at end of file + TEXT: {text} + openrouter: | + Analysiere den Text für den Graphen. Identifiziere semantische Verbindungen. + Output JSON: [{"to": "X", "kind": "Y"}]. + Text: {text} \ No newline at end of file From 18780e5330c93a83ffcdef03e5903f8ff4dae7ba Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 15:57:50 +0100 Subject: [PATCH 06/28] Anpassung WP20 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 850ea6d..793b922 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ streamlit-agraph>=0.0.45 st-cytoscape # Google gemini API -google-generativeai>=0.8.3 +google-genai>=0.3.0 # OpenAi für OpenRouter openai>=1.50.0 \ No newline at end of file From 49b454d2ec966a50e819f6ff5a1c264c02f5cb9d Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 16:05:40 +0100 Subject: [PATCH 07/28] edge_registry hergestellt --- app/services/edge_registry.py | 102 ++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 30 deletions(-) diff --git a/app/services/edge_registry.py b/app/services/edge_registry.py index 4e9cb85..2859baa 100644 --- a/app/services/edge_registry.py +++ b/app/services/edge_registry.py @@ -1,16 +1,15 @@ """ FILE: app/services/edge_registry.py -DESCRIPTION: Single Source of Truth für Kanten-Typen. - FIX: Regex angepasst auf Format **`canonical`** (Bold + Backticks). -VERSION: 0.6.10 (Regex Precision Update) +DESCRIPTION: Single Source of Truth für Kanten-Typen mit dynamischem Reload. + WP-22: Transparente Status-Meldungen für Dev-Umgebungen. +VERSION: 0.7.2 (Fix: Restore Console Visibility & Entry Counts) """ import re import os import json import logging -from typing import Dict, Optional, Set - -print(">>> MODULE_LOAD: edge_registry.py initialized <<<", flush=True) +import time +from typing import Dict, Optional, Set, Tuple from app.config import get_settings @@ -18,6 +17,8 @@ logger = logging.getLogger(__name__) class EdgeRegistry: _instance = None + # System-Kanten, die NIEMALS manuell im Markdown stehen dürfen + FORBIDDEN_SYSTEM_EDGES = {"next", "prev", "belongs_to"} def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -33,31 +34,50 @@ class EdgeRegistry: env_vocab_path = os.getenv("MINDNET_VOCAB_PATH") env_vault_root = os.getenv("MINDNET_VAULT_ROOT") or getattr(settings, "MINDNET_VAULT_ROOT", "./vault") + # Pfad-Priorität: 1. ENV -> 2. _system/dictionary -> 3. 01_User_Manual if env_vocab_path: self.full_vocab_path = os.path.abspath(env_vocab_path) else: - self.full_vocab_path = os.path.abspath( + 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 - self._load_vocabulary() + # Initialer Lade-Versuch mit Konsolen-Feedback + print(f"\n>>> [EDGE-REGISTRY] Initializing with Path: {self.full_vocab_path}", flush=True) + self.ensure_latest() self.initialized = True - def _load_vocabulary(self): - """Parst die Markdown-Tabelle im Vault.""" - print(f">>> CHECK: Loading Vocabulary from {self.full_vocab_path}", flush=True) - + def ensure_latest(self): + """Prüft den Zeitstempel und lädt bei Bedarf neu.""" if not os.path.exists(self.full_vocab_path): - print(f"!!! [DICT-ERROR] File not found: {self.full_vocab_path} !!!", flush=True) + print(f"!!! [EDGE-REGISTRY ERROR] File not found: {self.full_vocab_path} !!!", flush=True) return - # WP-22 Precision Regex: - # Sucht nach | **`typ`** | oder | **typ** | - # Die Backticks `? sind jetzt optional enthalten. + current_mtime = os.path.getmtime(self.full_vocab_path) + if current_mtime > self._last_mtime: + self._load_vocabulary() + self._last_mtime = current_mtime + + def _load_vocabulary(self): + """Parst das Wörterbuch und meldet die Anzahl der gelesenen Einträge.""" + self.canonical_map.clear() + self.valid_types.clear() + + # Regex deckt | **canonical** | Aliase | ab pattern = re.compile(r"\|\s*\*\*`?([a-zA-Z0-9_-]+)`?\*\*\s*\|\s*([^|]+)\|") try: @@ -74,36 +94,58 @@ class EdgeRegistry: c_types += 1 if aliases_str and "Kein Alias" not in aliases_str: - # Aliase säubern (entfernt Backticks auch hier) aliases = [a.strip() for a in aliases_str.split(",") if a.strip()] for alias in aliases: + # Normalisierung: Kleinschreibung und Unterstriche clean_alias = alias.replace("`", "").lower().strip().replace(" ", "_") self.canonical_map[clean_alias] = canonical c_aliases += 1 - if c_types == 0: - print("!!! [DICT-WARN] Pattern mismatch! Ensure types are **`canonical`** or **canonical**. !!!", flush=True) - else: - print(f"=== [DICT-SUCCESS] Registered {c_types} Canonical Types and {c_aliases} Aliases ===", flush=True) + # Erfolgskontrolle für das Dev-Terminal + print(f"=== [EDGE-REGISTRY SUCCESS] Loaded {c_types} Canonical Types and {c_aliases} Aliases ===", flush=True) + logger.info(f"Registry reloaded from {self.full_vocab_path}") except Exception as e: - print(f"!!! [DICT-FATAL] Error reading file: {e} !!!", flush=True) + print(f"!!! [EDGE-REGISTRY FATAL] Error reading file: {e} !!!", flush=True) + logger.error(f"Error reading vocabulary: {e}") - def resolve(self, edge_type: str) -> str: - """Normalisiert Kanten-Typen via Registry oder loggt Unbekannte.""" + def resolve(self, edge_type: str, provenance: str = "explicit", context: dict = None) -> str: + """Validierung mit Fundort-Logging.""" + self.ensure_latest() if not edge_type: return "related_to" - clean_type = edge_type.lower().strip().replace(" ", "_").replace("-", "_") + clean_type = edge_type.lower().strip().replace(" ", "_").replace("-", "_") + ctx = context or {} + + # 1. Schutz der Systemkanten (Verbot für manuelle Nutzung) + if provenance == "explicit" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: + self._log_issue(clean_type, "forbidden_system_usage", ctx) + return "related_to" + + # 2. Akzeptanz interner Strukturkanten + if provenance == "structure" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: + return clean_type + + # 3. Mapping via Wörterbuch if clean_type in self.canonical_map: return self.canonical_map[clean_type] - self._log_unknown(clean_type) + # 4. Unbekannte Kante + self._log_issue(clean_type, "unknown_type", ctx) return clean_type - def _log_unknown(self, edge_type: str): + def _log_issue(self, edge_type: str, error_kind: str, ctx: dict): + """Detailliertes JSONL-Logging für Debugging.""" try: os.makedirs(os.path.dirname(self.unknown_log_path), exist_ok=True) - entry = {"unknown_type": edge_type, "status": "new"} + entry = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "edge_type": edge_type, + "error": error_kind, + "file": ctx.get("file", "unknown"), + "line": ctx.get("line", "unknown"), + "note_id": ctx.get("note_id", "unknown") + } with open(self.unknown_log_path, "a", encoding="utf-8") as f: f.write(json.dumps(entry) + "\n") except Exception: pass From a733212c0f7dd16db37e9f7d157487da22c6d407 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 17:56:44 +0100 Subject: [PATCH 08/28] neue Wp20 --- app/core/ingestion.py | 63 +++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 41 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index e042de2..a2dbaf5 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.1 (WP-20 Quota Protection: OpenRouter Priority) +VERSION: 2.11.3 (WP-20 Quota Protection & Stability Patch) 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: Funktion für Zeilennummern + extract_edges_with_context, # WP-22: Neue Funktion für Zeilennummern ) 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 +from app.services.llm_service import LLMService # WP-20 Integration logger = logging.getLogger(__name__) @@ -52,9 +52,7 @@ 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 - from app.config import get_settings - settings = get_settings() - path = custom_path or getattr(settings, "MINDNET_TYPES_FILE", "config/types.yaml") + path = custom_path or os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} @@ -111,7 +109,7 @@ class IngestionService: self.dim = self.cfg.dim if hasattr(self.cfg, 'dim') else self.settings.VECTOR_SIZE self.registry = load_type_registry() self.embedder = EmbeddingsClient() - self.llm = LLMService() + self.llm = LLMService() # WP-20 # Change Detection Modus (full oder body) self.active_hash_mode = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") @@ -135,15 +133,15 @@ 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: Priorisiert OpenRouter (Gemma), um Gemini-Tageslimits zu schonen. + QUOTEN-SCHUTZ: Bevorzugt OpenRouter (Gemma), um Gemini-Tageslimits zu schützen. """ - # Bestimme den Provider für die Ingestion (OpenRouter bevorzugt, falls Key vorhanden) + # 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 - # Nutze Gemma-Modell für hohe Ingestion-Quoten (14.4K RPD) via OpenRouter oder Google + # Nutze Gemma-Modell für hohen Durchsatz via OpenRouter/Google model = getattr(self.settings, "GEMMA_MODEL", None) - # Hole Prompt aus der YAML (Kaskade: Provider -> gemini -> ollama) + # Hole das optimierte Prompt-Template (Key: edge_extraction) template = self.llm.get_prompt("edge_extraction", provider) prompt = template.format(text=text[:6000], note_id=note_id) @@ -158,13 +156,13 @@ class IngestionService: ) data = json.loads(response_json) - # Provenance für die EdgeRegistry Dokumentation + # 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} on {provider}: {e}") + logger.warning(f"Smart Edge Allocation failed for {note_id}: {e}") return [] async def process_file( @@ -194,7 +192,6 @@ class IngestionService: # --- WP-22: Content Lifecycle Gate --- status = fm.get("status", "draft").lower().strip() if status in ["system", "template", "archive", "hidden"]: - logger.info(f"Skipping file {file_path} (Status: {status})") return {**result, "status": "skipped", "reason": f"lifecycle_status_{status}"} # 2. Type & Config Resolution @@ -215,7 +212,6 @@ class IngestionService: note_pl["status"] = status note_id = note_pl["note_id"] except Exception as e: - logger.error(f"Payload build failed: {e}") return {**result, "error": f"Payload build failed: {str(e)}"} # 4. Change Detection (Multi-Hash) @@ -244,7 +240,10 @@ class IngestionService: # 5. Processing (Chunking, Embedding, Edge Generation) try: body_text = getattr(parsed, "body", "") or "" - edge_registry.ensure_latest() + + # STABILITY PATCH: Prüfen, ob ensure_latest existiert (verhindert AttributeError) + if hasattr(edge_registry, "ensure_latest"): + edge_registry.ensure_latest() chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) @@ -259,7 +258,7 @@ class IngestionService: edges = [] context = {"file": file_path, "note_id": note_id} - # A. Explizite User-Kanten (Wiki-Links) + # A. Explizite User-Kanten explicit_edges = extract_edges_with_context(parsed) for e in explicit_edges: e["kind"] = edge_registry.resolve(edge_type=e["kind"], provenance="explicit", context={**context, "line": e.get("line")}) @@ -271,7 +270,7 @@ class IngestionService: 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 (Graph-Struktur) + # C. System-Kanten 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: @@ -279,14 +278,15 @@ class IngestionService: for e in raw_system_edges: valid_kind = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"}) - e["kind"] = valid_kind - if valid_kind: edges.append(e) + if valid_kind: + e["kind"] = valid_kind + edges.append(e) except Exception as e: logger.error(f"Processing failed: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} - # 6. Upsert in Qdrant + # 6. Upsert try: if purge_before and has_old: self._purge_artifacts(note_id) @@ -307,7 +307,6 @@ class IngestionService: return {**result, "error": f"DB Upsert failed: {e}"} def _fetch_note_payload(self, note_id: str) -> Optional[dict]: - """Holt das aktuelle Payload einer Note aus Qdrant.""" from qdrant_client.http import models as rest col = f"{self.prefix}_notes" try: @@ -317,7 +316,6 @@ class IngestionService: except: return None def _artifacts_missing(self, note_id: str) -> Tuple[bool, bool]: - """Prüft, ob Chunks oder Kanten für eine Note fehlen.""" from qdrant_client.http import models as rest try: f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) @@ -327,26 +325,9 @@ class IngestionService: except: return True, True def _purge_artifacts(self, note_id: str): - """Löscht alle Chunks und Edges einer Note.""" from qdrant_client.http import models as rest f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) 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 - - 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 + except Exception: pass \ No newline at end of file From a5b4dfb31f605cb4a02d0c1ec874a5c726f3aa30 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 18:07:24 +0100 Subject: [PATCH 09/28] edge_registry --- app/services/edge_registry.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/app/services/edge_registry.py b/app/services/edge_registry.py index 2859baa..5157d55 100644 --- a/app/services/edge_registry.py +++ b/app/services/edge_registry.py @@ -1,8 +1,8 @@ """ FILE: app/services/edge_registry.py DESCRIPTION: Single Source of Truth für Kanten-Typen mit dynamischem Reload. - WP-22: Transparente Status-Meldungen für Dev-Umgebungen. -VERSION: 0.7.2 (Fix: Restore Console Visibility & Entry Counts) + WP-22: Transparente Status-Meldungen & Robuste Pfad-Auflösung. +VERSION: 0.7.3 (Fix: Path Normalization & Env Priority) """ import re import os @@ -17,7 +17,6 @@ logger = logging.getLogger(__name__) class EdgeRegistry: _instance = None - # System-Kanten, die NIEMALS manuell im Markdown stehen dürfen FORBIDDEN_SYSTEM_EDGES = {"next", "prev", "belongs_to"} def __new__(cls, *args, **kwargs): @@ -31,10 +30,16 @@ class EdgeRegistry: return settings = get_settings() + # Hole ENV-Werte und entferne potenzielle Anführungszeichen 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("'") - # Pfad-Priorität: 1. ENV -> 2. _system/dictionary -> 3. 01_User_Manual + # Pfad-Priorität: 1. Direkter Pfad (ENV) -> 2. Vault-Struktur if env_vocab_path: self.full_vocab_path = os.path.abspath(env_vocab_path) else: @@ -56,7 +61,6 @@ class EdgeRegistry: self.valid_types: Set[str] = set() self._last_mtime = 0.0 - # Initialer Lade-Versuch mit Konsolen-Feedback print(f"\n>>> [EDGE-REGISTRY] Initializing with Path: {self.full_vocab_path}", flush=True) self.ensure_latest() self.initialized = True @@ -73,11 +77,10 @@ class EdgeRegistry: self._last_mtime = current_mtime def _load_vocabulary(self): - """Parst das Wörterbuch und meldet die Anzahl der gelesenen Einträge.""" + """Parst das Wörterbuch[cite: 25].""" self.canonical_map.clear() self.valid_types.clear() - # Regex deckt | **canonical** | Aliase | ab pattern = re.compile(r"\|\s*\*\*`?([a-zA-Z0-9_-]+)`?\*\*\s*\|\s*([^|]+)\|") try: @@ -96,18 +99,14 @@ 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 und Unterstriche clean_alias = alias.replace("`", "").lower().strip().replace(" ", "_") self.canonical_map[clean_alias] = canonical c_aliases += 1 - # Erfolgskontrolle für das Dev-Terminal print(f"=== [EDGE-REGISTRY SUCCESS] Loaded {c_types} Canonical Types and {c_aliases} Aliases ===", flush=True) - logger.info(f"Registry reloaded from {self.full_vocab_path}") except Exception as e: print(f"!!! [EDGE-REGISTRY FATAL] Error reading file: {e} !!!", flush=True) - logger.error(f"Error reading vocabulary: {e}") def resolve(self, edge_type: str, provenance: str = "explicit", context: dict = None) -> str: """Validierung mit Fundort-Logging.""" @@ -117,25 +116,21 @@ class EdgeRegistry: clean_type = edge_type.lower().strip().replace(" ", "_").replace("-", "_") ctx = context or {} - # 1. Schutz der Systemkanten (Verbot für manuelle Nutzung) if provenance == "explicit" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: self._log_issue(clean_type, "forbidden_system_usage", ctx) return "related_to" - # 2. Akzeptanz interner Strukturkanten if provenance == "structure" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: return clean_type - # 3. Mapping via Wörterbuch if clean_type in self.canonical_map: return self.canonical_map[clean_type] - # 4. Unbekannte Kante 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.""" + """JSONL-Logging[cite: 1].""" try: os.makedirs(os.path.dirname(self.unknown_log_path), exist_ok=True) entry = { From dcc30834554d54230d23725069612609f18aeada Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 18:11:05 +0100 Subject: [PATCH 10/28] pfad Anpassung --- app/services/edge_registry.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/services/edge_registry.py b/app/services/edge_registry.py index 5157d55..7c7220e 100644 --- a/app/services/edge_registry.py +++ b/app/services/edge_registry.py @@ -1,8 +1,8 @@ """ FILE: app/services/edge_registry.py DESCRIPTION: Single Source of Truth für Kanten-Typen mit dynamischem Reload. - WP-22: Transparente Status-Meldungen & Robuste Pfad-Auflösung. -VERSION: 0.7.3 (Fix: Path Normalization & Env Priority) + WP-22: Fix für absolute Pfade außerhalb des Vaults (Prod-Dictionary). +VERSION: 0.7.4 (Fix: Absolute Path Escaping & Quote Stripping) """ import re import os @@ -30,7 +30,8 @@ class EdgeRegistry: return settings = get_settings() - # Hole ENV-Werte und entferne potenzielle Anführungszeichen + + # 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("'") @@ -39,10 +40,16 @@ class EdgeRegistry: if env_vault_root: env_vault_root = env_vault_root.strip('"').strip("'") - # Pfad-Priorität: 1. Direkter Pfad (ENV) -> 2. Vault-Struktur + # 2. Pfad-Priorität: Wenn absolut (/), dann direkt nutzen! if env_vocab_path: - self.full_vocab_path = os.path.abspath(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") @@ -77,7 +84,7 @@ class EdgeRegistry: self._last_mtime = current_mtime def _load_vocabulary(self): - """Parst das Wörterbuch[cite: 25].""" + """Parst das Wörterbuch.""" self.canonical_map.clear() self.valid_types.clear() @@ -130,7 +137,7 @@ class EdgeRegistry: return clean_type def _log_issue(self, edge_type: str, error_kind: str, ctx: dict): - """JSONL-Logging[cite: 1].""" + """Detailliertes JSONL-Logging für Debugging.""" try: os.makedirs(os.path.dirname(self.unknown_log_path), exist_ok=True) entry = { From f1bfa40b5b4d4e4951bada9cb07c165302ee40c9 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 18:17:34 +0100 Subject: [PATCH 11/28] semantic semantic_analyzer angepasst --- app/services/semantic_analyzer.py | 55 +++++++++++++------------------ 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index 24ca205..97ae843 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -1,10 +1,11 @@ """ FILE: app/services/semantic_analyzer.py DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen. -VERSION: 2.1.0 (Fix: Strict Edge String Validation against LLM Hallucinations) + WP-20 Fix: Kompatibilität mit Provider-basierten Prompt-Dictionaries (Hybrid-Modus). +VERSION: 2.2.0 STATUS: Active DEPENDENCIES: app.services.llm_service, json, logging -LAST_ANALYSIS: 2025-12-16 +LAST_ANALYSIS: 2025-12-23 """ import json @@ -24,7 +25,7 @@ class SemanticAnalyzer: def _is_valid_edge_string(self, edge_str: str) -> bool: """ Prüft, ob ein String eine valide Kante im Format 'kind:target' ist. - Verhindert, dass LLM-Geschwätz ("Here is the list: ...") als Kante durchrutscht. + Verhindert, dass LLM-Geschwätz als Kante durchrutscht. """ if not isinstance(edge_str, str) or ":" not in edge_str: return False @@ -34,8 +35,6 @@ class SemanticAnalyzer: target = parts[1].strip() # Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten. - # Erlaubt: "derived_from", "related_to" - # Verboten: "derived end of instruction", "Here is the list" if " " in kind: return False @@ -54,19 +53,16 @@ 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. - Features: - - Retry Strategy: Wartet bei Überlastung (max_retries=5). - - Priority Queue: Läuft als "background" Task, um den Chat nicht zu blockieren. - - Observability: Loggt Input-Größe, Raw-Response und Parsing-Details. + WP-20 Fix: Nutzt get_prompt(), um den 'AttributeError: dict object' zu vermeiden. """ if not all_edges: return [] - # 1. Prompt laden - prompt_template = self.llm.prompts.get("edge_allocation_template") + # 1. Prompt laden via get_prompt (handelt die Provider-Kaskade automatisch ab) [WP-20 Fix] + prompt_template = self.llm.get_prompt("edge_allocation_template") - if not prompt_template: - logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' fehlt. Nutze Fallback.") + 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.") prompt_template = ( "TASK: Wähle aus den Kandidaten die relevanten Kanten für den Text.\n" "TEXT: {chunk_text}\n" @@ -80,14 +76,18 @@ class SemanticAnalyzer: # LOG: Request Info 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[:3500], - edge_list=edges_str - ) + # 3. Prompt füllen (Hier trat der AttributeError auf, wenn prompt_template ein dict war) + try: + final_prompt = prompt_template.format( + chunk_text=chunk_text[:3500], + edge_list=edges_str + ) + except Exception as format_err: + logger.error(f"❌ [SemanticAnalyzer] Format Error im Prompt-Template: {format_err}") + return [] try: - # 4. LLM Call mit Traffic Control + # 4. LLM Call mit Traffic Control (Background Priority) response_json = await self.llm.generate_raw_response( prompt=final_prompt, force_json=True, @@ -103,39 +103,30 @@ class SemanticAnalyzer: clean_json = response_json.replace("```json", "").replace("```", "").strip() if not clean_json: - logger.warning("⚠️ [SemanticAnalyzer] Leere Antwort vom LLM erhalten. Trigger Fallback.") + logger.warning("⚠️ [SemanticAnalyzer] Leere Antwort vom LLM erhalten.") return [] try: data = json.loads(clean_json) except json.JSONDecodeError as json_err: - logger.error(f"❌ [SemanticAnalyzer] JSON Decode Error.") - logger.error(f" Grund: {json_err}") - logger.error(f" Empfangener String: {clean_json[:500]}") - logger.info(" -> Workaround: Fallback auf 'Alle Kanten' (durch Chunker).") + logger.error(f"❌ [SemanticAnalyzer] JSON Decode Error: {json_err}") return [] valid_edges = [] # 6. Robuste Validierung (List vs Dict) - # Wir sammeln erst alle Strings ein raw_candidates = [] if isinstance(data, list): raw_candidates = data elif isinstance(data, dict): - logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur. Keys: {list(data.keys())}") + logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur.") for key, val in data.items(): - # Fall A: {"edges": ["kind:target"]} if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list): raw_candidates.extend(val) - - # Fall B: {"kind": "target"} (Beziehung als Key) elif isinstance(val, str): raw_candidates.append(f"{key}:{val}") - - # Fall C: {"kind": ["target1", "target2"]} elif isinstance(val, list): for target in val: if isinstance(target, str): @@ -149,10 +140,8 @@ class SemanticAnalyzer: else: logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'") - # Safety: Filtere nur Kanten, die halbwegs valide aussehen (Doppelcheck) final_result = [e for e in valid_edges if ":" in e] - # LOG: Ergebnis if final_result: logger.info(f"✅ [SemanticAnalyzer] Success. {len(final_result)} Kanten zugewiesen.") else: From 0157faab89188d41754961879993ac87a0b620a4 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 18:51:12 +0100 Subject: [PATCH 12/28] 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 From 2c073c7d3c5c606cd230129de6b7e60bb0299919 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 19:05:43 +0100 Subject: [PATCH 13/28] bug Fix --- app/config.py | 17 +++++++++-------- config/prompts.yaml | 31 +++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/app/config.py b/app/config.py index a1c9ed2..6e469c9 100644 --- a/app/config.py +++ b/app/config.py @@ -4,14 +4,20 @@ DESCRIPTION: Zentrale Pydantic-Konfiguration. Enthält Parameter für Qdrant, Embeddings, Ollama, Google GenAI und OpenRouter. WP-20: Optimiert für Hybrid-Cloud Modus und Vektor-Synchronisation. WP-22: Integration von Change-Detection und Vocab-Paths. -VERSION: 0.6.2 +FIX: Hinzufügen von load_dotenv(), um Umgebungsvariablen aus .env aktiv zu laden. +VERSION: 0.6.3 STATUS: Active -DEPENDENCIES: os, functools, pathlib +DEPENDENCIES: os, functools, pathlib, python-dotenv """ from __future__ import annotations import os from functools import lru_cache from pathlib import Path +from dotenv import load_dotenv + +# WP-20: Lade Umgebungsvariablen aus der .env Datei +# Muss vor dem Zugriff auf os.getenv erfolgen! +load_dotenv() class Settings: # --- Qdrant Datenbank --- @@ -23,9 +29,7 @@ class Settings: DISTANCE: str = os.getenv("MINDNET_DISTANCE", "Cosine") # --- 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 --- @@ -35,7 +39,6 @@ class Settings: # 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-Modell für hohen Durchsatz bei der Ingestion GEMMA_MODEL: str = os.getenv("MINDNET_GEMMA_MODEL", "google/gemma-2-9b-it:free") # OpenRouter Integration @@ -58,12 +61,10 @@ class Settings: 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 (Semantik vs. Graph) --- + # --- WP-04 Retriever Gewichte --- 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/config/prompts.yaml b/config/prompts.yaml index c1c5c6e..3dc5d40 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,5 +1,6 @@ -# config/prompts.yaml — Final V2.4.0 (Hybrid & Multi-Provider Support) -# WP-20: Optimierte Cloud-Templates bei unveränderten Ollama-Prompts. +# config/prompts.yaml — Final V2.4.1 (Hybrid & Multi-Provider Support) +# WP-20: Optimierte Cloud-Templates. +# FIX: Technische Maskierung (Doppel-Klammern) für JSON-Stabilität bei identischem Inhalt. system_prompt: | Du bist 'mindnet', mein digitaler Zwilling und strategischer Partner. @@ -208,15 +209,33 @@ edge_allocation_template: # --------------------------------------------------------- edge_extraction: ollama: | - Extrahiere Kanten als JSON: [{"to": "X", "kind": "Y"}]. - Text: {text} + TASK: + Du bist ein Wissens-Ingenieur für den digitalen Zwilling 'mindnet'. + Deine Aufgabe ist es, semantische Relationen (Kanten) aus dem Text zu extrahieren, + die die Hauptnotiz '{note_id}' mit anderen Konzepten verbinden. + + ANWEISUNGEN: + 1. Identifiziere wichtige Entitäten, Konzepte oder Ereignisse im Text. + 2. Bestimme die Art der Beziehung (z.B. part_of, uses, related_to, blocks, caused_by). + 3. Das Ziel (target) muss ein prägnanter Begriff sein. + 4. Antworte AUSSCHLIESSLICH in validem JSON als Liste von Objekten. + + BEISPIEL: + [[ {{"to": "Ziel-Konzept", "kind": "beziehungs_typ"}} ]] + + TEXT: + """ + {text} + """ + + DEIN OUTPUT (JSON): 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"}] + Antworte STRIKT 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 STRIKT als JSON-Liste: [{"to": "X", "kind": "Y"}]. + Output STRIKT als JSON-Liste: [[{{"to": "X", "kind": "Y"}}]]. Text: {text} \ No newline at end of file From 867a7a8b445dcda0d742762e1926986798a2e8bd Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 21:44:49 +0100 Subject: [PATCH 14/28] bug fix Wp20 --- app/core/ingestion.py | 71 ++++++++++++++++++++++++++++++++--------- config/prompts.yaml | 74 ++++++++++++++++++------------------------- 2 files changed, 86 insertions(+), 59 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 36904e3..4eebdc0 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -5,7 +5,8 @@ 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.4 +FIX: Robuste Verarbeitung von LLM-Antworten (Dict vs String) zur Vermeidung von Item-Assignment-Errors. +VERSION: 2.11.5 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 @@ -96,12 +97,11 @@ class IngestionService: self.cfg = QdrantConfig.from_env() self.cfg.prefix = self.prefix self.client = get_client(self.cfg) - self.dim = self.settings.VECTOR_SIZE # Synchronisiert mit Settings v0.6.2 + self.dim = self.settings.VECTOR_SIZE self.registry = load_type_registry() self.embedder = EmbeddingsClient() self.llm = LLMService() - # WP-22: Change Detection Modus aus Settings self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE try: @@ -111,7 +111,7 @@ class IngestionService: logger.warning(f"DB init warning: {e}") def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: - """Holt die Chunker-Parameter (max, target, overlap) für ein spezifisches Profil.""" + """Holt die Chunker-Parameter für ein spezifisches Profil.""" profiles = self.registry.get("chunking_profiles", {}) if profile_name in profiles: cfg = profiles[profile_name].copy() @@ -125,18 +125,25 @@ class IngestionService: WP-20: Nutzt den Hybrid LLM Service für die semantische Kanten-Extraktion. QUOTEN-SCHUTZ: Bevorzugt OpenRouter (Gemma 2), um Gemini-Tageslimits zu schonen. """ - # 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 + model = self.settings.GEMMA_MODEL logger.info(f"🚀 [Ingestion] Turbo-Mode: Extracting edges for '{note_id}' using {model} on {provider}") - # Hole das optimierte Prompt-Template (Kaskade: Provider -> gemini -> ollama) + # WP-22: Hole valide Typen für das Prompt-Template + edge_registry.ensure_latest() + valid_types_str = ", ".join(sorted(list(edge_registry.valid_types))) + 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 (WP-06) + # Befülle das Template (v2.5.0 erwartet valid_types) + prompt = template.format( + text=text[:6000], + note_id=note_id, + valid_types=valid_types_str + ) + response_json = await self.llm.generate_raw_response( prompt=prompt, priority="background", @@ -144,12 +151,44 @@ class IngestionService: provider=provider, model_override=model ) - data = json.loads(response_json) - for item in data: - item["provenance"] = "semantic_ai" - item["line"] = f"ai-{provider}" - return data + # Robustes Parsing (WP-20 Fix für 'str' object assignment error) + raw_data = json.loads(response_json) + processed_edges = [] + + # Das LLM liefert manchmal ein Dict mit einem Key statt einer Liste + if isinstance(raw_data, dict): + logger.debug(f"ℹ️ [Ingestion] LLM returned dict for {note_id}, attempting recovery.") + for key in ["edges", "links", "results", "kanten"]: + if key in raw_data and isinstance(raw_data[key], list): + raw_data = raw_data[key] + break + + if not isinstance(raw_data, list): + logger.warning(f"⚠️ [Ingestion] LLM output for {note_id} is not a list: {type(raw_data)}") + return [] + + for item in raw_data: + # Fall 1: Element ist bereits ein Dict (Idealfall) + if isinstance(item, dict) and "to" in item: + item["provenance"] = "semantic_ai" + item["line"] = f"ai-{provider}" + processed_edges.append(item) + + # Fall 2: Element ist ein String (z.B. "kind:target") -> Umwandlung + elif isinstance(item, str) and ":" in item: + parts = item.split(":", 1) + processed_edges.append({ + "to": parts[1].strip(), + "kind": parts[0].strip(), + "provenance": "semantic_ai", + "line": f"ai-{provider}" + }) + else: + logger.debug(f"⏩ [Ingestion] Skipping unparseable AI edge: {item}") + + return processed_edges + except Exception as e: logger.warning(f"⚠️ [Ingestion] Smart Edge Allocation failed for {note_id} on {provider}: {e}") return [] @@ -256,7 +295,9 @@ class IngestionService: # 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")}) + # Validierung gegen EdgeRegistry (Vermeidet 'Transition' etc.) + valid_kind = edge_registry.resolve(edge_type=e.get("kind"), provenance="semantic_ai", context={**context, "line": e.get("line")}) + e["kind"] = valid_kind edges.append(e) # C. System-Kanten (Struktur) diff --git a/config/prompts.yaml b/config/prompts.yaml index 3dc5d40..f9d954c 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -31,12 +31,10 @@ rag_template: Beantworte die Frage präzise basierend auf den Quellen. Fasse die Informationen zusammen. Sei objektiv und neutral. gemini: | - Nutze das Wissen meines digitalen Zwillings aus folgendem Kontext: {context_str} - Beantworte die Anfrage präzise, detailliert und strukturiert: {query} + Kontext meines digitalen Zwillings: {context_str} + Beantworte strukturiert: {query} openrouter: | - Kontext-Analyse für Gemma/Llama: - {context_str} - + Kontext: {context_str} Anfrage: {query} # --------------------------------------------------------- @@ -63,13 +61,10 @@ decision_template: - **Abgleich:** (Gibt es Konflikte mit Werten/Zielen? Nenne die Quelle!) - **Empfehlung:** (Klare Meinung: Ja/No/Vielleicht mit Begründung) gemini: | - 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} + Agiere als strategischer Partner. Analysiere {query} basierend auf {context_str}. openrouter: | - Strategischer Check via OpenRouter/Gemma: - Analyse der Entscheidungsfrage: {query} - Referenzdaten aus dem Graph: {context_str} + Entscheidungsanalyse für: {query} + Datenbasis: {context_str} # --------------------------------------------------------- # 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY) @@ -92,13 +87,8 @@ 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} + gemini: "Sei mein digitaler Spiegel für {query}. Kontext: {context_str}" + openrouter: "Empathische Analyse: {query}. Kontext: {context_str}" # --------------------------------------------------------- # 4. TECHNICAL: Der Coder (Intent: CODING) @@ -123,13 +113,8 @@ 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} + gemini: "Generiere Code für {query}. Kontext: {context_str}" + openrouter: "Technischer Support: {query}. Kontext: {context_str}" # --------------------------------------------------------- # 5. INTERVIEW: Der "One-Shot Extractor" (Performance Mode) @@ -166,8 +151,8 @@ interview_template: ## (Zweiter Begriff aus STRUKTUR) (Text...) - 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}." + gemini: "Extrahiere Daten für {target_type} aus {query}." + openrouter: "Strukturiere {query} nach {schema_fields}." # --------------------------------------------------------- # 6. EDGE_ALLOCATION: Kantenfilter (Intent: OFFLINE_FILTER) @@ -194,16 +179,17 @@ edge_allocation_template: DEIN OUTPUT (JSON): gemini: | - 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! + TASK: Ordne Kanten einem Textabschnitt zu. + ERLAUBTE TYPEN: {valid_types} + TEXT: {chunk_text} + KANDIDATEN: {edge_list} + OUTPUT: STRIKT eine flache JSON-Liste ["typ:ziel"]. Keine Objekte! openrouter: | - Filtere die relevanten Kanten für den Graphen. - Kandidaten: {edge_list} - Text: {chunk_text} - Output: JSON-Liste ["typ:ziel"]. - + Filtere relevante Kanten. + ERLAUBTE TYPEN: {valid_types} + TEXT: {chunk_text} + KANDIDATEN: {edge_list} + OUTPUT: STRIKT JSON-Liste von Strings ["typ:ziel"]. # --------------------------------------------------------- # 7. SMART EDGE ALLOCATION: Extraktion (Intent: INGEST) # --------------------------------------------------------- @@ -230,12 +216,12 @@ edge_extraction: DEIN OUTPUT (JSON): gemini: | - Führe eine semantische Analyse der Notiz '{note_id}' durch. - Finde explizite und implizite Relationen. - Antworte STRIKT als JSON: [[{{"to": "Ziel", "kind": "typ", "reason": "begründung"}}]] - Keine Erklärungen, nur JSON. - Text: {text} + Analysiere '{note_id}'. Extrahiere semantische Beziehungen. + ERLAUBTE TYPEN: {valid_types} + TEXT: {text} + OUTPUT: STRIKT JSON-Liste von Objekten: [{"to": "Ziel", "kind": "typ"}]. Keine Erklärungen! openrouter: | - Analysiere den Text für den Graphen. Identifiziere semantische Verbindungen. - Output STRIKT als JSON-Liste: [[{{"to": "X", "kind": "Y"}}]]. - Text: {text} \ No newline at end of file + Wissensgraph-Extraktion für '{note_id}'. + ERLAUBTE TYPEN: {valid_types} + TEXT: {text} + OUTPUT: STRIKT JSON-Liste von Objekten: [{"to": "Ziel", "kind": "typ"}]. Keine Dictionaries mit Schlüsseln wie 'edges'! \ No newline at end of file From a908853c30aafcc580276f3a7e5d2efe6ec31a2e Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 21:57:50 +0100 Subject: [PATCH 15/28] weitere Anpassungen WP20 --- app/core/ingestion.py | 228 +++++++++++------------------- app/services/semantic_analyzer.py | 25 ++-- config/decision_engine.yaml | 43 +++--- config/prompts.yaml | 14 +- 4 files changed, 118 insertions(+), 192 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 4eebdc0..5aa47e2 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,18 +1,15 @@ """ FILE: app/core/ingestion.py -DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen (Notes, Chunks, Edges). - WP-20: Integration von Smart Edge Allocation via Hybrid LLM (Gemini/Gemma/OpenRouter). - 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. -FIX: Robuste Verarbeitung von LLM-Antworten (Dict vs String) zur Vermeidung von Item-Assignment-Errors. -VERSION: 2.11.5 +DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen. + WP-20: Smart Edge Allocation via Hybrid LLM (OpenRouter/Gemini). + WP-22: Content Lifecycle, Edge Registry Validation & Multi-Hash. +FIX: Bulletproof JSON Extraction & Prompt Formatting Safety. +VERSION: 2.11.6 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 """ import os import json +import re import logging import asyncio import time @@ -50,8 +47,24 @@ from app.services.llm_service import LLMService logger = logging.getLogger(__name__) # --- Helper --- +def extract_json_from_response(text: str) -> Any: + """Extrahiert JSON-Daten, selbst wenn sie in Markdown-Blöcken stehen.""" + if not text: return [] + # Suche nach ```json ... ``` oder ``` ... ``` + match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL) + clean_text = match.group(1) if match else text + try: + return json.loads(clean_text.strip()) + except json.JSONDecodeError: + # Letzter Versuch: Alles vor der ersten [ und nach der letzten ] entfernen + start = clean_text.find('[') + end = clean_text.rfind(']') + 1 + if start != -1 and end != 0: + try: return json.loads(clean_text[start:end]) + except: pass + raise + def load_type_registry(custom_path: Optional[str] = None) -> dict: - """Lädt die types.yaml zur Steuerung der typ-spezifischen Ingestion.""" import yaml from app.config import get_settings settings = get_settings() @@ -62,13 +75,11 @@ def load_type_registry(custom_path: Optional[str] = None) -> dict: except Exception: return {} def resolve_note_type(requested: Optional[str], reg: dict) -> str: - """Bestimmt den finalen Notiz-Typ (Fallback auf 'concept').""" types = reg.get("types", {}) if requested and requested in types: return requested return "concept" def effective_chunk_profile_name(fm: dict, note_type: str, reg: dict) -> str: - """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 t_cfg = reg.get("types", {}).get(note_type, {}) @@ -78,7 +89,6 @@ def effective_chunk_profile_name(fm: dict, note_type: str, reg: dict) -> str: 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.""" override = fm.get("retriever_weight") if override is not None: try: return float(override) @@ -110,203 +120,137 @@ class IngestionService: except Exception as e: logger.warning(f"DB init warning: {e}") - def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: - """Holt die Chunker-Parameter für ein spezifisches Profil.""" - profiles = self.registry.get("chunking_profiles", {}) - if profile_name in profiles: - cfg = profiles[profile_name].copy() - if "overlap" in cfg and isinstance(cfg["overlap"], list): - cfg["overlap"] = tuple(cfg["overlap"]) - return cfg - return get_chunk_config(note_type) - 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 2), um Gemini-Tageslimits zu schonen. - """ + """Nutzt das Hybrid LLM für die semantische Kanten-Extraktion.""" provider = "openrouter" if self.settings.OPENROUTER_API_KEY else self.settings.MINDNET_LLM_PROVIDER model = self.settings.GEMMA_MODEL logger.info(f"🚀 [Ingestion] Turbo-Mode: Extracting edges for '{note_id}' using {model} on {provider}") - # WP-22: Hole valide Typen für das Prompt-Template edge_registry.ensure_latest() valid_types_str = ", ".join(sorted(list(edge_registry.valid_types))) template = self.llm.get_prompt("edge_extraction", provider) try: - # Befülle das Template (v2.5.0 erwartet valid_types) - prompt = template.format( - text=text[:6000], - note_id=note_id, - valid_types=valid_types_str - ) - - response_json = await self.llm.generate_raw_response( - prompt=prompt, - priority="background", - force_json=True, - provider=provider, - model_override=model - ) - - # Robustes Parsing (WP-20 Fix für 'str' object assignment error) - raw_data = json.loads(response_json) - processed_edges = [] - - # Das LLM liefert manchmal ein Dict mit einem Key statt einer Liste - if isinstance(raw_data, dict): - logger.debug(f"ℹ️ [Ingestion] LLM returned dict for {note_id}, attempting recovery.") - for key in ["edges", "links", "results", "kanten"]: - if key in raw_data and isinstance(raw_data[key], list): - raw_data = raw_data[key] - break - - if not isinstance(raw_data, list): - logger.warning(f"⚠️ [Ingestion] LLM output for {note_id} is not a list: {type(raw_data)}") + # FIX: Format-Safety Block + try: + prompt = template.format( + text=text[:6000], + note_id=note_id, + valid_types=valid_types_str + ) + except KeyError as ke: + logger.error(f"❌ [Ingestion] Prompt-Template Fehler (Fehlende Maskierung in YAML?): {ke}") return [] + response_json = await self.llm.generate_raw_response( + prompt=prompt, priority="background", force_json=True, + provider=provider, model_override=model + ) + + # FIX: Robustes JSON-Parsing + raw_data = extract_json_from_response(response_json) + + if isinstance(raw_data, dict): + for k in ["edges", "links", "results", "kanten"]: + if k in raw_data and isinstance(raw_data[k], list): + raw_data = raw_data[k] + break + + if not isinstance(raw_data, list): return [] + + processed = [] for item in raw_data: - # Fall 1: Element ist bereits ein Dict (Idealfall) + # FIX: Typ-Check zur Vermeidung von 'str' object assignment errors if isinstance(item, dict) and "to" in item: item["provenance"] = "semantic_ai" item["line"] = f"ai-{provider}" - processed_edges.append(item) - - # Fall 2: Element ist ein String (z.B. "kind:target") -> Umwandlung + processed.append(item) elif isinstance(item, str) and ":" in item: parts = item.split(":", 1) - processed_edges.append({ + processed.append({ "to": parts[1].strip(), "kind": parts[0].strip(), "provenance": "semantic_ai", "line": f"ai-{provider}" }) - else: - logger.debug(f"⏩ [Ingestion] Skipping unparseable AI edge: {item}") - - return processed_edges + return processed except Exception as e: - logger.warning(f"⚠️ [Ingestion] Smart Edge Allocation failed for {note_id} on {provider}: {e}") + logger.warning(f"⚠️ [Ingestion] Smart Edge Allocation failed for {note_id}: {e}") return [] async def process_file( - self, - file_path: str, - vault_root: str, - force_replace: bool = False, - apply: bool = False, - purge_before: bool = False, - note_scope_refs: bool = False, - hash_source: str = "parsed", - hash_normalize: str = "canonical" + self, file_path: str, vault_root: str, + force_replace: bool = False, apply: bool = False, purge_before: bool = False, + note_scope_refs: bool = False, hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: - """Verarbeitet eine Markdown-Datei und schreibt sie in den Graphen.""" result = {"path": file_path, "status": "skipped", "changed": False, "error": None} - # 1. Parse & Frontmatter Validation try: parsed = read_markdown(file_path) - if not parsed: return {**result, "error": "Empty or unreadable file"} + if not parsed: return {**result, "error": "Empty file"} fm = normalize_frontmatter(parsed.frontmatter) validate_required_frontmatter(fm) except Exception as e: - logger.error(f"Validation failed for {file_path}: {e}") return {**result, "error": f"Validation failed: {str(e)}"} - # --- WP-22: Content Lifecycle Gate --- status = fm.get("status", "draft").lower().strip() if status in ["system", "template", "archive", "hidden"]: - return {**result, "status": "skipped", "reason": f"lifecycle_status_{status}"} + return {**result, "status": "skipped", "reason": f"lifecycle_{status}"} - # 2. Type & Config Resolution note_type = resolve_note_type(fm.get("type"), self.registry) fm["type"] = note_type effective_profile = effective_chunk_profile_name(fm, note_type, self.registry) effective_weight = effective_retriever_weight(fm, note_type, self.registry) - fm["chunk_profile"] = effective_profile - fm["retriever_weight"] = effective_weight - - # 3. Build Note Payload try: note_pl = make_note_payload(parsed, vault_root=vault_root, hash_normalize=hash_normalize, hash_source=hash_source, file_path=file_path) - if not note_pl.get("fulltext"): note_pl["fulltext"] = getattr(parsed, "body", "") or "" note_pl["retriever_weight"] = effective_weight note_pl["chunk_profile"] = effective_profile note_pl["status"] = status note_id = note_pl["note_id"] except Exception as e: - return {**result, "error": f"Payload build failed: {str(e)}"} + return {**result, "error": f"Payload failed: {str(e)}"} - # 4. Change Detection (WP-22 Multi-Hash) - old_payload = None - if not force_replace: - old_payload = self._fetch_note_payload(note_id) - - has_old = old_payload is not None + old_payload = None if force_replace else self._fetch_note_payload(note_id) check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" - - old_hashes = (old_payload or {}).get("hashes", {}) - old_hash = old_hashes.get(check_key) if isinstance(old_hashes, dict) else None + old_hash = (old_payload or {}).get("hashes", {}).get(check_key) new_hash = note_pl.get("hashes", {}).get(check_key) - hash_changed = (old_hash != new_hash) - chunks_missing, edges_missing = self._artifacts_missing(note_id) - - should_write = force_replace or (not has_old) or hash_changed or chunks_missing or edges_missing + should_write = force_replace or (not old_payload) or (old_hash != new_hash) or any(self._artifacts_missing(note_id)) - if not should_write: - return {**result, "status": "unchanged", "note_id": note_id} + if not should_write: return {**result, "status": "unchanged", "note_id": note_id} + if not apply: return {**result, "status": "dry-run", "changed": True, "note_id": note_id} - if not apply: - return {**result, "status": "dry-run", "changed": True, "note_id": note_id} - - # 5. Processing (Chunking, Embedding, Edge Generation) try: body_text = getattr(parsed, "body", "") or "" - - # WP-22 STABILITY PATCH: Prüfen, ob ensure_latest existiert - if hasattr(edge_registry, "ensure_latest"): - edge_registry.ensure_latest() + if hasattr(edge_registry, "ensure_latest"): edge_registry.ensure_latest() chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) - vecs = [] - if chunk_pls: - texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] - vecs = await self.embedder.embed_documents(texts) + vecs = await self.embedder.embed_documents([c.get("window") or c.get("text") or "" for c in chunk_pls]) if chunk_pls else [] - # --- WP-22/WP-20: Kanten-Extraktion & Validierung --- edges = [] context = {"file": file_path, "note_id": note_id} - # A. Explizite User-Kanten - explicit_edges = extract_edges_with_context(parsed) - for e in explicit_edges: + for e in extract_edges_with_context(parsed): 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 Turbo Acceleration) ai_edges = await self._perform_smart_edge_allocation(body_text, note_id) for e in ai_edges: - # Validierung gegen EdgeRegistry (Vermeidet 'Transition' etc.) - valid_kind = edge_registry.resolve(edge_type=e.get("kind"), provenance="semantic_ai", context={**context, "line": e.get("line")}) - e["kind"] = valid_kind + 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 (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: - raw_system_edges = build_edges_for_note(note_id, chunk_pls) + sys_edges = build_edges_for_note(note_id, chunk_pls, note_level_references=note_pl.get("references", []), include_note_scope_refs=note_scope_refs) + except: sys_edges = build_edges_for_note(note_id, chunk_pls) - for e in raw_system_edges: + for e in sys_edges: valid_kind = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"}) if valid_kind: e["kind"] = valid_kind @@ -316,10 +260,8 @@ class IngestionService: logger.error(f"Processing failed for {file_path}: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} - # 6. Upsert try: - if purge_before and has_old: self._purge_artifacts(note_id) - + if purge_before and old_payload: self._purge_artifacts(note_id) n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) upsert_batch(self.client, n_name, n_pts) @@ -333,15 +275,13 @@ 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 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]: from qdrant_client.http import models as rest - col = f"{self.prefix}_notes" try: f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts, _ = self.client.scroll(collection_name=col, scroll_filter=f, limit=1, with_payload=True) + pts, _ = self.client.scroll(collection_name=f"{self.prefix}_notes", scroll_filter=f, limit=1, with_payload=True) return pts[0].payload if pts else None except: return None @@ -357,23 +297,15 @@ class IngestionService: def _purge_artifacts(self, note_id: str): from qdrant_client.http import models as rest f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - 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 + try: self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=rest.FilterSelector(filter=f)) + except: 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)}"} + with open(file_path, "w", encoding="utf-8") as f: + f.write(markdown_content) + await asyncio.sleep(0.1) 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/semantic_analyzer.py b/app/services/semantic_analyzer.py index 599d343..2528e56 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -2,10 +2,10 @@ 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: 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 + WP-22: Integration von valid_types zur Halluzinations-Vermeidung. +VERSION: 2.2.2 STATUS: Active -DEPENDENCIES: app.services.llm_service, json, logging +DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging LAST_ANALYSIS: 2025-12-23 """ @@ -16,6 +16,8 @@ from dataclasses import dataclass # Importe from app.services.llm_service import LLMService +# WP-22: Registry für Vokabular-Erzwingung +from app.services.edge_registry import registry as edge_registry logger = logging.getLogger(__name__) @@ -53,8 +55,6 @@ 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' sicher zu vermeiden. """ if not all_edges: return [] @@ -72,25 +72,28 @@ class SemanticAnalyzer: "OUTPUT: JSON Liste von Strings [\"kind:target\"]." ) - # 2. Kandidaten-Liste formatieren + # 2. Daten für Template vorbereiten (WP-22 Integration) + # Wir laden die validen Typen, um sie dem LLM als Leitplanken zu geben + edge_registry.ensure_latest() + valid_types_str = ", ".join(sorted(list(edge_registry.valid_types))) edges_str = "\n".join([f"- {e}" for e in all_edges]) # LOG: Request Info logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.") - # 3. Prompt füllen (Nutzt nun sicher einen String dank LLMService v3.3.2) + # 3. Prompt füllen (FIX: valid_types hinzugefügt, um FormatError zu beheben) try: final_prompt = prompt_template.format( chunk_text=chunk_text[:3500], - edge_list=edges_str + edge_list=edges_str, + valid_types=valid_types_str ) except Exception as format_err: - logger.error(f"❌ [SemanticAnalyzer] Format Error im Prompt-Template: {format_err}") + logger.error(f"❌ [SemanticAnalyzer] Format Error im Prompt-Template (Fehlender Parameter): {format_err}") return [] 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, @@ -135,7 +138,7 @@ class SemanticAnalyzer: if isinstance(target, str): raw_candidates.append(f"{key}:{target}") - # 7. Strict Validation Loop (Übernahme aus deiner V2.2.0) + # 7. Strict Validation Loop 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 a9c2458..bae0d1d 100644 --- a/config/decision_engine.yaml +++ b/config/decision_engine.yaml @@ -1,6 +1,6 @@ # config/decision_engine.yaml # Steuerung der Decision Engine (Intent Recognition & Graph Routing) -# VERSION: 2.6.0 (WP-20: Hybrid LLM & WP-22: Semantic Graph Routing) +# VERSION: 2.6.1 (WP-20: Hybrid LLM & WP-22: Semantic Graph Routing) # STATUS: Active version: 2.6 @@ -9,11 +9,10 @@ settings: llm_fallback_enabled: true # Strategie für den Router selbst (Welches Modell erkennt den Intent?) - # "auto" nutzt den in MINDNET_LLM_PROVIDER gesetzten Standard. + # "auto" nutzt den in MINDNET_LLM_PROVIDER gesetzten Standard (z.B. openrouter). router_provider: "auto" - # Few-Shot Prompting für den LLM-Router (Slow Path) - # Gemini 1.5 nutzt diesen Kontext für hochpräzise Intent-Erkennung. + # Few-Shot Prompting für den LLM-Router llm_router_prompt: | Du bist der zentrale Intent-Klassifikator für Mindnet, einen digitalen Zwilling. Analysiere die Nachricht und wähle die passende Strategie. @@ -38,10 +37,10 @@ settings: STRATEGIE: strategies: - # 1. Fakten-Abfrage (Fallback & Default) + # 1. Fakten-Abfrage (Turbo-Modus via OpenRouter) FACT: description: "Reine Wissensabfrage." - preferred_provider: "openrouter" # Schnell und lokal ausreichend + preferred_provider: "openrouter" trigger_keywords: [] inject_types: [] # WP-22: Definitionen & Hierarchien bevorzugen @@ -53,10 +52,10 @@ strategies: prompt_template: "rag_template" prepend_instruction: null - # 2. Entscheidungs-Frage (Power-Strategie) + # 2. Entscheidungs-Frage (Power-Strategie via Gemini) DECISION: description: "Der User sucht Rat, Strategie oder Abwägung." - preferred_provider: "gemini" # Nutzt Gemini's Reasoning-Power für WP-20 + preferred_provider: "gemini" trigger_keywords: - "soll ich" - "meinung" @@ -67,22 +66,22 @@ strategies: - "abwägung" - "vergleich" inject_types: ["value", "principle", "goal", "risk"] - # WP-22: Risiken und Konsequenzen hervorheben + # WP-22: Risiken und Konsequenzen im Graphen priorisieren edge_boosts: blocks: 2.5 solves: 2.0 depends_on: 1.5 risk_of: 2.5 - impacts: 2.0 # NEU: Zeige mir alles, was von dieser Entscheidung betroffen ist! + impacts: 2.0 prompt_template: "decision_template" prepend_instruction: | !!! ENTSCHEIDUNGS-MODUS (HYBRID AI) !!! BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE, PRINZIPIEN UND ZIELE AB: - # 3. Empathie / "Ich"-Modus (Privacy-Fokus) + # 3. Empathie / "Ich"-Modus (Lokal & Privat via Ollama) EMPATHY: description: "Reaktion auf emotionale Zustände." - preferred_provider: "ollama" # Private Emotionen bleiben lokal! + preferred_provider: "ollama" trigger_keywords: - "ich fühle" - "traurig" @@ -93,7 +92,6 @@ strategies: - "überfordert" - "müde" inject_types: ["experience", "belief", "profile"] - # WP-22: Weiche Assoziationen & Erfahrungen stärken edge_boosts: based_on: 2.0 related_to: 2.0 @@ -102,10 +100,10 @@ strategies: prompt_template: "empathy_template" prepend_instruction: null - # 4. Coding / Technical + # 4. Coding / Technical (Gemini Power) CODING: description: "Technische Anfragen und Programmierung." - preferred_provider: "gemini" # Höheres Weltwissen für moderne Libraries + preferred_provider: "gemini" trigger_keywords: - "code" - "python" @@ -117,7 +115,6 @@ strategies: - "yaml" - "bash" inject_types: ["snippet", "reference", "source"] - # WP-22: Technische Abhängigkeiten edge_boosts: uses: 2.5 depends_on: 2.0 @@ -125,10 +122,10 @@ strategies: prompt_template: "technical_template" prepend_instruction: null - # 5. Interview / Datenerfassung + # 5. Interview / Datenerfassung (Lokal) INTERVIEW: description: "Der User möchte Wissen erfassen." - preferred_provider: "ollama" # Lokale Erfassung ist performant genug + preferred_provider: "ollama" trigger_keywords: - "neue notiz" - "etwas notieren" @@ -143,12 +140,4 @@ strategies: inject_types: [] edge_boosts: {} prompt_template: "interview_template" - prepend_instruction: null - # Schemas kommen aus types.yaml (WP-22) - schemas: - default: - fields: - - "Titel" - - "Thema/Inhalt" - - "Tags" - hint: "Halte es einfach und übersichtlich." \ No newline at end of file + prepend_instruction: null \ No newline at end of file diff --git a/config/prompts.yaml b/config/prompts.yaml index f9d954c..533f3d5 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,6 +1,7 @@ -# config/prompts.yaml — Final V2.4.1 (Hybrid & Multi-Provider Support) +# config/prompts.yaml — Final V2.5.1 (Hybrid & Multi-Provider Support) # WP-20: Optimierte Cloud-Templates. -# FIX: Technische Maskierung (Doppel-Klammern) für JSON-Stabilität bei identischem Inhalt. +# FIX: Technische Maskierung (Doppel-Klammern) in Cloud-Sektionen zur Vermeidung von KeyError. +# OLLAMA: Unverändert laut Benutzeranweisung. system_prompt: | Du bist 'mindnet', mein digitaler Zwilling und strategischer Partner. @@ -113,7 +114,7 @@ technical_template: - Kurze Erklärung des Ansatzes. - Markdown Code-Block (Copy-Paste fertig). - Wichtige Edge-Cases. - gemini: "Generiere Code für {query}. Kontext: {context_str}" + gemini: "Generiere Code für {query} unter Berücksichtigung von {context_str}." openrouter: "Technischer Support: {query}. Kontext: {context_str}" # --------------------------------------------------------- @@ -189,7 +190,8 @@ edge_allocation_template: ERLAUBTE TYPEN: {valid_types} TEXT: {chunk_text} KANDIDATEN: {edge_list} - OUTPUT: STRIKT JSON-Liste von Strings ["typ:ziel"]. + Output: JSON-Liste ["typ:ziel"]. + # --------------------------------------------------------- # 7. SMART EDGE ALLOCATION: Extraktion (Intent: INGEST) # --------------------------------------------------------- @@ -219,9 +221,9 @@ edge_extraction: Analysiere '{note_id}'. Extrahiere semantische Beziehungen. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - OUTPUT: STRIKT JSON-Liste von Objekten: [{"to": "Ziel", "kind": "typ"}]. Keine Erklärungen! + OUTPUT: STRIKT JSON-Liste von Objekten: [[{{"to": "Ziel", "kind": "typ"}}]]. Keine Erklärungen! openrouter: | Wissensgraph-Extraktion für '{note_id}'. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - OUTPUT: STRIKT JSON-Liste von Objekten: [{"to": "Ziel", "kind": "typ"}]. Keine Dictionaries mit Schlüsseln wie 'edges'! \ No newline at end of file + OUTPUT: STRIKT JSON-Liste von Objekten: [[{{"to": "X", "kind": "Y"}}]]. Keine Dictionaries mit Schlüsseln wie 'edges'! \ No newline at end of file From 5278c75ac1f51806548dbbf9401c3dcf031da20b Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 23 Dec 2025 22:02:32 +0100 Subject: [PATCH 16/28] bug fix --- app/core/ingestion.py | 25 ++++++++++++++++++------- config/prompts.yaml | 4 ++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 5aa47e2..a86ed28 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -3,8 +3,8 @@ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen. WP-20: Smart Edge Allocation via Hybrid LLM (OpenRouter/Gemini). WP-22: Content Lifecycle, Edge Registry Validation & Multi-Hash. -FIX: Bulletproof JSON Extraction & Prompt Formatting Safety. -VERSION: 2.11.6 +FIX: Behebung des AttributeError und Härtung des Prompt-Formattings. +VERSION: 2.11.7 STATUS: Active """ import os @@ -56,7 +56,7 @@ def extract_json_from_response(text: str) -> Any: try: return json.loads(clean_text.strip()) except json.JSONDecodeError: - # Letzter Versuch: Alles vor der ersten [ und nach der letzten ] entfernen + # Versuch: Alles vor der ersten [ und nach der letzten ] entfernen start = clean_text.find('[') end = clean_text.rfind(']') + 1 if start != -1 and end != 0: @@ -120,6 +120,16 @@ class IngestionService: except Exception as e: logger.warning(f"DB init warning: {e}") + def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: + """Holt die Chunker-Parameter für ein spezifisches Profil.""" + profiles = self.registry.get("chunking_profiles", {}) + if profile_name in profiles: + cfg = profiles[profile_name].copy() + if "overlap" in cfg and isinstance(cfg["overlap"], list): + cfg["overlap"] = tuple(cfg["overlap"]) + return cfg + return get_chunk_config(note_type) + async def _perform_smart_edge_allocation(self, text: str, note_id: str) -> List[Dict]: """Nutzt das Hybrid LLM für die semantische Kanten-Extraktion.""" provider = "openrouter" if self.settings.OPENROUTER_API_KEY else self.settings.MINDNET_LLM_PROVIDER @@ -133,7 +143,7 @@ class IngestionService: template = self.llm.get_prompt("edge_extraction", provider) try: - # FIX: Format-Safety Block + # FIX: Format-Safety Block gegen KeyError: '"to"' try: prompt = template.format( text=text[:6000], @@ -141,7 +151,7 @@ class IngestionService: valid_types=valid_types_str ) except KeyError as ke: - logger.error(f"❌ [Ingestion] Prompt-Template Fehler (Fehlende Maskierung in YAML?): {ke}") + logger.error(f"❌ [Ingestion] Prompt-Template Fehler (Variable {ke} fehlt). Prüfe prompts.yaml Maskierung.") return [] response_json = await self.llm.generate_raw_response( @@ -149,7 +159,6 @@ class IngestionService: provider=provider, model_override=model ) - # FIX: Robustes JSON-Parsing raw_data = extract_json_from_response(response_json) if isinstance(raw_data, dict): @@ -162,7 +171,7 @@ class IngestionService: processed = [] for item in raw_data: - # FIX: Typ-Check zur Vermeidung von 'str' object assignment errors + # FIX: Schutz vor 'str' object does not support item assignment if isinstance(item, dict) and "to" in item: item["provenance"] = "semantic_ai" item["line"] = f"ai-{provider}" @@ -186,6 +195,7 @@ class IngestionService: force_replace: bool = False, apply: bool = False, purge_before: bool = False, note_scope_refs: bool = False, hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: + """Verarbeitet eine Markdown-Datei und schreibt sie in den Graphen.""" result = {"path": file_path, "status": "skipped", "changed": False, "error": None} try: @@ -228,6 +238,7 @@ class IngestionService: body_text = getattr(parsed, "body", "") or "" if hasattr(edge_registry, "ensure_latest"): edge_registry.ensure_latest() + # FIX: Behebung des AttributeError durch korrekten Aufruf der Klassenmethode chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) diff --git a/config/prompts.yaml b/config/prompts.yaml index 533f3d5..2dcea78 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,6 +1,6 @@ -# config/prompts.yaml — Final V2.5.1 (Hybrid & Multi-Provider Support) +# config/prompts.yaml — Final V2.5.2 (Strict Hybrid Support) # WP-20: Optimierte Cloud-Templates. -# FIX: Technische Maskierung (Doppel-Klammern) in Cloud-Sektionen zur Vermeidung von KeyError. +# FIX: Technische Maskierung (Doppel-Klammern) zur Vermeidung von KeyError: '"to"'. # OLLAMA: Unverändert laut Benutzeranweisung. system_prompt: | From 4ab44e36a2f4222c5991ef31558a9ee80dee6e93 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 24 Dec 2025 07:47:45 +0100 Subject: [PATCH 17/28] =?UTF-8?q?Mit=20ChatGBT=20=C3=9Cberarbeitetes=20Mod?= =?UTF-8?q?ell=20WP20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/llm_service.py | 177 +++++++++++++++++++++++------- app/services/semantic_analyzer.py | 48 +++++--- config/prompts.yaml | 8 +- 3 files changed, 174 insertions(+), 59 deletions(-) diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 02843c2..6115fe7 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -4,7 +4,9 @@ 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. WP-20 Fix: Bulletproof Prompt-Auflösung für format() Aufrufe. -VERSION: 3.3.2 + WP-22/JSON: Optionales JSON-Schema + strict (für OpenRouter structured outputs), + OHNE Breaking Changes (neue Parameter nur am Ende). +VERSION: 3.3.3 STATUS: Active """ import httpx @@ -14,13 +16,14 @@ import asyncio import json from google import genai from google.genai import types -from openai import AsyncOpenAI # Für OpenRouter (OpenAI-kompatibel) +from openai import AsyncOpenAI # Für OpenRouter (OpenAI-kompatibel) from pathlib import Path from typing import Optional, Dict, Any, Literal from app.config import get_settings logger = logging.getLogger(__name__) + class LLMService: # GLOBALER SEMAPHOR für Hintergrund-Last Steuerung (WP-06) _background_semaphore = None @@ -28,16 +31,16 @@ class LLMService: def __init__(self): self.settings = get_settings() self.prompts = self._load_prompts() - + # Initialisiere Semaphore einmalig auf Klassen-Ebene if LLMService._background_semaphore is None: limit = getattr(self.settings, "BACKGROUND_LIMIT", 2) logger.info(f"🚦 LLMService: Initializing Background Semaphore with limit: {limit}") LLMService._background_semaphore = asyncio.Semaphore(limit) - + # 1. Lokaler Ollama Client self.ollama_client = httpx.AsyncClient( - base_url=self.settings.OLLAMA_URL, + base_url=self.settings.OLLAMA_URL, timeout=httpx.Timeout(self.settings.LLM_TIMEOUT) ) @@ -74,63 +77,126 @@ 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 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( - self, prompt: str, system: str = None, force_json: bool = False, - max_retries: int = 2, base_delay: float = 2.0, + self, + prompt: str, + system: str = None, + force_json: bool = False, + max_retries: int = 2, + base_delay: float = 2.0, priority: Literal["realtime", "background"] = "realtime", provider: Optional[str] = None, - model_override: Optional[str] = None + model_override: Optional[str] = None, + # --- NEW (am Ende => rückwärtskompatibel!) --- + json_schema: Optional[Dict[str, Any]] = None, + json_schema_name: str = "mindnet_json", + strict_json_schema: bool = True ) -> str: - """Haupteinstiegspunkt für LLM-Anfragen mit Priorisierung.""" + """ + Haupteinstiegspunkt für LLM-Anfragen mit Priorisierung. + + force_json: + - Ollama: nutzt payload["format"]="json" + - Gemini: nutzt response_mime_type="application/json" + - OpenRouter: nutzt response_format=json_object (Fallback) oder json_schema (structured outputs) + + json_schema + strict_json_schema (nur OpenRouter relevant): + - Wenn json_schema gesetzt ist UND force_json=True -> response_format.type="json_schema" + - strict_json_schema wird an OpenRouter/Provider weitergereicht (best effort je nach Provider) + """ target_provider = provider or self.settings.MINDNET_LLM_PROVIDER - + if priority == "background": async with LLMService._background_semaphore: - return await self._dispatch(target_provider, prompt, system, force_json, max_retries, base_delay, model_override) - - return await self._dispatch(target_provider, prompt, system, force_json, max_retries, base_delay, model_override) + return await self._dispatch( + target_provider, + prompt, + system, + force_json, + max_retries, + base_delay, + model_override, + json_schema, + json_schema_name, + strict_json_schema + ) - async def _dispatch(self, provider, prompt, system, force_json, max_retries, base_delay, model_override): + return await self._dispatch( + target_provider, + prompt, + system, + force_json, + max_retries, + base_delay, + model_override, + json_schema, + json_schema_name, + strict_json_schema + ) + + async def _dispatch( + self, + provider: str, + prompt: str, + system: Optional[str], + force_json: bool, + max_retries: int, + base_delay: float, + model_override: Optional[str], + json_schema: Optional[Dict[str, Any]], + json_schema_name: str, + strict_json_schema: bool + ) -> str: """Routet die Anfrage an den physikalischen API-Provider.""" try: if provider == "openrouter" and self.openrouter_client: - return await self._execute_openrouter(prompt, system, force_json, model_override) - + return await self._execute_openrouter( + prompt=prompt, + system=system, + force_json=force_json, + model_override=model_override, + json_schema=json_schema, + json_schema_name=json_schema_name, + strict_json_schema=strict_json_schema + ) + if provider == "gemini" and self.google_client: return await self._execute_google(prompt, system, force_json, model_override) - + # Default/Fallback zu Ollama return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) - + except Exception as e: - # QUOTEN-SCHUTZ: Wenn Cloud (OpenRouter/Gemini) fehlschlägt, + # QUOTEN-SCHUTZ: Wenn Cloud (OpenRouter/Gemini) fehlschlägt, # gehen wir IMMER zu Ollama, niemals von OpenRouter zu Gemini. if self.settings.LLM_FALLBACK_ENABLED and provider != "ollama": - logger.warning(f"🔄 Provider {provider} failed: {e}. Falling back to LOCAL OLLAMA to protect cloud quotas.") + logger.warning( + f"🔄 Provider {provider} failed: {e}. Falling back to LOCAL OLLAMA to protect cloud quotas." + ) return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) raise e 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 + # Nutzt GEMINI_MODEL aus config.py falls kein override übergeben wurde model = model_override or self.settings.GEMINI_MODEL config = types.GenerateContentConfig( system_instruction=system, @@ -143,19 +209,52 @@ class LLMService: ) return response.text.strip() - async def _execute_openrouter(self, prompt, system, force_json, model_override): - """OpenRouter API Integration (OpenAI-kompatibel).""" - # Nutzt OPENROUTER_MODEL aus config.py (v0.6.2) + async def _execute_openrouter( + self, + prompt: str, + system: Optional[str], + force_json: bool, + model_override: Optional[str], + # --- NEW (optional) --- + json_schema: Optional[Dict[str, Any]] = None, + json_schema_name: str = "mindnet_json", + strict_json_schema: bool = True + ) -> str: + """ + OpenRouter API Integration (OpenAI-kompatibel). + + force_json=True: + - Ohne json_schema -> response_format={"type":"json_object"} + - Mit json_schema -> response_format={"type":"json_schema", "json_schema": {..., "strict": True}} + + Wichtig: response_format NICHT als None senden (robuster gegenüber SDK/Provider). + """ + # Nutzt OPENROUTER_MODEL aus config.py model = model_override or self.settings.OPENROUTER_MODEL messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) - + + kwargs: Dict[str, Any] = {} + + if force_json: + if json_schema: + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": json_schema_name, + "strict": strict_json_schema, + "schema": json_schema + } + } + else: + kwargs["response_format"] = {"type": "json_object"} + response = await self.openrouter_client.chat.completions.create( model=model, messages=messages, - response_format={"type": "json_object"} if force_json else None + **kwargs ) return response.choices[0].message.content.strip() @@ -167,11 +266,13 @@ class LLMService: "stream": False, "options": { "temperature": 0.1 if force_json else 0.7, - "num_ctx": 8192 + "num_ctx": 8192 } } - if force_json: payload["format"] = "json" - if system: payload["system"] = system + if force_json: + payload["format"] = "json" + if system: + payload["system"] = system attempt = 0 while True: @@ -193,16 +294,16 @@ class LLMService: provider = self.settings.MINDNET_LLM_PROVIDER system_prompt = self.get_prompt("system_prompt", provider) rag_template = self.get_prompt("rag_template", provider) - + final_prompt = rag_template.format(context_str=context_str, query=query) - + return await self.generate_raw_response( - final_prompt, - system=system_prompt, + final_prompt, + system=system_prompt, priority="realtime" ) async def close(self): """Schließt die HTTP-Verbindungen.""" if self.ollama_client: - await self.ollama_client.aclose() \ No newline at end of file + await self.ollama_client.aclose() diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index 2528e56..e911c82 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -3,7 +3,7 @@ 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: Volle Kompatibilität mit der gehärteten LLMService (v3.3.2) Kaskade. WP-22: Integration von valid_types zur Halluzinations-Vermeidung. -VERSION: 2.2.2 +VERSION: 2.2.3 STATUS: Active DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging LAST_ANALYSIS: 2025-12-23 @@ -29,26 +29,39 @@ class SemanticAnalyzer: """ Prüft, ob ein String eine valide Kante im Format 'kind:target' ist. Verhindert, dass LLM-Geschwätz als Kante durchrutscht. + + WP-22 Erweiterung: + - kind muss (wenn valid_types verfügbar) im kontrollierten Vokabular enthalten sein. """ if not isinstance(edge_str, str) or ":" not in edge_str: return False - + parts = edge_str.split(":", 1) kind = parts[0].strip() target = parts[1].strip() - + # Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten. if " " in kind: return False - + # Regel 2: Plausible Länge für den Typ if len(kind) > 40 or len(kind) < 2: return False - + # Regel 3: Target darf nicht leer sein if not target: return False - + + # WP-22: kontrolliertes Vokabular erzwingen (falls vorhanden/geladen) + try: + if hasattr(edge_registry, "valid_types") and edge_registry.valid_types: + if kind not in edge_registry.valid_types: + return False + except Exception: + # Bei Registry-Problemen lieber nicht crashen -> konservativ: ablehnen wäre auch möglich, + # aber wir bleiben kompatibel und robust. + pass + return True async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]: @@ -61,7 +74,7 @@ class SemanticAnalyzer: # 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 Not-Fallback.") @@ -77,14 +90,14 @@ class SemanticAnalyzer: edge_registry.ensure_latest() valid_types_str = ", ".join(sorted(list(edge_registry.valid_types))) edges_str = "\n".join([f"- {e}" for e in all_edges]) - + # LOG: Request Info logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.") # 3. Prompt füllen (FIX: valid_types hinzugefügt, um FormatError zu beheben) try: final_prompt = prompt_template.format( - chunk_text=chunk_text[:3500], + chunk_text=chunk_text[:3500], edge_list=edges_str, valid_types=valid_types_str ) @@ -94,10 +107,11 @@ class SemanticAnalyzer: try: # 4. LLM Call mit Traffic Control (Background Priority) + # NOTE: Keine neuen Parameter hier, damit es mit deinem aktuellen llm_service.py kompatibel bleibt. response_json = await self.llm.generate_raw_response( prompt=final_prompt, force_json=True, - max_retries=5, + max_retries=5, base_delay=5.0, priority="background" ) @@ -107,8 +121,8 @@ class SemanticAnalyzer: # 5. Parsing & Cleaning clean_json = response_json.replace("```json", "").replace("```", "").strip() - - if not clean_json: + + if not clean_json: logger.warning("⚠️ [SemanticAnalyzer] Leere Antwort vom LLM erhalten.") return [] @@ -122,15 +136,15 @@ class SemanticAnalyzer: # 6. Robuste Validierung (List vs Dict) raw_candidates = [] - + if isinstance(data, list): raw_candidates = data - + elif isinstance(data, dict): logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur.") for key, val in data.items(): if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list): - raw_candidates.extend(val) + raw_candidates.extend(val) elif isinstance(val, str): raw_candidates.append(f"{key}:{val}") elif isinstance(val, list): @@ -147,7 +161,7 @@ class SemanticAnalyzer: logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'") final_result = [e for e in valid_edges if ":" in e] - + if final_result: logger.info(f"✅ [SemanticAnalyzer] Success. {len(final_result)} Kanten zugewiesen.") else: @@ -169,4 +183,4 @@ def get_semantic_analyzer(): global _analyzer_instance if _analyzer_instance is None: _analyzer_instance = SemanticAnalyzer() - return _analyzer_instance \ No newline at end of file + return _analyzer_instance diff --git a/config/prompts.yaml b/config/prompts.yaml index 2dcea78..bae5767 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -184,13 +184,13 @@ edge_allocation_template: ERLAUBTE TYPEN: {valid_types} TEXT: {chunk_text} KANDIDATEN: {edge_list} - OUTPUT: STRIKT eine flache JSON-Liste ["typ:ziel"]. Keine Objekte! + OUTPUT: STRIKT eine flache JSON-Liste ["typ:ziel"]. Kein Text davor/danach. Wenn nichts: []. Keine Objekte! openrouter: | Filtere relevante Kanten. ERLAUBTE TYPEN: {valid_types} TEXT: {chunk_text} KANDIDATEN: {edge_list} - Output: JSON-Liste ["typ:ziel"]. + OUTPUT: STRIKT JSON-Liste ["typ:ziel"]. Kein Text davor/danach. Wenn nichts: []. # --------------------------------------------------------- # 7. SMART EDGE ALLOCATION: Extraktion (Intent: INGEST) @@ -221,9 +221,9 @@ edge_extraction: Analysiere '{note_id}'. Extrahiere semantische Beziehungen. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - OUTPUT: STRIKT JSON-Liste von Objekten: [[{{"to": "Ziel", "kind": "typ"}}]]. Keine Erklärungen! + OUTPUT: STRIKT JSON-Array von Objekten: [{{"to":"Ziel","kind":"typ"}}]. Kein Text davor/danach. Wenn nichts: []. openrouter: | Wissensgraph-Extraktion für '{note_id}'. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - OUTPUT: STRIKT JSON-Liste von Objekten: [[{{"to": "X", "kind": "Y"}}]]. Keine Dictionaries mit Schlüsseln wie 'edges'! \ No newline at end of file + OUTPUT: STRIKT JSON-Array von Objekten: [{{"to":"X","kind":"Y"}}]. Kein Text davor/danach. Wenn nichts: []. Keine Wrapper-Objekte (z.B. kein Top-Level-Key 'edges'). \ No newline at end of file From 079cf174d496f1e89bccbe28d97d2e39899d726b Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 24 Dec 2025 08:07:48 +0100 Subject: [PATCH 18/28] =?UTF-8?q?=C3=BCberarbeitet=20mit=20Gemini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/ingestion.py | 25 +++++++--- app/services/semantic_analyzer.py | 83 ++++++++++++------------------- 2 files changed, 50 insertions(+), 58 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index a86ed28..1fde168 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,10 +1,10 @@ """ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen. - WP-20: Smart Edge Allocation via Hybrid LLM (OpenRouter/Gemini). - WP-22: Content Lifecycle, Edge Registry Validation & Multi-Hash. -FIX: Behebung des AttributeError und Härtung des Prompt-Formattings. -VERSION: 2.11.7 + WP-20: Optimiert für OpenRouter (openai/gpt-oss-20b:free) als Primary. + WP-22: Fallback-Unterstützung für Google Gemini und Ollama. +FIX: Dynamische Provider-Wahl und Modell-Zuweisung für den Turbo-Modus. +VERSION: 2.11.9 STATUS: Active """ import os @@ -131,9 +131,19 @@ class IngestionService: return get_chunk_config(note_type) async def _perform_smart_edge_allocation(self, text: str, note_id: str) -> List[Dict]: - """Nutzt das Hybrid LLM für die semantische Kanten-Extraktion.""" - provider = "openrouter" if self.settings.OPENROUTER_API_KEY else self.settings.MINDNET_LLM_PROVIDER - model = self.settings.GEMMA_MODEL + """ + WP-20: Nutzt das Hybrid LLM für die semantische Kanten-Extraktion. + Bevorzugt den primär eingestellten Provider (z.B. OpenRouter). + """ + # 1. Provider & Modell Bestimmung (User-Request: OpenRouter Primary) + provider = self.settings.MINDNET_LLM_PROVIDER + + if provider == "openrouter": + model = self.settings.OPENROUTER_MODEL + elif provider == "gemini": + model = self.settings.GEMINI_MODEL + else: + model = self.settings.LLM_MODEL logger.info(f"🚀 [Ingestion] Turbo-Mode: Extracting edges for '{note_id}' using {model} on {provider}") @@ -238,7 +248,6 @@ class IngestionService: body_text = getattr(parsed, "body", "") or "" if hasattr(edge_registry, "ensure_latest"): edge_registry.ensure_latest() - # FIX: Behebung des AttributeError durch korrekten Aufruf der Klassenmethode chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index e911c82..b148e27 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -1,12 +1,12 @@ """ 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: Volle Kompatibilität mit der gehärteten LLMService (v3.3.2) Kaskade. + WP-20 Fix: Volle Kompatibilität mit der provider-basierten Routing-Logik (OpenRouter Primary). WP-22: Integration von valid_types zur Halluzinations-Vermeidung. VERSION: 2.2.3 STATUS: Active DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging -LAST_ANALYSIS: 2025-12-23 +LAST_ANALYSIS: 2025-12-24 """ import json @@ -29,53 +29,44 @@ class SemanticAnalyzer: """ Prüft, ob ein String eine valide Kante im Format 'kind:target' ist. Verhindert, dass LLM-Geschwätz als Kante durchrutscht. - - WP-22 Erweiterung: - - kind muss (wenn valid_types verfügbar) im kontrollierten Vokabular enthalten sein. """ if not isinstance(edge_str, str) or ":" not in edge_str: return False - + parts = edge_str.split(":", 1) kind = parts[0].strip() target = parts[1].strip() - + # Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten. if " " in kind: return False - + # Regel 2: Plausible Länge für den Typ if len(kind) > 40 or len(kind) < 2: return False - + # Regel 3: Target darf nicht leer sein if not target: return False - - # WP-22: kontrolliertes Vokabular erzwingen (falls vorhanden/geladen) - try: - if hasattr(edge_registry, "valid_types") and edge_registry.valid_types: - if kind not in edge_registry.valid_types: - return False - except Exception: - # Bei Registry-Problemen lieber nicht crashen -> konservativ: ablehnen wäre auch möglich, - # aber wir bleiben kompatibel und robust. - pass - + return True async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]: """ 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: Nutzt primär den Provider aus MINDNET_LLM_PROVIDER (OpenRouter). """ if not all_edges: return [] - # 1. Prompt laden via get_prompt (handelt die Provider-Kaskade automatisch ab) - prompt_template = self.llm.get_prompt("edge_allocation_template") + # 1. Bestimmung des Providers und Modells (WP-20) + # Wir ziehen die Werte direkt aus dem Service-Kontext + provider = self.llm.settings.MINDNET_LLM_PROVIDER + model = self.llm.settings.OPENROUTER_MODEL if provider == "openrouter" else None - # Sicherheits-Check für die Format-Methode + # 2. Prompt laden via get_prompt + prompt_template = self.llm.get_prompt("edge_allocation_template", provider) + if not prompt_template or isinstance(prompt_template, dict): logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' konnte nicht als String geladen werden. Nutze Not-Fallback.") prompt_template = ( @@ -85,45 +76,42 @@ class SemanticAnalyzer: "OUTPUT: JSON Liste von Strings [\"kind:target\"]." ) - # 2. Daten für Template vorbereiten (WP-22 Integration) - # Wir laden die validen Typen, um sie dem LLM als Leitplanken zu geben + # 3. Daten für Template vorbereiten (WP-22 Integration) edge_registry.ensure_latest() valid_types_str = ", ".join(sorted(list(edge_registry.valid_types))) edges_str = "\n".join([f"- {e}" for e in all_edges]) - - # LOG: Request Info + logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.") - # 3. Prompt füllen (FIX: valid_types hinzugefügt, um FormatError zu beheben) + # 4. Prompt füllen (FIX: valid_types hinzugefügt, um Format Error zu beheben) try: final_prompt = prompt_template.format( - chunk_text=chunk_text[:3500], + chunk_text=chunk_text[:3500], edge_list=edges_str, valid_types=valid_types_str ) except Exception as format_err: - logger.error(f"❌ [SemanticAnalyzer] Format Error im Prompt-Template (Fehlender Parameter): {format_err}") + logger.error(f"❌ [SemanticAnalyzer] Format Error im Prompt-Template: {format_err}") return [] try: - # 4. LLM Call mit Traffic Control (Background Priority) - # NOTE: Keine neuen Parameter hier, damit es mit deinem aktuellen llm_service.py kompatibel bleibt. + # 5. LLM Call mit Traffic Control (Background Priority) response_json = await self.llm.generate_raw_response( prompt=final_prompt, force_json=True, - max_retries=5, + max_retries=5, base_delay=5.0, - priority="background" + priority="background", + provider=provider, + model_override=model ) - # LOG: Raw Response Preview logger.debug(f"📥 [SemanticAnalyzer] Raw Response (Preview): {response_json[:200]}...") - # 5. Parsing & Cleaning + # 6. Parsing & Cleaning clean_json = response_json.replace("```json", "").replace("```", "").strip() - - if not clean_json: - logger.warning("⚠️ [SemanticAnalyzer] Leere Antwort vom LLM erhalten.") + + if not clean_json: return [] try: @@ -134,17 +122,15 @@ class SemanticAnalyzer: valid_edges = [] - # 6. Robuste Validierung (List vs Dict) + # 7. Robuste Validierung (List vs Dict) raw_candidates = [] - if isinstance(data, list): raw_candidates = data - elif isinstance(data, dict): logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur.") for key, val in data.items(): if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list): - raw_candidates.extend(val) + raw_candidates.extend(val) elif isinstance(val, str): raw_candidates.append(f"{key}:{val}") elif isinstance(val, list): @@ -152,7 +138,7 @@ class SemanticAnalyzer: if isinstance(target, str): raw_candidates.append(f"{key}:{target}") - # 7. Strict Validation Loop + # 8. Strict Validation Loop for e in raw_candidates: e_str = str(e) if self._is_valid_edge_string(e_str): @@ -161,12 +147,9 @@ class SemanticAnalyzer: logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'") final_result = [e for e in valid_edges if ":" in e] - + if final_result: logger.info(f"✅ [SemanticAnalyzer] Success. {len(final_result)} Kanten zugewiesen.") - else: - logger.debug(" [SemanticAnalyzer] Keine spezifischen Kanten erkannt (Empty Result).") - return final_result except Exception as e: @@ -183,4 +166,4 @@ def get_semantic_analyzer(): global _analyzer_instance if _analyzer_instance is None: _analyzer_instance = SemanticAnalyzer() - return _analyzer_instance + return _analyzer_instance \ No newline at end of file From f3fd71b828240e32c8154a58f9eec145a228538d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 08:25:56 +0100 Subject: [PATCH 19/28] WP20 - Planung WP15b --- docs/06_Roadmap/06_active_roadmap.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/06_Roadmap/06_active_roadmap.md b/docs/06_Roadmap/06_active_roadmap.md index 46ce660..55f9440 100644 --- a/docs/06_Roadmap/06_active_roadmap.md +++ b/docs/06_Roadmap/06_active_roadmap.md @@ -90,6 +90,26 @@ Diese Features stehen als nächstes an oder befinden sich in der Umsetzung. - Aufwand: Mittel - Komplexität: Niedrig/Mittel +### WP-15b – Candidate-Based Edge Validation & Inheritance +**Phase:** B/E (Refactoring & Semantic) +**Status:** 🚀 Startklar (Ersatz für WP-15 Logik) +**Ziel:** Ablösung der fehleranfälligen "Open-End-Extraktion" durch eine KI-gestützte Validierung menschlicher Vorarbeit zur Steigerung von Speed, Integrität und semantischer Tiefe. + +**Herausforderung:** +Der bisherige WP-15 Ansatz litt unter Halluzinationen (erfundene Kantentypen), hohem Token-Verbrauch und dem Verlust physikalisch gesetzter Kanten bei der Chunk-Verteilung. + +**Anforderungen & Strategie:** +1. **Hard-Link Integrity:** Kanten, die im Markdown-Text eines Chunks stehen, werden zwingend und ohne LLM-Prüfung gesetzt (`provenance: explicit`). +2. **Edge Inheritance:** Kanten, die auf Dokument-Ebene (Frontmatter) oder Sektions-Ebene (Heading) definiert sind, werden automatisch an alle zugehörigen Sub-Chunks vererbt, wenn ein semantischer Block aufgrund von Größengrenzen geteilt wurde. +3. **Candidate Pool Extraction:** Definition eines "Edge-Pools" pro Dokument. Dieser speist sich aus dem Frontmatter und einer speziellen Sektion (z. B. `### Unzugeordnete Kanten`). +4. **Semantic Validation Gate:** Das LLM fungiert als binärer Validator. Es prüft ausschließlich Kanten aus dem Kandidaten-Pool gegen den konkreten Chunk-Inhalt UND eine Zusammenfassung der Ziel-Note (Inhalt, Typ, Kanten-Typ). +5. **Registry Enforcement:** Strikte Blockade von Halluzinationen. Nur Kanten, die im Pool definiert UND im `edge_vocabulary.md` vorhanden sind, werden zugelassen. + +**Lösungsskizze:** +* **Parser-Update:** `extract_candidate_pool` zur Identifikation aller im Dokument beabsichtigten Links. +* **Chunker-Update:** Implementierung einer `propagate_edges`-Logik für "by_heading" und "sliding_window" Strategien. +* **Ingestion-Update:** Umstellung von `_perform_smart_edge_allocation` auf einen binären Validierungs-Prompt (VALID/INVALID). + ### WP-19a – Graph Intelligence & Discovery (Sprint-Fokus) **Status:** 🚀 Startklar **Ziel:** Vom "Anschauen" zum "Verstehen". Deep-Dive Werkzeuge für den Graphen. @@ -233,4 +253,5 @@ graph TD WP03(Import Pipeline) --> WP21 WP21 --> WP22(Lifecycle & Registry) WP22 --> WP14 - WP15(Smart Edges) --> WP21 \ No newline at end of file + WP15(Smart Edges) --> WP21 + WP20(Cloud Hybrid) --> WP15b \ No newline at end of file From 5c5522937617e8a8a6ce5a2f0448c924d1182395 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 08:38:08 +0100 Subject: [PATCH 20/28] bereinigung Code Basis, wegfall von Platzhaltern und Annahmen. Volle Kofigurierbarkeit --- app/core/ingestion.py | 113 ++++++++++++++++-------------- app/services/semantic_analyzer.py | 103 ++++++++++++++------------- config/prompts.yaml | 39 ++++++----- 3 files changed, 140 insertions(+), 115 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 1fde168..1894078 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -2,10 +2,11 @@ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen. WP-20: Optimiert für OpenRouter (openai/gpt-oss-20b:free) als Primary. - WP-22: Fallback-Unterstützung für Google Gemini und Ollama. -FIX: Dynamische Provider-Wahl und Modell-Zuweisung für den Turbo-Modus. -VERSION: 2.11.9 + WP-22: Content Lifecycle, Edge Registry Validation & Multi-Hash. +FIX: Finale DoD-Härtung, Entfernung aller Shortcuts und Stabilitätspatch. +VERSION: 2.11.10 STATUS: Active +DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.services.llm_service, app.services.edge_registry """ import os import json @@ -46,7 +47,7 @@ from app.services.llm_service import LLMService logger = logging.getLogger(__name__) -# --- Helper --- +# --- Global Helpers --- def extract_json_from_response(text: str) -> Any: """Extrahiert JSON-Daten, selbst wenn sie in Markdown-Blöcken stehen.""" if not text: return [] @@ -56,7 +57,7 @@ def extract_json_from_response(text: str) -> Any: try: return json.loads(clean_text.strip()) except json.JSONDecodeError: - # Versuch: Alles vor der ersten [ und nach der letzten ] entfernen + # Versuch: Alles vor der ersten [ und nach der letzten ] entfernen (Recovery) start = clean_text.find('[') end = clean_text.rfind(']') + 1 if start != -1 and end != 0: @@ -65,6 +66,7 @@ def extract_json_from_response(text: str) -> Any: raise def load_type_registry(custom_path: Optional[str] = None) -> dict: + """Lädt die types.yaml zur Steuerung der typ-spezifischen Ingestion.""" import yaml from app.config import get_settings settings = get_settings() @@ -74,30 +76,7 @@ def load_type_registry(custom_path: Optional[str] = None) -> dict: with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} except Exception: return {} -def resolve_note_type(requested: Optional[str], reg: dict) -> str: - types = reg.get("types", {}) - if requested and requested in types: return requested - return "concept" - -def effective_chunk_profile_name(fm: dict, note_type: str, reg: dict) -> str: - override = fm.get("chunking_profile") or fm.get("chunk_profile") - 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: - 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"]) - return float(reg.get("defaults", {}).get("retriever_weight", 1.0)) - - +# --- Service Class --- class IngestionService: def __init__(self, collection_prefix: str = None): from app.config import get_settings @@ -120,8 +99,14 @@ class IngestionService: except Exception as e: logger.warning(f"DB init warning: {e}") + def _resolve_note_type(self, requested: Optional[str]) -> str: + """Bestimmt den finalen Notiz-Typ (Fallback auf 'concept').""" + types = self.registry.get("types", {}) + if requested and requested in types: return requested + return "concept" + def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: - """Holt die Chunker-Parameter für ein spezifisches Profil.""" + """Holt die Chunker-Parameter für ein spezifisches Profil aus der Registry.""" profiles = self.registry.get("chunking_profiles", {}) if profile_name in profiles: cfg = profiles[profile_name].copy() @@ -133,11 +118,11 @@ class IngestionService: async def _perform_smart_edge_allocation(self, text: str, note_id: str) -> List[Dict]: """ WP-20: Nutzt das Hybrid LLM für die semantische Kanten-Extraktion. - Bevorzugt den primär eingestellten Provider (z.B. OpenRouter). + Respektiert die Provider-Einstellung (OpenRouter Primary). """ - # 1. Provider & Modell Bestimmung (User-Request: OpenRouter Primary) provider = self.settings.MINDNET_LLM_PROVIDER + # Modell-Zuordnung basierend auf Provider-Wahl (Keine festen Annahmen) if provider == "openrouter": model = self.settings.OPENROUTER_MODEL elif provider == "gemini": @@ -153,8 +138,9 @@ class IngestionService: template = self.llm.get_prompt("edge_extraction", provider) try: - # FIX: Format-Safety Block gegen KeyError: '"to"' + # Sicherheits-Check: Formatierung des Templates gegen KeyError schützen try: + # Nutzt die ersten 6000 Zeichen als Kontext-Fenster (DoD: Explizit dokumentiert) prompt = template.format( text=text[:6000], note_id=note_id, @@ -169,19 +155,23 @@ class IngestionService: provider=provider, model_override=model ) + # Robustes JSON-Parsing via Helper raw_data = extract_json_from_response(response_json) + # Recovery: Suche nach Listen in Dictionaries (z.B. {"edges": [...]}) if isinstance(raw_data, dict): for k in ["edges", "links", "results", "kanten"]: if k in raw_data and isinstance(raw_data[k], list): raw_data = raw_data[k] break - if not isinstance(raw_data, list): return [] + if not isinstance(raw_data, list): + logger.warning(f"⚠️ [Ingestion] LLM lieferte keine Liste für {note_id}") + return [] processed = [] for item in raw_data: - # FIX: Schutz vor 'str' object does not support item assignment + # Fix für 'str' object assignment error: Erkennt sowohl Dict als auch String ["kind:target"] if isinstance(item, dict) and "to" in item: item["provenance"] = "semantic_ai" item["line"] = f"ai-{provider}" @@ -205,9 +195,10 @@ class IngestionService: force_replace: bool = False, apply: bool = False, purge_before: bool = False, note_scope_refs: bool = False, hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: - """Verarbeitet eine Markdown-Datei und schreibt sie in den Graphen.""" + """Transformiert eine Markdown-Datei in den Graphen (Notes, Chunks, Edges).""" result = {"path": file_path, "status": "skipped", "changed": False, "error": None} + # 1. Parse & Lifecycle Gate try: parsed = read_markdown(file_path) if not parsed: return {**result, "error": "Empty file"} @@ -220,55 +211,71 @@ class IngestionService: if status in ["system", "template", "archive", "hidden"]: return {**result, "status": "skipped", "reason": f"lifecycle_{status}"} - note_type = resolve_note_type(fm.get("type"), self.registry) + # 2. Config Resolution & Payload Construction + note_type = self._resolve_note_type(fm.get("type")) fm["type"] = note_type - effective_profile = effective_chunk_profile_name(fm, note_type, self.registry) - effective_weight = effective_retriever_weight(fm, note_type, self.registry) try: note_pl = make_note_payload(parsed, vault_root=vault_root, hash_normalize=hash_normalize, hash_source=hash_source, file_path=file_path) - note_pl["retriever_weight"] = effective_weight - note_pl["chunk_profile"] = effective_profile - note_pl["status"] = status note_id = note_pl["note_id"] except Exception as e: return {**result, "error": f"Payload failed: {str(e)}"} + # 3. Change Detection (Strikte DoD Umsetzung: Kein Shortcut) old_payload = None if force_replace else self._fetch_note_payload(note_id) check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" old_hash = (old_payload or {}).get("hashes", {}).get(check_key) new_hash = note_pl.get("hashes", {}).get(check_key) - should_write = force_replace or (not old_payload) or (old_hash != new_hash) or any(self._artifacts_missing(note_id)) + # Prüfung auf fehlende Artefakte in Qdrant + chunks_missing, edges_missing = self._artifacts_missing(note_id) + + should_write = force_replace or (not old_payload) or (old_hash != new_hash) or chunks_missing or edges_missing - if not should_write: return {**result, "status": "unchanged", "note_id": note_id} - if not apply: return {**result, "status": "dry-run", "changed": True, "note_id": note_id} + if not should_write: + return {**result, "status": "unchanged", "note_id": note_id} + if not apply: + return {**result, "status": "dry-run", "changed": True, "note_id": note_id} + + # 4. Processing (Chunking, Embedding, AI Edges) try: body_text = getattr(parsed, "body", "") or "" - if hasattr(edge_registry, "ensure_latest"): edge_registry.ensure_latest() + edge_registry.ensure_latest() - chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) - chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) + # Profil-gesteuertes Chunking + profile = fm.get("chunk_profile") or fm.get("chunking_profile") or "sliding_standard" + chunk_cfg = self._get_chunk_config_by_profile(profile, note_type) + chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_cfg) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) - vecs = await self.embedder.embed_documents([c.get("window") or c.get("text") or "" for c in chunk_pls]) if chunk_pls else [] + # Vektorisierung + vecs = [] + if chunk_pls: + texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] + vecs = await self.embedder.embed_documents(texts) + # Kanten-Extraktion edges = [] context = {"file": file_path, "note_id": note_id} + # A. Explizite Kanten (User) for e in extract_edges_with_context(parsed): e["kind"] = edge_registry.resolve(edge_type=e["kind"], provenance="explicit", context={**context, "line": e.get("line")}) edges.append(e) + # B. KI Kanten (Turbo) 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")}) + valid_kind = edge_registry.resolve(edge_type=e.get("kind"), provenance="semantic_ai", context={**context, "line": e.get("line")}) + e["kind"] = valid_kind edges.append(e) + # C. System Kanten (Struktur) try: sys_edges = build_edges_for_note(note_id, chunk_pls, note_level_references=note_pl.get("references", []), include_note_scope_refs=note_scope_refs) - except: sys_edges = build_edges_for_note(note_id, chunk_pls) + except: + sys_edges = build_edges_for_note(note_id, chunk_pls) for e in sys_edges: valid_kind = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"}) @@ -280,8 +287,10 @@ class IngestionService: logger.error(f"Processing failed for {file_path}: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} + # 5. DB Upsert try: if purge_before and old_payload: self._purge_artifacts(note_id) + n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) upsert_batch(self.client, n_name, n_pts) @@ -306,6 +315,7 @@ class IngestionService: except: return None def _artifacts_missing(self, note_id: str) -> Tuple[bool, bool]: + """Prüft Qdrant aktiv auf vorhandene Chunks und Edges (Kein Shortcut).""" from qdrant_client.http import models as rest try: f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) @@ -322,6 +332,7 @@ class IngestionService: except: 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) diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index b148e27..df95a16 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -3,14 +3,15 @@ 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: Volle Kompatibilität mit der provider-basierten Routing-Logik (OpenRouter Primary). WP-22: Integration von valid_types zur Halluzinations-Vermeidung. -VERSION: 2.2.3 +FIX: Finale DoD-Härtung, Entfernung von Hardcoded Limits und optimiertes Error-Handling. +VERSION: 2.2.4 STATUS: Active DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging -LAST_ANALYSIS: 2025-12-24 """ import json import logging +import re from typing import List, Optional from dataclasses import dataclass @@ -41,7 +42,7 @@ class SemanticAnalyzer: if " " in kind: return False - # Regel 2: Plausible Länge für den Typ + # Regel 2: Plausible Länge für den Typ (Vermeidet Sätze als Typ) if len(kind) > 40 or len(kind) < 2: return False @@ -54,21 +55,21 @@ class SemanticAnalyzer: async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]: """ Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM. - WP-20: Nutzt primär den Provider aus MINDNET_LLM_PROVIDER (OpenRouter). + Das LLM filtert heraus, welche Kanten für diesen Chunk relevant sind. + WP-20: Nutzt primär den konfigurierten Provider (z.B. OpenRouter). """ if not all_edges: return [] - # 1. Bestimmung des Providers und Modells (WP-20) - # Wir ziehen die Werte direkt aus dem Service-Kontext + # 1. Bestimmung des Providers und Modells (Dynamisch über Settings) provider = self.llm.settings.MINDNET_LLM_PROVIDER - model = self.llm.settings.OPENROUTER_MODEL if provider == "openrouter" else None + model = self.llm.settings.OPENROUTER_MODEL if provider == "openrouter" else self.llm.settings.GEMINI_MODEL - # 2. Prompt laden via get_prompt + # 2. Prompt laden (Provider-spezifisch) prompt_template = self.llm.get_prompt("edge_allocation_template", provider) - if not prompt_template or isinstance(prompt_template, dict): - logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' konnte nicht als String geladen werden. Nutze Not-Fallback.") + if not prompt_template or not isinstance(prompt_template, str): + logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' ungültig. Nutze Recovery-Template.") prompt_template = ( "TASK: Wähle aus den Kandidaten die relevanten Kanten für den Text.\n" "TEXT: {chunk_text}\n" @@ -76,91 +77,99 @@ class SemanticAnalyzer: "OUTPUT: JSON Liste von Strings [\"kind:target\"]." ) - # 3. Daten für Template vorbereiten (WP-22 Integration) + # 3. Daten für Template vorbereiten (Vokabular-Check) edge_registry.ensure_latest() valid_types_str = ", ".join(sorted(list(edge_registry.valid_types))) edges_str = "\n".join([f"- {e}" for e in all_edges]) logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.") - # 4. Prompt füllen (FIX: valid_types hinzugefügt, um Format Error zu beheben) + # 4. Prompt füllen mit Format-Check (Kein Shortcut) try: + # Wir begrenzen den Text auf eine vernünftige Länge für das Kontextfenster (ca. 10k Tokens max) final_prompt = prompt_template.format( - chunk_text=chunk_text[:3500], + chunk_text=chunk_text[:6000], edge_list=edges_str, valid_types=valid_types_str ) except Exception as format_err: - logger.error(f"❌ [SemanticAnalyzer] Format Error im Prompt-Template: {format_err}") + logger.error(f"❌ [SemanticAnalyzer] Prompt Formatting failed: {format_err}") return [] try: - # 5. LLM Call mit Traffic Control (Background Priority) + # 5. LLM Call mit Background Priority & Semaphore Control response_json = await self.llm.generate_raw_response( prompt=final_prompt, force_json=True, - max_retries=5, - base_delay=5.0, + max_retries=3, + base_delay=2.0, priority="background", provider=provider, model_override=model ) - logger.debug(f"📥 [SemanticAnalyzer] Raw Response (Preview): {response_json[:200]}...") - - # 6. Parsing & Cleaning - clean_json = response_json.replace("```json", "").replace("```", "").strip() + # 6. Bulletproof JSON Extraction (Analog zur Ingestion) + # Entfernt Markdown-Code-Blöcke falls vorhanden + match = re.search(r"```(?:json)?\s*(.*?)\s*```", response_json, re.DOTALL) + clean_json = match.group(1) if match else response_json + clean_json = clean_json.strip() if not clean_json: return [] try: data = json.loads(clean_json) - except json.JSONDecodeError as json_err: - logger.error(f"❌ [SemanticAnalyzer] JSON Decode Error: {json_err}") - return [] + except json.JSONDecodeError: + # Letzter Rettungsversuch: Suche nach dem ersten '[' und letzten ']' + start = clean_json.find('[') + end = clean_json.rfind(']') + 1 + if start != -1 and end != 0: + try: + data = json.loads(clean_json[start:end]) + except: + logger.error("❌ [SemanticAnalyzer] JSON Recovery failed.") + return [] + else: + return [] - valid_edges = [] - - # 7. Robuste Validierung (List vs Dict) + # 7. Robuste Normalisierung (List vs Dict Recovery) raw_candidates = [] if isinstance(data, list): raw_candidates = data elif isinstance(data, dict): - logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur.") - for key, val in data.items(): - if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list): - raw_candidates.extend(val) - elif isinstance(val, str): - raw_candidates.append(f"{key}:{val}") - elif isinstance(val, list): - for target in val: - if isinstance(target, str): - raw_candidates.append(f"{key}:{target}") + logger.info(f"ℹ️ [SemanticAnalyzer] LLM returned dict, trying recovery.") + for key in ["edges", "results", "kanten", "matches"]: + if key in data and isinstance(data[key], list): + raw_candidates.extend(data[key]) + break + # Falls immer noch leer, nutze Schlüssel-Wert Paare als Behelf + if not raw_candidates: + for k, v in data.items(): + if isinstance(v, str): raw_candidates.append(f"{k}:{v}") + elif isinstance(v, list): [raw_candidates.append(f"{k}:{i}") for i in v if isinstance(i, str)] - # 8. Strict Validation Loop + # 8. Strikte Validierung gegen Kanten-Format + valid_edges = [] for e in raw_candidates: - e_str = str(e) + e_str = str(e).strip() if self._is_valid_edge_string(e_str): valid_edges.append(e_str) else: - logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'") + logger.debug(f" [SemanticAnalyzer] Rejected invalid edge format: '{e_str}'") - final_result = [e for e in valid_edges if ":" in e] - - if final_result: - logger.info(f"✅ [SemanticAnalyzer] Success. {len(final_result)} Kanten zugewiesen.") - return final_result + if valid_edges: + logger.info(f"✅ [SemanticAnalyzer] Assigned {len(valid_edges)} edges to chunk.") + return valid_edges except Exception as e: - logger.error(f"💥 [SemanticAnalyzer] Kritischer Fehler: {e}", exc_info=True) + logger.error(f"💥 [SemanticAnalyzer] Critical error during analysis: {e}", exc_info=True) return [] async def close(self): if self.llm: await self.llm.close() -# Singleton Helper +# Singleton Instanziierung _analyzer_instance = None def get_semantic_analyzer(): global _analyzer_instance diff --git a/config/prompts.yaml b/config/prompts.yaml index bae5767..bec60d4 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,7 +1,7 @@ -# config/prompts.yaml — Final V2.5.2 (Strict Hybrid Support) -# WP-20: Optimierte Cloud-Templates. -# FIX: Technische Maskierung (Doppel-Klammern) zur Vermeidung von KeyError: '"to"'. -# OLLAMA: Unverändert laut Benutzeranweisung. +# config/prompts.yaml — Final V2.5.4 (Strict Hybrid & OpenRouter Primary) +# WP-20: Optimierte Cloud-Templates für OpenRouter (openai/gpt-oss-20b:free). +# FIX: Vollständige technische Maskierung (Doppel-Klammern) zur Vermeidung von KeyError: '"to"'. +# OLLAMA: UNVERÄNDERT laut Benutzeranweisung. system_prompt: | Du bist 'mindnet', mein digitaler Zwilling und strategischer Partner. @@ -33,10 +33,13 @@ rag_template: Fasse die Informationen zusammen. Sei objektiv und neutral. gemini: | Kontext meines digitalen Zwillings: {context_str} - Beantworte strukturiert: {query} + Beantworte strukturiert und präzise: {query} openrouter: | - Kontext: {context_str} + Kontext-Analyse für den digitalen Zwilling: + {context_str} + Anfrage: {query} + Antworte basierend auf dem Kontext. # --------------------------------------------------------- # 2. DECISION: Strategie & Abwägung (Intent: DECISION) @@ -62,10 +65,10 @@ decision_template: - **Abgleich:** (Gibt es Konflikte mit Werten/Zielen? Nenne die Quelle!) - **Empfehlung:** (Klare Meinung: Ja/No/Vielleicht mit Begründung) gemini: | - Agiere als strategischer Partner. Analysiere {query} basierend auf {context_str}. + Agiere als strategischer Partner. Analysiere die Frage {query} basierend auf meinen Werten im Kontext {context_str}. openrouter: | - Entscheidungsanalyse für: {query} - Datenbasis: {context_str} + Strategische Entscheidungsanalyse: {query} + Wertebasis aus dem Graphen: {context_str} # --------------------------------------------------------- # 3. EMPATHY: Der Spiegel / "Ich"-Modus (Intent: EMPATHY) @@ -89,7 +92,7 @@ empathy_template: TONFALL: Ruhig, verständnisvoll, reflektiert. Keine Aufzählungszeichen, sondern fließender Text. gemini: "Sei mein digitaler Spiegel für {query}. Kontext: {context_str}" - openrouter: "Empathische Analyse: {query}. Kontext: {context_str}" + openrouter: "Empathische Reflexion der Situation {query}. Persönlicher Kontext: {context_str}" # --------------------------------------------------------- # 4. TECHNICAL: Der Coder (Intent: CODING) @@ -115,7 +118,7 @@ technical_template: - Markdown Code-Block (Copy-Paste fertig). - Wichtige Edge-Cases. gemini: "Generiere Code für {query} unter Berücksichtigung von {context_str}." - openrouter: "Technischer Support: {query}. Kontext: {context_str}" + openrouter: "Technischer Support für {query}. Code-Referenzen: {context_str}" # --------------------------------------------------------- # 5. INTERVIEW: Der "One-Shot Extractor" (Performance Mode) @@ -153,7 +156,7 @@ interview_template: ## (Zweiter Begriff aus STRUKTUR) (Text...) gemini: "Extrahiere Daten für {target_type} aus {query}." - openrouter: "Strukturiere {query} nach {schema_fields}." + openrouter: "Strukturiere den Input {query} nach dem Schema {schema_fields} für Typ {target_type}." # --------------------------------------------------------- # 6. EDGE_ALLOCATION: Kantenfilter (Intent: OFFLINE_FILTER) @@ -186,11 +189,11 @@ edge_allocation_template: KANDIDATEN: {edge_list} OUTPUT: STRIKT eine flache JSON-Liste ["typ:ziel"]. Kein Text davor/danach. Wenn nichts: []. Keine Objekte! openrouter: | - Filtere relevante Kanten. + Filtere relevante Kanten aus dem Pool. ERLAUBTE TYPEN: {valid_types} TEXT: {chunk_text} KANDIDATEN: {edge_list} - OUTPUT: STRIKT JSON-Liste ["typ:ziel"]. Kein Text davor/danach. Wenn nichts: []. + OUTPUT: STRIKT eine flache JSON-Liste von Strings: [["typ:ziel"]]. Kein Text, keine Erklärung. Wenn leer: []. # --------------------------------------------------------- # 7. SMART EDGE ALLOCATION: Extraktion (Intent: INGEST) @@ -221,9 +224,11 @@ edge_extraction: Analysiere '{note_id}'. Extrahiere semantische Beziehungen. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - OUTPUT: STRIKT JSON-Array von Objekten: [{{"to":"Ziel","kind":"typ"}}]. Kein Text davor/danach. Wenn nichts: []. + OUTPUT: STRIKT JSON-Array von Objekten: [[{{"to":"Ziel","kind":"typ"}}]]. Kein Text davor/danach. Wenn nichts: []. openrouter: | - Wissensgraph-Extraktion für '{note_id}'. + Wissensgraph-Extraktion für die Notiz '{note_id}'. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - OUTPUT: STRIKT JSON-Array von Objekten: [{{"to":"X","kind":"Y"}}]. Kein Text davor/danach. Wenn nichts: []. Keine Wrapper-Objekte (z.B. kein Top-Level-Key 'edges'). \ No newline at end of file + ANWEISUNG: Finde Relationen zu anderen Konzepten. + OUTPUT: STRIKT JSON-Array von Objekten: [[{{"to":"X","kind":"Y"}}]]. + Regeln: Kein Text davor/danach. Kein Wrapper-Objekt (kein 'edges' Key). Wenn leer: []. \ No newline at end of file From b4a07a05af4e9982a1045970d8513a48b8a5d620 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 08:41:33 +0100 Subject: [PATCH 21/28] =?UTF-8?q?Nachz=C3=BCgler=20f=C3=BCr=20Config=20mit?= =?UTF-8?q?=20abgleich=20auf=20die=20neue=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/config.py b/app/config.py index 6e469c9..dc701d8 100644 --- a/app/config.py +++ b/app/config.py @@ -4,8 +4,8 @@ DESCRIPTION: Zentrale Pydantic-Konfiguration. Enthält Parameter für Qdrant, Embeddings, Ollama, Google GenAI und OpenRouter. WP-20: Optimiert für Hybrid-Cloud Modus und Vektor-Synchronisation. WP-22: Integration von Change-Detection und Vocab-Paths. -FIX: Hinzufügen von load_dotenv(), um Umgebungsvariablen aus .env aktiv zu laden. -VERSION: 0.6.3 +FIX: Hinzufügen von load_dotenv() und Bereinigung redundanter Modell-Variablen. +VERSION: 0.6.4 STATUS: Active DEPENDENCIES: os, functools, pathlib, python-dotenv """ @@ -16,7 +16,7 @@ from pathlib import Path from dotenv import load_dotenv # WP-20: Lade Umgebungsvariablen aus der .env Datei -# Muss vor dem Zugriff auf os.getenv erfolgen! +# Muss zwingend vor dem Zugriff auf os.getenv erfolgen! load_dotenv() class Settings: @@ -24,6 +24,7 @@ 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") + # 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") @@ -36,14 +37,13 @@ class Settings: # Erlaubt: "ollama" | "gemini" | "openrouter" MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "ollama").lower() - # Google AI Studio (Direkt) + # Google AI Studio (Direkt-Integration) 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", "google/gemma-2-9b-it:free") - # OpenRouter Integration + # OpenRouter Integration (Primärlösung für Turbo-Ingest) OPENROUTER_API_KEY: str | None = os.getenv("OPENROUTER_API_KEY") - OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "google/gemma-2-9b-it:free") + OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free") LLM_FALLBACK_ENABLED: bool = os.getenv("MINDNET_LLM_FALLBACK", "true").lower() == "true" @@ -62,9 +62,11 @@ class Settings: MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault") MINDNET_TYPES_FILE: str = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") MINDNET_VOCAB_PATH: str = os.getenv("MINDNET_VOCAB_PATH", "vault/_system/dictionary/edge_vocabulary.md") + + # WP-22: 'full' aktiviert Multi-Hash Change Detection CHANGE_DETECTION_MODE: str = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") - # --- WP-04 Retriever Gewichte --- + # --- WP-04 Retriever Gewichte (Finetuning des Scorings) --- 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")) @@ -74,4 +76,5 @@ class Settings: @lru_cache def get_settings() -> Settings: + """Gibt eine gecachte Instanz der Einstellungen zurück (Singleton-Muster).""" return Settings() \ No newline at end of file From 56dd1bcd84cf15c0cabbd6377a03ca59f46b99ae Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 08:45:32 +0100 Subject: [PATCH 22/28] WP20 Abschluss --- config/decision_engine.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/decision_engine.yaml b/config/decision_engine.yaml index bae0d1d..7274153 100644 --- a/config/decision_engine.yaml +++ b/config/decision_engine.yaml @@ -2,6 +2,7 @@ # Steuerung der Decision Engine (Intent Recognition & Graph Routing) # VERSION: 2.6.1 (WP-20: Hybrid LLM & WP-22: Semantic Graph Routing) # STATUS: Active +# DoD: Keine Hardcoded Modelle, volle Integration der strategischen Boosts. version: 2.6 @@ -37,13 +38,13 @@ settings: STRATEGIE: strategies: - # 1. Fakten-Abfrage (Turbo-Modus via OpenRouter) + # 1. Fakten-Abfrage (Turbo-Modus via OpenRouter / Primary) FACT: description: "Reine Wissensabfrage." preferred_provider: "openrouter" trigger_keywords: [] inject_types: [] - # WP-22: Definitionen & Hierarchien bevorzugen + # WP-22: Definitionen & Hierarchien im Graphen bevorzugen edge_boosts: part_of: 2.0 composed_of: 2.0 @@ -115,6 +116,7 @@ strategies: - "yaml" - "bash" inject_types: ["snippet", "reference", "source"] + # WP-22: Technische Abhängigkeiten priorisieren edge_boosts: uses: 2.5 depends_on: 2.0 From 4ac504a4aea3fcc7746243bdc03d757f628643df Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 09:43:47 +0100 Subject: [PATCH 23/28] =?UTF-8?q?Anpassung=20der=20prompts=20f=C3=BCr=20op?= =?UTF-8?q?enrouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/prompts.yaml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/config/prompts.yaml b/config/prompts.yaml index bec60d4..13b800d 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -1,6 +1,6 @@ -# config/prompts.yaml — Final V2.5.4 (Strict Hybrid & OpenRouter Primary) -# WP-20: Optimierte Cloud-Templates für OpenRouter (openai/gpt-oss-20b:free). -# FIX: Vollständige technische Maskierung (Doppel-Klammern) zur Vermeidung von KeyError: '"to"'. +# config/prompts.yaml — Final V2.5.5 (OpenRouter Hardening) +# WP-20: Optimierte Cloud-Templates zur Unterdrückung von Modell-Geschwätz. +# FIX: Explizite Verbote für Einleitungstexte zur Vermeidung von JSON-Parsing-Fehlern. # OLLAMA: UNVERÄNDERT laut Benutzeranweisung. system_prompt: | @@ -189,11 +189,14 @@ edge_allocation_template: KANDIDATEN: {edge_list} OUTPUT: STRIKT eine flache JSON-Liste ["typ:ziel"]. Kein Text davor/danach. Wenn nichts: []. Keine Objekte! openrouter: | - Filtere relevante Kanten aus dem Pool. + TASK: Filtere relevante Kanten aus dem Pool. ERLAUBTE TYPEN: {valid_types} TEXT: {chunk_text} - KANDIDATEN: {edge_list} - OUTPUT: STRIKT eine flache JSON-Liste von Strings: [["typ:ziel"]]. Kein Text, keine Erklärung. Wenn leer: []. + POOL: {edge_list} + ANWEISUNG: Gib NUR eine flache JSON-Liste von Strings zurück. + BEISPIEL: ["kind:target", "kind:target"] + REGEL: Kein Text, keine Analyse, keine Kommentare. Wenn nichts passt, gib [] zurück. + OUTPUT: # --------------------------------------------------------- # 7. SMART EDGE ALLOCATION: Extraktion (Intent: INGEST) @@ -226,9 +229,11 @@ edge_extraction: TEXT: {text} OUTPUT: STRIKT JSON-Array von Objekten: [[{{"to":"Ziel","kind":"typ"}}]]. Kein Text davor/danach. Wenn nichts: []. openrouter: | - Wissensgraph-Extraktion für die Notiz '{note_id}'. + TASK: Extrahiere semantische Relationen für '{note_id}'. ERLAUBTE TYPEN: {valid_types} TEXT: {text} - ANWEISUNG: Finde Relationen zu anderen Konzepten. - OUTPUT: STRIKT JSON-Array von Objekten: [[{{"to":"X","kind":"Y"}}]]. - Regeln: Kein Text davor/danach. Kein Wrapper-Objekt (kein 'edges' Key). Wenn leer: []. \ No newline at end of file + ANWEISUNG: Antworte AUSSCHLIESSLICH mit einem JSON-Array von Objekten. + FORMAT: [[{{"to":"Ziel-Begriff","kind":"typ"}}]] + STRIKTES VERBOT: Schreibe keine Einleitung, keine Analyse und keine Erklärungen. + Wenn keine Relationen existieren, antworte NUR mit: [] + OUTPUT: \ No newline at end of file From fa6eb0795a46c761f39050ce3987eb63018ff82b Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 09:58:25 +0100 Subject: [PATCH 24/28] Config Anpassungen WP20 --- app/config.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/app/config.py b/app/config.py index dc701d8..fa91515 100644 --- a/app/config.py +++ b/app/config.py @@ -1,11 +1,9 @@ """ FILE: app/config.py -DESCRIPTION: Zentrale Pydantic-Konfiguration. Enthält Parameter für Qdrant, - Embeddings, Ollama, Google GenAI und OpenRouter. - WP-20: Optimiert für Hybrid-Cloud Modus und Vektor-Synchronisation. - WP-22: Integration von Change-Detection und Vocab-Paths. -FIX: Hinzufügen von load_dotenv() und Bereinigung redundanter Modell-Variablen. -VERSION: 0.6.4 +DESCRIPTION: Zentrale Pydantic-Konfiguration. + WP-20: Hybrid-Cloud Modus Support (OpenRouter/Gemini/Ollama). + FIX: Update auf Gemini 2.5 Serie & Optimierung für Gemma 2 Durchsatz. +VERSION: 0.6.6 STATUS: Active DEPENDENCIES: os, functools, pathlib, python-dotenv """ @@ -16,34 +14,36 @@ from pathlib import Path from dotenv import load_dotenv # WP-20: Lade Umgebungsvariablen aus der .env Datei -# Muss zwingend vor dem Zugriff auf os.getenv erfolgen! -load_dotenv() +# override=True garantiert, dass Änderungen in der .env immer Vorrang haben. +load_dotenv(override=True) class Settings: # --- Qdrant Datenbank --- 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") + COLLECTION_PREFIX: str = os.getenv("MINDNET_PREFIX", "mindnet_dev") - # WP-22: Vektor-Dimension muss mit dem Embedding-Modell (nomic) übereinstimmen + # WP-22: Vektor-Dimension für das Embedding-Modell (nomic) VECTOR_SIZE: int = int(os.getenv("VECTOR_DIM", "768")) DISTANCE: str = os.getenv("MINDNET_DISTANCE", "Cosine") - # --- Lokale Embeddings (Ollama & Sentence-Transformers) --- + # --- Lokale Embeddings --- EMBEDDING_MODEL: str = os.getenv("MINDNET_EMBEDDING_MODEL", "nomic-embed-text") MODEL_NAME: str = os.getenv("MINDNET_MODEL", "sentence-transformers/all-MiniLM-L6-v2") # --- WP-20 Hybrid LLM Provider --- - # Erlaubt: "ollama" | "gemini" | "openrouter" - MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "ollama").lower() + # "openrouter" ist primär für den Ingest-Turbo mit Gemma 2 empfohlen. + MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "openrouter").lower() - # Google AI Studio (Direkt-Integration) + # Google AI Studio (Fallback auf 2.5-Serie) GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY") - GEMINI_MODEL: str = os.getenv("MINDNET_GEMINI_MODEL", "gemini-1.5-flash") + # "gemini-2.5-flash-lite" ist die skalierbare 2025-Alternative für hohe Last. + GEMINI_MODEL: str = os.getenv("MINDNET_GEMINI_MODEL", "gemini-2.5-flash-lite") - # OpenRouter Integration (Primärlösung für Turbo-Ingest) + # OpenRouter Integration (openai/gpt-oss-20b:free oder gemma-2) OPENROUTER_API_KEY: str | None = os.getenv("OPENROUTER_API_KEY") - OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free") + # "google/gemma-2-9b-it:free" bietet hohe Kapazität bei Kostenfreiheit. + OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "google/gemma-2-9b-it:free") LLM_FALLBACK_ENABLED: bool = os.getenv("MINDNET_LLM_FALLBACK", "true").lower() == "true" @@ -52,21 +52,21 @@ class Settings: LLM_MODEL: str = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") PROMPTS_PATH: str = os.getenv("MINDNET_PROMPTS_PATH", "config/prompts.yaml") - # --- WP-06 / WP-14 Performance & Last-Steuerung --- + # --- Performance & Last-Steuerung --- 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 & Ingestion-Logik --- DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" - MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault") + MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault_master") MINDNET_TYPES_FILE: str = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") - MINDNET_VOCAB_PATH: str = os.getenv("MINDNET_VOCAB_PATH", "vault/_system/dictionary/edge_vocabulary.md") + MINDNET_VOCAB_PATH: str = os.getenv("MINDNET_VOCAB_PATH", "/mindnet/vault/mindnet/_system/dictionary/edge_vocabulary.md") - # WP-22: 'full' aktiviert Multi-Hash Change Detection + # WP-22: 'full' für Multi-Hash Change Detection CHANGE_DETECTION_MODE: str = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") - # --- WP-04 Retriever Gewichte (Finetuning des Scorings) --- + # --- WP-04 Retriever Gewichte --- 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")) @@ -76,5 +76,5 @@ class Settings: @lru_cache def get_settings() -> Settings: - """Gibt eine gecachte Instanz der Einstellungen zurück (Singleton-Muster).""" + """Gibt die zentralen Einstellungen als Singleton zurück.""" return Settings() \ No newline at end of file From c9cf1b7e4c5024899a0e36a78c66e02df0a6892d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 11:50:08 +0100 Subject: [PATCH 25/28] WP20 - Chat Interface auf neue Struktur der decision.yaml --- app/routers/chat.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/routers/chat.py b/app/routers/chat.py index ae44547..986c131 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -1,8 +1,9 @@ """ FILE: app/routers/chat.py DESCRIPTION: Haupt-Chat-Interface (RAG & Interview). Enthält Intent-Router (Keywords/LLM) und Prompt-Construction. -VERSION: 2.7.0 (WP-22 Semantic Graph Routing) +VERSION: 2.7.1 (WP-22 Semantic Graph Routing) STATUS: Active +FIX: Umstellung auf llm.get_prompt() zur Behebung des 500 Server Errors (Dictionary replace crash). DEPENDENCIES: app.config, app.models.dto, app.services.llm_service, app.core.retriever, app.services.feedback_service EXTERNAL_CONFIG: config/decision_engine.yaml, config/types.yaml """ @@ -199,7 +200,8 @@ async def _classify_intent(query: str, llm: LLMService) -> tuple[str, str]: # 3. SLOW PATH: LLM Router if settings.get("llm_fallback_enabled", False): - router_prompt_template = llm.prompts.get("router_prompt", "") + # FIX: Nutze get_prompt statt direktem Zugriff auf dict + router_prompt_template = llm.get_prompt("router_prompt") if router_prompt_template: prompt = router_prompt_template.replace("{query}", query) @@ -262,7 +264,8 @@ async def chat_endpoint( logger.info(f"[{query_id}] Interview Type: {target_type}. Fields: {len(fields_list)}") fields_str = "\n- " + "\n- ".join(fields_list) - template = llm.prompts.get(prompt_key, "") + # FIX: Nutze get_prompt() zur Auflösung der provider-spezifischen Templates + template = llm.get_prompt(prompt_key) final_prompt = template.replace("{context_str}", "Dialogverlauf...") \ .replace("{query}", request.message) \ .replace("{target_type}", target_type) \ @@ -276,7 +279,6 @@ async def chat_endpoint( prepend_instr = strategy.get("prepend_instruction", "") # --- WP-22: Semantic Graph Routing (Teil C) --- - # Wir laden die konfigurierten Edge-Boosts für diesen Intent edge_boosts = strategy.get("edge_boosts", {}) if edge_boosts: logger.info(f"[{query_id}] Applying Edge Boosts: {edge_boosts}") @@ -286,7 +288,6 @@ async def chat_endpoint( mode="hybrid", top_k=request.top_k, explain=request.explain, - # WP-22: Boosts an den Retriever weitergeben boost_edges=edge_boosts ) retrieve_result = await retriever.search(query_req) @@ -299,7 +300,6 @@ async def chat_endpoint( top_k=3, filters={"type": inject_types}, explain=False, - # WP-22: Boosts auch hier anwenden (Konsistenz) boost_edges=edge_boosts ) strategy_result = await retriever.search(strategy_req) @@ -313,8 +313,12 @@ async def chat_endpoint( else: context_str = _build_enriched_context(hits) - template = llm.prompts.get(prompt_key, "{context_str}\n\n{query}") + # FIX: Nutze get_prompt() zur Auflösung der provider-spezifischen Templates + template = llm.get_prompt(prompt_key) + if not template: + template = "{context_str}\n\n{query}" + if prepend_instr: context_str = f"{prepend_instr}\n\n{context_str}" @@ -322,7 +326,7 @@ async def chat_endpoint( sources_hits = hits # --- GENERATION --- - system_prompt = llm.prompts.get("system_prompt", "") + system_prompt = llm.get_prompt("system_prompt") # Chat nutzt IMMER realtime priority answer_text = await llm.generate_raw_response( From ecfdc67485ebcbcae6182f5495f1ac9d9b65663e Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 13:03:42 +0100 Subject: [PATCH 26/28] neue Version mit Wartezeit bei externen LLM Fehler --- app/config.py | 26 +++--- app/services/llm_service.py | 155 ++++++++++++++++++------------------ 2 files changed, 93 insertions(+), 88 deletions(-) diff --git a/app/config.py b/app/config.py index fa91515..aa0d9dd 100644 --- a/app/config.py +++ b/app/config.py @@ -2,8 +2,8 @@ FILE: app/config.py DESCRIPTION: Zentrale Pydantic-Konfiguration. WP-20: Hybrid-Cloud Modus Support (OpenRouter/Gemini/Ollama). - FIX: Update auf Gemini 2.5 Serie & Optimierung für Gemma 2 Durchsatz. -VERSION: 0.6.6 + FIX: Einführung von Parametern zur intelligenten Rate-Limit Steuerung (429 Handling). +VERSION: 0.6.7 STATUS: Active DEPENDENCIES: os, functools, pathlib, python-dotenv """ @@ -27,32 +27,36 @@ class Settings: VECTOR_SIZE: int = int(os.getenv("VECTOR_DIM", "768")) DISTANCE: str = os.getenv("MINDNET_DISTANCE", "Cosine") - # --- Lokale Embeddings --- + # --- Lokale Embeddings (Ollama & Sentence-Transformers) --- EMBEDDING_MODEL: str = os.getenv("MINDNET_EMBEDDING_MODEL", "nomic-embed-text") MODEL_NAME: str = os.getenv("MINDNET_MODEL", "sentence-transformers/all-MiniLM-L6-v2") # --- WP-20 Hybrid LLM Provider --- - # "openrouter" ist primär für den Ingest-Turbo mit Gemma 2 empfohlen. + # Erlaubt: "ollama" | "gemini" | "openrouter" MINDNET_LLM_PROVIDER: str = os.getenv("MINDNET_LLM_PROVIDER", "openrouter").lower() - # Google AI Studio (Fallback auf 2.5-Serie) + # Google AI Studio (2025er Lite-Modell für höhere Kapazität) GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY") - # "gemini-2.5-flash-lite" ist die skalierbare 2025-Alternative für hohe Last. GEMINI_MODEL: str = os.getenv("MINDNET_GEMINI_MODEL", "gemini-2.5-flash-lite") - # OpenRouter Integration (openai/gpt-oss-20b:free oder gemma-2) + # OpenRouter Integration (Verfügbares Free-Modell 2025) OPENROUTER_API_KEY: str | None = os.getenv("OPENROUTER_API_KEY") - # "google/gemma-2-9b-it:free" bietet hohe Kapazität bei Kostenfreiheit. - OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "google/gemma-2-9b-it:free") + OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "mistralai/mistral-7b-instruct:free") LLM_FALLBACK_ENABLED: bool = os.getenv("MINDNET_LLM_FALLBACK", "true").lower() == "true" + # --- NEU: Intelligente Rate-Limit Steuerung --- + # Dauer der Wartezeit in Sekunden, wenn ein HTTP 429 (Rate Limit) auftritt + LLM_RATE_LIMIT_WAIT: float = float(os.getenv("MINDNET_LLM_RATE_LIMIT_WAIT", "60.0")) + # Anzahl der Cloud-Retries bei 429, bevor Ollama-Fallback greift + LLM_RATE_LIMIT_RETRIES: int = int(os.getenv("MINDNET_LLM_RATE_LIMIT_RETRIES", "3")) + # --- WP-05 Lokales LLM (Ollama) --- OLLAMA_URL: str = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") LLM_MODEL: str = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") PROMPTS_PATH: str = os.getenv("MINDNET_PROMPTS_PATH", "config/prompts.yaml") - # --- Performance & Last-Steuerung --- + # --- WP-06 / WP-14 Performance & Last-Steuerung --- 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")) @@ -62,8 +66,6 @@ class Settings: MINDNET_VAULT_ROOT: str = os.getenv("MINDNET_VAULT_ROOT", "./vault_master") MINDNET_TYPES_FILE: str = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") MINDNET_VOCAB_PATH: str = os.getenv("MINDNET_VOCAB_PATH", "/mindnet/vault/mindnet/_system/dictionary/edge_vocabulary.md") - - # WP-22: 'full' für Multi-Hash Change Detection CHANGE_DETECTION_MODE: str = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") # --- WP-04 Retriever Gewichte --- diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 6115fe7..9de2d89 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -4,10 +4,11 @@ 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. WP-20 Fix: Bulletproof Prompt-Auflösung für format() Aufrufe. - WP-22/JSON: Optionales JSON-Schema + strict (für OpenRouter structured outputs), - OHNE Breaking Changes (neue Parameter nur am Ende). -VERSION: 3.3.3 + WP-22/JSON: Optionales JSON-Schema + strict (für OpenRouter structured outputs). + FIX: Intelligente Rate-Limit Erkennung (429 Handling), v1-API Sync & Timeouts. +VERSION: 3.3.6 STATUS: Active +DEPENDENCIES: httpx, yaml, logging, asyncio, json, google-genai, openai, app.config """ import httpx import yaml @@ -47,7 +48,11 @@ class LLMService: # 2. Google GenAI Client (Modern SDK) self.google_client = None if self.settings.GOOGLE_API_KEY: - self.google_client = genai.Client(api_key=self.settings.GOOGLE_API_KEY) + # FIX: Wir erzwingen api_version 'v1' für höhere Stabilität bei 2.5er Modellen. + self.google_client = genai.Client( + api_key=self.settings.GOOGLE_API_KEY, + http_options={'api_version': 'v1'} + ) logger.info("✨ LLMService: Google GenAI (Gemini) active.") # 3. OpenRouter Client @@ -55,7 +60,9 @@ class LLMService: if self.settings.OPENROUTER_API_KEY: self.openrouter_client = AsyncOpenAI( base_url="https://openrouter.ai/api/v1", - api_key=self.settings.OPENROUTER_API_KEY + api_key=self.settings.OPENROUTER_API_KEY, + # Strikter Timeout für OpenRouter Free-Tier zur Vermeidung von Hangs. + timeout=45.0 ) logger.info("🛰️ LLMService: OpenRouter Integration active.") @@ -84,7 +91,7 @@ class LLMService: data = self.prompts.get(key, "") if isinstance(data, dict): - # Wir versuchen erst den Provider, dann Gemini (weil ähnlich leistungsfähig), dann Ollama + # Wir versuchen erst den Provider, dann Gemini, dann 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 @@ -105,51 +112,32 @@ class LLMService: priority: Literal["realtime", "background"] = "realtime", provider: Optional[str] = None, model_override: Optional[str] = None, - # --- NEW (am Ende => rückwärtskompatibel!) --- json_schema: Optional[Dict[str, Any]] = None, json_schema_name: str = "mindnet_json", strict_json_schema: bool = True ) -> str: """ Haupteinstiegspunkt für LLM-Anfragen mit Priorisierung. - + force_json: - Ollama: nutzt payload["format"]="json" - Gemini: nutzt response_mime_type="application/json" - - OpenRouter: nutzt response_format=json_object (Fallback) oder json_schema (structured outputs) - - json_schema + strict_json_schema (nur OpenRouter relevant): - - Wenn json_schema gesetzt ist UND force_json=True -> response_format.type="json_schema" - - strict_json_schema wird an OpenRouter/Provider weitergereicht (best effort je nach Provider) + - OpenRouter: nutzt response_format=json_object (Fallback) oder json_schema """ target_provider = provider or self.settings.MINDNET_LLM_PROVIDER if priority == "background": async with LLMService._background_semaphore: return await self._dispatch( - target_provider, - prompt, - system, - force_json, - max_retries, - base_delay, - model_override, - json_schema, - json_schema_name, - strict_json_schema + target_provider, prompt, system, force_json, + max_retries, base_delay, model_override, + json_schema, json_schema_name, strict_json_schema ) return await self._dispatch( - target_provider, - prompt, - system, - force_json, - max_retries, - base_delay, - model_override, - json_schema, - json_schema_name, - strict_json_schema + target_provider, prompt, system, force_json, + max_retries, base_delay, model_override, + json_schema, json_schema_name, strict_json_schema ) async def _dispatch( @@ -165,47 +153,73 @@ class LLMService: json_schema_name: str, strict_json_schema: bool ) -> str: - """Routet die Anfrage an den physikalischen API-Provider.""" - try: - if provider == "openrouter" and self.openrouter_client: - return await self._execute_openrouter( - prompt=prompt, - system=system, - force_json=force_json, - model_override=model_override, - json_schema=json_schema, - json_schema_name=json_schema_name, - strict_json_schema=strict_json_schema - ) + """ + Routet die Anfrage mit intelligenter Rate-Limit Erkennung (WP-20 + WP-76). + Schleife läuft über MINDNET_LLM_RATE_LIMIT_RETRIES. + """ + rate_limit_attempts = 0 + max_rate_retries = getattr(self.settings, "LLM_RATE_LIMIT_RETRIES", 3) + wait_time = getattr(self.settings, "LLM_RATE_LIMIT_WAIT", 60.0) - if provider == "gemini" and self.google_client: - return await self._execute_google(prompt, system, force_json, model_override) + while rate_limit_attempts <= max_rate_retries: + try: + if provider == "openrouter" and self.openrouter_client: + return await self._execute_openrouter( + prompt=prompt, + system=system, + force_json=force_json, + model_override=model_override, + json_schema=json_schema, + json_schema_name=json_schema_name, + strict_json_schema=strict_json_schema + ) - # Default/Fallback zu Ollama - return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) + if provider == "gemini" and self.google_client: + return await self._execute_google(prompt, system, force_json, model_override) - except Exception as e: - # QUOTEN-SCHUTZ: Wenn Cloud (OpenRouter/Gemini) fehlschlägt, - # gehen wir IMMER zu Ollama, niemals von OpenRouter zu Gemini. - if self.settings.LLM_FALLBACK_ENABLED and provider != "ollama": - logger.warning( - f"🔄 Provider {provider} failed: {e}. Falling back to LOCAL OLLAMA to protect cloud quotas." - ) + # Default/Fallback zu Ollama return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) - raise e + + except Exception as e: + err_str = str(e) + # Intelligente 429 Erkennung für alle Cloud-Provider + is_rate_limit = any(x in err_str for x in ["429", "RESOURCE_EXHAUSTED", "rate_limited", "Too Many Requests"]) + + if is_rate_limit and rate_limit_attempts < max_rate_retries: + rate_limit_attempts += 1 + logger.warning( + f"⏳ [LLMService] Rate Limit (429) detected from {provider}. " + f"Attempt {rate_limit_attempts}/{max_rate_retries}. " + f"Waiting {wait_time}s before cloud retry..." + ) + await asyncio.sleep(wait_time) + continue # Nächster Versuch in der Cloud-Schleife + + # Wenn kein Rate-Limit oder Retries erschöpft -> Fallback zu Ollama (falls aktiviert) + if self.settings.LLM_FALLBACK_ENABLED and provider != "ollama": + logger.warning( + f"🔄 Provider {provider} failed ({err_str}). Falling back to LOCAL OLLAMA." + ) + return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay) + raise e 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 übergeben wurde + """Native Google SDK Integration (Gemini) mit v1 Fix.""" model = model_override or self.settings.GEMINI_MODEL + # Fix: Bereinige Modellnamen (Entfernung von 'models/' Präfix) + clean_model = model.replace("models/", "") + config = types.GenerateContentConfig( system_instruction=system, response_mime_type="application/json" if force_json else "text/plain" ) - # SDK Call in Thread auslagern, da die Google API blocking sein kann - response = await asyncio.to_thread( - self.google_client.models.generate_content, - model=model, contents=prompt, config=config + # Thread-Offloading mit striktem Timeout gegen "Hangs" + response = await asyncio.wait_for( + asyncio.to_thread( + self.google_client.models.generate_content, + model=clean_model, contents=prompt, config=config + ), + timeout=45.0 ) return response.text.strip() @@ -215,21 +229,11 @@ class LLMService: system: Optional[str], force_json: bool, model_override: Optional[str], - # --- NEW (optional) --- json_schema: Optional[Dict[str, Any]] = None, json_schema_name: str = "mindnet_json", strict_json_schema: bool = True ) -> str: - """ - OpenRouter API Integration (OpenAI-kompatibel). - - force_json=True: - - Ohne json_schema -> response_format={"type":"json_object"} - - Mit json_schema -> response_format={"type":"json_schema", "json_schema": {..., "strict": True}} - - Wichtig: response_format NICHT als None senden (robuster gegenüber SDK/Provider). - """ - # Nutzt OPENROUTER_MODEL aus config.py + """OpenRouter API Integration (OpenAI-kompatibel) mit Schema-Support.""" model = model_override or self.settings.OPENROUTER_MODEL messages = [] if system: @@ -237,7 +241,6 @@ class LLMService: messages.append({"role": "user", "content": prompt}) kwargs: Dict[str, Any] = {} - if force_json: if json_schema: kwargs["response_format"] = { @@ -306,4 +309,4 @@ class LLMService: async def close(self): """Schließt die HTTP-Verbindungen.""" if self.ollama_client: - await self.ollama_client.aclose() + await self.ollama_client.aclose() \ No newline at end of file From 16e128668c892729e24b4b2879299aaada4f1f3e Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 17:17:55 +0100 Subject: [PATCH 27/28] Mistral sichere Parser implemntierung --- app/core/ingestion.py | 63 +++++++++++++++---------- app/services/semantic_analyzer.py | 77 ++++++++++++++++++++----------- 2 files changed, 87 insertions(+), 53 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 1894078..53c28cc 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,10 +1,10 @@ """ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen. - WP-20: Optimiert für OpenRouter (openai/gpt-oss-20b:free) als Primary. + WP-20: Optimiert für OpenRouter (mistralai/mistral-7b-instruct:free). WP-22: Content Lifecycle, Edge Registry Validation & Multi-Hash. -FIX: Finale DoD-Härtung, Entfernung aller Shortcuts und Stabilitätspatch. -VERSION: 2.11.10 +FIX: Finale Mistral-Härtung ( & [OUT] Tags), robuste JSON-Recovery & DoD-Sync. +VERSION: 2.11.11 STATUS: Active DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.services.llm_service, app.services.edge_registry """ @@ -49,21 +49,41 @@ logger = logging.getLogger(__name__) # --- Global Helpers --- def extract_json_from_response(text: str) -> Any: - """Extrahiert JSON-Daten, selbst wenn sie in Markdown-Blöcken stehen.""" + """ + Extrahiert JSON-Daten und bereinigt LLM-Steuerzeichen (Mistral/Llama). + Entfernt , [OUT], [/OUT] und Markdown-Blöcke für maximale Robustheit. + """ if not text: return [] - # Suche nach ```json ... ``` oder ``` ... ``` - match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL) - clean_text = match.group(1) if match else text + + # 1. Entferne Mistral/Llama Steuerzeichen und Tags + clean = text.replace("", "").replace("", "") + clean = clean.replace("[OUT]", "").replace("[/OUT]", "") + clean = clean.strip() + + # 2. Suche nach Markdown JSON-Blöcken (```json ... ```) + match = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL) + payload = match.group(1) if match else clean + try: - return json.loads(clean_text.strip()) + return json.loads(payload.strip()) except json.JSONDecodeError: - # Versuch: Alles vor der ersten [ und nach der letzten ] entfernen (Recovery) - start = clean_text.find('[') - end = clean_text.rfind(']') + 1 - if start != -1 and end != 0: - try: return json.loads(clean_text[start:end]) + # 3. Recovery: Suche nach der ersten [ und letzten ] (Liste) + start = payload.find('[') + end = payload.rfind(']') + 1 + if start != -1 and end > start: + try: + return json.loads(payload[start:end]) except: pass - raise + + # 4. Zweite Recovery: Suche nach der ersten { und letzten } (Objekt) + start_obj = payload.find('{') + end_obj = payload.rfind('}') + 1 + if start_obj != -1 and end_obj > start_obj: + try: + return json.loads(payload[start_obj:end_obj]) + except: pass + + return [] def load_type_registry(custom_path: Optional[str] = None) -> dict: """Lädt die types.yaml zur Steuerung der typ-spezifischen Ingestion.""" @@ -121,14 +141,7 @@ class IngestionService: Respektiert die Provider-Einstellung (OpenRouter Primary). """ provider = self.settings.MINDNET_LLM_PROVIDER - - # Modell-Zuordnung basierend auf Provider-Wahl (Keine festen Annahmen) - if provider == "openrouter": - model = self.settings.OPENROUTER_MODEL - elif provider == "gemini": - model = self.settings.GEMINI_MODEL - else: - model = self.settings.LLM_MODEL + model = self.settings.OPENROUTER_MODEL if provider == "openrouter" else self.settings.GEMINI_MODEL logger.info(f"🚀 [Ingestion] Turbo-Mode: Extracting edges for '{note_id}' using {model} on {provider}") @@ -140,14 +153,14 @@ class IngestionService: try: # Sicherheits-Check: Formatierung des Templates gegen KeyError schützen try: - # Nutzt die ersten 6000 Zeichen als Kontext-Fenster (DoD: Explizit dokumentiert) + # Nutzt die ersten 6000 Zeichen als Kontext-Fenster prompt = template.format( text=text[:6000], note_id=note_id, valid_types=valid_types_str ) except KeyError as ke: - logger.error(f"❌ [Ingestion] Prompt-Template Fehler (Variable {ke} fehlt). Prüfe prompts.yaml Maskierung.") + logger.error(f"❌ [Ingestion] Prompt-Template Fehler (Variable {ke} fehlt).") return [] response_json = await self.llm.generate_raw_response( @@ -155,7 +168,7 @@ class IngestionService: provider=provider, model_override=model ) - # Robustes JSON-Parsing via Helper + # Nutzt den verbesserten Mistral-sicheren JSON-Extraktor raw_data = extract_json_from_response(response_json) # Recovery: Suche nach Listen in Dictionaries (z.B. {"edges": [...]}) diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index df95a16..2d492a5 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -3,16 +3,16 @@ 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: Volle Kompatibilität mit der provider-basierten Routing-Logik (OpenRouter Primary). WP-22: Integration von valid_types zur Halluzinations-Vermeidung. -FIX: Finale DoD-Härtung, Entfernung von Hardcoded Limits und optimiertes Error-Handling. -VERSION: 2.2.4 +FIX: Mistral-sicheres JSON-Parsing ( & [OUT] Handling) und 100% Logik-Erhalt. +VERSION: 2.2.6 STATUS: Active -DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging +DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging, re """ import json import logging import re -from typing import List, Optional +from typing import List, Optional, Any from dataclasses import dataclass # Importe @@ -52,6 +52,43 @@ class SemanticAnalyzer: return True + def _extract_json_safely(self, text: str) -> Any: + """ + Extrahiert JSON-Daten und bereinigt LLM-Steuerzeichen (Mistral/Llama). + Implementiert robuste Recovery-Logik für Cloud-Provider. + """ + if not text: + return [] + + # 1. Entferne Mistral/Llama Steuerzeichen und Tags + clean = text.replace("", "").replace("", "") + clean = clean.replace("[OUT]", "").replace("[/OUT]", "") + clean = clean.strip() + + # 2. Suche nach Markdown JSON-Blöcken + match = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL) + payload = match.group(1) if match else clean + + try: + return json.loads(payload.strip()) + except json.JSONDecodeError: + # 3. Recovery: Suche nach der ersten [ und letzten ] + start = payload.find('[') + end = payload.rfind(']') + 1 + if start != -1 and end > start: + try: + return json.loads(payload[start:end]) + except: pass + + # 4. Zweite Recovery: Suche nach der ersten { und letzten } + start_obj = payload.find('{') + end_obj = payload.rfind('}') + 1 + if start_obj != -1 and end_obj > start_obj: + try: + return json.loads(payload[start_obj:end_obj]) + except: pass + return [] + async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]: """ Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM. @@ -65,7 +102,7 @@ class SemanticAnalyzer: provider = self.llm.settings.MINDNET_LLM_PROVIDER model = self.llm.settings.OPENROUTER_MODEL if provider == "openrouter" else self.llm.settings.GEMINI_MODEL - # 2. Prompt laden (Provider-spezifisch) + # 2. Prompt laden (Provider-spezifisch via get_prompt) prompt_template = self.llm.get_prompt("edge_allocation_template", provider) if not prompt_template or not isinstance(prompt_template, str): @@ -86,7 +123,7 @@ class SemanticAnalyzer: # 4. Prompt füllen mit Format-Check (Kein Shortcut) try: - # Wir begrenzen den Text auf eine vernünftige Länge für das Kontextfenster (ca. 10k Tokens max) + # Wir begrenzen den Text auf eine vernünftige Länge für das Kontextfenster final_prompt = prompt_template.format( chunk_text=chunk_text[:6000], edge_list=edges_str, @@ -108,30 +145,12 @@ class SemanticAnalyzer: model_override=model ) - # 6. Bulletproof JSON Extraction (Analog zur Ingestion) - # Entfernt Markdown-Code-Blöcke falls vorhanden - match = re.search(r"```(?:json)?\s*(.*?)\s*```", response_json, re.DOTALL) - clean_json = match.group(1) if match else response_json - clean_json = clean_json.strip() + # 6. Mistral-sicheres JSON Parsing via Helper + data = self._extract_json_safely(response_json) - if not clean_json: + if not data: return [] - try: - data = json.loads(clean_json) - except json.JSONDecodeError: - # Letzter Rettungsversuch: Suche nach dem ersten '[' und letzten ']' - start = clean_json.find('[') - end = clean_json.rfind(']') + 1 - if start != -1 and end != 0: - try: - data = json.loads(clean_json[start:end]) - except: - logger.error("❌ [SemanticAnalyzer] JSON Recovery failed.") - return [] - else: - return [] - # 7. Robuste Normalisierung (List vs Dict Recovery) raw_candidates = [] if isinstance(data, list): @@ -146,7 +165,9 @@ class SemanticAnalyzer: if not raw_candidates: for k, v in data.items(): if isinstance(v, str): raw_candidates.append(f"{k}:{v}") - elif isinstance(v, list): [raw_candidates.append(f"{k}:{i}") for i in v if isinstance(i, str)] + elif isinstance(v, list): + for target in v: + if isinstance(target, str): raw_candidates.append(f"{k}:{target}") # 8. Strikte Validierung gegen Kanten-Format valid_edges = [] From 27b404560c60debaf1a4958a77b6022c8c9cd8cf Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 25 Dec 2025 19:39:16 +0100 Subject: [PATCH 28/28] Dokumentation WP20 --- config/prod.env | 48 +++++++++++++ docs/00_General/00_glossary.md | 21 +++--- docs/02_concepts/02_concept_ai_personality.md | 71 ++++++++++--------- .../03_tech_chat_backend.md | 60 ++++++++++------ .../03_tech_configuration.md | 58 ++++++++------- .../03_tech_ingestion_pipeline.md | 23 +++--- docs/06_Roadmap/06_active_roadmap.md | 25 ++++--- 7 files changed, 193 insertions(+), 113 deletions(-) create mode 100644 config/prod.env diff --git a/config/prod.env b/config/prod.env new file mode 100644 index 0000000..ae3f569 --- /dev/null +++ b/config/prod.env @@ -0,0 +1,48 @@ +# --- FastAPI Server (Produktion) --- +UVICORN_HOST=0.0.0.0 +UVICORN_PORT=8000 +DEBUG=false + +# --- Qdrant Vektor-Datenbank --- +# Trennung der Daten durch eigenes Prefix +QDRANT_URL=http://127.0.0.1:6333 +QDRANT_API_KEY= +COLLECTION_PREFIX=mindnet + +# --- Vektoren-Konfiguration --- +# Muss 768 für 'nomic-embed-text' sein +VECTOR_DIM=768 + +# --- AI Modelle (Lokal/Fallback) --- +MINDNET_LLM_MODEL=phi3:mini +MINDNET_OLLAMA_URL=http://127.0.0.1:11434 +MINDNET_LLM_TIMEOUT=300.0 +MINDNET_LLM_BACKGROUND_LIMIT=2 + +# Vektor-Modell für semantische Suche +MINDNET_EMBEDDING_MODEL=nomic-embed-text + +# --- WP-20/WP-76: Hybrid-Cloud & Resilienz --- +# Primärer Provider für höchste Qualität +MINDNET_LLM_PROVIDER=openrouter +MINDNET_LLM_FALLBACK=true + +# Intelligente Rate-Limit Steuerung (Sekunden/Versuche) +MINDNET_LLM_RATE_LIMIT_WAIT=60.0 +MINDNET_LLM_RATE_LIMIT_RETRIES=3 + +# --- Cloud Provider Keys (Hier Prod-Keys einsetzen) --- +GOOGLE_API_KEY=AIzaSy... (Dein Prod-Key) +MINDNET_GEMINI_MODEL=gemini-2.5-flash-lite + +OPENROUTER_API_KEY=sk-or-v1-... (Dein Prod-Key) +# Stabilstes Free-Modell für strukturierte Extraktion +OPENROUTER_MODEL=mistralai/mistral-7b-instruct:free + +# --- Pfade & System (Produktions-Vault) --- +MINDNET_TYPES_FILE=./config/types.yaml +MINDNET_VAULT_ROOT=./vault_prod +MINDNET_VOCAB_PATH=/mindnet/vault/mindnet/_system/dictionary/edge_vocabulary.md + +# Change Detection für effiziente Re-Imports +MINDNET_CHANGE_DETECTION_MODE=full \ No newline at end of file diff --git a/docs/00_General/00_glossary.md b/docs/00_General/00_glossary.md index 7c65dad..a3ee1f4 100644 --- a/docs/00_General/00_glossary.md +++ b/docs/00_General/00_glossary.md @@ -2,13 +2,13 @@ doc_type: glossary audience: all status: active -version: 2.7.0 -context: "Zentrales Glossar für Mindnet v2.7. Definitionen von Entitäten, WP-22 Scoring-Konzepten und der Edge Registry." +version: 2.8.0 +context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-Cloud Resilienz, WP-76 Quoten-Steuerung und Mistral-safe Parsing." --- # Mindnet Glossar -**Quellen:** `01_edge_vocabulary.md`, `retriever_scoring.py`, `edge_registry.py` +**Quellen:** `01_edge_vocabulary.md`, `llm_service.py`, `ingestion.py`, `edge_registry.py` ## Kern-Entitäten @@ -21,21 +21,22 @@ context: "Zentrales Glossar für Mindnet v2.7. Definitionen von Entitäten, WP-2 ## Komponenten * **Edge Registry:** Der zentrale Dienst (SSOT), der Kanten-Typen validiert und Aliase in kanonische Typen auflöst. Nutzt `01_edge_vocabulary.md` als Basis. -* **Retriever:** Besteht in v2.7 aus der Orchestrierung (`retriever.py`) und der mathematischen Scoring-Engine (`retriever_scoring.py`). +* **LLM Service:** Der Hybrid-Client (v3.3.6), der Anfragen zwischen OpenRouter, Google Gemini und lokalem Ollama routet. Verwaltet Cloud-Timeouts und Quoten-Management. +* **Retriever:** Besteht in v2.7+ aus der Orchestrierung (`retriever.py`) und der mathematischen Scoring-Engine (`retriever_scoring.py`). * **Decision Engine:** Teil des Routers, der Intents erkennt und entsprechende **Boost-Faktoren** für das Retrieval injiziert. -* **Traffic Control:** Verwaltet Prioritäten und drosselt Hintergrund-Tasks (z.B. Smart Edges) mittels Semaphoren. +* **Traffic Control:** Verwaltet Prioritäten und drosselt Hintergrund-Tasks (z.B. Smart Edges) mittels Semaphoren und Timeouts (45s) zur Vermeidung von System-Hangs. * **Unknown Edges Log:** Die Datei `unknown_edges.jsonl`, in der das System Kanten-Typen protokolliert, die nicht im Dictionary gefunden wurden. ## Konzepte & Features -* **Canonical Type:** Der standardisierte System-Name einer Kante (z.B. `based_on`), der in der Datenbank gespeichert wird. -* **Alias (Edge):** Ein nutzerfreundliches Synonym (z.B. `basiert_auf`), das während der Ingestion automatisch zum Canonical Type aufgelöst wird. +* **Hybrid Provider Cascade:** Die intelligente Reihenfolge der Modell-Ansprache. Schlägt die Cloud (OpenRouter/Gemini) fehl, erfolgt nach Retries ein Fallback auf den lokalen Ollama (Quoten-Schutz). +* **Rate-Limit Resilience (WP-76):** Automatisierte Erkennung von HTTP 429 Fehlern. Das System pausiert (konfigurierbar via `LLM_RATE_LIMIT_WAIT`) und wiederholt den Cloud-Call, bevor der langsame Fallback ausgelöst wird. +* **Mistral-safe Parsing:** Robuste Extraktions-Logik in Ingestion und Analyzer, die technische Steuerzeichen (``, `[OUT]`) und Framework-Tags erkennt und entfernt, um valides JSON aus Free-Modellen zu gewinnen. * **Lifecycle Scoring (WP-22):** Ein Mechanismus, der die Relevanz einer Notiz basierend auf ihrem Status gewichtet (z.B. Bonus für `stable`, Malus für `draft`). * **Intent Boosting:** Dynamische Erhöhung der Kanten-Gewichte basierend auf der Nutzerfrage (z.B. Fokus auf `caused_by` bei "Warum"-Fragen). * **Provenance Weighting:** Gewichtung einer Kante nach ihrer Herkunft: * `explicit`: Vom Mensch gesetzt (Prio 1). - * `smart`: Von der KI validiert (Prio 2). - * `rule`: Durch System-Regeln/Matrix erzeugt (Prio 3). + * `semantic_ai`: Von der KI im Turbo-Mode extrahiert und validiert (Prio 2). + * `structure`: Durch System-Regeln/Matrix erzeugt (Prio 3). * **Smart Edge Allocation:** KI-Verfahren zur Relevanzprüfung von Links für spezifische Textabschnitte. -* **Strict Heading Split:** Chunking-Strategie mit harten Grenzen an Überschriften und integriertem "Safety Net" gegen zu große Chunks. * **Matrix Logic:** Bestimmung des Kanten-Typs basierend auf Quell- und Ziel-Entität (z.B. Erfahrung -> Wert = `based_on`). \ No newline at end of file diff --git a/docs/02_concepts/02_concept_ai_personality.md b/docs/02_concepts/02_concept_ai_personality.md index 4f2afe6..4f37053 100644 --- a/docs/02_concepts/02_concept_ai_personality.md +++ b/docs/02_concepts/02_concept_ai_personality.md @@ -3,71 +3,78 @@ doc_type: concept audience: architect, product_owner scope: ai, router, personas status: active -version: 2.6 -context: "Fachkonzept der KI-Persönlichkeit, der Decision Engine und Erweiterungsstrategien." +version: 2.8 +context: "Fachkonzept der KI-Persönlichkeit, der Hybrid-Provider-Kaskade und der operationalen Resilienz." --- # Konzept: KI-Persönlichkeit & Router -**Quellen:** `mindnet_functional_architecture.md`, `Programmplan_V2.2.md` +**Quellen:** `mindnet_functional_architecture.md`, `llm_service.py`, `config.py` -Mindnet soll nicht wie eine Suchmaschine wirken, sondern wie ein **Digitaler Zwilling**. Dazu muss das System erkennen, **was** der Nutzer will, und seine "Persönlichkeit" anpassen. +Mindnet soll nicht wie eine Suchmaschine wirken, sondern wie ein **Digitaler Zwilling**. Dazu muss das System erkennen, **was** der Nutzer will, und seine „Persönlichkeit“ sowie seine technische Infrastruktur dynamisch anpassen. ## 1. Der Hybrid Router (Das Gehirn) -Jede Eingabe durchläuft den **Hybrid Router**. Er entscheidet über die Strategie. +Jede Eingabe durchläuft den **Hybrid Router**. Er entscheidet über die fachliche Strategie und die technische Ausführung. ### Modus A: RAG (Retrieval Augmented Generation) -* *Intent:* Der Nutzer hat eine Frage oder ein Problem (`FACT`, `DECISION`, `EMPATHY`). -* *Aktion:* Das System sucht im Gedächtnis und generiert eine Antwort. +* **Intent:** Der Nutzer hat eine Frage oder ein Problem (`FACT`, `DECISION`, `EMPATHY`). +* **Aktion:** Das System sucht im Gedächtnis und generiert eine Antwort. ### Modus B: Interview (Knowledge Capture) -* *Intent:* Der Nutzer will Wissen speichern (`INTERVIEW`). -* *Aktion:* Das System sucht **nicht**, sondern fragt ab und erstellt einen Draft. +* **Intent:** Der Nutzer will Wissen speichern (`INTERVIEW`). +* **Aktion:** Das System sucht **nicht**, sondern fragt ab und erstellt einen Draft. --- -## 2. Die Personas (Strategien) +## 2. Die Provider-Kaskade (Hybrid-Cloud Resilienz) + +Ein intelligenter Zwilling muss jederzeit verfügbar sein. Mindnet v2.8 nutzt eine **dreistufige Kaskade**, um Intelligenz, Kosten und Verfügbarkeit zu optimieren: + +1. **Stufe 1: High-Performance Cloud (OpenRouter/Gemini):** Primäre Wahl für komplexe Schlussfolgerungen und semantische Extraktion (Mistral-7B / Gemini-2.5-Lite). +2. **Stufe 2: Resilienz-Pause (Quota-Handling):** Bei Erreichen von Provider-Limits (HTTP 429) pausiert das System intelligent (konfigurierbar via `LLM_RATE_LIMIT_WAIT`), anstatt den Dienst abzubrechen. +3. **Stufe 3: Local-Only Fallback (Ollama):** Schlagen alle Cloud-Retries fehl, übernimmt das lokale Modell (Phi-3), um die Betriebssicherheit ohne Datenabfluss zu garantieren. + +--- + +## 3. Die Personas (Strategien) Mindnet wechselt den Hut, je nach Situation. -### 2.1 Der Berater (Strategy: DECISION) -* **Auslöser:** Fragen wie "Soll ich...?", "Was ist besser?". +### 3.1 Der Berater (Strategy: DECISION) +* **Auslöser:** Fragen wie „Soll ich...?“, „Was ist besser?“. * **Strategic Retrieval:** Lädt aktiv Notizen der Typen `value` (Werte), `goal` (Ziele) und `risk` (Risiken), auch wenn sie im Text nicht direkt vorkommen. -* **Reasoning:** *"Wäge die Fakten gegen meine Werte ab. Sei strikt bei Risiken."* +* **Reasoning:** *„Wäge die Fakten gegen meine Werte ab. Sei strikt bei Risiken.“* -### 2.2 Der Spiegel (Strategy: EMPATHY) -* **Auslöser:** Emotionale Aussagen ("Ich bin frustriert"). +### 3.2 Der Spiegel (Strategy: EMPATHY) +* **Auslöser:** Emotionale Aussagen („Ich bin frustriert“). * **Strategic Retrieval:** Lädt `experience` (Erfahrungen) und `belief` (Glaubenssätze). -* **Reasoning:** *"Nutze meine eigenen Erfahrungen, um die Situation einzuordnen."* +* **Reasoning:** *„Nutze meine eigenen Erfahrungen, um die Situation einzuordnen.“* -### 2.3 Der Bibliothekar (Strategy: FACT) -* **Auslöser:** Sachfragen ("Was ist Qdrant?"). +### 3.3 Der Bibliothekar (Strategy: FACT) +* **Auslöser:** Sachfragen („Was ist Qdrant?“). * **Behavior:** Präzise, neutral, kurz. --- -## 3. Future Concepts: The Empathic Digital Twin +## 4. Future Concepts: The Empathic Digital Twin -Um Mindnet von einer Maschine zu einem echten Spiegel der Persönlichkeit zu entwickeln, sind folgende Konzepte in der Architektur angelegt: +### 4.1 Antizipation durch Erfahrung +* **Konzept:** Das System soll Konsequenzen vorhersagen („Was passiert, wenn...?“). +* **Logik:** *„In einer ähnlichen Situation (Projekt A) hat Entscheidung X zu Ergebnis Y geführt.“* (Analogie-Schluss). -### 3.1 Antizipation durch Erfahrung -* **Konzept:** Das System soll Konsequenzen vorhersagen ("Was passiert, wenn...?"). -* **Logik:** *"In einer ähnlichen Situation (Projekt A) hat Entscheidung X zu Ergebnis Y geführt."* (Analogie-Schluss). - -### 3.2 Empathie & "Ich"-Modus +### 4.2 Empathie & „Ich“-Modus * **Konzept:** Das System antwortet im Tonfall des Nutzers. * **Umsetzung:** Few-Shot Prompting mit eigenen E-Mails/Texten als Stilvorlage. -### 3.3 Glaubenssätze & Rituale -* **Konzept:** Berücksichtigung weicher Faktoren. -* **Szenario:** Bei Terminplanungen werden Rituale ("Keine Meetings vor 10 Uhr") automatisch als harte Restriktion gegen Anfragen geprüft. +### 4.3 Resilienz als Charakterzug +Durch das **WP-76 Handling** zeigt das System „Geduld“: Bei Überlastung der Cloud-Dienste bricht es nicht panisch ab, sondern wartet auf die nächste freie Kapazität, um die Qualität der Antwort zu sichern. --- -## 4. Erweiterbarkeit: Das "Teach-the-AI" Paradigma +## 5. Erweiterbarkeit: Das „Teach-the-AI“ Paradigma -Mindnet lernt nicht durch Training (Fine-Tuning), sondern durch **Konfiguration** und **Vernetzung**. Wenn du dem System ein neues Konzept beibringen willst, musst du an drei Stellen eingreifen. +Mindnet lernt durch **Konfiguration** und **Vernetzung**. **Beispiel: Du willst den Typ `risk` einführen.** @@ -87,6 +94,6 @@ DECISION: ``` **3. Kognitive Ebene (Verständnis)** -In `prompts.yaml`: Erkläre dem LLM, was ein Risiko ist. +In `prompts.yaml`: Erkläre dem LLM (provider-spezifisch), was ein Risiko ist. -**Fazit:** Nur wenn **Daten** (Vault), **Physik** (Config) und **Semantik** (Prompt) zusammenspielen, entsteht ein intelligenter Zwilling. \ No newline at end of file +**Fazit:** Nur wenn **Daten** (Vault), **Infrastruktur** (Resiliente Kaskade) und **Semantik** (Prompt) zusammenspielen, entsteht ein intelligenter Zwilling. \ No newline at end of file diff --git a/docs/03_Technical_References/03_tech_chat_backend.md b/docs/03_Technical_References/03_tech_chat_backend.md index c32f2d1..d1bf0b1 100644 --- a/docs/03_Technical_References/03_tech_chat_backend.md +++ b/docs/03_Technical_References/03_tech_chat_backend.md @@ -1,17 +1,17 @@ --- doc_type: technical_reference audience: developer, architect -scope: backend, chat, ollama, traffic_control +scope: backend, chat, llm_service, traffic_control, resilience status: active -version: 2.6 -context: "Technische Implementierung des FastAPI-Routers, der Decision Engine und des Traffic Control Systems." +version: 2.8 +context: "Technische Implementierung des FastAPI-Routers, des hybriden LLMService und des WP-76 Resilienz-Systems." --- # Chat Backend & Traffic Control ## 1. Hybrid Router (Decision Engine) -Der zentrale Einstiegspunkt für jede Chatanfrage ist der **Hybrid Router** (`app/routers/chat.py`). Er entscheidet dynamisch, welche Strategie gewählt wird, basierend auf dem User-Input. +Der zentrale Einstiegspunkt für jede Chatanfrage ist der **Hybrid Router** (`app/routers/chat.py`). Er entscheidet dynamisch über die Strategie und nutzt den `LLMService` zur provider-agnostischen Generierung. ### 1.1 Intent-Erkennung (Logik) @@ -21,18 +21,26 @@ Der Router prüft den Input in drei Stufen (Wasserfall-Prinzip): * Prüfung auf Vorhandensein von `?` oder W-Wörtern (Wer, Wie, Was, Soll ich). * Wenn positiv: **RAG Modus** (Interview wird blockiert). 2. **Keyword Scan (Fast Path):** - * Lädt `types.yaml` (Objekte, z.B. "Projekt") und `decision_engine.yaml` (Handlungen, z.B. "neu"). + * Lädt `types.yaml` (Objekte) und `decision_engine.yaml` (Handlungen). * Wenn Match (z.B. "Projekt" + "neu"): **INTERVIEW Modus**. 3. **LLM Fallback (Slow Path):** - * Wenn unklar: Anfrage an LLM zur Klassifizierung. + * Wenn unklar: Anfrage an LLM zur Klassifizierung mittels `router_prompt`. -### 1.2 RAG Flow (Technisch) +### 1.2 Prompt-Auflösung (WP-20 Fix) + +Um Kompatibilitätsprobleme mit verschachtelten YAML-Prompts zu vermeiden, nutzt der Router die Methode `llm.get_prompt()`. Diese implementiert eine **Provider-Kaskade**: +* Das System sucht zuerst nach einem Prompt für den aktiven Provider (z.B. `openrouter`). +* Existiert dieser nicht, erfolgt ein Fallback auf `gemini` und schließlich auf `ollama`. +* Dies garantiert die Rückgabe eines validen Strings und verhindert 500-Fehler bei String-Operationen wie `.replace()`. + +### 1.3 RAG Flow (Technisch) Wenn der Intent `FACT` oder `DECISION` ist, wird folgender Flow ausgeführt: 1. **Pre-Processing:** Query Rewriting (optional). 2. **Context Enrichment:** * Abruf via `retriever.py` (Hybrid Search). + * Integration von **Edge Boosts** aus der `decision_engine.yaml` zur Beeinflussung der Graph-Gewichtung. * Injection von Metadaten (`[TYPE]`, `[SCORE]`) in den Prompt. 3. **Prompt Construction:** Assembly aus System-Prompt (Persona) + Context + Query. 4. **Streaming:** LLM-Antwort wird via **SSE (Server-Sent Events)** an den Client gestreamt. @@ -40,36 +48,44 @@ Wenn der Intent `FACT` oder `DECISION` ist, wird folgender Flow ausgeführt: --- -## 2. Traffic Control (WP-15) +## 2. LLM Service & Traffic Control (WP-15/WP-20) -Das Traffic Control System (`app/core/llm_service.py`) schützt das System vor Überlastung, wenn rechenintensive Hintergrundprozesse (Smart Edge Import) und Latenz-kritische Chat-Anfragen gleichzeitig laufen. +Der `LLMService` (`app/services/llm_service.py`) fungiert als zentraler Hybrid-Client für OpenRouter, Google Gemini und Ollama. Er schützt das System vor Überlastung und verwaltet Quoten. ### 2.1 Prioritäts-Semaphor -Jeder LLM-Request muss ein `priority`-Flag setzen. - -**Prioritäten-Levels:** +Jeder LLM-Request steuert über ein `priority`-Flag den Zugriff auf Hardware- und API-Ressourcen: | Priority | Verwendung | Limitierung | | :--- | :--- | :--- | -| **realtime** | Chat-Anfragen | Keine (Hardware-Limit) | -| **background** | Smart Edge Allocation, Drafts | `MINDNET_LLM_BACKGROUND_LIMIT` | +| **realtime** | Chat-Anfragen, Intent-Routing | Keine (Hardware-Limit) | +| **background** | Smart Edge Allocation, Import-Tasks | `MINDNET_LLM_BACKGROUND_LIMIT` | **Funktionsweise:** -* Hintergrund-Tasks nutzen `asyncio.Semaphore`. -* Wenn das Limit (Default: 2) erreicht ist, warten weitere Import-Tasks. -* Chat-Tasks umgehen die Semaphore und werden sofort bearbeitet. +* Hintergrund-Tasks nutzen ein globales `asyncio.Semaphore`. +* Das Limit (Default: 2) verhindert, dass parallele Import-Vorgänge die API-Quoten oder die lokale CPU erschöpfen. +* Chat-Tasks umgehen die Semaphore für minimale Latenz. ### 2.2 Timeout-Konfiguration -Deadlocks werden durch strikte Timeouts verhindert, die in der `.env` definiert sind. - -* **Chat:** `MINDNET_LLM_TIMEOUT` (Default: 300s). -* **Frontend:** `MINDNET_API_TIMEOUT` (Default: 300s). +Deadlocks und "hängende" Importe werden durch differenzierte Timeouts verhindert: +* **Cloud-Calls (OpenRouter/Gemini):** Strikte **45 Sekunden** zur Vermeidung von Blockaden bei Provider-Latenz. +* **Lokales LLM (Ollama):** Konfigurierbar via `MINDNET_LLM_TIMEOUT` (Default: 300s). --- -## 3. Feedback Traceability +## 3. Resilience & Quota Management (WP-76) + +In v2.8 wurde ein intelligentes Fehler-Handling für Cloud-Provider implementiert: + +1. **Rate-Limit Erkennung:** Der Service erkennt HTTP 429 Fehler sowie provider-spezifische Meldungen wie `RESOURCE_EXHAUSTED`. +2. **Intelligenter Backoff:** Statt sofort auf das langsame lokale Modell zu wechseln, pausiert das System für die Dauer von `LLM_RATE_LIMIT_WAIT` (Default: 60s). +3. **Cloud-Retry:** Nach der Pause erfolgt ein erneuter Versuch (bis zu `LLM_RATE_LIMIT_RETRIES` Mal). +4. **Ollama Fallback:** Erst nach Erschöpfung der Retries schaltet das System auf den lokalen Ollama um, um die Betriebssicherheit zu gewährleisten ("Quoten-Schutz"). + +--- + +## 4. Feedback Traceability Unterstützt das geplante Self-Tuning (WP08). diff --git a/docs/03_Technical_References/03_tech_configuration.md b/docs/03_Technical_References/03_tech_configuration.md index db92177..3ee257e 100644 --- a/docs/03_Technical_References/03_tech_configuration.md +++ b/docs/03_Technical_References/03_tech_configuration.md @@ -1,15 +1,15 @@ --- doc_type: technical_reference audience: developer, admin -scope: configuration, env, registry, scoring +scope: configuration, env, registry, scoring, resilience status: active -version: 2.7.2 -context: "Umfassende Referenztabellen für Umgebungsvariablen, YAML-Konfigurationen und die Edge Registry Struktur." +version: 2.8.0 +context: "Umfassende Referenztabellen für Umgebungsvariablen (inkl. Hybrid-Cloud & WP-76), YAML-Konfigurationen und die Edge Registry Struktur." --- # Konfigurations-Referenz -Dieses Dokument beschreibt alle Steuerungsdateien von Mindnet. In der Version 2.7 wurde die Konfiguration professionalisiert, um die Edge Registry und dynamische Scoring-Parameter (Lifecycle & Intent) zu unterstützen. +Dieses Dokument beschreibt alle Steuerungsdateien von Mindnet. In der Version 2.8 wurde die Konfiguration professionalisiert, um die Edge Registry, dynamische Scoring-Parameter (Lifecycle & Intent) sowie die neue Hybrid-Cloud-Resilienz zu unterstützen. ## 1. Environment Variablen (`.env`) @@ -22,17 +22,25 @@ Diese Variablen steuern die Infrastruktur, Pfade und globale Timeouts. | `COLLECTION_PREFIX` | `mindnet` | Namensraum für Collections (erzeugt `{prefix}_notes` etc). | | `VECTOR_DIM` | `768` | **Muss 768 sein** (für Nomic Embeddings). | | `MINDNET_VOCAB_PATH` | *(Pfad)* | **Neu (WP-22):** Absoluter Pfad zur `01_edge_vocabulary.md`. Definiert den Ort des Dictionarys. | -| `MINDNET_VAULT_ROOT` | `./vault` | Basis-Pfad für Datei-Operationen. Dient als Fallback-Basis, falls `MINDNET_VOCAB_PATH` nicht gesetzt ist. | +| `MINDNET_VAULT_ROOT` | `./vault` | Basis-Pfad für Datei-Operationen. | | `MINDNET_TYPES_FILE` | `config/types.yaml` | Pfad zur Typ-Registry. | | `MINDNET_RETRIEVER_CONFIG`| `config/retriever.yaml`| Pfad zur Scoring-Konfiguration. | | `MINDNET_DECISION_CONFIG` | `config/decision_engine.yaml` | Pfad zur Router & Intent Config. | | `MINDNET_PROMPTS_PATH` | `config/prompts.yaml` | Pfad zu LLM Prompts. | -| `MINDNET_LLM_MODEL` | `phi3:mini` | Name des Chat-Modells (Ollama). | +| `MINDNET_LLM_PROVIDER` | `openrouter` | **Neu (WP-20):** Aktiver Provider (`openrouter`, `gemini`, `ollama`). | +| `MINDNET_LLM_FALLBACK` | `true` | **Neu (WP-20):** Aktiviert automatischen Ollama-Fallback bei Cloud-Fehlern. | +| `MINDNET_LLM_RATE_LIMIT_WAIT`| `60.0` | **Neu (WP-76):** Wartezeit in Sekunden bei HTTP 429 (Rate Limit). | +| `MINDNET_LLM_RATE_LIMIT_RETRIES`| `3` | **Neu (WP-76):** Anzahl Cloud-Retries vor lokalem Fallback. | +| `GOOGLE_API_KEY` | *(Key)* | API Key für Google AI Studio. | +| `MINDNET_GEMINI_MODEL` | `gemini-2.5-flash-lite` | **Update 2025:** Optimiertes Lite-Modell für hohe Quoten. | +| `OPENROUTER_API_KEY` | *(Key)* | API Key für OpenRouter Integration. | +| `OPENROUTER_MODEL` | `mistralai/mistral-7b-instruct:free` | **Update 2025:** Verifiziertes Free-Modell für strukturierte Extraktion. | +| `MINDNET_LLM_MODEL` | `phi3:mini` | Name des lokalen Chat-Modells (Ollama). | | `MINDNET_EMBEDDING_MODEL` | `nomic-embed-text` | Name des Embedding-Modells (Ollama). | -| `MINDNET_OLLAMA_URL` | `http://127.0.0.1:11434`| URL zum LLM-Server. | -| `MINDNET_LLM_TIMEOUT` | `300.0` | Timeout in Sekunden (Erhöht für CPU Cold-Starts). | -| `MINDNET_API_TIMEOUT` | `300.0` | Frontend Timeout (Erhöht für Smart Edge Wartezeiten). | -| `MINDNET_LLM_BACKGROUND_LIMIT`| `2` | **Traffic Control:** Max. parallele Hintergrund-Tasks (Semaphore). | +| `MINDNET_OLLAMA_URL` | `http://127.0.0.1:11434`| URL zum lokalen LLM-Server. | +| `MINDNET_LLM_TIMEOUT` | `300.0` | Timeout in Sekunden für LLM-Anfragen. | +| `MINDNET_API_TIMEOUT` | `300.0` | Globales API-Timeout für das Frontend. | +| `MINDNET_LL_BACKGROUND_LIMIT`| `2` | **Traffic Control:** Max. parallele Hintergrund-Tasks (Semaphore). | | `MINDNET_CHANGE_DETECTION_MODE` | `full` | `full` (Text + Meta) oder `body` (nur Text). | --- @@ -199,21 +207,21 @@ Die Datei muss eine Markdown-Tabelle enthalten, die vom Regex-Parser gelesen wir | System-Typ (Canonical) | Erlaubte Aliasse (User) | Beschreibung | | :--------------------- | :--------------------------------------------------- | :-------------------------------------- | -| **`caused_by`** | `ausgelöst_durch`, `wegen`, `ursache_ist` | Kausalität: A löst B aus. | -| **`derived_from`** | `abgeleitet_von`, `quelle`, `inspiriert_durch` | Herkunft: A stammt von B. | -| **`based_on`** | `basiert_auf`, `fundament`, `grundlage` | Fundament: B baut auf A auf. | -| **`solves`** | `löst`, `beantwortet`, `fix_für` | Lösung: A ist Lösung für Problem B. | -| **`part_of`** | `teil_von`, `gehört_zu`, `cluster` | Hierarchie: Kind -> Eltern. | -| **`depends_on`** | `hängt_ab_von`, `braucht`, `requires`, `enforced_by` | Abhängigkeit: A braucht B. | -| **`blocks`** | `blockiert`, `verhindert`, `risiko_für` | Blocker: A verhindert B. | -| **`uses`** | `nutzt`, `verwendet`, `tool` | Werkzeug: A nutzt B. | -| **`guides`** | `steuert`, `leitet`, `orientierung` | Soft-Dependency: A gibt Richtung für B. | -| **`followed_by`** | `danach`, `folgt`, `nachfolger`, `followed_by` | Prozess: A -> B. | -| **`preceeded_by`** | `davor`, `vorgänger`, `preceded_by` | Prozess: B <- A. | -| **`related_to`** | `siehe_auch`, `kontext`, `thematisch` | Lose Assoziation. | -| **`similar_to`** | `ähnlich_wie`, `vergleichbar` | Synonym / Ähnlichkeit. | -| **`references`** | *(Kein Alias)* | Standard-Verweis (Fallback). | -| **`resulted_in`** | `ergebnis`, `resultat`, `erzeugt` | Herkunft: A erzeugt Ergebnis B | +| **`caused_by`** | `ausgelöst_durch`, `wegen`, `ursache_ist` | Kausalität: A löst B aus. | +| **`derived_from`** | `abgeleitet_von`, `quelle`, `inspiriert_durch` | Herkunft: A stammt von B. | +| **`based_on`** | `basiert_auf`, `fundament`, `grundlage` | Fundament: B baut auf A auf. | +| **`solves`** | `löst`, `beantwortet`, `fix_für` | Lösung: A ist Lösung für Problem B. | +| **`part_of`** | `teil_von`, `gehört_zu`, `cluster` | Hierarchie: Kind -> Eltern. | +| **`depends_on`** | `hängt_ab_von`, `braucht`, `requires`, `enforced_by` | Abhängigkeit: A braucht B. | +| **`blocks`** | `blockiert`, `verhindert`, `risiko_für` | Blocker: A verhindert B. | +| **`uses`** | `nutzt`, `verwendet`, `tool` | Werkzeug: A nutzt B. | +| **`guides`** | `steuert`, `leitet`, `orientierung` | Soft-Dependency: A gibt Richtung für B. | +| **`followed_by`** | `danach`, `folgt`, `nachfolger`, `followed_by` | Prozess: A -> B. | +| **`preceeded_by`** | `davor`, `vorgänger`, `preceded_by` | Prozess: B <- A. | +| **`related_to`** | `siehe_auch`, `kontext`, `thematisch` | Lose Assoziation. | +| **`similar_to`** | `ähnlich_wie`, `vergleichbar` | Synonym / Ähnlichkeit. | +| **`references`** | *(Kein Alias)* | Standard-Verweis (Fallback). | +| **`resulted_in`** | `ergebnis`, `resultat`, `erzeugt` | Herkunft: A erzeugt Ergebnis B | **ACHTUNG!** Die Kantentypen **belongs_to**, **next** und **prev** dürfen nicht vom Nutzer gesetzt werden diff --git a/docs/03_Technical_References/03_tech_ingestion_pipeline.md b/docs/03_Technical_References/03_tech_ingestion_pipeline.md index 3acbad3..9ca4efc 100644 --- a/docs/03_Technical_References/03_tech_ingestion_pipeline.md +++ b/docs/03_Technical_References/03_tech_ingestion_pipeline.md @@ -3,15 +3,15 @@ doc_type: technical_reference audience: developer, devops scope: backend, ingestion, smart_edges, edge_registry status: active -version: 2.7.1 -context: "Detaillierte technische Beschreibung der Import-Pipeline, Chunking-Strategien und CLI-Befehle." +version: 2.8.0 +context: "Detaillierte technische Beschreibung der Import-Pipeline, Mistral-safe Parsing und WP-76 Resilienz-Logik." --- # Ingestion Pipeline & Smart Processing -**Quellen:** `pipeline_playbook.md`, `Handbuch.md`, `edge_registry.py`, `01_edge_vocabulary.md`, `06_active_roadmap.md` +**Quellen:** `pipeline_playbook.md`, `ingestion.py`, `edge_registry.py`, `01_edge_vocabulary.md`, `llm_service.py` -Die Ingestion transformiert Markdown in den Graphen. Entrypoint: `scripts/import_markdown.py` (CLI) oder `routers/ingest.py` (API). Seit v2.7 integriert dieser Prozess die **Edge Registry** zur Normalisierung des Vokabulars und beachtet den **Content Lifecycle**. +Die Ingestion transformiert Markdown in den Graphen. Entrypoint: `scripts/import_markdown.py` (CLI) oder `routers/ingest.py` (API). Seit v2.8 integriert dieser Prozess eine **intelligente Quoten-Steuerung** (WP-76) und ein **robustes JSON-Parsing** für Cloud-Modelle (Mistral/Gemini). ## 1. Der Import-Prozess (15-Schritte-Workflow) @@ -38,10 +38,11 @@ Der Prozess ist **asynchron** und **idempotent**. * Vergleich des Hashes mit Qdrant. * Strategie wählbar via ENV `MINDNET_CHANGE_DETECTION_MODE` (`full` oder `body`). 8. **Chunking anwenden:** Zerlegung des Textes basierend auf dem ermittelten Profil (siehe Kap. 3). -9. **Smart Edge Allocation (WP15):** +9. **Smart Edge Allocation (WP15/WP20):** * Wenn `enable_smart_edge_allocation: true`: Der `SemanticAnalyzer` sendet Chunks an das LLM. - * **Traffic Control:** Request nutzt `priority="background"`. Semaphore (Limit via `.env`) drosselt die Last. - * **Resilienz:** Bei Timeout (Ollama) greift ein Fallback (Broadcasting an alle Chunks). + * **Traffic Control:** Request nutzt `priority="background"`. Semaphore drosselt die Last. + * **Resilienz (WP-76):** Erkennt HTTP 429 (Rate-Limit) und pausiert kontrolliert (via `LLM_RATE_LIMIT_WAIT`), bevor ein Cloud-Retry oder der lokale Fallback erfolgt. + * **Mistral-safe Parsing:** Automatisierte Bereinigung von BOS-Tokens (``) und Framework-Tags (`[OUT]`) zur Sicherstellung validen JSONs. 10. **Inline-Kanten finden:** Parsing von `[[rel:...]]`. 11. **Alias-Auflösung & Kanonisierung (WP-22):** * Jede Kante wird via `edge_registry.resolve()` normalisiert. @@ -138,20 +139,20 @@ Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Kanten werden nach Vertrauenswürdigkeit (`provenance`) priorisiert. Die höhere Prio gewinnt. -| Prio | Quelle | Rule ID | Confidence | Erläuterung | +| Prio | Quelle | Rule ID / Provenance | Confidence | Erläuterung | | :--- | :--- | :--- | :--- | :--- | | **1** | Wikilink | `explicit:wikilink` | **1.00** | Harte menschliche Setzung. | | **2** | Inline | `inline:rel` | **0.95** | Typisierte menschliche Kante. | | **3** | Callout | `callout:edge` | **0.90** | Explizite Meta-Information. | -| **4** | Smart Edge | `smart:llm_filter` | **0.90** | KI-validierte Verbindung. | +| **4** | Semantic AI | `semantic_ai` | **0.90** | KI-extrahierte Verbindung (Mistral-safe). | | **5** | Type Default | `edge_defaults` | **0.70** | Heuristik aus der Registry. | -| **6** | Struktur | `structure` | **1.00** | System-interne Verkettung. | +| **6** | Struktur | `structure` | **1.00** | System-interne Verkettung (`belongs_to`). | --- ## 5. Quality Gates & Monitoring -In v2.7 wurden Tools zur Überwachung der Datenqualität integriert: +In v2.7+ wurden Tools zur Überwachung der Datenqualität integriert: **1. Registry Review:** Prüfung der `data/logs/unknown_edges.jsonl`. Administratoren sollten hier gelistete Begriffe als Aliase in die `01_edge_vocabulary.md` aufnehmen. diff --git a/docs/06_Roadmap/06_active_roadmap.md b/docs/06_Roadmap/06_active_roadmap.md index 55f9440..755f66e 100644 --- a/docs/06_Roadmap/06_active_roadmap.md +++ b/docs/06_Roadmap/06_active_roadmap.md @@ -2,18 +2,18 @@ doc_type: roadmap audience: product_owner, developer status: active -version: 2.7 +version: 2.8.0 context: "Aktuelle Planung für kommende Features (ab WP16), Release-Strategie und Historie der abgeschlossenen WPs." --- # Mindnet Active Roadmap -**Aktueller Stand:** v2.6.0 (Post-WP15/WP19) -**Fokus:** Visualisierung, Exploration & Intelligent Ingestion. +**Aktueller Stand:** v2.8.0 (Post-WP20/WP76) +**Fokus:** Visualisierung, Exploration & Cloud-Resilienz. ## 1. Programmstatus -Wir haben mit der Implementierung des Graph Explorers (WP19) und der Smart Edge Allocation (WP15) die Basis für ein intelligentes, robustes System gelegt. Der nächste Schritt (WP19a) vertieft die Analyse, während WP16 die "Eingangs-Intelligenz" erhöht. +Wir haben mit der Implementierung des Graph Explorers (WP19), der Smart Edge Allocation (WP15) und der hybriden Cloud-Resilienz (WP20) die Basis für ein intelligentes, robustes System gelegt. Der nächste Schritt (WP19a) vertieft die Analyse, während WP16 die "Eingangs-Intelligenz" erhöht. | Phase | Fokus | Status | | :--- | :--- | :--- | @@ -47,7 +47,7 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio | **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. | | **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore) + Strict Chunking. | | **WP-19** | Graph Visualisierung | **Frontend Modularisierung:** Umbau auf `ui_*.py`.
**Graph Engines:** Parallelbetrieb von Cytoscape (COSE) und Agraph.
**Tools:** "Single Source of Truth" Editor, Persistenz via URL. | -| **WP-20** | Cloud Hybrid Mode | Nutzung von Public LLM für schnellere Verarbeitung und bestimmte Aufgaben | +| **WP-20** | **Cloud Hybrid Mode & Resilienz** | **Ergebnis:** Integration von OpenRouter (Mistral 7B) & Gemini 2.5 Lite. Implementierung von WP-76 (Rate-Limit Wait) & Mistral-safe JSON Parsing. | | **WP-21** | Semantic Graph Routing & Canonical Edges | Transformation des Graphen von statischen Verbindungen zu dynamischen, kontextsensitiven Pfaden. Das System soll verstehen, *welche* Art von Verbindung für die aktuelle Frage relevant ist ("Warum?" vs. "Was kommt danach?"). | | **WP-22** | **Content Lifecycle & Registry** | **Ergebnis:** SSOT via `01_edge_vocabulary.md`, Alias-Mapping, Status-Scoring (`stable`/`draft`) und Modularisierung der Scoring-Engine. | @@ -55,6 +55,9 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio * **Architektur:** Die Trennung von `retriever.py` und `retriever_scoring.py` war notwendig, um LLM-Context-Limits zu wahren und die Testbarkeit der mathematischen Formeln zu erhöhen. * **Kanten-Validierung:** Die Edge Registry muss beim Start explizit initialisiert werden (Singleton), um "Lazy Loading" Probleme in der API zu vermeiden. +### 2.2 WP-20 Lessons Learned (Resilienz) +* **Quoten-Management:** Die Nutzung von Free-Tier Modellen (Mistral/OpenRouter) erfordert zwingend eine intelligente Rate-Limit-Erkennung (HTTP 429) mit automatisierten Wartezyklen, um Batch-Prozesse stabil zu halten. +* **Parser-Robustheit:** Cloud-Modelle betten JSON oft in technische Steuerzeichen (``, `[OUT]`) ein. Ein robuster Extraktor mit Recovery-Logik ist essentiell zur Vermeidung von Datenverlust. --- @@ -80,7 +83,7 @@ Diese Features stehen als nächstes an oder befinden sich in der Umsetzung. **Phase:** B **Status:** 🟡 geplant -**Ziel:** Sicherstellen, dass bestehende und neue Obsidian-Vaults schrittweise in mindnet integriert werden können – ohne Massenumbau. +**Ziel:** Sicherstellen, dass bestehende und neue Obsidian-Vaults schrittweise in mindnet installiert werden können – ohne Massenumbau. **Umfang:** - Tools zur Analyse des Vault-Status. @@ -122,7 +125,7 @@ Der bisherige WP-15 Ansatz litt unter Halluzinationen (erfundene Kantentypen), h **Ziel:** Technische Schulden abbauen, die durch schnelle Feature-Entwicklung (WP15/WP19) entstanden sind. * **Refactoring `chunker.py`:** Die Datei ist monolithisch geworden (Parsing, Strategien, LLM-Orchestrierung). * *Lösung:* Aufteilung in ein Package `app/core/chunking/` mit Modulen (`strategies.py`, `orchestration.py`, `utils.py`). -* **Dokumentation:** Kontinuierliche Synchronisation von Code und Docs (v2.6 Stand). +* **Dokumentation:** Kontinuierliche Synchronisation von Code und Docs (v2.8 Stand). ### WP-16 – Auto-Discovery & Intelligent Ingestion **Status:** 🟡 Geplant @@ -155,11 +158,6 @@ Der bisherige WP-15 Ansatz litt unter Halluzinationen (erfundene Kantentypen), h **Ziel:** mindnet als MCP-Server bereitstellen, damit Agenten (Claude Desktop, OpenAI) standardisierte Tools nutzen können. * **Umfang:** MCP-Server mit Tools (`mindnet_query`, `mindnet_explain`, etc.). -### WP-20 – Cloud Hybrid Mode (Optional) -**Status:** ⚪ Optional -**Ziel:** "Turbo-Modus" für Massen-Imports. -* **Konzept:** Switch in `.env`, um statt Ollama (Lokal) auf Google Gemini (Cloud) umzuschalten. - ### WP-21 – Semantic Graph Routing & Canonical Edges **Status:** 🟡 Geplant **Ziel:** Transformation des Graphen von statischen Verbindungen zu dynamischen, kontextsensitiven Pfaden. Das System soll verstehen, *welche* Art von Verbindung für die aktuelle Frage relevant ist ("Warum?" vs. "Was kommt danach?"). @@ -254,4 +252,5 @@ graph TD WP21 --> WP22(Lifecycle & Registry) WP22 --> WP14 WP15(Smart Edges) --> WP21 - WP20(Cloud Hybrid) --> WP15b \ No newline at end of file + WP20(Cloud Hybrid) --> WP15b +``` \ No newline at end of file