mindnet/app/core/graph/graph_derive_edges.py
Lars 6047e94964 Refactor edge processing in graph_derive_edges.py and ingestion_processor.py for consistency and efficiency
Implement deterministic sorting of semantic groups in graph_derive_edges.py to ensure consistent edge extraction across batches. Update ingestion_processor.py to enhance change detection logic, ensuring that hash checks are performed before artifact checks to prevent redundant processing. These changes improve the reliability and efficiency of the edge building and ingestion workflows.
2026-01-12 08:04:28 +01:00

997 lines
48 KiB
Python

"""
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.9: Deterministische Sortierung der semantic_groups für konsistente Edge-Extraktion
# Verhindert Varianz zwischen Batches (33 vs 34 Kanten)
sorted_semantic_keys = sorted(semantic_groups.keys())
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