197 lines
9.3 KiB
Python
197 lines
9.3 KiB
Python
"""
|
|
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
|
|
provenance = "explicit:callout" if is_callout else "explicit"
|
|
ch.candidate_pool.append({"kind": k, "to": t, "provenance": provenance})
|
|
|
|
# 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 |