""" FILE: app/core/graph/graph_derive_edges.py DESCRIPTION: Hauptlogik zur Kanten-Aggregation und De-Duplizierung. WP-15b/c Audit: - Präzises Sektions-Splitting via parse_link_target. - v4.1.0: Eindeutige ID-Generierung pro Sektions-Variante (Multigraph). - Ermöglicht dem Retriever die Super-Edge-Aggregation. WP-24c v4.2.0: Note-Scope Extraktions-Zonen für globale Referenzen. - Header-basierte Identifikation von Note-Scope Zonen - Automatische Scope-Umschaltung (chunk -> note) - Priorisierung: Note-Scope Links haben Vorrang bei Duplikaten WP-24c v4.2.1: Clean-Context Bereinigung - Konsolidierte Callout-Extraktion (keine Duplikate) - Smart Scope-Priorisierung (chunk bevorzugt, außer bei höherer Provenance) - Effiziente Verarbeitung ohne redundante Scans WP-24c v4.2.2: Semantische De-Duplizierung - Gruppierung nach (kind, source, target, section) unabhängig vom Scope - Scope-Entscheidung: explicit:note_zone > chunk-Scope - ID-Berechnung erst nach Scope-Entscheidung WP-24c v4.3.0: Lokalisierung des Datenverlusts - Debug-Logik für Audit des Datentransfers - Verifizierung der candidate_pool Übertragung WP-24c v4.3.1: Präzisions-Priorität für Chunk-Scope - Chunk-Scope gewinnt zwingend über Note-Scope (außer explicit:note_zone) - Confidence-Werte: candidate_pool explicit:callout = 1.0, globaler Scan = 0.7 - Key-Generierung gehärtet für konsistente Deduplizierung VERSION: 4.3.1 (WP-24c: Präzisions-Priorität) STATUS: Active """ import re import logging from typing import List, Optional, Dict, Tuple, Set from .graph_utils import ( _get, _edge, _mk_edge_id, _dedupe_seq, parse_link_target, PROVENANCE_PRIORITY, load_types_registry, get_edge_defaults_for ) from .graph_extractors import ( extract_typed_relations, extract_callout_relations, extract_wikilinks ) # WP-24c v4.4.0-DEBUG: Logger am Modul-Level für alle Funktionen verfügbar logger = logging.getLogger(__name__) # WP-24c v4.2.0: Header-basierte Identifikation von Note-Scope Zonen # Konfigurierbar via MINDNET_NOTE_SCOPE_ZONE_HEADERS (komma-separiert) def get_note_scope_zone_headers() -> List[str]: """ Lädt die konfigurierten Header-Namen für Note-Scope Zonen. Fallback auf Defaults, falls nicht konfiguriert. """ import os headers_env = os.getenv( "MINDNET_NOTE_SCOPE_ZONE_HEADERS", "Smart Edges,Relationen,Global Links,Note-Level Relations,Globale Verbindungen" ) header_list = [h.strip() for h in headers_env.split(",") if h.strip()] # Fallback auf Defaults, falls leer if not header_list: header_list = [ "Smart Edges", "Relationen", "Global Links", "Note-Level Relations", "Globale Verbindungen" ] return header_list # WP-24c v4.5.6: Header-basierte Identifikation von LLM-Validierungs-Zonen # Konfigurierbar via MINDNET_LLM_VALIDATION_HEADERS (komma-separiert) def get_llm_validation_zone_headers() -> List[str]: """ Lädt die konfigurierten Header-Namen für LLM-Validierungs-Zonen. Fallback auf Defaults, falls nicht konfiguriert. """ import os headers_env = os.getenv( "MINDNET_LLM_VALIDATION_HEADERS", "Unzugeordnete Kanten,Edge Pool,Candidates" ) header_list = [h.strip() for h in headers_env.split(",") if h.strip()] # Fallback auf Defaults, falls leer if not header_list: header_list = [ "Unzugeordnete Kanten", "Edge Pool", "Candidates" ] return header_list def extract_note_scope_zones(markdown_body: str) -> List[Tuple[str, str]]: """ WP-24c v4.2.0: Extrahiert Note-Scope Zonen aus Markdown. WP-24c v4.5.6: Unterscheidet zwischen Note-Scope-Zonen und LLM-Validierungs-Zonen. Identifiziert Sektionen mit spezifischen Headern (konfigurierbar via .env) und extrahiert alle darin enthaltenen Links. Returns: List[Tuple[str, str]]: Liste von (kind, target) Tupeln """ if not markdown_body: return [] edges: List[Tuple[str, str]] = [] # WP-24c v4.2.0: Konfigurierbare Header-Ebene import os import re note_scope_level = int(os.getenv("MINDNET_NOTE_SCOPE_HEADER_LEVEL", "2")) header_level_pattern = "#" * note_scope_level # Regex für Header-Erkennung (konfigurierbare Ebene) header_pattern = rf'^{re.escape(header_level_pattern)}\s+(.+?)$' lines = markdown_body.split('\n') in_zone = False zone_content = [] # WP-24c v4.5.6: Lade beide Header-Listen für Unterscheidung zone_headers = get_note_scope_zone_headers() llm_validation_headers = get_llm_validation_zone_headers() for i, line in enumerate(lines): # Prüfe auf Header header_match = re.match(header_pattern, line.strip()) if header_match: header_text = header_match.group(1).strip() # WP-24c v4.5.6: Prüfe, ob dieser Header eine Note-Scope Zone ist # (NICHT eine LLM-Validierungs-Zone - diese werden separat behandelt) is_zone_header = any( header_text.lower() == zone_header.lower() for zone_header in zone_headers ) # WP-24c v4.5.6: Ignoriere LLM-Validierungs-Zonen hier (werden separat verarbeitet) is_llm_validation = any( header_text.lower() == llm_header.lower() for llm_header in llm_validation_headers ) if is_zone_header and not is_llm_validation: in_zone = True zone_content = [] continue else: # Neuer Header gefunden, der keine Zone ist -> Zone beendet if in_zone: # Verarbeite gesammelten Inhalt zone_text = '\n'.join(zone_content) # Extrahiere Typed Relations typed, _ = extract_typed_relations(zone_text) edges.extend(typed) # Extrahiere Wikilinks (als related_to) wikilinks = extract_wikilinks(zone_text) for wl in wikilinks: edges.append(("related_to", wl)) # WP-24c v4.2.1: Callouts werden NICHT hier extrahiert, da sie global abgedeckt werden in_zone = False zone_content = [] # Sammle Inhalt, wenn wir in einer Zone sind if in_zone: zone_content.append(line) # Verarbeite letzte Zone (falls am Ende des Dokuments) if in_zone and zone_content: zone_text = '\n'.join(zone_content) typed, _ = extract_typed_relations(zone_text) edges.extend(typed) wikilinks = extract_wikilinks(zone_text) for wl in wikilinks: edges.append(("related_to", wl)) # WP-24c v4.2.1: Callouts werden NICHT hier extrahiert, da sie global abgedeckt werden return edges def extract_llm_validation_zones(markdown_body: str) -> List[Tuple[str, str]]: """ WP-24c v4.5.6: Extrahiert LLM-Validierungs-Zonen aus Markdown. Identifiziert Sektionen mit LLM-Validierungs-Headern (konfigurierbar via .env) und extrahiert alle darin enthaltenen Links (Wikilinks, Typed Relations, Callouts). Diese Kanten erhalten das Präfix "candidate:" in der rule_id. Returns: List[Tuple[str, str]]: Liste von (kind, target) Tupeln """ if not markdown_body: return [] edges: List[Tuple[str, str]] = [] # WP-24c v4.5.6: Konfigurierbare Header-Ebene für LLM-Validierung import os import re llm_validation_level = int(os.getenv("MINDNET_LLM_VALIDATION_HEADER_LEVEL", "3")) header_level_pattern = "#" * llm_validation_level # Regex für Header-Erkennung (konfigurierbare Ebene) header_pattern = rf'^{re.escape(header_level_pattern)}\s+(.+?)$' lines = markdown_body.split('\n') in_zone = False zone_content = [] # WP-24c v4.5.6: Lade LLM-Validierungs-Header llm_validation_headers = get_llm_validation_zone_headers() for i, line in enumerate(lines): # Prüfe auf Header (konfiguriertes Level aus MINDNET_LLM_VALIDATION_HEADER_LEVEL) header_match = re.match(header_pattern, line.strip()) if header_match: header_text = header_match.group(1).strip() # WP-24c v4.5.6: Prüfe, ob dieser Header eine LLM-Validierungs-Zone ist is_llm_validation = any( header_text.lower() == llm_header.lower() for llm_header in llm_validation_headers ) if is_llm_validation: in_zone = True zone_content = [] continue else: # Neuer Header gefunden, der keine Zone ist -> Zone beendet if in_zone: # Verarbeite gesammelten Inhalt zone_text = '\n'.join(zone_content) # Extrahiere Typed Relations typed, _ = extract_typed_relations(zone_text) edges.extend(typed) # Extrahiere Wikilinks (als related_to) wikilinks = extract_wikilinks(zone_text) for wl in wikilinks: edges.append(("related_to", wl)) # WP-24c v4.5.6: Extrahiere auch Callouts aus LLM-Validierungs-Zonen callout_pairs, _ = extract_callout_relations(zone_text) edges.extend(callout_pairs) in_zone = False zone_content = [] # Sammle Inhalt, wenn wir in einer Zone sind if in_zone: zone_content.append(line) # Verarbeite letzte Zone (falls am Ende des Dokuments) if in_zone and zone_content: zone_text = '\n'.join(zone_content) typed, _ = extract_typed_relations(zone_text) edges.extend(typed) wikilinks = extract_wikilinks(zone_text) for wl in wikilinks: edges.append(("related_to", wl)) # WP-24c v4.5.6: Extrahiere auch Callouts aus LLM-Validierungs-Zonen callout_pairs, _ = extract_callout_relations(zone_text) edges.extend(callout_pairs) return edges def extract_callouts_from_markdown( markdown_body: str, note_id: str, existing_chunk_callouts: Optional[Set[Tuple[str, str, Optional[str]]]] = None ) -> List[dict]: """ WP-24c v4.2.1: Extrahiert Callouts aus dem Original-Markdown. WP-24c v4.5.6: Header-Status-Maschine für korrekte Zonen-Erkennung. Smart Logic: Nur Callouts, die NICHT in Chunks vorkommen (z.B. in Edge-Zonen), werden mit scope: "note" angelegt. Callouts, die bereits in Chunks erfasst wurden, werden übersprungen, um Duplikate zu vermeiden. WP-24c v4.5.6: Prüft für jeden Callout, ob er in einer LLM-Validierungs-Zone liegt. - In LLM-Validierungs-Zone: rule_id = "candidate:explicit:callout" - In Standard-Zone: rule_id = "explicit:callout" (ohne candidate:) Args: markdown_body: Original-Markdown-Text (vor Chunking-Filterung) note_id: ID der Note existing_chunk_callouts: Set von (kind, target, section) Tupeln aus Chunks Returns: List[dict]: Liste von Edge-Payloads mit scope: "note" """ if not markdown_body: return [] if existing_chunk_callouts is None: existing_chunk_callouts = set() edges: List[dict] = [] # WP-24c v4.5.6: Header-Status-Maschine - Baue Mapping von Zeilen zu Zonen-Status import os import re llm_validation_headers = get_llm_validation_zone_headers() llm_validation_level = int(os.getenv("MINDNET_LLM_VALIDATION_HEADER_LEVEL", "3")) # WP-24c v4.5.6: Konfigurierbare Header-Ebene (vollständig über .env steuerbar) header_level_pattern = "#" * llm_validation_level header_pattern = rf'^{re.escape(header_level_pattern)}\s+(.+?)$' lines = markdown_body.split('\n') current_zone_is_llm_validation = False # WP-24c v4.5.6: Zeile-für-Zeile Verarbeitung mit Zonen-Tracking # Extrahiere Callouts direkt während des Durchlaufs, um Zonen-Kontext zu behalten current_kind = None in_callout_block = False callout_block_lines = [] # Sammle Zeilen eines Callout-Blocks for i, line in enumerate(lines): stripped = line.strip() # WP-24c v4.5.6: Prüfe auf Header (Zonen-Wechsel) # Verwendet das konfigurierte Level aus MINDNET_LLM_VALIDATION_HEADER_LEVEL header_match = re.match(header_pattern, stripped) if header_match: header_text = header_match.group(1).strip() # WP-24c v4.5.7: Speichere Zonen-Status VOR der Aktualisierung # (für Callout-Blöcke, die vor diesem Header enden) zone_before_header = current_zone_is_llm_validation # Prüfe, ob dieser Header eine LLM-Validierungs-Zone startet # WP-24c v4.5.6: Header-Status-Maschine - korrekte Zonen-Erkennung current_zone_is_llm_validation = any( header_text.lower() == llm_header.lower() for llm_header in llm_validation_headers ) logger.debug(f"DEBUG-TRACER [Zone-Change]: Header '{header_text}' (Level {llm_validation_level}) -> LLM-Validierung: {current_zone_is_llm_validation} (vorher: {zone_before_header})") # Beende aktuellen Callout-Block bei Header-Wechsel if in_callout_block: # Verarbeite gesammelten Callout-Block VOR dem Zonen-Wechsel if callout_block_lines: block_text = '\n'.join([lines[j] for j in callout_block_lines]) block_call_pairs, _ = extract_callout_relations(block_text) # Verarbeite jeden Callout mit Zonen-Kontext # WICHTIG: Verwende den Zonen-Status VOR dem Header-Wechsel for k, raw_t in block_call_pairs: t, sec = parse_link_target(raw_t, note_id) if not t: continue callout_key = (k, t, sec) is_blocked = callout_key in existing_chunk_callouts if is_blocked: continue # WP-24c v4.5.6: Bestimme rule_id basierend auf Zonen-Status VOR Header if zone_before_header: rule_id = "candidate:explicit:callout" provenance = "explicit:callout" else: rule_id = "explicit:callout" # KEIN candidate: für Standard-Zonen provenance = "explicit:callout" payload = { "edge_id": _mk_edge_id(k, note_id, t, "note", target_section=sec), "provenance": provenance, "rule_id": rule_id, "confidence": 0.7 } if sec: payload["target_section"] = sec logger.debug(f"DEBUG-TRACER [Zone-Check]: Callout in {'LLM-Validierungs' if zone_before_header else 'Standard'}-Zone (Zeile {callout_block_lines[0]}) -> rule_id: {rule_id}") edges.append(_edge( kind=k, scope="note", source_id=note_id, target_id=t, note_id=note_id, extra=payload )) # Reset für nächsten Block in_callout_block = False current_kind = None callout_block_lines = [] continue # WP-24c v4.5.6: Prüfe auf Callout-Start callout_start_match = re.match(r'^\s*>{1,}\s*\[!edge\]\s*(.*)$', stripped, re.IGNORECASE) if callout_start_match: in_callout_block = True callout_block_lines = [i] # Start-Zeile header_content = callout_start_match.group(1).strip() # Prüfe, ob Header einen Typ enthält if header_content and re.match(r'^[a-z_]+$', header_content, re.IGNORECASE): current_kind = header_content.lower() continue # WP-24c v4.5.6: Sammle Callout-Block-Zeilen if in_callout_block: if stripped.startswith('>'): callout_block_lines.append(i) else: # Callout-Block beendet - verarbeite gesammelte Zeilen if callout_block_lines: # Extrahiere Callouts aus diesem Block block_text = '\n'.join([lines[j] for j in callout_block_lines]) block_call_pairs, _ = extract_callout_relations(block_text) # Verarbeite jeden Callout mit Zonen-Kontext for k, raw_t in block_call_pairs: t, sec = parse_link_target(raw_t, note_id) if not t: continue callout_key = (k, t, sec) is_blocked = callout_key in existing_chunk_callouts if is_blocked: continue # WP-24c v4.5.6: Bestimme rule_id basierend auf Zonen-Status if current_zone_is_llm_validation: rule_id = "candidate:explicit:callout" provenance = "explicit:callout" else: rule_id = "explicit:callout" # KEIN candidate: für Standard-Zonen provenance = "explicit:callout" payload = { "edge_id": _mk_edge_id(k, note_id, t, "note", target_section=sec), "provenance": provenance, "rule_id": rule_id, "confidence": 0.7 } if sec: payload["target_section"] = sec logger.debug(f"DEBUG-TRACER [Zone-Check]: Callout in {'LLM-Validierungs' if current_zone_is_llm_validation else 'Standard'}-Zone (Zeile {callout_block_lines[0]}) -> rule_id: {rule_id}") edges.append(_edge( kind=k, scope="note", source_id=note_id, target_id=t, note_id=note_id, extra=payload )) # Reset für nächsten Block in_callout_block = False current_kind = None callout_block_lines = [] # WP-24c v4.5.6: Verarbeite letzten Callout-Block (falls am Ende) if in_callout_block and callout_block_lines: block_text = '\n'.join([lines[j] for j in callout_block_lines]) block_call_pairs, _ = extract_callout_relations(block_text) for k, raw_t in block_call_pairs: t, sec = parse_link_target(raw_t, note_id) if not t: continue callout_key = (k, t, sec) is_blocked = callout_key in existing_chunk_callouts if is_blocked: continue # WP-24c v4.5.6: Bestimme rule_id basierend auf Zonen-Status if current_zone_is_llm_validation: rule_id = "candidate:explicit:callout" provenance = "explicit:callout" else: rule_id = "explicit:callout" provenance = "explicit:callout" payload = { "edge_id": _mk_edge_id(k, note_id, t, "note", target_section=sec), "provenance": provenance, "rule_id": rule_id, "confidence": 0.7 } if sec: payload["target_section"] = sec logger.debug(f"DEBUG-TRACER [Zone-Check]: Callout in {'LLM-Validierungs' if current_zone_is_llm_validation else 'Standard'}-Zone (Zeile {callout_block_lines[0]}) -> rule_id: {rule_id}") edges.append(_edge( kind=k, scope="note", source_id=note_id, target_id=t, note_id=note_id, extra=payload )) return edges def build_edges_for_note( note_id: str, chunks: List[dict], note_level_references: Optional[List[str]] = None, include_note_scope_refs: bool = False, markdown_body: Optional[str] = None, ) -> List[dict]: """ Erzeugt und aggregiert alle Kanten für eine Note. WP-24c v4.2.0: Unterstützt Note-Scope Extraktions-Zonen. WP-24c v4.2.7: Chunk-Attribution für Callouts über candidate_pool mit explicit:callout Provenance. WP-24c v4.2.9: Finalisierung der Chunk-Attribution - Synchronisation mit "Semantic First" Signal. Callout-Keys werden VOR dem globalen Scan aus candidate_pool gesammelt. WP-24c v4.2.9 Fix B: Zwei-Phasen-Synchronisation für Chunk-Autorität. Phase 1: Sammle alle explicit:callout Keys VOR Text-Scan. Phase 2: Globaler Scan respektiert all_chunk_callout_keys als Ausschlusskriterium. Args: note_id: ID der Note chunks: Liste von Chunk-Payloads note_level_references: Optionale Liste von Note-Level Referenzen include_note_scope_refs: Ob Note-Scope Referenzen eingeschlossen werden sollen markdown_body: Optionaler Original-Markdown-Text für Note-Scope Zonen-Extraktion """ edges: List[dict] = [] # note_type für die Ermittlung der edge_defaults (types.yaml) note_type = _get(chunks[0], "type") if chunks else "concept" # WP-24c v4.5.7: Initialisiere all_chunk_callout_keys VOR jeder Verwendung # Dies verhindert UnboundLocalError, wenn LLM-Validierungs-Zonen vor Phase 1 verarbeitet werden all_chunk_callout_keys: Set[Tuple[str, str, Optional[str]]] = set() # WP-24c v4.2.0: Note-Scope Zonen Extraktion (VOR Chunk-Verarbeitung) # WP-24c v4.5.6: Separate Behandlung von LLM-Validierungs-Zonen note_scope_edges: List[dict] = [] llm_validation_edges: List[dict] = [] if markdown_body: # 1. Note-Scope Zonen (Wikilinks und Typed Relations) # WP-24c v4.2.1: Callouts werden NICHT hier extrahiert, da sie separat behandelt werden zone_links = extract_note_scope_zones(markdown_body) for kind, raw_target in zone_links: target, sec = parse_link_target(raw_target, note_id) if not target: continue # WP-24c v4.2.0: Note-Scope Links mit scope: "note" und source_id: note_id # ID-Konsistenz: Exakt wie in Phase 2 (Symmetrie-Prüfung) payload = { "edge_id": _mk_edge_id(kind, note_id, target, "note", target_section=sec), "provenance": "explicit:note_zone", "rule_id": "explicit:note_zone", "confidence": PROVENANCE_PRIORITY.get("explicit:note_zone", 1.0) } if sec: payload["target_section"] = sec note_scope_edges.append(_edge( kind=kind, scope="note", source_id=note_id, # WP-24c v4.2.0: source_id = note_id (nicht chunk_id) target_id=target, note_id=note_id, extra=payload )) # WP-24c v4.5.6: LLM-Validierungs-Zonen (mit candidate: Präfix) llm_validation_links = extract_llm_validation_zones(markdown_body) for kind, raw_target in llm_validation_links: target, sec = parse_link_target(raw_target, note_id) if not target: continue # WP-24c v4.5.6: LLM-Validierungs-Kanten mit scope: "note" und rule_id: "candidate:..." # Diese werden gegen alle Chunks der Note geprüft # Bestimme Provenance basierend auf Link-Typ if kind == "related_to": # Wikilink in LLM-Validierungs-Zone provenance = "explicit:wikilink" else: # Typed Relation oder Callout in LLM-Validierungs-Zone provenance = "explicit" payload = { "edge_id": _mk_edge_id(kind, note_id, target, "note", target_section=sec), "provenance": provenance, "rule_id": f"candidate:{provenance}", # WP-24c v4.5.6: Zonen-Priorität - candidate: Präfix "confidence": PROVENANCE_PRIORITY.get(provenance, 0.90) } if sec: payload["target_section"] = sec llm_validation_edges.append(_edge( kind=kind, scope="note", source_id=note_id, # WP-24c v4.5.6: source_id = note_id (Note-Scope für LLM-Validierung) target_id=target, note_id=note_id, extra=payload )) # WP-24c v4.5.6: Füge Callouts aus LLM-Validierungs-Zonen zu all_chunk_callout_keys hinzu # damit sie nicht im globalen Scan doppelt verarbeitet werden # (Nur für Callouts, nicht für Wikilinks oder Typed Relations) # Callouts werden in extract_llm_validation_zones bereits extrahiert # und müssen daher aus dem globalen Scan ausgeschlossen werden # Hinweis: extract_llm_validation_zones gibt auch Callouts zurück (als (kind, target) Tupel) # Daher müssen wir prüfen, ob es sich um einen Callout handelt # (Callouts haben typischerweise spezifische kinds wie "depends_on", "related_to", etc.) # Für jetzt nehmen wir an, dass alle Links aus LLM-Validierungs-Zonen als "bereits verarbeitet" markiert werden # Dies verhindert Duplikate im globalen Scan callout_key = (kind, target, sec) all_chunk_callout_keys.add(callout_key) logger.debug(f"Note [{note_id}]: LLM-Validierungs-Zone Callout-Key hinzugefügt: ({kind}, {target}, {sec})") # 1) Struktur-Kanten (Internal: belongs_to, next/prev) # Diese erhalten die Provenienz 'structure' und sind in der Registry geschützt. for idx, ch in enumerate(chunks): cid = _get(ch, "chunk_id", "id") if not cid: continue # Verbindung Chunk -> Note # WP-24c v4.0.0: rule_id wird nur im Payload gespeichert, fließt nicht in die ID ein edges.append(_edge("belongs_to", "chunk", cid, note_id, note_id, { "chunk_id": cid, "edge_id": _mk_edge_id("belongs_to", cid, note_id, "chunk"), "provenance": "structure", "rule_id": "structure:belongs_to", "confidence": PROVENANCE_PRIORITY["structure:belongs_to"] })) # Horizontale Verkettung (Ordnung) if idx < len(chunks) - 1: next_id = _get(chunks[idx+1], "chunk_id", "id") if next_id: # WP-24c v4.0.0: rule_id wird nur im Payload gespeichert, fließt nicht in die ID ein edges.append(_edge("next", "chunk", cid, next_id, note_id, { "chunk_id": cid, "edge_id": _mk_edge_id("next", cid, next_id, "chunk"), "provenance": "structure", "rule_id": "structure:order", "confidence": PROVENANCE_PRIORITY["structure:order"] })) edges.append(_edge("prev", "chunk", next_id, cid, note_id, { "edge_id": _mk_edge_id("prev", next_id, cid, "chunk"), "provenance": "structure", "rule_id": "structure:order", "confidence": PROVENANCE_PRIORITY["structure:order"] })) # 2) Inhaltliche Kanten (Explicit & Candidate Pool) reg = load_types_registry() defaults = get_edge_defaults_for(note_type, reg) refs_all: List[str] = [] # WP-24c v4.5.7: all_chunk_callout_keys wurde bereits oben initialisiert # (Zeile 530) - keine erneute Initialisierung nötig # PHASE 1 (Sicherung der Chunk-Autorität): Sammle alle Callout-Keys aus candidate_pool # BEVOR der globale Markdown-Scan oder der Loop über die Chunks beginnt # Dies stellt sicher, dass bereits geerntete Callouts nicht dupliziert werden # WP-24c v4.3.0: Debug-Logik für Audit des Datentransfers # WP-24c v4.4.0-DEBUG: Logger ist am Modul-Level definiert for ch in chunks: cid = _get(ch, "chunk_id", "id") if not cid: continue # Iteriere durch candidate_pool und sammle explicit:callout Kanten pool = ch.get("candidate_pool") or ch.get("candidate_edges") or [] # WP-24c v4.3.0: Debug-Logik - Ausgabe der Pool-Größe pool_size = len(pool) explicit_callout_count = sum(1 for cand in pool if cand.get("provenance") == "explicit:callout") if pool_size > 0: logger.debug(f"Note [{note_id}]: Chunk [{ch.get('index', '?')}] hat {pool_size} Kanten im Candidate-Pool ({explicit_callout_count} explicit:callout)") for cand in pool: # WP-24c v4.4.1: Harmonisierung - akzeptiere sowohl "to" als auch "target_id" raw_t = cand.get("to") or cand.get("target_id") k = cand.get("kind", "related_to") p = cand.get("provenance", "semantic_ai") # WP-24c v4.4.1: String-Check - Provenance muss exakt "explicit:callout" sein (case-sensitive) # WP-24c v4.2.9 Fix B: Wenn Provenance explicit:callout, extrahiere Key # WP-24c v4.3.1: Key-Generierung gehärtet - Format (kind, target_id, target_section) # Exakt konsistent mit dem globalen Scan für zuverlässige Deduplizierung if p == "explicit:callout" and raw_t: t, sec = parse_link_target(raw_t, note_id) if t: # Key-Format: (kind, target_id, target_section) - exakt wie im globalen Scan # Dies verhindert, dass der globale Scan diese Kante als Note-Scope neu anlegt callout_key = (k, t, sec) # WP-24c v4.3.1: Explizite Key-Generierung all_chunk_callout_keys.add(callout_key) logger.debug(f"Note [{note_id}]: Callout-Key gesammelt: ({k}, {t}, {sec})") # WP-24c v4.4.0-DEBUG: Schnittstelle 3 - Synchronisation Phase 1 logger.debug(f"DEBUG-TRACER [Phase 1 Sync]: Gefundener Key im Pool: ({k}, {t}, {sec}), Raw_Target: {raw_t}, Zugeordnet zu: {cid}, Chunk_Index: {ch.get('index', '?')}, Provenance: {p}") # WP-24c v4.3.0: Debug-Logik - Ausgabe der gesammelten Keys if all_chunk_callout_keys: logger.debug(f"Note [{note_id}]: Gesammelt {len(all_chunk_callout_keys)} Callout-Keys aus candidate_pools") else: logger.warning(f"Note [{note_id}]: KEINE Callout-Keys in candidate_pools gefunden - möglicher Datenverlust!") # WP-24c v4.2.9: PHASE 2: Verarbeite Chunks und erstelle Kanten for ch in chunks: cid = _get(ch, "chunk_id", "id") if not cid: continue raw = _get(ch, "window") or _get(ch, "text") or "" # A. Typed Relations (Inline [[rel:kind|target]]) typed, rem = extract_typed_relations(raw) for k, raw_t in typed: t, sec = parse_link_target(raw_t, note_id) if not t: continue payload = { "chunk_id": cid, # WP-24c v4.1.0: target_section fließt nun fest in die ID-Generierung ein "edge_id": _mk_edge_id(k, cid, t, "chunk", target_section=sec), "provenance": "explicit", "rule_id": "inline:rel", "confidence": PROVENANCE_PRIORITY["inline:rel"] } if sec: payload["target_section"] = sec edges.append(_edge(k, "chunk", cid, t, note_id, payload)) # B. Candidate Pool (WP-15b Validierte KI-Kanten) # WP-24c v4.2.9: Erstelle Kanten aus candidate_pool (Keys bereits in Phase 1 gesammelt) pool = ch.get("candidate_pool") or ch.get("candidate_edges") or [] for cand in pool: # WP-24c v4.4.1: Harmonisierung - akzeptiere sowohl "to" als auch "target_id" raw_t = cand.get("to") or cand.get("target_id") k = cand.get("kind", "related_to") p = cand.get("provenance", "semantic_ai") t, sec = parse_link_target(raw_t, note_id) if t: # WP-24c v4.1.0: target_section fließt nun fest in die ID-Generierung ein # WP-24c v4.3.1: explicit:callout erhält Confidence 1.0 für Präzisions-Priorität # WP-24c v4.5.6: candidate: Präfix NUR für global_pool (aus LLM-Validierungs-Zonen) # Normale Callouts im Fließtext erhalten KEIN candidate: Präfix confidence = 1.0 if p == "explicit:callout" else PROVENANCE_PRIORITY.get(p, 0.90) # WP-24c v4.5.6: rule_id nur mit candidate: für global_pool (LLM-Validierungs-Zonen) # explicit:callout (normale Callouts im Fließtext) erhalten KEIN candidate: Präfix if p == "global_pool": rule_id = f"candidate:{p}" elif p == "explicit:callout": rule_id = "explicit:callout" # WP-24c v4.5.6: Kein candidate: für Fließtext-Callouts else: rule_id = p # Andere Provenances ohne candidate: payload = { "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", target_section=sec), "provenance": p, "rule_id": rule_id, "confidence": confidence } if sec: payload["target_section"] = sec edges.append(_edge(k, "chunk", cid, t, note_id, payload)) # C. Callouts (> [!edge]) - WP-24c v4.2.9: Fallback für Callouts im gereinigten Text # HINWEIS: Da der Text bereits gereinigt wurde (Clean-Context), werden hier typischerweise # keine Callouts mehr gefunden. Falls doch, prüfe gegen all_chunk_callout_keys. call_pairs, rem2 = extract_callout_relations(rem) for k, raw_t in call_pairs: t, sec = parse_link_target(raw_t, note_id) if not t: continue # WP-24c v4.2.9: Prüfe, ob dieser Callout bereits im candidate_pool erfasst wurde callout_key = (k, t, sec) if callout_key in all_chunk_callout_keys: # Bereits im candidate_pool erfasst -> überspringe (wird mit chunk-Scope angelegt) continue # WP-24c v4.2.1: Tracke Callout für spätere Deduplizierung (global sammeln) all_chunk_callout_keys.add(callout_key) # WP-24c v4.1.0: target_section fließt nun fest in die ID-Generierung ein payload = { "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", target_section=sec), "provenance": "explicit", "rule_id": "callout:edge", "confidence": PROVENANCE_PRIORITY["callout:edge"] } if sec: payload["target_section"] = sec edges.append(_edge(k, "chunk", cid, t, note_id, payload)) # D. Standard Wikilinks & Typ-Defaults refs = extract_wikilinks(rem2) for raw_r in refs: r, sec = parse_link_target(raw_r, note_id) if not r: continue # WP-24c v4.1.0: target_section fließt nun fest in die ID-Generierung ein payload = { "chunk_id": cid, "ref_text": raw_r, "edge_id": _mk_edge_id("references", cid, r, "chunk", target_section=sec), "provenance": "explicit", "rule_id": "explicit:wikilink", "confidence": PROVENANCE_PRIORITY["explicit:wikilink"] } if sec: payload["target_section"] = sec edges.append(_edge("references", "chunk", cid, r, note_id, payload)) # Automatische Kanten-Vererbung aus types.yaml for rel in defaults: if rel != "references": # WP-24c v4.1.0: target_section fließt nun fest in die ID-Generierung ein def_payload = { "chunk_id": cid, "edge_id": _mk_edge_id(rel, cid, r, "chunk", target_section=sec), "provenance": "rule", "rule_id": f"edge_defaults:{rel}", "confidence": PROVENANCE_PRIORITY["edge_defaults"] } if sec: def_payload["target_section"] = sec edges.append(_edge(rel, "chunk", cid, r, note_id, def_payload)) refs_all.extend([parse_link_target(r, note_id)[0] for r in refs]) # 3) Note-Scope (Grobe Struktur-Verbindungen) if include_note_scope_refs: cleaned_note_refs = [parse_link_target(r, note_id)[0] for r in (note_level_references or [])] refs_note = _dedupe_seq((refs_all or []) + cleaned_note_refs) for r in refs_note: if not r: continue # WP-24c v4.0.0: rule_id wird nur im Payload gespeichert, fließt nicht in die ID ein edges.append(_edge("references", "note", note_id, r, note_id, { "edge_id": _mk_edge_id("references", note_id, r, "note"), "provenance": "explicit", "rule_id": "explicit:note_scope", "confidence": PROVENANCE_PRIORITY["explicit:note_scope"] })) # Backlinks zur Stärkung der Bidirektionalität edges.append(_edge("backlink", "note", r, note_id, note_id, { "edge_id": _mk_edge_id("backlink", r, note_id, "note"), "provenance": "rule", "rule_id": "derived:backlink", "confidence": PROVENANCE_PRIORITY["derived:backlink"] })) # 4) WP-24c v4.2.0: Note-Scope Edges hinzufügen (VOR De-Duplizierung) # WP-24c v4.2.0: Note-Scope Edges hinzufügen edges.extend(note_scope_edges) # WP-24c v4.5.6: LLM-Validierungs-Edges hinzufügen (mit candidate: Präfix) edges.extend(llm_validation_edges) # 5) WP-24c v4.2.9 Fix B PHASE 2 (Deduplizierung): Callout-Extraktion aus Markdown # Der globale Scan des markdown_body nutzt all_chunk_callout_keys als Ausschlusskriterium. # Callouts, die bereits in Phase 1 als Chunk-Kanten identifiziert wurden, # dürfen nicht erneut als Note-Scope Kanten angelegt werden. callout_edges_from_markdown: List[dict] = [] if markdown_body: # WP-24c v4.3.0: Debug-Logik - Ausgabe vor globalem Scan logger.debug(f"Note [{note_id}]: Starte globalen Markdown-Scan mit {len(all_chunk_callout_keys)} ausgeschlossenen Callout-Keys") # WP-24c v4.4.0-DEBUG: Schnittstelle 3 - Global Scan Start block_list = list(all_chunk_callout_keys) logger.debug(f"DEBUG-TRACER [Global Scan Start]: Block-Liste (all_chunk_callout_keys): {block_list}, Anzahl: {len(block_list)}") for key in block_list: logger.debug(f"DEBUG-TRACER [Global Scan Start]: Block-Key Detail - Kind: {key[0]}, Target: {key[1]}, Section: {key[2]}") callout_edges_from_markdown = extract_callouts_from_markdown( markdown_body, note_id, existing_chunk_callouts=all_chunk_callout_keys # WP-24c v4.2.9 Fix B: Strikte Respektierung ) # WP-24c v4.3.0: Debug-Logik - Ausgabe nach globalem Scan if callout_edges_from_markdown: logger.debug(f"Note [{note_id}]: Globaler Scan erzeugte {len(callout_edges_from_markdown)} Note-Scope Callout-Kanten") else: logger.debug(f"Note [{note_id}]: Globaler Scan erzeugte KEINE Note-Scope Callout-Kanten (alle bereits in Chunks)") edges.extend(callout_edges_from_markdown) # 6) WP-24c v4.2.2: Semantische De-Duplizierung mit Scope-Entscheidung # Problem: edge_id enthält Scope, daher werden semantisch identische Kanten # (gleiches kind, source, target, section) mit unterschiedlichem Scope nicht erkannt. # Lösung: Zuerst semantische Gruppierung, dann Scope-Entscheidung, dann ID-Berechnung. # Schritt 1: Semantische Gruppierung (unabhängig vom Scope) # Schlüssel: (kind, source_id, target_id, target_section) # Hinweis: source_id ist bei chunk-Scope die chunk_id, bei note-Scope die note_id # Für semantische Gleichheit müssen wir prüfen: Ist die Quelle die gleiche Note? semantic_groups: Dict[Tuple[str, str, str, Optional[str]], List[dict]] = {} for e in edges: kind = e.get("kind", "related_to") source_id = e.get("source_id", "") target_id = e.get("target_id", "") target_section = e.get("target_section") scope = e.get("scope", "chunk") note_id_from_edge = e.get("note_id", "") # WP-24c v4.2.2: Normalisiere source_id für semantische Gruppierung # Bei chunk-Scope: source_id ist chunk_id, aber wir wollen nach note_id gruppieren # Bei note-Scope: source_id ist bereits note_id # Für semantische Gleichheit: Beide Kanten müssen von derselben Note ausgehen if scope == "chunk": # Bei chunk-Scope: source_id ist chunk_id, aber note_id ist im Edge vorhanden # Wir verwenden note_id als semantische Quelle semantic_source = note_id_from_edge else: # Bei note-Scope: source_id ist bereits note_id semantic_source = source_id # Semantischer Schlüssel: (kind, semantic_source, target_id, target_section) semantic_key = (kind, semantic_source, target_id, target_section) # WP-24c v4.4.0-DEBUG: Schnittstelle 4 - De-Duplizierung Gruppierung # Nur für Callout-Kanten loggen if e.get("provenance") == "explicit:callout": logger.debug(f"DEBUG-TRACER [Dedup Grouping]: Edge zu Gruppe - Semantic_Key: {semantic_key}, Scope: {scope}, Source_ID: {source_id}, Provenance: {e.get('provenance')}, Confidence: {e.get('confidence')}, Edge_ID: {e.get('edge_id')}") if semantic_key not in semantic_groups: semantic_groups[semantic_key] = [] semantic_groups[semantic_key].append(e) # Schritt 2: Scope-Entscheidung pro semantischer Gruppe # Schritt 3: ID-Zuweisung nach Scope-Entscheidung final_edges: List[dict] = [] # WP-24c v4.5.10: Deterministische Sortierung der semantic_groups für konsistente Edge-Extraktion # Verhindert Varianz zwischen Batches (33 vs 34 Kanten) # WICHTIG: target_section kann None sein, daher benötigen wir eine benutzerdefinierte Sortierfunktion def sort_key_func(key_tuple): """ Sortierfunktion für semantic_keys, die None-Werte korrekt behandelt. None wird als leere Zeichenkette behandelt, um Vergleichbarkeit zu gewährleisten. """ kind, semantic_source, target_id, target_section = key_tuple # Konvertiere None zu leerem String für konsistente Sortierung target_section_safe = target_section if target_section is not None else "" return (kind, semantic_source, target_id, target_section_safe) sorted_semantic_keys = sorted(semantic_groups.keys(), key=sort_key_func) for semantic_key in sorted_semantic_keys: group = semantic_groups[semantic_key] # WP-24c v4.4.0-DEBUG: Schnittstelle 4 - De-Duplizierung Entscheidung # Prüfe, ob diese Gruppe Callout-Kanten enthält has_callouts = any(e.get("provenance") == "explicit:callout" for e in group) if len(group) == 1: # Nur eine Kante: Direkt verwenden, aber ID neu berechnen mit finalem Scope winner = group[0] final_scope = winner.get("scope", "chunk") final_source = winner.get("source_id", "") kind, semantic_source, target_id, target_section = semantic_key # WP-24c v4.2.2: Berechne edge_id mit finalem Scope final_edge_id = _mk_edge_id(kind, final_source, target_id, final_scope, target_section=target_section) winner["edge_id"] = final_edge_id # WP-24c v4.3.0: Debug-Logik - Ausgabe für Callout-Kanten if winner.get("provenance") == "explicit:callout": logger.debug(f"Note [{note_id}]: Finale Callout-Kante (single): scope={final_scope}, source={final_source}, target={target_id}, section={target_section}") # WP-24c v4.4.0-DEBUG: Schnittstelle 4 - Single Edge if has_callouts: logger.debug(f"DEBUG-TRACER [Dedup]: Gruppe: {semantic_key}, Kandidaten: [Single: scope={final_scope}/provenance={winner.get('provenance')}/confidence={winner.get('confidence')}], Gewinner: {final_edge_id}, Grund: Single-Edge") final_edges.append(winner) else: # Mehrere Kanten mit gleichem semantischen Schlüssel: Scope-Entscheidung # WP-24c v4.3.1: Präzision (Chunk) siegt über Globalität (Note) winner = None # WP-24c v4.4.0-DEBUG: Schnittstelle 4 - De-Duplizierung Kandidaten-Analyse if has_callouts: candidates_info = [] for e in group: candidates_info.append(f"scope={e.get('scope')}/provenance={e.get('provenance')}/confidence={e.get('confidence')}/source={e.get('source_id')}") logger.debug(f"DEBUG-TRACER [Dedup]: Gruppe: {semantic_key}, Kandidaten: [{', '.join(candidates_info)}]") # Regel 1: explicit:note_zone hat höchste Priorität (Autorität) note_zone_candidates = [e for e in group if e.get("provenance") == "explicit:note_zone"] if note_zone_candidates: # Wenn mehrere note_zone: Nimm die mit höchster Confidence winner = max(note_zone_candidates, key=lambda e: e.get("confidence", 0)) decision_reason = "explicit:note_zone (höchste Priorität)" else: # Regel 2: chunk-Scope ZWINGEND bevorzugen (Präzisions-Vorteil) # WP-24c v4.3.1: Wenn mindestens ein chunk-Kandidat existiert, muss dieser gewinnen chunk_candidates = [e for e in group if e.get("scope") == "chunk"] if chunk_candidates: # Wenn mehrere chunk: Nimm die mit höchster Confidence * Priority # Die Confidence ist hier nicht der alleinige Ausschlaggeber - chunk-Scope hat Vorrang winner = max(chunk_candidates, key=lambda e: ( e.get("confidence", 0) * PROVENANCE_PRIORITY.get(e.get("provenance", ""), 0.7) )) decision_reason = f"chunk-Scope (Präzision, {len(chunk_candidates)} chunk-Kandidaten)" else: # Regel 3: Fallback (nur wenn KEIN chunk-Kandidat vorhanden): Höchste Confidence * Priority winner = max(group, key=lambda e: ( e.get("confidence", 0) * PROVENANCE_PRIORITY.get(e.get("provenance", ""), 0.7) )) decision_reason = "Fallback (höchste Confidence * Priority, kein chunk-Kandidat)" # WP-24c v4.2.2: Berechne edge_id mit finalem Scope final_scope = winner.get("scope", "chunk") final_source = winner.get("source_id", "") kind, semantic_source, target_id, target_section = semantic_key final_edge_id = _mk_edge_id(kind, final_source, target_id, final_scope, target_section=target_section) winner["edge_id"] = final_edge_id # WP-24c v4.3.0: Debug-Logik - Ausgabe für Callout-Kanten bei Deduplizierung if winner.get("provenance") == "explicit:callout": logger.debug(f"Note [{note_id}]: Finale Callout-Kante (deduped, {len(group)} Kandidaten): scope={final_scope}, source={final_source}, target={target_id}, section={target_section}") # WP-24c v4.4.0-DEBUG: Schnittstelle 4 - Entscheidung if has_callouts: logger.debug(f"DEBUG-TRACER [Decision]: Gewinner: {final_edge_id}, Scope: {final_scope}, Source: {final_source}, Provenance: {winner.get('provenance')}, Confidence: {winner.get('confidence')}, Grund: {decision_reason}") final_edges.append(winner) return final_edges