Implement functions to extract LLM validation zones from Markdown, allowing for configurable header identification via environment variables. Enhance the existing note scope zone extraction to differentiate between note scope and LLM validation zones. Update edge building logic to handle LLM validation edges with a 'candidate:' prefix, ensuring proper processing and avoiding duplicates in global scans. This update improves the overall handling of edge data and enhances the flexibility of the extraction process.
825 lines
40 KiB
Python
825 lines
40 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
|
|
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.
|
|
|
|
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)
|
|
|
|
# WP-24c v4.4.0-DEBUG: Schnittstelle 3 - Global Scan Vergleich
|
|
is_blocked = callout_key in existing_chunk_callouts
|
|
logger.debug(f"DEBUG-TRACER [Global Scan Compare]: Key: ({k}, {t}, {sec}), Raw_Target: {raw_t}, In_Block_List: {is_blocked}, Block_List_Size: {len(existing_chunk_callouts) if existing_chunk_callouts else 0}")
|
|
|
|
if is_blocked:
|
|
# 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
|
|
logger.debug(f"DEBUG-TRACER [Global Scan Compare]: Key ({k}, {t}, {sec}) ist blockiert - überspringe")
|
|
continue
|
|
|
|
# WP-24c v4.2.1: Callout ist NICHT in Chunks -> lege mit scope: "note" an
|
|
# (typischerweise in Edge-Zonen, die nicht gechunkt werden)
|
|
# WP-24c v4.3.1: Confidence auf 0.7 gesenkt, damit chunk-Scope (1.0) gewinnt
|
|
payload = {
|
|
"edge_id": _mk_edge_id(k, note_id, t, "note", target_section=sec),
|
|
"provenance": "explicit:callout",
|
|
"rule_id": "callout:edge",
|
|
"confidence": 0.7 # WP-24c v4.3.1: Niedrigere Confidence für Note-Scope Callouts
|
|
}
|
|
if sec:
|
|
payload["target_section"] = sec
|
|
|
|
# WP-24c v4.4.0-DEBUG: Schnittstelle 3 - Global Scan erstellt Note-Scope Callout
|
|
logger.debug(f"DEBUG-TRACER [Global Scan Create]: Erstelle Note-Scope Callout - Kind: {k}, Target: {t}, Section: {sec}, Raw_Target: {raw_t}, Edge_ID: {payload['edge_id']}, Confidence: {payload['confidence']}")
|
|
|
|
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.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.2.9 Fix B: Zwei-Phasen-Synchronisation für Chunk-Autorität
|
|
# WICHTIG: Diese Menge muss VOR dem globalen Scan vollständig sein
|
|
all_chunk_callout_keys: Set[Tuple[str, str, Optional[str]]] = set()
|
|
|
|
# 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] = []
|
|
|
|
for semantic_key, group in semantic_groups.items():
|
|
# 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 |