WP15 #9

Merged
Lars merged 54 commits from WP15 into main 2025-12-13 06:39:48 +01:00
4 changed files with 329 additions and 487 deletions
Showing only changes of commit cd28a9d35c - Show all commits

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple, Any
from typing import List, Dict, Optional, Tuple, Any, Set
import re
import math
import yaml
@ -10,387 +10,124 @@ from markdown_it.token import Token
import asyncio
# NEUE IMPORTS
# Import der benötigten Klassen direkt (ersetzt get_semantic_analyzer)
try:
# ANNAHME: Die Klassen SemanticAnalyzer und SemanticChunkResult existieren in app.services.semantic_analyzer.py
from app.services.semantic_analyzer import SemanticAnalyzer, SemanticChunkResult
except ImportError:
# Fallback für Tests, wenn der Service noch nicht auf dem Pfad ist
print("WARNUNG: SemanticAnalyzer Service nicht gefunden. Semantic Chunking wird fehlschlagen.")
class SemanticAnalyzer:
async def analyze_and_chunk(self, text, type): return [SemanticChunkResult(content=text, suggested_edges=[])]
@dataclass
class SemanticChunkResult:
content: str
suggested_edges: List[str] # Format: "kind:Target"
from app.services.semantic_analyzer import get_semantic_analyzer
from app.core.note_payload import extract_frontmatter_from_text
# WICHTIG: Import der Edge Derivations Logik
from app.core.derive_edges import build_edges_for_note # <-- Muss importiert werden
# ... bestehender Code (Konfiguration, Hilfsfunktionen, RawBlock, Chunk)
# ==========================================
# 1. FUNKTION ZUM AUSLESEN DES FRONTMATTERS (Lokalisiert und stabil)
# ==========================================
def extract_frontmatter_from_text(md_text: str) -> Tuple[Dict[str, Any], str]:
# --- NEUE STRATEGIE: SMART EDGE ALLOCATION (Ersetzt _strategy_semantic_llm) ---
async def _strategy_smart_edge_allocation(md_text: str, config: Dict, note_id: str, note_type: str) -> List[Chunk]:
"""
Extrakte das YAML Frontmatter aus dem Markdown-Text und gibt den Body zurück.
(Lokalisiert im Chunker zur Vermeidung von Import-Fehlern)
[WP-15, Neue Logik] Zerlegt Note deterministisch und nutzt LLM zur Zuweisung von Kanten (Schritte 1-5).
"""
# Regex toleriert Whitespace/Newline vor dem ersten ---
fm_match = re.match(r'^\s*---\s*\n(.*?)\n---', md_text, re.DOTALL)
# 0. Initialisierung
analyzer = get_semantic_analyzer()
if not fm_match:
return {}, md_text
frontmatter_yaml = fm_match.group(1)
# 1. [Schritt 2 des Workflows] Sammeln ALLER Kanten (Inline & Defaults)
# Führt die Edge-Derivation für die gesamte Notiz aus, basierend auf Text und Typ.
raw_edges: List[Dict] = build_edges_for_note(
text=md_text,
note_id=note_id,
note_type=note_type,
# Leere Listen übergeben, da wir noch keine Chunks haben und nur die Note selbst analysieren.
chunks=[],
references=[]
)
try:
# Nutzung von safe_load
frontmatter = yaml.safe_load(frontmatter_yaml)
if not isinstance(frontmatter, dict):
frontmatter = {}
except yaml.YAMLError:
frontmatter = {}
# Entferne den Frontmatter Block aus dem Text
text_without_fm = re.sub(r'^\s*---\s*\n(.*?)\n---', '', md_text, flags=re.DOTALL)
# Kanten im Format "kind:Target" sammeln (ohne Duplikate)
all_note_edges = set()
for edge in raw_edges:
# Extrahiere nur Kanten, die relevant für das Chunking sind (Explizite oder Defaults)
if edge.get("target_id") and edge.get("kind"):
# Nutze target_id, da dies der Notiz-ID entspricht
all_note_edges.add(f"{edge['kind']}:{edge['target_id']}")
return frontmatter, text_without_fm.strip()
# ==========================================
# 2. CONFIGURATION LOADER
# ==========================================
# Pfad-Logik: app/core/chunker.py -> app/core -> app -> root/config/types.yaml
BASE_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "types.yaml"
# Fallback Values
DEFAULT_PROFILE = {
"strategy": "sliding_window",
"target": 400,
"max": 600,
"overlap": (50, 80)
}
_CONFIG_CACHE = None
def _load_yaml_config() -> Dict[str, Any]:
"""Lädt die config/types.yaml und cached das Ergebnis."""
global _CONFIG_CACHE
if _CONFIG_CACHE is not None:
return _CONFIG_CACHE
if not CONFIG_PATH.exists():
print(f"WARNUNG: types.yaml nicht gefunden unter: {CONFIG_PATH}")
return {}
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
_CONFIG_CACHE = data
return data
except Exception as e:
print(f"FEHLER beim Laden von {CONFIG_PATH}: {e}")
return {}
def get_chunk_config(note_type: str) -> Dict[str, Any]:
"""Löst Typ -> Profil -> Konfiguration auf."""
full_config = _load_yaml_config()
all_note_edges_list = list(all_note_edges)
profiles = full_config.get("chunking_profiles", {})
type_def = full_config.get("types", {}).get(note_type.lower(), {})
profile_name = type_def.get("chunking_profile")
if not profile_name:
profile_name = full_config.get("defaults", {}).get("chunking_profile", "sliding_standard")
# 2. [Schritt 3 des Workflows] Deterministic Chunking
# Nutzt die in der Config angegebene deterministische Strategie (z.B. by_heading)
blocks, doc_title = parse_blocks(md_text)
config = profiles.get(profile_name, DEFAULT_PROFILE).copy()
# Nutze _strategy_by_heading (oder _strategy_sliding_window, je nach Config-Intent),
# da dies die robusteste deterministische Strategie ist. Die Konfiguration kommt
# vom "structured_strict" oder ähnlichem Profil.
chunks = await asyncio.to_thread(_strategy_by_heading, blocks, config, note_id, doc_title)
if "overlap" in config and isinstance(config["overlap"], list):
config["overlap"] = tuple(config["overlap"])
return config
# Fallback, falls by_heading nur einen Chunk liefert oder fehlschlägt
if not chunks or len(chunks) <= 1:
# Erhöht die Robustheit bei unstrukturierten Texten
chunks = await asyncio.to_thread(_strategy_sliding_window, blocks, config, note_id, doc_title)
# Legacy Support
def get_sizes(note_type: str):
cfg = get_chunk_config(note_type)
return {
"target": (cfg["target"], cfg["target"]),
"max": cfg["max"],
"overlap": cfg["overlap"]
}
# ==========================================
# 3. DATA CLASSES & HELPERS
# ==========================================
# --- Hilfen ---
_SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])')
_WS = re.compile(r'\s+')
def estimate_tokens(text: str) -> int:
t = len(text.strip())
return max(1, math.ceil(t / 4))
def split_sentences(text: str) -> list[str]:
text = _WS.sub(' ', text.strip())
if not text: return []
parts = _SENT_SPLIT.split(text)
return [p.strip() for p in parts if p.strip()]
@dataclass
class RawBlock:
kind: str
text: str
level: Optional[int]
section_path: str
section_title: Optional[str]
@dataclass
class Chunk:
id: str
note_id: str
index: int
text: str # Reintext für Anzeige (inkl. injizierter Links bei LLM/Heading)
window: str # Text + Context für Embeddings
token_count: int
section_title: Optional[str]
section_path: str
neighbors_prev: Optional[str]
neighbors_next: Optional[str]
char_start: int
char_end: int
# --- Markdown Parser ---
def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]:
"""Parst MD und gibt Blöcke UND den H1 Titel zurück."""
md = MarkdownIt("commonmark").enable("table")
tokens: List[Token] = md.parse(md_text)
blocks: List[RawBlock] = []
h1_title = "Dokument"
h2, h3 = None, None
section_path = "/"
# Rudimentäres Block-Parsing für non-LLM Strategien
fm, text_without_fm = extract_frontmatter_from_text(md_text)
if text_without_fm.strip():
blocks.append(RawBlock(kind="paragraph", text=text_without_fm.strip(),
level=None, section_path=section_path, section_title=h2))
# H1 Titel Extraktion (für Context Injection in by_heading)
h1_match = re.search(r'^#\s+(.*)', text_without_fm, re.MULTILINE)
if h1_match:
h1_title = h1_match.group(1).strip()
return blocks, h1_title
# ==========================================
# 4. STRATEGIES (SYNCHRON)
# ==========================================
def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, context_prefix: str = "") -> List[Chunk]:
"""Klassisches Sliding Window."""
target = config.get("target", 400)
max_tokens = config.get("max", 600)
overlap_val = config.get("overlap", (50, 80))
overlap = sum(overlap_val) // 2 if isinstance(overlap_val, tuple) else overlap_val
chunks: List[Chunk] = []
buf: List[RawBlock] = []
def flush_buffer():
nonlocal buf
if not buf: return
text_body = "\n\n".join([b.text for b in buf])
sec_title = buf[-1].section_title if buf else None
sec_path = buf[-1].section_path if buf else "/"
window_body = f"{context_prefix}\n{text_body}".strip() if context_prefix else text_body
if estimate_tokens(text_body) > max_tokens:
sentences = split_sentences(text_body)
current_sents = []
cur_toks = 0
for s in sentences:
st = estimate_tokens(s)
if cur_toks + st > target and current_sents:
txt = "\n".join(current_sents)
win = f"{context_prefix}\n{txt}".strip() if context_prefix else txt
_add_chunk(txt, win, sec_title, sec_path)
ov_txt = " ".join(current_sents)[-overlap*4:]
current_sents = [ov_txt, s] if ov_txt else [s]
cur_toks = estimate_tokens(" ".join(current_sents))
else:
current_sents.append(s)
cur_toks += st
if current_sents:
txt = "\n".join(current_sents)
win = f"{context_prefix}\n{txt}".strip() if context_prefix else txt
_add_chunk(txt, win, sec_title, sec_path)
if not chunks:
# Absoluter Fallback: Ganzer Text ist 1 Chunk.
text = " ".join([b.text for b in blocks if b.kind not in ("heading", "code")]).strip()
if text:
chunks = [Chunk(id=f"{note_id}-c0", note_id=note_id, index=0, text=text, token_count=estimate_tokens(text), section_title=doc_title, section_path="", neighbors_prev=None, neighbors_next=None, char_start=0, char_end=len(text))]
else:
_add_chunk(text_body, window_body, sec_title, sec_path)
buf = []
return []
def _add_chunk(txt, win, sec, path):
idx = len(chunks)
chunks.append(Chunk(
id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx,
text=txt, window=win, token_count=estimate_tokens(txt),
section_title=sec, section_path=path,
neighbors_prev=None, neighbors_next=None, char_start=0, char_end=0
))
for b in blocks:
if estimate_tokens("\n\n".join([x.text for x in buf] + [b.text])) >= target:
flush_buffer()
buf.append(b)
flush_buffer()
return chunks
def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str) -> List[Chunk]:
"""Harter Split an Überschriften mit Context Injection."""
chunks: List[Chunk] = []
sections: Dict[str, List[RawBlock]] = {}
ordered = []
# 3. [Schritt 4 des Workflows] Kanten pro Chunk zuweisen/filtern
for b in blocks:
if b.kind == "heading": continue
if b.section_path not in sections:
sections[b.section_path] = []
ordered.append(b.section_path)
sections[b.section_path].append(b)
unassigned_edges: Set[str] = set(all_note_edges_list)
llm_tasks = []
for chunk in chunks:
# Starte den LLM-Filter-Call für jeden Chunk parallel
task = analyzer.filter_edges_for_chunk(chunk.text, all_note_edges_list, note_type)
llm_tasks.append(task)
for path in ordered:
s_blocks = sections[path]
if not s_blocks: continue
# Warte auf alle LLM-Antworten (Batch-Processing)
filtered_edges_results: List[List[str]] = await asyncio.gather(*llm_tasks)
# 4. Ergebnisse zuweisen und Unassigned Edges sammeln
for i, filtered_edges_list in enumerate(filtered_edges_results):
chunk = chunks[i]
breadcrumbs = path.strip("/").replace("/", " > ")
context_header = f"# {doc_title}\n## {breadcrumbs}"
full_text = "\n\n".join([b.text for b in s_blocks])
# Lege die vom LLM gefilterten Edges in den Chunk-Payload
# Die Chunk-Klasse muss ein Feld 'suggested_edges' haben (wie im alten SemanticChunkResult)
chunk.suggested_edges = filtered_edges_list
if estimate_tokens(full_text) <= config.get("max", 600):
chunks.append(Chunk(
id=f"{note_id}#c{len(chunks):02d}", note_id=note_id, index=len(chunks),
text=full_text, window=f"{context_header}\n{full_text}",
token_count=estimate_tokens(full_text),
section_title=s_blocks[0].section_title if s_blocks else None,
section_path=path,
neighbors_prev=None, neighbors_next=None, char_start=0, char_end=0
))
else:
# Fallback auf Sliding Window mit Context Injection
sub = _strategy_sliding_window(s_blocks, config, note_id, context_prefix=context_header)
base = len(chunks)
for i, sc in enumerate(sub):
sc.index = base + i
sc.id = f"{note_id}#c{sc.index:02d}"
chunks.append(sc)
return chunks
# Unassigned Edges: Subtrahiere alle Edges, die in diesem Chunk gefunden wurden
unassigned_edges.difference_update(set(filtered_edges_list))
# ==========================================
# 5. STRATEGY (ASYNCHRON)
# ==========================================
# Singleton Instanz für den Analyzer
_semantic_analyzer_instance = None
def _get_semantic_analyzer_instance() -> SemanticAnalyzer:
"""Liefert die Singleton-Instanz des SemanticAnalyzer."""
global _semantic_analyzer_instance
if _semantic_analyzer_instance is None:
_semantic_analyzer_instance = SemanticAnalyzer()
return _semantic_analyzer_instance
async def _strategy_semantic_llm(md_text: str, config: Dict[str, Any], note_id: str, note_type: str) -> List[Chunk]:
"""
Strategie: Delegiert die Zerlegung und Kanten-Extraktion an ein LLM (Async).
"""
analyzer = _get_semantic_analyzer_instance()
# Text-Splitting wird hier vom LLM übernommen
semantic_chunks: List[SemanticChunkResult] = await analyzer.analyze_and_chunk(md_text, note_type)
# 5. [Schritt 5 des Workflows] Fallback: Nicht zugeordnete Kanten zuweisen
# Alle Kanten, die in KEINEM Chunk explizit zugewiesen wurden, werden JEDEM Chunk zugewiesen.
unassigned_edges_list = list(unassigned_edges)
chunks: List[Chunk] = []
for i, sc in enumerate(semantic_chunks):
# 1. Edge Injection für derive_edges.py
injection_block = "\n"
for edge_str in sc.suggested_edges:
# Stellt sicher, dass das Split-Ergebnis 2 Teile hat
if ":" in edge_str:
kind, target = edge_str.split(":", 1)
# Nutzt die Syntax: [[rel:kind | Target]]
injection_block += f"[[rel:{kind} | {target}]] "
if unassigned_edges_list:
logger.info(f"Adding {len(unassigned_edges_list)} unassigned edges as fallback to all chunks for note {note_id}")
for chunk in chunks:
# Füge die unassigned Edges hinzu (Set-Operation für Duplikat-Schutz)
existing_edges = set(chunk.suggested_edges)
chunk.suggested_edges = list(existing_edges.union(unassigned_edges_list))
full_text = sc.content + injection_block
# 2. Chunk Objekt bauen
chunks.append(Chunk(
id=f"{note_id}#sem{i:02d}",
note_id=note_id,
index=i,
text=full_text.strip(),
window=full_text.strip(),
token_count=estimate_tokens(full_text),
section_title="Semantic Section",
section_path="/LLM",
neighbors_prev=None, neighbors_next=None,
char_start=0, char_end=0
))
# 6. Return Chunks
return chunks
# ==========================================
# 6. MAIN ENTRY POINT (ASYNC)
# ==========================================
async def assemble_chunks(note_id: str, md_text: str, note_type: str) -> List[Chunk]:
"""
Hauptfunktion. Analysiert Config und wählt Strategie (MUSS ASYNC SEIN).
Enthält die Logik zur Vermeidung des Double-LLM-Effekts.
"""
# --- UPDATE DISPATCHER: chunk_note_async ---
async def chunk_note_async(md_text: str, note_id: str, note_type: str, note_status: str, path_arg: str = None) -> List[Chunk]:
# 1. Frontmatter prüfen (Double-LLM-Prevention)
# Nutzen der lokalen, robusten Funktion
fm, body = extract_frontmatter_from_text(md_text)
note_status = fm.get("status", "").lower()
config = get_chunk_config(note_type)
strategy = config.get("strategy", "sliding_window")
# 2. Strategie-Auswahl
# Wenn der Typ LLM-Chunking nutzt (semantic_llm),
# ABER der Status ist 'draft' (wahrscheinlich vom LLM generiert):
if strategy == "semantic_llm" and note_status in ["draft", "initial_gen"]:
# Setze auf die zweitbeste, aber synchrone und deterministische Strategie
print(f"INFO: Overriding '{strategy}' for draft status. Using 'by_heading' instead.")
strategy = "by_heading"
# ... bestehender Code (Frontmatter, Config, etc.)
# 3. Execution (Dispatcher)
# Der Text, der an die Chunker-Strategie geht.
md_to_chunk = md_text
# Update: Rufe die NEUE Strategie auf, wenn 'semantic_llm' konfiguriert ist.
if strategy == "semantic_llm":
# LLM-Strategie nutzt den gesamten MD-Text zur Orientierung
chunks = await _strategy_semantic_llm(md_to_chunk, config, note_id, note_type)
chunks = await _strategy_smart_edge_allocation(md_text, config, note_id, note_type)
elif strategy == "by_heading":
blocks, doc_title = parse_blocks(md_to_chunk)
# Synchronen Code in einem Thread ausführen
chunks = await asyncio.to_thread(_strategy_by_heading, blocks, config, note_id, doc_title)
blocks, doc_title = parse_blocks(md_text)
# ... bestehender Code
else: # sliding_window (Default)
blocks, doc_title = parse_blocks(md_to_chunk)
# Synchronen Code in einem Thread ausführen
chunks = await asyncio.to_thread(_strategy_sliding_window, blocks, config, note_id)
# 4. Post-Process: Neighbors setzen
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
blocks, doc_title = parse_blocks(md_text)
# ... bestehender Code
# ... bestehender Code (Post-Processing)

View File

@ -1,136 +1,87 @@
"""
app/services/semantic_analyzer.py
Kapselt die LLM-Strategie für Chunking und Kanten-Extraktion.
app/services/semantic_analyzer.py Edge Validation & Filtering
Der Service ist nun primär dafür zuständig, Kanten aus einer Liste dem gegebenen Chunk zuzuordnen.
"""
import json
import logging
import re
from typing import List, Dict, Any, Optional
from dataclasses import dataclass
# Import der benötigten Services (Annahme: llm_service und discovery sind verfügbar.)
# Import der benötigten Services (Annahme: llm_service ist verfügbar.)
from app.services.llm_service import LLMService
from app.services.discovery import DiscoveryService
logger = logging.getLogger(__name__)
@dataclass
class SemanticChunkResult:
content: str
suggested_edges: List[str] # Format: "kind:Target"
# Ein Singleton-Muster für den Analyzer (wie zuvor)
_analyzer_instance: Optional['SemanticAnalyzer'] = None
def get_semantic_analyzer():
global _analyzer_instance
if _analyzer_instance is None:
_analyzer_instance = SemanticAnalyzer()
return _analyzer_instance
class SemanticAnalyzer:
def __init__(self):
# Der DiscoveryService wird hier nicht mehr direkt benötigt.
self.llm = LLMService()
self.discovery = DiscoveryService()
self.MAX_CONTEXT_TOKENS = 3000
async def analyze_and_chunk(self, text: str, source_type: str) -> List[SemanticChunkResult]:
async def filter_edges_for_chunk(self, chunk_text: str, all_note_edges: List[str], note_type: str) -> List[str]:
"""
Zerlegt Text mittels LLM in semantische Abschnitte und extrahiert Kanten.
[Schritt 4 des Workflows] Sendet Chunk und alle Kanten an LLM, um die relevanten Kanten für diesen Chunk zu filtern.
:param chunk_text: Der Text des Chunks zur Analyse.
:param all_note_edges: Alle für die gesamte Notiz gefundenen Kanten (Format: "kind:Target").
:param note_type: Der Typ der Notiz.
:return: Liste der relevanten Kanten für diesen Chunk.
"""
system_prompt = (
"Du bist ein Knowledge Graph Experte. Deine Aufgabe ist es, Rohtext in "
"thematisch geschlossene Abschnitte (Chunks) zu zerlegen.\n"
"Analysiere jeden Abschnitt auf Beziehungen zu anderen Konzepten (Entitäten, Personen, etc.).\n"
"Antworte AUSSCHLIESSLICH mit validem JSON in diesem Format:\n"
"[\n"
" {\n"
" \"content\": \"Der Text des Abschnitts...\",\n"
" \"relations\": [{\"target\": \"Entität X\", \"type\": \"related_to\"}]\n"
" }\n"
"]\n"
"Halte die Chunks mittellang (ca. 100-300 Wörter). Verändere den Inhalt nicht, nur die Struktur."
)
if not all_note_edges:
return []
edge_list_str = "\n".join([f"- {e}" for e in all_note_edges])
user_prompt = f"Dokument-Typ: {source_type}\n\nTEXT:\n{text}"
system_prompt = (
"Du bist ein Edge Filter Agent. Deine Aufgabe ist es, aus einer gegebenen Liste von potentiellen "
"Knowledge Graph Kanten (Edges) jene auszuwählen, die *semantisch relevant* für den vorliegenden "
"Textausschnitt sind. Alle Kanten beziehen sich auf die Hauptnotiz.\n"
"Antworte AUSSCHLIESSLICH mit einer validen JSON-Liste von Kanten-Strings, die im Text direkt erwähnt oder "
"klar impliziert werden. Es ist KEIN Array von Objekten, sondern ein Array von Strings.\n"
"Format: [\"kind:Target\", \"kind:Target\", ...]\n"
"Wähle nur Kanten, die der Chunk *aktiv* benötigt oder referenziert."
)
user_prompt = (
f"Notiz-Typ: {note_type}\n"
f"Textausschnitt:\n---\n{chunk_text}\n---\n\n"
f"Gesamte Kanten der Notiz (AUSWAHL):\n{edge_list_str}\n\n"
"Welche der oben genannten Kanten sind für diesen Textabschnitt relevant? Liste sie im JSON-Array auf."
)
try:
# 2. LLM Call (Async)
# 1. LLM Call
response_json = await self.llm.generate_raw_response(
user_prompt,
system=system_prompt,
force_json=True
)
# 3. JSON Parsing & Validierung
# 2. Robustes JSON Parsing
clean_json = response_json.replace("```json", "").replace("```", "").strip()
data = json.loads(clean_json)
# FIX: Typsicherheit auf der Wurzel
if isinstance(data, dict):
# LLM hat ein Einzelobjekt geliefert -> wandle es in ein Array
data = [data]
elif not isinstance(data, list):
logger.error("SemanticAnalyzer: JSON root ist weder Array noch Objekt. Fehlerhafte LLM-Antwort.")
raise ValueError("Root element is not a list or dictionary.")
results = []
for item in data:
# Typsicherheit auf Item-Ebene
if not isinstance(item, dict):
logger.warning(f"SemanticAnalyzer: Ungültiges Chunk-Element ignoriert: {item}")
continue
content = item.get("content", "").strip()
if not content: continue
raw_rels = item.get("relations", [])
refined_edges = []
for rel in raw_rels:
# Typsicherheit auf Relation-Ebene
if not isinstance(rel, dict):
logger.warning(f"SemanticAnalyzer: Ignoriere ungültige Relation: {rel}")
continue
target = rel.get("target")
raw_type = rel.get("type", "related_to")
if target:
# 1. Typ-Auflösung (für Matrix)
target_entity_type = self._get_target_type_from_title(target)
# 2. Matrix-Logik anwenden:
final_kind = self.discovery._resolve_edge_type(source_type, target_entity_type)
# 3. Priorisierung: Wählt den Matrix-Vorschlag, wenn er spezifischer ist.
if final_kind not in ["related_to", "references"] and target_entity_type != "concept":
edge_str = f"{final_kind}:{target}"
else:
edge_str = f"{raw_type}:{target}"
refined_edges.append(edge_str)
results.append(SemanticChunkResult(content=content, suggested_edges=refined_edges))
return results
except json.JSONDecodeError:
logger.error("SemanticAnalyzer: LLM lieferte KEIN valides JSON. Fallback auf Raw Text.")
return [SemanticChunkResult(content=text, suggested_edges=[])]
except Exception as e:
logger.error(f"SemanticAnalyzer Unbehandelter Fehler: {e}")
return [SemanticChunkResult(content=text, suggested_edges=[])]
# NEU: Helper zur Abfrage des Typs (muss die bestehenden Funktionen nutzen)
def _get_target_type_from_title(self, title: str) -> str:
"""Simuliert den Abruf des Notiztyps basierend auf dem Titel aus dem Index (für Matrix-Logik)."""
title_lower = title.lower()
if "leitbild-werte" in title_lower or "integrität" in title_lower:
return "value"
if "leitbild-prinzipien" in title_lower:
return "principle"
if "leitbild-rollen" in title_lower:
return "profile"
if "leitbild-rituale-system" in title_lower:
return "concept"
if isinstance(data, list):
# Filtere nach Strings, die den Doppelpunkt enthalten, um das Format "kind:Target" zu garantieren.
return [s for s in data if isinstance(s, str) and ":" in s]
return "concept"
logger.warning(f"SemanticAnalyzer: LLM lieferte non-list beim Edge-Filtern: {data}")
return []
except json.JSONDecodeError as e:
logger.error(f"SemanticAnalyzer: LLM lieferte KEIN valides JSON beim Edge-Filtern: {e}")
return []
except Exception as e:
logger.error(f"SemanticAnalyzer Unbehandelter Fehler beim Edge-Filtern: {e}")
return []
async def close(self):
# Stellt sicher, dass der AsyncClient geschlossen wird (gute Praxis)
if self.llm:
await self.llm.close()

View File

@ -1,41 +1,42 @@
version: 1.2 # Update für Smart Chunking Config
version: 1.3 # Update für Smart Edge Allocation
# --- CHUNKING DEFINITIONEN ---
# Hier definieren wir die technischen Strategien zentral.
# Hier definieren wir die technischen Strategien und den Smart Edge Filter.
chunking_profiles:
# Standard für Fließtexte (Sliding Window)
# 1. Standard Profile (Sliding Window, KEIN LLM-Filter)
sliding_short:
strategy: sliding_window
enable_smart_edge_allocation: false # Sekundärverfeinerung deaktiviert
target: 200
max: 350
overlap: [30, 50]
sliding_standard:
strategy: sliding_window
# 2. Smart Edge Allocation Profile (Sliding Window + LLM-Filter)
sliding_smart_edges:
strategy: sliding_window # Primärzerlegung: Sliding Window
enable_smart_edge_allocation: true # SEKUNDÄRVERFEINERUNG: LLM-Filter aktiv
target: 400
max: 600
overlap: [50, 80]
sliding_large:
strategy: sliding_window
target: 500
max: 800
overlap: [60, 100]
# Smart Chunking für Strukturen (Harte Splits)
# 3. Strukturierte Profile (By Heading, KEIN LLM-Filter)
structured_strict:
strategy: by_heading
enable_smart_edge_allocation: false
split_level: 2
max: 600 # Fallback Limit
target: 400 # Fallback Target bei Sub-Chunking
overlap: [50, 80] # Overlap bei Sub-Chunking
# NEU: LLM-basierte semantische Zerlegung (Chunker.py ruft semantic_analyzer.py)
semantic_llm:
strategy: semantic_llm
# Da das LLM die Längensteuerung übernimmt, dienen diese als Fallback/Empfehlung
target: 400
max: 800
max: 600
target: 400
overlap: [50, 80]
# 4. Strukturierte Profile (By Heading + LLM-Filter)
structured_smart_edges:
strategy: by_heading # Primärzerlegung: Harte Trennung
enable_smart_edge_allocation: true # SEKUNDÄRVERFEINERUNG: LLM-Filter aktiv
split_level: 2
max: 600
target: 400
overlap: [50, 80]
defaults:
retriever_weight: 1.0
@ -45,12 +46,12 @@ defaults:
types:
# --- WISSENSBAUSTEINE ---
concept:
chunking_profile: sliding_standard
chunking_profile: sliding_smart_edges # Nutzt Kantenfilterung
retriever_weight: 0.60
edge_defaults: ["references", "related_to"]
source:
chunking_profile: sliding_standard
chunking_profile: sliding_short # Kein LLM-Filter
retriever_weight: 0.50
edge_defaults: []
@ -61,17 +62,17 @@ types:
# --- IDENTITÄT & PERSÖNLICHKEIT ---
profile:
chunking_profile: structured_strict
chunking_profile: structured_smart_edges # Strukturiert + Kantenfilterung
retriever_weight: 0.70
edge_defaults: ["references", "related_to"]
value:
chunking_profile: structured_strict
chunking_profile: structured_smart_edges
retriever_weight: 1.00
edge_defaults: ["related_to"]
principle:
chunking_profile: structured_strict
chunking_profile: structured_smart_edges
retriever_weight: 0.95
edge_defaults: ["derived_from", "references"]
@ -81,18 +82,18 @@ types:
edge_defaults: ["related_to"]
experience:
chunking_profile: sliding_standard
chunking_profile: sliding_smart_edges
retriever_weight: 0.90
edge_defaults: ["derived_from", "references"]
# --- STRATEGIE & ENTSCHEIDUNG ---
goal:
chunking_profile: sliding_standard
chunking_profile: sliding_smart_edges
retriever_weight: 0.95
edge_defaults: ["depends_on", "related_to"]
decision:
chunking_profile: structured_strict
chunking_profile: structured_smart_edges
retriever_weight: 1.00
edge_defaults: ["caused_by", "references"]
@ -108,7 +109,7 @@ types:
# --- OPERATIV ---
project:
chunking_profile: sliding_large
chunking_profile: sliding_smart_edges
retriever_weight: 0.97
edge_defaults: ["references", "depends_on"]
@ -118,7 +119,6 @@ types:
edge_defaults: ["depends_on", "part_of"]
journal:
# NEUE ZUWEISUNG: Journale profitieren am meisten von der semantischen Analyse
chunking_profile: semantic_llm
chunking_profile: sliding_smart_edges # Fließtext + Kantenfilterung
retriever_weight: 0.80
edge_defaults: ["references", "related_to"]

View File

@ -0,0 +1,154 @@
# tests/test_final_wp15_validation.py
import asyncio
import unittest
import os
import sys
from pathlib import Path
from typing import List, Dict, Any
# --- PFAD-KORREKTUR ---
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT_DIR))
# ----------------------
# Import der Kernkomponenten
from app.core import chunker
from app.core import derive_edges
from app.services.semantic_analyzer import SemanticAnalyzer
# 1. Hilfsfunktion zur Manipulation der Konfiguration im Test
def get_config_for_test(strategy: str, enable_smart_edge: bool) -> Dict[str, Any]:
"""Erzeugt eine ad-hoc Konfiguration, um eine Strategie zu erzwingen."""
cfg = chunker.get_chunk_config("concept") # Nutze eine Basis
cfg['strategy'] = strategy
cfg['enable_smart_edge_allocation'] = enable_smart_edge
return cfg
# 2. Test-Daten (Muss die Entitäten aus den Vault-Dateien verwenden)
TEST_NOTE_ID = "20251212-test-integration"
TEST_NOTE_TYPE = "concept" # Kann eine beliebige Basis sein
# Text, der die Matrix-Logik und Header triggert
TEST_MARKDOWN_SMART = """
---
id: 20251212-test-integration
title: Integrationstest - Smart Edges
type: concept
status: active
---
# Teil 1: Intro
Dies ist die Einleitung. Wir definieren unsere Mission: Präsent sein und vorleben.
Dies entspricht unseren Werten [[leitbild-werte#Integrität]] und [[leitbild-werte#Respekt]].
## Teil 2: Rollenkonflikt
Der Konflikt zwischen [[leitbild-rollen#Vater]] und [[leitbild-rollen#Berufsrolle (Umbrella)]] muss gelöst werden.
Die Lösung muss [[rel:depends_on leitbild-review#Weekly Review]].
"""
# Text, der nur für Sliding Window geeignet ist
TEST_MARKDOWN_SLIDING = """
---
id: 20251212-test-sliding
title: Fließtext Protokoll
type: journal
status: active
---
Dies ist ein langer Fließtextabschnitt, der ohne Header auskommt.
Er spricht über die neue [[leitbild-prinzipien#P1 Integrität]] Regel und den Ablauf des Tages.
Das sollte in zwei Chunks zerlegt werden.
"""
# 3. Testklasse
class TestFinalWP15Integration(unittest.TestCase):
# Initiale Ressourcen-Verwaltung (um den AsyncClient zu schließen)
_analyzer_instance = None
@classmethod
def setUpClass(cls):
cls._analyzer_instance = SemanticAnalyzer()
chunker._semantic_analyzer_instance = cls._analyzer_instance
@classmethod
def tearDownClass(cls):
if cls._analyzer_instance:
# Nutzt die temporäre Loop-Lösung
loop = asyncio.get_event_loop()
loop.run_until_complete(cls._analyzer_instance.close())
# --- A. Smart Edge Allocation Test ---
def test_a_smart_edge_allocation(self):
"""Prüft die neue LLM-Orchestrierung (5 Schritte) und die Kanten-Bindung."""
config = get_config_for_test('by_heading', enable_smart_edge=True)
# 1. Chunking (Asynchroner Aufruf der neuen Orchestrierung)
chunks = asyncio.run(chunker.assemble_chunks(
note_id=TEST_NOTE_ID,
md_text=TEST_MARKDOWN_SMART,
note_type=TEST_NOTE_TYPE,
config=config # Übergibt die ad-hoc Konfiguration (Annahme: assemble_chunks akzeptiert kwargs)
))
# NOTE: Da assemble_chunks die config intern lädt, müssten wir hier idealerweise
# die types.yaml zur Laufzeit manipulieren oder die config in kwargs übergeben (letzteres ist hier angenommen).
# 2. Grundlegende Checks
self.assertTrue(len(chunks) >= 2, "A1 Fehler: Primärzerlegung (by_heading) muss mindestens 2 Chunks liefern.")
# 3. Kanten-Checks (durch derive_edges.py im Chunker ausgelöst)
# Wir suchen nach der LLM-generierten, spezifischen Kante
# Erwartet: Chunk 1/2 enthält die Kante 'derived_from' oder 'based_on' zu 'leitbild-werte'.
all_edges = []
for c in chunks:
# Um die Kanten zu erhalten, muss derive_edges manuell aufgerufen werden,
# da der Chunker nur den Text injiziert.
# Im echten Importer würde build_edges_for_note auf den injizierten Text angewendet.
# Hier simulieren wir den Endeffekt, indem wir die injizierten Kanten prüfen:
if "suggested_edges" in c.__dict__:
all_edges.extend(c.suggested_edges)
has_matrix_kante = any("based_on:leitbild-werte" in e or "derived_from:leitbild-werte" in e for e in all_edges)
self.assertTrue(has_matrix_kante,
"A2 Fehler: LLM-Kantenfilter hat die Matrix-Logik (value -> based_on/derived_from) nicht angewendet oder erkannt.")
print("\n✅ Test A: Smart Edge Allocation erfolgreich.")
# --- B. Abwärtskompatibilität (Legacy Tests) ---
def test_b_backward_compatibility(self):
"""Prüft, ob die alte, reine Sliding Window Strategie (ohne LLM-Filter) noch funktioniert."""
# Erzwinge das alte, reine Sliding Window Profil
config = get_config_for_test('sliding_window', enable_smart_edge=False)
# 1. Chunking (Sollte *mehrere* Chunks liefern, ohne LLM-Aufruf)
# Die Orchestrierung sollte nur den reinen Sliding Window Call nutzen.
chunks = asyncio.run(chunker.assemble_chunks(
note_id=TEST_NOTE_ID,
md_text=TEST_MARKDOWN_SLIDING,
note_type='journal',
config=config
))
self.assertTrue(len(chunks) >= 2, "B1 Fehler: Reine Sliding Window Strategie ist fehlerhaft oder zerlegt nicht.")
# 2. Prüfen auf Kanten-Injection (Dürfen NUR aus Wikilinks und Defaults kommen)
# Die manuelle Wikilink [[leitbild-prinzipien#P1 Integrität]] sollte in JEDEM Chunk sein
# wenn Defaults für journal aktiv sind, was falsch ist.
# Im reinen Sliding Window Modus (ohne LLM) werden Kanten nur durch derive_edges.py erkannt.
# Wir prüfen nur, dass die Chunks existieren.
self.assertNotIn('suggested_edges', chunks[0].__dict__, "B2 Fehler: LLM-Kantenfilter wurde fälschlicherweise für enable_smart_edge=False ausgeführt.")
print("\n✅ Test B: Abwärtskompatibilität (reines Sliding Window) erfolgreich.")
if __name__ == '__main__':
print("Startet den finalen WP-15 Validierungstest.")
unittest.main()