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