Compare commits

..

No commits in common. "cd5056d4c9e6137edd98e6fb4c2883aa0cd504e8" and "fdf99b2bb02c9148c76a9b5058657b6ead442f09" have entirely different histories.

23 changed files with 406 additions and 1140 deletions

View File

@ -1,8 +1,6 @@
""" """
FILE: app/core/chunking/chunking_parser.py FILE: app/core/chunking/chunking_parser.py
DESCRIPTION: Zerlegt Markdown in logische Einheiten (RawBlocks). DESCRIPTION: Zerlegt Markdown in Blöcke und extrahiert Kanten-Strings.
Hält alle Überschriftenebenen (H1-H6) im Stream.
Stellt die Funktion parse_edges_robust zur Verfügung.
""" """
import re import re
from typing import List, Tuple, Set from typing import List, Tuple, Set
@ -13,86 +11,69 @@ _WS = re.compile(r'\s+')
_SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])') _SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])')
def split_sentences(text: str) -> list[str]: def split_sentences(text: str) -> list[str]:
"""Teilt Text in Sätze auf unter Berücksichtigung deutscher Interpunktion.""" """Teilt Text in Sätze auf."""
text = _WS.sub(' ', text.strip()) text = _WS.sub(' ', text.strip())
if not text: return [] if not text: return []
# Splittet bei Punkt, Ausrufezeichen oder Fragezeichen, gefolgt von Leerzeichen und Großbuchstabe
return [p.strip() for p in _SENT_SPLIT.split(text) if p.strip()] return [p.strip() for p in _SENT_SPLIT.split(text) if p.strip()]
def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]:
"""Zerlegt Text in logische Einheiten (RawBlocks), inklusive H1-H6.""" """Zerlegt Text in logische Einheiten."""
blocks = [] blocks = []
h1_title = "Dokument" h1_title = "Dokument"; section_path = "/"; current_h2 = None
section_path = "/"
current_section_title = None
# Frontmatter entfernen
fm, text_without_fm = extract_frontmatter_from_text(md_text) fm, text_without_fm = extract_frontmatter_from_text(md_text)
# H1 für Note-Titel extrahieren (Metadaten-Zweck)
h1_match = re.search(r'^#\s+(.*)', text_without_fm, re.MULTILINE) h1_match = re.search(r'^#\s+(.*)', text_without_fm, re.MULTILINE)
if h1_match: if h1_match: h1_title = h1_match.group(1).strip()
h1_title = h1_match.group(1).strip()
lines = text_without_fm.split('\n') lines = text_without_fm.split('\n')
buffer = [] buffer = []
for line in lines: for line in lines:
stripped = line.strip() stripped = line.strip()
# Heading-Erkennung (H1 bis H6) # H1 ignorieren (ist Doc Title)
heading_match = re.match(r'^(#{1,6})\s+(.*)', stripped) if stripped.startswith('# '):
continue
# Generische Heading-Erkennung (H2 bis H6) für flexible Split-Levels
heading_match = re.match(r'^(#{2,6})\s+(.*)', stripped)
if heading_match: if heading_match:
# Vorherigen Text-Block abschließen # Buffer leeren (vorherigen Text abschließen)
if buffer: if buffer:
content = "\n".join(buffer).strip() content = "\n".join(buffer).strip()
if content: if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title))
buffer = [] buffer = []
level = len(heading_match.group(1)) level = len(heading_match.group(1))
title = heading_match.group(2).strip() title = heading_match.group(2).strip()
# Pfad- und Titel-Update für die Metadaten der folgenden Blöcke # Pfad-Logik: H2 setzt den Haupt-Pfad
if level == 1: if level == 2:
current_section_title = title; section_path = "/" current_h2 = title
elif level == 2: section_path = f"/{current_h2}"
current_section_title = title; section_path = f"/{current_section_title}" # Bei H3+ bleibt der section_path beim Parent, aber das Level wird korrekt gesetzt
# Die Überschrift selbst als regulären Block hinzufügen blocks.append(RawBlock("heading", stripped, level, section_path, current_h2))
blocks.append(RawBlock("heading", stripped, level, section_path, current_section_title))
continue
# Trenner (---) oder Leerzeilen beenden Blöcke, außer innerhalb von Callouts elif not stripped:
if (not stripped or stripped == "---") and not line.startswith('>'):
if buffer: if buffer:
content = "\n".join(buffer).strip() content = "\n".join(buffer).strip()
if content: if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title))
buffer = [] buffer = []
if stripped == "---":
blocks.append(RawBlock("separator", "---", None, section_path, current_section_title))
else: else:
buffer.append(line) buffer.append(line)
if buffer: if buffer:
content = "\n".join(buffer).strip() content = "\n".join(buffer).strip()
if content: if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title))
return blocks, h1_title return blocks, h1_title
def parse_edges_robust(text: str) -> Set[str]: def parse_edges_robust(text: str) -> Set[str]:
"""Extrahiert Kanten-Kandidaten aus Wikilinks und Callouts.""" """Extrahiert Kanten-Kandidaten (Wikilinks, Callouts)."""
found_edges = set() found_edges = set()
# 1. Wikilinks [[rel:kind|target]]
inlines = re.findall(r'\[\[rel:([^\|\]]+)\|?([^\]]*)\]\]', text) inlines = re.findall(r'\[\[rel:([^\|\]]+)\|?([^\]]*)\]\]', text)
for kind, target in inlines: for kind, target in inlines:
k = kind.strip().lower() k = kind.strip().lower()
t = target.strip() t = target.strip()
if k and t: found_edges.add(f"{k}:{t}") if k and t: found_edges.add(f"{k}:{t}")
# 2. Callout Edges > [!edge] kind
lines = text.split('\n') lines = text.split('\n')
current_edge_type = None current_edge_type = None
for line in lines: for line in lines:
@ -100,16 +81,13 @@ def parse_edges_robust(text: str) -> Set[str]:
callout_match = re.match(r'>\s*\[!edge\]\s*([^:\s]+)', stripped) callout_match = re.match(r'>\s*\[!edge\]\s*([^:\s]+)', stripped)
if callout_match: if callout_match:
current_edge_type = callout_match.group(1).strip().lower() current_edge_type = callout_match.group(1).strip().lower()
# Links in der gleichen Zeile des Callouts
links = re.findall(r'\[\[([^\]]+)\]\]', stripped) links = re.findall(r'\[\[([^\]]+)\]\]', stripped)
for l in links: for l in links:
if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}") if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}")
continue continue
# Links in Folgezeilen des Callouts
if current_edge_type and stripped.startswith('>'): if current_edge_type and stripped.startswith('>'):
links = re.findall(r'\[\[([^\]]+)\]\]', stripped) links = re.findall(r'\[\[([^\]]+)\]\]', stripped)
for l in links: for l in links:
if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}") if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}")
elif not stripped.startswith('>'): elif not stripped.startswith('>'): current_edge_type = None
current_edge_type = None
return found_edges return found_edges

View File

@ -1,8 +1,7 @@
""" """
FILE: app/core/chunking/chunking_processor.py FILE: app/core/chunking/chunking_processor.py
DESCRIPTION: Der zentrale Orchestrator für das Chunking-System. DESCRIPTION: Der zentrale Orchestrator für das Chunking-System.
AUDIT v3.3.4: Wiederherstellung der "Gold-Standard" Qualität. AUDIT v3.3.3: Wiederherstellung der "Gold-Standard" Qualität.
- Fix: Synchronisierung der Parameter (context_prefix) für alle Strategien.
- Integriert physikalische Kanten-Injektion (Propagierung). - Integriert physikalische Kanten-Injektion (Propagierung).
- Stellt H1-Kontext-Fenster sicher. - Stellt H1-Kontext-Fenster sicher.
- Baut den Candidate-Pool für die WP-15b Ingestion auf. - Baut den Candidate-Pool für die WP-15b Ingestion auf.
@ -31,19 +30,16 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
fm, body_text = extract_frontmatter_from_text(md_text) fm, body_text = extract_frontmatter_from_text(md_text)
blocks, doc_title = parse_blocks(md_text) blocks, doc_title = parse_blocks(md_text)
# Vorbereitung des H1-Präfix für die Embedding-Fenster (Breadcrumbs) # Vorbereitung des H1-Präfix für die Embedding-Fenster
h1_prefix = f"# {doc_title}" if doc_title else "" h1_prefix = f"# {doc_title}" if doc_title else ""
# 2. Anwendung der Splitting-Strategie # 2. Anwendung der Splitting-Strategie
# Alle Strategien nutzen nun einheitlich context_prefix für die Window-Bildung. # Wir übergeben den Dokument-Titel/Präfix für die Window-Bildung.
if config.get("strategy") == "by_heading": if config.get("strategy") == "by_heading":
chunks = await asyncio.to_thread( chunks = await asyncio.to_thread(strategy_by_heading, blocks, config, note_id, doc_title)
strategy_by_heading, blocks, config, note_id, context_prefix=h1_prefix
)
else: else:
chunks = await asyncio.to_thread( # sliding_window nutzt nun den context_prefix für das Window-Feld.
strategy_sliding_window, blocks, config, note_id, context_prefix=h1_prefix chunks = await asyncio.to_thread(strategy_sliding_window, blocks, config, note_id, context_prefix=h1_prefix)
)
if not chunks: if not chunks:
return [] return []
@ -56,7 +52,6 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
# Zuerst die explizit im Text vorhandenen Kanten sammeln. # Zuerst die explizit im Text vorhandenen Kanten sammeln.
for ch in chunks: for ch in chunks:
# Wir extrahieren aus dem bereits (durch Propagation) angereicherten Text. # Wir extrahieren aus dem bereits (durch Propagation) angereicherten Text.
# ch.candidate_pool wird im Modell-Konstruktor als leere Liste initialisiert.
for e_str in parse_edges_robust(ch.text): for e_str in parse_edges_robust(ch.text):
parts = e_str.split(':', 1) parts = e_str.split(':', 1)
if len(parts) == 2: if len(parts) == 2:
@ -76,7 +71,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
parts = e_str.split(':', 1) parts = e_str.split(':', 1)
if len(parts) == 2: if len(parts) == 2:
k, t = parts k, t = parts
# Diese Kanten werden als "global_pool" markiert für die spätere KI-Prüfung. # Diese Kanten werden als "Global Pool" markiert für die spätere KI-Prüfung.
for ch in chunks: for ch in chunks:
ch.candidate_pool.append({"kind": k, "to": t, "provenance": "global_pool"}) ch.candidate_pool.append({"kind": k, "to": t, "provenance": "global_pool"})
@ -85,7 +80,6 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
seen = set() seen = set()
unique = [] unique = []
for c in ch.candidate_pool: for c in ch.candidate_pool:
# Eindeutigkeit über Typ, Ziel und Herkunft (Provenance)
key = (c["kind"], c["to"], c["provenance"]) key = (c["kind"], c["to"], c["provenance"])
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)

View File

@ -1,8 +1,9 @@
""" """
FILE: app/core/chunking/chunking_propagation.py FILE: app/core/chunking/chunking_propagation.py
DESCRIPTION: Injiziert Sektions-Kanten physisch in den Text (Embedding-Enrichment). DESCRIPTION: Injiziert Sektions-Kanten physisch in den Text (Embedding-Enrichment).
Fix v3.3.6: Nutzt robustes Parsing zur Erkennung vorhandener Kanten, Stellt die "Gold-Standard"-Qualität von v3.1.0 wieder her.
um Dopplungen direkt hinter [!edge] Callouts format-agnostisch zu verhindern. VERSION: 3.3.1
STATUS: Active
""" """
from typing import List, Dict, Set from typing import List, Dict, Set
from .chunking_models import Chunk from .chunking_models import Chunk
@ -11,7 +12,7 @@ from .chunking_parser import parse_edges_robust
def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]: def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]:
""" """
Sammelt Kanten pro Sektion und schreibt sie hart in den Text und das Window. Sammelt Kanten pro Sektion und schreibt sie hart in den Text und das Window.
Verhindert Dopplungen, wenn Kanten bereits via [!edge] Callout vorhanden sind. Dies ist essenziell für die Vektorisierung der Beziehungen.
""" """
# 1. Sammeln: Alle expliziten Kanten pro Sektions-Pfad aggregieren # 1. Sammeln: Alle expliziten Kanten pro Sektions-Pfad aggregieren
section_map: Dict[str, Set[str]] = {} # path -> set(kind:target) section_map: Dict[str, Set[str]] = {} # path -> set(kind:target)
@ -35,28 +36,21 @@ def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]:
if not edges_to_add: if not edges_to_add:
continue continue
# Vorhandene Kanten (Typ:Ziel) in DIESEM Chunk ermitteln,
# um Dopplungen (z.B. durch Callouts) zu vermeiden.
existing_edges = parse_edges_robust(ch.text)
injections = [] injections = []
# Sortierung für deterministische Ergebnisse for e_str in edges_to_add:
for e_str in sorted(list(edges_to_add)):
# Wenn die Kante (Typ + Ziel) bereits vorhanden ist (egal welches Format),
# überspringen wir die Injektion für diesen Chunk.
if e_str in existing_edges:
continue
kind, target = e_str.split(':', 1) kind, target = e_str.split(':', 1)
injections.append(f"[[rel:{kind}|{target}]]") # Nur injizieren, wenn die Kante nicht bereits im Text steht
token = f"[[rel:{kind}|{target}]]"
if token not in ch.text:
injections.append(token)
if injections: if injections:
# Physische Anreicherung # Physische Anreicherung (Der v3.1.0 Qualitäts-Fix)
# Triple-Newline für saubere Trennung im Embedding-Fenster # Triple-Newline für saubere Trennung im Embedding-Fenster
block = "\n\n\n" + " ".join(injections) block = "\n\n\n" + " ".join(injections)
ch.text += block ch.text += block
# Auch ins Window schreiben, da Qdrant hier sucht! # ENTSCHEIDEND: Auch ins Window schreiben, da Qdrant hier sucht!
if ch.window: if ch.window:
ch.window += block ch.window += block
else: else:

View File

@ -1,166 +1,142 @@
""" """
FILE: app/core/chunking/chunking_strategies.py FILE: app/core/chunking/chunking_strategies.py
DESCRIPTION: Strategien für atomares Sektions-Chunking v3.9.9. DESCRIPTION: Mathematische Splitting-Strategien.
Implementiert das 'Pack-and-Carry-Over' Verfahren nach Regel 1-3. AUDIT v3.3.2: 100% Konformität zur 'by_heading' Spezifikation.
- Keine redundante Kanten-Injektion. - Implementiert Hybrid-Safety-Net (Sliding Window für Übergrößen).
- Strikte Einhaltung von Sektionsgrenzen via Look-Ahead. - Breadcrumb-Kontext im Window (H1 > H2).
- Fix: Synchronisierung der Parameter mit dem Orchestrator (context_prefix). - Sliding Window mit H1-Kontext (Gold-Standard v3.1.0).
""" """
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from .chunking_models import RawBlock, Chunk from .chunking_models import RawBlock, Chunk
from .chunking_utils import estimate_tokens from .chunking_utils import estimate_tokens
from .chunking_parser import split_sentences from .chunking_parser import split_sentences
def _create_win(context_prefix: str, sec_title: Optional[str], text: str) -> str: def _create_context_win(doc_title: str, sec_title: Optional[str], text: str) -> str:
"""Baut den Breadcrumb-Kontext für das Embedding-Fenster.""" """Baut den Breadcrumb-Kontext für das Embedding-Fenster."""
parts = [context_prefix] if context_prefix else [] parts = []
# Verhindert Dopplung, falls der Context-Prefix (H1) bereits den Sektionsnamen enthält if doc_title: parts.append(doc_title)
if sec_title and f"# {sec_title}" != context_prefix and sec_title not in (context_prefix or ""): if sec_title and sec_title != doc_title: parts.append(sec_title)
parts.append(sec_title)
prefix = " > ".join(parts) prefix = " > ".join(parts)
return f"{prefix}\n{text}".strip() if prefix else text return f"{prefix}\n{text}".strip() if prefix else text
def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, context_prefix: str = "") -> List[Chunk]: def strategy_sliding_window(blocks: List[RawBlock],
config: Dict[str, Any],
note_id: str,
context_prefix: str = "") -> List[Chunk]:
""" """
Universelle Heading-Strategie mit Carry-Over Logik. Fasst Blöcke zusammen und schneidet bei 'target' Tokens.
Synchronisiert auf context_prefix für Kompatibilität mit dem Orchestrator. Ignoriert H2-Überschriften beim Splitting, um Kontext zu wahren.
"""
target = config.get("target", 400)
max_tokens = config.get("max", 600)
overlap_val = config.get("overlap", (50, 80))
overlap = sum(overlap_val) // 2 if isinstance(overlap_val, tuple) else overlap_val
chunks: List[Chunk] = []
buf: List[RawBlock] = []
def _add(txt, sec, path):
idx = len(chunks)
# H1-Kontext Präfix für das Window-Feld
win = f"{context_prefix}\n{txt}".strip() if context_prefix else txt
chunks.append(Chunk(
id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx,
text=txt, window=win, token_count=estimate_tokens(txt),
section_title=sec, section_path=path,
neighbors_prev=None, neighbors_next=None
))
def flush():
nonlocal buf
if not buf: return
text_body = "\n\n".join([b.text for b in buf])
sec_title = buf[-1].section_title; sec_path = buf[-1].section_path
if estimate_tokens(text_body) <= max_tokens:
_add(text_body, sec_title, sec_path)
else:
sents = split_sentences(text_body); cur_sents = []; cur_len = 0
for s in sents:
slen = estimate_tokens(s)
if cur_len + slen > target and cur_sents:
_add(" ".join(cur_sents), sec_title, sec_path)
ov_s = []; ov_l = 0
for os in reversed(cur_sents):
if ov_l + estimate_tokens(os) < overlap:
ov_s.insert(0, os); ov_l += estimate_tokens(os)
else: break
cur_sents = list(ov_s); cur_sents.append(s); cur_len = ov_l + slen
else:
cur_sents.append(s); cur_len += slen
if cur_sents:
_add(" ".join(cur_sents), sec_title, sec_path)
buf = []
for b in blocks:
# H2-Überschriften werden ignoriert, um den Zusammenhang zu wahren
if b.kind == "heading": continue
if estimate_tokens("\n\n".join([x.text for x in buf])) + estimate_tokens(b.text) >= target:
flush()
buf.append(b)
flush()
return chunks
def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "") -> List[Chunk]:
"""
Splittet Text basierend auf Markdown-Überschriften mit Hybrid-Safety-Net.
""" """
smart_edge = config.get("enable_smart_edge_allocation", True)
strict = config.get("strict_heading_split", False) strict = config.get("strict_heading_split", False)
target = config.get("target", 400) target = config.get("target", 400)
max_tokens = config.get("max", 600) max_tokens = config.get("max", 600)
split_level = config.get("split_level", 2) split_level = config.get("split_level", 2)
overlap_cfg = config.get("overlap", (50, 80)) overlap = sum(config.get("overlap", (50, 80))) // 2
overlap = sum(overlap_cfg) // 2 if isinstance(overlap_cfg, (list, tuple)) else overlap_cfg
chunks: List[Chunk] = [] chunks: List[Chunk] = []
buf: List[str] = []
cur_tokens = 0
def _emit(txt, title, path): def _add_to_chunks(txt, title, path):
"""Schreibt den finalen Chunk ohne Text-Modifikationen."""
idx = len(chunks) idx = len(chunks)
win = _create_win(context_prefix, title, txt) win = _create_context_win(doc_title, title, txt)
chunks.append(Chunk( chunks.append(Chunk(
id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx,
text=txt, window=win, token_count=estimate_tokens(txt), text=txt, window=win, token_count=estimate_tokens(txt),
section_title=title, section_path=path, neighbors_prev=None, neighbors_next=None section_title=title, section_path=path,
neighbors_prev=None, neighbors_next=None
)) ))
# --- SCHRITT 1: Gruppierung in atomare Sektions-Einheiten --- def _flush(title, path):
sections: List[Dict[str, Any]] = [] nonlocal buf, cur_tokens
curr_blocks = [] if not buf: return
for b in blocks: full_text = "\n\n".join(buf)
if b.kind == "heading" and b.level <= split_level: if estimate_tokens(full_text) <= max_tokens:
if curr_blocks: _add_to_chunks(full_text, title, path)
sections.append({
"text": "\n\n".join([x.text for x in curr_blocks]),
"meta": curr_blocks[0],
"is_empty": len(curr_blocks) == 1 and curr_blocks[0].kind == "heading"
})
curr_blocks = [b]
else: else:
curr_blocks.append(b) sents = split_sentences(full_text); cur_sents = []; sub_len = 0
if curr_blocks: for s in sents:
sections.append({ slen = estimate_tokens(s)
"text": "\n\n".join([x.text for x in curr_blocks]), if sub_len + slen > target and cur_sents:
"meta": curr_blocks[0], _add_to_chunks(" ".join(cur_sents), title, path)
"is_empty": len(curr_blocks) == 1 and curr_blocks[0].kind == "heading" ov_s = []; ov_l = 0
}) for os in reversed(cur_sents):
if ov_l + estimate_tokens(os) < overlap:
# --- SCHRITT 2: Verarbeitung der Queue --- ov_s.insert(0, os); ov_l += estimate_tokens(os)
queue = list(sections) else: break
current_chunk_text = "" cur_sents = list(ov_s); cur_sents.append(s); sub_len = ov_l + slen
current_meta = {"title": None, "path": "/"} else: cur_sents.append(s); sub_len += slen
if cur_sents: _add_to_chunks(" ".join(cur_sents), title, path)
# Bestimmung des Modus: Hard-Split wenn smart_edge=False ODER strict=True buf = []; cur_tokens = 0
is_hard_split_mode = (not smart_edge) or (strict)
while queue:
item = queue.pop(0)
item_text = item["text"]
# Initialisierung für neuen Chunk
if not current_chunk_text:
current_meta["title"] = item["meta"].section_title
current_meta["path"] = item["meta"].section_path
# FALL A: HARD SPLIT MODUS
if is_hard_split_mode:
# Leere Überschriften (z.B. H1 direkt vor H2) verbleiben am nächsten Chunk
if item.get("is_empty", False) and queue:
current_chunk_text = (current_chunk_text + "\n\n" + item_text).strip()
continue
combined = (current_chunk_text + "\n\n" + item_text).strip()
# Wenn durch Verschmelzung das Limit gesprengt würde, vorher flashen
if estimate_tokens(combined) > max_tokens and current_chunk_text:
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
current_chunk_text = item_text
else:
current_chunk_text = combined
# Im Hard-Split wird nach jeder Sektion geflasht
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
current_chunk_text = ""
continue
# FALL B: SMART MODE (Regel 1-3)
combined_text = (current_chunk_text + "\n\n" + item_text).strip() if current_chunk_text else item_text
combined_est = estimate_tokens(combined_text)
if combined_est <= max_tokens:
# Regel 1 & 2: Passt rein laut Schätzung -> Aufnehmen
current_chunk_text = combined_text
else:
if current_chunk_text:
# Regel 2: Flashen an Sektionsgrenze, Item zurücklegen
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
current_chunk_text = ""
queue.insert(0, item)
else:
# Regel 3: Einzelne Sektion zu groß -> Smart Zerlegung
sents = split_sentences(item_text)
header_prefix = item["meta"].text if item["meta"].kind == "heading" else ""
take_sents = []; take_len = 0
while sents:
s = sents.pop(0); slen = estimate_tokens(s)
if take_len + slen > target and take_sents:
sents.insert(0, s); break
take_sents.append(s); take_len += slen
_emit(" ".join(take_sents), current_meta["title"], current_meta["path"])
if sents:
remainder = " ".join(sents)
# Kontext-Erhalt: Überschrift für den Rest wiederholen
if header_prefix and not remainder.startswith(header_prefix):
remainder = header_prefix + "\n\n" + remainder
# Carry-Over: Rest wird vorne in die Queue geschoben
queue.insert(0, {"text": remainder, "meta": item["meta"], "is_split": True})
if current_chunk_text:
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
return chunks
def strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, context_prefix: str = "") -> List[Chunk]:
"""Standard-Sliding-Window für flache Texte ohne Sektionsfokus."""
target = config.get("target", 400); max_tokens = config.get("max", 600)
chunks: List[Chunk] = []; buf: List[RawBlock] = []
for b in blocks: for b in blocks:
b_tokens = estimate_tokens(b.text) if b.kind == "heading":
curr_tokens = sum(estimate_tokens(x.text) for x in buf) if buf else 0 if b.level < split_level: _flush(b.section_title, b.section_path)
if curr_tokens + b_tokens > max_tokens and buf: elif b.level == split_level:
txt = "\n\n".join([x.text for x in buf]); idx = len(chunks) if strict or cur_tokens >= target: _flush(b.section_title, b.section_path)
win = _create_win(context_prefix, buf[0].section_title, txt) continue
chunks.append(Chunk(id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, text=txt, window=win, token_count=curr_tokens, section_title=buf[0].section_title, section_path=buf[0].section_path, neighbors_prev=None, neighbors_next=None)) bt = estimate_tokens(b.text)
buf = [] if cur_tokens + bt > max_tokens and buf: _flush(b.section_title, b.section_path)
buf.append(b) buf.append(b.text); cur_tokens += bt
if buf: if buf:
txt = "\n\n".join([x.text for x in buf]); idx = len(chunks) last_b = blocks[-1] if blocks else None
win = _create_win(context_prefix, buf[0].section_title, txt) _flush(last_b.section_title if last_b else None, last_b.section_path if last_b else "/")
chunks.append(Chunk(id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, text=txt, window=win, token_count=estimate_tokens(txt), section_title=buf[0].section_title, section_path=buf[0].section_path, neighbors_prev=None, neighbors_next=None))
return chunks return chunks

View File

@ -3,7 +3,7 @@ FILE: app/core/database/qdrant.py
DESCRIPTION: Qdrant-Client Factory und Schema-Management. DESCRIPTION: Qdrant-Client Factory und Schema-Management.
Erstellt Collections und Payload-Indizes. Erstellt Collections und Payload-Indizes.
MODULARISIERUNG: Verschoben in das database-Paket für WP-14. MODULARISIERUNG: Verschoben in das database-Paket für WP-14.
VERSION: 2.2.2 (WP-Fix: Index für target_section) VERSION: 2.2.1
STATUS: Active STATUS: Active
DEPENDENCIES: qdrant_client, dataclasses, os DEPENDENCIES: qdrant_client, dataclasses, os
""" """
@ -124,7 +124,7 @@ def ensure_payload_indexes(client: QdrantClient, prefix: str) -> None:
Stellt sicher, dass alle benötigten Payload-Indizes für die Suche existieren. Stellt sicher, dass alle benötigten Payload-Indizes für die Suche existieren.
- notes: note_id, type, title, updated, tags - notes: note_id, type, title, updated, tags
- chunks: note_id, chunk_id, index, type, tags - chunks: note_id, chunk_id, index, type, tags
- edges: note_id, kind, scope, source_id, target_id, chunk_id, target_section - edges: note_id, kind, scope, source_id, target_id, chunk_id
""" """
notes, chunks, edges = collection_names(prefix) notes, chunks, edges = collection_names(prefix)
@ -156,8 +156,6 @@ def ensure_payload_indexes(client: QdrantClient, prefix: str) -> None:
("source_id", rest.PayloadSchemaType.KEYWORD), ("source_id", rest.PayloadSchemaType.KEYWORD),
("target_id", rest.PayloadSchemaType.KEYWORD), ("target_id", rest.PayloadSchemaType.KEYWORD),
("chunk_id", rest.PayloadSchemaType.KEYWORD), ("chunk_id", rest.PayloadSchemaType.KEYWORD),
# NEU: Index für Section-Links (WP-15b)
("target_section", rest.PayloadSchemaType.KEYWORD),
]: ]:
_ensure_index(client, edges, field, schema) _ensure_index(client, edges, field, schema)

View File

@ -1,10 +1,10 @@
""" """
FILE: app/core/database/qdrant_points.py FILE: app/core/database/qdrant_points.py
DESCRIPTION: Object-Mapper für Qdrant. Konvertiert JSON-Payloads (Notes, Chunks, Edges) in PointStructs und generiert deterministische UUIDs. DESCRIPTION: Object-Mapper für Qdrant. Konvertiert JSON-Payloads (Notes, Chunks, Edges) in PointStructs und generiert deterministische UUIDs.
VERSION: 1.5.1 (WP-Fix: Explicit Target Section Support) VERSION: 1.5.0
STATUS: Active STATUS: Active
DEPENDENCIES: qdrant_client, uuid, os DEPENDENCIES: qdrant_client, uuid, os
LAST_ANALYSIS: 2025-12-29 LAST_ANALYSIS: 2025-12-15
""" """
from __future__ import annotations from __future__ import annotations
import os import os
@ -46,25 +46,16 @@ def points_for_chunks(prefix: str, chunk_payloads: List[dict], vectors: List[Lis
return chunks_col, points return chunks_col, points
def _normalize_edge_payload(pl: dict) -> dict: def _normalize_edge_payload(pl: dict) -> dict:
"""Normalisiert Edge-Felder und sichert Schema-Konformität."""
kind = pl.get("kind") or pl.get("edge_type") or "edge" kind = pl.get("kind") or pl.get("edge_type") or "edge"
source_id = pl.get("source_id") or pl.get("src_id") or "unknown-src" source_id = pl.get("source_id") or pl.get("src_id") or "unknown-src"
target_id = pl.get("target_id") or pl.get("dst_id") or "unknown-tgt" target_id = pl.get("target_id") or pl.get("dst_id") or "unknown-tgt"
seq = pl.get("seq") or pl.get("order") or pl.get("index") seq = pl.get("seq") or pl.get("order") or pl.get("index")
# WP-Fix: target_section explizit durchreichen
target_section = pl.get("target_section")
pl.setdefault("kind", kind) pl.setdefault("kind", kind)
pl.setdefault("source_id", source_id) pl.setdefault("source_id", source_id)
pl.setdefault("target_id", target_id) pl.setdefault("target_id", target_id)
if seq is not None and "seq" not in pl: if seq is not None and "seq" not in pl:
pl["seq"] = seq pl["seq"] = seq
if target_section is not None:
pl["target_section"] = target_section
return pl return pl
def points_for_edges(prefix: str, edge_payloads: List[dict]) -> Tuple[str, List[rest.PointStruct]]: def points_for_edges(prefix: str, edge_payloads: List[dict]) -> Tuple[str, List[rest.PointStruct]]:

View File

@ -1,14 +1,10 @@
""" """
FILE: app/core/graph/graph_derive_edges.py FILE: app/core/graph/graph_derive_edges.py
DESCRIPTION: Hauptlogik zur Kanten-Aggregation und De-Duplizierung. DESCRIPTION: Hauptlogik zur Kanten-Aggregation und De-Duplizierung.
AUDIT:
- Nutzt parse_link_target
- Übergibt Section als 'variant' an ID-Gen
- Dedup basiert jetzt auf Edge-ID (erlaubt Multigraph für Sections)
""" """
from typing import List, Optional, Dict, Tuple from typing import List, Optional, Dict, Tuple
from .graph_utils import ( from .graph_utils import (
_get, _edge, _mk_edge_id, _dedupe_seq, parse_link_target, _get, _edge, _mk_edge_id, _dedupe_seq,
PROVENANCE_PRIORITY, load_types_registry, get_edge_defaults_for PROVENANCE_PRIORITY, load_types_registry, get_edge_defaults_for
) )
from .graph_extractors import ( from .graph_extractors import (
@ -57,85 +53,47 @@ def build_edges_for_note(
# Typed & Candidate Pool (WP-15b Integration) # Typed & Candidate Pool (WP-15b Integration)
typed, rem = extract_typed_relations(raw) typed, rem = extract_typed_relations(raw)
for k, raw_t in typed: for k, t in typed:
t, sec = parse_link_target(raw_t, note_id) edges.append(_edge(k, "chunk", cid, t, note_id, {
if not t: continue "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", "inline:rel"),
payload = {
"chunk_id": cid,
# Variant=sec sorgt für eindeutige ID pro Abschnitt
"edge_id": _mk_edge_id(k, cid, t, "chunk", "inline:rel", variant=sec),
"provenance": "explicit", "rule_id": "inline:rel", "confidence": PROVENANCE_PRIORITY["inline:rel"] "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))
pool = ch.get("candidate_pool") or ch.get("candidate_edges") or [] pool = ch.get("candidate_pool") or ch.get("candidate_edges") or []
for cand in pool: for cand in pool:
raw_t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai") t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai")
t, sec = parse_link_target(raw_t, note_id)
if t: if t:
payload = { edges.append(_edge(k, "chunk", cid, t, note_id, {
"chunk_id": cid, "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", f"candidate:{p}"),
"edge_id": _mk_edge_id(k, cid, t, "chunk", f"candidate:{p}", variant=sec),
"provenance": p, "rule_id": f"candidate:{p}", "confidence": PROVENANCE_PRIORITY.get(p, 0.90) "provenance": p, "rule_id": f"candidate:{p}", "confidence": PROVENANCE_PRIORITY.get(p, 0.90)
} }))
if sec: payload["target_section"] = sec
edges.append(_edge(k, "chunk", cid, t, note_id, payload))
# Callouts & Wikilinks # Callouts & Wikilinks
call_pairs, rem2 = extract_callout_relations(rem) call_pairs, rem2 = extract_callout_relations(rem)
for k, raw_t in call_pairs: for k, t in call_pairs:
t, sec = parse_link_target(raw_t, note_id) edges.append(_edge(k, "chunk", cid, t, note_id, {
if not t: continue "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", "callout:edge"),
payload = {
"chunk_id": cid,
"edge_id": _mk_edge_id(k, cid, t, "chunk", "callout:edge", variant=sec),
"provenance": "explicit", "rule_id": "callout:edge", "confidence": PROVENANCE_PRIORITY["callout:edge"] "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))
refs = extract_wikilinks(rem2) refs = extract_wikilinks(rem2)
for raw_r in refs: for r in refs:
r, sec = parse_link_target(raw_r, note_id) edges.append(_edge("references", "chunk", cid, r, note_id, {
if not r: continue "chunk_id": cid, "ref_text": r, "edge_id": _mk_edge_id("references", cid, r, "chunk", "explicit:wikilink"),
payload = {
"chunk_id": cid, "ref_text": raw_r,
"edge_id": _mk_edge_id("references", cid, r, "chunk", "explicit:wikilink", variant=sec),
"provenance": "explicit", "rule_id": "explicit:wikilink", "confidence": PROVENANCE_PRIORITY["explicit:wikilink"] "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))
for rel in defaults: for rel in defaults:
if rel != "references": if rel != "references":
def_payload = { edges.append(_edge(rel, "chunk", cid, r, note_id, {
"chunk_id": cid, "chunk_id": cid, "edge_id": _mk_edge_id(rel, cid, r, "chunk", f"edge_defaults:{rel}"),
"edge_id": _mk_edge_id(rel, cid, r, "chunk", f"edge_defaults:{rel}", variant=sec),
"provenance": "rule", "rule_id": f"edge_defaults:{rel}", "confidence": PROVENANCE_PRIORITY["edge_defaults"] "provenance": "rule", "rule_id": f"edge_defaults:{rel}", "confidence": PROVENANCE_PRIORITY["edge_defaults"]
} }))
if sec: def_payload["target_section"] = sec refs_all.extend(refs)
edges.append(_edge(rel, "chunk", cid, r, note_id, def_payload))
# Für Note-Scope Sammlung nutzen wir den Original-String zur Dedup, aber gesäubert
refs_all.extend([parse_link_target(r, note_id)[0] for r in refs])
# 3) Note-Scope & De-Duplizierung # 3) Note-Scope & De-Duplizierung
if include_note_scope_refs: if include_note_scope_refs:
# refs_all ist jetzt schon gesäubert (nur Targets) refs_note = _dedupe_seq((refs_all or []) + (note_level_references or []))
# note_level_references müssen auch gesäubert werden
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: for r in refs_note:
if not r: continue
edges.append(_edge("references", "note", note_id, r, note_id, { edges.append(_edge("references", "note", note_id, r, note_id, {
"edge_id": _mk_edge_id("references", note_id, r, "note", "explicit:note_scope"), "edge_id": _mk_edge_id("references", note_id, r, "note", "explicit:note_scope"),
"provenance": "explicit", "confidence": PROVENANCE_PRIORITY["explicit:note_scope"] "provenance": "explicit", "confidence": PROVENANCE_PRIORITY["explicit:note_scope"]
@ -145,13 +103,10 @@ def build_edges_for_note(
"provenance": "rule", "confidence": PROVENANCE_PRIORITY["derived:backlink"] "provenance": "rule", "confidence": PROVENANCE_PRIORITY["derived:backlink"]
})) }))
# Deduplizierung: Wir nutzen jetzt die EDGE-ID als Schlüssel. unique_map: Dict[Tuple[str, str, str], dict] = {}
# Da die Edge-ID nun 'variant' (Section) enthält, bleiben unterschiedliche Sections erhalten.
unique_map: Dict[str, dict] = {}
for e in edges: for e in edges:
eid = e["edge_id"] key = (str(e.get("source_id")), str(e.get("target_id")), str(e.get("kind")))
# Bei Konflikt (gleiche ID = exakt gleiche Kante und Section) gewinnt die höhere Confidence if key not in unique_map or e.get("confidence", 0) > unique_map[key].get("confidence", 0):
if eid not in unique_map or e.get("confidence", 0) > unique_map[eid].get("confidence", 0): unique_map[key] = e
unique_map[eid] = e
return list(unique_map.values()) return list(unique_map.values())

View File

@ -1,36 +1,25 @@
""" """
FILE: app/core/graph/graph_extractors.py FILE: app/core/graph/graph_extractors.py
DESCRIPTION: Regex-basierte Extraktion von Relationen aus Text. DESCRIPTION: Regex-basierte Extraktion von Relationen aus Text.
AUDIT:
- Regex für Wikilinks liberalisiert (Umlaute, Sonderzeichen).
- Callout-Parser erweitert für Multi-Line-Listen und Header-Typen.
""" """
import re import re
from typing import List, Tuple from typing import List, Tuple
# Erlaube alle Zeichen außer ']' im Target (fängt Umlaute, Emojis, '&', '#' ab) _WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([a-zA-Z0-9_\-#:. ]+)\]\]")
_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([^\]]+)\]\]")
_REL_PIPE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s*\|\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_PIPE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s*\|\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_REL_SPACE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s+(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_SPACE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s+(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_REL_TEXT = re.compile(r"rel\s*:\s*(?P<kind>[a-z_]+)\s*\[\[\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_TEXT = re.compile(r"rel\s*:\s*(?P<kind>[a-z_]+)\s*\[\[\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_CALLOUT_START = re.compile(r"^\s*>\s*\[!edge\]\s*(.*)$", re.IGNORECASE) _CALLOUT_START = re.compile(r"^\s*>\s*\[!edge\]\s*(.*)$", re.IGNORECASE)
# Erkennt "kind: targets..."
_REL_LINE = re.compile(r"^(?P<kind>[a-z_]+)\s*:\s*(?P<targets>.+?)\s*$", re.IGNORECASE) _REL_LINE = re.compile(r"^(?P<kind>[a-z_]+)\s*:\s*(?P<targets>.+?)\s*$", re.IGNORECASE)
# Erkennt reine Typen (z.B. "depends_on" im Header) _WIKILINKS_IN_LINE = re.compile(r"\[\[([^\]]+)\]\]")
_SIMPLE_KIND = re.compile(r"^[a-z_]+$", re.IGNORECASE)
def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]: def extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
""" """Extrahiert [[rel:KIND|Target]]."""
Findet Inline-Relationen wie [[rel:depends_on Target]].
Gibt (Liste[(kind, target)], bereinigter_text) zurück.
"""
if not text: return [], ""
pairs = [] pairs = []
def _collect(m): def _collect(m):
k, t = m.group("kind").strip().lower(), m.group("target").strip() k, t = (m.group("kind") or "").strip().lower(), (m.group("target") or "").strip()
pairs.append((k, t)) if k and t: pairs.append((k, t))
return "" return ""
text = _REL_PIPE.sub(_collect, text) text = _REL_PIPE.sub(_collect, text)
text = _REL_SPACE.sub(_collect, text) text = _REL_SPACE.sub(_collect, text)
@ -38,90 +27,29 @@ def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]:
return pairs, text return pairs, text
def extract_callout_relations(text: str) -> Tuple[List[Tuple[str,str]], str]: def extract_callout_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
""" """Verarbeitet Obsidian [!edge]-Callouts."""
Verarbeitet Obsidian [!edge]-Callouts.
Unterstützt zwei Formate:
1. Explizit: "kind: [[Target]]"
2. Implizit (Header): "> [!edge] kind" gefolgt von "[[Target]]" Zeilen
"""
if not text: return [], text if not text: return [], text
lines = text.splitlines() lines = text.splitlines(); out_pairs, keep_lines, i = [], [], 0
out_pairs = []
keep_lines = []
i = 0
while i < len(lines): while i < len(lines):
line = lines[i] m = _CALLOUT_START.match(lines[i])
m = _CALLOUT_START.match(line)
if not m: if not m:
keep_lines.append(line) keep_lines.append(lines[i]); i += 1; continue
i += 1 block_lines = [m.group(1)] if m.group(1).strip() else []
continue
# Callout-Block gefunden. Wir sammeln alle relevanten Zeilen.
block_lines = []
# Header Content prüfen (z.B. "type" aus "> [!edge] type")
header_raw = m.group(1).strip()
if header_raw:
block_lines.append(header_raw)
i += 1 i += 1
while i < len(lines) and lines[i].lstrip().startswith('>'): while i < len(lines) and lines[i].lstrip().startswith('>'):
# Entferne '>' und führende Leerzeichen block_lines.append(lines[i].lstrip()[1:].lstrip()); i += 1
content = lines[i].lstrip()[1:].lstrip()
if content:
block_lines.append(content)
i += 1
# Verarbeitung des Blocks
current_kind = None
# Heuristik: Ist die allererste Zeile (meist aus dem Header) ein reiner Typ?
# Dann setzen wir diesen als Default für den Block.
if block_lines:
first = block_lines[0]
# Wenn es NICHT wie "Key: Value" aussieht, aber wie ein Wort:
if not _REL_LINE.match(first) and _SIMPLE_KIND.match(first):
current_kind = first.lower()
for bl in block_lines: for bl in block_lines:
# 1. Prüfen auf explizites "Kind: Targets" (überschreibt Header-Typ für diese Zeile)
mrel = _REL_LINE.match(bl) mrel = _REL_LINE.match(bl)
if mrel: if not mrel: continue
line_kind = mrel.group("kind").strip().lower() kind, targets = mrel.group("kind").strip().lower(), mrel.group("targets") or ""
targets = mrel.group("targets") found = _WIKILINKS_IN_LINE.findall(targets)
# Links extrahieren
found = _WIKILINK_RE.findall(targets)
if found: if found:
for t in found: out_pairs.append((line_kind, t.strip())) for t in found: out_pairs.append((kind, t.strip()))
else: else:
# Fallback für kommagetrennten Plaintext
for raw in re.split(r"[,;]", targets): for raw in re.split(r"[,;]", targets):
if raw.strip(): out_pairs.append((line_kind, raw.strip())) if raw.strip(): out_pairs.append((kind, raw.strip()))
# Wenn wir eine explizite Zeile gefunden haben, aktualisieren wir NICHT
# den current_kind für nachfolgende Zeilen (Design-Entscheidung: lokal scope),
# oder wir machen es doch?
# Üblicher ist: Header setzt Default, Zeile überschreibt lokal.
# Wir lassen current_kind also unangetastet.
continue
# 2. Kein Key:Value Muster -> Prüfen auf Links, die den current_kind nutzen
found = _WIKILINK_RE.findall(bl)
if found:
if current_kind:
for t in found: out_pairs.append((current_kind, t.strip()))
else:
# Link ohne Typ und ohne Header-Typ.
# Wird ignoriert oder könnte als 'related_to' fallback dienen.
# Aktuell: Ignorieren, um False Positives zu vermeiden.
pass
return out_pairs, "\n".join(keep_lines) return out_pairs, "\n".join(keep_lines)
def extract_wikilinks(text: str) -> List[str]: def extract_wikilinks(text: str) -> List[str]:
"""Findet Standard-Wikilinks [[Target]] oder [[Alias|Target]].""" """Extrahiert Standard-Wikilinks."""
if not text: return [] return [m.group(1).strip() for m in _WIKILINK_RE.finditer(text or "")]
return [m.strip() for m in _WIKILINK_RE.findall(text) if m.strip()]

View File

@ -1,11 +1,10 @@
""" """
FILE: app/core/graph/graph_utils.py FILE: app/core/graph/graph_utils.py
DESCRIPTION: Basale Werkzeuge, ID-Generierung und Provenance-Konfiguration für den Graphen. DESCRIPTION: Basale Werkzeuge, ID-Generierung und Provenance-Konfiguration für den Graphen.
AUDIT: Erweitert um parse_link_target für sauberes Section-Splitting (WP-Fix).
""" """
import os import os
import hashlib import hashlib
from typing import Iterable, List, Optional, Set, Any, Tuple from typing import Iterable, List, Optional, Set, Any
try: try:
import yaml import yaml
@ -41,19 +40,10 @@ def _dedupe_seq(seq: Iterable[str]) -> List[str]:
seen.add(s); out.append(s) seen.add(s); out.append(s)
return out return out
def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None, variant: Optional[str] = None) -> str: def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None) -> str:
""" """Erzeugt eine deterministische 12-Byte ID mittels BLAKE2s."""
Erzeugt eine deterministische 12-Byte ID mittels BLAKE2s.
WP-Fix: 'variant' (z.B. Section) fließt in den Hash ein, um mehrere Kanten
zum gleichen Target-Node (aber unterschiedlichen Abschnitten) zu unterscheiden.
"""
base = f"{kind}:{s}->{t}#{scope}" base = f"{kind}:{s}->{t}#{scope}"
if rule_id: if rule_id: base += f"|{rule_id}"
base += f"|{rule_id}"
if variant:
base += f"|{variant}" # <--- Hier entsteht die Eindeutigkeit für verschiedene Sections
return hashlib.blake2s(base.encode("utf-8"), digest_size=12).hexdigest() return hashlib.blake2s(base.encode("utf-8"), digest_size=12).hexdigest()
def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, extra: Optional[dict] = None) -> dict: def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, extra: Optional[dict] = None) -> dict:
@ -69,27 +59,6 @@ def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, e
if extra: pl.update(extra) if extra: pl.update(extra)
return pl return pl
def parse_link_target(raw: str, current_note_id: Optional[str] = None) -> Tuple[str, Optional[str]]:
"""
Zerlegt einen Link (z.B. 'Note#Section') in Target-ID und Section.
Behandelt Self-Links ('#Section'), indem current_note_id eingesetzt wird.
Returns:
(target_id, target_section)
"""
if not raw:
return "", None
parts = raw.split("#", 1)
target = parts[0].strip()
section = parts[1].strip() if len(parts) > 1 else None
# Handle Self-Link [[#Section]] -> target wird zu current_note_id
if not target and section and current_note_id:
target = current_note_id
return target, section
def load_types_registry() -> dict: def load_types_registry() -> dict:
"""Lädt die YAML-Registry.""" """Lädt die YAML-Registry."""
p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml") p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml")

View File

@ -3,8 +3,9 @@ FILE: app/core/ingestion/ingestion_note_payload.py
DESCRIPTION: Baut das JSON-Objekt für mindnet_notes. DESCRIPTION: Baut das JSON-Objekt für mindnet_notes.
FEATURES: FEATURES:
- Multi-Hash (body/full) für flexible Change Detection. - Multi-Hash (body/full) für flexible Change Detection.
- Fix v2.4.5: Präzise Hash-Logik für Profil-Änderungen. - Fix v2.4.4: Integration der zentralen Registry (WP-14) für konsistente Defaults.
- Integration der zentralen Registry (WP-14). VERSION: 2.4.4
STATUS: Active
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Tuple, Optional from typing import Any, Dict, Tuple, Optional
@ -44,23 +45,14 @@ def _compute_hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest() return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str: def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str:
""" """Generiert den Hash-Input-String basierend auf Body oder Metadaten."""
Generiert den Hash-Input-String basierend auf Body oder Metadaten. body = str(n.get("body") or "")
Fix: Inkludiert nun alle entscheidungsrelevanten Profil-Parameter.
"""
body = str(n.get("body") or "").strip()
if mode == "body": return body if mode == "body": return body
if mode == "full": if mode == "full":
fm = n.get("frontmatter") or {} fm = n.get("frontmatter") or {}
meta_parts = [] meta_parts = []
# Wir inkludieren alle Felder, die das Chunking oder Retrieval beeinflussen # Sortierte Liste für deterministische Hashes
# Jede Änderung hier führt nun zwingend zu einem neuen Full-Hash for k in sorted(["title", "type", "status", "tags", "chunking_profile", "chunk_profile", "retriever_weight"]):
keys = [
"title", "type", "status", "tags",
"chunking_profile", "chunk_profile",
"retriever_weight", "split_level", "strict_heading_split"
]
for k in sorted(keys):
val = fm.get(k) val = fm.get(k)
if val is not None: meta_parts.append(f"{k}:{val}") if val is not None: meta_parts.append(f"{k}:{val}")
return f"{'|'.join(meta_parts)}||{body}" return f"{'|'.join(meta_parts)}||{body}"
@ -87,11 +79,11 @@ def _cfg_defaults(reg: dict) -> dict:
def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
""" """
Baut das Note-Payload inklusive Multi-Hash und Audit-Validierung. Baut das Note-Payload inklusive Multi-Hash und Audit-Validierung.
WP-14: Nutzt die zentrale Registry für alle Fallbacks. WP-14: Nutzt nun die zentrale Registry für alle Fallbacks.
""" """
n = _as_dict(note) n = _as_dict(note)
# Registry & Context Settings # Nutzt übergebene Registry oder lädt sie global
reg = kwargs.get("types_cfg") or load_type_registry() reg = kwargs.get("types_cfg") or load_type_registry()
hash_source = kwargs.get("hash_source", "parsed") hash_source = kwargs.get("hash_source", "parsed")
hash_normalize = kwargs.get("hash_normalize", "canonical") hash_normalize = kwargs.get("hash_normalize", "canonical")
@ -104,6 +96,7 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
ingest_cfg = reg.get("ingestion_settings", {}) ingest_cfg = reg.get("ingestion_settings", {})
# --- retriever_weight Audit --- # --- retriever_weight Audit ---
# Priorität: Frontmatter -> Typ-Config -> globale Config -> Env-Var
default_rw = float(os.environ.get("MINDNET_DEFAULT_RETRIEVER_WEIGHT", 1.0)) default_rw = float(os.environ.get("MINDNET_DEFAULT_RETRIEVER_WEIGHT", 1.0))
retriever_weight = fm.get("retriever_weight") retriever_weight = fm.get("retriever_weight")
if retriever_weight is None: if retriever_weight is None:
@ -114,13 +107,14 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
retriever_weight = default_rw retriever_weight = default_rw
# --- chunk_profile Audit --- # --- chunk_profile Audit ---
# Nutzt nun primär die ingestion_settings aus der Registry
chunk_profile = fm.get("chunking_profile") or fm.get("chunk_profile") chunk_profile = fm.get("chunking_profile") or fm.get("chunk_profile")
if chunk_profile is None: if chunk_profile is None:
chunk_profile = cfg_type.get("chunking_profile") or cfg_type.get("chunk_profile") chunk_profile = cfg_type.get("chunking_profile") or cfg_type.get("chunk_profile")
if chunk_profile is None: if chunk_profile is None:
chunk_profile = ingest_cfg.get("default_chunk_profile", cfg_def.get("chunking_profile", "sliding_standard")) chunk_profile = ingest_cfg.get("default_chunk_profile", cfg_def.get("chunking_profile", "sliding_standard"))
# --- edge_defaults Audit --- # --- edge_defaults ---
edge_defaults = fm.get("edge_defaults") edge_defaults = fm.get("edge_defaults")
if edge_defaults is None: if edge_defaults is None:
edge_defaults = cfg_type.get("edge_defaults", cfg_def.get("edge_defaults", [])) edge_defaults = cfg_type.get("edge_defaults", cfg_def.get("edge_defaults", []))
@ -144,24 +138,21 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
} }
# --- MULTI-HASH --- # --- MULTI-HASH ---
# Generiert Hashes für Change Detection (WP-15b) # Generiert Hashes für Change Detection
for mode in ["body", "full"]: for mode in ["body", "full"]:
content = _get_hash_source_content(n, mode) content = _get_hash_source_content(n, mode)
payload["hashes"][f"{mode}:{hash_source}:{hash_normalize}"] = _compute_hash(content) payload["hashes"][f"{mode}:{hash_source}:{hash_normalize}"] = _compute_hash(content)
# Metadaten Anreicherung (Tags, Aliases, Zeitstempel) # Metadaten Anreicherung
tags = fm.get("tags") or fm.get("keywords") or n.get("tags") tags = fm.get("tags") or fm.get("keywords") or n.get("tags")
if tags: payload["tags"] = _ensure_list(tags) if tags: payload["tags"] = _ensure_list(tags)
if fm.get("aliases"): payload["aliases"] = _ensure_list(fm.get("aliases"))
aliases = fm.get("aliases")
if aliases: payload["aliases"] = _ensure_list(aliases)
for k in ("created", "modified", "date"): for k in ("created", "modified", "date"):
v = fm.get(k) or n.get(k) v = fm.get(k) or n.get(k)
if v: payload[k] = str(v) if v: payload[k] = str(v)
if n.get("body"): if n.get("body"): payload["fulltext"] = str(n["body"])
payload["fulltext"] = str(n["body"])
# Final JSON Validation Audit # Final JSON Validation Audit
json.loads(json.dumps(payload, ensure_ascii=False)) json.loads(json.dumps(payload, ensure_ascii=False))

View File

@ -4,8 +4,8 @@ DESCRIPTION: Der zentrale IngestionService (Orchestrator).
WP-14: Modularisierung der Datenbank-Ebene (app.core.database). WP-14: Modularisierung der Datenbank-Ebene (app.core.database).
WP-15b: Two-Pass Workflow mit globalem Kontext-Cache. WP-15b: Two-Pass Workflow mit globalem Kontext-Cache.
WP-20/22: Cloud-Resilienz und Content-Lifecycle integriert. WP-20/22: Cloud-Resilienz und Content-Lifecycle integriert.
AUDIT v2.13.12: Synchronisierung der Profil-Auflösung mit Registry-Defaults. AUDIT v2.13.10: Umstellung auf app.core.database Infrastruktur.
VERSION: 2.13.12 VERSION: 2.13.10
STATUS: Active STATUS: Active
""" """
import logging import logging
@ -60,7 +60,6 @@ class IngestionService:
self.embedder = EmbeddingsClient() self.embedder = EmbeddingsClient()
self.llm = LLMService() self.llm = LLMService()
# Festlegen, welcher Hash für die Change-Detection maßgeblich ist
self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE
self.batch_cache: Dict[str, NoteContext] = {} # WP-15b LocalBatchCache self.batch_cache: Dict[str, NoteContext] = {} # WP-15b LocalBatchCache
@ -131,18 +130,12 @@ class IngestionService:
) )
note_id = note_pl["note_id"] note_id = note_pl["note_id"]
# Abgleich mit der Datenbank (Qdrant)
old_payload = None if force_replace else fetch_note_payload(self.client, self.prefix, note_id) old_payload = None if force_replace else fetch_note_payload(self.client, self.prefix, note_id)
# Prüfung gegen den konfigurierten Hash-Modus (body vs. full)
check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}"
old_hash = (old_payload or {}).get("hashes", {}).get(check_key) old_hash = (old_payload or {}).get("hashes", {}).get(check_key)
new_hash = note_pl.get("hashes", {}).get(check_key) new_hash = note_pl.get("hashes", {}).get(check_key)
# Check ob Chunks oder Kanten in der DB fehlen (Reparatur-Modus)
c_miss, e_miss = artifacts_missing(self.client, self.prefix, note_id) c_miss, e_miss = artifacts_missing(self.client, self.prefix, note_id)
# Wenn Hash identisch und Artefakte vorhanden -> Skip
if not (force_replace or not old_payload or old_hash != new_hash or c_miss or e_miss): if not (force_replace or not old_payload or old_hash != new_hash or c_miss or e_miss):
return {**result, "status": "unchanged", "note_id": note_id} return {**result, "status": "unchanged", "note_id": note_id}
@ -153,49 +146,36 @@ class IngestionService:
try: try:
body_text = getattr(parsed, "body", "") or "" body_text = getattr(parsed, "body", "") or ""
edge_registry.ensure_latest() edge_registry.ensure_latest()
profile = fm.get("chunk_profile") or fm.get("chunking_profile") or "sliding_standard"
# Profil-Auflösung via Registry
# FIX: Wir nutzen das Profil, das bereits in make_note_payload unter
# Berücksichtigung der types.yaml (Registry) ermittelt wurde.
profile = note_pl.get("chunk_profile", "sliding_standard")
chunk_cfg = get_chunk_config_by_profile(self.registry, profile, note_type) chunk_cfg = get_chunk_config_by_profile(self.registry, profile, note_type)
enable_smart = chunk_cfg.get("enable_smart_edge_allocation", False) enable_smart = chunk_cfg.get("enable_smart_edge_allocation", False)
# WP-15b: Chunker-Aufruf bereitet den Candidate-Pool pro Chunk vor. # WP-15b: Chunker-Aufruf bereitet Candidate-Pool vor
# assemble_chunks führt intern auch die Propagierung durch.
chunks = await assemble_chunks(note_id, body_text, note_type, config=chunk_cfg) chunks = await assemble_chunks(note_id, body_text, note_type, config=chunk_cfg)
# Semantische Kanten-Validierung (Smart Edge Allocation)
for ch in chunks: for ch in chunks:
filtered = [] filtered = []
for cand in getattr(ch, "candidate_pool", []): for cand in getattr(ch, "candidate_pool", []):
# Nur global_pool Kandidaten (aus dem Pool am Ende) erfordern KI-Validierung # WP-15b: Nur global_pool Kandidaten erfordern binäre Validierung
if cand.get("provenance") == "global_pool" and enable_smart: if cand.get("provenance") == "global_pool" and enable_smart:
if await validate_edge_candidate(ch.text, cand, self.batch_cache, self.llm, self.settings.MINDNET_LLM_PROVIDER): if await validate_edge_candidate(ch.text, cand, self.batch_cache, self.llm, self.settings.MINDNET_LLM_PROVIDER):
filtered.append(cand) filtered.append(cand)
else: else:
# Explizite Kanten (Wikilinks/Callouts) werden ungeprüft übernommen
filtered.append(cand) filtered.append(cand)
ch.candidate_pool = filtered ch.candidate_pool = filtered
# Payload-Erstellung für die Chunks # Payload-Erstellung via interne Module
chunk_pls = make_chunk_payloads( chunk_pls = make_chunk_payloads(
fm, note_pl["path"], chunks, file_path=file_path, fm, note_pl["path"], chunks, file_path=file_path,
types_cfg=self.registry types_cfg=self.registry
) )
# Vektorisierung der Fenster-Texte
vecs = await self.embedder.embed_documents([c.get("window") or "" for c in chunk_pls]) if chunk_pls else [] vecs = await self.embedder.embed_documents([c.get("window") or "" for c in chunk_pls]) if chunk_pls else []
# Aggregation aller finalen Kanten (Edges) # Kanten-Aggregation
edges = build_edges_for_note( edges = build_edges_for_note(
note_id, chunk_pls, note_id, chunk_pls,
note_level_references=note_pl.get("references", []), note_level_references=note_pl.get("references", []),
include_note_scope_refs=note_scope_refs include_note_scope_refs=note_scope_refs
) )
# Kanten-Typen via Registry validieren/auflösen
for e in edges: for e in edges:
e["kind"] = edge_registry.resolve( e["kind"] = edge_registry.resolve(
e.get("kind", "related_to"), e.get("kind", "related_to"),
@ -204,20 +184,16 @@ class IngestionService:
) )
# 4. DB Upsert via modularisierter Points-Logik # 4. DB Upsert via modularisierter Points-Logik
# WICHTIG: Wenn sich der Inhalt geändert hat, löschen wir erst alle alten Fragmente.
if purge_before and old_payload: if purge_before and old_payload:
purge_artifacts(self.client, self.prefix, note_id) purge_artifacts(self.client, self.prefix, note_id)
# Speichern der Haupt-Note
n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim)
upsert_batch(self.client, n_name, n_pts) upsert_batch(self.client, n_name, n_pts)
# Speichern der Chunks
if chunk_pls and vecs: if chunk_pls and vecs:
c_pts = points_for_chunks(self.prefix, chunk_pls, vecs)[1] c_pts = points_for_chunks(self.prefix, chunk_pls, vecs)[1]
upsert_batch(self.client, f"{self.prefix}_chunks", c_pts) upsert_batch(self.client, f"{self.prefix}_chunks", c_pts)
# Speichern der Kanten
if edges: if edges:
e_pts = points_for_edges(self.prefix, edges)[1] e_pts = points_for_edges(self.prefix, edges)[1]
upsert_batch(self.client, f"{self.prefix}_edges", e_pts) upsert_batch(self.client, f"{self.prefix}_edges", e_pts)
@ -241,5 +217,4 @@ class IngestionService:
with open(target_path, "w", encoding="utf-8") as f: with open(target_path, "w", encoding="utf-8") as f:
f.write(markdown_content) f.write(markdown_content)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Triggert sofortigen Import mit force_replace/purge_before
return await self.process_file(file_path=target_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True) return await self.process_file(file_path=target_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True)

View File

@ -1,10 +1,10 @@
""" """
FILE: app/frontend/ui_graph_service.py FILE: app/frontend/ui_graph_service.py
DESCRIPTION: Data Layer für den Graphen. Greift direkt auf Qdrant zu (Performance), um Knoten/Kanten zu laden und Texte zu rekonstruieren ("Stitching"). DESCRIPTION: Data Layer für den Graphen. Greift direkt auf Qdrant zu (Performance), um Knoten/Kanten zu laden und Texte zu rekonstruieren ("Stitching").
VERSION: 2.6.0 VERSION: 2.6.1 (Fix: Anchor-Link & Fragment Resolution)
STATUS: Active STATUS: Active
DEPENDENCIES: qdrant_client, streamlit_agraph, ui_config, re DEPENDENCIES: qdrant_client, streamlit_agraph, ui_config, re
LAST_ANALYSIS: 2025-12-15 LAST_ANALYSIS: 2025-12-28
""" """
import re import re
@ -24,6 +24,7 @@ class GraphExplorerService:
self.chunks_col = f"{self.prefix}_chunks" self.chunks_col = f"{self.prefix}_chunks"
self.edges_col = f"{self.prefix}_edges" self.edges_col = f"{self.prefix}_edges"
self._note_cache = {} self._note_cache = {}
self._ref_resolution_cache = {}
def get_note_with_full_content(self, note_id): def get_note_with_full_content(self, note_id):
""" """
@ -37,8 +38,7 @@ class GraphExplorerService:
# 2. Volltext aus Chunks bauen # 2. Volltext aus Chunks bauen
full_text = self._fetch_full_text_stitched(note_id) full_text = self._fetch_full_text_stitched(note_id)
# 3. Ergebnis kombinieren (Wir überschreiben das 'fulltext' Feld mit dem frischen Stitching) # 3. Ergebnis kombinieren (Kopie zurückgeben)
# Wir geben eine Kopie zurück, um den Cache nicht zu verfälschen
complete_note = meta.copy() complete_note = meta.copy()
if full_text: if full_text:
complete_note['fulltext'] = full_text complete_note['fulltext'] = full_text
@ -61,7 +61,7 @@ class GraphExplorerService:
# Initialset für Suche # Initialset für Suche
level_1_ids = {center_note_id} level_1_ids = {center_note_id}
# Suche Kanten für Center (L1) # Suche Kanten für Center (L1) inkl. Titel für Anchor-Suche
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title")) l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
for edge_data in l1_edges: for edge_data in l1_edges:
@ -84,7 +84,6 @@ class GraphExplorerService:
if center_note_id in nodes_dict: if center_note_id in nodes_dict:
orig_title = nodes_dict[center_note_id].title orig_title = nodes_dict[center_note_id].title
clean_full = self._clean_markdown(center_text[:2000]) clean_full = self._clean_markdown(center_text[:2000])
# Wir packen den Text in den Tooltip (title attribute)
nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 INHALT:\n{clean_full}..." nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 INHALT:\n{clean_full}..."
# B. Previews für alle Nachbarn holen (Batch) # B. Previews für alle Nachbarn holen (Batch)
@ -104,8 +103,6 @@ class GraphExplorerService:
prov = data['provenance'] prov = data['provenance']
color = get_edge_color(kind) color = get_edge_color(kind)
is_smart = (prov != "explicit" and prov != "rule") is_smart = (prov != "explicit" and prov != "rule")
# Label Logik
label_text = kind if show_labels else " " label_text = kind if show_labels else " "
final_edges.append(Edge( final_edges.append(Edge(
@ -116,15 +113,11 @@ class GraphExplorerService:
return list(nodes_dict.values()), final_edges return list(nodes_dict.values()), final_edges
def _clean_markdown(self, text): def _clean_markdown(self, text):
"""Entfernt Markdown-Sonderzeichen für saubere Tooltips im Browser.""" """Entfernt Markdown-Sonderzeichen für saubere Tooltips."""
if not text: return "" if not text: return ""
# Entferne Header Marker (## )
text = re.sub(r'#+\s', '', text) text = re.sub(r'#+\s', '', text)
# Entferne Bold/Italic (** oder *)
text = re.sub(r'\*\*|__|\*|_', '', text) text = re.sub(r'\*\*|__|\*|_', '', text)
# Entferne Links [Text](Url) -> Text
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
# Entferne Wikilinks [[Link]] -> Link
text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text) text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
return text return text
@ -134,52 +127,47 @@ class GraphExplorerService:
scroll_filter = models.Filter( scroll_filter = models.Filter(
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))] must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]
) )
# Limit hoch genug setzen
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True) chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True)
# Sortieren nach 'ord' (Reihenfolge im Dokument)
chunks.sort(key=lambda x: x.payload.get('ord', 999)) chunks.sort(key=lambda x: x.payload.get('ord', 999))
full_text = [c.payload.get('text', '') for c in chunks if c.payload.get('text')]
full_text = []
for c in chunks:
# 'text' ist der reine Inhalt ohne Overlap
txt = c.payload.get('text', '')
if txt: full_text.append(txt)
return "\n\n".join(full_text) return "\n\n".join(full_text)
except: except:
return "Fehler beim Laden des Volltexts." return "Fehler beim Laden des Volltexts."
def _fetch_previews_for_nodes(self, node_ids): def _fetch_previews_for_nodes(self, node_ids):
"""Holt Batch-weise den ersten Chunk für eine Liste von Nodes.""" """
if not node_ids: return {} Holt Batch-weise den ersten relevanten Textabschnitt für eine Liste von Nodes.
Optimiert die Ladezeit durch Reduzierung der API-Calls.
"""
if not node_ids:
return {}
previews = {} previews = {}
try: try:
scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))]) scroll_filter = models.Filter(
# Limit = Anzahl Nodes * 3 (Puffer) must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))]
)
# Genügend Chunks laden, um für jede ID eine Vorschau zu finden
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True) chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True)
for c in chunks: for c in chunks:
nid = c.payload.get("note_id") nid = c.payload.get("note_id")
# Nur den ersten gefundenen Chunk pro Note nehmen # Wir nehmen den ersten gefundenen Chunk
if nid and nid not in previews: if nid and nid not in previews:
previews[nid] = c.payload.get("window") or c.payload.get("text") or "" previews[nid] = c.payload.get("window") or c.payload.get("text") or ""
except: pass except Exception:
pass
return previews return previews
def _find_connected_edges(self, note_ids, note_title=None): def _find_connected_edges(self, note_ids, note_title=None):
""" """
Findet eingehende und ausgehende Kanten. Findet ein- und ausgehende Kanten für eine Liste von IDs.
Implementiert den Fix für Anker-Links [[Titel#Abschnitt]] durch Präfix-Suche in der target_id.
WICHTIG: target_id enthält nur den Titel (ohne #Abschnitt).
target_section ist ein separates Feld für Abschnitt-Informationen.
""" """
results = [] results = []
if not note_ids: if not note_ids:
return results return results
# 1. OUTGOING EDGES (Der "Owner"-Fix) # 1. AUSGEHENDE KANTEN (Outgoing)
# Wir suchen Kanten, die im Feld 'note_id' (Owner) eine unserer Notizen haben. # Suche über 'note_id' als Besitzer der Kante.
# Das findet ALLE ausgehenden Kanten, egal ob sie an einem Chunk oder der Note hängen.
out_filter = models.Filter(must=[ out_filter = models.Filter(must=[
models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)), models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)),
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES})) models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
@ -187,79 +175,71 @@ class GraphExplorerService:
res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=2000, with_payload=True) res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=2000, with_payload=True)
results.extend(res_out) results.extend(res_out)
# 2. INCOMING EDGES (Ziel = Chunk ID, Note ID oder Titel) # 2. EINGEHENDE KANTEN (Incoming)
# WICHTIG: target_id enthält nur den Titel, target_section ist separat # Suche über target_id (Ziel der Kante).
# Chunk IDs der aktuellen Notes holen # Sammele alle Chunk-IDs für exakte Treffer auf Segment-Ebene
c_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]) c_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))])
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=c_filter, limit=1000, with_payload=False) chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=c_filter, limit=1000, with_payload=False)
chunk_ids = [c.id for c in chunks] chunk_ids = [c.id for c in chunks]
shoulds = [] should_conditions = []
# Case A: Edge zeigt auf einen unserer Chunks
if chunk_ids: if chunk_ids:
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids))) should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids)))
should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
# Case B: Edge zeigt direkt auf unsere Note ID # TITEL-BASIERTE SUCHE (Inkl. Anker-Fix)
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) titles_to_check = []
# Case C: Edge zeigt auf unseren Titel
# WICHTIG: target_id enthält nur den Titel (z.B. "Meine Prinzipien 2025")
# target_section enthält die Abschnitt-Information (z.B. "P3 Disziplin"), wenn gesetzt
# Sammle alle relevanten Titel (inkl. Aliase)
titles_to_search = []
if note_title: if note_title:
titles_to_search.append(note_title) titles_to_check.append(note_title)
# Aliase laden für robuste Verlinkung
# Lade auch Titel aus den Notes selbst (falls note_title nicht übergeben wurde)
for nid in note_ids: for nid in note_ids:
note = self._fetch_note_cached(nid) note = self._fetch_note_cached(nid)
if note: if note:
note_title_from_db = note.get("title")
if note_title_from_db and note_title_from_db not in titles_to_search:
titles_to_search.append(note_title_from_db)
# Aliase hinzufügen
aliases = note.get("aliases", []) aliases = note.get("aliases", [])
if isinstance(aliases, str): if isinstance(aliases, str): aliases = [aliases]
aliases = [aliases] titles_to_check.extend([a for a in aliases if a not in titles_to_check])
for alias in aliases:
if alias and alias not in titles_to_search:
titles_to_search.append(alias)
# Für jeden Titel: Suche nach exaktem Match # Exakte Titel-Matches hinzufügen
# target_id enthält nur den Titel, daher reicht MatchValue for t in titles_to_check:
for title in titles_to_search: should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=t)))
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=title)))
if shoulds: if should_conditions:
in_filter = models.Filter( in_filter = models.Filter(
must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))], must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))],
should=shoulds should=should_conditions
) )
res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=2000, with_payload=True) res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=2000, with_payload=True)
results.extend(res_in) results.extend(res_in)
# FIX FÜR [[Titel#Abschnitt]]: Suche nach Fragmenten
if titles_to_check:
for t in titles_to_check:
anchor_filter = models.Filter(must=[
models.FieldCondition(key="target_id", match=models.MatchText(text=t)),
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
])
res_anchor, _ = self.client.scroll(self.edges_col, scroll_filter=anchor_filter, limit=1000, with_payload=True)
existing_ids = {r.id for r in results}
for edge in res_anchor:
tgt = edge.payload.get("target_id", "")
# Client-seitige Filterung: Nur Kanten nehmen, die mit Titel# beginnen
if edge.id not in existing_ids and (tgt == t or tgt.startswith(f"{t}#")):
results.append(edge)
return results return results
def _find_connected_edges_batch(self, note_ids): def _find_connected_edges_batch(self, note_ids):
""" """Wrapper für die Suche in tieferen Ebenen des Graphen."""
Wrapper für Level 2 Suche. first_note = self._fetch_note_cached(note_ids[0]) if note_ids else None
Lädt Titel der ersten Note für Titel-basierte Suche. title = first_note.get("title") if first_note else None
""" return self._find_connected_edges(note_ids, note_title=title)
if not note_ids:
return []
first_note = self._fetch_note_cached(note_ids[0])
note_title = first_note.get("title") if first_note else None
return self._find_connected_edges(note_ids, note_title=note_title)
def _process_edge(self, record, nodes_dict, unique_edges, current_depth): def _process_edge(self, record, nodes_dict, unique_edges, current_depth):
""" """
Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries hinzu. Verarbeitet eine rohe Kante, löst Quell- und Ziel-Referenzen auf
und fügt sie den Dictionaries für den Graphen hinzu.
WICHTIG: Beide Richtungen werden unterstützt:
- Ausgehende Kanten: source_id gehört zu unserer Note (via note_id Owner)
- Eingehende Kanten: target_id zeigt auf unsere Note (via target_id Match)
""" """
if not record or not record.payload: if not record or not record.payload:
return None, None return None, None
@ -270,13 +250,10 @@ class GraphExplorerService:
kind = payload.get("kind") kind = payload.get("kind")
provenance = payload.get("provenance", "explicit") provenance = payload.get("provenance", "explicit")
# Prüfe, ob beide Referenzen vorhanden sind
if not src_ref or not tgt_ref: if not src_ref or not tgt_ref:
return None, None return None, None
# IDs zu Notes auflösen # IDs zu Notes auflösen (Hier greift der Fragment-Fix)
# WICHTIG: source_id kann Chunk-ID (note_id#c01), Note-ID oder Titel sein
# WICHTIG: target_id kann Chunk-ID, Note-ID oder Titel sein (ohne #Abschnitt)
src_note = self._resolve_note_from_ref(src_ref) src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref) tgt_note = self._resolve_note_from_ref(tgt_ref)
@ -284,159 +261,118 @@ class GraphExplorerService:
src_id = src_note.get('note_id') src_id = src_note.get('note_id')
tgt_id = tgt_note.get('note_id') tgt_id = tgt_note.get('note_id')
# Prüfe, ob beide IDs vorhanden sind if src_id and tgt_id and src_id != tgt_id:
if not src_id or not tgt_id: # Knoten zum Set hinzufügen
return None, None
if src_id != tgt_id:
# Nodes hinzufügen
self._add_node_to_dict(nodes_dict, src_note, level=current_depth) self._add_node_to_dict(nodes_dict, src_note, level=current_depth)
self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth) self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth)
# Kante hinzufügen (mit Deduplizierung) # Kante registrieren (Deduplizierung)
key = (src_id, tgt_id) key = (src_id, tgt_id)
existing = unique_edges.get(key) existing = unique_edges.get(key)
should_update = True
# Bevorzuge explizite Kanten vor Smart Kanten
is_current_explicit = (provenance in ["explicit", "rule"]) is_current_explicit = (provenance in ["explicit", "rule"])
should_update = True
if existing: if existing:
is_existing_explicit = (existing.get('provenance', '') in ["explicit", "rule"]) is_existing_explicit = (existing.get('provenance', '') in ["explicit", "rule"])
if is_existing_explicit and not is_current_explicit: if is_existing_explicit and not is_current_explicit:
should_update = False should_update = False
if should_update: if should_update:
unique_edges[key] = {"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance} unique_edges[key] = {
"source": src_id,
"target": tgt_id,
"kind": kind,
"provenance": provenance
}
return src_id, tgt_id return src_id, tgt_id
return None, None return None, None
def _fetch_note_cached(self, note_id): def _fetch_note_cached(self, note_id):
if note_id in self._note_cache: return self._note_cache[note_id] """Lädt eine Note aus Qdrant mit Session-Caching."""
if not note_id:
return None
if note_id in self._note_cache:
return self._note_cache[note_id]
try:
res, _ = self.client.scroll( res, _ = self.client.scroll(
collection_name=self.notes_col, collection_name=self.notes_col,
scroll_filter=models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]), scroll_filter=models.Filter(must=[
models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))
]),
limit=1, with_payload=True limit=1, with_payload=True
) )
if res: if res and res[0].payload:
self._note_cache[note_id] = res[0].payload payload = res[0].payload
return res[0].payload self._note_cache[note_id] = payload
return payload
except Exception:
pass
return None return None
def _resolve_note_from_ref(self, ref_str): def _resolve_note_from_ref(self, ref_str):
""" """
Löst eine Referenz zu einer Note Payload auf. Löst eine Referenz (ID, Chunk-ID oder Wikilink mit Anker) auf eine Note auf.
Bereinigt Anker (#) vor der Suche.
WICHTIG: Wenn ref_str ein Titel#Abschnitt Format hat, wird nur der Titel-Teil verwendet.
Unterstützt:
- Note-ID: "20250101-meine-note"
- Chunk-ID: "20250101-meine-note#c01"
- Titel: "Meine Prinzipien 2025"
- Titel#Abschnitt: "Meine Prinzipien 2025#P3 Disziplin" (trennt Abschnitt ab, sucht nur nach Titel)
""" """
if not ref_str: if not ref_str:
return None return None
# Fall A: Enthält # (kann Chunk-ID oder Titel#Abschnitt sein) if ref_str in self._ref_resolution_cache:
if "#" in ref_str: return self._ref_resolution_cache[ref_str]
# Fragment-Behandlung: Trenne Anker ab
base_ref = ref_str.split("#")[0].strip()
# 1. Versuch: Direkte Note-ID Suche
note = self._fetch_note_cached(base_ref)
if note:
self._ref_resolution_cache[ref_str] = note
return note
# 2. Versuch: Titel-Suche (Keyword-Match)
try: try:
# Versuch 1: Chunk ID direkt (Format: note_id#c01) res, _ = self.client.scroll(
res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True) collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchValue(value=base_ref))
]),
limit=1, with_payload=True
)
if res and res[0].payload: if res and res[0].payload:
note_id = res[0].payload.get("note_id") payload = res[0].payload
if note_id: self._ref_resolution_cache[ref_str] = payload
return self._fetch_note_cached(note_id) return payload
except: except Exception:
pass pass
# Versuch 2: NoteID#Section (Hash abtrennen und als Note-ID versuchen) # 3. Versuch: Auflösung über Chunks
# z.B. "20250101-meine-note#Abschnitt" -> "20250101-meine-note" if "#" in ref_str:
possible_note_id = ref_str.split("#")[0].strip() try:
note = self._fetch_note_cached(possible_note_id) res_chunk = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True)
if res_chunk and res_chunk[0].payload:
note_id = res_chunk[0].payload.get("note_id")
note = self._fetch_note_cached(note_id)
if note: if note:
self._ref_resolution_cache[ref_str] = note
return note return note
except Exception:
# Versuch 3: Titel#Abschnitt (Hash abtrennen und als Titel suchen) pass
# z.B. "Meine Prinzipien 2025#P3 Disziplin" -> "Meine Prinzipien 2025"
# WICHTIG: target_id enthält nur den Titel, daher suchen wir nur nach dem Titel-Teil
possible_title = ref_str.split("#")[0].strip()
if possible_title:
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchValue(value=possible_title))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[payload['note_id']] = payload
return payload
# Fallback: Text-Suche für Fuzzy-Matching
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchText(text=possible_title))
]),
limit=10, with_payload=True
)
if res:
# Nimm das erste Ergebnis, das exakt oder beginnend mit possible_title übereinstimmt
for r in res:
if r.payload:
note_title = r.payload.get("title", "")
if note_title == possible_title or note_title.startswith(possible_title):
payload = r.payload
self._note_cache[payload['note_id']] = payload
return payload
# Fall B: Note ID direkt
note = self._fetch_note_cached(ref_str)
if note:
return note
# Fall C: Titel (exakte Übereinstimmung)
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[payload['note_id']] = payload
return payload
# Fall D: Titel (Text-Suche für Fuzzy-Matching)
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchText(text=ref_str))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[payload['note_id']] = payload
return payload
return None return None
def _add_node_to_dict(self, node_dict, note_payload, level=1): def _add_node_to_dict(self, node_dict, note_payload, level=1):
"""Erstellt ein Node-Objekt für streamlit-agraph mit Styling."""
nid = note_payload.get("note_id") nid = note_payload.get("note_id")
if not nid or nid in node_dict: return if not nid or nid in node_dict:
return
ntype = note_payload.get("type", "default") ntype = note_payload.get("type", "default")
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) color = GRAPH_COLORS.get(ntype, GRAPH_COLORS.get("default", "#8395a7"))
# Basis-Tooltip (wird später erweitert)
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}" tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
if level == 0: size = 45 size = 45 if level == 0 else (25 if level == 1 else 15)
elif level == 1: size = 25
else: size = 15
node_dict[nid] = Node( node_dict[nid] = Node(
id=nid, id=nid,
label=note_payload.get('title', nid), label=note_payload.get('title', nid),

View File

@ -1,10 +1,10 @@
""" """
FILE: app/models/dto.py FILE: app/models/dto.py
DESCRIPTION: Pydantic-Modelle (DTOs) für Request/Response Bodies. Definiert das API-Schema. DESCRIPTION: Pydantic-Modelle (DTOs) für Request/Response Bodies. Definiert das API-Schema.
VERSION: 0.6.7 (WP-Fix: Target Section Support) VERSION: 0.6.6 (WP-22 Debug & Stability Update)
STATUS: Active STATUS: Active
DEPENDENCIES: pydantic, typing, uuid DEPENDENCIES: pydantic, typing, uuid
LAST_ANALYSIS: 2025-12-29 LAST_ANALYSIS: 2025-12-18
""" """
from __future__ import annotations from __future__ import annotations
@ -43,7 +43,6 @@ class EdgeDTO(BaseModel):
direction: Literal["out", "in", "undirected"] = "out" direction: Literal["out", "in", "undirected"] = "out"
provenance: Optional[Literal["explicit", "rule", "smart", "structure"]] = "explicit" provenance: Optional[Literal["explicit", "rule", "smart", "structure"]] = "explicit"
confidence: float = 1.0 confidence: float = 1.0
target_section: Optional[str] = None # Neu: Speichert den Anker (z.B. #Abschnitt)
# --- Request Models --- # --- Request Models ---

View File

@ -2,7 +2,7 @@
doc_type: glossary doc_type: glossary
audience: all audience: all
status: active status: active
version: 2.9.1 version: 2.8.1
context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-Cloud Resilienz, WP-14 Modularisierung, WP-15b Two-Pass Ingestion und Mistral-safe Parsing." context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-Cloud Resilienz, WP-14 Modularisierung, WP-15b Two-Pass Ingestion und Mistral-safe Parsing."
--- ---
@ -14,7 +14,7 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C
* **Note:** Repräsentiert eine Markdown-Datei. Die fachliche Haupteinheit. Verfügt über einen **Status** (stable, draft, system), der das Scoring beeinflusst. * **Note:** Repräsentiert eine Markdown-Datei. Die fachliche Haupteinheit. Verfügt über einen **Status** (stable, draft, system), der das Scoring beeinflusst.
* **Chunk:** Ein Textabschnitt einer Note. Die technische Sucheinheit (Vektor). * **Chunk:** Ein Textabschnitt einer Note. Die technische Sucheinheit (Vektor).
* **Edge:** Eine gerichtete Verbindung zwischen zwei Knoten. Wird in WP-22 durch die Registry validiert. Seit v2.9.1 unterstützt Edges **Section-basierte Links** (`target_section`), sodass mehrere Kanten zwischen denselben Knoten existieren können, wenn sie auf verschiedene Abschnitte zeigen. * **Edge:** Eine gerichtete Verbindung zwischen zwei Knoten. Wird in WP-22 durch die Registry validiert.
* **Vault:** Der lokale Ordner mit den Markdown-Dateien (Source of Truth). * **Vault:** Der lokale Ordner mit den Markdown-Dateien (Source of Truth).
* **Frontmatter:** Der YAML-Header am Anfang einer Notiz (enthält `id`, `type`, `title`, `status`). * **Frontmatter:** Der YAML-Header am Anfang einer Notiz (enthält `id`, `type`, `title`, `status`).
@ -48,6 +48,3 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C
* **Pass 1 (Pre-Scan):** Schnelles Scannen aller Dateien zur Befüllung des LocalBatchCache. * **Pass 1 (Pre-Scan):** Schnelles Scannen aller Dateien zur Befüllung des LocalBatchCache.
* **Pass 2 (Semantic Processing):** Tiefenverarbeitung (Chunking, Embedding, Validierung) nur für geänderte Dateien. * **Pass 2 (Semantic Processing):** Tiefenverarbeitung (Chunking, Embedding, Validierung) nur für geänderte Dateien.
* **Circular Import Registry (WP-14):** Entkopplung von Kern-Logik (wie Textbereinigung) in eine neutrale `registry.py`, um Abhängigkeitsschleifen zwischen Diensten und Ingestion-Utilities zu verhindern. * **Circular Import Registry (WP-14):** Entkopplung von Kern-Logik (wie Textbereinigung) in eine neutrale `registry.py`, um Abhängigkeitsschleifen zwischen Diensten und Ingestion-Utilities zu verhindern.
* **Deep-Link / Section-basierter Link:** Ein Link wie `[[Note#Section]]`, der auf einen spezifischen Abschnitt innerhalb einer Note verweist. Seit v2.9.1 wird dieser in `target_id="Note"` und `target_section="Section"` aufgeteilt, um "Phantom-Knoten" zu vermeiden und Multigraph-Support zu ermöglichen.
* **Atomic Section Logic (v3.9.9):** Chunking-Verfahren, das Sektions-Überschriften und deren Inhalte atomar in Chunks hält (Pack-and-Carry-Over). Verhindert, dass Überschriften über Chunk-Grenzen hinweg getrennt werden.
* **Registry-First Profiling (v2.13.12):** Hierarchische Auflösung des Chunking-Profils: Frontmatter > types.yaml Typ-Config > Global Defaults. Stellt sicher, dass Note-Typen automatisch das korrekte Profil erhalten.

View File

@ -3,7 +3,7 @@ doc_type: user_manual
audience: user, author audience: user, author
scope: vault, markdown, schema scope: vault, markdown, schema
status: active status: active
version: 2.9.1 version: 2.8.0
context: "Regelwerk für das Erstellen von Notizen im Vault. Die 'Source of Truth' für Autoren." context: "Regelwerk für das Erstellen von Notizen im Vault. Die 'Source of Truth' für Autoren."
--- ---
@ -208,12 +208,6 @@ Dies ist die **mächtigste** Methode. Du sagst dem System explizit, **wie** Ding
> "Daher [[rel:depends_on Qdrant]]." > "Daher [[rel:depends_on Qdrant]]."
> "Dieses Konzept ist [[rel:similar_to Pinecone]]." > "Dieses Konzept ist [[rel:similar_to Pinecone]]."
**Deep-Links zu Abschnitten (v2.9.1):**
Du kannst auch auf spezifische Abschnitte innerhalb einer Note verlinken:
> "Siehe [[rel:based_on Mein Leitbild#P3 Disziplin]]."
Das System trennt automatisch den Note-Namen (`Mein Leitbild`) vom Abschnitts-Namen (`P3 Disziplin`), sodass mehrere Links zur gleichen Note möglich sind, wenn sie auf verschiedene Abschnitte zeigen.
**Gültige Relationen:** **Gültige Relationen:**
* `depends_on`: Hängt ab von / Benötigt. * `depends_on`: Hängt ab von / Benötigt.
* `blocks`: Blockiert oder gefährdet (z.B. Risiko -> Projekt). * `blocks`: Blockiert oder gefährdet (z.B. Risiko -> Projekt).
@ -232,12 +226,6 @@ Für Zusammenfassungen am Ende einer Notiz, oder eines Absatzes:
> [[AI Agents]] > [[AI Agents]]
``` ```
**Multi-Line Support (v2.9.1):**
Callout-Blocks mit mehreren Zeilen werden korrekt verarbeitet. Das System erkennt automatisch, wenn mehrere Links im gleichen Callout-Block stehen, und erstellt für jeden Link eine separate Kante (auch bei Deep-Links zu verschiedenen Sections).
**Format-agnostische De-Duplizierung:**
Wenn Kanten bereits via `[!edge]` Callout vorhanden sind, werden sie nicht mehrfach injiziert. Das System erkennt vorhandene Kanten unabhängig vom Format (Inline, Callout, Wikilink).
### 4.3 Implizite Bidirektionalität (Edger-Logik) [NEU] [PRÜFEN!] ### 4.3 Implizite Bidirektionalität (Edger-Logik) [NEU] [PRÜFEN!]
In Mindnet musst du Kanten **nicht** manuell in beide Richtungen pflegen. Der **Edger** übernimmt die Paarbildung automatisch im Hintergrund. In Mindnet musst du Kanten **nicht** manuell in beide Richtungen pflegen. Der **Edger** übernimmt die Paarbildung automatisch im Hintergrund.

View File

@ -3,7 +3,7 @@ doc_type: concept
audience: architect, product_owner audience: architect, product_owner
scope: graph, logic, provenance scope: graph, logic, provenance
status: active status: active
version: 2.9.1 version: 2.7.0
context: "Fachliche Beschreibung des Wissensgraphen: Knoten, Kanten, Provenance, Matrix-Logik und WP-22 Scoring-Prinzipien." context: "Fachliche Beschreibung des Wissensgraphen: Knoten, Kanten, Provenance, Matrix-Logik und WP-22 Scoring-Prinzipien."
--- ---
@ -118,30 +118,8 @@ Der Intent-Router injiziert spezifische Multiplikatoren für kanonische Typen:
--- ---
## 6. Section-basierte Links & Multigraph-Support ## 6. Idempotenz & Konsistenz
Seit v2.9.1 unterstützt Mindnet **Deep-Links** zu spezifischen Abschnitten innerhalb einer Note.
### 6.1 Link-Parsing
Links wie `[[Note#Section]]` werden in zwei Komponenten aufgeteilt:
* **`target_id`:** Enthält nur den Note-Namen (z.B. "Mein Leitbild")
* **`target_section`:** Enthält den Abschnitts-Namen (z.B. "P3 Disziplin")
**Vorteil:** Verhindert "Phantom-Knoten", die durch das Einbeziehen des Anchors in die `target_id` entstanden wären.
### 6.2 Multigraph-Support
Die Edge-ID enthält nun einen `variant`-Parameter (die Section), sodass mehrere Kanten zwischen denselben Knoten existieren können, wenn sie auf verschiedene Sections zeigen:
* `[[Note#Section1]]` → Edge-ID: `src->tgt:kind@Section1`
* `[[Note#Section2]]` → Edge-ID: `src->tgt:kind@Section2`
### 6.3 Semantische Deduplizierung
Die Deduplizierung basiert auf dem `src->tgt:kind@sec` Key, um sicherzustellen, dass identische Links (gleiche Quelle, Ziel, Typ und Section) nicht mehrfach erstellt werden.
---
## 7. Idempotenz & Konsistenz
Das System garantiert fachliche Konsistenz auch bei mehrfachen Importen. Das System garantiert fachliche Konsistenz auch bei mehrfachen Importen.
* **Stabile IDs:** Deterministische IDs verhindern Duplikate bei Re-Imports. * **Stabile IDs:** Deterministische IDs verhindern Duplikate bei Re-Imports.
* **Deduplizierung:** Kanten werden anhand ihrer Identität (inkl. Section) erkannt. Die "stärkere" Provenance gewinnt. * **Deduplizierung:** Kanten werden anhand ihrer Identität erkannt. Die "stärkere" Provenance gewinnt.
* **Format-agnostische Erkennung:** Kanten werden unabhängig vom Format (Inline, Callout, Wikilink) erkannt, um Dopplungen zu vermeiden.

View File

@ -144,11 +144,8 @@ Lädt den Subgraphen um eine Note herum.
"kind": "depends_on", "kind": "depends_on",
"source": "uuid", "source": "uuid",
"target": "uuid", "target": "uuid",
"target_section": "P3 Disziplin", // Optional: Abschnitts-Name bei Deep-Links
"weight": 1.4, "weight": 1.4,
"direction": "out", "direction": "out"
"provenance": "explicit",
"confidence": 1.0
} }
], ],
"stats": { "stats": {

View File

@ -3,7 +3,7 @@ doc_type: technical_reference
audience: developer, architect audience: developer, architect
scope: database, qdrant, schema scope: database, qdrant, schema
status: active status: active
version: 2.9.1 version: 2.8.0
context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen. Berücksichtigt WP-14 Modularisierung und WP-15b Multi-Hashes." context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen. Berücksichtigt WP-14 Modularisierung und WP-15b Multi-Hashes."
--- ---
@ -96,19 +96,15 @@ Es müssen Payload-Indizes für folgende Felder existieren:
## 4. Edge Payload (`mindnet_edges`) ## 4. Edge Payload (`mindnet_edges`)
Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Tracking. Seit v2.9.1 unterstützt das System **Section-basierte Links** (`[[Note#Section]]`), die in `target_id` und `target_section` aufgeteilt werden. Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Tracking.
**JSON-Schema:** **JSON-Schema:**
```json ```json
{ {
"edge_id": "string (keyword)", // Deterministischer Hash aus (src, dst, kind, variant) "edge_id": "string (keyword)", // Deterministischer Hash aus (src, dst, kind)
// variant = target_section (erlaubt Multigraph für Sections)
"source_id": "string (keyword)", // Chunk-ID (Start) "source_id": "string (keyword)", // Chunk-ID (Start)
"target_id": "string (keyword)", // Chunk-ID oder Note-Titel (bei Unresolved) "target_id": "string (keyword)", // Chunk-ID oder Note-Titel (bei Unresolved)
// WICHTIG: Enthält NUR den Note-Namen, KEINE Section-Info
"target_section": "string (keyword)", // Optional: Abschnitts-Name (z.B. "P3 Disziplin")
// Wird aus [[Note#Section]] extrahiert
"kind": "string (keyword)", // Beziehungsart (z.B. 'depends_on') "kind": "string (keyword)", // Beziehungsart (z.B. 'depends_on')
"scope": "string (keyword)", // Immer 'chunk' (Legacy-Support: 'note') "scope": "string (keyword)", // Immer 'chunk' (Legacy-Support: 'note')
"note_id": "string (keyword)", // Owner Note ID (Ursprung der Kante) "note_id": "string (keyword)", // Owner Note ID (Ursprung der Kante)
@ -120,16 +116,10 @@ Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Track
} }
``` ```
**Section-Support:**
* Links wie `[[Note#Section]]` werden in `target_id="Note"` und `target_section="Section"` aufgeteilt.
* Die Edge-ID enthält die Section als `variant`, sodass mehrere Kanten zwischen denselben Knoten existieren können, wenn sie auf verschiedene Sections zeigen.
* Semantische Deduplizierung basiert auf `src->tgt:kind@sec` Key, um "Phantom-Knoten" zu vermeiden.
**Erforderliche Indizes:** **Erforderliche Indizes:**
Es müssen Payload-Indizes für folgende Felder existieren: Es müssen Payload-Indizes für folgende Felder existieren:
* `source_id` * `source_id`
* `target_id` * `target_id`
* `target_section` (neu: Keyword-Index für Section-basierte Filterung)
* `kind` * `kind`
* `scope` * `scope`
* `note_id` * `note_id`

View File

@ -3,7 +3,7 @@ doc_type: technical_reference
audience: developer, frontend_architect audience: developer, frontend_architect
scope: architecture, graph_viz, state_management scope: architecture, graph_viz, state_management
status: active status: active
version: 2.9.1 version: 2.7.0
context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors." context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors."
--- ---

View File

@ -3,7 +3,7 @@ doc_type: technical_reference
audience: developer, devops audience: developer, devops
scope: backend, ingestion, smart_edges, edge_registry, modularization scope: backend, ingestion, smart_edges, edge_registry, modularization
status: active status: active
version: 2.13.12 version: 2.9.0
context: "Detaillierte technische Beschreibung der Import-Pipeline, Two-Pass-Workflow (WP-15b) und modularer Datenbank-Architektur (WP-14). Integriert Mistral-safe Parsing und Deep Fallback." context: "Detaillierte technische Beschreibung der Import-Pipeline, Two-Pass-Workflow (WP-15b) und modularer Datenbank-Architektur (WP-14). Integriert Mistral-safe Parsing und Deep Fallback."
--- ---
@ -31,10 +31,9 @@ Der Prozess ist **asynchron**, **idempotent** und wird nun in zwei logische Durc
4. **Edge Registry Initialisierung (WP-22):** 4. **Edge Registry Initialisierung (WP-22):**
* Laden der Singleton-Instanz der `EdgeRegistry`. * Laden der Singleton-Instanz der `EdgeRegistry`.
* Validierung der Vokabular-Datei unter `MINDNET_VOCAB_PATH`. * Validierung der Vokabular-Datei unter `MINDNET_VOCAB_PATH`.
5. **Config Resolution (WP-14 / v2.13.12):** 5. **Config Resolution (WP-14):**
* Bestimmung von `chunking_profile` und `retriever_weight` via zentraler `TypeRegistry`. * Bestimmung von `chunking_profile` und `retriever_weight` via zentraler `TypeRegistry`.
* **Priorität:** 1. Frontmatter (Override) -> 2. `types.yaml` (Type) -> 3. Global Default. * **Priorität:** 1. Frontmatter (Override) -> 2. `types.yaml` (Type) -> 3. Global Default.
* **Registry-First Profiling:** Automatische Anwendung der korrekten Profile basierend auf dem Note-Typ (z.B. `value` nutzt automatisch `structured_smart_edges_strict`).
6. **LocalBatchCache & Summary Generation (WP-15b):** 6. **LocalBatchCache & Summary Generation (WP-15b):**
* Erstellung von Kurz-Zusammenfassungen für jede Note. * Erstellung von Kurz-Zusammenfassungen für jede Note.
* Speicherung im `batch_cache` als Referenzrahmen für die spätere Kantenvalidierung. * Speicherung im `batch_cache` als Referenzrahmen für die spätere Kantenvalidierung.
@ -127,44 +126,19 @@ Das Chunking ist profilbasiert und bezieht seine Konfiguration dynamisch aus der
| `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte (Projekte). | | `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte (Projekte). |
| `structured_smart_edges` | `by_heading` | `strict: false` | Strukturierte Texte. | | `structured_smart_edges` | `by_heading` | `strict: false` | Strukturierte Texte. |
### 3.2 Die `by_heading` Logik (v3.9.9 Atomic Section Logic) ### 3.2 Die `by_heading` Logik (v2.9 Hybrid)
Die Strategie `by_heading` implementiert seit v3.9.9 das **"Pack-and-Carry-Over"** Verfahren (Regel 1-3), um Sektions-Überschriften und deren Inhalte atomar in Chunks zu halten. Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Sie unterstützt ein "Safety Net" gegen zu große Chunks.
**Kernprinzipien:** * **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split).
* **Atomic Section Logic:** Überschriften und deren Inhalte werden als atomare Einheiten behandelt und nicht über Chunk-Grenzen hinweg getrennt. * **Modus "Strict" (`strict_heading_split: true`):**
* **H1-Context Preservation:** Der Dokumenttitel (H1) wird zuverlässig als Breadcrumb in das Embedding-Fenster (`window`) aller Chunks injiziert. * Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk.
* **Signature Alignment:** Parameter-Synchronisierung zwischen Orchestrator und Strategien (`context_prefix` statt `doc_title`). * *Merge-Check:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt.
* *Safety Net:* Wird ein Abschnitt zu lang (> `max` Token), wird auch ohne Überschrift getrennt.
**Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split). * **Modus "Soft" (`strict_heading_split: false`):**
* **Hierarchie-Check:** Überschriften *oberhalb* des Split-Levels erzwingen **immer** einen Split.
**Modus "Strict" (`strict_heading_split: true`):** * **Füll-Logik:** Überschriften *auf* dem Split-Level lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat.
* Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk. * *Safety Net:* Auch hier greift das `max` Token Limit.
* *Merge-Check:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt.
* *Safety Net:* Wird ein Abschnitt zu lang (> `max` Token), wird auch ohne Überschrift getrennt.
**Modus "Soft" (`strict_heading_split: false`):**
* **Hierarchie-Check:** Überschriften *oberhalb* des Split-Levels erzwingen **immer** einen Split.
* **Füll-Logik:** Überschriften *auf* dem Split-Level lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat.
* **Pack-and-Carry-Over:** Wenn ein Abschnitt zu groß ist, wird er intelligent zerlegt, wobei der Rest (mit Überschrift) zurück in die Queue gelegt wird.
* *Safety Net:* Auch hier greift das `max` Token Limit.
### 3.3 Registry-First Profiling (v2.13.12)
Seit v2.13.12 nutzt der `IngestionService` die korrekte Hierarchie zur Ermittlung des Chunking-Profils:
**Priorität:**
1. **Frontmatter** (Override) - Explizite `chunking_profile` Angabe
2. **`types.yaml` Typ-Config** - Profil basierend auf `type`
3. **Global Defaults** - Fallback auf `sliding_standard`
**Wichtig:** Ein Hard-Fallback auf `sliding_standard` erfolgt nur noch, wenn keine Konfiguration existiert. Dies stellt sicher, dass Note-Typen wie `value` automatisch das korrekte Profil (z.B. `structured_smart_edges_strict`) erhalten.
### 3.4 Deterministic Hashing (v2.13.12)
Der `full`-Hash inkludiert nun alle strategischen Parameter (z.B. `split_level`, `strict_heading_split`), sodass Konfigurationsänderungen im Frontmatter zwingend einen Re-Import auslösen.
**Impact:** Änderungen an Chunking-Parametern werden zuverlässig erkannt, auch wenn der Text unverändert bleibt.
--- ---

View File

@ -280,10 +280,3 @@ python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes
# 2. Neu importieren (Force Hash recalculation) # 2. Neu importieren (Force Hash recalculation)
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
``` ```
**Wichtig (v2.9.1 Migration):**
Nach dem Update auf v2.9.1 (Section-basierte Links, Multigraph-Support) ist ein vollständiger Re-Import erforderlich, um "Phantom-Knoten" zu beheben und die neue Edge-Struktur zu konsolidieren:
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
Dies stellt sicher, dass alle bestehenden Links korrekt in `target_id` und `target_section` aufgeteilt werden.

View File

@ -1,99 +0,0 @@
# Branch Merge Commit Message: WP4d
```
feat: Section-basierte Links, Atomic Section Chunking & Registry-First Profiling (v2.9.1)
## Graph Topology & Edge Management
### Section-basierte Links (Multigraph-Support)
- Split `[[Note#Section]]` Links in `target_id="Note"` und `target_section="Section"`
- Edge-ID enthält nun `variant` (Section), ermöglicht mehrere Kanten zwischen denselben Knoten
- Semantische Deduplizierung basiert auf `src->tgt:kind@sec` Key
- Behebt "Phantom-Knoten" durch korrekte Trennung von Note-Name und Abschnitt
**Geänderte Dateien:**
- `app/core/graph/graph_utils.py`: `parse_link_target()` für Section-Extraktion
- `app/core/graph/graph_derive_edges.py`: `target_section` in Edge-Payload
- `app/core/database/qdrant.py`: Keyword-Index für `target_section`
- `app/core/database/qdrant_points.py`: Explizites Durchreichen von `target_section`
- `app/models/dto.py`: `EdgeDTO` mit `target_section` Feld
### Extraction & Parsing Verbesserungen
- Multi-line Callout-Blocks korrekt verarbeitet (stop-check logic)
- Robuster Fallback für "headless" Blocks (split chunks)
- Liberalisierte Regex für Umlaute und Sonderzeichen in Targets
**Geänderte Dateien:**
- `app/core/graph/graph_extractors.py`: Multi-line Callout-Parser, erweiterte Regex
## Chunking & Ingestion (v3.9.9 / v2.13.12)
### Atomic Section Logic (v3.9.9)
- Vollständige Implementierung des "Pack-and-Carry-Over" Verfahrens (Regel 1-3)
- Sektions-Überschriften und Inhalte bleiben atomar in Chunks
- H1-Context Preservation: Dokumenttitel als Breadcrumb in Embedding-Fenster
- Signature Alignment: Parameter-Synchronisierung (`context_prefix` statt `doc_title`)
**Geänderte Dateien:**
- `app/core/chunking/chunking_strategies.py`: Atomic Section Logic implementiert
### Format-agnostische De-Duplizierung
- Prüfung auf vorhandene Kanten basiert auf Ziel (`target`), nicht String-Match
- Verhindert Dopplung von Kanten, die bereits via `[!edge]` Callout vorhanden sind
- Global Pool Integration für unzugeordnete Kanten
**Geänderte Dateien:**
- `app/core/chunking/chunking_propagation.py`: Ziel-basierte Prüfung
### Registry-First Profiling (v2.13.12)
- Korrekte Hierarchie: Frontmatter > types.yaml Typ-Config > Global Defaults
- Hard-Fallback auf `sliding_standard` nur wenn keine Konfiguration existiert
- Automatische Anwendung korrekter Profile basierend auf Note-Typ
### Deterministic Hashing
- `full`-Hash inkludiert strategische Parameter (`split_level`, `strict_heading_split`)
- Konfigurationsänderungen im Frontmatter lösen zwingend Re-Import aus
**Geänderte Dateien:**
- `app/core/ingestion/ingestion_processor.py`: Registry-First Profiling, Deterministic Hashing
## Impact & Breaking Changes
### Migration erforderlich
**WICHTIG:** Vollständiger Re-Import erforderlich für bestehende Vaults:
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
**Grund:**
- Behebt "Phantom-Knoten" durch korrekte Aufteilung von `[[Note#Section]]` Links
- Konsolidiert Edge-Struktur mit `target_section` Feld
- Aktualisiert Chunking basierend auf neuen Strategien
### Fixes
- ✅ Resolves: Mehrere Links zur gleichen Note in einem Callout-Block wurden zu einer Kante gemergt
- ✅ Resolves: "Phantom-Knoten" durch Einbeziehung des Anchors in `target_id`
- ✅ Resolves: Redundante `[[rel:...]]` Links in Chunks
- ✅ Resolves: Inkonsistente Metadaten in Qdrant durch Registry-First Profiling
## Dokumentation
Alle relevanten Dokumente aktualisiert:
- `03_tech_data_model.md`: Edge Payload Schema mit `target_section`
- `02_concept_graph_logic.md`: Section-basierte Links & Multigraph-Support
- `03_tech_ingestion_pipeline.md`: Chunking-Strategien, Registry-First Profiling
- `03_tech_api_reference.md`: EdgeDTO mit `target_section`
- `01_knowledge_design.md`: Deep-Links dokumentiert
- `00_glossary.md`: Neue Begriffe ergänzt
- `04_admin_operations.md`: Migration-Hinweis
## Versionen
- Graph Topology: v2.9.1
- Chunking Strategies: v3.9.9
- Ingestion Processor: v2.13.12
- API DTO: v0.6.7
Closes #[issue-number]
```

View File

@ -1,236 +0,0 @@
# Release Notes: Mindnet v2.9.1 (WP4d)
**Release Date:** 2025-01-XX
**Type:** Feature Release mit Breaking Changes
**Branch:** WP4d
---
## 🎯 Übersicht
Diese Version führt **Section-basierte Links** ein, verbessert das Chunking durch **Atomic Section Logic** und implementiert **Registry-First Profiling** für konsistentere Konfigurationsauflösung. Die Änderungen erfordern einen **vollständigen Re-Import** bestehender Vaults.
---
## ✨ Neue Features
### Section-basierte Links (Deep-Links)
Mindnet unterstützt nun **Deep-Links** zu spezifischen Abschnitten innerhalb einer Note:
```markdown
[[rel:based_on Mein Leitbild#P3 Disziplin]]
```
**Vorteile:**
- Mehrere Links zur gleichen Note möglich (verschiedene Sections)
- Präzise Kontext-Ladung (nur relevanter Abschnitt)
- Keine "Phantom-Knoten" mehr durch korrekte Trennung von Note-Name und Abschnitt
**Technische Details:**
- Links werden in `target_id="Note"` und `target_section="Section"` aufgeteilt
- Edge-ID enthält `variant` (Section) für Multigraph-Support
- Semantische Deduplizierung basiert auf `src->tgt:kind@sec` Key
### Atomic Section Logic (Chunking v3.9.9)
Das Chunking hält nun Sektions-Überschriften und deren Inhalte **atomar** zusammen:
**"Pack-and-Carry-Over" Verfahren:**
- Regel 1 & 2: Sektionen werden zusammengepackt, wenn sie in den Token-Limit passen
- Regel 3: Zu große Sektionen werden intelligent zerlegt, Rest wird zurück in Queue gelegt
- H1-Context Preservation: Dokumenttitel wird als Breadcrumb in alle Chunks injiziert
**Vorteile:**
- Keine getrennten Überschriften mehr
- Bessere semantische Kohärenz in Chunks
- Verbesserte Retrieval-Qualität durch vollständigen Kontext
### Registry-First Profiling (v2.13.12)
Die Konfigurationsauflösung folgt nun einer klaren Hierarchie:
1. **Frontmatter** (höchste Priorität)
2. **types.yaml Typ-Config**
3. **Global Defaults**
**Impact:**
- Note-Typen wie `value` erhalten automatisch das korrekte Profil (`structured_smart_edges_strict`)
- Keine manuellen Overrides mehr nötig für Standard-Typen
- Konsistente Metadaten in Qdrant
---
## 🔧 Verbesserungen
### Extraction & Parsing
- **Multi-line Callout-Blocks:** Korrekte Verarbeitung von mehrzeiligen `[!edge]` Callouts
- **Robuste Fallbacks:** "Headless" Blocks werden korrekt behandelt
- **Liberalisierte Regex:** Unterstützung für Umlaute und Sonderzeichen in Link-Targets
### Format-agnostische De-Duplizierung
- Kanten werden unabhängig vom Format (Inline, Callout, Wikilink) erkannt
- Verhindert Dopplungen, wenn Kanten bereits via `[!edge]` Callout vorhanden sind
- Ziel-basierte Prüfung statt String-Match
### Deterministic Hashing
- `full`-Hash inkludiert strategische Parameter (`split_level`, `strict_heading_split`)
- Konfigurationsänderungen im Frontmatter lösen zwingend Re-Import aus
- Zuverlässigere Change Detection
---
## 🐛 Bugfixes
- ✅ **Behoben:** Mehrere Links zur gleichen Note in einem Callout-Block wurden zu einer Kante gemergt
- ✅ **Behoben:** "Phantom-Knoten" durch Einbeziehung des Anchors in `target_id`
- ✅ **Behoben:** Redundante `[[rel:...]]` Links in Chunks
- ✅ **Behoben:** Inkonsistente Metadaten in Qdrant durch fehlerhafte Profil-Auflösung
- ✅ **Behoben:** `TypeError` durch Parameter-Mismatch zwischen Orchestrator und Strategien
---
## ⚠️ Breaking Changes & Migration
### Migration erforderlich
**WICHTIG:** Nach dem Update auf v2.9.1 ist ein **vollständiger Re-Import** erforderlich:
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
**Warum?**
- Behebt "Phantom-Knoten" durch korrekte Aufteilung von `[[Note#Section]]` Links
- Konsolidiert Edge-Struktur mit neuem `target_section` Feld
- Aktualisiert Chunking basierend auf Atomic Section Logic
**Was passiert beim Re-Import?**
- Alle bestehenden Links werden neu geparst und in `target_id` + `target_section` aufgeteilt
- Chunks werden mit neuer Atomic Section Logic neu generiert
- Edge-Struktur wird konsolidiert (Multigraph-Support)
**Dauer:** Abhängig von Vault-Größe (typischerweise 5-30 Minuten)
---
## 📚 API-Änderungen
### EdgeDTO erweitert
```python
class EdgeDTO(BaseModel):
# ... bestehende Felder ...
target_section: Optional[str] = None # Neu: Abschnitts-Name
```
**Impact für API-Consumer:**
- Graph-Endpunkte (`/graph/{note_id}`) enthalten nun `target_section` in Edge-Objekten
- Frontend kann Section-Informationen für präzisere Visualisierung nutzen
---
## 📖 Dokumentation
Alle relevanten Dokumente wurden aktualisiert:
- ✅ `03_tech_data_model.md`: Edge Payload Schema mit `target_section`
- ✅ `02_concept_graph_logic.md`: Section-basierte Links & Multigraph-Support
- ✅ `03_tech_ingestion_pipeline.md`: Chunking-Strategien, Registry-First Profiling
- ✅ `03_tech_api_reference.md`: EdgeDTO mit `target_section`
- ✅ `01_knowledge_design.md`: Deep-Links dokumentiert
- ✅ `00_glossary.md`: Neue Begriffe ergänzt
- ✅ `04_admin_operations.md`: Migration-Hinweis
---
## 🔄 Technische Details
### Geänderte Module
**Graph Topology:**
- `app/core/graph/graph_utils.py`: `parse_link_target()` für Section-Extraktion
- `app/core/graph/graph_derive_edges.py`: `target_section` in Edge-Payload
- `app/core/graph/graph_extractors.py`: Multi-line Callout-Parser
**Chunking:**
- `app/core/chunking/chunking_strategies.py`: Atomic Section Logic (v3.9.9)
- `app/core/chunking/chunking_propagation.py`: Format-agnostische De-Duplizierung
**Ingestion:**
- `app/core/ingestion/ingestion_processor.py`: Registry-First Profiling (v2.13.12), Deterministic Hashing
**Database:**
- `app/core/database/qdrant.py`: Keyword-Index für `target_section`
- `app/core/database/qdrant_points.py`: Explizites Durchreichen von `target_section`
**API:**
- `app/models/dto.py`: `EdgeDTO` mit `target_section` Feld (v0.6.7)
### Versionsnummern
- Graph Topology: **v2.9.1**
- Chunking Strategies: **v3.9.9**
- Ingestion Processor: **v2.13.12**
- API DTO: **v0.6.7**
---
## 🚀 Upgrade-Pfad
### Für Administratoren
1. **Backup erstellen:**
```bash
docker stop qdrant
tar -czf qdrant_backup_$(date +%F).tar.gz ./qdrant_data
docker start qdrant
```
2. **Code aktualisieren:**
```bash
git pull origin main
source .venv/bin/activate
pip install -r requirements.txt
```
3. **Re-Import durchführen:**
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
4. **Services neu starten:**
```bash
sudo systemctl restart mindnet-prod
sudo systemctl restart mindnet-ui-prod
```
### Für Entwickler
- Keine Code-Änderungen erforderlich, wenn nur API genutzt wird
- Frontend kann `target_section` Feld in Edge-Objekten nutzen (optional)
---
## 📝 Bekannte Einschränkungen
- **Migration-Dauer:** Große Vaults (>10.000 Notizen) können 30+ Minuten benötigen
- **Temporärer Speicher:** Während des Re-Imports kann Qdrant-Speicher temporär ansteigen
---
## 🙏 Danksagungen
Diese Version wurde durch umfangreiche Code-Analyse und Dokumentationsprüfung ermöglicht. Besonderer Fokus lag auf:
- Konsistenz zwischen Code und Dokumentation
- Vollständige Abdeckung aller Rollen (Entwickler, Administratoren, Anwender, Tester, Deployment)
- Klare Migration-Pfade
---
**Vollständige Changelog:** Siehe Git-Commits für detaillierte Änderungen
**Support:** Bei Fragen zur Migration siehe [Admin Operations Guide](../04_Operations/04_admin_operations.md)