""" FILE: app/core/chunking/chunking_processor.py DESCRIPTION: Der zentrale Orchestrator für das Chunking-System. AUDIT v3.3.4: Wiederherstellung der "Gold-Standard" Qualität. - Fix: Synchronisierung der Parameter (context_prefix) für alle Strategien. - Integriert physikalische Kanten-Injektion (Propagierung). - Stellt H1-Kontext-Fenster sicher. - Baut den Candidate-Pool für die WP-15b Ingestion auf. WP-24c v4.2.0: Konfigurierbare Header-Namen für LLM-Validierung. WP-24c v4.2.5: Wiederherstellung der Chunking-Präzision - Frontmatter-Override für chunking_profile - Callout-Exclusion aus Chunks - Strict-Mode ohne Carry-Over WP-24c v4.2.6: Finale Härtung - "Semantic First, Clean Second" - Callouts werden gechunkt (Chunk-Attribution), aber später entfernt (Clean-Context) - remove_callouts_from_text erst nach propagate_section_edges und Candidate Pool WP-24c v4.2.7: Wiederherstellung der Chunk-Attribution - Callout-Kanten erhalten explicit:callout Provenance im candidate_pool - graph_derive_edges.py erkennt diese und verhindert Note-Scope Duplikate """ import asyncio import re import os import logging from typing import List, Dict, Optional from .chunking_models import Chunk from .chunking_utils import get_chunk_config, extract_frontmatter_from_text from .chunking_parser import parse_blocks, parse_edges_robust from .chunking_strategies import strategy_sliding_window, strategy_by_heading from .chunking_propagation import propagate_section_edges logger = logging.getLogger(__name__) async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Optional[Dict] = None) -> List[Chunk]: """ Hauptfunktion zur Zerlegung einer Note. Verbindet Strategien mit physikalischer Kontext-Anreicherung. WP-24c v4.2.5: Frontmatter-Override für chunking_profile wird berücksichtigt. """ # 1. WP-24c v4.2.5: Frontmatter VOR Konfiguration extrahieren (für Override) fm, body_text = extract_frontmatter_from_text(md_text) # 2. Konfiguration mit Frontmatter-Override if config is None: config = get_chunk_config(note_type, frontmatter=fm) blocks, doc_title = parse_blocks(md_text) # WP-24c v4.2.6: Filtere NUR Edge-Zonen (LLM-Validierung & Note-Scope) # Callouts (is_meta_content=True) müssen durch, damit Chunk-Attribution erhalten bleibt blocks_for_chunking = [b for b in blocks if not getattr(b, 'exclude_from_chunking', False)] # Vorbereitung des H1-Präfix für die Embedding-Fenster (Breadcrumbs) h1_prefix = f"# {doc_title}" if doc_title else "" # 2. Anwendung der Splitting-Strategie # Alle Strategien nutzen nun einheitlich context_prefix für die Window-Bildung. # WP-24c v4.2.6: Callouts sind in blocks_for_chunking enthalten (für Chunk-Attribution) if config.get("strategy") == "by_heading": chunks = await asyncio.to_thread( strategy_by_heading, blocks_for_chunking, config, note_id, context_prefix=h1_prefix ) else: chunks = await asyncio.to_thread( strategy_sliding_window, blocks_for_chunking, config, note_id, context_prefix=h1_prefix ) if not chunks: return [] # 3. Physikalische Kontext-Anreicherung (Der Qualitäts-Fix) # WP-24c v4.2.6: Arbeite auf Original-Text inkl. Callouts (für korrekte Chunk-Attribution) # Schreibt Kanten aus Callouts/Inlines hart in den Text für Qdrant. chunks = propagate_section_edges(chunks) # 5. WP-15b: Candidate Pool Aufbau (Metadaten für IngestionService) # WP-24c v4.2.7: Markiere Callout-Kanten explizit für Chunk-Attribution # Zuerst die explizit im Text vorhandenen Kanten sammeln. # WP-24c v4.4.0-DEBUG: Schnittstelle 1 - Extraktion for idx, ch in enumerate(chunks): # Wir extrahieren aus dem bereits (durch Propagation) angereicherten Text. # ch.candidate_pool wird im Modell-Konstruktor als leere Liste initialisiert. for edge_info in parse_edges_robust(ch.text): edge_str = edge_info["edge"] is_callout = edge_info.get("is_callout", False) parts = edge_str.split(':', 1) if len(parts) == 2: k, t = parts # WP-24c v4.2.7: Callout-Kanten erhalten explicit:callout Provenance # WP-24c v4.4.1: Harmonisierung - Provenance muss exakt "explicit:callout" sein provenance = "explicit:callout" if is_callout else "explicit" # WP-24c v4.4.1: Verwende "to" für Kompatibilität (wird auch in graph_derive_edges.py erwartet) # Zusätzlich "target_id" für maximale Kompatibilität mit ingestion_processor Validierung pool_entry = {"kind": k, "to": t, "provenance": provenance} if is_callout: # WP-24c v4.4.1: Für Callouts auch "target_id" hinzufügen für Validierung pool_entry["target_id"] = t ch.candidate_pool.append(pool_entry) # WP-24c v4.4.0-DEBUG: Schnittstelle 1 - Logging if is_callout: logger.debug(f"DEBUG-TRACER [Extraction]: Chunk Index: {idx}, Chunk ID: {ch.id}, Kind: {k}, Target: {t}, Provenance: {provenance}, Is_Callout: {is_callout}, Raw_Edge_Str: {edge_str}") # 6. Global Pool (Unzugeordnete Kanten - kann mitten im Dokument oder am Ende stehen) # WP-24c v4.2.0: Konfigurierbare Header-Namen und -Ebene via .env # Sucht nach ALLEN Edge-Pool Blöcken im Original-Markdown (nicht nur am Ende). llm_validation_headers = os.getenv( "MINDNET_LLM_VALIDATION_HEADERS", "Unzugeordnete Kanten,Edge Pool,Candidates" ) header_list = [h.strip() for h in llm_validation_headers.split(",") if h.strip()] # Fallback auf Defaults, falls leer if not header_list: header_list = ["Unzugeordnete Kanten", "Edge Pool", "Candidates"] # Header-Ebene konfigurierbar (Default: 3 für ###) llm_validation_level = int(os.getenv("MINDNET_LLM_VALIDATION_HEADER_LEVEL", "3")) header_level_pattern = "#" * llm_validation_level # Regex-Pattern mit konfigurierbaren Headern und Ebene # WP-24c v4.2.0: finditer statt search, um ALLE Zonen zu finden (auch mitten im Dokument) # Zone endet bei einem neuen Header (jeder Ebene) oder am Dokument-Ende header_pattern = "|".join(re.escape(h) for h in header_list) zone_pattern = rf'^{re.escape(header_level_pattern)}\s*(?:{header_pattern})\s*\n(.*?)(?=\n#|$)' for pool_match in re.finditer(zone_pattern, body_text, re.DOTALL | re.IGNORECASE | re.MULTILINE): global_edges = parse_edges_robust(pool_match.group(1)) for edge_info in global_edges: edge_str = edge_info["edge"] parts = edge_str.split(':', 1) if len(parts) == 2: k, t = parts # Diese Kanten werden als "global_pool" markiert für die spätere KI-Prüfung. for ch in chunks: ch.candidate_pool.append({"kind": k, "to": t, "provenance": "global_pool"}) # 7. De-Duplikation des Pools & Linking for ch in chunks: seen = set() unique = [] for c in ch.candidate_pool: # Eindeutigkeit über Typ, Ziel und Herkunft (Provenance) key = (c["kind"], c["to"], c["provenance"]) if key not in seen: seen.add(key) unique.append(c) ch.candidate_pool = unique # 8. WP-24c v4.2.6: Clean-Context - Entferne Callout-Syntax aus Chunk-Text # WICHTIG: Dies geschieht NACH propagate_section_edges und Candidate Pool Aufbau, # damit Chunk-Attribution erhalten bleibt und Kanten korrekt extrahiert werden. # Hinweis: Callouts können mehrzeilig sein (auch verschachtelt: >>) def remove_callouts_from_text(text: str) -> str: """Entfernt alle Callout-Zeilen (> [!edge] oder > [!abstract]) aus dem Text.""" if not text: return text lines = text.split('\n') cleaned_lines = [] i = 0 # NEU (v4.2.8): # WP-24c v4.2.8: Callout-Pattern für Edge und Abstract callout_start_pattern = re.compile(r'^>\s*\[!(edge|abstract)[^\]]*\]', re.IGNORECASE) while i < len(lines): line = lines[i] callout_match = callout_start_pattern.match(line) if callout_match: # Callout gefunden: Überspringe alle Zeilen des Callout-Blocks leading_gt_count = len(line) - len(line.lstrip('>')) i += 1 # Überspringe alle Zeilen, die zum Callout gehören while i < len(lines): next_line = lines[i] if not next_line.strip().startswith('>'): break next_leading_gt = len(next_line) - len(next_line.lstrip('>')) if next_leading_gt < leading_gt_count: break i += 1 else: # Normale Zeile: Behalte cleaned_lines.append(line) i += 1 # Normalisiere Leerzeilen (max. 2 aufeinanderfolgende) result = '\n'.join(cleaned_lines) result = re.sub(r'\n\s*\n\s*\n+', '\n\n', result) return result for ch in chunks: ch.text = remove_callouts_from_text(ch.text) if ch.window: ch.window = remove_callouts_from_text(ch.window) # Verknüpfung der Nachbarschaften für Graph-Traversierung for i, ch in enumerate(chunks): ch.neighbors_prev = chunks[i-1].id if i > 0 else None ch.neighbors_next = chunks[i+1].id if i < len(chunks)-1 else None return chunks