diff --git a/app/core/chunking/chunking_parser.py b/app/core/chunking/chunking_parser.py index 3d56f55..e36ff0e 100644 --- a/app/core/chunking/chunking_parser.py +++ b/app/core/chunking/chunking_parser.py @@ -1,6 +1,8 @@ """ FILE: app/core/chunking/chunking_parser.py -DESCRIPTION: Zerlegt Markdown in Blöcke und extrahiert Kanten-Strings. +DESCRIPTION: Zerlegt Markdown in logische Einheiten (RawBlocks). + Hält alle Überschriftenebenen (H1-H6) im Stream. + Stellt die Funktion parse_edges_robust zur Verfügung. """ import re from typing import List, Tuple, Set @@ -11,69 +13,86 @@ _WS = re.compile(r'\s+') _SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])') def split_sentences(text: str) -> list[str]: - """Teilt Text in Sätze auf.""" + """Teilt Text in Sätze auf unter Berücksichtigung deutscher Interpunktion.""" text = _WS.sub(' ', text.strip()) 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()] def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: - """Zerlegt Text in logische Einheiten.""" + """Zerlegt Text in logische Einheiten (RawBlocks), inklusive H1-H6.""" blocks = [] - h1_title = "Dokument"; section_path = "/"; current_h2 = None + h1_title = "Dokument" + section_path = "/" + current_section_title = None + + # Frontmatter entfernen 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) - if h1_match: h1_title = h1_match.group(1).strip() + if h1_match: + h1_title = h1_match.group(1).strip() + lines = text_without_fm.split('\n') buffer = [] for line in lines: stripped = line.strip() - # H1 ignorieren (ist Doc Title) - if stripped.startswith('# '): - continue - - # Generische Heading-Erkennung (H2 bis H6) für flexible Split-Levels - heading_match = re.match(r'^(#{2,6})\s+(.*)', stripped) + # Heading-Erkennung (H1 bis H6) + heading_match = re.match(r'^(#{1,6})\s+(.*)', stripped) if heading_match: - # Buffer leeren (vorherigen Text abschließen) + # Vorherigen Text-Block abschließen if buffer: content = "\n".join(buffer).strip() - if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2)) + if content: + blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title)) buffer = [] level = len(heading_match.group(1)) title = heading_match.group(2).strip() - # Pfad-Logik: H2 setzt den Haupt-Pfad - if level == 2: - current_h2 = title - section_path = f"/{current_h2}" - # Bei H3+ bleibt der section_path beim Parent, aber das Level wird korrekt gesetzt + # Pfad- und Titel-Update für die Metadaten der folgenden Blöcke + if level == 1: + current_section_title = title; section_path = "/" + elif level == 2: + current_section_title = title; section_path = f"/{current_section_title}" - blocks.append(RawBlock("heading", stripped, level, section_path, current_h2)) - - elif not stripped: + # Die Überschrift selbst als regulären Block hinzufügen + blocks.append(RawBlock("heading", stripped, level, section_path, current_section_title)) + continue + + # Trenner (---) oder Leerzeilen beenden Blöcke, außer innerhalb von Callouts + if (not stripped or stripped == "---") and not line.startswith('>'): if buffer: content = "\n".join(buffer).strip() - if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2)) + if content: + blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title)) buffer = [] + if stripped == "---": + blocks.append(RawBlock("separator", "---", None, section_path, current_section_title)) else: buffer.append(line) if buffer: content = "\n".join(buffer).strip() - if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2)) + if content: + blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title)) + return blocks, h1_title def parse_edges_robust(text: str) -> Set[str]: - """Extrahiert Kanten-Kandidaten (Wikilinks, Callouts).""" + """Extrahiert Kanten-Kandidaten aus Wikilinks und Callouts.""" found_edges = set() + # 1. Wikilinks [[rel:kind|target]] inlines = re.findall(r'\[\[rel:([^\|\]]+)\|?([^\]]*)\]\]', text) for kind, target in inlines: k = kind.strip().lower() t = target.strip() if k and t: found_edges.add(f"{k}:{t}") + + # 2. Callout Edges > [!edge] kind lines = text.split('\n') current_edge_type = None for line in lines: @@ -81,13 +100,16 @@ def parse_edges_robust(text: str) -> Set[str]: callout_match = re.match(r'>\s*\[!edge\]\s*([^:\s]+)', stripped) if callout_match: current_edge_type = callout_match.group(1).strip().lower() + # Links in der gleichen Zeile des Callouts links = re.findall(r'\[\[([^\]]+)\]\]', stripped) for l in links: if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}") continue + # Links in Folgezeilen des Callouts if current_edge_type and stripped.startswith('>'): links = re.findall(r'\[\[([^\]]+)\]\]', stripped) for l in links: if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}") - elif not stripped.startswith('>'): current_edge_type = None + elif not stripped.startswith('>'): + current_edge_type = None return found_edges \ No newline at end of file diff --git a/app/core/chunking/chunking_processor.py b/app/core/chunking/chunking_processor.py index 1a17acb..26c2b68 100644 --- a/app/core/chunking/chunking_processor.py +++ b/app/core/chunking/chunking_processor.py @@ -1,7 +1,8 @@ """ FILE: app/core/chunking/chunking_processor.py DESCRIPTION: Der zentrale Orchestrator für das Chunking-System. - AUDIT v3.3.3: Wiederherstellung der "Gold-Standard" Qualität. + AUDIT v3.3.4: Wiederherstellung der "Gold-Standard" Qualität. + - Fix: Synchronisierung der Parameter (context_prefix) für alle Strategien. - Integriert physikalische Kanten-Injektion (Propagierung). - Stellt H1-Kontext-Fenster sicher. - Baut den Candidate-Pool für die WP-15b Ingestion auf. @@ -30,16 +31,19 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op fm, body_text = extract_frontmatter_from_text(md_text) blocks, doc_title = parse_blocks(md_text) - # Vorbereitung des H1-Präfix für die Embedding-Fenster + # Vorbereitung des H1-Präfix für die Embedding-Fenster (Breadcrumbs) h1_prefix = f"# {doc_title}" if doc_title else "" # 2. Anwendung der Splitting-Strategie - # Wir übergeben den Dokument-Titel/Präfix für die Window-Bildung. + # Alle Strategien nutzen nun einheitlich context_prefix für die Window-Bildung. if config.get("strategy") == "by_heading": - chunks = await asyncio.to_thread(strategy_by_heading, blocks, config, note_id, doc_title) + chunks = await asyncio.to_thread( + strategy_by_heading, blocks, config, note_id, context_prefix=h1_prefix + ) else: - # sliding_window nutzt nun den context_prefix für das Window-Feld. - chunks = await asyncio.to_thread(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: return [] @@ -52,6 +56,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op # Zuerst die explizit im Text vorhandenen Kanten sammeln. for ch in chunks: # 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): parts = e_str.split(':', 1) if len(parts) == 2: @@ -71,7 +76,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op parts = e_str.split(':', 1) if len(parts) == 2: 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: ch.candidate_pool.append({"kind": k, "to": t, "provenance": "global_pool"}) @@ -80,6 +85,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op seen = set() unique = [] for c in ch.candidate_pool: + # Eindeutigkeit über Typ, Ziel und Herkunft (Provenance) key = (c["kind"], c["to"], c["provenance"]) if key not in seen: seen.add(key) diff --git a/app/core/chunking/chunking_propagation.py b/app/core/chunking/chunking_propagation.py index 099d075..890b89e 100644 --- a/app/core/chunking/chunking_propagation.py +++ b/app/core/chunking/chunking_propagation.py @@ -1,9 +1,8 @@ """ FILE: app/core/chunking/chunking_propagation.py DESCRIPTION: Injiziert Sektions-Kanten physisch in den Text (Embedding-Enrichment). - Stellt die "Gold-Standard"-Qualität von v3.1.0 wieder her. -VERSION: 3.3.1 -STATUS: Active + Fix v3.3.6: Nutzt robustes Parsing zur Erkennung vorhandener Kanten, + um Dopplungen direkt hinter [!edge] Callouts format-agnostisch zu verhindern. """ from typing import List, Dict, Set from .chunking_models import Chunk @@ -12,7 +11,7 @@ from .chunking_parser import parse_edges_robust def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]: """ Sammelt Kanten pro Sektion und schreibt sie hart in den Text und das Window. - Dies ist essenziell für die Vektorisierung der Beziehungen. + Verhindert Dopplungen, wenn Kanten bereits via [!edge] Callout vorhanden sind. """ # 1. Sammeln: Alle expliziten Kanten pro Sektions-Pfad aggregieren section_map: Dict[str, Set[str]] = {} # path -> set(kind:target) @@ -36,21 +35,28 @@ def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]: if not edges_to_add: 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 = [] - for e_str in edges_to_add: + # Sortierung für deterministische Ergebnisse + 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) - # Nur injizieren, wenn die Kante nicht bereits im Text steht - token = f"[[rel:{kind}|{target}]]" - if token not in ch.text: - injections.append(token) + injections.append(f"[[rel:{kind}|{target}]]") if injections: - # Physische Anreicherung (Der v3.1.0 Qualitäts-Fix) + # Physische Anreicherung # Triple-Newline für saubere Trennung im Embedding-Fenster block = "\n\n\n" + " ".join(injections) ch.text += block - # ENTSCHEIDEND: Auch ins Window schreiben, da Qdrant hier sucht! + # Auch ins Window schreiben, da Qdrant hier sucht! if ch.window: ch.window += block else: diff --git a/app/core/chunking/chunking_strategies.py b/app/core/chunking/chunking_strategies.py index 8945fee..5ca68fe 100644 --- a/app/core/chunking/chunking_strategies.py +++ b/app/core/chunking/chunking_strategies.py @@ -1,142 +1,166 @@ """ FILE: app/core/chunking/chunking_strategies.py -DESCRIPTION: Mathematische Splitting-Strategien. - AUDIT v3.3.2: 100% Konformität zur 'by_heading' Spezifikation. - - Implementiert Hybrid-Safety-Net (Sliding Window für Übergrößen). - - Breadcrumb-Kontext im Window (H1 > H2). - - Sliding Window mit H1-Kontext (Gold-Standard v3.1.0). +DESCRIPTION: Strategien für atomares Sektions-Chunking v3.9.9. + Implementiert das 'Pack-and-Carry-Over' Verfahren nach Regel 1-3. + - Keine redundante Kanten-Injektion. + - Strikte Einhaltung von Sektionsgrenzen via Look-Ahead. + - Fix: Synchronisierung der Parameter mit dem Orchestrator (context_prefix). """ from typing import List, Dict, Any, Optional from .chunking_models import RawBlock, Chunk from .chunking_utils import estimate_tokens from .chunking_parser import split_sentences -def _create_context_win(doc_title: str, sec_title: Optional[str], text: str) -> str: +def _create_win(context_prefix: str, sec_title: Optional[str], text: str) -> str: """Baut den Breadcrumb-Kontext für das Embedding-Fenster.""" - parts = [] - if doc_title: parts.append(doc_title) - if sec_title and sec_title != doc_title: parts.append(sec_title) + parts = [context_prefix] if context_prefix else [] + # Verhindert Dopplung, falls der Context-Prefix (H1) bereits den Sektionsnamen enthält + if sec_title and f"# {sec_title}" != context_prefix and sec_title not in (context_prefix or ""): + parts.append(sec_title) prefix = " > ".join(parts) return f"{prefix}\n{text}".strip() if prefix else text -def strategy_sliding_window(blocks: List[RawBlock], - config: Dict[str, Any], - note_id: str, - context_prefix: str = "") -> List[Chunk]: +def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, context_prefix: str = "") -> List[Chunk]: """ - Fasst Blöcke zusammen und schneidet bei 'target' Tokens. - 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. + Universelle Heading-Strategie mit Carry-Over Logik. + Synchronisiert auf context_prefix für Kompatibilität mit dem Orchestrator. """ + smart_edge = config.get("enable_smart_edge_allocation", True) strict = config.get("strict_heading_split", False) target = config.get("target", 400) max_tokens = config.get("max", 600) split_level = config.get("split_level", 2) - overlap = sum(config.get("overlap", (50, 80))) // 2 + overlap_cfg = config.get("overlap", (50, 80)) + overlap = sum(overlap_cfg) // 2 if isinstance(overlap_cfg, (list, tuple)) else overlap_cfg chunks: List[Chunk] = [] - buf: List[str] = [] - cur_tokens = 0 - def _add_to_chunks(txt, title, path): + def _emit(txt, title, path): + """Schreibt den finalen Chunk ohne Text-Modifikationen.""" idx = len(chunks) - win = _create_context_win(doc_title, title, txt) + win = _create_win(context_prefix, title, 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=title, section_path=path, - neighbors_prev=None, neighbors_next=None + section_title=title, section_path=path, neighbors_prev=None, neighbors_next=None )) - def _flush(title, path): - nonlocal buf, cur_tokens - if not buf: return - full_text = "\n\n".join(buf) - if estimate_tokens(full_text) <= max_tokens: - _add_to_chunks(full_text, title, path) - else: - sents = split_sentences(full_text); cur_sents = []; sub_len = 0 - for s in sents: - slen = estimate_tokens(s) - if sub_len + slen > target and cur_sents: - _add_to_chunks(" ".join(cur_sents), title, 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); sub_len = ov_l + slen - else: cur_sents.append(s); sub_len += slen - if cur_sents: _add_to_chunks(" ".join(cur_sents), title, path) - buf = []; cur_tokens = 0 - + # --- SCHRITT 1: Gruppierung in atomare Sektions-Einheiten --- + sections: List[Dict[str, Any]] = [] + curr_blocks = [] for b in blocks: - if b.kind == "heading": - if b.level < split_level: _flush(b.section_title, b.section_path) - elif b.level == split_level: - if strict or cur_tokens >= target: _flush(b.section_title, b.section_path) + if b.kind == "heading" and b.level <= split_level: + if curr_blocks: + 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: + curr_blocks.append(b) + if curr_blocks: + 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" + }) + + # --- SCHRITT 2: Verarbeitung der Queue --- + queue = list(sections) + current_chunk_text = "" + current_meta = {"title": None, "path": "/"} + + # Bestimmung des Modus: Hard-Split wenn smart_edge=False ODER strict=True + 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 - bt = estimate_tokens(b.text) - if cur_tokens + bt > max_tokens and buf: _flush(b.section_title, b.section_path) - buf.append(b.text); cur_tokens += bt + + # 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: + b_tokens = estimate_tokens(b.text) + curr_tokens = sum(estimate_tokens(x.text) for x in buf) if buf else 0 + if curr_tokens + b_tokens > max_tokens and buf: + txt = "\n\n".join([x.text for x in buf]); idx = len(chunks) + win = _create_win(context_prefix, buf[0].section_title, txt) + 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)) + buf = [] + buf.append(b) + if buf: - last_b = blocks[-1] if blocks else None - _flush(last_b.section_title if last_b else None, last_b.section_path if last_b else "/") + txt = "\n\n".join([x.text for x in buf]); idx = len(chunks) + win = _create_win(context_prefix, buf[0].section_title, 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=buf[0].section_title, section_path=buf[0].section_path, neighbors_prev=None, neighbors_next=None)) + return chunks \ No newline at end of file diff --git a/app/core/database/qdrant.py b/app/core/database/qdrant.py index 163c210..d5ee7bd 100644 --- a/app/core/database/qdrant.py +++ b/app/core/database/qdrant.py @@ -3,7 +3,7 @@ FILE: app/core/database/qdrant.py DESCRIPTION: Qdrant-Client Factory und Schema-Management. Erstellt Collections und Payload-Indizes. MODULARISIERUNG: Verschoben in das database-Paket für WP-14. -VERSION: 2.2.1 +VERSION: 2.2.2 (WP-Fix: Index für target_section) STATUS: Active 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. - notes: note_id, type, title, updated, tags - chunks: note_id, chunk_id, index, type, tags - - edges: note_id, kind, scope, source_id, target_id, chunk_id + - edges: note_id, kind, scope, source_id, target_id, chunk_id, target_section """ notes, chunks, edges = collection_names(prefix) @@ -156,6 +156,8 @@ def ensure_payload_indexes(client: QdrantClient, prefix: str) -> None: ("source_id", rest.PayloadSchemaType.KEYWORD), ("target_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) diff --git a/app/core/database/qdrant_points.py b/app/core/database/qdrant_points.py index fd90403..7c36a52 100644 --- a/app/core/database/qdrant_points.py +++ b/app/core/database/qdrant_points.py @@ -1,10 +1,10 @@ """ 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. -VERSION: 1.5.0 +VERSION: 1.5.1 (WP-Fix: Explicit Target Section Support) STATUS: Active DEPENDENCIES: qdrant_client, uuid, os -LAST_ANALYSIS: 2025-12-15 +LAST_ANALYSIS: 2025-12-29 """ from __future__ import annotations import os @@ -46,16 +46,25 @@ def points_for_chunks(prefix: str, chunk_payloads: List[dict], vectors: List[Lis return chunks_col, points 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" 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" 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("source_id", source_id) pl.setdefault("target_id", target_id) + if seq is not None and "seq" not in pl: pl["seq"] = seq + + if target_section is not None: + pl["target_section"] = target_section + return pl def points_for_edges(prefix: str, edge_payloads: List[dict]) -> Tuple[str, List[rest.PointStruct]]: diff --git a/app/core/graph/graph_derive_edges.py b/app/core/graph/graph_derive_edges.py index 284e789..687de6d 100644 --- a/app/core/graph/graph_derive_edges.py +++ b/app/core/graph/graph_derive_edges.py @@ -1,10 +1,14 @@ """ FILE: app/core/graph/graph_derive_edges.py 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 .graph_utils import ( - _get, _edge, _mk_edge_id, _dedupe_seq, + _get, _edge, _mk_edge_id, _dedupe_seq, parse_link_target, PROVENANCE_PRIORITY, load_types_registry, get_edge_defaults_for ) from .graph_extractors import ( @@ -53,47 +57,85 @@ def build_edges_for_note( # Typed & Candidate Pool (WP-15b Integration) typed, rem = extract_typed_relations(raw) - for k, t in typed: - edges.append(_edge(k, "chunk", cid, t, note_id, { - "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", "inline:rel"), + for k, raw_t in typed: + t, sec = parse_link_target(raw_t, note_id) + if not t: continue + + 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"] - })) + } + 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 [] for cand in pool: - t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai") + raw_t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai") + t, sec = parse_link_target(raw_t, note_id) if t: - edges.append(_edge(k, "chunk", cid, t, note_id, { - "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", f"candidate:{p}"), + payload = { + "chunk_id": cid, + "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) - })) + } + if sec: payload["target_section"] = sec + + edges.append(_edge(k, "chunk", cid, t, note_id, payload)) # Callouts & Wikilinks call_pairs, rem2 = extract_callout_relations(rem) - for k, t in call_pairs: - edges.append(_edge(k, "chunk", cid, t, note_id, { - "chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", "callout:edge"), + for k, raw_t in call_pairs: + t, sec = parse_link_target(raw_t, note_id) + if not t: continue + + 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"] - })) + } + if sec: payload["target_section"] = sec + + edges.append(_edge(k, "chunk", cid, t, note_id, payload)) refs = extract_wikilinks(rem2) - for r in refs: - edges.append(_edge("references", "chunk", cid, r, note_id, { - "chunk_id": cid, "ref_text": r, "edge_id": _mk_edge_id("references", cid, r, "chunk", "explicit:wikilink"), + for raw_r in refs: + r, sec = parse_link_target(raw_r, note_id) + if not r: continue + + 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"] - })) + } + if sec: payload["target_section"] = sec + + edges.append(_edge("references", "chunk", cid, r, note_id, payload)) + for rel in defaults: if rel != "references": - edges.append(_edge(rel, "chunk", cid, r, note_id, { - "chunk_id": cid, "edge_id": _mk_edge_id(rel, cid, r, "chunk", f"edge_defaults:{rel}"), + def_payload = { + "chunk_id": cid, + "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"] - })) - refs_all.extend(refs) + } + if sec: def_payload["target_section"] = sec + 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 if include_note_scope_refs: - refs_note = _dedupe_seq((refs_all or []) + (note_level_references or [])) + # refs_all ist jetzt schon gesäubert (nur Targets) + # 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: + if not r: continue edges.append(_edge("references", "note", note_id, r, note_id, { "edge_id": _mk_edge_id("references", note_id, r, "note", "explicit:note_scope"), "provenance": "explicit", "confidence": PROVENANCE_PRIORITY["explicit:note_scope"] @@ -103,10 +145,13 @@ def build_edges_for_note( "provenance": "rule", "confidence": PROVENANCE_PRIORITY["derived:backlink"] })) - unique_map: Dict[Tuple[str, str, str], dict] = {} + # Deduplizierung: Wir nutzen jetzt die EDGE-ID als Schlüssel. + # Da die Edge-ID nun 'variant' (Section) enthält, bleiben unterschiedliche Sections erhalten. + unique_map: Dict[str, dict] = {} for e in edges: - key = (str(e.get("source_id")), str(e.get("target_id")), str(e.get("kind"))) - if key not in unique_map or e.get("confidence", 0) > unique_map[key].get("confidence", 0): - unique_map[key] = e + eid = e["edge_id"] + # Bei Konflikt (gleiche ID = exakt gleiche Kante und Section) gewinnt die höhere Confidence + if eid not in unique_map or e.get("confidence", 0) > unique_map[eid].get("confidence", 0): + unique_map[eid] = e return list(unique_map.values()) \ No newline at end of file diff --git a/app/core/graph/graph_extractors.py b/app/core/graph/graph_extractors.py index 9c1fedf..690e561 100644 --- a/app/core/graph/graph_extractors.py +++ b/app/core/graph/graph_extractors.py @@ -1,25 +1,36 @@ """ FILE: app/core/graph/graph_extractors.py 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 from typing import List, Tuple -_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([a-zA-Z0-9_\-#:. ]+)\]\]") +# Erlaube alle Zeichen außer ']' im Target (fängt Umlaute, Emojis, '&', '#' ab) +_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([^\]]+)\]\]") + _REL_PIPE = re.compile(r"\[\[\s*rel:(?P[a-z_]+)\s*\|\s*(?P[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_SPACE = re.compile(r"\[\[\s*rel:(?P[a-z_]+)\s+(?P[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_TEXT = re.compile(r"rel\s*:\s*(?P[a-z_]+)\s*\[\[\s*(?P[^\]]+?)\s*\]\]", re.IGNORECASE) _CALLOUT_START = re.compile(r"^\s*>\s*\[!edge\]\s*(.*)$", re.IGNORECASE) +# Erkennt "kind: targets..." _REL_LINE = re.compile(r"^(?P[a-z_]+)\s*:\s*(?P.+?)\s*$", re.IGNORECASE) -_WIKILINKS_IN_LINE = re.compile(r"\[\[([^\]]+)\]\]") +# Erkennt reine Typen (z.B. "depends_on" im Header) +_SIMPLE_KIND = re.compile(r"^[a-z_]+$", re.IGNORECASE) -def extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]: - """Extrahiert [[rel:KIND|Target]].""" +def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]: + """ + Findet Inline-Relationen wie [[rel:depends_on Target]]. + Gibt (Liste[(kind, target)], bereinigter_text) zurück. + """ + if not text: return [], "" pairs = [] def _collect(m): - k, t = (m.group("kind") or "").strip().lower(), (m.group("target") or "").strip() - if k and t: pairs.append((k, t)) + k, t = m.group("kind").strip().lower(), m.group("target").strip() + pairs.append((k, t)) return "" text = _REL_PIPE.sub(_collect, text) text = _REL_SPACE.sub(_collect, text) @@ -27,29 +38,90 @@ def extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]: return pairs, text 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 - lines = text.splitlines(); out_pairs, keep_lines, i = [], [], 0 + lines = text.splitlines() + out_pairs = [] + keep_lines = [] + i = 0 + while i < len(lines): - m = _CALLOUT_START.match(lines[i]) + line = lines[i] + m = _CALLOUT_START.match(line) if not m: - keep_lines.append(lines[i]); i += 1; continue - block_lines = [m.group(1)] if m.group(1).strip() else [] + keep_lines.append(line) + i += 1 + 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 while i < len(lines) and lines[i].lstrip().startswith('>'): - block_lines.append(lines[i].lstrip()[1:].lstrip()); i += 1 + # Entferne '>' und führende Leerzeichen + 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: + # 1. Prüfen auf explizites "Kind: Targets" (überschreibt Header-Typ für diese Zeile) mrel = _REL_LINE.match(bl) - if not mrel: continue - kind, targets = mrel.group("kind").strip().lower(), mrel.group("targets") or "" - found = _WIKILINKS_IN_LINE.findall(targets) + if mrel: + line_kind = mrel.group("kind").strip().lower() + targets = mrel.group("targets") + + # Links extrahieren + found = _WIKILINK_RE.findall(targets) + if found: + for t in found: out_pairs.append((line_kind, t.strip())) + else: + # Fallback für kommagetrennten Plaintext + for raw in re.split(r"[,;]", targets): + if raw.strip(): out_pairs.append((line_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: - for t in found: out_pairs.append((kind, t.strip())) - else: - for raw in re.split(r"[,;]", targets): - if raw.strip(): out_pairs.append((kind, raw.strip())) + 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) def extract_wikilinks(text: str) -> List[str]: - """Extrahiert Standard-Wikilinks.""" - return [m.group(1).strip() for m in _WIKILINK_RE.finditer(text or "")] \ No newline at end of file + """Findet Standard-Wikilinks [[Target]] oder [[Alias|Target]].""" + if not text: return [] + return [m.strip() for m in _WIKILINK_RE.findall(text) if m.strip()] \ No newline at end of file diff --git a/app/core/graph/graph_utils.py b/app/core/graph/graph_utils.py index 5f295ed..fbdc51f 100644 --- a/app/core/graph/graph_utils.py +++ b/app/core/graph/graph_utils.py @@ -1,10 +1,11 @@ """ FILE: app/core/graph/graph_utils.py 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 hashlib -from typing import Iterable, List, Optional, Set, Any +from typing import Iterable, List, Optional, Set, Any, Tuple try: import yaml @@ -40,10 +41,19 @@ def _dedupe_seq(seq: Iterable[str]) -> List[str]: seen.add(s); out.append(s) return out -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.""" +def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None, variant: Optional[str] = None) -> str: + """ + 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}" - if rule_id: base += f"|{rule_id}" + if 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() def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, extra: Optional[dict] = None) -> dict: @@ -59,6 +69,27 @@ def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, e if extra: pl.update(extra) 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: """Lädt die YAML-Registry.""" p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml") diff --git a/app/core/ingestion/ingestion_note_payload.py b/app/core/ingestion/ingestion_note_payload.py index d41410b..5d30707 100644 --- a/app/core/ingestion/ingestion_note_payload.py +++ b/app/core/ingestion/ingestion_note_payload.py @@ -3,9 +3,8 @@ FILE: app/core/ingestion/ingestion_note_payload.py DESCRIPTION: Baut das JSON-Objekt für mindnet_notes. FEATURES: - Multi-Hash (body/full) für flexible Change Detection. - - Fix v2.4.4: Integration der zentralen Registry (WP-14) für konsistente Defaults. -VERSION: 2.4.4 -STATUS: Active + - Fix v2.4.5: Präzise Hash-Logik für Profil-Änderungen. + - Integration der zentralen Registry (WP-14). """ from __future__ import annotations from typing import Any, Dict, Tuple, Optional @@ -45,14 +44,23 @@ def _compute_hash(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str: - """Generiert den Hash-Input-String basierend auf Body oder Metadaten.""" - body = str(n.get("body") or "") + """ + Generiert den Hash-Input-String basierend auf Body oder Metadaten. + Fix: Inkludiert nun alle entscheidungsrelevanten Profil-Parameter. + """ + body = str(n.get("body") or "").strip() if mode == "body": return body if mode == "full": fm = n.get("frontmatter") or {} meta_parts = [] - # Sortierte Liste für deterministische Hashes - for k in sorted(["title", "type", "status", "tags", "chunking_profile", "chunk_profile", "retriever_weight"]): + # Wir inkludieren alle Felder, die das Chunking oder Retrieval beeinflussen + # Jede Änderung hier führt nun zwingend zu einem neuen Full-Hash + 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) if val is not None: meta_parts.append(f"{k}:{val}") return f"{'|'.join(meta_parts)}||{body}" @@ -79,11 +87,11 @@ def _cfg_defaults(reg: dict) -> dict: def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: """ Baut das Note-Payload inklusive Multi-Hash und Audit-Validierung. - WP-14: Nutzt nun die zentrale Registry für alle Fallbacks. + WP-14: Nutzt die zentrale Registry für alle Fallbacks. """ n = _as_dict(note) - # Nutzt übergebene Registry oder lädt sie global + # Registry & Context Settings reg = kwargs.get("types_cfg") or load_type_registry() hash_source = kwargs.get("hash_source", "parsed") hash_normalize = kwargs.get("hash_normalize", "canonical") @@ -96,7 +104,6 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: ingest_cfg = reg.get("ingestion_settings", {}) # --- retriever_weight Audit --- - # Priorität: Frontmatter -> Typ-Config -> globale Config -> Env-Var default_rw = float(os.environ.get("MINDNET_DEFAULT_RETRIEVER_WEIGHT", 1.0)) retriever_weight = fm.get("retriever_weight") if retriever_weight is None: @@ -107,14 +114,13 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: retriever_weight = default_rw # --- chunk_profile Audit --- - # Nutzt nun primär die ingestion_settings aus der Registry chunk_profile = fm.get("chunking_profile") or fm.get("chunk_profile") if chunk_profile is None: chunk_profile = cfg_type.get("chunking_profile") or cfg_type.get("chunk_profile") if chunk_profile is None: chunk_profile = ingest_cfg.get("default_chunk_profile", cfg_def.get("chunking_profile", "sliding_standard")) - # --- edge_defaults --- + # --- edge_defaults Audit --- edge_defaults = fm.get("edge_defaults") if edge_defaults is None: edge_defaults = cfg_type.get("edge_defaults", cfg_def.get("edge_defaults", [])) @@ -138,21 +144,24 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: } # --- MULTI-HASH --- - # Generiert Hashes für Change Detection + # Generiert Hashes für Change Detection (WP-15b) for mode in ["body", "full"]: content = _get_hash_source_content(n, mode) payload["hashes"][f"{mode}:{hash_source}:{hash_normalize}"] = _compute_hash(content) - # Metadaten Anreicherung + # Metadaten Anreicherung (Tags, Aliases, Zeitstempel) tags = fm.get("tags") or fm.get("keywords") or n.get("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"): v = fm.get(k) or n.get(k) if v: payload[k] = str(v) - if n.get("body"): payload["fulltext"] = str(n["body"]) + if n.get("body"): + payload["fulltext"] = str(n["body"]) # Final JSON Validation Audit json.loads(json.dumps(payload, ensure_ascii=False)) diff --git a/app/core/ingestion/ingestion_processor.py b/app/core/ingestion/ingestion_processor.py index 8d6114d..22ae909 100644 --- a/app/core/ingestion/ingestion_processor.py +++ b/app/core/ingestion/ingestion_processor.py @@ -4,8 +4,8 @@ DESCRIPTION: Der zentrale IngestionService (Orchestrator). WP-14: Modularisierung der Datenbank-Ebene (app.core.database). WP-15b: Two-Pass Workflow mit globalem Kontext-Cache. WP-20/22: Cloud-Resilienz und Content-Lifecycle integriert. - AUDIT v2.13.10: Umstellung auf app.core.database Infrastruktur. -VERSION: 2.13.10 + AUDIT v2.13.12: Synchronisierung der Profil-Auflösung mit Registry-Defaults. +VERSION: 2.13.12 STATUS: Active """ import logging @@ -60,6 +60,7 @@ class IngestionService: self.embedder = EmbeddingsClient() self.llm = LLMService() + # Festlegen, welcher Hash für die Change-Detection maßgeblich ist self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE self.batch_cache: Dict[str, NoteContext] = {} # WP-15b LocalBatchCache @@ -130,12 +131,18 @@ class IngestionService: ) 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) + + # Prüfung gegen den konfigurierten Hash-Modus (body vs. full) check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" old_hash = (old_payload or {}).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) + + # 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): return {**result, "status": "unchanged", "note_id": note_id} @@ -146,36 +153,49 @@ class IngestionService: try: body_text = getattr(parsed, "body", "") or "" 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) enable_smart = chunk_cfg.get("enable_smart_edge_allocation", False) - # WP-15b: Chunker-Aufruf bereitet Candidate-Pool vor + # WP-15b: Chunker-Aufruf bereitet den Candidate-Pool pro Chunk vor. + # assemble_chunks führt intern auch die Propagierung durch. chunks = await assemble_chunks(note_id, body_text, note_type, config=chunk_cfg) + + # Semantische Kanten-Validierung (Smart Edge Allocation) for ch in chunks: filtered = [] for cand in getattr(ch, "candidate_pool", []): - # WP-15b: Nur global_pool Kandidaten erfordern binäre Validierung + # Nur global_pool Kandidaten (aus dem Pool am Ende) erfordern KI-Validierung 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): filtered.append(cand) else: + # Explizite Kanten (Wikilinks/Callouts) werden ungeprüft übernommen filtered.append(cand) ch.candidate_pool = filtered - # Payload-Erstellung via interne Module + # Payload-Erstellung für die Chunks chunk_pls = make_chunk_payloads( fm, note_pl["path"], chunks, file_path=file_path, 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 [] - # Kanten-Aggregation + # Aggregation aller finalen Kanten (Edges) edges = build_edges_for_note( note_id, chunk_pls, note_level_references=note_pl.get("references", []), include_note_scope_refs=note_scope_refs ) + + # Kanten-Typen via Registry validieren/auflösen for e in edges: e["kind"] = edge_registry.resolve( e.get("kind", "related_to"), @@ -184,16 +204,20 @@ class IngestionService: ) # 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: 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) upsert_batch(self.client, n_name, n_pts) + # Speichern der Chunks if chunk_pls and vecs: c_pts = points_for_chunks(self.prefix, chunk_pls, vecs)[1] upsert_batch(self.client, f"{self.prefix}_chunks", c_pts) + # Speichern der Kanten if edges: e_pts = points_for_edges(self.prefix, edges)[1] upsert_batch(self.client, f"{self.prefix}_edges", e_pts) @@ -217,4 +241,5 @@ class IngestionService: with open(target_path, "w", encoding="utf-8") as f: f.write(markdown_content) 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) \ No newline at end of file diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index fc2d3e2..2601d85 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -1,10 +1,10 @@ """ 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"). -VERSION: 2.6.1 (Fix: Anchor-Link & Fragment Resolution) +VERSION: 2.6.0 STATUS: Active DEPENDENCIES: qdrant_client, streamlit_agraph, ui_config, re -LAST_ANALYSIS: 2025-12-28 +LAST_ANALYSIS: 2025-12-15 """ import re @@ -24,7 +24,6 @@ class GraphExplorerService: self.chunks_col = f"{self.prefix}_chunks" self.edges_col = f"{self.prefix}_edges" self._note_cache = {} - self._ref_resolution_cache = {} def get_note_with_full_content(self, note_id): """ @@ -38,7 +37,8 @@ class GraphExplorerService: # 2. Volltext aus Chunks bauen full_text = self._fetch_full_text_stitched(note_id) - # 3. Ergebnis kombinieren (Kopie zurückgeben) + # 3. Ergebnis kombinieren (Wir überschreiben das 'fulltext' Feld mit dem frischen Stitching) + # Wir geben eine Kopie zurück, um den Cache nicht zu verfälschen complete_note = meta.copy() if full_text: complete_note['fulltext'] = full_text @@ -61,7 +61,7 @@ class GraphExplorerService: # Initialset für Suche level_1_ids = {center_note_id} - # Suche Kanten für Center (L1) inkl. Titel für Anchor-Suche + # Suche Kanten für Center (L1) l1_edges = self._find_connected_edges([center_note_id], center_note.get("title")) for edge_data in l1_edges: @@ -84,6 +84,7 @@ class GraphExplorerService: if center_note_id in nodes_dict: orig_title = nodes_dict[center_note_id].title 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}..." # B. Previews für alle Nachbarn holen (Batch) @@ -103,6 +104,8 @@ class GraphExplorerService: prov = data['provenance'] color = get_edge_color(kind) is_smart = (prov != "explicit" and prov != "rule") + + # Label Logik label_text = kind if show_labels else " " final_edges.append(Edge( @@ -113,11 +116,15 @@ class GraphExplorerService: return list(nodes_dict.values()), final_edges def _clean_markdown(self, text): - """Entfernt Markdown-Sonderzeichen für saubere Tooltips.""" + """Entfernt Markdown-Sonderzeichen für saubere Tooltips im Browser.""" if not text: return "" + # Entferne Header Marker (## ) text = re.sub(r'#+\s', '', text) + # Entferne Bold/Italic (** oder *) text = re.sub(r'\*\*|__|\*|_', '', text) + # Entferne Links [Text](Url) -> Text text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) + # Entferne Wikilinks [[Link]] -> Link text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text) return text @@ -127,47 +134,52 @@ class GraphExplorerService: scroll_filter = models.Filter( 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) + # Sortieren nach 'ord' (Reihenfolge im Dokument) 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) except: return "Fehler beim Laden des Volltexts." + def _fetch_previews_for_nodes(self, node_ids): - """ - 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 {} + """Holt Batch-weise den ersten Chunk für eine Liste von Nodes.""" + if not node_ids: return {} previews = {} try: - scroll_filter = models.Filter( - 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 + scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))]) + # Limit = Anzahl Nodes * 3 (Puffer) chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True) for c in chunks: nid = c.payload.get("note_id") - # Wir nehmen den ersten gefundenen Chunk + # Nur den ersten gefundenen Chunk pro Note nehmen if nid and nid not in previews: previews[nid] = c.payload.get("window") or c.payload.get("text") or "" - except Exception: - pass + except: pass return previews def _find_connected_edges(self, note_ids, note_title=None): """ - 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. + Findet eingehende und ausgehende Kanten. + + WICHTIG: target_id enthält nur den Titel (ohne #Abschnitt). + target_section ist ein separates Feld für Abschnitt-Informationen. """ results = [] if not note_ids: return results - # 1. AUSGEHENDE KANTEN (Outgoing) - # Suche über 'note_id' als Besitzer der Kante. + # 1. OUTGOING EDGES (Der "Owner"-Fix) + # Wir suchen Kanten, die im Feld 'note_id' (Owner) eine unserer Notizen haben. + # Das findet ALLE ausgehenden Kanten, egal ob sie an einem Chunk oder der Note hängen. out_filter = models.Filter(must=[ models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)), models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES})) @@ -175,71 +187,79 @@ class GraphExplorerService: res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=2000, with_payload=True) results.extend(res_out) - # 2. EINGEHENDE KANTEN (Incoming) - # Suche über target_id (Ziel der Kante). + # 2. INCOMING EDGES (Ziel = Chunk ID, Note ID oder Titel) + # WICHTIG: target_id enthält nur den Titel, target_section ist separat - # Sammele alle Chunk-IDs für exakte Treffer auf Segment-Ebene + # Chunk IDs der aktuellen Notes holen 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) chunk_ids = [c.id for c in chunks] - should_conditions = [] + shoulds = [] + # Case A: Edge zeigt auf einen unserer Chunks if 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))) - - # TITEL-BASIERTE SUCHE (Inkl. Anker-Fix) - titles_to_check = [] - if note_title: - titles_to_check.append(note_title) - # Aliase laden für robuste Verlinkung - for nid in note_ids: - note = self._fetch_note_cached(nid) - if note: - aliases = note.get("aliases", []) - if isinstance(aliases, str): aliases = [aliases] - titles_to_check.extend([a for a in aliases if a not in titles_to_check]) - - # Exakte Titel-Matches hinzufügen - for t in titles_to_check: - should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=t))) + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids))) - if should_conditions: + # Case B: Edge zeigt direkt auf unsere Note ID + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) + + # 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: + titles_to_search.append(note_title) + + # Lade auch Titel aus den Notes selbst (falls note_title nicht übergeben wurde) + for nid in note_ids: + note = self._fetch_note_cached(nid) + 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", []) + if isinstance(aliases, str): + aliases = [aliases] + 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 + # target_id enthält nur den Titel, daher reicht MatchValue + for title in titles_to_search: + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=title))) + + if shoulds: in_filter = models.Filter( must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))], - should=should_conditions + should=shoulds ) res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=2000, with_payload=True) 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 def _find_connected_edges_batch(self, note_ids): - """Wrapper für die Suche in tieferen Ebenen des Graphen.""" - first_note = self._fetch_note_cached(note_ids[0]) if note_ids else None - title = first_note.get("title") if first_note else None - return self._find_connected_edges(note_ids, note_title=title) + """ + Wrapper für Level 2 Suche. + Lädt Titel der ersten Note für Titel-basierte Suche. + """ + 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): """ - Verarbeitet eine rohe Kante, löst Quell- und Ziel-Referenzen auf - und fügt sie den Dictionaries für den Graphen hinzu. + Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries 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: return None, None @@ -250,10 +270,13 @@ class GraphExplorerService: kind = payload.get("kind") provenance = payload.get("provenance", "explicit") + # Prüfe, ob beide Referenzen vorhanden sind if not src_ref or not tgt_ref: return None, None - # IDs zu Notes auflösen (Hier greift der Fragment-Fix) + # IDs zu Notes auflösen + # 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) tgt_note = self._resolve_note_from_ref(tgt_ref) @@ -261,118 +284,159 @@ class GraphExplorerService: src_id = src_note.get('note_id') tgt_id = tgt_note.get('note_id') - if src_id and tgt_id and src_id != tgt_id: - # Knoten zum Set hinzufügen + # Prüfe, ob beide IDs vorhanden sind + if not src_id or not tgt_id: + 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, tgt_note, level=current_depth) - # Kante registrieren (Deduplizierung) + # Kante hinzufügen (mit Deduplizierung) key = (src_id, tgt_id) existing = unique_edges.get(key) - is_current_explicit = (provenance in ["explicit", "rule"]) should_update = True - + # Bevorzuge explizite Kanten vor Smart Kanten + is_current_explicit = (provenance in ["explicit", "rule"]) if existing: is_existing_explicit = (existing.get('provenance', '') in ["explicit", "rule"]) if is_existing_explicit and not is_current_explicit: should_update = False 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 None, None def _fetch_note_cached(self, 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( - collection_name=self.notes_col, - scroll_filter=models.Filter(must=[ - models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id)) - ]), - limit=1, with_payload=True - ) - if res and res[0].payload: - payload = res[0].payload - self._note_cache[note_id] = payload - return payload - except Exception: - pass + if note_id in self._note_cache: return self._note_cache[note_id] + res, _ = self.client.scroll( + collection_name=self.notes_col, + scroll_filter=models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]), + limit=1, with_payload=True + ) + if res: + self._note_cache[note_id] = res[0].payload + return res[0].payload return None def _resolve_note_from_ref(self, ref_str): """ - Löst eine Referenz (ID, Chunk-ID oder Wikilink mit Anker) auf eine Note auf. - Bereinigt Anker (#) vor der Suche. + Löst eine Referenz zu einer Note Payload auf. + + 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 - if ref_str in self._ref_resolution_cache: - 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: - res, _ = self.client.scroll( - 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: - payload = res[0].payload - self._ref_resolution_cache[ref_str] = payload - return payload - except Exception: - pass - - # 3. Versuch: Auflösung über Chunks + # Fall A: Enthält # (kann Chunk-ID oder Titel#Abschnitt sein) if "#" in ref_str: try: - 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: - self._ref_resolution_cache[ref_str] = note - return note - except Exception: + # Versuch 1: Chunk ID direkt (Format: note_id#c01) + res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True) + if res and res[0].payload: + note_id = res[0].payload.get("note_id") + if note_id: + return self._fetch_note_cached(note_id) + except: pass + + # Versuch 2: NoteID#Section (Hash abtrennen und als Note-ID versuchen) + # z.B. "20250101-meine-note#Abschnitt" -> "20250101-meine-note" + possible_note_id = ref_str.split("#")[0].strip() + note = self._fetch_note_cached(possible_note_id) + if note: + return note + + # Versuch 3: Titel#Abschnitt (Hash abtrennen und als Titel suchen) + # 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 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") - if not nid or nid in node_dict: - return + if not nid or nid in node_dict: return ntype = note_payload.get("type", "default") - color = GRAPH_COLORS.get(ntype, GRAPH_COLORS.get("default", "#8395a7")) + color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) + + # Basis-Tooltip (wird später erweitert) tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}" - size = 45 if level == 0 else (25 if level == 1 else 15) + if level == 0: size = 45 + elif level == 1: size = 25 + else: size = 15 + node_dict[nid] = Node( id=nid, label=note_payload.get('title', nid), diff --git a/app/models/dto.py b/app/models/dto.py index 7d4cb64..4c6dd67 100644 --- a/app/models/dto.py +++ b/app/models/dto.py @@ -1,10 +1,10 @@ """ FILE: app/models/dto.py DESCRIPTION: Pydantic-Modelle (DTOs) für Request/Response Bodies. Definiert das API-Schema. -VERSION: 0.6.6 (WP-22 Debug & Stability Update) +VERSION: 0.6.7 (WP-Fix: Target Section Support) STATUS: Active DEPENDENCIES: pydantic, typing, uuid -LAST_ANALYSIS: 2025-12-18 +LAST_ANALYSIS: 2025-12-29 """ from __future__ import annotations @@ -43,6 +43,7 @@ class EdgeDTO(BaseModel): direction: Literal["out", "in", "undirected"] = "out" provenance: Optional[Literal["explicit", "rule", "smart", "structure"]] = "explicit" confidence: float = 1.0 + target_section: Optional[str] = None # Neu: Speichert den Anker (z.B. #Abschnitt) # --- Request Models --- diff --git a/docs/00_General/00_glossary.md b/docs/00_General/00_glossary.md index e14ead9..3ff270b 100644 --- a/docs/00_General/00_glossary.md +++ b/docs/00_General/00_glossary.md @@ -2,7 +2,7 @@ doc_type: glossary audience: all status: active -version: 2.8.1 +version: 2.9.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." --- @@ -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. * **Chunk:** Ein Textabschnitt einer Note. Die technische Sucheinheit (Vektor). -* **Edge:** Eine gerichtete Verbindung zwischen zwei Knoten. Wird in WP-22 durch die Registry validiert. +* **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. * **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`). @@ -47,4 +47,7 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C * **Two-Pass Workflow (WP-15b):** Optimiertes Ingestion-Verfahren: * **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. -* **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. \ No newline at end of file +* **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. \ No newline at end of file diff --git a/docs/01_User_Manual/01_knowledge_design.md b/docs/01_User_Manual/01_knowledge_design.md index ed2f3b3..885664e 100644 --- a/docs/01_User_Manual/01_knowledge_design.md +++ b/docs/01_User_Manual/01_knowledge_design.md @@ -3,7 +3,7 @@ doc_type: user_manual audience: user, author scope: vault, markdown, schema status: active -version: 2.8.0 +version: 2.9.1 context: "Regelwerk für das Erstellen von Notizen im Vault. Die 'Source of Truth' für Autoren." --- @@ -208,6 +208,12 @@ Dies ist die **mächtigste** Methode. Du sagst dem System explizit, **wie** Ding > "Daher [[rel:depends_on Qdrant]]." > "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:** * `depends_on`: Hängt ab von / Benötigt. * `blocks`: Blockiert oder gefährdet (z.B. Risiko -> Projekt). @@ -226,6 +232,12 @@ Für Zusammenfassungen am Ende einer Notiz, oder eines Absatzes: > [[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!] In Mindnet musst du Kanten **nicht** manuell in beide Richtungen pflegen. Der **Edger** übernimmt die Paarbildung automatisch im Hintergrund. diff --git a/docs/02_concepts/02_concept_graph_logic.md b/docs/02_concepts/02_concept_graph_logic.md index 6c08e4c..b7f89a7 100644 --- a/docs/02_concepts/02_concept_graph_logic.md +++ b/docs/02_concepts/02_concept_graph_logic.md @@ -3,7 +3,7 @@ doc_type: concept audience: architect, product_owner scope: graph, logic, provenance status: active -version: 2.7.0 +version: 2.9.1 context: "Fachliche Beschreibung des Wissensgraphen: Knoten, Kanten, Provenance, Matrix-Logik und WP-22 Scoring-Prinzipien." --- @@ -118,8 +118,30 @@ Der Intent-Router injiziert spezifische Multiplikatoren für kanonische Typen: --- -## 6. Idempotenz & Konsistenz +## 6. Section-basierte Links & Multigraph-Support + +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. * **Stabile IDs:** Deterministische IDs verhindern Duplikate bei Re-Imports. -* **Deduplizierung:** Kanten werden anhand ihrer Identität erkannt. Die "stärkere" Provenance gewinnt. \ No newline at end of file +* **Deduplizierung:** Kanten werden anhand ihrer Identität (inkl. Section) erkannt. Die "stärkere" Provenance gewinnt. +* **Format-agnostische Erkennung:** Kanten werden unabhängig vom Format (Inline, Callout, Wikilink) erkannt, um Dopplungen zu vermeiden. \ No newline at end of file diff --git a/docs/03_Technical_References/03_tech_api_reference.md b/docs/03_Technical_References/03_tech_api_reference.md index 198f542..6e1d856 100644 --- a/docs/03_Technical_References/03_tech_api_reference.md +++ b/docs/03_Technical_References/03_tech_api_reference.md @@ -144,8 +144,11 @@ Lädt den Subgraphen um eine Note herum. "kind": "depends_on", "source": "uuid", "target": "uuid", + "target_section": "P3 – Disziplin", // Optional: Abschnitts-Name bei Deep-Links "weight": 1.4, - "direction": "out" + "direction": "out", + "provenance": "explicit", + "confidence": 1.0 } ], "stats": { diff --git a/docs/03_Technical_References/03_tech_data_model.md b/docs/03_Technical_References/03_tech_data_model.md index 6492522..320705a 100644 --- a/docs/03_Technical_References/03_tech_data_model.md +++ b/docs/03_Technical_References/03_tech_data_model.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, architect scope: database, qdrant, schema status: active -version: 2.8.0 +version: 2.9.1 context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen. Berücksichtigt WP-14 Modularisierung und WP-15b Multi-Hashes." --- @@ -96,15 +96,19 @@ Es müssen Payload-Indizes für folgende Felder existieren: ## 4. Edge Payload (`mindnet_edges`) -Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Tracking. +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. **JSON-Schema:** ```json { - "edge_id": "string (keyword)", // Deterministischer Hash aus (src, dst, kind) + "edge_id": "string (keyword)", // Deterministischer Hash aus (src, dst, kind, variant) + // variant = target_section (erlaubt Multigraph für Sections) "source_id": "string (keyword)", // Chunk-ID (Start) "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') "scope": "string (keyword)", // Immer 'chunk' (Legacy-Support: 'note') "note_id": "string (keyword)", // Owner Note ID (Ursprung der Kante) @@ -116,10 +120,16 @@ 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:** Es müssen Payload-Indizes für folgende Felder existieren: * `source_id` * `target_id` +* `target_section` (neu: Keyword-Index für Section-basierte Filterung) * `kind` * `scope` * `note_id` diff --git a/docs/03_Technical_References/03_tech_frontend.md b/docs/03_Technical_References/03_tech_frontend.md index 5d61203..0ea3c64 100644 --- a/docs/03_Technical_References/03_tech_frontend.md +++ b/docs/03_Technical_References/03_tech_frontend.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, frontend_architect scope: architecture, graph_viz, state_management status: active -version: 2.7.0 +version: 2.9.1 context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors." --- diff --git a/docs/03_Technical_References/03_tech_ingestion_pipeline.md b/docs/03_Technical_References/03_tech_ingestion_pipeline.md index ce199dd..4d29518 100644 --- a/docs/03_Technical_References/03_tech_ingestion_pipeline.md +++ b/docs/03_Technical_References/03_tech_ingestion_pipeline.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, devops scope: backend, ingestion, smart_edges, edge_registry, modularization status: active -version: 2.9.0 +version: 2.13.12 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,9 +31,10 @@ Der Prozess ist **asynchron**, **idempotent** und wird nun in zwei logische Durc 4. **Edge Registry Initialisierung (WP-22):** * Laden der Singleton-Instanz der `EdgeRegistry`. * Validierung der Vokabular-Datei unter `MINDNET_VOCAB_PATH`. -5. **Config Resolution (WP-14):** +5. **Config Resolution (WP-14 / v2.13.12):** * Bestimmung von `chunking_profile` und `retriever_weight` via zentraler `TypeRegistry`. * **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):** * Erstellung von Kurz-Zusammenfassungen für jede Note. * Speicherung im `batch_cache` als Referenzrahmen für die spätere Kantenvalidierung. @@ -126,19 +127,44 @@ Das Chunking ist profilbasiert und bezieht seine Konfiguration dynamisch aus der | `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte (Projekte). | | `structured_smart_edges` | `by_heading` | `strict: false` | Strukturierte Texte. | -### 3.2 Die `by_heading` Logik (v2.9 Hybrid) +### 3.2 Die `by_heading` Logik (v3.9.9 Atomic Section Logic) -Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Sie unterstützt ein "Safety Net" gegen zu große Chunks. +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. -* **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split). -* **Modus "Strict" (`strict_heading_split: true`):** - * Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk. - * *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. - * *Safety Net:* Auch hier greift das `max` Token Limit. +**Kernprinzipien:** +* **Atomic Section Logic:** Überschriften und deren Inhalte werden als atomare Einheiten behandelt und nicht über Chunk-Grenzen hinweg getrennt. +* **H1-Context Preservation:** Der Dokumenttitel (H1) wird zuverlässig als Breadcrumb in das Embedding-Fenster (`window`) aller Chunks injiziert. +* **Signature Alignment:** Parameter-Synchronisierung zwischen Orchestrator und Strategien (`context_prefix` statt `doc_title`). + +**Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split). + +**Modus "Strict" (`strict_heading_split: true`):** +* Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk. +* *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. --- diff --git a/docs/04_Operations/04_admin_operations.md b/docs/04_Operations/04_admin_operations.md index 3797ebc..96f7b1b 100644 --- a/docs/04_Operations/04_admin_operations.md +++ b/docs/04_Operations/04_admin_operations.md @@ -279,4 +279,11 @@ python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes # 2. Neu importieren (Force Hash recalculation) python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force -``` \ No newline at end of file +``` + +**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. \ No newline at end of file diff --git a/docs/99_Archive/WP4d_merge_commit.md b/docs/99_Archive/WP4d_merge_commit.md new file mode 100644 index 0000000..298c055 --- /dev/null +++ b/docs/99_Archive/WP4d_merge_commit.md @@ -0,0 +1,99 @@ +# 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] +``` + diff --git a/docs/99_Archive/WP4d_release_notes.md b/docs/99_Archive/WP4d_release_notes.md new file mode 100644 index 0000000..4653ceb --- /dev/null +++ b/docs/99_Archive/WP4d_release_notes.md @@ -0,0 +1,236 @@ +# 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) +