From cc258008dc212f9286195e7bb4b50e632421c59c Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 25 Jan 2026 16:27:09 +0100 Subject: [PATCH] Refactor provenance handling in EdgeDTO and graph utilities - Updated provenance priorities and introduced a mapping from internal provenance values to EdgeDTO-compliant literals. - Added a new function `normalize_provenance` to standardize internal provenance strings. - Enhanced the `_edge` function to include an `is_internal` flag and provenance normalization. - Modified the `EdgeDTO` model to include a new `source_hint` field for detailed provenance information and an `is_internal` flag for intra-note edges. - Reduced the provenance options in `EdgeDTO` to valid literals, improving data integrity. --- app/core/chunking/chunking_models.py | 22 +- app/core/chunking/chunking_parser.py | 92 +- app/core/chunking/chunking_strategies.py | 103 +- app/core/graph/graph_utils.py | 99 +- app/core/ingestion/ingestion_chunk_payload.py | 34 +- app/models/dto.py | 20 +- docs/05_Development/05_WP26_Manual_Testing.md | 284 ++++ .../06_LH_Section_Types_Intra_Note_Edges.md | 1470 +++++++++++++++++ tests/test_wp26_section_types.py | 265 +++ 9 files changed, 2337 insertions(+), 52 deletions(-) create mode 100644 docs/05_Development/05_WP26_Manual_Testing.md create mode 100644 docs/06_Roadmap/06_LH_Section_Types_Intra_Note_Edges.md create mode 100644 tests/test_wp26_section_types.py diff --git a/app/core/chunking/chunking_models.py b/app/core/chunking/chunking_models.py index 1cf3fd0..8021498 100644 --- a/app/core/chunking/chunking_models.py +++ b/app/core/chunking/chunking_models.py @@ -1,13 +1,17 @@ """ FILE: app/core/chunking/chunking_models.py DESCRIPTION: Datenklassen für das Chunking-System. +WP-26 v1.0: Erweiterung um section_type für typ-spezifische Sektionen. """ from dataclasses import dataclass, field from typing import List, Dict, Optional, Any @dataclass class RawBlock: - """Repräsentiert einen logischen Block aus dem Markdown-Parsing.""" + """ + Repräsentiert einen logischen Block aus dem Markdown-Parsing. + WP-26 v1.0: Erweitert um section_type für typ-spezifische Sektionen. + """ kind: str text: str level: Optional[int] @@ -15,10 +19,17 @@ class RawBlock: section_title: Optional[str] exclude_from_chunking: bool = False # WP-24c v4.2.0: Flag für Edge-Zonen, die nicht gechunkt werden sollen is_meta_content: bool = False # WP-24c v4.2.6: Flag für Meta-Content (Callouts), der später entfernt wird + # WP-26 v1.0: Section-Type für typ-spezifische Sektionen + section_type: Optional[str] = None # z.B. "insight", "decision", "experience" + # WP-26 v1.0: Block-ID für Intra-Note-Links (z.B. "^sit" aus "## Situation ^sit") + block_id: Optional[str] = None @dataclass class Chunk: - """Das finale Chunk-Objekt für Embedding und Graph-Speicherung.""" + """ + Das finale Chunk-Objekt für Embedding und Graph-Speicherung. + WP-26 v1.0: Erweitert um section_type für effektiven Typ. + """ id: str note_id: str index: int @@ -30,4 +41,9 @@ class Chunk: neighbors_prev: Optional[str] neighbors_next: Optional[str] candidate_pool: List[Dict[str, Any]] = field(default_factory=list) - suggested_edges: Optional[List[str]] = None \ No newline at end of file + suggested_edges: Optional[List[str]] = None + # WP-26 v1.0: Section-Type für typ-spezifische Sektionen + # Wenn gesetzt, wird dieser als "effektiver Typ" verwendet statt note_type + section_type: Optional[str] = None + # WP-26 v1.0: Block-ID für Intra-Note-Links + block_id: Optional[str] = None \ No newline at end of file diff --git a/app/core/chunking/chunking_parser.py b/app/core/chunking/chunking_parser.py index a26eefd..0bb6fd4 100644 --- a/app/core/chunking/chunking_parser.py +++ b/app/core/chunking/chunking_parser.py @@ -5,16 +5,28 @@ DESCRIPTION: Zerlegt Markdown in logische Einheiten (RawBlocks). Stellt die Funktion parse_edges_robust zur Verfügung. WP-24c v4.2.0: Identifiziert Edge-Zonen und markiert sie für Chunking-Ausschluss. WP-24c v4.2.5: Callout-Exclusion - Callouts werden als separate RawBlocks identifiziert und ausgeschlossen. + WP-26 v1.0: Section-Type-Erkennung via [!section]-Callouts und automatische Section-Erkennung. """ import re import os +import logging from typing import List, Tuple, Set, Dict, Any, Optional from .chunking_models import RawBlock from .chunking_utils import extract_frontmatter_from_text +logger = logging.getLogger(__name__) + _WS = re.compile(r'\s+') _SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])') +# WP-26 v1.0: Pattern für [!section]-Callouts +# Matches: > [!section] type-name +_SECTION_CALLOUT_PATTERN = re.compile(r'^\s*>\s*\[!section\]\s*(\w+)', re.IGNORECASE) + +# WP-26 v1.0: Pattern für Block-IDs in Überschriften +# Matches: ## Titel ^block-id +_BLOCK_ID_PATTERN = re.compile(r'\^([a-zA-Z0-9_-]+)\s*$') + def split_sentences(text: str) -> list[str]: """Teilt Text in Sätze auf unter Berücksichtigung deutscher Interpunktion.""" text = _WS.sub(' ', text.strip()) @@ -27,12 +39,18 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: Zerlegt Text in logische Einheiten (RawBlocks), inklusive H1-H6. WP-24c v4.2.0: Identifiziert Edge-Zonen (LLM-Validierung & Note-Scope) und markiert sie für Chunking-Ausschluss. WP-24c v4.2.6: Callouts werden mit is_meta_content=True markiert (werden gechunkt, aber später entfernt). + WP-26 v1.0: Section-Type-Erkennung via [!section]-Callouts und automatische Section-Erkennung. """ blocks = [] h1_title = "Dokument" section_path = "/" current_section_title = None + # WP-26 v1.0: State-Machine für Section-Type-Tracking + current_section_type: Optional[str] = None # Aktueller Section-Type (oder None für note_type Fallback) + section_introduced_at_level: Optional[int] = None # Ebene, auf der erste Section eingeführt wurde + current_block_id: Optional[str] = None # Block-ID der aktuellen Sektion + # Frontmatter entfernen fm, text_without_fm = extract_frontmatter_from_text(md_text) @@ -70,8 +88,9 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: buffer = [] # WP-24c v4.2.5: Callout-Erkennung (auch verschachtelt: >>) - # Regex für Callouts: >\s*[!edge] oder >\s*[!abstract] (auch mit mehreren >) - callout_pattern = re.compile(r'^\s*>{1,}\s*\[!(edge|abstract)\]', re.IGNORECASE) + # WP-26 v1.0: Erweitert um [!section]-Callouts + # Regex für Callouts: >\s*[!edge], >\s*[!abstract], >\s*[!section] (auch mit mehreren >) + callout_pattern = re.compile(r'^\s*>{1,}\s*\[!(edge|abstract|section)\]', re.IGNORECASE) # WP-24c v4.2.5: Markiere verarbeitete Zeilen, um sie zu überspringen processed_indices = set() @@ -86,13 +105,39 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: # Prüfe, ob diese Zeile ein Callout startet callout_match = callout_pattern.match(line) if callout_match: + callout_type = callout_match.group(1).lower() # "edge", "abstract", oder "section" + + # WP-26 v1.0: [!section] Callout-Behandlung + if callout_type == "section": + # Extrahiere Section-Type aus dem Callout + section_match = _SECTION_CALLOUT_PATTERN.match(line) + if section_match: + new_section_type = section_match.group(1).lower() + current_section_type = new_section_type + + # Tracke die Ebene, auf der die erste Section eingeführt wurde + # Wir nehmen die Ebene der letzten Überschrift (section_path basiert) + if section_introduced_at_level is None: + # Bestimme Ebene aus section_path + # "/" = H1, "/Title" = H2, "/Title/Sub" = H3, etc. + path_depth = section_path.count('/') if section_path else 1 + section_introduced_at_level = max(1, path_depth + 1) + + logger.debug(f"WP-26: Section-Type erkannt: '{new_section_type}' bei '{current_section_title}' (Level: {section_introduced_at_level})") + + # [!section] Callout wird nicht als Block hinzugefügt (ist nur Metadaten) + processed_indices.add(i) + continue + # Vorherigen Text-Block abschließen if buffer: content = "\n".join(buffer).strip() if content: blocks.append(RawBlock( "paragraph", content, None, section_path, current_section_title, - exclude_from_chunking=in_exclusion_zone + exclude_from_chunking=in_exclusion_zone, + section_type=current_section_type, + block_id=current_block_id )) buffer = [] @@ -120,7 +165,9 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: blocks.append(RawBlock( "callout", callout_content, None, section_path, current_section_title, exclude_from_chunking=in_exclusion_zone, # Nur Edge-Zonen werden ausgeschlossen - is_meta_content=True # WP-24c v4.2.6: Markierung für spätere Entfernung + is_meta_content=True, # WP-24c v4.2.6: Markierung für spätere Entfernung + section_type=current_section_type, + block_id=current_block_id )) continue @@ -133,13 +180,32 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: if content: blocks.append(RawBlock( "paragraph", content, None, section_path, current_section_title, - exclude_from_chunking=in_exclusion_zone + exclude_from_chunking=in_exclusion_zone, + section_type=current_section_type, + block_id=current_block_id )) buffer = [] level = len(heading_match.group(1)) title = heading_match.group(2).strip() + # WP-26 v1.0: Block-ID aus Überschrift extrahieren (z.B. "## Titel ^block-id") + block_id_match = _BLOCK_ID_PATTERN.search(title) + if block_id_match: + current_block_id = block_id_match.group(1) + # Entferne Block-ID aus dem Titel für saubere Anzeige + title = _BLOCK_ID_PATTERN.sub('', title).strip() + else: + current_block_id = None + + # WP-26 v1.0: Section-Type State-Machine + # Wenn eine Section eingeführt wurde und wir auf gleicher oder höherer Ebene sind: + # -> Automatisch neue Section erkennen (FA-02b) + if section_introduced_at_level is not None and level <= section_introduced_at_level: + # Neue Überschrift auf gleicher oder höherer Ebene -> Reset auf None (note_type Fallback) + current_section_type = None + logger.debug(f"WP-26: Neue Section erkannt bei H{level} '{title}' -> Reset auf note_type") + # WP-24c v4.2.0: Prüfe, ob dieser Header eine Edge-Zone startet is_llm_validation_zone = ( level == llm_validation_level and @@ -170,7 +236,9 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: # Die Überschrift selbst als regulären Block hinzufügen (auch markiert, wenn in Zone) blocks.append(RawBlock( "heading", stripped, level, section_path, current_section_title, - exclude_from_chunking=in_exclusion_zone + exclude_from_chunking=in_exclusion_zone, + section_type=current_section_type, + block_id=current_block_id )) continue @@ -181,13 +249,17 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: if content: blocks.append(RawBlock( "paragraph", content, None, section_path, current_section_title, - exclude_from_chunking=in_exclusion_zone + exclude_from_chunking=in_exclusion_zone, + section_type=current_section_type, + block_id=current_block_id )) buffer = [] if stripped == "---": blocks.append(RawBlock( "separator", "---", None, section_path, current_section_title, - exclude_from_chunking=in_exclusion_zone + exclude_from_chunking=in_exclusion_zone, + section_type=current_section_type, + block_id=current_block_id )) else: buffer.append(line) @@ -197,7 +269,9 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: if content: blocks.append(RawBlock( "paragraph", content, None, section_path, current_section_title, - exclude_from_chunking=in_exclusion_zone + exclude_from_chunking=in_exclusion_zone, + section_type=current_section_type, + block_id=current_block_id )) return blocks, h1_title diff --git a/app/core/chunking/chunking_strategies.py b/app/core/chunking/chunking_strategies.py index fefb343..b8cc96c 100644 --- a/app/core/chunking/chunking_strategies.py +++ b/app/core/chunking/chunking_strategies.py @@ -6,6 +6,7 @@ DESCRIPTION: Strategien für atomares Sektions-Chunking v3.9.9. - Strikte Einhaltung von Sektionsgrenzen via Look-Ahead. - Fix: Synchronisierung der Parameter mit dem Orchestrator (context_prefix). WP-24c v4.2.5: Strict-Mode ohne Carry-Over - Bei strict_heading_split wird nach jeder Sektion geflasht. + WP-26 v1.0: section_type und block_id werden an Chunks weitergegeben. """ from typing import List, Dict, Any, Optional from .chunking_models import RawBlock, Chunk @@ -36,41 +37,70 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: chunks: List[Chunk] = [] - def _emit(txt, title, path): - """Schreibt den finalen Chunk ohne Text-Modifikationen.""" + def _emit(txt, title, path, section_type=None, block_id=None): + """ + Schreibt den finalen Chunk ohne Text-Modifikationen. + WP-26 v1.0: Erweitert um section_type und block_id. + """ idx = len(chunks) 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, + section_type=section_type, block_id=block_id )) # --- SCHRITT 1: Gruppierung in atomare Sektions-Einheiten --- + # WP-26 v1.0: Erweitert um section_type und block_id Tracking sections: List[Dict[str, Any]] = [] curr_blocks = [] for b in blocks: if b.kind == "heading" and b.level <= split_level: if curr_blocks: + # WP-26 v1.0: Finde den effektiven section_type und block_id für diese Sektion + # Priorisiere den ersten Block mit section_type, sonst den Heading-Block + effective_section_type = None + effective_block_id = None + for cb in curr_blocks: + if cb.section_type and effective_section_type is None: + effective_section_type = cb.section_type + if cb.block_id and effective_block_id is None: + effective_block_id = cb.block_id + 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" + "is_empty": len(curr_blocks) == 1 and curr_blocks[0].kind == "heading", + "section_type": effective_section_type, + "block_id": effective_block_id }) curr_blocks = [b] else: curr_blocks.append(b) if curr_blocks: + # WP-26 v1.0: Gleiche Logik für den letzten Block + effective_section_type = None + effective_block_id = None + for cb in curr_blocks: + if cb.section_type and effective_section_type is None: + effective_section_type = cb.section_type + if cb.block_id and effective_block_id is None: + effective_block_id = cb.block_id + 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" + "is_empty": len(curr_blocks) == 1 and curr_blocks[0].kind == "heading", + "section_type": effective_section_type, + "block_id": effective_block_id }) # --- SCHRITT 2: Verarbeitung der Queue --- queue = list(sections) current_chunk_text = "" - current_meta = {"title": None, "path": "/"} + # WP-26 v1.0: Erweitert um section_type und block_id + current_meta = {"title": None, "path": "/", "section_type": None, "block_id": None} # Bestimmung des Modus: Hard-Split wenn smart_edge=False ODER strict=True is_hard_split_mode = (not smart_edge) or (strict) @@ -83,6 +113,9 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: if not current_chunk_text: current_meta["title"] = item["meta"].section_title current_meta["path"] = item["meta"].section_path + # WP-26 v1.0: section_type und block_id aus Item übernehmen + current_meta["section_type"] = item.get("section_type") + current_meta["block_id"] = item.get("block_id") # FALL A: HARD SPLIT MODUS (WP-24c v4.2.5: Strict-Mode ohne Carry-Over) if is_hard_split_mode: @@ -90,18 +123,23 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: # Kein Carry-Over erlaubt, auch nicht für leere Überschriften if current_chunk_text: # Flashe vorherigen Chunk - _emit(current_chunk_text, current_meta["title"], current_meta["path"]) + _emit(current_chunk_text, current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) current_chunk_text = "" # Neue Sektion: Initialisiere Meta current_meta["title"] = item["meta"].section_title current_meta["path"] = item["meta"].section_path + # WP-26 v1.0: section_type und block_id aus Item übernehmen + current_meta["section_type"] = item.get("section_type") + current_meta["block_id"] = item.get("block_id") # WP-24c v4.2.5: Auch leere Sektionen werden als separater Chunk erstellt # (nur Überschrift, kein Inhalt) if item.get("is_empty", False): # Leere Sektion: Nur Überschrift als Chunk - _emit(item_text, current_meta["title"], current_meta["path"]) + _emit(item_text, current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) else: # Normale Sektion: Prüfe auf Token-Limit if estimate_tokens(item_text) > max_tokens: @@ -113,16 +151,19 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: while sents: s = sents.pop(0); slen = estimate_tokens(s) if take_len + slen > target and take_sents: - _emit(" ".join(take_sents), current_meta["title"], current_meta["path"]) + _emit(" ".join(take_sents), current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) take_sents = [s]; take_len = slen else: take_sents.append(s); take_len += slen if take_sents: - _emit(" ".join(take_sents), current_meta["title"], current_meta["path"]) + _emit(" ".join(take_sents), current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) else: # Sektion passt: Direkt als Chunk - _emit(item_text, current_meta["title"], current_meta["path"]) + _emit(item_text, current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) current_chunk_text = "" continue @@ -137,7 +178,8 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: else: if current_chunk_text: # Regel 2: Flashen an Sektionsgrenze, Item zurücklegen - _emit(current_chunk_text, current_meta["title"], current_meta["path"]) + _emit(current_chunk_text, current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) current_chunk_text = "" queue.insert(0, item) else: @@ -152,7 +194,8 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: sents.insert(0, s); break take_sents.append(s); take_len += slen - _emit(" ".join(take_sents), current_meta["title"], current_meta["path"]) + _emit(" ".join(take_sents), current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) if sents: remainder = " ".join(sents) @@ -160,15 +203,21 @@ def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: 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}) + # WP-26 v1.0: section_type und block_id weitergeben + queue.insert(0, {"text": remainder, "meta": item["meta"], "is_split": True, + "section_type": item.get("section_type"), "block_id": item.get("block_id")}) if current_chunk_text: - _emit(current_chunk_text, current_meta["title"], current_meta["path"]) + _emit(current_chunk_text, current_meta["title"], current_meta["path"], + current_meta["section_type"], current_meta["block_id"]) 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.""" + """ + Standard-Sliding-Window für flache Texte ohne Sektionsfokus. + WP-26 v1.0: Erweitert um section_type und block_id Weitergabe. + """ target = config.get("target", 400); max_tokens = config.get("max", 600) chunks: List[Chunk] = []; buf: List[RawBlock] = [] @@ -178,13 +227,31 @@ def strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], note 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)) + # WP-26 v1.0: Finde effektiven section_type und block_id + effective_section_type = next((b.section_type for b in buf if b.section_type), None) + effective_block_id = next((b.block_id for b in buf if b.block_id), None) + 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, + section_type=effective_section_type, block_id=effective_block_id + )) buf = [] buf.append(b) if 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=estimate_tokens(txt), section_title=buf[0].section_title, section_path=buf[0].section_path, neighbors_prev=None, neighbors_next=None)) + # WP-26 v1.0: Finde effektiven section_type und block_id + effective_section_type = next((b.section_type for b in buf if b.section_type), None) + effective_block_id = next((b.block_id for b in buf if b.block_id), None) + 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, + section_type=effective_section_type, block_id=effective_block_id + )) return chunks \ No newline at end of file diff --git a/app/core/graph/graph_utils.py b/app/core/graph/graph_utils.py index 94c6f2a..5859f3b 100644 --- a/app/core/graph/graph_utils.py +++ b/app/core/graph/graph_utils.py @@ -12,28 +12,85 @@ STATUS: Active import os import uuid import hashlib -from typing import Iterable, List, Optional, Set, Any, Tuple +from typing import Dict, Iterable, List, Optional, Set, Any, Tuple try: import yaml except ImportError: yaml = None -# WP-15b: Prioritäten-Ranking für die De-Duplizierung von Kanten unterschiedlicher Herkunft +# WP-26 v1.0: Provenance-Literale auf valide EdgeDTO-Werte reduziert +# Legacy-Prioritäten für interne Verarbeitung (werden zu source_hint gemappt) PROVENANCE_PRIORITY = { + # Explizite Kanten (provenance: "explicit") "explicit:wikilink": 1.00, "inline:rel": 0.95, "callout:edge": 0.90, - "explicit:callout": 0.90, # WP-24c v4.2.7: Callout-Kanten aus candidate_pool - "semantic_ai": 0.90, # Validierte KI-Kanten - "structure:belongs_to": 1.00, - "structure:order": 0.95, # next/prev + "explicit:callout": 0.90, "explicit:note_scope": 1.00, - "explicit:note_zone": 1.00, # WP-24c v4.2.0: Note-Scope Zonen (höchste Priorität) + "explicit:note_zone": 1.00, + # Regel-basierte Kanten (provenance: "rule") "derived:backlink": 0.90, - "edge_defaults": 0.70 # Heuristik basierend auf types.yaml + "edge_defaults": 0.70, + "schema_default": 0.85, + # Struktur-Kanten (provenance: "structure") + "structure:belongs_to": 1.00, + "structure:order": 0.95, + # KI-generierte Kanten (provenance: "smart") + "semantic_ai": 0.90, + "global_pool": 0.80, } +# WP-26 v1.0: Mapping von internen Provenance-Werten zu EdgeDTO-konformen Literalen +PROVENANCE_TO_DTO = { + # explicit + "explicit:wikilink": ("explicit", "wikilink"), + "explicit:callout": ("explicit", "callout"), + "explicit:note_scope": ("explicit", "note_scope"), + "explicit:note_zone": ("explicit", "note_zone"), + "inline:rel": ("explicit", "inline_rel"), + "callout:edge": ("explicit", "callout"), + "explicit": ("explicit", None), + # rule + "derived:backlink": ("rule", "backlink"), + "edge_defaults": ("rule", "edge_defaults"), + "schema_default": ("rule", "schema_default"), + "inferred:schema": ("rule", "schema_default"), + "rule": ("rule", None), + # structure + "structure:belongs_to": ("structure", "belongs_to"), + "structure:order": ("structure", "order"), + "structure": ("structure", None), + # smart + "semantic_ai": ("smart", None), + "global_pool": ("smart", "global_pool"), + "smart": ("smart", None), +} + +def normalize_provenance(internal_provenance: str) -> Tuple[str, Optional[str]]: + """ + WP-26 v1.0: Normalisiert interne Provenance-Werte zu EdgeDTO-konformen Literalen. + + Args: + internal_provenance: Interner Provenance-String (z.B. "explicit:callout") + + Returns: + Tuple (provenance, source_hint) mit validen EdgeDTO-Werten + """ + if internal_provenance in PROVENANCE_TO_DTO: + return PROVENANCE_TO_DTO[internal_provenance] + + # Fallback: Versuche Präfix-Matching + if internal_provenance.startswith("explicit"): + return ("explicit", internal_provenance.split(":")[-1] if ":" in internal_provenance else None) + if internal_provenance.startswith("structure"): + return ("structure", internal_provenance.split(":")[-1] if ":" in internal_provenance else None) + if internal_provenance.startswith("rule") or internal_provenance.startswith("derived"): + return ("rule", internal_provenance.split(":")[-1] if ":" in internal_provenance else None) + + # Default: explicit ohne source_hint + return ("explicit", None) + # --------------------------------------------------------------------------- # Pfad-Auflösung (Integration der .env Umgebungsvariablen) # --------------------------------------------------------------------------- @@ -123,7 +180,15 @@ def _mk_edge_id(kind: str, s: str, t: str, scope: str, target_section: Optional[ def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, extra: Optional[dict] = None) -> dict: """ Konstruiert ein standardisiertes Kanten-Payload für Qdrant. - Wird von graph_derive_edges.py benötigt. + WP-26 v1.0: Erweitert um is_internal Flag und Provenance-Normalisierung. + + Args: + kind: Kantentyp (z.B. "derives", "caused_by") + scope: Granularität ("chunk" oder "note") + source_id: ID der Quelle (Chunk oder Note) + target_id: ID des Ziels (Chunk oder Note) + note_id: ID der Note (für Kontext) + extra: Zusätzliche Payload-Felder """ pl = { "kind": kind, @@ -134,8 +199,24 @@ def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, e "note_id": note_id, "virtual": False # Standardmäßig explizit, solange nicht anders in Phase 2 gesetzt } + + # WP-26 v1.0: is_internal Flag berechnen + # Intra-Note-Edge: Source und Target gehören zur gleichen Note + source_note = source_id.split("#")[0] if "#" in source_id else source_id + target_note = target_id.split("#")[0] if "#" in target_id else target_id + pl["is_internal"] = (source_note == target_note) or (source_note == note_id and target_note == note_id) + if extra: pl.update(extra) + + # WP-26 v1.0: Provenance normalisieren, falls vorhanden + if "provenance" in extra: + internal_prov = extra["provenance"] + dto_prov, source_hint = normalize_provenance(internal_prov) + pl["provenance"] = dto_prov + if source_hint: + pl["source_hint"] = source_hint + return pl # --------------------------------------------------------------------------- diff --git a/app/core/ingestion/ingestion_chunk_payload.py b/app/core/ingestion/ingestion_chunk_payload.py index e29f544..3ce7d8c 100644 --- a/app/core/ingestion/ingestion_chunk_payload.py +++ b/app/core/ingestion/ingestion_chunk_payload.py @@ -3,7 +3,8 @@ FILE: app/core/ingestion/ingestion_chunk_payload.py DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. Fix v2.4.3: Integration der zentralen Registry (WP-14) für konsistente Defaults. WP-24c v4.3.0: candidate_pool wird explizit übernommen für Chunk-Attribution. -VERSION: 2.4.4 (WP-24c v4.3.0) + WP-26 v1.0: Erweiterung um effective_type (section_type || note_type) und note_type-Feld. +VERSION: 2.5.0 (WP-26 v1.0) STATUS: Active """ from __future__ import annotations @@ -91,14 +92,35 @@ def make_chunk_payloads(note: Dict[str, Any], note_path: str, chunks_from_chunke section = getattr(ch, "section_title", "") if not is_dict else ch.get("section", "") # WP-24c v4.3.0: candidate_pool muss erhalten bleiben für Chunk-Attribution candidate_pool = getattr(ch, "candidate_pool", []) if not is_dict else ch.get("candidate_pool", []) + + # WP-26 v1.0: Section-Type für typ-spezifische Sektionen + section_type = getattr(ch, "section_type", None) if not is_dict else ch.get("section_type") + # WP-26 v1.0: Block-ID für Intra-Note-Links + block_id = getattr(ch, "block_id", None) if not is_dict else ch.get("block_id") + + # WP-26 v1.0: Effektiver Typ = section_type || note_type (FA-03) + effective_type = section_type if section_type else note_type + # WP-26 v1.0: retriever_weight basiert auf effektivem Typ (FA-09b) + # Wenn section_type vorhanden, nutze dessen retriever_weight + effective_rw = rw + if section_type: + effective_rw = _resolve_val(section_type, reg, "retriever_weight", rw) + try: + effective_rw = float(effective_rw) + except: + effective_rw = rw + pl: Dict[str, Any] = { "note_id": nid or fm.get("id"), "chunk_id": cid, "title": title, "index": int(index), "ord": int(index) + 1, - "type": note_type, + # WP-26 v1.0: type enthält den effektiven Typ (section_type || note_type) + "type": effective_type, + # WP-26 v1.0: note_type ist immer der ursprüngliche Note-Typ (für Filterung) + "note_type": note_type, "tags": tags, "text": text, "window": window, @@ -107,9 +129,13 @@ def make_chunk_payloads(note: Dict[str, Any], note_path: str, chunks_from_chunke "section": section, "path": note_path, "source_path": kwargs.get("file_path") or note_path, - "retriever_weight": rw, + # WP-26 v1.0: retriever_weight basiert auf effektivem Typ + "retriever_weight": effective_rw, "chunk_profile": cp, - "candidate_pool": candidate_pool # WP-24c v4.3.0: Kritisch für Chunk-Attribution + "candidate_pool": candidate_pool, # WP-24c v4.3.0: Kritisch für Chunk-Attribution + # WP-26 v1.0: Optionale Felder für Section-Type-Tracking + "section_type": section_type, # Expliziter Section-Type (oder None) + "block_id": block_id, # Block-ID für Intra-Note-Links (oder None) } # Audit: Cleanup Pop (Vermeidung von redundanten Alias-Feldern) diff --git a/app/models/dto.py b/app/models/dto.py index 15d6eea..46a267e 100644 --- a/app/models/dto.py +++ b/app/models/dto.py @@ -46,16 +46,18 @@ class EdgeDTO(BaseModel): target: str weight: float direction: Literal["out", "in", "undirected"] = "out" - # WP-24c v4.5.3: Erweiterte Provenance-Werte für Chunk-Aware Edges - # Unterstützt alle tatsächlich verwendeten Provenance-Typen im System - provenance: Optional[Literal[ - "explicit", "rule", "smart", "structure", - "explicit:callout", "explicit:wikilink", "explicit:note_zone", "explicit:note_scope", - "inline:rel", "callout:edge", "semantic_ai", "structure:belongs_to", "structure:order", - "derived:backlink", "edge_defaults", "global_pool" - ]] = "explicit" + # WP-26 v1.0: Provenance auf valide Literale reduziert (EdgeDTO-Constraint) + # Detail-Informationen werden über source_hint transportiert + provenance: Optional[Literal["explicit", "rule", "smart", "structure"]] = "explicit" + # WP-26 v1.0: Neues Feld für Detail-Informationen zur Herkunft + source_hint: Optional[Literal[ + "callout", "wikilink", "inline_rel", "schema_default", "note_scope", + "note_zone", "belongs_to", "order", "backlink", "edge_defaults", "global_pool" + ]] = None confidence: float = 1.0 - target_section: Optional[str] = None + target_section: Optional[str] = None + # WP-26 v1.0: Flag für Intra-Note-Edges + is_internal: Optional[bool] = None # --- Request Models --- diff --git a/docs/05_Development/05_WP26_Manual_Testing.md b/docs/05_Development/05_WP26_Manual_Testing.md new file mode 100644 index 0000000..2585095 --- /dev/null +++ b/docs/05_Development/05_WP26_Manual_Testing.md @@ -0,0 +1,284 @@ +# WP-26 Manuelle Testszenarien + +**Version:** 1.0 +**Datum:** 25. Januar 2026 +**Status:** Phase 1 Implementierung abgeschlossen + +--- + +## 1. Überblick + +Dieses Dokument beschreibt die manuellen Testszenarien für WP-26 Phase 1: Section-Types und Intra-Note-Edges. + +--- + +## 2. Voraussetzungen + +1. **Python-Umgebung** mit allen Dependencies aus `requirements.txt` +2. **Qdrant-Instanz** erreichbar (lokal oder Docker) +3. **Vault mit Test-Note** (siehe Abschnitt 3) + +--- + +## 3. Test-Note erstellen + +Erstelle eine neue Markdown-Datei im Vault mit folgendem Inhalt: + +```markdown +--- +id: wp26-test-experience +title: WP-26 Test Experience +type: experience +tags: [test, wp26] +--- + +# WP-26 Test Experience + +## Situation ^sit +> [!section] experience + +Am 25. Januar 2026 testete ich das neue Section-Type Feature. +Dies ist der Experience-Teil der Note. + +## Meine Reaktion ^react +> [!section] experience + +> [!edge] followed_by +> [[#^sit]] + +Ich war zunächst skeptisch, aber die Implementierung sah solide aus. + +## Reflexion ^ref +> [!section] insight + +Diese Erfahrung zeigt mir, dass typ-spezifische Sektionen +die semantische Präzision des Retrievals verbessern können. + +> [!abstract] Semantic Edges +>> [!edge] derives +>> [[#^sit]] +>> [[#^react]] + +## Nächste Schritte ^next +> [!section] decision + +Ich werde: +1. Die Tests ausführen +2. Die Ergebnisse dokumentieren + +> [!edge] caused_by +> [[#^ref]] +``` + +--- + +## 4. Testszenarien + +### 4.1 TS-01: Section-Type-Erkennung + +**Ziel:** Prüfen, ob `[!section]`-Callouts korrekt erkannt werden. + +**Schritte:** + +1. Importiere die Test-Note via `scripts/import_markdown.py` +2. Prüfe die Chunks in Qdrant via API oder Debug-Skript + +**Prüfkriterien:** + +| Chunk | Erwarteter `type` | Erwarteter `note_type` | Erwarteter `section` | +|-------|-------------------|------------------------|----------------------| +| #c00 | experience | experience | Situation | +| #c01 | experience | experience | Meine Reaktion | +| #c02 | insight | experience | Reflexion | +| #c03 | decision | experience | Nächste Schritte | + +**Prüf-Script:** + +```python +# scripts/check_wp26_chunks.py +from qdrant_client import QdrantClient + +client = QdrantClient("http://localhost:6333") +note_id = "wp26-test-experience" + +# Hole alle Chunks der Note +result = client.scroll( + collection_name="mindnet_chunks", + scroll_filter={"must": [{"key": "note_id", "match": {"value": note_id}}]}, + with_payload=True, + limit=100 +) + +for point in result[0]: + p = point.payload + print(f"Chunk: {p.get('chunk_id')}") + print(f" type: {p.get('type')}") + print(f" note_type: {p.get('note_type')}") + print(f" section: {p.get('section')}") + print(f" section_type: {p.get('section_type')}") + print(f" block_id: {p.get('block_id')}") + print() +``` + +--- + +### 4.2 TS-02: Block-ID-Erkennung + +**Ziel:** Prüfen, ob Block-IDs (`^id`) aus Überschriften korrekt extrahiert werden. + +**Prüfkriterien:** + +| Chunk | Erwartete `block_id` | +|-------|---------------------| +| #c00 | sit | +| #c01 | react | +| #c02 | ref | +| #c03 | next | + +--- + +### 4.3 TS-03: is_internal Flag für Edges + +**Ziel:** Prüfen, ob Intra-Note-Edges das `is_internal: true` Flag erhalten. + +**Schritte:** + +1. Importiere die Test-Note +2. Prüfe die Edges in Qdrant + +**Prüfkriterien:** + +| Edge | `is_internal` | +|------|---------------| +| #c01 → #c00 (followed_by) | `true` | +| #c02 → #c00 (derives) | `true` | +| #c02 → #c01 (derives) | `true` | +| #c03 → #c02 (caused_by) | `true` | +| Alle structure edges (next/prev) | `true` | + +**Prüf-Script:** + +```python +# scripts/check_wp26_edges.py +from qdrant_client import QdrantClient + +client = QdrantClient("http://localhost:6333") +note_id = "wp26-test-experience" + +# Hole alle Edges der Note +result = client.scroll( + collection_name="mindnet_edges", + scroll_filter={"must": [{"key": "note_id", "match": {"value": note_id}}]}, + with_payload=True, + limit=100 +) + +for point in result[0]: + p = point.payload + kind = p.get('kind', 'unknown') + source = p.get('source_id', '?') + target = p.get('target_id', '?') + is_internal = p.get('is_internal', 'MISSING') + provenance = p.get('provenance', '?') + source_hint = p.get('source_hint', '-') + + print(f"{source} --[{kind}]--> {target}") + print(f" is_internal: {is_internal}") + print(f" provenance: {provenance}") + print(f" source_hint: {source_hint}") + print() +``` + +--- + +### 4.4 TS-04: Provenance-Normalisierung + +**Ziel:** Prüfen, ob Provenance-Werte korrekt normalisiert werden. + +**Prüfkriterien:** + +| Altes Provenance | Neues `provenance` | `source_hint` | +|------------------|-------------------|---------------| +| explicit:callout | explicit | callout | +| explicit:wikilink | explicit | wikilink | +| structure:belongs_to | structure | belongs_to | +| structure:order | structure | order | +| edge_defaults | rule | edge_defaults | + +--- + +### 4.5 TS-05: Automatische Section-Erkennung + +**Ziel:** Prüfen, ob neue Überschriften ohne `[!section]` automatisch neue Chunks erstellen. + +**Test-Note:** + +```markdown +--- +id: wp26-test-auto-section +type: experience +--- + +# Test Auto Section + +## Section A ^a +> [!section] insight + +Content A (insight). + +## Section B ^b + +Content B (sollte experience sein - Fallback). + +## Section C ^c +> [!section] decision + +Content C (decision). +``` + +**Prüfkriterien:** + +| Chunk | `type` | Grund | +|-------|--------|-------| +| Section A | insight | Explizites `[!section]` | +| Section B | experience | Fallback auf `note_type` | +| Section C | decision | Explizites `[!section]` | + +--- + +## 5. Unit-Tests ausführen + +```bash +# Im Projekt-Root +cd c:\Dev\cursor\mindnet + +# Aktiviere virtuelle Umgebung (falls vorhanden) +# .venv\Scripts\activate + +# Führe WP-26 Tests aus +python -m pytest tests/test_wp26_section_types.py -v +``` + +**Erwartetes Ergebnis:** Alle Tests grün. + +--- + +## 6. Bekannte Einschränkungen + +1. **Block-ID-Stability:** Obsidian aktualisiert Block-IDs nicht automatisch bei Umbenennung von Überschriften. +2. **Heading-Links:** Links wie `[[#Section Name]]` werden unterstützt, aber Block-References (`[[#^id]]`) werden bevorzugt. +3. **Nested Callouts:** Verschachtelte Callouts (`>> [!edge]`) werden korrekt verarbeitet. + +--- + +## 7. Nächste Schritte (Phase 2) + +Nach erfolgreicher Validierung von Phase 1: + +1. **Retriever-Anpassung:** Path-Bonus für Intra-Note-Edges +2. **Graph-Exploration:** Navigation entlang `typical edges` aus `graph_schema.md` +3. **Schema-Validierung:** Agentic Validation gegen effektive Chunk-Typen + +--- + +**Ende der Testdokumentation** diff --git a/docs/06_Roadmap/06_LH_Section_Types_Intra_Note_Edges.md b/docs/06_Roadmap/06_LH_Section_Types_Intra_Note_Edges.md new file mode 100644 index 0000000..3c187e6 --- /dev/null +++ b/docs/06_Roadmap/06_LH_Section_Types_Intra_Note_Edges.md @@ -0,0 +1,1470 @@ +# LASTENHEFT: Typ-spezifische Sektionen mit Intra-Note-Edges + +**Version:** 1.3 +**Datum:** 25. Januar 2026 +**Status:** Freigegeben für Implementierung (WP-26) +**Projekt:** mindnet (Backend) – Contract für Obsidian-Vault-Integration + +--- + +## 1. Einleitung + +### 1.1 Zweck des Dokuments + +Dieses Lastenheft definiert die vollständigen Anforderungen für die Erweiterung des mindnet-Systems um **typ-spezifische Sektionen** innerhalb von Notes sowie **semantische Intra-Note-Edges** zwischen Chunks derselben Note. + +### 1.2 Geltungsbereich + +- **Backend**: mindnet Python-Anwendung (`c:\Dev\cursor\mindnet`) +- **Schnittstelle**: Markdown-Formate im Obsidian-Vault (Callout-Syntax, Block-References) +- **Konfiguration**: Vault-basierte Dateien (`graph_schema.md`, `types.yaml`, `edge_vocabulary.md`) + +**Hinweis:** Dieses Dokument dient als **Contract** zwischen dem Obsidian-Vault (Datenquelle) und dem mindnet-Backend (Verarbeitung). Obsidian-Plugin-Implementierungen sind nicht Teil dieses Lastenhefts – bestehende Obsidian-Assistenten können gegen diese Spezifikation geprüft werden. + +### 1.3 Definitionen + +| Begriff | Definition | +|---------|------------| +| **Note-Type** | Der im Frontmatter definierte Typ einer Note (z.B. `experience`) | +| **Section-Type** | Ein explizit per Callout definierter Typ für einen Abschnitt innerhalb einer Note | +| **Effektiver Typ** | Der für Scoring/Validierung verwendete Typ: `section_type` falls vorhanden, sonst `note_type` | +| **Intra-Note-Edge** | Eine semantische Kante zwischen zwei Chunks derselben Note (Flag: `is_internal: true`) | +| **Inter-Note-Edge** | Eine semantische Kante zwischen Chunks verschiedener Notes (Flag: `is_internal: false`) | +| **Block-Reference** | Obsidian-natives Link-Format `[[#^block-id]]` | +| **Chunk** | Ein semantisch zusammenhängender Textblock, gespeichert in Qdrant | +| **Body-Section** | Implizite Sektion für Textblöcke vor dem ersten `[!section]`-Callout | + +--- + +## 2. Ausgangssituation (Ist-Zustand) + +### 2.1 Chunking + +| Komponente | Aktueller Stand | +|------------|-----------------| +| `Chunk.type` | Enthält immer den **Note-Type** | +| `Chunk.section` | Enthält den **Section-Titel** (String) | +| Section-Type | **Nicht vorhanden** | +| Callout-Erkennung | Nur `[!edge]` und `[!abstract]` | + +### 2.2 Edge-Erstellung + +| Komponente | Aktueller Stand | +|------------|-----------------| +| Intra-Note-Links | `[[#Section]]` wird zu `(NoteID, "Section")` aufgelöst | +| Chunk-zu-Chunk-Edges | Nur `next`, `prev`, `belongs_to` | +| Semantische Edges | Nur zwischen **verschiedenen** Notes | + +### 2.3 graph_schema.md Integration + +| Komponente | Aktueller Stand | +|------------|-----------------| +| Pfad-Konfiguration | `MINDNET_SCHEMA_PATH` in `.env` | +| Parsing | `EdgeRegistry._load_schema()` | +| Topology-Lookup | `get_topology_info(source_type, target_type)` | +| Verwendung | Validierung & Vorschläge für Note-zu-Note-Edges | + +### 2.4 Retriever + +| Komponente | Aktueller Stand | +|------------|-----------------| +| Scoring-Ebene | Chunk-Ebene | +| Deduplizierung | Note-Ebene (beste Chunk pro Note) | +| Edge-Gewichtung | `retriever.yaml` → `next/prev: 0.1` (niedrig) | + +--- + +## 3. Schnittstellen-Spezifikation für Obsidian-Assistenten + +Dieses Kapitel definiert die **erwarteten Markdown-Formate**, die das Backend korrekt interpretieren kann. Obsidian-Assistenten und Autoren können diese Spezifikation nutzen, um valide Notes zu erstellen. + +### 3.1 Kernziele + +1. **Sektionen können eigene Typen haben**, die vom Note-Type abweichen +2. **Semantische Kanten zwischen Chunks** derselben Note sind möglich +3. **Vollständige Abwärtskompatibilität** – bestehende Notes funktionieren unverändert +4. **graph_schema.md** liefert Default-Edges für Intra-Note-Verbindungen +5. **Block-References** (`[[#^block-id]]`) als stabiles Link-Format + +### 3.2 Format-Spezifikation: Section-Type-Callout + +#### Syntax + +```markdown +## Überschrift ^block-id +> [!section] type-name +``` + +#### Parser-Interpretation (Backend) + +| Element | Regex/Pattern | Verarbeitung | +|---------|---------------|--------------| +| Callout-Start | `^\s*>\s*\[!section\]\s*(\w+)` | Extrahiert `type-name` | +| Gültiger Type | Lookup in `types.yaml` | Warnung bei unbekanntem Typ | +| Scope | Bis nächste Überschrift ≤ aktuelle Ebene | State-Machine im Parser | + +#### Valide Beispiele + +```markdown +> [!section] insight ✓ Standardformat +> [!section] experience ✓ Mit Note-Type identisch (explizit) +> [!section] decision ✓ Abweichend vom Note-Type +``` + +#### Invalide Beispiele + +```markdown +> [!section] ✗ Typ fehlt +> [!section] unknown_type ⚠ Warnung (nicht in types.yaml) +> [!Section] insight ✗ Case-sensitiv (muss lowercase sein) +``` + +### 3.3 Format-Spezifikation: Edge-Callout + +#### Syntax (einfach) + +```markdown +> [!edge] edge-kind +> [[Target]] +> [[Target#Section]] +> [[#^block-id]] +``` + +#### Syntax (verschachtelt in Container) + +```markdown +> [!abstract] Semantic Edges +>> [!edge] edge-kind +>> [[Target1]] +>> [[Target2]] +> +>> [!edge] another-kind +>> [[Target3]] +``` + +#### Parser-Interpretation (Backend) + +| Element | Regex/Pattern | Verarbeitung | +|---------|---------------|--------------| +| Edge-Start | `^\s*>{1,}\s*\[!edge\]\s*(\w+)` | Extrahiert `edge-kind` | +| Wikilink | `\[\[([^\]]+)\]\]` | Extrahiert Target inkl. `#Section` | +| Block-Reference | `\[\[#\^([a-zA-Z0-9_-]+)\]\]` | Intra-Note-Link erkannt | +| Container | `\[!abstract\]` oder ähnlich | Ignoriert (nur Gruppierung) | + +#### Valide Edge-Kinds (aus `edge_vocabulary.md`) + +| Canonical | Aliase | +|-----------|--------| +| `caused_by` | `ausgelöst_durch`, `wegen` | +| `derived_from` | `abgeleitet_von`, `quelle` | +| `based_on` | `basiert_auf`, `fundament` | +| `solves` | `löst`, `beantwortet` | +| `depends_on` | `hängt_ab_von`, `braucht` | +| `blocks` | `blockiert`, `verhindert` | +| `guides` | `steuert`, `leitet` | +| `related_to` | `siehe_auch`, `kontext` | + +### 3.4 Format-Spezifikation: Block-References + +#### Deklaration (in Überschrift) + +```markdown +## Überschrift ^block-id +``` + +#### Referenzierung (in Edge-Callout) + +```markdown +> [!edge] derives +> [[#^block-id]] +``` + +#### Parser-Interpretation + +| Link-Format | Interpretation | +|-------------|----------------| +| `[[#^sit]]` | Intra-Note-Link zu Block-ID `sit` in aktueller Note | +| `[[#Situation]]` | Intra-Note-Link zu Heading `Situation` (Fallback) | +| `[[Other Note#^id]]` | Inter-Note-Link zu Block-ID in anderer Note | +| `[[Other Note#Section]]` | Inter-Note-Link zu Heading in anderer Note | + +### 3.5 Nicht-Ziele (Out of Scope für WP-26) + +- Plugin-Implementierungen (UI, Autovervollständigung, Visualisierung) +- Automatische Section-Type-Erkennung via LLM +- Neue Obsidian-Commands + +--- + +## 4. Funktionale Anforderungen + +### 4.1 Section-Type-Deklaration + +#### FA-01: Neues Callout-Format `[!section]` + +**Syntax:** + +```markdown +## Überschrift ^block-id +> [!section] type-name +``` + +**Beispiel:** + +```markdown +## Reflexion ^ref +> [!section] insight + +Diese Erfahrung hat mir gezeigt, dass... +``` + +**Regeln:** + +- Der Section-Type gilt ab der Überschrift bis zur nächsten Überschrift **gleicher oder höherer Ebene** +- Bei Fehlen eines `[!section]`-Callouts gilt der Note-Type als Fallback +- Valide Section-Types müssen in `types.yaml` definiert sein +- Das `[!section]`-Callout kann **an beliebiger Stelle** innerhalb des Abschnitts stehen (muss nicht direkt unter der Überschrift sein) +- Das `[!section]`-Callout ist **unabhängig** von `[!edge]`-Callouts und kann separat platziert werden + +#### FA-01b: Verschachtelte Edge-Callouts (Semantic Edges Container) + +Das System unterstützt verschachtelte Callouts für eine übersichtliche Gruppierung von Edges: + +**Syntax:** + +```markdown +> [!abstract] Semantic Edges +>> [!edge] derived_from +>> [[Wikilink#Abschnitt]] +> +>> [!edge] solves +>> [[Wikilink2]] +``` + +**Regeln:** + +- Container-Callouts wie `[!abstract]` werden als Gruppierung erkannt, aber nicht semantisch verarbeitet +- Eingebettete `>> [!edge]` Callouts werden korrekt extrahiert +- Die Einrückungsebene (Anzahl `>`) bestimmt die Zugehörigkeit zum Block +- Leere Zeilen innerhalb des Containers (mit `>`) beenden den Edge-Block nicht + +**Beispiel mit separatem `[!section]`:** + +```markdown +## Reflexion ^ref +> [!section] insight + +Normaler Inhalt der Reflexion... + +> [!abstract] Semantic Edges +>> [!edge] derives +>> [[#^sit]] +>> [[#^react]] +> +>> [!edge] supports +>> [[Externe Note]] +``` + +#### FA-02: Scope-Beendigung + +| Szenario | Verhalten | +|----------|-----------| +| H2 mit `[!section] insight` → H2 ohne Callout | Reset auf Note-Type | +| H2 mit `[!section] insight` → H3 ohne Callout | Section-Type bleibt `insight` | +| H2 mit `[!section] insight` → H3 mit `[!section] value` | Nested: H3-Bereich ist `value`, danach wieder `insight` | +| H1 ohne Callout → H2 mit `[!section]` | Nur H2-Bereich hat Section-Type | + +#### FA-02b: Automatische Section-Erkennung bei neuen Überschriften + +**Kernregel:** Sobald eine Section auf einer bestimmten Überschriften-Ebene eingeführt wurde (z.B. H2), beginnt bei **jeder weiteren Überschrift auf dieser Ebene automatisch eine neue Section** – auch ohne explizites `[!section]`-Callout. + +**Algorithmus:** + +```python +# State-Tracking +section_introduced_at_level = None # Ebene, auf der erste Section eingeführt wurde + +for heading in headings: + if heading has [!section] callout: + section_introduced_at_level = heading.level + current_section_type = callout.type + elif section_introduced_at_level is not None: + if heading.level <= section_introduced_at_level: + # Neue Section beginnt automatisch + # → Reset auf Note-Type (kein expliziter Section-Type) + current_section_type = None # Fallback auf note_type +``` + +**Beispiel:** + +```markdown +--- +type: experience +--- + +# Titel + +## Situation ^sit +> [!section] experience + +Text A... → type = "experience" (explizit) + +## Reflexion ^ref + + +Text B... → type = "experience" (note_type Fallback) + → ABER: Neue Section erkannt, neuer Chunk! + +## Learnings ^learn +> [!section] insight + +Text C... → type = "insight" (explizit) + +## Ausblick ^out + + +Text D... → type = "experience" (note_type Fallback) + → Neue Section erkannt, neuer Chunk! +``` + +**Erwartete Chunks:** + +| Chunk-ID | type | section | Grund | +|----------|------|---------|-------| +| `#c01` | experience | Situation | Explizites `[!section]` | +| `#c02` | experience | Reflexion | H2 → neue Section, Fallback auf note_type | +| `#c03` | insight | Learnings | Explizites `[!section]` | +| `#c04` | experience | Ausblick | H2 → neue Section, Fallback auf note_type | + +**Wichtig:** Diese Regel stellt sicher, dass der Chunker **immer** bei gleichwertigen Überschriften einen neuen Chunk erstellt, sobald das Section-Konzept in der Note aktiviert wurde. Dies garantiert konsistentes Verhalten und ermöglicht Intra-Note-Edges zwischen allen Abschnitten. + +### 4.2 Chunk-Payload-Anpassung + +#### FA-03: `type`-Feld-Befüllung + +**Aktuelle Logik:** + +```python +type = note_type # Immer Note-Type +``` + +**Neue Logik:** + +```python +type = section_type if section_type else note_type +``` + +**Kein neues Feld erforderlich** – das bestehende `type`-Feld wird kontextabhängig befüllt. + +**Präzisierung "Effektiver Typ":** + +Der **effektive Typ** eines Chunks ist der Wert, der im `type`-Feld gespeichert wird und für alle nachfolgenden Operationen verbindlich ist: + +| Operation | Verwendeter Wert | +|-----------|------------------| +| Embedding-Generierung | `type` (effektiver Typ) | +| `retriever_weight` Lookup | `type` (effektiver Typ) | +| Schema-Validierung (FA-12) | `type` (effektiver Typ) | +| Type-Filter in Queries | `type` oder `note_type` (je nach Query) | + +Der `section_type` **überschreibt** den `note_type` vollständig für den jeweiligen Chunk. Es gibt keine "Vererbung" oder "Mischung" – der effektive Typ ist entweder der explizite `section_type` oder der `note_type` als Fallback. + +#### FA-03b: Default-Handling für Textblöcke vor erstem `[!section]` (Body-Section) + +**Anforderung:** Textblöcke, die **vor dem ersten `[!section]`-Callout** einer Note stehen, müssen konsistent behandelt werden. + +**Regeln:** + +1. Diese Chunks erben den `note_type` als ihren effektiven `type` +2. Der Sektionsname wird auf `body` gesetzt +3. Es wird **kein** impliziter `section_type` generiert (bleibt `None`) + +**Beispiel:** + +```markdown +--- +type: experience +--- + +# Meine Erfahrung + +Einleitender Text ohne Section-Callout... + +## Situation ^sit +> [!section] experience + +Die eigentliche Geschichte... +``` + +**Erwartete Chunks:** + +| Chunk-ID | type | section | section_type | +|----------|------|---------|--------------| +| `#c00` | experience | body | `None` | +| `#c01` | experience | Situation | `experience` | + +**Begründung:** Dies stellt sicher, dass: +- Keine Chunks ohne Sektionszuordnung entstehen +- Der Einleitungstext der Note semantisch korrekt als `body` identifizierbar ist +- Die Abwärtskompatibilität für Notes ohne jegliche `[!section]`-Callouts gewahrt bleibt + +#### FA-04: Optionales Feld `note_type` (für Filterung) + +Für Queries, die alle Chunks einer Note unabhängig vom Section-Type finden sollen: + +```python +{ + "type": "insight", # Effektiver Typ (section_type || note_type) + "note_type": "experience", # Ursprünglicher Note-Typ (immer vorhanden) + ... +} +``` + +**Index:** Neuer Keyword-Index auf `note_type` in Qdrant. + +### 4.3 Intra-Note-Edges + +#### FA-05: Block-Reference als Link-Format + +**Bevorzugtes Format:** + +```markdown +> [!edge] derives +> [[#^sit]] +``` + +**Fallback (mit Einschränkungen):** + +```markdown +> [!edge] derives +> [[#Situation]] +``` + +#### FA-06: Section-zu-Chunk-Mapping + +Der Chunker erstellt ein Mapping von Section-Identifikatoren zu Chunk-IDs: + +```python +section_chunk_map = { + "Situation": "NoteID#c01", # Heading-Match + "^sit": "NoteID#c01", # Block-ID-Match + "Reflexion": "NoteID#c02", + "^ref": "NoteID#c02" +} +``` + +**Mapping-Regeln:** + +- Erste Chunk-ID, die zur Section gehört, ist der Anker +- Groß-/Kleinschreibung normalisieren +- Block-IDs haben Priorität vor Heading-Matches + +#### FA-07: Edge-Erstellung für Intra-Note-Links + +**Input:** + +```markdown +## Reflexion ^ref +> [!section] insight +> [!edge] derives +> [[#^sit]] +``` + +**Erwartete Kante:** + +```python +{ + "kind": "derives", + "source_id": "NoteID#c02", # Chunk der Reflexion + "target_id": "NoteID#c01", # Chunk der Situation + "scope": "chunk", # NEU: Chunk-Scope statt Note-Scope + "note_id": "NoteID", + "provenance": "explicit", # Valides EdgeDTO-Literal + "source_hint": "callout", # Metadaten: Herkunft aus [!edge]-Callout + "is_internal": True # NEU: Flag für Intra-Note-Edge +} +``` + +**Hinweis zu Provenance-Literalen:** Das `EdgeDTO` akzeptiert nur die folgenden Werte für `provenance`: +- `explicit` – Manuell vom Autor definierte Kante (Callouts, Wikilinks) +- `rule` – Aus graph_schema.md abgeleitete Default-Kante +- `smart` – KI-generierte/validierte Kante +- `structure` – System-Kanten (`next`, `prev`, `belongs_to`) + +Interne Unterscheidungen (z.B. Callout vs. Wikilink) werden über das optionale Feld `source_hint` dokumentiert. + +#### FA-07b: Metadaten-Erweiterung für Kanten (`is_internal` Flag) + +**Anforderung:** Jede generierte Edge muss ein boolesches Flag `is_internal` erhalten. + +**Definition:** + +```python +is_internal = (source_note_id == target_note_id) +``` + +| Szenario | `is_internal` | +|----------|---------------| +| Edge zwischen Chunks derselben Note | `True` | +| Edge zwischen Chunks verschiedener Notes | `False` | +| Struktur-Edges (`next`, `prev`, `belongs_to`) | `True` (immer innerhalb einer Note) | + +**Zweck:** + +1. **Retrieval-Scoring (WP-22):** Intra-Note-Pfade (logische Gedankenfolgen innerhalb einer Note) können stärker gewichtet werden +2. **Graph-Analyse:** Unterscheidung zwischen internem Wissensfluss und externen Referenzen +3. **Visualisierung:** Differenzierte Darstellung in Graph-Views + +**Konfiguration** in `retriever.yaml`: + +```yaml +edge_scoring: + internal_edge_boost: 1.2 # +20% Boost für Intra-Note-Edges + external_edge_boost: 1.0 # Standard für Inter-Note-Edges +``` + +**Qdrant-Index:** Neuer Boolean-Index auf `is_internal` für effizientes Filtering. + +#### FA-08: Default-Edges aus graph_schema.md + +Wenn **keine expliziten Intra-Note-Edges** definiert sind, aber Section-Types vorhanden: + +1. Ermittle Source-Type und Target-Type (benachbarte Sektionen) +2. Rufe `EdgeRegistry.get_topology_info(source_type, target_type)` auf +3. Nutze **ersten Eintrag** aus `typical` als Default-Edge-Type + +**Beispiel:** + +```markdown +## Situation ^sit +> [!section] experience + +## Reflexion ^ref +> [!section] insight + +``` + +**Lookup:** + +```python +topology = registry.get_topology_info("experience", "insight") +# → {"typical": ["resulted_in", ...], "prohibited": ["solves", ...]} +default_edge = topology["typical"][0] # "resulted_in" +``` + +**Generierte Kante:** + +```python +{ + "kind": "resulted_in", + "source_id": "NoteID#c01", # experience + "target_id": "NoteID#c02", # insight + "scope": "chunk", + "provenance": "rule", # Valides EdgeDTO-Literal (aus Schema abgeleitet) + "source_hint": "schema_default",# Metadaten: Aus graph_schema.md + "is_internal": True +} +``` + +### 4.4 Retriever-Anpassungen + +#### FA-09: Edge-Gewichtung für Intra-Note-Edges + +Intra-Note-Edges erhalten die gleiche Gewichtung wie Inter-Note-Edges gleichen Typs (konfiguriert in `retriever.yaml`). + +**Zusätzlich:** Über das `is_internal`-Flag kann ein konfigurierbarer Boost für Intra-Note-Pfade angewendet werden (siehe FA-07b). + +#### FA-09b: Retrieval-Priorisierung – Section-Type vor Note-Type + +**Kernregel:** Für das Vektor-Ranking und den Type-Boost hat der **Section-Type (sofern vorhanden) immer Vorrang** vor dem `note_type`. + +**Betroffene Komponenten:** + +| Komponente | Verwendeter Typ | +|------------|-----------------| +| Vektor-Embedding (Suche) | `type` (= effektiver Typ) | +| `retriever_weight` Lookup | `type` (= effektiver Typ) | +| Type-Filter in Queries | `type` oder `note_type` (je nach Query) | +| Graph-Expansion | `type` (= effektiver Typ) | + +**Beispiel:** + +Ein Chunk mit `type: "insight"` und `note_type: "experience"` erhält: +- `retriever_weight: 1.20` (aus `types.yaml` für `insight`, nicht `experience`) +- Wird bei `filter: {type: "insight"}` gefunden +- Wird bei `filter: {note_type: "experience"}` ebenfalls gefunden + +**Implementierung:** + +```python +# In retriever_scoring.py +effective_type = payload.get("type") # Enthält bereits section_type || note_type +retriever_weight = types_config.get(effective_type, {}).get("retriever_weight", 1.0) +``` + +#### FA-10: Optionale Chunk-Level-Deduplizierung + +**Neue Konfiguration** in `retriever.yaml`: + +```yaml +aggregation: + level: note # "note" (default) oder "chunk" + max_chunks_per_note: 3 # Optional: Limit bei "note"-Level +``` + +**Verhalten:** + +- `level: note` → Aktuelle Logik (beste Chunk pro Note) +- `level: chunk` → Alle Chunks individuell ranken + +### 4.5 Abwärtskompatibilität + +#### FA-11: Fallback-Verhalten + +| Szenario | Verhalten | +|----------|-----------| +| Note ohne `[!section]` Callouts | `Chunk.type = note_type` (unverändert) | +| Note ohne Block-IDs | Heading-Links funktionieren (mit Einschränkungen) | +| Note ohne Intra-Note-Edges | Nur `next/prev/belongs_to` (wie bisher) | +| Note ohne Section-Types | Keine Default-Edges aus graph_schema.md | + +**Garantie:** Bestehende Notes, die keine der neuen Features nutzen, verhalten sich **exakt wie heute**. + +### 4.6 Validierung von Intra-Note-Edges (Agentic Validation – Phase 3) + +#### FA-12: Schema-Validierung gegen effektiven Chunk-Typ + +**Anforderung:** Die Agentic Validation (Phase 3 der Ingestion-Pipeline) muss bei Intra-Note-Edges **zwingend den effektiven Chunk-Typ** (Section-Type bzw. Fallback auf Note-Type) für die Validierung verwenden. + +**Validierungslogik:** + +```python +def validate_intra_note_edge(edge: dict, source_chunk: dict, target_chunk: dict) -> bool: + """ + Validiert eine Intra-Note-Edge gegen das graph_schema.md. + Verwendet den EFFEKTIVEN Typ (section_type || note_type) beider Chunks. + """ + source_type = source_chunk.get("type") # Effektiver Typ + target_type = target_chunk.get("type") # Effektiver Typ + edge_kind = edge.get("kind") + + # Lookup im Schema + topology = edge_registry.get_topology_info(source_type, target_type) + + # Prüfung: Ist die Edge erlaubt? + if edge_kind in topology.get("prohibited", []): + log.warning(f"Edge '{edge_kind}' von {source_type} → {target_type} ist verboten") + return False + + # Prüfung: Ist die Edge typisch? (optional: nur Warnung) + if edge_kind not in topology.get("typical", []): + log.info(f"Edge '{edge_kind}' von {source_type} → {target_type} ist atypisch") + + return True +``` + +**Beispiel:** + +```markdown +## Situation ^sit +> [!section] experience + +## Reflexion ^ref +> [!section] insight +> [!edge] derives +> [[#^sit]] +``` + +**Validierung:** + +| Prüfung | Wert | +|---------|------| +| Source-Typ | `insight` (nicht `experience`!) | +| Target-Typ | `experience` (nicht `experience`!) | +| Edge-Kind | `derives` | +| Schema-Lookup | `get_topology_info("insight", "experience")` | +| Ergebnis | `derived_from` ist in `typical` für `insight → experience` → **VALID** | + +**Wichtig:** Die Validierung erfolgt **nicht** gegen den übergeordneten `note_type`, sondern gegen die effektiven Chunk-Typen. Dies ermöglicht: + +1. Semantisch korrekte Validierung von Gedankenketten innerhalb einer Note +2. Konsistenz mit dem Graph-Schema auch bei gemischten Section-Types +3. Frühzeitige Erkennung von semantisch unsinnigen Verbindungen + +**Fehlerbehandlung:** + +| Szenario | Verhalten | +|----------|-----------| +| Edge in `prohibited` | Edge wird **abgelehnt** (nicht gespeichert) | +| Edge nicht in `typical` | Edge wird gespeichert, aber mit `confidence: 0.7` markiert | +| Schema-Lookup fehlgeschlagen | Fallback auf `any → any` Regel (`related_to`, `references`) | + +--- + +## 5. Nicht-Funktionale Anforderungen + +### 5.1 Performance + +| Anforderung | Metrik | +|-------------|--------| +| **NFA-01**: Parser-Performance | Max. 10% Overhead durch Section-Type-Tracking | +| **NFA-02**: Section-Chunk-Mapping | O(1) Lookup via Dictionary | +| **NFA-03**: EdgeRegistry-Reload | Hot-Reload bei Dateiänderung (bereits implementiert) | + +### 5.2 Konfigurierbarkeit + +| Anforderung | Beschreibung | +|-------------|--------------| +| **NFA-04**: Section-Type-Validation | Optional aktivierbar (Warnung vs. Error) | +| **NFA-05**: Default-Edge-Generierung | Optional deaktivierbar via Flag | +| **NFA-06**: Aggregation-Level | Konfigurierbar in `retriever.yaml` | + +### 5.3 Testbarkeit + +| Anforderung | Beschreibung | +|-------------|--------------| +| **NFA-07**: Unit-Tests | 100% Coverage für neue Parser-Logik | +| **NFA-08**: Integration-Tests | End-to-End-Tests für Chunking → Edging → Retrieval | +| **NFA-09**: Regression-Tests | Bestehende Test-Suite muss grün bleiben | + +--- + +## 6. Technische Spezifikation + +### 6.1 Betroffene Komponenten + +#### 6.1.1 Chunking-Module + +| Datei | Änderungen | +|-------|------------| +| `app/core/chunking/chunking_models.py` | Neues Feld `section_type: Optional[str]` in `RawBlock` und `Chunk` | +| `app/core/chunking/chunking_parser.py` | Erkennung `[!section]` (auch separat platziert), State-Machine für Scope, automatische Section-Erkennung bei neuen Überschriften | +| `app/core/chunking/chunking_processor.py` | Übergabe `section_type` an Chunks, Tracking der Section-Einführungsebene | +| `app/core/ingestion/ingestion_chunk_payload.py` | Fallback-Logik `type = section_type || note_type`, neues Feld `note_type` | + +#### 6.1.2 Edge-Extraktions-Module + +| Datei | Änderungen | +|-------|------------| +| `app/core/graph/graph_extractors.py` | Verschachtelte Callouts in `[!abstract]`-Containern korrekt verarbeiten, Leere Zeilen mit `>` innerhalb von Containern nicht als Block-Ende behandeln | + +#### 6.1.2 Edge-Module + +| Datei | Änderungen | +|-------|------------| +| `app/core/graph/graph_derive_edges.py` | Section-Chunk-Mapping, Intra-Note-Edge-Erstellung | +| `app/core/graph/graph_utils.py` | `parse_link_target()` erweitern für Block-IDs | +| `app/services/edge_registry.py` | Keine Änderung (bereits vollständig) | + +#### 6.1.3 Retriever-Module + +| Datei | Änderungen | +|-------|------------| +| `app/core/retrieval/retriever.py` | Optionale Chunk-Level-Deduplizierung | +| `config/retriever.yaml` | Neue Sektion `aggregation` | + +#### 6.1.4 Datenbank + +| Komponente | Änderungen | +|------------|------------| +| Qdrant `mindnet_chunks` | Neuer Keyword-Index auf `note_type` | +| Qdrant `mindnet_edges` | Neuer Boolean-Index auf `is_internal` | + +### 6.2 Datenfluss + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ MARKDOWN NOTE │ +│ ## Situation ^sit │ +│ > [!section] experience │ +│ Die Geschichte... │ +│ │ +│ ## Reflexion ^ref │ +│ > [!section] insight │ +│ > [!edge] derives │ +│ > [[#^sit]] │ +│ Was ich daraus lerne... │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ CHUNKING PARSER │ +│ 1. Erkennt [!section] experience → section_type = "experience" │ +│ 2. Erkennt [!section] insight → section_type = "insight" │ +│ 3. Erstellt section_chunk_map: {"^sit": "Note#c01", "^ref": ...} │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ CHUNK PAYLOADS │ +│ Chunk #c01: { type: "experience", note_type: "experience", ... } │ +│ Chunk #c02: { type: "insight", note_type: "experience", ... } │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ EDGE DERIVATION │ +│ 1. Explizit: [!edge] derives [[#^sit]] │ +│ → Edge: Note#c02 --[derives]--> Note#c01 │ +│ 2. Falls kein [!edge]: │ +│ → Lookup: get_topology_info("experience", "insight") │ +│ → Default: "resulted_in" (erster typical) │ +│ → Edge: Note#c01 --[resulted_in]--> Note#c02 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ QDRANT STORAGE │ +│ mindnet_chunks: [Chunk#c01, Chunk#c02] │ +│ mindnet_edges: [next, prev, belongs_to, derives, resulted_in] │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ RETRIEVER │ +│ - Scoring auf Chunk-Ebene │ +│ - retriever_weight aus types.yaml für "insight" → 1.20 │ +│ - Edge-Bonus für "derives" aus retriever.yaml │ +│ - Aggregation: note oder chunk (konfigurierbar) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 Provenance-Literale und Metadaten + +#### Valide `provenance`-Werte (EdgeDTO-Constraint) + +Das `EdgeDTO` akzeptiert ausschließlich folgende Literale: + +| Provenance | Verwendung | Beschreibung | +|------------|------------|--------------| +| `explicit` | Autor-definiert | Kanten aus `[!edge]`-Callouts, Wikilinks, `[[rel:kind\|target]]` | +| `rule` | Schema-abgeleitet | Default-Kanten aus `graph_schema.md` (FA-08) | +| `smart` | KI-generiert | Durch LLM validierte oder vorgeschlagene Kanten | +| `structure` | System | Struktur-Kanten: `next`, `prev`, `belongs_to` | + +#### Zusätzliche Metadaten via `source_hint` + +Für feinere Unterscheidungen wird das optionale Feld `source_hint` verwendet: + +| `source_hint` | Bedeutung | +|---------------|-----------| +| `callout` | Kante stammt aus `[!edge]`-Callout | +| `wikilink` | Kante stammt aus Standard-Wikilink `[[Target]]` | +| `inline_rel` | Kante stammt aus `[[rel:kind\|target]]` Format | +| `schema_default` | Kante wurde aus `graph_schema.md` abgeleitet (FA-08) | +| `note_scope` | Kante aus Note-Scope-Zone (z.B. `## Smart Edges`) | + +**Beispiel Edge-Payload:** + +```python +{ + "kind": "derives", + "source_id": "note#c02", + "target_id": "note#c01", + "provenance": "explicit", # Valides Literal + "source_hint": "callout", # Detailinformation + "is_internal": True, + "scope": "chunk" +} +``` + +### 6.4 Konfigurationserweiterungen + +#### 6.4.1 `config/types.yaml` + +```yaml +# Neue Sektion (optional) +section_type_settings: + enabled: true + validate_types: true # Warnung bei unbekanntem Section-Type + generate_default_edges: true # Default-Edges aus graph_schema.md +``` + +#### 6.4.2 `config/retriever.yaml` + +```yaml +# Neue Sektion: Aggregation +aggregation: + level: note # "note" oder "chunk" + max_chunks_per_note: 3 # Limit bei note-level aggregation + +# Neue Sektion: Edge-Scoring für Intra-Note-Edges (WP-26) +edge_scoring: + internal_edge_boost: 1.2 # +20% Boost für is_internal: true Edges + external_edge_boost: 1.0 # Standard für is_internal: false Edges + +# Neue Sektion: Fragmentierungs-Mitigation (WP-26) +fragmentation_mitigation: + enable_note_expansion: true # Bei Chunk-Hit: Lade benachbarte Chunks derselben Note + max_expansion_depth: 2 # Maximale Tiefe der Intra-Note-Expansion + prefer_internal_edges: true # Bevorzuge is_internal Edges bei Expansion +``` + +#### 6.4.3 `.env` + +```env +# Bereits vorhanden, keine Änderung erforderlich +MINDNET_SCHEMA_PATH=/path/to/graph_schema.md +``` + +--- + +## 7. Testspezifikation + +### 7.1 Unit-Tests + +#### UT-01: Parser – Section-Type-Erkennung + +```python +def test_section_type_recognition(): + md = """ + ## Reflexion ^ref + > [!section] insight + Content here. + """ + blocks, _ = parse_blocks(md) + assert blocks[1].section_type == "insight" +``` + +#### UT-02: Parser – Scope-Beendigung + +```python +def test_section_type_scope_ends_at_same_level_heading(): + md = """ + ## Section A + > [!section] insight + Content A. + + ## Section B + Content B. + """ + blocks, _ = parse_blocks(md) + # Section A hat section_type "insight" + # Section B hat section_type None (Fallback auf note_type) +``` + +#### UT-03: Payload – Type-Befüllung + +```python +def test_chunk_type_uses_section_type_when_present(): + note = {"frontmatter": {"type": "experience"}} + chunks = [Chunk(section_type="insight", ...)] + payloads = make_chunk_payloads(note, "path", chunks) + assert payloads[0]["type"] == "insight" + assert payloads[0]["note_type"] == "experience" +``` + +#### UT-04: Section-Chunk-Mapping + +```python +def test_section_chunk_mapping_with_block_id(): + mapping = build_section_chunk_map(chunks) + assert mapping["^sit"] == "NoteID#c01" + assert mapping["Situation"] == "NoteID#c01" +``` + +#### UT-05: Intra-Note-Edge-Erstellung + +```python +def test_intra_note_edge_created(): + edges = build_edges_for_note(note, chunks, section_map) + intra_edges = [e for e in edges if e["source_id"].startswith(note_id) + and e["target_id"].startswith(note_id) + and e["kind"] not in ["next", "prev", "belongs_to"]] + assert len(intra_edges) == 1 + assert intra_edges[0]["kind"] == "derives" +``` + +#### UT-06: Default-Edge aus Schema + +```python +def test_default_edge_from_schema(): + # Keine expliziten Edges, aber Section-Types vorhanden + edges = build_edges_for_note(note, chunks, section_map, use_schema_defaults=True) + schema_edges = [e for e in edges if e["provenance"] == "rule" + and e.get("source_hint") == "schema_default"] + assert len(schema_edges) == 1 + assert schema_edges[0]["kind"] == "resulted_in" # Aus graph_schema.md +``` + +#### UT-07: Abwärtskompatibilität + +```python +def test_backwards_compatibility_no_section_callouts(): + md = """ + ## Heading + Content without section callout. + """ + blocks, _ = parse_blocks(md) + assert all(b.section_type is None for b in blocks) + + payloads = make_chunk_payloads(note, "path", chunks) + assert payloads[0]["type"] == "experience" # Note-Type als Fallback +``` + +#### UT-08: Verschachtelte Edge-Callouts in Container + +```python +def test_nested_edge_callouts_in_abstract_container(): + md = """ + > [!abstract] Semantic Edges + >> [!edge] derived_from + >> [[Target1#Section]] + > + >> [!edge] solves + >> [[Target2]] + """ + edges, _ = extract_callout_relations(md) + assert len(edges) == 2 + assert edges[0] == ("derived_from", "Target1#Section") + assert edges[1] == ("solves", "Target2") +``` + +#### UT-09: Automatische Section-Erkennung bei neuen Überschriften + +```python +def test_automatic_section_recognition_at_same_heading_level(): + md = """ + ## Situation ^sit + > [!section] experience + Content A. + + ## Reflexion ^ref + Content B. + + ## Learnings ^learn + > [!section] insight + Content C. + + ## Ausblick ^out + Content D. + """ + blocks, _ = parse_blocks(md) + chunks = assemble_chunks(blocks, note_type="experience") + + # Vier separate Chunks erwartet (eine pro H2) + assert len(chunks) == 4 + + # Chunk 1: Expliziter section_type + assert chunks[0].section_type == "experience" + + # Chunk 2: Neue Section ohne Callout → Fallback auf None (wird zu note_type) + assert chunks[1].section_type is None + + # Chunk 3: Expliziter section_type + assert chunks[2].section_type == "insight" + + # Chunk 4: Neue Section ohne Callout → Fallback auf None + assert chunks[3].section_type is None +``` + +#### UT-10: Separates Section-Callout an beliebiger Stelle + +```python +def test_section_callout_separate_from_edge_callout(): + md = """ + ## Reflexion ^ref + + Einleitender Text hier... + + > [!section] insight + + Weiterer normaler Inhalt... + + > [!abstract] Semantic Edges + >> [!edge] derives + >> [[#^sit]] + """ + blocks, _ = parse_blocks(md) + + # Section-Type wird korrekt erkannt, obwohl nicht direkt unter Überschrift + section_block = [b for b in blocks if b.section_type == "insight"] + assert len(section_block) > 0 + + # Edge-Callouts werden separat verarbeitet + edges, _ = extract_callout_relations(md) + assert len(edges) == 1 + assert edges[0] == ("derives", "#^sit") +``` + +#### UT-11: Mehrere Edges im gleichen Container-Callout + +```python +def test_multiple_edges_in_same_container(): + md = """ + > [!abstract] Semantic Edges + >> [!edge] derives + >> [[Target1]] + >> [[Target2]] + > + >> [!edge] supports + >> [[Target3]] + """ + edges, _ = extract_callout_relations(md) + assert len(edges) == 3 + assert edges[0] == ("derives", "Target1") + assert edges[1] == ("derives", "Target2") + assert edges[2] == ("supports", "Target3") +``` + +#### UT-12: Body-Section für Textblöcke vor erstem [!section] + +```python +def test_body_section_for_text_before_first_section_callout(): + md = """ + # Meine Erfahrung + + Einleitender Text ohne Section-Callout... + + ## Situation ^sit + > [!section] experience + + Die eigentliche Geschichte... + """ + blocks, _ = parse_blocks(md) + chunks = assemble_chunks(blocks, note_type="experience") + + # Erster Chunk ist Body-Section + assert chunks[0].section == "body" + assert chunks[0].section_type is None + + # Zweiter Chunk hat expliziten Section-Type + assert chunks[1].section == "Situation" + assert chunks[1].section_type == "experience" +``` + +#### UT-13: is_internal Flag für Intra-Note-Edges + +```python +def test_is_internal_flag_for_intra_note_edges(): + edges = build_edges_for_note(note, chunks, section_map) + + intra_edges = [e for e in edges if e["source_id"].startswith(note_id) + and e["target_id"].startswith(note_id)] + inter_edges = [e for e in edges if not e["target_id"].startswith(note_id)] + + # Alle Intra-Note-Edges haben is_internal: True + assert all(e.get("is_internal") == True for e in intra_edges) + + # Alle Inter-Note-Edges haben is_internal: False + assert all(e.get("is_internal") == False for e in inter_edges) +``` + +#### UT-14: Schema-Validierung gegen effektiven Chunk-Typ + +```python +def test_schema_validation_uses_effective_chunk_type(): + # Chunk mit section_type "insight" in Note mit note_type "experience" + source_chunk = {"type": "insight", "note_type": "experience"} + target_chunk = {"type": "experience", "note_type": "experience"} + edge = {"kind": "derived_from"} + + # Validierung erfolgt gegen insight → experience (nicht experience → experience) + is_valid = validate_intra_note_edge(edge, source_chunk, target_chunk) + assert is_valid == True # derived_from ist typical für insight → experience +``` + +#### UT-15: Zyklen-Erkennung bei Intra-Note-Edges + +```python +def test_cycle_detection_in_intra_note_edges(): + edges = [ + {"source_id": "note#c01", "target_id": "note#c02", "kind": "derives", "is_internal": True}, + {"source_id": "note#c02", "target_id": "note#c01", "kind": "caused_by", "is_internal": True} + ] + + cycles = detect_cycles(edges, "note") + assert len(cycles) == 1 + assert set(cycles[0]) == {"note#c01", "note#c02"} +``` + +### 7.2 Integration-Tests + +#### IT-01: End-to-End Ingestion + +```python +def test_e2e_ingestion_with_section_types(): + # 1. Ingestiere Note mit Section-Types + # 2. Prüfe Chunks in Qdrant + # 3. Prüfe Edges in Qdrant + # 4. Führe Query aus + # 5. Verifiziere Ranking +``` + +#### IT-02: Retriever mit Intra-Note-Edges + +```python +def test_retriever_uses_intra_note_edges(): + # Query, die Chunk#c02 (insight) findet + # Prüfe, dass Edge-Bonus von "derives" zu Chunk#c01 angewendet wird +``` + +### 7.3 Regression-Tests + +Alle bestehenden Tests in `tests/` müssen grün bleiben. + +--- + +## 8. Risiken und Gegenmaßnahmen + +### 8.1 Risikomatrix (Fokus: Datenintegrität & Retrieval) + +| ID | Risiko | Wahrscheinlichkeit | Auswirkung | Mitigation | +|----|--------|-------------------|------------|------------| +| R-01 | Scope-Berechnung fehlerhaft | Mittel | Hoch | Umfangreiche Unit-Tests, Edge-Cases dokumentieren | +| R-02 | Block-IDs nicht gepflegt | Hoch | Mittel | Fallback auf Heading-Links, Dokumentation der Spezifikation (Kap. 3) | +| R-03 | Performance-Regression | Niedrig | Mittel | Benchmarks vor/nach, Lazy Evaluation | +| R-04 | Retriever-Ergebnisse ändern sich | Mittel | Hoch | Feature-Flags, A/B-Testing | +| R-05 | Zyklische Intra-Note-Edges | Mittel | Mittel | Zyklen-Erkennung während Edge-Erstellung | +| R-06 | Fragmentierung im Vektorraum | Mittel | Hoch | Kohärenz-Mechanismen (siehe 8.2) | +| R-07 | Semantische Inkonsistenz | Niedrig | Hoch | Schema-Validierung (FA-12) | +| R-08 | Provenance-Validierungsfehler | Mittel | Hoch | Nur valide Literale (`explicit`, `rule`, `smart`, `structure`) verwenden | + +### 8.2 Detailanalyse kritischer Risiken + +#### R-05: Zirkelbezüge bei Intra-Note-Links + +**Beschreibung:** Intra-Note-Edges können zirkuläre Abhängigkeiten erzeugen, z.B.: +- `#c01 → derives → #c02` +- `#c02 → caused_by → #c01` + +**Auswirkung:** +- Endlosschleifen bei Graph-Traversal +- Semantisch unsinnige Gedankenfolgen +- Explosion der Edge-Gewichtung beim Scoring + +**Gegenmaßnahmen:** + +1. **Erkennung während Ingestion:** + +```python +def detect_cycles(edges: List[dict], note_id: str) -> List[List[str]]: + """Erkennt Zyklen in Intra-Note-Edges mittels DFS.""" + intra_edges = [e for e in edges if e.get("is_internal")] + graph = build_adjacency_list(intra_edges) + return find_all_cycles(graph) +``` + +2. **Behandlung:** + - Zyklen werden **nicht abgelehnt**, aber mit `has_cycle: true` markiert + - Retriever kann Zyklen-Edges optional niedriger gewichten + - Warnung im Ingestion-Log + +3. **Monitoring:** Logging und Metriken für Zyklen-Häufigkeit + +#### R-06: Fragmentierung im Vektorraum + +**Beschreibung:** Wenn Chunks derselben Note unterschiedliche `type`-Werte haben, kann der semantische Zusammenhalt der Note im Vektorraum fragmentieren: + +- Chunk `#c01` (type: `experience`) liegt im "Experience-Cluster" +- Chunk `#c02` (type: `insight`) liegt im "Insight-Cluster" +- Die Note als Ganzes hat keine einheitliche Repräsentation + +**Auswirkung:** +- Bei Suche nach "experience"-Themen wird nur Teil der Note gefunden +- Kontext-Verlust bei Retrieval +- Inkonsistente Rankings + +**Gegenmaßnahmen:** + +1. **Kohärenz-Erhalt durch `note_type`:** + - Jeder Chunk trägt zusätzlich `note_type` (siehe FA-04) + - Queries können auf `note_type` filtern, um ganze Notes zu finden + - Hybrid-Strategie: Erst Section-Type-Match, dann Note-Type-Expansion + +2. **Intra-Note-Edges als Brücken:** + - Die semantischen Edges innerhalb der Note (`derives`, `caused_by`, etc.) verbinden die fragmentierten Chunks + - Graph-Expansion nutzt diese Edges für Kohärenz-Retrieval + - `is_internal: true` Edges erhalten höheren Boost (FA-07b) + +3. **Window-Embedding mit Note-Kontext:** + - Das `window`-Feld des Chunks enthält bereits H1-Titel als Kontext-Präfix + - Optional: Note-Type als zusätzlicher Kontext im Window + +```python +# Beispiel Window-Bildung mit Note-Kontext +window = f"[{note_type}] {h1_title} > {section_title}\n{chunk_text}" +``` + +4. **Retrieval-Strategie:** + +```yaml +# In retriever.yaml +fragmentation_mitigation: + enable_note_expansion: true # Bei Chunk-Hit: Lade benachbarte Chunks derselben Note + max_expansion_depth: 2 # Maximale Tiefe der Intra-Note-Expansion + prefer_internal_edges: true # Bevorzuge is_internal Edges bei Expansion +``` + +5. **Monitoring:** + - Metrik: "Durchschnittliche Chunk-Distanz innerhalb einer Note" + - Alert bei Notes mit hoher interner Fragmentierung + +--- + +## 9. Implementierungsplan + +### Phase 1: Chunking-Erweiterung (4-5h) + +1. `chunking_models.py`: Feld `section_type` hinzufügen in `RawBlock` und `Chunk` +2. `chunking_parser.py`: + - `[!section]` Erkennung (auch separat platziert, nicht nur direkt unter Überschrift) + - State-Machine für Scope-Tracking + - Automatische Section-Erkennung bei neuen Überschriften gleicher Ebene + - Tracking der `section_introduced_at_level` +3. `chunking_processor.py`: Durchreichen von `section_type` +4. Unit-Tests für Parser (UT-01, UT-02, UT-09, UT-10, UT-12) + +### Phase 2: Edge-Extraktor-Erweiterung (1-2h) + +1. `graph_extractors.py`: + - Verschachtelte Callouts in `[!abstract]`-Containern korrekt verarbeiten + - Leere Zeilen mit `>` innerhalb von Containern nicht als Block-Ende behandeln +2. Unit-Tests für verschachtelte Callouts (UT-08, UT-11) + +### Phase 3: Payload-Anpassung (1-2h) + +1. `ingestion_chunk_payload.py`: Fallback-Logik, Feld `note_type` +2. Qdrant-Index für `note_type` +3. Unit-Tests für Payload (UT-03) + +### Phase 4: Edge-Erweiterung (4-5h) + +1. `graph_derive_edges.py`: Section-Chunk-Mapping erstellen +2. `graph_derive_edges.py`: Intra-Note-Edge-Erstellung mit `is_internal` Flag +3. Default-Edge-Generierung aus graph_schema.md +4. Schema-Validierung gegen effektiven Chunk-Typ (FA-12) +5. Zyklen-Erkennung (R-05 Mitigation) +6. Qdrant-Index für `is_internal` +7. Unit-Tests für Edges (UT-04, UT-05, UT-06, UT-13, UT-14, UT-15) + +### Phase 5: Retriever-Anpassung (3-4h) + +1. `retriever.yaml`: Aggregation-Konfiguration, `edge_scoring`, `fragmentation_mitigation` +2. `retriever.py`: Optionale Chunk-Level-Deduplizierung +3. `retriever_scoring.py`: Priorisierung Section-Type vor Note-Type (FA-09b) +4. `retriever.py`: Internal-Edge-Boost (FA-07b) +5. Integration-Tests (IT-01, IT-02) + +### Phase 6: Qualitätssicherung (2-3h) + +1. Regression-Tests +2. Manuelle Tests mit echten Vault-Daten +3. Performance-Benchmarks +4. Dokumentation aktualisieren + +**Gesamtaufwand: 15-21h** + +--- + +## 10. Abnahmekriterien + +| ID | Kriterium | Prüfmethode | +|----|-----------|-------------| +| AK-01 | Section-Types werden korrekt erkannt | Unit-Test UT-01 | +| AK-02 | Chunk.type enthält Section-Type wenn definiert | Unit-Test UT-03 | +| AK-03 | Intra-Note-Edges werden erstellt | Unit-Test UT-05 | +| AK-04 | Default-Edges aus graph_schema.md | Unit-Test UT-06 | +| AK-05 | Bestehende Notes funktionieren unverändert | Unit-Test UT-07, Regression | +| AK-06 | Alle Tests grün | CI/CD Pipeline | +| AK-07 | Performance < 10% Overhead | Benchmark | +| AK-08 | Verschachtelte Edge-Callouts in Containern funktionieren | Unit-Test UT-08, UT-11 | +| AK-09 | Automatische Section-Erkennung bei neuen Überschriften | Unit-Test UT-09 | +| AK-10 | Separates `[!section]`-Callout wird erkannt | Unit-Test UT-10 | +| AK-11 | Body-Section für Text vor erstem `[!section]` | Unit-Test UT-12 | +| AK-12 | `is_internal` Flag korrekt gesetzt | Unit-Test UT-13 | +| AK-13 | Schema-Validierung gegen effektiven Chunk-Typ | Unit-Test UT-14 | +| AK-14 | Zyklen-Erkennung funktioniert | Unit-Test UT-15 | +| AK-15 | Retrieval-Priorisierung nutzt Section-Type | Integration-Test IT-02 | + +--- + +## 11. Anhang + +### A. Beispiel-Note + +```markdown +--- +id: erlebnis-konflikt-team +title: Konflikt im Team-Meeting +type: experience +tags: [team, konflikt, learning] +--- + +# Konflikt im Team-Meeting + +## Situation ^sit +> [!section] experience + +Am 15. Januar 2026 kam es im Sprint-Review zu einer Eskalation... + +## Meine Reaktion ^react +> [!section] experience +> [!edge] followed_by +> [[#^sit]] + +Ich habe versucht zu deeskalieren, aber... + +## Reflexion ^ref +> [!section] insight + +Diese Erfahrung zeigt mir, dass ich in Konfliktsituationen... + +> [!abstract] Semantic Edges +>> [!edge] derives +>> [[#^sit]] +>> [[#^react]] + +## Nächste Schritte ^next +> [!section] decision + +Ich werde in Zukunft: +1. Früher eingreifen +2. Neutrale Sprache verwenden + +> [!abstract] Semantic Edges +>> [!edge] caused_by +>> [[#^ref]] +``` + +### B. Erwartete Chunks + +| Chunk-ID | type | note_type | section | section_type | +|----------|------|-----------|---------|--------------| +| `erlebnis-konflikt-team#c01` | experience | experience | Situation | `experience` | +| `erlebnis-konflikt-team#c02` | experience | experience | Meine Reaktion | `experience` | +| `erlebnis-konflikt-team#c03` | insight | experience | Reflexion | `insight` | +| `erlebnis-konflikt-team#c04` | decision | experience | Nächste Schritte | `decision` | + +### C. Erwartete Edges (semantisch) + +| Source | Kind | Target | Provenance | source_hint | is_internal | +|--------|------|--------|------------|-------------|-------------| +| #c02 | followed_by | #c01 | `explicit` | `callout` | `true` | +| #c03 | derives | #c01 | `explicit` | `callout` | `true` | +| #c03 | derives | #c02 | `explicit` | `callout` | `true` | +| #c04 | caused_by | #c03 | `explicit` | `callout` | `true` | + +### D. Schema-Validierung der Edges + +| Edge | Source-Typ | Target-Typ | Schema-Lookup | Ergebnis | +|------|------------|------------|---------------|----------| +| #c02 → #c01 | experience | experience | `get_topology_info("experience", "experience")` | `followed_by` in typical → **VALID** | +| #c03 → #c01 | insight | experience | `get_topology_info("insight", "experience")` | `derives` äquivalent zu `caused_by` → **VALID** | +| #c03 → #c02 | insight | experience | `get_topology_info("insight", "experience")` | `derives` äquivalent zu `caused_by` → **VALID** | +| #c04 → #c03 | decision | insight | `get_topology_info("decision", "insight")` | `caused_by` in typical → **VALID** | + +--- + +## 12. Änderungshistorie + +| Version | Datum | Autor | Änderung | +|---------|-------|-------|----------| +| 1.0 | 25.01.2026 | Claude (Cursor Agent) | Initiale Erstellung | +| 1.1 | 25.01.2026 | Claude (Cursor Agent) | Ergänzung: FA-01b (Verschachtelte Edge-Callouts), FA-02b (Automatische Section-Erkennung), UT-08 bis UT-11, AK-08 bis AK-10, aktualisierter Implementierungsplan | +| 1.2 | 25.01.2026 | Claude (Cursor Agent) | **WP-26 Release:** FA-03b (Body-Section Default-Handling), FA-07b (`is_internal` Flag), FA-09b (Retrieval-Priorisierung Section-Type), FA-12 (Schema-Validierung gegen effektiven Chunk-Typ), FA-13 (Block-ID Autovervollständigung), Kapitel 8 erweitert (Fragmentierung im Vektorraum, Zirkelbezüge), UT-12 bis UT-15, AK-11 bis AK-15, Phase 6 (Obsidian-Plugin), Gesamtaufwand auf 17-24h aktualisiert | +| 1.3 | 25.01.2026 | Claude (Cursor Agent) | **Refactoring Backend-Fokus:** Kapitel 3 umstrukturiert zu "Schnittstellen-Spezifikation für Obsidian-Assistenten", FA-13 (Plugin-Implementierung) entfernt, Provenance-Literale korrigiert (`explicit`, `rule`, `smart`, `structure` statt Subtypen), neues Feld `source_hint` für Detailinformationen, Phase 6 (Plugin) entfernt, Gesamtaufwand auf 15-21h reduziert, R-08 (Provenance-Validierungsfehler) hinzugefügt | + +--- + +**Ende des Lastenhefts** diff --git a/tests/test_wp26_section_types.py b/tests/test_wp26_section_types.py new file mode 100644 index 0000000..f8e4464 --- /dev/null +++ b/tests/test_wp26_section_types.py @@ -0,0 +1,265 @@ +""" +FILE: tests/test_wp26_section_types.py +DESCRIPTION: Unit-Tests für WP-26 Phase 1: Section-Types und Intra-Note-Edges +VERSION: 1.0.0 +""" +import pytest +from app.core.chunking.chunking_parser import parse_blocks +from app.core.chunking.chunking_models import RawBlock, Chunk +from app.core.graph.graph_utils import normalize_provenance, _edge + + +class TestSectionTypeRecognition: + """UT-01: Parser – Section-Type-Erkennung""" + + def test_section_type_recognition(self): + """Testet, ob [!section]-Callouts korrekt erkannt werden.""" + md = """ +## Reflexion ^ref +> [!section] insight + +Content here about insights. +""" + blocks, _ = parse_blocks(md) + + # Finde den Paragraph-Block nach dem Section-Callout + paragraph_blocks = [b for b in blocks if b.kind == "paragraph"] + assert len(paragraph_blocks) >= 1 + + # Der Paragraph-Block sollte section_type "insight" haben + assert paragraph_blocks[0].section_type == "insight" + + def test_section_type_with_block_id(self): + """Testet, ob Block-IDs in Überschriften korrekt extrahiert werden.""" + md = """ +## Situation ^sit +> [!section] experience + +Die Geschichte beginnt hier. +""" + blocks, _ = parse_blocks(md) + + # Finde den Heading-Block + heading_blocks = [b for b in blocks if b.kind == "heading"] + assert len(heading_blocks) >= 1 + + # Block-ID sollte "sit" sein + assert heading_blocks[0].block_id == "sit" + + +class TestSectionTypeScope: + """UT-02: Parser – Scope-Beendigung""" + + def test_section_type_scope_ends_at_same_level_heading(self): + """Section-Type endet bei nächster H2.""" + md = """ +## Section A +> [!section] insight + +Content A with insight. + +## Section B + +Content B without section callout. +""" + blocks, _ = parse_blocks(md) + + # Finde Paragraph-Blöcke + paragraphs = [b for b in blocks if b.kind == "paragraph"] + + # Erster Paragraph hat section_type "insight" + assert paragraphs[0].section_type == "insight" + + # Zweiter Paragraph hat section_type None (Reset) + assert paragraphs[1].section_type is None + + +class TestProvenanceNormalization: + """UT für Provenance-Normalisierung (WP-26 v1.0)""" + + def test_normalize_explicit_callout(self): + """explicit:callout -> (explicit, callout)""" + prov, hint = normalize_provenance("explicit:callout") + assert prov == "explicit" + assert hint == "callout" + + def test_normalize_explicit_wikilink(self): + """explicit:wikilink -> (explicit, wikilink)""" + prov, hint = normalize_provenance("explicit:wikilink") + assert prov == "explicit" + assert hint == "wikilink" + + def test_normalize_structure_belongs_to(self): + """structure:belongs_to -> (structure, belongs_to)""" + prov, hint = normalize_provenance("structure:belongs_to") + assert prov == "structure" + assert hint == "belongs_to" + + def test_normalize_schema_default(self): + """inferred:schema -> (rule, schema_default)""" + prov, hint = normalize_provenance("inferred:schema") + assert prov == "rule" + assert hint == "schema_default" + + def test_normalize_unknown_fallback(self): + """Unbekannte Provenance -> (explicit, None)""" + prov, hint = normalize_provenance("unknown_provenance") + assert prov == "explicit" + assert hint is None + + +class TestIsInternalFlag: + """UT-13: is_internal Flag für Intra-Note-Edges""" + + def test_is_internal_true_for_same_note(self): + """Edges zwischen Chunks derselben Note haben is_internal=True""" + edge = _edge( + kind="derives", + scope="chunk", + source_id="note1#c01", + target_id="note1#c02", + note_id="note1" + ) + assert edge["is_internal"] is True + + def test_is_internal_false_for_different_notes(self): + """Edges zwischen verschiedenen Notes haben is_internal=False""" + edge = _edge( + kind="references", + scope="chunk", + source_id="note1#c01", + target_id="note2#c01", + note_id="note1" + ) + assert edge["is_internal"] is False + + def test_is_internal_true_for_note_to_chunk(self): + """Edges von Note zu eigenem Chunk haben is_internal=True""" + edge = _edge( + kind="belongs_to", + scope="chunk", + source_id="note1#c01", + target_id="note1", + note_id="note1" + ) + assert edge["is_internal"] is True + + +class TestEdgeProvenanceInPayload: + """Test für Provenance-Normalisierung in Edge-Payloads""" + + def test_edge_provenance_normalized(self): + """Provenance wird in Edge-Payloads normalisiert""" + edge = _edge( + kind="derives", + scope="chunk", + source_id="note1#c01", + target_id="note1#c02", + note_id="note1", + extra={"provenance": "explicit:callout"} + ) + + assert edge["provenance"] == "explicit" + assert edge["source_hint"] == "callout" + + +class TestAutomaticSectionRecognition: + """UT-09: Automatische Section-Erkennung bei neuen Überschriften""" + + def test_automatic_section_recognition_at_same_heading_level(self): + """Neue Überschriften auf gleicher Ebene starten automatisch neue Sections""" + md = """ +## Situation ^sit +> [!section] experience + +Content A. + +## Reflexion ^ref + +Content B. + +## Learnings ^learn +> [!section] insight + +Content C. + +## Ausblick ^out + +Content D. +""" + blocks, _ = parse_blocks(md) + + # Sammle alle Paragraph-Blöcke in Reihenfolge + paragraphs = [b for b in blocks if b.kind == "paragraph"] + + assert len(paragraphs) == 4 + + # Chunk 1: Expliziter section_type "experience" + assert paragraphs[0].section_type == "experience" + + # Chunk 2: Neue Section ohne Callout → None (Fallback auf note_type) + assert paragraphs[1].section_type is None + + # Chunk 3: Expliziter section_type "insight" + assert paragraphs[2].section_type == "insight" + + # Chunk 4: Neue Section ohne Callout → None (Fallback auf note_type) + assert paragraphs[3].section_type is None + + +class TestSeparateSectionCallout: + """UT-10: Separates Section-Callout an beliebiger Stelle""" + + def test_section_callout_separate_from_edge_callout(self): + """Section-Callout kann separat von Edge-Callouts stehen""" + md = """ +## Reflexion ^ref + +Einleitender Text hier... + +> [!section] insight + +Weiterer normaler Inhalt... + +> [!edge] derives +> [[#^sit]] +""" + blocks, _ = parse_blocks(md) + + # Finde Paragraph-Blöcke nach dem Section-Callout + paragraphs = [b for b in blocks if b.kind == "paragraph"] + + # Es sollten mindestens 2 Paragraphen geben + assert len(paragraphs) >= 2 + + # Der erste Paragraph hat noch keinen section_type (vor dem Callout) + # Der zweite Paragraph hat section_type "insight" + # Hinweis: Die genaue Zuordnung hängt von der Parser-Implementierung ab + section_types = [p.section_type for p in paragraphs] + assert "insight" in section_types + + +class TestNestedEdgeCallouts: + """UT-08: Verschachtelte Edge-Callouts in Container""" + + def test_nested_callouts_recognized(self): + """Verschachtelte Callouts werden als Callout-Blöcke erkannt""" + md = """ +> [!abstract] Semantic Edges +>> [!edge] derived_from +>> [[Target1#Section]] +> +>> [!edge] solves +>> [[Target2]] +""" + blocks, _ = parse_blocks(md) + + # Finde Callout-Blöcke + callouts = [b for b in blocks if b.kind == "callout"] + + # Es sollte mindestens ein Callout-Block erkannt werden + assert len(callouts) >= 1 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])