- Introduced a `virtual` attribute for automatically generated section transitions and backlinks in the graph edge definitions, indicating their virtual nature. - Updated documentation to reflect the addition of the `virtual` attribute for both section transitions and backlinks, clarifying its implications for scoring in the retriever. - Enhanced the understanding of edge types by specifying that these automatically generated edges will receive a penalty during scoring.
1119 lines
54 KiB
Python
1119 lines
54 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.4.0 (WP-26 v1.4: Automatische Backlinks für Intra-Note-Edges)
|
|
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,
|
|
get_typical_edge_for # WP-26 v1.1: Für automatische Intra-Note-Edges
|
|
)
|
|
# WP-26 v1.4: Für automatische Backlinks bei Intra-Note-Edges
|
|
try:
|
|
from app.services.edge_registry import registry as edge_registry
|
|
except ImportError:
|
|
edge_registry = None
|
|
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"]
|
|
}))
|
|
|
|
# 1b) WP-26 v1.1: Automatische Intra-Note-Edges zwischen Sektionen mit unterschiedlichen Typen
|
|
# Wenn sich der section_type zwischen aufeinanderfolgenden Chunks ändert,
|
|
# wird eine semantische Kante basierend auf graph_schema.md erstellt.
|
|
for idx, ch in enumerate(chunks):
|
|
if idx >= len(chunks) - 1:
|
|
continue # Kein nächster Chunk
|
|
|
|
cid = _get(ch, "chunk_id", "id")
|
|
next_ch = chunks[idx + 1]
|
|
next_id = _get(next_ch, "chunk_id", "id")
|
|
|
|
if not cid or not next_id:
|
|
continue
|
|
|
|
# Hole die effective_types der Chunks
|
|
# WP-26 v1.1: section_type oder note_type (effective_type)
|
|
current_section_type = ch.get("section_type")
|
|
next_section_type = next_ch.get("section_type")
|
|
current_type = current_section_type or ch.get("type") or note_type
|
|
next_type = next_section_type or next_ch.get("type") or note_type
|
|
|
|
# Prüfe, ob es einen Section-Type-Wechsel gibt
|
|
# Nur wenn beide einen expliziten section_type haben oder sich die effective_types unterscheiden
|
|
is_section_change = (
|
|
(current_section_type is not None or next_section_type is not None) and
|
|
current_type != next_type
|
|
)
|
|
|
|
if is_section_change:
|
|
# Ermittle den passenden Edge-Typ aus graph_schema.md
|
|
edge_kind = get_typical_edge_for(current_type, next_type)
|
|
|
|
logger.debug(f"WP-26 Intra-Note-Edge: {current_type} -> {next_type} = {edge_kind}")
|
|
|
|
# Erstelle die automatische Edge (Forward-Richtung)
|
|
edges.append(_edge(edge_kind, "chunk", cid, next_id, note_id, {
|
|
"chunk_id": cid,
|
|
"edge_id": _mk_edge_id(edge_kind, cid, next_id, "chunk"),
|
|
"provenance": "rule",
|
|
"rule_id": "inferred:section_transition",
|
|
"source_hint": "schema_default",
|
|
"confidence": PROVENANCE_PRIORITY.get("schema_default", 0.85),
|
|
"is_internal": True, # Explizit als Intra-Note-Edge markieren
|
|
"virtual": True, # WP-26 v1.4: Automatisch generierte Section-Transitions sind virtuell
|
|
"section_transition": f"{current_type}->{next_type}" # Debug-Info
|
|
}))
|
|
|
|
# 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)
|
|
|
|
# WP-26 v1.4: Automatische Backlinks für Intra-Note-Edges (Chunk-Level)
|
|
# Erstelle inverse Edges für alle Intra-Note-Edges, wenn noch nicht vorhanden
|
|
if edge_registry:
|
|
# Erstelle Set aller existierenden Edge-Keys für schnelle Lookup
|
|
existing_edge_keys: Set[Tuple[str, str, str, Optional[str]]] = set()
|
|
for e in final_edges:
|
|
source = e.get("source_id", "")
|
|
target = e.get("target_id", "")
|
|
kind = e.get("kind", "")
|
|
target_section = e.get("target_section")
|
|
existing_edge_keys.add((source, target, kind, target_section))
|
|
|
|
# Durchlaufe alle Edges und erstelle Backlinks für Intra-Note-Edges
|
|
backlinks_to_add: List[dict] = []
|
|
for e in final_edges:
|
|
is_internal = e.get("is_internal", False)
|
|
scope = e.get("scope", "chunk")
|
|
source_id = e.get("source_id", "")
|
|
target_id = e.get("target_id", "")
|
|
kind = e.get("kind", "")
|
|
target_section = e.get("target_section")
|
|
|
|
# Nur Intra-Note-Edges auf Chunk-Level berücksichtigen
|
|
if not is_internal or scope != "chunk":
|
|
continue
|
|
|
|
# Prüfe, ob bereits ein inverser Edge existiert
|
|
inv_kind = edge_registry.get_inverse(kind) if edge_registry else None
|
|
if not inv_kind:
|
|
continue # Kein inverser Edge-Type verfügbar
|
|
|
|
# Prüfe, ob inverser Edge bereits existiert
|
|
inv_key = (target_id, source_id, inv_kind, None) # Backlink hat keine target_section
|
|
if inv_key in existing_edge_keys:
|
|
continue # Backlink bereits vorhanden
|
|
|
|
# Erstelle automatischen Backlink
|
|
backlink_edge = _edge(inv_kind, "chunk", target_id, source_id, note_id, {
|
|
"chunk_id": target_id, # Backlink geht vom Target-Chunk aus
|
|
"edge_id": _mk_edge_id(inv_kind, target_id, source_id, "chunk"),
|
|
"provenance": "rule",
|
|
"rule_id": "derived:intra_note_backlink",
|
|
"source_hint": "automatic_backlink",
|
|
"confidence": PROVENANCE_PRIORITY.get("derived:backlink", 0.8),
|
|
"is_internal": True,
|
|
"virtual": True, # WP-26 v1.4: Automatisch generierte Backlinks sind virtuell
|
|
"original_edge_kind": kind # Debug-Info: Welcher Edge-Type wurde invertiert
|
|
})
|
|
|
|
backlinks_to_add.append(backlink_edge)
|
|
existing_edge_keys.add(inv_key) # Verhindere Duplikate
|
|
logger.debug(f"WP-26 Backlink erstellt: {target_id} --[{inv_kind}]--> {source_id} (Original: {kind})")
|
|
|
|
# Füge Backlinks zu final_edges hinzu
|
|
if backlinks_to_add:
|
|
final_edges.extend(backlinks_to_add)
|
|
logger.info(f"WP-26: {len(backlinks_to_add)} automatische Backlinks für Intra-Note-Edges erstellt")
|
|
|
|
return final_edges |