""" 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 VERSION: 4.2.2 (WP-24c: Semantische De-Duplizierung) STATUS: Active """ import re 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.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 def extract_note_scope_zones(markdown_body: str) -> List[Tuple[str, str]]: """ WP-24c v4.2.0: Extrahiert Note-Scope Zonen aus Markdown. 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 = [] 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() # Prüfe, ob dieser Header eine Note-Scope Zone ist # WP-24c v4.2.0: Dynamisches Laden der konfigurierten Header zone_headers = get_note_scope_zone_headers() is_zone_header = any( header_text.lower() == zone_header.lower() for zone_header in zone_headers ) if is_zone_header: 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_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. 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. 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] = [] # Extrahiere alle Callouts aus dem gesamten Markdown call_pairs, _ = extract_callout_relations(markdown_body) for k, raw_t in call_pairs: t, sec = parse_link_target(raw_t, note_id) if not t: continue # WP-24c v4.2.2: Prüfe, ob dieser Callout bereits in einem Chunk vorkommt # Härtung: Berücksichtigt auch Sektions-Anker (sec) für Multigraph-Präzision # Ein Callout zu "Note#Section1" ist anders als "Note#Section2" oder "Note" callout_key = (k, t, sec) if callout_key in existing_chunk_callouts: # Callout ist bereits in Chunk erfasst -> überspringe (wird mit chunk-Scope angelegt) # Die Sektion (sec) ist bereits im Key enthalten, daher wird Multigraph-Präzision gewährleistet continue # WP-24c v4.2.1: Callout ist NICHT in Chunks -> lege mit scope: "note" an # (typischerweise in Edge-Zonen, die nicht gechunkt werden) payload = { "edge_id": _mk_edge_id(k, note_id, t, "note", target_section=sec), "provenance": "explicit:callout", "rule_id": "callout:edge", "confidence": PROVENANCE_PRIORITY.get("callout:edge", 1.0) } if sec: payload["target_section"] = sec 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. 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.2.0: Note-Scope Zonen Extraktion (VOR Chunk-Verarbeitung) note_scope_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 )) # 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.2.1: Sammle alle Callout-Keys aus Chunks für Smart Logic all_chunk_callout_keys: Set[Tuple[str, str, Optional[str]]] = set() 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.7: Sammle Callout-Keys für Chunk-Attribution pool = ch.get("candidate_pool") or ch.get("candidate_edges") or [] for cand in pool: raw_t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai") t, sec = parse_link_target(raw_t, note_id) if t: # WP-24c v4.2.7: Wenn Provenance explicit:callout, füge zu all_chunk_callout_keys hinzu # Dadurch weiß die globale Extraktion, dass diese Kante bereits auf Chunk-Ebene versorgt ist if p == "explicit:callout": all_chunk_callout_keys.add((k, t, sec)) # 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": p, "rule_id": f"candidate:{p}", "confidence": PROVENANCE_PRIORITY.get(p, 0.90) } if sec: payload["target_section"] = sec edges.append(_edge(k, "chunk", cid, t, note_id, payload)) # C. Callouts (> [!edge]) - WP-24c v4.2.1: Sammle für Smart Logic 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.1: Tracke Callout für spätere Deduplizierung (global sammeln) all_chunk_callout_keys.add((k, t, sec)) # 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) edges.extend(note_scope_edges) # 5) WP-24c v4.2.1: Callout-Extraktion aus Markdown (NACH Chunk-Verarbeitung) # Smart Logic: Nur Callouts, die NICHT in Chunks vorkommen, werden mit scope: "note" angelegt callout_edges_from_markdown: List[dict] = [] if markdown_body: callout_edges_from_markdown = extract_callouts_from_markdown( markdown_body, note_id, existing_chunk_callouts=all_chunk_callout_keys ) 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) 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] = [] for semantic_key, group in semantic_groups.items(): 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 final_edges.append(winner) else: # Mehrere Kanten mit gleichem semantischen Schlüssel: Scope-Entscheidung winner = None # Regel 1: explicit:note_zone hat höchste Prioritä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)) else: # Regel 2: chunk-Scope bevorzugen (Präzisions-Vorteil) 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 winner = max(chunk_candidates, key=lambda e: ( e.get("confidence", 0) * PROVENANCE_PRIORITY.get(e.get("provenance", ""), 0.7) )) else: # Regel 3: Fallback: Höchste Confidence * Priority winner = max(group, key=lambda e: ( e.get("confidence", 0) * PROVENANCE_PRIORITY.get(e.get("provenance", ""), 0.7) )) # 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 final_edges.append(winner) return final_edges