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