Compare commits

..

34 Commits

Author SHA1 Message Date
cd5056d4c9 Merge pull request 'WP4d' (#16) from WP4d into main
All checks were successful
Deploy mindnet to llm-node / deploy (push) Successful in 4s
Reviewed-on: #16
2025-12-30 12:25:33 +01:00
39fb821481 WP4d - branch merger Comment & Release Note 2025-12-30 12:24:20 +01:00
beb87a8c43 Update documentation to version 2.9.1, introducing support for section-based links and multigraph functionality. Enhance glossary, user manual, and technical references to reflect new deep-linking capabilities and edge structure adjustments. Ensure proper migration instructions for users transitioning to the new version. 2025-12-30 12:16:58 +01:00
4327fc939c zrück zur Vorversion zum Test der LLM checks 2025-12-30 09:40:30 +01:00
ef1046c6f5 Enhance callout relation extraction by ensuring correct termination on new headers. Update regex for simple kinds to support hyphens. Refactor block processing logic for improved clarity and functionality. 2025-12-30 09:26:38 +01:00
ef8cf719f2 Update ingestion processor to version 2.13.12, synchronizing profile resolution with registry defaults. Refactor profile retrieval logic to utilize the profile determined in make_note_payload, ensuring consistency in chunk configuration. 2025-12-30 09:11:55 +01:00
6aa6b32a6c Update chunking system to version 3.9.9, synchronizing parameters with the orchestrator and enhancing edge detection. Implement robust parsing to prevent duplicate edges in section propagation. Adjust comments for clarity and consistency across the codebase. 2025-12-30 08:40:19 +01:00
33dff04d47 Fix v3.3.5: Prevent duplicate Wikilink targets in text by checking for existing references before injecting section edges. Update comments for clarity and maintain consistency in the code structure. 2025-12-30 08:22:17 +01:00
65d697b7be Aktualisierung der atomaren Sektions-Chunking-Strategie auf Version 3.9.8 mit verbesserten Implementierungen des 'Pack-and-Carry-Over' Verfahrens. Einführung von Look-Ahead zur strikten Einhaltung von Sektionsgrenzen und Vermeidung redundanter Kanten-Injektionen. Anpassungen an der Chunk-Erstellung und Optimierung der Handhabung von leeren Überschriften. 2025-12-30 07:54:54 +01:00
06fc42ed37 Aktualisierung des Chunking-Parsers zur Einführung der Funktion parse_edges_robust zur Extraktion von Kanten-Kandidaten aus Wikilinks und Callouts. Verbesserung der Satzverarbeitung durch die Implementierung der Funktion split_sentences. Aktualisierung der Sektions-Chunking-Strategie auf Version 3.9.6 mit optimierter Handhabung von leeren Überschriften und Carry-Over Logik zur besseren Chunk-Erstellung. 2025-12-30 07:44:30 +01:00
3c5c567077 Aktualisierung der atomaren Sektions-Chunking-Strategie auf Version 3.9.5 mit Implementierung des 'Pack-and-Carry-Over' Verfahrens. Einführung neuer Konfigurationsoptionen für Smart-Edge und strikte Überschriftenteilung. Verbesserte Handhabung von leeren Überschriften und Anpassungen an der Warteschlangen-Verarbeitung zur Optimierung der Chunk-Erstellung. 2025-12-30 07:41:30 +01:00
8f65e550c8 Optimierung des Chunking-Parsers zur Unterstützung atomarer Blöcke und Verbesserung der Satzverarbeitung. Aktualisierung der Sektions-Chunking-Strategie auf Version 3.9.0 mit regelkonformer Implementierung und Anpassungen an der Warteschlangen-Verarbeitung für Carry-Over. Verbesserte Handhabung von Überschriften und Metadaten zur Gewährleistung der strukturellen Integrität. 2025-12-29 22:16:12 +01:00
6b83879741 Aktualisierung des Chunking-Parsers zur Verbesserung der Satzverarbeitung und Blocktrennung. Einführung des 'Pack-and-Carry-Over' Verfahrens in der Sektions-Chunking-Strategie zur Optimierung der Handhabung von großen Sektionen und Gewährleistung der Sektionsintegrität. Anpassungen an der Token-Schätzung und Verbesserung der Metadatenverarbeitung. 2025-12-29 22:04:23 +01:00
be265e9cc0 Verbesserung des Chunking-Parsers zur Unterstützung atomarer Blöcke und Gewährleistung der strukturellen Integrität von Callouts. Aktualisierung der Beschreibung und Optimierung der Satz- und Blockverarbeitung, einschließlich präziserer Handhabung von H1-Überschriften und Trennern. 2025-12-29 21:48:54 +01:00
680c36ab59 Aktualisierung des Chunking-Parsers zur Verbesserung der Blockverarbeitung und Beschreibung. Anpassungen an der atomaren Sektions-Chunking-Strategie mit optimierter Token-Schätzung und neuen Hilfsfunktionen zur besseren Handhabung von großen Sektionen. Einführung einer präziseren Schätzung für deutsche Texte und Anpassungen an der Logik zur Handhabung von Sektionen. 2025-12-29 21:45:14 +01:00
96b4f65cd1 Aktualisierung des Chunking-Parsers zur Verbesserung der Blockverarbeitung und Beschreibung. Anpassungen an der atomaren Sektions-Chunking-Strategie mit optimierter Token-Schätzung und neuen Hilfsfunktionen zur besseren Handhabung von großen Sektionen. 2025-12-29 21:37:11 +01:00
b1a897e51c Verbesserung des Chunking-Parsers zur Unterstützung aller Überschriften (H1-H6) und Optimierung der Block-Trennung für atomares Sektions-Chunking. Aktualisierung der Sektions-Chunking-Strategie mit striktem Look-Ahead und präziserer Token-Schätzung für eine verbesserte Handhabung von großen Blöcken. 2025-12-29 21:26:05 +01:00
e5a34efee9 Verbesserung des Chunking-Parsers zur Gewährleistung der Integrität von Callouts und Listen sowie Anpassungen an der Blockverarbeitung. Aktualisierung der atomaren Sektions-Chunking-Strategie mit Block-Aware-Flushing und optimierter Token-Schätzung für eine präzisere Handhabung von großen Blöcken. 2025-12-29 21:15:03 +01:00
f9ac4e4dbf Verbesserung der atomaren Sektions-Chunking-Strategie durch Einführung strikter Look-Ahead-Logik und präventiven Flush zur Gewährleistung von Sektionsgrenzen. Anpassungen an der Token-Schätzung und Umbenennung von Funktionen zur besseren Lesbarkeit. 2025-12-29 21:05:42 +01:00
1b40e29f40 Optimierung des Chunking-Parsers zur Unterstützung atomares Chunking und verbesserte Block-Trennung. Anpassungen an der Sektions-Chunking-Strategie zur Wahrung von Sektionsgrenzen und Vermeidung von Überhängen. 2025-12-29 20:57:07 +01:00
7eba1fb487 Aktualisierung des Chunking-Parsers zur Unterstützung aller Überschriften im Stream und Verbesserung der Metadatenverarbeitung. Anpassungen an der atomaren Sektions-Chunking-Strategie zur besseren Handhabung von Blockinhalten und Token-Schätzungen. 2025-12-29 20:45:04 +01:00
838083b909 Verbesserung des Chunking-Parsers zur Unterstützung von H1-Überschriften und Anpassung der Metadatenlogik. Implementierung einer atomaren Sektions-Chunking-Strategie, die Überschriften und deren Inhalte zusammenhält. 2025-12-29 20:33:43 +01:00
8f5eb36b5f neuer Chunking parser, der Headings mitführt und nicht mitten im Abschnitt schneidet 2025-12-29 20:16:23 +01:00
b7d1bcce3d Rücksprung zur Vorwersion, in der 2 Kantentypen angelegt wurden 2025-12-29 18:04:14 +01:00
03d3173ca6 neu deduplizierung für callout-edges 2025-12-29 12:42:26 +01:00
38a61d7b50 Fix: Semantische Deduplizierung in graph_derive_edges.py 2025-12-29 12:21:57 +01:00
0a429e1f7b anpassungen Kantenvergeleich 2025-12-29 11:45:25 +01:00
857ba953e3 bug fix 2025-12-29 11:00:00 +01:00
e180018c99 Anpassung gui 2025-12-29 10:31:51 +01:00
ac9956bf00 Index und Anlage neues Feld in qdrant 2025-12-29 10:16:51 +01:00
62b5a8bf65 Anpassung payload für neues Feld in edges 2025-12-29 08:40:05 +01:00
303efefcb7 bug fix 2025-12-29 08:19:40 +01:00
feeb7c2d92 Initial WP4d 2025-12-29 07:58:20 +01:00
ea9a54421a ui_fraph.old Version 2025-12-29 07:51:07 +01:00
23 changed files with 1142 additions and 408 deletions

View File

@ -1,6 +1,8 @@
"""
FILE: app/core/chunking/chunking_parser.py
DESCRIPTION: Zerlegt Markdown in Blöcke und extrahiert Kanten-Strings.
DESCRIPTION: Zerlegt Markdown in logische Einheiten (RawBlocks).
Hält alle Überschriftenebenen (H1-H6) im Stream.
Stellt die Funktion parse_edges_robust zur Verfügung.
"""
import re
from typing import List, Tuple, Set
@ -11,69 +13,86 @@ _WS = re.compile(r'\s+')
_SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])')
def split_sentences(text: str) -> list[str]:
"""Teilt Text in Sätze auf."""
"""Teilt Text in Sätze auf unter Berücksichtigung deutscher Interpunktion."""
text = _WS.sub(' ', text.strip())
if not text: return []
# Splittet bei Punkt, Ausrufezeichen oder Fragezeichen, gefolgt von Leerzeichen und Großbuchstabe
return [p.strip() for p in _SENT_SPLIT.split(text) if p.strip()]
def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]:
"""Zerlegt Text in logische Einheiten."""
"""Zerlegt Text in logische Einheiten (RawBlocks), inklusive H1-H6."""
blocks = []
h1_title = "Dokument"; section_path = "/"; current_h2 = None
h1_title = "Dokument"
section_path = "/"
current_section_title = None
# Frontmatter entfernen
fm, text_without_fm = extract_frontmatter_from_text(md_text)
# H1 für Note-Titel extrahieren (Metadaten-Zweck)
h1_match = re.search(r'^#\s+(.*)', text_without_fm, re.MULTILINE)
if h1_match: h1_title = h1_match.group(1).strip()
if h1_match:
h1_title = h1_match.group(1).strip()
lines = text_without_fm.split('\n')
buffer = []
for line in lines:
stripped = line.strip()
# H1 ignorieren (ist Doc Title)
if stripped.startswith('# '):
continue
# Generische Heading-Erkennung (H2 bis H6) für flexible Split-Levels
heading_match = re.match(r'^(#{2,6})\s+(.*)', stripped)
# Heading-Erkennung (H1 bis H6)
heading_match = re.match(r'^(#{1,6})\s+(.*)', stripped)
if heading_match:
# Buffer leeren (vorherigen Text abschließen)
# Vorherigen Text-Block abschließen
if buffer:
content = "\n".join(buffer).strip()
if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title))
buffer = []
level = len(heading_match.group(1))
title = heading_match.group(2).strip()
# Pfad-Logik: H2 setzt den Haupt-Pfad
if level == 2:
current_h2 = title
section_path = f"/{current_h2}"
# Bei H3+ bleibt der section_path beim Parent, aber das Level wird korrekt gesetzt
# Pfad- und Titel-Update für die Metadaten der folgenden Blöcke
if level == 1:
current_section_title = title; section_path = "/"
elif level == 2:
current_section_title = title; section_path = f"/{current_section_title}"
blocks.append(RawBlock("heading", stripped, level, section_path, current_h2))
# Die Überschrift selbst als regulären Block hinzufügen
blocks.append(RawBlock("heading", stripped, level, section_path, current_section_title))
continue
elif not stripped:
# Trenner (---) oder Leerzeilen beenden Blöcke, außer innerhalb von Callouts
if (not stripped or stripped == "---") and not line.startswith('>'):
if buffer:
content = "\n".join(buffer).strip()
if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title))
buffer = []
if stripped == "---":
blocks.append(RawBlock("separator", "---", None, section_path, current_section_title))
else:
buffer.append(line)
if buffer:
content = "\n".join(buffer).strip()
if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_section_title))
return blocks, h1_title
def parse_edges_robust(text: str) -> Set[str]:
"""Extrahiert Kanten-Kandidaten (Wikilinks, Callouts)."""
"""Extrahiert Kanten-Kandidaten aus Wikilinks und Callouts."""
found_edges = set()
# 1. Wikilinks [[rel:kind|target]]
inlines = re.findall(r'\[\[rel:([^\|\]]+)\|?([^\]]*)\]\]', text)
for kind, target in inlines:
k = kind.strip().lower()
t = target.strip()
if k and t: found_edges.add(f"{k}:{t}")
# 2. Callout Edges > [!edge] kind
lines = text.split('\n')
current_edge_type = None
for line in lines:
@ -81,13 +100,16 @@ def parse_edges_robust(text: str) -> Set[str]:
callout_match = re.match(r'>\s*\[!edge\]\s*([^:\s]+)', stripped)
if callout_match:
current_edge_type = callout_match.group(1).strip().lower()
# Links in der gleichen Zeile des Callouts
links = re.findall(r'\[\[([^\]]+)\]\]', stripped)
for l in links:
if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}")
continue
# Links in Folgezeilen des Callouts
if current_edge_type and stripped.startswith('>'):
links = re.findall(r'\[\[([^\]]+)\]\]', stripped)
for l in links:
if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}")
elif not stripped.startswith('>'): current_edge_type = None
elif not stripped.startswith('>'):
current_edge_type = None
return found_edges

View File

@ -1,7 +1,8 @@
"""
FILE: app/core/chunking/chunking_processor.py
DESCRIPTION: Der zentrale Orchestrator für das Chunking-System.
AUDIT v3.3.3: Wiederherstellung der "Gold-Standard" Qualität.
AUDIT v3.3.4: Wiederherstellung der "Gold-Standard" Qualität.
- Fix: Synchronisierung der Parameter (context_prefix) für alle Strategien.
- Integriert physikalische Kanten-Injektion (Propagierung).
- Stellt H1-Kontext-Fenster sicher.
- Baut den Candidate-Pool für die WP-15b Ingestion auf.
@ -30,16 +31,19 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
fm, body_text = extract_frontmatter_from_text(md_text)
blocks, doc_title = parse_blocks(md_text)
# Vorbereitung des H1-Präfix für die Embedding-Fenster
# Vorbereitung des H1-Präfix für die Embedding-Fenster (Breadcrumbs)
h1_prefix = f"# {doc_title}" if doc_title else ""
# 2. Anwendung der Splitting-Strategie
# Wir übergeben den Dokument-Titel/Präfix für die Window-Bildung.
# Alle Strategien nutzen nun einheitlich context_prefix für die Window-Bildung.
if config.get("strategy") == "by_heading":
chunks = await asyncio.to_thread(strategy_by_heading, blocks, config, note_id, doc_title)
chunks = await asyncio.to_thread(
strategy_by_heading, blocks, config, note_id, context_prefix=h1_prefix
)
else:
# sliding_window nutzt nun den context_prefix für das Window-Feld.
chunks = await asyncio.to_thread(strategy_sliding_window, blocks, config, note_id, context_prefix=h1_prefix)
chunks = await asyncio.to_thread(
strategy_sliding_window, blocks, config, note_id, context_prefix=h1_prefix
)
if not chunks:
return []
@ -52,6 +56,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
# Zuerst die explizit im Text vorhandenen Kanten sammeln.
for ch in chunks:
# Wir extrahieren aus dem bereits (durch Propagation) angereicherten Text.
# ch.candidate_pool wird im Modell-Konstruktor als leere Liste initialisiert.
for e_str in parse_edges_robust(ch.text):
parts = e_str.split(':', 1)
if len(parts) == 2:
@ -71,7 +76,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
parts = e_str.split(':', 1)
if len(parts) == 2:
k, t = parts
# Diese Kanten werden als "Global Pool" markiert für die spätere KI-Prüfung.
# Diese Kanten werden als "global_pool" markiert für die spätere KI-Prüfung.
for ch in chunks:
ch.candidate_pool.append({"kind": k, "to": t, "provenance": "global_pool"})
@ -80,6 +85,7 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
seen = set()
unique = []
for c in ch.candidate_pool:
# Eindeutigkeit über Typ, Ziel und Herkunft (Provenance)
key = (c["kind"], c["to"], c["provenance"])
if key not in seen:
seen.add(key)

View File

@ -1,9 +1,8 @@
"""
FILE: app/core/chunking/chunking_propagation.py
DESCRIPTION: Injiziert Sektions-Kanten physisch in den Text (Embedding-Enrichment).
Stellt die "Gold-Standard"-Qualität von v3.1.0 wieder her.
VERSION: 3.3.1
STATUS: Active
Fix v3.3.6: Nutzt robustes Parsing zur Erkennung vorhandener Kanten,
um Dopplungen direkt hinter [!edge] Callouts format-agnostisch zu verhindern.
"""
from typing import List, Dict, Set
from .chunking_models import Chunk
@ -12,7 +11,7 @@ from .chunking_parser import parse_edges_robust
def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]:
"""
Sammelt Kanten pro Sektion und schreibt sie hart in den Text und das Window.
Dies ist essenziell für die Vektorisierung der Beziehungen.
Verhindert Dopplungen, wenn Kanten bereits via [!edge] Callout vorhanden sind.
"""
# 1. Sammeln: Alle expliziten Kanten pro Sektions-Pfad aggregieren
section_map: Dict[str, Set[str]] = {} # path -> set(kind:target)
@ -36,21 +35,28 @@ def propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]:
if not edges_to_add:
continue
# Vorhandene Kanten (Typ:Ziel) in DIESEM Chunk ermitteln,
# um Dopplungen (z.B. durch Callouts) zu vermeiden.
existing_edges = parse_edges_robust(ch.text)
injections = []
for e_str in edges_to_add:
# Sortierung für deterministische Ergebnisse
for e_str in sorted(list(edges_to_add)):
# Wenn die Kante (Typ + Ziel) bereits vorhanden ist (egal welches Format),
# überspringen wir die Injektion für diesen Chunk.
if e_str in existing_edges:
continue
kind, target = e_str.split(':', 1)
# Nur injizieren, wenn die Kante nicht bereits im Text steht
token = f"[[rel:{kind}|{target}]]"
if token not in ch.text:
injections.append(token)
injections.append(f"[[rel:{kind}|{target}]]")
if injections:
# Physische Anreicherung (Der v3.1.0 Qualitäts-Fix)
# Physische Anreicherung
# Triple-Newline für saubere Trennung im Embedding-Fenster
block = "\n\n\n" + " ".join(injections)
ch.text += block
# ENTSCHEIDEND: Auch ins Window schreiben, da Qdrant hier sucht!
# Auch ins Window schreiben, da Qdrant hier sucht!
if ch.window:
ch.window += block
else:

View File

@ -1,142 +1,166 @@
"""
FILE: app/core/chunking/chunking_strategies.py
DESCRIPTION: Mathematische Splitting-Strategien.
AUDIT v3.3.2: 100% Konformität zur 'by_heading' Spezifikation.
- Implementiert Hybrid-Safety-Net (Sliding Window für Übergrößen).
- Breadcrumb-Kontext im Window (H1 > H2).
- Sliding Window mit H1-Kontext (Gold-Standard v3.1.0).
DESCRIPTION: Strategien für atomares Sektions-Chunking v3.9.9.
Implementiert das 'Pack-and-Carry-Over' Verfahren nach Regel 1-3.
- Keine redundante Kanten-Injektion.
- Strikte Einhaltung von Sektionsgrenzen via Look-Ahead.
- Fix: Synchronisierung der Parameter mit dem Orchestrator (context_prefix).
"""
from typing import List, Dict, Any, Optional
from .chunking_models import RawBlock, Chunk
from .chunking_utils import estimate_tokens
from .chunking_parser import split_sentences
def _create_context_win(doc_title: str, sec_title: Optional[str], text: str) -> str:
def _create_win(context_prefix: str, sec_title: Optional[str], text: str) -> str:
"""Baut den Breadcrumb-Kontext für das Embedding-Fenster."""
parts = []
if doc_title: parts.append(doc_title)
if sec_title and sec_title != doc_title: parts.append(sec_title)
parts = [context_prefix] if context_prefix else []
# Verhindert Dopplung, falls der Context-Prefix (H1) bereits den Sektionsnamen enthält
if sec_title and f"# {sec_title}" != context_prefix and sec_title not in (context_prefix or ""):
parts.append(sec_title)
prefix = " > ".join(parts)
return f"{prefix}\n{text}".strip() if prefix else text
def strategy_sliding_window(blocks: List[RawBlock],
config: Dict[str, Any],
note_id: str,
context_prefix: str = "") -> List[Chunk]:
def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, context_prefix: str = "") -> List[Chunk]:
"""
Fasst Blöcke zusammen und schneidet bei 'target' Tokens.
Ignoriert H2-Überschriften beim Splitting, um Kontext zu wahren.
"""
target = config.get("target", 400)
max_tokens = config.get("max", 600)
overlap_val = config.get("overlap", (50, 80))
overlap = sum(overlap_val) // 2 if isinstance(overlap_val, tuple) else overlap_val
chunks: List[Chunk] = []
buf: List[RawBlock] = []
def _add(txt, sec, path):
idx = len(chunks)
# H1-Kontext Präfix für das Window-Feld
win = f"{context_prefix}\n{txt}".strip() if context_prefix else txt
chunks.append(Chunk(
id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx,
text=txt, window=win, token_count=estimate_tokens(txt),
section_title=sec, section_path=path,
neighbors_prev=None, neighbors_next=None
))
def flush():
nonlocal buf
if not buf: return
text_body = "\n\n".join([b.text for b in buf])
sec_title = buf[-1].section_title; sec_path = buf[-1].section_path
if estimate_tokens(text_body) <= max_tokens:
_add(text_body, sec_title, sec_path)
else:
sents = split_sentences(text_body); cur_sents = []; cur_len = 0
for s in sents:
slen = estimate_tokens(s)
if cur_len + slen > target and cur_sents:
_add(" ".join(cur_sents), sec_title, sec_path)
ov_s = []; ov_l = 0
for os in reversed(cur_sents):
if ov_l + estimate_tokens(os) < overlap:
ov_s.insert(0, os); ov_l += estimate_tokens(os)
else: break
cur_sents = list(ov_s); cur_sents.append(s); cur_len = ov_l + slen
else:
cur_sents.append(s); cur_len += slen
if cur_sents:
_add(" ".join(cur_sents), sec_title, sec_path)
buf = []
for b in blocks:
# H2-Überschriften werden ignoriert, um den Zusammenhang zu wahren
if b.kind == "heading": continue
if estimate_tokens("\n\n".join([x.text for x in buf])) + estimate_tokens(b.text) >= target:
flush()
buf.append(b)
flush()
return chunks
def strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "") -> List[Chunk]:
"""
Splittet Text basierend auf Markdown-Überschriften mit Hybrid-Safety-Net.
Universelle Heading-Strategie mit Carry-Over Logik.
Synchronisiert auf context_prefix für Kompatibilität mit dem Orchestrator.
"""
smart_edge = config.get("enable_smart_edge_allocation", True)
strict = config.get("strict_heading_split", False)
target = config.get("target", 400)
max_tokens = config.get("max", 600)
split_level = config.get("split_level", 2)
overlap = sum(config.get("overlap", (50, 80))) // 2
overlap_cfg = config.get("overlap", (50, 80))
overlap = sum(overlap_cfg) // 2 if isinstance(overlap_cfg, (list, tuple)) else overlap_cfg
chunks: List[Chunk] = []
buf: List[str] = []
cur_tokens = 0
def _add_to_chunks(txt, title, path):
def _emit(txt, title, path):
"""Schreibt den finalen Chunk ohne Text-Modifikationen."""
idx = len(chunks)
win = _create_context_win(doc_title, title, txt)
win = _create_win(context_prefix, title, txt)
chunks.append(Chunk(
id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx,
text=txt, window=win, token_count=estimate_tokens(txt),
section_title=title, section_path=path,
neighbors_prev=None, neighbors_next=None
section_title=title, section_path=path, neighbors_prev=None, neighbors_next=None
))
def _flush(title, path):
nonlocal buf, cur_tokens
if not buf: return
full_text = "\n\n".join(buf)
if estimate_tokens(full_text) <= max_tokens:
_add_to_chunks(full_text, title, path)
# --- SCHRITT 1: Gruppierung in atomare Sektions-Einheiten ---
sections: List[Dict[str, Any]] = []
curr_blocks = []
for b in blocks:
if b.kind == "heading" and b.level <= split_level:
if curr_blocks:
sections.append({
"text": "\n\n".join([x.text for x in curr_blocks]),
"meta": curr_blocks[0],
"is_empty": len(curr_blocks) == 1 and curr_blocks[0].kind == "heading"
})
curr_blocks = [b]
else:
sents = split_sentences(full_text); cur_sents = []; sub_len = 0
for s in sents:
slen = estimate_tokens(s)
if sub_len + slen > target and cur_sents:
_add_to_chunks(" ".join(cur_sents), title, path)
ov_s = []; ov_l = 0
for os in reversed(cur_sents):
if ov_l + estimate_tokens(os) < overlap:
ov_s.insert(0, os); ov_l += estimate_tokens(os)
else: break
cur_sents = list(ov_s); cur_sents.append(s); sub_len = ov_l + slen
else: cur_sents.append(s); sub_len += slen
if cur_sents: _add_to_chunks(" ".join(cur_sents), title, path)
buf = []; cur_tokens = 0
curr_blocks.append(b)
if curr_blocks:
sections.append({
"text": "\n\n".join([x.text for x in curr_blocks]),
"meta": curr_blocks[0],
"is_empty": len(curr_blocks) == 1 and curr_blocks[0].kind == "heading"
})
# --- SCHRITT 2: Verarbeitung der Queue ---
queue = list(sections)
current_chunk_text = ""
current_meta = {"title": None, "path": "/"}
# Bestimmung des Modus: Hard-Split wenn smart_edge=False ODER strict=True
is_hard_split_mode = (not smart_edge) or (strict)
while queue:
item = queue.pop(0)
item_text = item["text"]
# Initialisierung für neuen Chunk
if not current_chunk_text:
current_meta["title"] = item["meta"].section_title
current_meta["path"] = item["meta"].section_path
# FALL A: HARD SPLIT MODUS
if is_hard_split_mode:
# Leere Überschriften (z.B. H1 direkt vor H2) verbleiben am nächsten Chunk
if item.get("is_empty", False) and queue:
current_chunk_text = (current_chunk_text + "\n\n" + item_text).strip()
continue
combined = (current_chunk_text + "\n\n" + item_text).strip()
# Wenn durch Verschmelzung das Limit gesprengt würde, vorher flashen
if estimate_tokens(combined) > max_tokens and current_chunk_text:
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
current_chunk_text = item_text
else:
current_chunk_text = combined
# Im Hard-Split wird nach jeder Sektion geflasht
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
current_chunk_text = ""
continue
# FALL B: SMART MODE (Regel 1-3)
combined_text = (current_chunk_text + "\n\n" + item_text).strip() if current_chunk_text else item_text
combined_est = estimate_tokens(combined_text)
if combined_est <= max_tokens:
# Regel 1 & 2: Passt rein laut Schätzung -> Aufnehmen
current_chunk_text = combined_text
else:
if current_chunk_text:
# Regel 2: Flashen an Sektionsgrenze, Item zurücklegen
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
current_chunk_text = ""
queue.insert(0, item)
else:
# Regel 3: Einzelne Sektion zu groß -> Smart Zerlegung
sents = split_sentences(item_text)
header_prefix = item["meta"].text if item["meta"].kind == "heading" else ""
take_sents = []; take_len = 0
while sents:
s = sents.pop(0); slen = estimate_tokens(s)
if take_len + slen > target and take_sents:
sents.insert(0, s); break
take_sents.append(s); take_len += slen
_emit(" ".join(take_sents), current_meta["title"], current_meta["path"])
if sents:
remainder = " ".join(sents)
# Kontext-Erhalt: Überschrift für den Rest wiederholen
if header_prefix and not remainder.startswith(header_prefix):
remainder = header_prefix + "\n\n" + remainder
# Carry-Over: Rest wird vorne in die Queue geschoben
queue.insert(0, {"text": remainder, "meta": item["meta"], "is_split": True})
if current_chunk_text:
_emit(current_chunk_text, current_meta["title"], current_meta["path"])
return chunks
def strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, context_prefix: str = "") -> List[Chunk]:
"""Standard-Sliding-Window für flache Texte ohne Sektionsfokus."""
target = config.get("target", 400); max_tokens = config.get("max", 600)
chunks: List[Chunk] = []; buf: List[RawBlock] = []
for b in blocks:
if b.kind == "heading":
if b.level < split_level: _flush(b.section_title, b.section_path)
elif b.level == split_level:
if strict or cur_tokens >= target: _flush(b.section_title, b.section_path)
continue
bt = estimate_tokens(b.text)
if cur_tokens + bt > max_tokens and buf: _flush(b.section_title, b.section_path)
buf.append(b.text); cur_tokens += bt
b_tokens = estimate_tokens(b.text)
curr_tokens = sum(estimate_tokens(x.text) for x in buf) if buf else 0
if curr_tokens + b_tokens > max_tokens and buf:
txt = "\n\n".join([x.text for x in buf]); idx = len(chunks)
win = _create_win(context_prefix, buf[0].section_title, txt)
chunks.append(Chunk(id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, text=txt, window=win, token_count=curr_tokens, section_title=buf[0].section_title, section_path=buf[0].section_path, neighbors_prev=None, neighbors_next=None))
buf = []
buf.append(b)
if buf:
last_b = blocks[-1] if blocks else None
_flush(last_b.section_title if last_b else None, last_b.section_path if last_b else "/")
txt = "\n\n".join([x.text for x in buf]); idx = len(chunks)
win = _create_win(context_prefix, buf[0].section_title, txt)
chunks.append(Chunk(id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, text=txt, window=win, token_count=estimate_tokens(txt), section_title=buf[0].section_title, section_path=buf[0].section_path, neighbors_prev=None, neighbors_next=None))
return chunks

View File

@ -3,7 +3,7 @@ FILE: app/core/database/qdrant.py
DESCRIPTION: Qdrant-Client Factory und Schema-Management.
Erstellt Collections und Payload-Indizes.
MODULARISIERUNG: Verschoben in das database-Paket für WP-14.
VERSION: 2.2.1
VERSION: 2.2.2 (WP-Fix: Index für target_section)
STATUS: Active
DEPENDENCIES: qdrant_client, dataclasses, os
"""
@ -124,7 +124,7 @@ def ensure_payload_indexes(client: QdrantClient, prefix: str) -> None:
Stellt sicher, dass alle benötigten Payload-Indizes für die Suche existieren.
- notes: note_id, type, title, updated, tags
- chunks: note_id, chunk_id, index, type, tags
- edges: note_id, kind, scope, source_id, target_id, chunk_id
- edges: note_id, kind, scope, source_id, target_id, chunk_id, target_section
"""
notes, chunks, edges = collection_names(prefix)
@ -156,6 +156,8 @@ def ensure_payload_indexes(client: QdrantClient, prefix: str) -> None:
("source_id", rest.PayloadSchemaType.KEYWORD),
("target_id", rest.PayloadSchemaType.KEYWORD),
("chunk_id", rest.PayloadSchemaType.KEYWORD),
# NEU: Index für Section-Links (WP-15b)
("target_section", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, edges, field, schema)

View File

@ -1,10 +1,10 @@
"""
FILE: app/core/database/qdrant_points.py
DESCRIPTION: Object-Mapper für Qdrant. Konvertiert JSON-Payloads (Notes, Chunks, Edges) in PointStructs und generiert deterministische UUIDs.
VERSION: 1.5.0
VERSION: 1.5.1 (WP-Fix: Explicit Target Section Support)
STATUS: Active
DEPENDENCIES: qdrant_client, uuid, os
LAST_ANALYSIS: 2025-12-15
LAST_ANALYSIS: 2025-12-29
"""
from __future__ import annotations
import os
@ -46,16 +46,25 @@ def points_for_chunks(prefix: str, chunk_payloads: List[dict], vectors: List[Lis
return chunks_col, points
def _normalize_edge_payload(pl: dict) -> dict:
"""Normalisiert Edge-Felder und sichert Schema-Konformität."""
kind = pl.get("kind") or pl.get("edge_type") or "edge"
source_id = pl.get("source_id") or pl.get("src_id") or "unknown-src"
target_id = pl.get("target_id") or pl.get("dst_id") or "unknown-tgt"
seq = pl.get("seq") or pl.get("order") or pl.get("index")
# WP-Fix: target_section explizit durchreichen
target_section = pl.get("target_section")
pl.setdefault("kind", kind)
pl.setdefault("source_id", source_id)
pl.setdefault("target_id", target_id)
if seq is not None and "seq" not in pl:
pl["seq"] = seq
if target_section is not None:
pl["target_section"] = target_section
return pl
def points_for_edges(prefix: str, edge_payloads: List[dict]) -> Tuple[str, List[rest.PointStruct]]:

View File

@ -1,10 +1,14 @@
"""
FILE: app/core/graph/graph_derive_edges.py
DESCRIPTION: Hauptlogik zur Kanten-Aggregation und De-Duplizierung.
AUDIT:
- Nutzt parse_link_target
- Übergibt Section als 'variant' an ID-Gen
- Dedup basiert jetzt auf Edge-ID (erlaubt Multigraph für Sections)
"""
from typing import List, Optional, Dict, Tuple
from .graph_utils import (
_get, _edge, _mk_edge_id, _dedupe_seq,
_get, _edge, _mk_edge_id, _dedupe_seq, parse_link_target,
PROVENANCE_PRIORITY, load_types_registry, get_edge_defaults_for
)
from .graph_extractors import (
@ -53,47 +57,85 @@ def build_edges_for_note(
# Typed & Candidate Pool (WP-15b Integration)
typed, rem = extract_typed_relations(raw)
for k, t in typed:
edges.append(_edge(k, "chunk", cid, t, note_id, {
"chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", "inline:rel"),
for k, raw_t in typed:
t, sec = parse_link_target(raw_t, note_id)
if not t: continue
payload = {
"chunk_id": cid,
# Variant=sec sorgt für eindeutige ID pro Abschnitt
"edge_id": _mk_edge_id(k, cid, t, "chunk", "inline:rel", variant=sec),
"provenance": "explicit", "rule_id": "inline:rel", "confidence": PROVENANCE_PRIORITY["inline:rel"]
}))
}
if sec: payload["target_section"] = sec
edges.append(_edge(k, "chunk", cid, t, note_id, payload))
pool = ch.get("candidate_pool") or ch.get("candidate_edges") or []
for cand in pool:
t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai")
raw_t, k, p = cand.get("to"), cand.get("kind", "related_to"), cand.get("provenance", "semantic_ai")
t, sec = parse_link_target(raw_t, note_id)
if t:
edges.append(_edge(k, "chunk", cid, t, note_id, {
"chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", f"candidate:{p}"),
payload = {
"chunk_id": cid,
"edge_id": _mk_edge_id(k, cid, t, "chunk", f"candidate:{p}", variant=sec),
"provenance": p, "rule_id": f"candidate:{p}", "confidence": PROVENANCE_PRIORITY.get(p, 0.90)
}))
}
if sec: payload["target_section"] = sec
edges.append(_edge(k, "chunk", cid, t, note_id, payload))
# Callouts & Wikilinks
call_pairs, rem2 = extract_callout_relations(rem)
for k, t in call_pairs:
edges.append(_edge(k, "chunk", cid, t, note_id, {
"chunk_id": cid, "edge_id": _mk_edge_id(k, cid, t, "chunk", "callout:edge"),
for k, raw_t in call_pairs:
t, sec = parse_link_target(raw_t, note_id)
if not t: continue
payload = {
"chunk_id": cid,
"edge_id": _mk_edge_id(k, cid, t, "chunk", "callout:edge", variant=sec),
"provenance": "explicit", "rule_id": "callout:edge", "confidence": PROVENANCE_PRIORITY["callout:edge"]
}))
}
if sec: payload["target_section"] = sec
edges.append(_edge(k, "chunk", cid, t, note_id, payload))
refs = extract_wikilinks(rem2)
for r in refs:
edges.append(_edge("references", "chunk", cid, r, note_id, {
"chunk_id": cid, "ref_text": r, "edge_id": _mk_edge_id("references", cid, r, "chunk", "explicit:wikilink"),
for raw_r in refs:
r, sec = parse_link_target(raw_r, note_id)
if not r: continue
payload = {
"chunk_id": cid, "ref_text": raw_r,
"edge_id": _mk_edge_id("references", cid, r, "chunk", "explicit:wikilink", variant=sec),
"provenance": "explicit", "rule_id": "explicit:wikilink", "confidence": PROVENANCE_PRIORITY["explicit:wikilink"]
}))
}
if sec: payload["target_section"] = sec
edges.append(_edge("references", "chunk", cid, r, note_id, payload))
for rel in defaults:
if rel != "references":
edges.append(_edge(rel, "chunk", cid, r, note_id, {
"chunk_id": cid, "edge_id": _mk_edge_id(rel, cid, r, "chunk", f"edge_defaults:{rel}"),
def_payload = {
"chunk_id": cid,
"edge_id": _mk_edge_id(rel, cid, r, "chunk", f"edge_defaults:{rel}", variant=sec),
"provenance": "rule", "rule_id": f"edge_defaults:{rel}", "confidence": PROVENANCE_PRIORITY["edge_defaults"]
}))
refs_all.extend(refs)
}
if sec: def_payload["target_section"] = sec
edges.append(_edge(rel, "chunk", cid, r, note_id, def_payload))
# Für Note-Scope Sammlung nutzen wir den Original-String zur Dedup, aber gesäubert
refs_all.extend([parse_link_target(r, note_id)[0] for r in refs])
# 3) Note-Scope & De-Duplizierung
if include_note_scope_refs:
refs_note = _dedupe_seq((refs_all or []) + (note_level_references or []))
# refs_all ist jetzt schon gesäubert (nur Targets)
# note_level_references müssen auch gesäubert werden
cleaned_note_refs = [parse_link_target(r, note_id)[0] for r in (note_level_references or [])]
refs_note = _dedupe_seq((refs_all or []) + cleaned_note_refs)
for r in refs_note:
if not r: continue
edges.append(_edge("references", "note", note_id, r, note_id, {
"edge_id": _mk_edge_id("references", note_id, r, "note", "explicit:note_scope"),
"provenance": "explicit", "confidence": PROVENANCE_PRIORITY["explicit:note_scope"]
@ -103,10 +145,13 @@ def build_edges_for_note(
"provenance": "rule", "confidence": PROVENANCE_PRIORITY["derived:backlink"]
}))
unique_map: Dict[Tuple[str, str, str], dict] = {}
# Deduplizierung: Wir nutzen jetzt die EDGE-ID als Schlüssel.
# Da die Edge-ID nun 'variant' (Section) enthält, bleiben unterschiedliche Sections erhalten.
unique_map: Dict[str, dict] = {}
for e in edges:
key = (str(e.get("source_id")), str(e.get("target_id")), str(e.get("kind")))
if key not in unique_map or e.get("confidence", 0) > unique_map[key].get("confidence", 0):
unique_map[key] = e
eid = e["edge_id"]
# Bei Konflikt (gleiche ID = exakt gleiche Kante und Section) gewinnt die höhere Confidence
if eid not in unique_map or e.get("confidence", 0) > unique_map[eid].get("confidence", 0):
unique_map[eid] = e
return list(unique_map.values())

View File

@ -1,25 +1,36 @@
"""
FILE: app/core/graph/graph_extractors.py
DESCRIPTION: Regex-basierte Extraktion von Relationen aus Text.
AUDIT:
- Regex für Wikilinks liberalisiert (Umlaute, Sonderzeichen).
- Callout-Parser erweitert für Multi-Line-Listen und Header-Typen.
"""
import re
from typing import List, Tuple
_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([a-zA-Z0-9_\-#:. ]+)\]\]")
# Erlaube alle Zeichen außer ']' im Target (fängt Umlaute, Emojis, '&', '#' ab)
_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([^\]]+)\]\]")
_REL_PIPE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s*\|\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_REL_SPACE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s+(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_REL_TEXT = re.compile(r"rel\s*:\s*(?P<kind>[a-z_]+)\s*\[\[\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_CALLOUT_START = re.compile(r"^\s*>\s*\[!edge\]\s*(.*)$", re.IGNORECASE)
# Erkennt "kind: targets..."
_REL_LINE = re.compile(r"^(?P<kind>[a-z_]+)\s*:\s*(?P<targets>.+?)\s*$", re.IGNORECASE)
_WIKILINKS_IN_LINE = re.compile(r"\[\[([^\]]+)\]\]")
# Erkennt reine Typen (z.B. "depends_on" im Header)
_SIMPLE_KIND = re.compile(r"^[a-z_]+$", re.IGNORECASE)
def extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
"""Extrahiert [[rel:KIND|Target]]."""
def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]:
"""
Findet Inline-Relationen wie [[rel:depends_on Target]].
Gibt (Liste[(kind, target)], bereinigter_text) zurück.
"""
if not text: return [], ""
pairs = []
def _collect(m):
k, t = (m.group("kind") or "").strip().lower(), (m.group("target") or "").strip()
if k and t: pairs.append((k, t))
k, t = m.group("kind").strip().lower(), m.group("target").strip()
pairs.append((k, t))
return ""
text = _REL_PIPE.sub(_collect, text)
text = _REL_SPACE.sub(_collect, text)
@ -27,29 +38,90 @@ def extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
return pairs, text
def extract_callout_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
"""Verarbeitet Obsidian [!edge]-Callouts."""
"""
Verarbeitet Obsidian [!edge]-Callouts.
Unterstützt zwei Formate:
1. Explizit: "kind: [[Target]]"
2. Implizit (Header): "> [!edge] kind" gefolgt von "[[Target]]" Zeilen
"""
if not text: return [], text
lines = text.splitlines(); out_pairs, keep_lines, i = [], [], 0
lines = text.splitlines()
out_pairs = []
keep_lines = []
i = 0
while i < len(lines):
m = _CALLOUT_START.match(lines[i])
line = lines[i]
m = _CALLOUT_START.match(line)
if not m:
keep_lines.append(lines[i]); i += 1; continue
block_lines = [m.group(1)] if m.group(1).strip() else []
keep_lines.append(line)
i += 1
continue
# Callout-Block gefunden. Wir sammeln alle relevanten Zeilen.
block_lines = []
# Header Content prüfen (z.B. "type" aus "> [!edge] type")
header_raw = m.group(1).strip()
if header_raw:
block_lines.append(header_raw)
i += 1
while i < len(lines) and lines[i].lstrip().startswith('>'):
block_lines.append(lines[i].lstrip()[1:].lstrip()); i += 1
# Entferne '>' und führende Leerzeichen
content = lines[i].lstrip()[1:].lstrip()
if content:
block_lines.append(content)
i += 1
# Verarbeitung des Blocks
current_kind = None
# Heuristik: Ist die allererste Zeile (meist aus dem Header) ein reiner Typ?
# Dann setzen wir diesen als Default für den Block.
if block_lines:
first = block_lines[0]
# Wenn es NICHT wie "Key: Value" aussieht, aber wie ein Wort:
if not _REL_LINE.match(first) and _SIMPLE_KIND.match(first):
current_kind = first.lower()
for bl in block_lines:
# 1. Prüfen auf explizites "Kind: Targets" (überschreibt Header-Typ für diese Zeile)
mrel = _REL_LINE.match(bl)
if not mrel: continue
kind, targets = mrel.group("kind").strip().lower(), mrel.group("targets") or ""
found = _WIKILINKS_IN_LINE.findall(targets)
if mrel:
line_kind = mrel.group("kind").strip().lower()
targets = mrel.group("targets")
# Links extrahieren
found = _WIKILINK_RE.findall(targets)
if found:
for t in found: out_pairs.append((line_kind, t.strip()))
else:
# Fallback für kommagetrennten Plaintext
for raw in re.split(r"[,;]", targets):
if raw.strip(): out_pairs.append((line_kind, raw.strip()))
# Wenn wir eine explizite Zeile gefunden haben, aktualisieren wir NICHT
# den current_kind für nachfolgende Zeilen (Design-Entscheidung: lokal scope),
# oder wir machen es doch?
# Üblicher ist: Header setzt Default, Zeile überschreibt lokal.
# Wir lassen current_kind also unangetastet.
continue
# 2. Kein Key:Value Muster -> Prüfen auf Links, die den current_kind nutzen
found = _WIKILINK_RE.findall(bl)
if found:
for t in found: out_pairs.append((kind, t.strip()))
else:
for raw in re.split(r"[,;]", targets):
if raw.strip(): out_pairs.append((kind, raw.strip()))
if current_kind:
for t in found: out_pairs.append((current_kind, t.strip()))
else:
# Link ohne Typ und ohne Header-Typ.
# Wird ignoriert oder könnte als 'related_to' fallback dienen.
# Aktuell: Ignorieren, um False Positives zu vermeiden.
pass
return out_pairs, "\n".join(keep_lines)
def extract_wikilinks(text: str) -> List[str]:
"""Extrahiert Standard-Wikilinks."""
return [m.group(1).strip() for m in _WIKILINK_RE.finditer(text or "")]
"""Findet Standard-Wikilinks [[Target]] oder [[Alias|Target]]."""
if not text: return []
return [m.strip() for m in _WIKILINK_RE.findall(text) if m.strip()]

View File

@ -1,10 +1,11 @@
"""
FILE: app/core/graph/graph_utils.py
DESCRIPTION: Basale Werkzeuge, ID-Generierung und Provenance-Konfiguration für den Graphen.
AUDIT: Erweitert um parse_link_target für sauberes Section-Splitting (WP-Fix).
"""
import os
import hashlib
from typing import Iterable, List, Optional, Set, Any
from typing import Iterable, List, Optional, Set, Any, Tuple
try:
import yaml
@ -40,10 +41,19 @@ def _dedupe_seq(seq: Iterable[str]) -> List[str]:
seen.add(s); out.append(s)
return out
def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None) -> str:
"""Erzeugt eine deterministische 12-Byte ID mittels BLAKE2s."""
def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None, variant: Optional[str] = None) -> str:
"""
Erzeugt eine deterministische 12-Byte ID mittels BLAKE2s.
WP-Fix: 'variant' (z.B. Section) fließt in den Hash ein, um mehrere Kanten
zum gleichen Target-Node (aber unterschiedlichen Abschnitten) zu unterscheiden.
"""
base = f"{kind}:{s}->{t}#{scope}"
if rule_id: base += f"|{rule_id}"
if rule_id:
base += f"|{rule_id}"
if variant:
base += f"|{variant}" # <--- Hier entsteht die Eindeutigkeit für verschiedene Sections
return hashlib.blake2s(base.encode("utf-8"), digest_size=12).hexdigest()
def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, extra: Optional[dict] = None) -> dict:
@ -59,6 +69,27 @@ def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, e
if extra: pl.update(extra)
return pl
def parse_link_target(raw: str, current_note_id: Optional[str] = None) -> Tuple[str, Optional[str]]:
"""
Zerlegt einen Link (z.B. 'Note#Section') in Target-ID und Section.
Behandelt Self-Links ('#Section'), indem current_note_id eingesetzt wird.
Returns:
(target_id, target_section)
"""
if not raw:
return "", None
parts = raw.split("#", 1)
target = parts[0].strip()
section = parts[1].strip() if len(parts) > 1 else None
# Handle Self-Link [[#Section]] -> target wird zu current_note_id
if not target and section and current_note_id:
target = current_note_id
return target, section
def load_types_registry() -> dict:
"""Lädt die YAML-Registry."""
p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml")

View File

@ -3,9 +3,8 @@ FILE: app/core/ingestion/ingestion_note_payload.py
DESCRIPTION: Baut das JSON-Objekt für mindnet_notes.
FEATURES:
- Multi-Hash (body/full) für flexible Change Detection.
- Fix v2.4.4: Integration der zentralen Registry (WP-14) für konsistente Defaults.
VERSION: 2.4.4
STATUS: Active
- Fix v2.4.5: Präzise Hash-Logik für Profil-Änderungen.
- Integration der zentralen Registry (WP-14).
"""
from __future__ import annotations
from typing import Any, Dict, Tuple, Optional
@ -45,14 +44,23 @@ def _compute_hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str:
"""Generiert den Hash-Input-String basierend auf Body oder Metadaten."""
body = str(n.get("body") or "")
"""
Generiert den Hash-Input-String basierend auf Body oder Metadaten.
Fix: Inkludiert nun alle entscheidungsrelevanten Profil-Parameter.
"""
body = str(n.get("body") or "").strip()
if mode == "body": return body
if mode == "full":
fm = n.get("frontmatter") or {}
meta_parts = []
# Sortierte Liste für deterministische Hashes
for k in sorted(["title", "type", "status", "tags", "chunking_profile", "chunk_profile", "retriever_weight"]):
# Wir inkludieren alle Felder, die das Chunking oder Retrieval beeinflussen
# Jede Änderung hier führt nun zwingend zu einem neuen Full-Hash
keys = [
"title", "type", "status", "tags",
"chunking_profile", "chunk_profile",
"retriever_weight", "split_level", "strict_heading_split"
]
for k in sorted(keys):
val = fm.get(k)
if val is not None: meta_parts.append(f"{k}:{val}")
return f"{'|'.join(meta_parts)}||{body}"
@ -79,11 +87,11 @@ def _cfg_defaults(reg: dict) -> dict:
def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
"""
Baut das Note-Payload inklusive Multi-Hash und Audit-Validierung.
WP-14: Nutzt nun die zentrale Registry für alle Fallbacks.
WP-14: Nutzt die zentrale Registry für alle Fallbacks.
"""
n = _as_dict(note)
# Nutzt übergebene Registry oder lädt sie global
# Registry & Context Settings
reg = kwargs.get("types_cfg") or load_type_registry()
hash_source = kwargs.get("hash_source", "parsed")
hash_normalize = kwargs.get("hash_normalize", "canonical")
@ -96,7 +104,6 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
ingest_cfg = reg.get("ingestion_settings", {})
# --- retriever_weight Audit ---
# Priorität: Frontmatter -> Typ-Config -> globale Config -> Env-Var
default_rw = float(os.environ.get("MINDNET_DEFAULT_RETRIEVER_WEIGHT", 1.0))
retriever_weight = fm.get("retriever_weight")
if retriever_weight is None:
@ -107,14 +114,13 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
retriever_weight = default_rw
# --- chunk_profile Audit ---
# Nutzt nun primär die ingestion_settings aus der Registry
chunk_profile = fm.get("chunking_profile") or fm.get("chunk_profile")
if chunk_profile is None:
chunk_profile = cfg_type.get("chunking_profile") or cfg_type.get("chunk_profile")
if chunk_profile is None:
chunk_profile = ingest_cfg.get("default_chunk_profile", cfg_def.get("chunking_profile", "sliding_standard"))
# --- edge_defaults ---
# --- edge_defaults Audit ---
edge_defaults = fm.get("edge_defaults")
if edge_defaults is None:
edge_defaults = cfg_type.get("edge_defaults", cfg_def.get("edge_defaults", []))
@ -138,21 +144,24 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
}
# --- MULTI-HASH ---
# Generiert Hashes für Change Detection
# Generiert Hashes für Change Detection (WP-15b)
for mode in ["body", "full"]:
content = _get_hash_source_content(n, mode)
payload["hashes"][f"{mode}:{hash_source}:{hash_normalize}"] = _compute_hash(content)
# Metadaten Anreicherung
# Metadaten Anreicherung (Tags, Aliases, Zeitstempel)
tags = fm.get("tags") or fm.get("keywords") or n.get("tags")
if tags: payload["tags"] = _ensure_list(tags)
if fm.get("aliases"): payload["aliases"] = _ensure_list(fm.get("aliases"))
aliases = fm.get("aliases")
if aliases: payload["aliases"] = _ensure_list(aliases)
for k in ("created", "modified", "date"):
v = fm.get(k) or n.get(k)
if v: payload[k] = str(v)
if n.get("body"): payload["fulltext"] = str(n["body"])
if n.get("body"):
payload["fulltext"] = str(n["body"])
# Final JSON Validation Audit
json.loads(json.dumps(payload, ensure_ascii=False))

View File

@ -4,8 +4,8 @@ DESCRIPTION: Der zentrale IngestionService (Orchestrator).
WP-14: Modularisierung der Datenbank-Ebene (app.core.database).
WP-15b: Two-Pass Workflow mit globalem Kontext-Cache.
WP-20/22: Cloud-Resilienz und Content-Lifecycle integriert.
AUDIT v2.13.10: Umstellung auf app.core.database Infrastruktur.
VERSION: 2.13.10
AUDIT v2.13.12: Synchronisierung der Profil-Auflösung mit Registry-Defaults.
VERSION: 2.13.12
STATUS: Active
"""
import logging
@ -60,6 +60,7 @@ class IngestionService:
self.embedder = EmbeddingsClient()
self.llm = LLMService()
# Festlegen, welcher Hash für die Change-Detection maßgeblich ist
self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE
self.batch_cache: Dict[str, NoteContext] = {} # WP-15b LocalBatchCache
@ -130,12 +131,18 @@ class IngestionService:
)
note_id = note_pl["note_id"]
# Abgleich mit der Datenbank (Qdrant)
old_payload = None if force_replace else fetch_note_payload(self.client, self.prefix, note_id)
# Prüfung gegen den konfigurierten Hash-Modus (body vs. full)
check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}"
old_hash = (old_payload or {}).get("hashes", {}).get(check_key)
new_hash = note_pl.get("hashes", {}).get(check_key)
# Check ob Chunks oder Kanten in der DB fehlen (Reparatur-Modus)
c_miss, e_miss = artifacts_missing(self.client, self.prefix, note_id)
# Wenn Hash identisch und Artefakte vorhanden -> Skip
if not (force_replace or not old_payload or old_hash != new_hash or c_miss or e_miss):
return {**result, "status": "unchanged", "note_id": note_id}
@ -146,36 +153,49 @@ class IngestionService:
try:
body_text = getattr(parsed, "body", "") or ""
edge_registry.ensure_latest()
profile = fm.get("chunk_profile") or fm.get("chunking_profile") or "sliding_standard"
# Profil-Auflösung via Registry
# FIX: Wir nutzen das Profil, das bereits in make_note_payload unter
# Berücksichtigung der types.yaml (Registry) ermittelt wurde.
profile = note_pl.get("chunk_profile", "sliding_standard")
chunk_cfg = get_chunk_config_by_profile(self.registry, profile, note_type)
enable_smart = chunk_cfg.get("enable_smart_edge_allocation", False)
# WP-15b: Chunker-Aufruf bereitet Candidate-Pool vor
# WP-15b: Chunker-Aufruf bereitet den Candidate-Pool pro Chunk vor.
# assemble_chunks führt intern auch die Propagierung durch.
chunks = await assemble_chunks(note_id, body_text, note_type, config=chunk_cfg)
# Semantische Kanten-Validierung (Smart Edge Allocation)
for ch in chunks:
filtered = []
for cand in getattr(ch, "candidate_pool", []):
# WP-15b: Nur global_pool Kandidaten erfordern binäre Validierung
# Nur global_pool Kandidaten (aus dem Pool am Ende) erfordern KI-Validierung
if cand.get("provenance") == "global_pool" and enable_smart:
if await validate_edge_candidate(ch.text, cand, self.batch_cache, self.llm, self.settings.MINDNET_LLM_PROVIDER):
filtered.append(cand)
else:
# Explizite Kanten (Wikilinks/Callouts) werden ungeprüft übernommen
filtered.append(cand)
ch.candidate_pool = filtered
# Payload-Erstellung via interne Module
# Payload-Erstellung für die Chunks
chunk_pls = make_chunk_payloads(
fm, note_pl["path"], chunks, file_path=file_path,
types_cfg=self.registry
)
# Vektorisierung der Fenster-Texte
vecs = await self.embedder.embed_documents([c.get("window") or "" for c in chunk_pls]) if chunk_pls else []
# Kanten-Aggregation
# Aggregation aller finalen Kanten (Edges)
edges = build_edges_for_note(
note_id, chunk_pls,
note_level_references=note_pl.get("references", []),
include_note_scope_refs=note_scope_refs
)
# Kanten-Typen via Registry validieren/auflösen
for e in edges:
e["kind"] = edge_registry.resolve(
e.get("kind", "related_to"),
@ -184,16 +204,20 @@ class IngestionService:
)
# 4. DB Upsert via modularisierter Points-Logik
# WICHTIG: Wenn sich der Inhalt geändert hat, löschen wir erst alle alten Fragmente.
if purge_before and old_payload:
purge_artifacts(self.client, self.prefix, note_id)
# Speichern der Haupt-Note
n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim)
upsert_batch(self.client, n_name, n_pts)
# Speichern der Chunks
if chunk_pls and vecs:
c_pts = points_for_chunks(self.prefix, chunk_pls, vecs)[1]
upsert_batch(self.client, f"{self.prefix}_chunks", c_pts)
# Speichern der Kanten
if edges:
e_pts = points_for_edges(self.prefix, edges)[1]
upsert_batch(self.client, f"{self.prefix}_edges", e_pts)
@ -217,4 +241,5 @@ class IngestionService:
with open(target_path, "w", encoding="utf-8") as f:
f.write(markdown_content)
await asyncio.sleep(0.1)
# Triggert sofortigen Import mit force_replace/purge_before
return await self.process_file(file_path=target_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True)

View File

@ -1,10 +1,10 @@
"""
FILE: app/frontend/ui_graph_service.py
DESCRIPTION: Data Layer für den Graphen. Greift direkt auf Qdrant zu (Performance), um Knoten/Kanten zu laden und Texte zu rekonstruieren ("Stitching").
VERSION: 2.6.1 (Fix: Anchor-Link & Fragment Resolution)
VERSION: 2.6.0
STATUS: Active
DEPENDENCIES: qdrant_client, streamlit_agraph, ui_config, re
LAST_ANALYSIS: 2025-12-28
LAST_ANALYSIS: 2025-12-15
"""
import re
@ -24,7 +24,6 @@ class GraphExplorerService:
self.chunks_col = f"{self.prefix}_chunks"
self.edges_col = f"{self.prefix}_edges"
self._note_cache = {}
self._ref_resolution_cache = {}
def get_note_with_full_content(self, note_id):
"""
@ -38,7 +37,8 @@ class GraphExplorerService:
# 2. Volltext aus Chunks bauen
full_text = self._fetch_full_text_stitched(note_id)
# 3. Ergebnis kombinieren (Kopie zurückgeben)
# 3. Ergebnis kombinieren (Wir überschreiben das 'fulltext' Feld mit dem frischen Stitching)
# Wir geben eine Kopie zurück, um den Cache nicht zu verfälschen
complete_note = meta.copy()
if full_text:
complete_note['fulltext'] = full_text
@ -61,7 +61,7 @@ class GraphExplorerService:
# Initialset für Suche
level_1_ids = {center_note_id}
# Suche Kanten für Center (L1) inkl. Titel für Anchor-Suche
# Suche Kanten für Center (L1)
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
for edge_data in l1_edges:
@ -84,6 +84,7 @@ class GraphExplorerService:
if center_note_id in nodes_dict:
orig_title = nodes_dict[center_note_id].title
clean_full = self._clean_markdown(center_text[:2000])
# Wir packen den Text in den Tooltip (title attribute)
nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 INHALT:\n{clean_full}..."
# B. Previews für alle Nachbarn holen (Batch)
@ -103,6 +104,8 @@ class GraphExplorerService:
prov = data['provenance']
color = get_edge_color(kind)
is_smart = (prov != "explicit" and prov != "rule")
# Label Logik
label_text = kind if show_labels else " "
final_edges.append(Edge(
@ -113,11 +116,15 @@ class GraphExplorerService:
return list(nodes_dict.values()), final_edges
def _clean_markdown(self, text):
"""Entfernt Markdown-Sonderzeichen für saubere Tooltips."""
"""Entfernt Markdown-Sonderzeichen für saubere Tooltips im Browser."""
if not text: return ""
# Entferne Header Marker (## )
text = re.sub(r'#+\s', '', text)
# Entferne Bold/Italic (** oder *)
text = re.sub(r'\*\*|__|\*|_', '', text)
# Entferne Links [Text](Url) -> Text
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
# Entferne Wikilinks [[Link]] -> Link
text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
return text
@ -127,47 +134,52 @@ class GraphExplorerService:
scroll_filter = models.Filter(
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]
)
# Limit hoch genug setzen
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True)
# Sortieren nach 'ord' (Reihenfolge im Dokument)
chunks.sort(key=lambda x: x.payload.get('ord', 999))
full_text = [c.payload.get('text', '') for c in chunks if c.payload.get('text')]
full_text = []
for c in chunks:
# 'text' ist der reine Inhalt ohne Overlap
txt = c.payload.get('text', '')
if txt: full_text.append(txt)
return "\n\n".join(full_text)
except:
return "Fehler beim Laden des Volltexts."
def _fetch_previews_for_nodes(self, node_ids):
"""
Holt Batch-weise den ersten relevanten Textabschnitt für eine Liste von Nodes.
Optimiert die Ladezeit durch Reduzierung der API-Calls.
"""
if not node_ids:
return {}
"""Holt Batch-weise den ersten Chunk für eine Liste von Nodes."""
if not node_ids: return {}
previews = {}
try:
scroll_filter = models.Filter(
must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))]
)
# Genügend Chunks laden, um für jede ID eine Vorschau zu finden
scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))])
# Limit = Anzahl Nodes * 3 (Puffer)
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True)
for c in chunks:
nid = c.payload.get("note_id")
# Wir nehmen den ersten gefundenen Chunk
# Nur den ersten gefundenen Chunk pro Note nehmen
if nid and nid not in previews:
previews[nid] = c.payload.get("window") or c.payload.get("text") or ""
except Exception:
pass
except: pass
return previews
def _find_connected_edges(self, note_ids, note_title=None):
"""
Findet ein- und ausgehende Kanten für eine Liste von IDs.
Implementiert den Fix für Anker-Links [[Titel#Abschnitt]] durch Präfix-Suche in der target_id.
Findet eingehende und ausgehende Kanten.
WICHTIG: target_id enthält nur den Titel (ohne #Abschnitt).
target_section ist ein separates Feld für Abschnitt-Informationen.
"""
results = []
if not note_ids:
return results
# 1. AUSGEHENDE KANTEN (Outgoing)
# Suche über 'note_id' als Besitzer der Kante.
# 1. OUTGOING EDGES (Der "Owner"-Fix)
# Wir suchen Kanten, die im Feld 'note_id' (Owner) eine unserer Notizen haben.
# Das findet ALLE ausgehenden Kanten, egal ob sie an einem Chunk oder der Note hängen.
out_filter = models.Filter(must=[
models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)),
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
@ -175,71 +187,79 @@ class GraphExplorerService:
res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=2000, with_payload=True)
results.extend(res_out)
# 2. EINGEHENDE KANTEN (Incoming)
# Suche über target_id (Ziel der Kante).
# 2. INCOMING EDGES (Ziel = Chunk ID, Note ID oder Titel)
# WICHTIG: target_id enthält nur den Titel, target_section ist separat
# Sammele alle Chunk-IDs für exakte Treffer auf Segment-Ebene
# Chunk IDs der aktuellen Notes holen
c_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))])
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=c_filter, limit=1000, with_payload=False)
chunk_ids = [c.id for c in chunks]
should_conditions = []
shoulds = []
# Case A: Edge zeigt auf einen unserer Chunks
if chunk_ids:
should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids)))
should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids)))
# TITEL-BASIERTE SUCHE (Inkl. Anker-Fix)
titles_to_check = []
# Case B: Edge zeigt direkt auf unsere Note ID
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
# Case C: Edge zeigt auf unseren Titel
# WICHTIG: target_id enthält nur den Titel (z.B. "Meine Prinzipien 2025")
# target_section enthält die Abschnitt-Information (z.B. "P3 Disziplin"), wenn gesetzt
# Sammle alle relevanten Titel (inkl. Aliase)
titles_to_search = []
if note_title:
titles_to_check.append(note_title)
# Aliase laden für robuste Verlinkung
for nid in note_ids:
note = self._fetch_note_cached(nid)
if note:
aliases = note.get("aliases", [])
if isinstance(aliases, str): aliases = [aliases]
titles_to_check.extend([a for a in aliases if a not in titles_to_check])
titles_to_search.append(note_title)
# Exakte Titel-Matches hinzufügen
for t in titles_to_check:
should_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=t)))
# Lade auch Titel aus den Notes selbst (falls note_title nicht übergeben wurde)
for nid in note_ids:
note = self._fetch_note_cached(nid)
if note:
note_title_from_db = note.get("title")
if note_title_from_db and note_title_from_db not in titles_to_search:
titles_to_search.append(note_title_from_db)
# Aliase hinzufügen
aliases = note.get("aliases", [])
if isinstance(aliases, str):
aliases = [aliases]
for alias in aliases:
if alias and alias not in titles_to_search:
titles_to_search.append(alias)
if should_conditions:
# Für jeden Titel: Suche nach exaktem Match
# target_id enthält nur den Titel, daher reicht MatchValue
for title in titles_to_search:
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=title)))
if shoulds:
in_filter = models.Filter(
must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))],
should=should_conditions
should=shoulds
)
res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=2000, with_payload=True)
results.extend(res_in)
# FIX FÜR [[Titel#Abschnitt]]: Suche nach Fragmenten
if titles_to_check:
for t in titles_to_check:
anchor_filter = models.Filter(must=[
models.FieldCondition(key="target_id", match=models.MatchText(text=t)),
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
])
res_anchor, _ = self.client.scroll(self.edges_col, scroll_filter=anchor_filter, limit=1000, with_payload=True)
existing_ids = {r.id for r in results}
for edge in res_anchor:
tgt = edge.payload.get("target_id", "")
# Client-seitige Filterung: Nur Kanten nehmen, die mit Titel# beginnen
if edge.id not in existing_ids and (tgt == t or tgt.startswith(f"{t}#")):
results.append(edge)
return results
def _find_connected_edges_batch(self, note_ids):
"""Wrapper für die Suche in tieferen Ebenen des Graphen."""
first_note = self._fetch_note_cached(note_ids[0]) if note_ids else None
title = first_note.get("title") if first_note else None
return self._find_connected_edges(note_ids, note_title=title)
"""
Wrapper für Level 2 Suche.
Lädt Titel der ersten Note für Titel-basierte Suche.
"""
if not note_ids:
return []
first_note = self._fetch_note_cached(note_ids[0])
note_title = first_note.get("title") if first_note else None
return self._find_connected_edges(note_ids, note_title=note_title)
def _process_edge(self, record, nodes_dict, unique_edges, current_depth):
"""
Verarbeitet eine rohe Kante, löst Quell- und Ziel-Referenzen auf
und fügt sie den Dictionaries für den Graphen hinzu.
Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries hinzu.
WICHTIG: Beide Richtungen werden unterstützt:
- Ausgehende Kanten: source_id gehört zu unserer Note (via note_id Owner)
- Eingehende Kanten: target_id zeigt auf unsere Note (via target_id Match)
"""
if not record or not record.payload:
return None, None
@ -250,10 +270,13 @@ class GraphExplorerService:
kind = payload.get("kind")
provenance = payload.get("provenance", "explicit")
# Prüfe, ob beide Referenzen vorhanden sind
if not src_ref or not tgt_ref:
return None, None
# IDs zu Notes auflösen (Hier greift der Fragment-Fix)
# IDs zu Notes auflösen
# WICHTIG: source_id kann Chunk-ID (note_id#c01), Note-ID oder Titel sein
# WICHTIG: target_id kann Chunk-ID, Note-ID oder Titel sein (ohne #Abschnitt)
src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref)
@ -261,118 +284,159 @@ class GraphExplorerService:
src_id = src_note.get('note_id')
tgt_id = tgt_note.get('note_id')
if src_id and tgt_id and src_id != tgt_id:
# Knoten zum Set hinzufügen
# Prüfe, ob beide IDs vorhanden sind
if not src_id or not tgt_id:
return None, None
if src_id != tgt_id:
# Nodes hinzufügen
self._add_node_to_dict(nodes_dict, src_note, level=current_depth)
self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth)
# Kante registrieren (Deduplizierung)
# Kante hinzufügen (mit Deduplizierung)
key = (src_id, tgt_id)
existing = unique_edges.get(key)
is_current_explicit = (provenance in ["explicit", "rule"])
should_update = True
# Bevorzuge explizite Kanten vor Smart Kanten
is_current_explicit = (provenance in ["explicit", "rule"])
if existing:
is_existing_explicit = (existing.get('provenance', '') in ["explicit", "rule"])
if is_existing_explicit and not is_current_explicit:
should_update = False
if should_update:
unique_edges[key] = {
"source": src_id,
"target": tgt_id,
"kind": kind,
"provenance": provenance
}
unique_edges[key] = {"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance}
return src_id, tgt_id
return None, None
def _fetch_note_cached(self, note_id):
"""Lädt eine Note aus Qdrant mit Session-Caching."""
if not note_id:
return None
if note_id in self._note_cache:
return self._note_cache[note_id]
try:
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[note_id] = payload
return payload
except Exception:
pass
if note_id in self._note_cache: return self._note_cache[note_id]
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]),
limit=1, with_payload=True
)
if res:
self._note_cache[note_id] = res[0].payload
return res[0].payload
return None
def _resolve_note_from_ref(self, ref_str):
"""
Löst eine Referenz (ID, Chunk-ID oder Wikilink mit Anker) auf eine Note auf.
Bereinigt Anker (#) vor der Suche.
Löst eine Referenz zu einer Note Payload auf.
WICHTIG: Wenn ref_str ein Titel#Abschnitt Format hat, wird nur der Titel-Teil verwendet.
Unterstützt:
- Note-ID: "20250101-meine-note"
- Chunk-ID: "20250101-meine-note#c01"
- Titel: "Meine Prinzipien 2025"
- Titel#Abschnitt: "Meine Prinzipien 2025#P3 Disziplin" (trennt Abschnitt ab, sucht nur nach Titel)
"""
if not ref_str:
return None
if ref_str in self._ref_resolution_cache:
return self._ref_resolution_cache[ref_str]
# Fragment-Behandlung: Trenne Anker ab
base_ref = ref_str.split("#")[0].strip()
# 1. Versuch: Direkte Note-ID Suche
note = self._fetch_note_cached(base_ref)
if note:
self._ref_resolution_cache[ref_str] = note
return note
# 2. Versuch: Titel-Suche (Keyword-Match)
try:
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchValue(value=base_ref))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._ref_resolution_cache[ref_str] = payload
return payload
except Exception:
pass
# 3. Versuch: Auflösung über Chunks
# Fall A: Enthält # (kann Chunk-ID oder Titel#Abschnitt sein)
if "#" in ref_str:
try:
res_chunk = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True)
if res_chunk and res_chunk[0].payload:
note_id = res_chunk[0].payload.get("note_id")
note = self._fetch_note_cached(note_id)
if note:
self._ref_resolution_cache[ref_str] = note
return note
except Exception:
# Versuch 1: Chunk ID direkt (Format: note_id#c01)
res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True)
if res and res[0].payload:
note_id = res[0].payload.get("note_id")
if note_id:
return self._fetch_note_cached(note_id)
except:
pass
# Versuch 2: NoteID#Section (Hash abtrennen und als Note-ID versuchen)
# z.B. "20250101-meine-note#Abschnitt" -> "20250101-meine-note"
possible_note_id = ref_str.split("#")[0].strip()
note = self._fetch_note_cached(possible_note_id)
if note:
return note
# Versuch 3: Titel#Abschnitt (Hash abtrennen und als Titel suchen)
# z.B. "Meine Prinzipien 2025#P3 Disziplin" -> "Meine Prinzipien 2025"
# WICHTIG: target_id enthält nur den Titel, daher suchen wir nur nach dem Titel-Teil
possible_title = ref_str.split("#")[0].strip()
if possible_title:
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchValue(value=possible_title))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[payload['note_id']] = payload
return payload
# Fallback: Text-Suche für Fuzzy-Matching
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchText(text=possible_title))
]),
limit=10, with_payload=True
)
if res:
# Nimm das erste Ergebnis, das exakt oder beginnend mit possible_title übereinstimmt
for r in res:
if r.payload:
note_title = r.payload.get("title", "")
if note_title == possible_title or note_title.startswith(possible_title):
payload = r.payload
self._note_cache[payload['note_id']] = payload
return payload
# Fall B: Note ID direkt
note = self._fetch_note_cached(ref_str)
if note:
return note
# Fall C: Titel (exakte Übereinstimmung)
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[payload['note_id']] = payload
return payload
# Fall D: Titel (Text-Suche für Fuzzy-Matching)
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[
models.FieldCondition(key="title", match=models.MatchText(text=ref_str))
]),
limit=1, with_payload=True
)
if res and res[0].payload:
payload = res[0].payload
self._note_cache[payload['note_id']] = payload
return payload
return None
def _add_node_to_dict(self, node_dict, note_payload, level=1):
"""Erstellt ein Node-Objekt für streamlit-agraph mit Styling."""
nid = note_payload.get("note_id")
if not nid or nid in node_dict:
return
if not nid or nid in node_dict: return
ntype = note_payload.get("type", "default")
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS.get("default", "#8395a7"))
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
# Basis-Tooltip (wird später erweitert)
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
size = 45 if level == 0 else (25 if level == 1 else 15)
if level == 0: size = 45
elif level == 1: size = 25
else: size = 15
node_dict[nid] = Node(
id=nid,
label=note_payload.get('title', nid),

View File

@ -1,10 +1,10 @@
"""
FILE: app/models/dto.py
DESCRIPTION: Pydantic-Modelle (DTOs) für Request/Response Bodies. Definiert das API-Schema.
VERSION: 0.6.6 (WP-22 Debug & Stability Update)
VERSION: 0.6.7 (WP-Fix: Target Section Support)
STATUS: Active
DEPENDENCIES: pydantic, typing, uuid
LAST_ANALYSIS: 2025-12-18
LAST_ANALYSIS: 2025-12-29
"""
from __future__ import annotations
@ -43,6 +43,7 @@ class EdgeDTO(BaseModel):
direction: Literal["out", "in", "undirected"] = "out"
provenance: Optional[Literal["explicit", "rule", "smart", "structure"]] = "explicit"
confidence: float = 1.0
target_section: Optional[str] = None # Neu: Speichert den Anker (z.B. #Abschnitt)
# --- Request Models ---

View File

@ -2,7 +2,7 @@
doc_type: glossary
audience: all
status: active
version: 2.8.1
version: 2.9.1
context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-Cloud Resilienz, WP-14 Modularisierung, WP-15b Two-Pass Ingestion und Mistral-safe Parsing."
---
@ -14,7 +14,7 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C
* **Note:** Repräsentiert eine Markdown-Datei. Die fachliche Haupteinheit. Verfügt über einen **Status** (stable, draft, system), der das Scoring beeinflusst.
* **Chunk:** Ein Textabschnitt einer Note. Die technische Sucheinheit (Vektor).
* **Edge:** Eine gerichtete Verbindung zwischen zwei Knoten. Wird in WP-22 durch die Registry validiert.
* **Edge:** Eine gerichtete Verbindung zwischen zwei Knoten. Wird in WP-22 durch die Registry validiert. Seit v2.9.1 unterstützt Edges **Section-basierte Links** (`target_section`), sodass mehrere Kanten zwischen denselben Knoten existieren können, wenn sie auf verschiedene Abschnitte zeigen.
* **Vault:** Der lokale Ordner mit den Markdown-Dateien (Source of Truth).
* **Frontmatter:** Der YAML-Header am Anfang einer Notiz (enthält `id`, `type`, `title`, `status`).
@ -48,3 +48,6 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C
* **Pass 1 (Pre-Scan):** Schnelles Scannen aller Dateien zur Befüllung des LocalBatchCache.
* **Pass 2 (Semantic Processing):** Tiefenverarbeitung (Chunking, Embedding, Validierung) nur für geänderte Dateien.
* **Circular Import Registry (WP-14):** Entkopplung von Kern-Logik (wie Textbereinigung) in eine neutrale `registry.py`, um Abhängigkeitsschleifen zwischen Diensten und Ingestion-Utilities zu verhindern.
* **Deep-Link / Section-basierter Link:** Ein Link wie `[[Note#Section]]`, der auf einen spezifischen Abschnitt innerhalb einer Note verweist. Seit v2.9.1 wird dieser in `target_id="Note"` und `target_section="Section"` aufgeteilt, um "Phantom-Knoten" zu vermeiden und Multigraph-Support zu ermöglichen.
* **Atomic Section Logic (v3.9.9):** Chunking-Verfahren, das Sektions-Überschriften und deren Inhalte atomar in Chunks hält (Pack-and-Carry-Over). Verhindert, dass Überschriften über Chunk-Grenzen hinweg getrennt werden.
* **Registry-First Profiling (v2.13.12):** Hierarchische Auflösung des Chunking-Profils: Frontmatter > types.yaml Typ-Config > Global Defaults. Stellt sicher, dass Note-Typen automatisch das korrekte Profil erhalten.

View File

@ -3,7 +3,7 @@ doc_type: user_manual
audience: user, author
scope: vault, markdown, schema
status: active
version: 2.8.0
version: 2.9.1
context: "Regelwerk für das Erstellen von Notizen im Vault. Die 'Source of Truth' für Autoren."
---
@ -208,6 +208,12 @@ Dies ist die **mächtigste** Methode. Du sagst dem System explizit, **wie** Ding
> "Daher [[rel:depends_on Qdrant]]."
> "Dieses Konzept ist [[rel:similar_to Pinecone]]."
**Deep-Links zu Abschnitten (v2.9.1):**
Du kannst auch auf spezifische Abschnitte innerhalb einer Note verlinken:
> "Siehe [[rel:based_on Mein Leitbild#P3 Disziplin]]."
Das System trennt automatisch den Note-Namen (`Mein Leitbild`) vom Abschnitts-Namen (`P3 Disziplin`), sodass mehrere Links zur gleichen Note möglich sind, wenn sie auf verschiedene Abschnitte zeigen.
**Gültige Relationen:**
* `depends_on`: Hängt ab von / Benötigt.
* `blocks`: Blockiert oder gefährdet (z.B. Risiko -> Projekt).
@ -226,6 +232,12 @@ Für Zusammenfassungen am Ende einer Notiz, oder eines Absatzes:
> [[AI Agents]]
```
**Multi-Line Support (v2.9.1):**
Callout-Blocks mit mehreren Zeilen werden korrekt verarbeitet. Das System erkennt automatisch, wenn mehrere Links im gleichen Callout-Block stehen, und erstellt für jeden Link eine separate Kante (auch bei Deep-Links zu verschiedenen Sections).
**Format-agnostische De-Duplizierung:**
Wenn Kanten bereits via `[!edge]` Callout vorhanden sind, werden sie nicht mehrfach injiziert. Das System erkennt vorhandene Kanten unabhängig vom Format (Inline, Callout, Wikilink).
### 4.3 Implizite Bidirektionalität (Edger-Logik) [NEU] [PRÜFEN!]
In Mindnet musst du Kanten **nicht** manuell in beide Richtungen pflegen. Der **Edger** übernimmt die Paarbildung automatisch im Hintergrund.

View File

@ -3,7 +3,7 @@ doc_type: concept
audience: architect, product_owner
scope: graph, logic, provenance
status: active
version: 2.7.0
version: 2.9.1
context: "Fachliche Beschreibung des Wissensgraphen: Knoten, Kanten, Provenance, Matrix-Logik und WP-22 Scoring-Prinzipien."
---
@ -118,8 +118,30 @@ Der Intent-Router injiziert spezifische Multiplikatoren für kanonische Typen:
---
## 6. Idempotenz & Konsistenz
## 6. Section-basierte Links & Multigraph-Support
Seit v2.9.1 unterstützt Mindnet **Deep-Links** zu spezifischen Abschnitten innerhalb einer Note.
### 6.1 Link-Parsing
Links wie `[[Note#Section]]` werden in zwei Komponenten aufgeteilt:
* **`target_id`:** Enthält nur den Note-Namen (z.B. "Mein Leitbild")
* **`target_section`:** Enthält den Abschnitts-Namen (z.B. "P3 Disziplin")
**Vorteil:** Verhindert "Phantom-Knoten", die durch das Einbeziehen des Anchors in die `target_id` entstanden wären.
### 6.2 Multigraph-Support
Die Edge-ID enthält nun einen `variant`-Parameter (die Section), sodass mehrere Kanten zwischen denselben Knoten existieren können, wenn sie auf verschiedene Sections zeigen:
* `[[Note#Section1]]` → Edge-ID: `src->tgt:kind@Section1`
* `[[Note#Section2]]` → Edge-ID: `src->tgt:kind@Section2`
### 6.3 Semantische Deduplizierung
Die Deduplizierung basiert auf dem `src->tgt:kind@sec` Key, um sicherzustellen, dass identische Links (gleiche Quelle, Ziel, Typ und Section) nicht mehrfach erstellt werden.
---
## 7. Idempotenz & Konsistenz
Das System garantiert fachliche Konsistenz auch bei mehrfachen Importen.
* **Stabile IDs:** Deterministische IDs verhindern Duplikate bei Re-Imports.
* **Deduplizierung:** Kanten werden anhand ihrer Identität erkannt. Die "stärkere" Provenance gewinnt.
* **Deduplizierung:** Kanten werden anhand ihrer Identität (inkl. Section) erkannt. Die "stärkere" Provenance gewinnt.
* **Format-agnostische Erkennung:** Kanten werden unabhängig vom Format (Inline, Callout, Wikilink) erkannt, um Dopplungen zu vermeiden.

View File

@ -144,8 +144,11 @@ Lädt den Subgraphen um eine Note herum.
"kind": "depends_on",
"source": "uuid",
"target": "uuid",
"target_section": "P3 Disziplin", // Optional: Abschnitts-Name bei Deep-Links
"weight": 1.4,
"direction": "out"
"direction": "out",
"provenance": "explicit",
"confidence": 1.0
}
],
"stats": {

View File

@ -3,7 +3,7 @@ doc_type: technical_reference
audience: developer, architect
scope: database, qdrant, schema
status: active
version: 2.8.0
version: 2.9.1
context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen. Berücksichtigt WP-14 Modularisierung und WP-15b Multi-Hashes."
---
@ -96,15 +96,19 @@ Es müssen Payload-Indizes für folgende Felder existieren:
## 4. Edge Payload (`mindnet_edges`)
Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Tracking.
Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Tracking. Seit v2.9.1 unterstützt das System **Section-basierte Links** (`[[Note#Section]]`), die in `target_id` und `target_section` aufgeteilt werden.
**JSON-Schema:**
```json
{
"edge_id": "string (keyword)", // Deterministischer Hash aus (src, dst, kind)
"edge_id": "string (keyword)", // Deterministischer Hash aus (src, dst, kind, variant)
// variant = target_section (erlaubt Multigraph für Sections)
"source_id": "string (keyword)", // Chunk-ID (Start)
"target_id": "string (keyword)", // Chunk-ID oder Note-Titel (bei Unresolved)
// WICHTIG: Enthält NUR den Note-Namen, KEINE Section-Info
"target_section": "string (keyword)", // Optional: Abschnitts-Name (z.B. "P3 Disziplin")
// Wird aus [[Note#Section]] extrahiert
"kind": "string (keyword)", // Beziehungsart (z.B. 'depends_on')
"scope": "string (keyword)", // Immer 'chunk' (Legacy-Support: 'note')
"note_id": "string (keyword)", // Owner Note ID (Ursprung der Kante)
@ -116,10 +120,16 @@ Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Track
}
```
**Section-Support:**
* Links wie `[[Note#Section]]` werden in `target_id="Note"` und `target_section="Section"` aufgeteilt.
* Die Edge-ID enthält die Section als `variant`, sodass mehrere Kanten zwischen denselben Knoten existieren können, wenn sie auf verschiedene Sections zeigen.
* Semantische Deduplizierung basiert auf `src->tgt:kind@sec` Key, um "Phantom-Knoten" zu vermeiden.
**Erforderliche Indizes:**
Es müssen Payload-Indizes für folgende Felder existieren:
* `source_id`
* `target_id`
* `target_section` (neu: Keyword-Index für Section-basierte Filterung)
* `kind`
* `scope`
* `note_id`

View File

@ -3,7 +3,7 @@ doc_type: technical_reference
audience: developer, frontend_architect
scope: architecture, graph_viz, state_management
status: active
version: 2.7.0
version: 2.9.1
context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors."
---

View File

@ -3,7 +3,7 @@ doc_type: technical_reference
audience: developer, devops
scope: backend, ingestion, smart_edges, edge_registry, modularization
status: active
version: 2.9.0
version: 2.13.12
context: "Detaillierte technische Beschreibung der Import-Pipeline, Two-Pass-Workflow (WP-15b) und modularer Datenbank-Architektur (WP-14). Integriert Mistral-safe Parsing und Deep Fallback."
---
@ -31,9 +31,10 @@ Der Prozess ist **asynchron**, **idempotent** und wird nun in zwei logische Durc
4. **Edge Registry Initialisierung (WP-22):**
* Laden der Singleton-Instanz der `EdgeRegistry`.
* Validierung der Vokabular-Datei unter `MINDNET_VOCAB_PATH`.
5. **Config Resolution (WP-14):**
5. **Config Resolution (WP-14 / v2.13.12):**
* Bestimmung von `chunking_profile` und `retriever_weight` via zentraler `TypeRegistry`.
* **Priorität:** 1. Frontmatter (Override) -> 2. `types.yaml` (Type) -> 3. Global Default.
* **Registry-First Profiling:** Automatische Anwendung der korrekten Profile basierend auf dem Note-Typ (z.B. `value` nutzt automatisch `structured_smart_edges_strict`).
6. **LocalBatchCache & Summary Generation (WP-15b):**
* Erstellung von Kurz-Zusammenfassungen für jede Note.
* Speicherung im `batch_cache` als Referenzrahmen für die spätere Kantenvalidierung.
@ -126,19 +127,44 @@ Das Chunking ist profilbasiert und bezieht seine Konfiguration dynamisch aus der
| `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte (Projekte). |
| `structured_smart_edges` | `by_heading` | `strict: false` | Strukturierte Texte. |
### 3.2 Die `by_heading` Logik (v2.9 Hybrid)
### 3.2 Die `by_heading` Logik (v3.9.9 Atomic Section Logic)
Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Sie unterstützt ein "Safety Net" gegen zu große Chunks.
Die Strategie `by_heading` implementiert seit v3.9.9 das **"Pack-and-Carry-Over"** Verfahren (Regel 1-3), um Sektions-Überschriften und deren Inhalte atomar in Chunks zu halten.
* **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split).
* **Modus "Strict" (`strict_heading_split: true`):**
* Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk.
* *Merge-Check:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt.
* *Safety Net:* Wird ein Abschnitt zu lang (> `max` Token), wird auch ohne Überschrift getrennt.
* **Modus "Soft" (`strict_heading_split: false`):**
* **Hierarchie-Check:** Überschriften *oberhalb* des Split-Levels erzwingen **immer** einen Split.
* **Füll-Logik:** Überschriften *auf* dem Split-Level lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat.
* *Safety Net:* Auch hier greift das `max` Token Limit.
**Kernprinzipien:**
* **Atomic Section Logic:** Überschriften und deren Inhalte werden als atomare Einheiten behandelt und nicht über Chunk-Grenzen hinweg getrennt.
* **H1-Context Preservation:** Der Dokumenttitel (H1) wird zuverlässig als Breadcrumb in das Embedding-Fenster (`window`) aller Chunks injiziert.
* **Signature Alignment:** Parameter-Synchronisierung zwischen Orchestrator und Strategien (`context_prefix` statt `doc_title`).
**Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split).
**Modus "Strict" (`strict_heading_split: true`):**
* Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk.
* *Merge-Check:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt.
* *Safety Net:* Wird ein Abschnitt zu lang (> `max` Token), wird auch ohne Überschrift getrennt.
**Modus "Soft" (`strict_heading_split: false`):**
* **Hierarchie-Check:** Überschriften *oberhalb* des Split-Levels erzwingen **immer** einen Split.
* **Füll-Logik:** Überschriften *auf* dem Split-Level lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat.
* **Pack-and-Carry-Over:** Wenn ein Abschnitt zu groß ist, wird er intelligent zerlegt, wobei der Rest (mit Überschrift) zurück in die Queue gelegt wird.
* *Safety Net:* Auch hier greift das `max` Token Limit.
### 3.3 Registry-First Profiling (v2.13.12)
Seit v2.13.12 nutzt der `IngestionService` die korrekte Hierarchie zur Ermittlung des Chunking-Profils:
**Priorität:**
1. **Frontmatter** (Override) - Explizite `chunking_profile` Angabe
2. **`types.yaml` Typ-Config** - Profil basierend auf `type`
3. **Global Defaults** - Fallback auf `sliding_standard`
**Wichtig:** Ein Hard-Fallback auf `sliding_standard` erfolgt nur noch, wenn keine Konfiguration existiert. Dies stellt sicher, dass Note-Typen wie `value` automatisch das korrekte Profil (z.B. `structured_smart_edges_strict`) erhalten.
### 3.4 Deterministic Hashing (v2.13.12)
Der `full`-Hash inkludiert nun alle strategischen Parameter (z.B. `split_level`, `strict_heading_split`), sodass Konfigurationsänderungen im Frontmatter zwingend einen Re-Import auslösen.
**Impact:** Änderungen an Chunking-Parametern werden zuverlässig erkannt, auch wenn der Text unverändert bleibt.
---

View File

@ -280,3 +280,10 @@ python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes
# 2. Neu importieren (Force Hash recalculation)
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
**Wichtig (v2.9.1 Migration):**
Nach dem Update auf v2.9.1 (Section-basierte Links, Multigraph-Support) ist ein vollständiger Re-Import erforderlich, um "Phantom-Knoten" zu beheben und die neue Edge-Struktur zu konsolidieren:
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
Dies stellt sicher, dass alle bestehenden Links korrekt in `target_id` und `target_section` aufgeteilt werden.

View File

@ -0,0 +1,99 @@
# Branch Merge Commit Message: WP4d
```
feat: Section-basierte Links, Atomic Section Chunking & Registry-First Profiling (v2.9.1)
## Graph Topology & Edge Management
### Section-basierte Links (Multigraph-Support)
- Split `[[Note#Section]]` Links in `target_id="Note"` und `target_section="Section"`
- Edge-ID enthält nun `variant` (Section), ermöglicht mehrere Kanten zwischen denselben Knoten
- Semantische Deduplizierung basiert auf `src->tgt:kind@sec` Key
- Behebt "Phantom-Knoten" durch korrekte Trennung von Note-Name und Abschnitt
**Geänderte Dateien:**
- `app/core/graph/graph_utils.py`: `parse_link_target()` für Section-Extraktion
- `app/core/graph/graph_derive_edges.py`: `target_section` in Edge-Payload
- `app/core/database/qdrant.py`: Keyword-Index für `target_section`
- `app/core/database/qdrant_points.py`: Explizites Durchreichen von `target_section`
- `app/models/dto.py`: `EdgeDTO` mit `target_section` Feld
### Extraction & Parsing Verbesserungen
- Multi-line Callout-Blocks korrekt verarbeitet (stop-check logic)
- Robuster Fallback für "headless" Blocks (split chunks)
- Liberalisierte Regex für Umlaute und Sonderzeichen in Targets
**Geänderte Dateien:**
- `app/core/graph/graph_extractors.py`: Multi-line Callout-Parser, erweiterte Regex
## Chunking & Ingestion (v3.9.9 / v2.13.12)
### Atomic Section Logic (v3.9.9)
- Vollständige Implementierung des "Pack-and-Carry-Over" Verfahrens (Regel 1-3)
- Sektions-Überschriften und Inhalte bleiben atomar in Chunks
- H1-Context Preservation: Dokumenttitel als Breadcrumb in Embedding-Fenster
- Signature Alignment: Parameter-Synchronisierung (`context_prefix` statt `doc_title`)
**Geänderte Dateien:**
- `app/core/chunking/chunking_strategies.py`: Atomic Section Logic implementiert
### Format-agnostische De-Duplizierung
- Prüfung auf vorhandene Kanten basiert auf Ziel (`target`), nicht String-Match
- Verhindert Dopplung von Kanten, die bereits via `[!edge]` Callout vorhanden sind
- Global Pool Integration für unzugeordnete Kanten
**Geänderte Dateien:**
- `app/core/chunking/chunking_propagation.py`: Ziel-basierte Prüfung
### Registry-First Profiling (v2.13.12)
- Korrekte Hierarchie: Frontmatter > types.yaml Typ-Config > Global Defaults
- Hard-Fallback auf `sliding_standard` nur wenn keine Konfiguration existiert
- Automatische Anwendung korrekter Profile basierend auf Note-Typ
### Deterministic Hashing
- `full`-Hash inkludiert strategische Parameter (`split_level`, `strict_heading_split`)
- Konfigurationsänderungen im Frontmatter lösen zwingend Re-Import aus
**Geänderte Dateien:**
- `app/core/ingestion/ingestion_processor.py`: Registry-First Profiling, Deterministic Hashing
## Impact & Breaking Changes
### Migration erforderlich
**WICHTIG:** Vollständiger Re-Import erforderlich für bestehende Vaults:
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
**Grund:**
- Behebt "Phantom-Knoten" durch korrekte Aufteilung von `[[Note#Section]]` Links
- Konsolidiert Edge-Struktur mit `target_section` Feld
- Aktualisiert Chunking basierend auf neuen Strategien
### Fixes
- ✅ Resolves: Mehrere Links zur gleichen Note in einem Callout-Block wurden zu einer Kante gemergt
- ✅ Resolves: "Phantom-Knoten" durch Einbeziehung des Anchors in `target_id`
- ✅ Resolves: Redundante `[[rel:...]]` Links in Chunks
- ✅ Resolves: Inkonsistente Metadaten in Qdrant durch Registry-First Profiling
## Dokumentation
Alle relevanten Dokumente aktualisiert:
- `03_tech_data_model.md`: Edge Payload Schema mit `target_section`
- `02_concept_graph_logic.md`: Section-basierte Links & Multigraph-Support
- `03_tech_ingestion_pipeline.md`: Chunking-Strategien, Registry-First Profiling
- `03_tech_api_reference.md`: EdgeDTO mit `target_section`
- `01_knowledge_design.md`: Deep-Links dokumentiert
- `00_glossary.md`: Neue Begriffe ergänzt
- `04_admin_operations.md`: Migration-Hinweis
## Versionen
- Graph Topology: v2.9.1
- Chunking Strategies: v3.9.9
- Ingestion Processor: v2.13.12
- API DTO: v0.6.7
Closes #[issue-number]
```

View File

@ -0,0 +1,236 @@
# Release Notes: Mindnet v2.9.1 (WP4d)
**Release Date:** 2025-01-XX
**Type:** Feature Release mit Breaking Changes
**Branch:** WP4d
---
## 🎯 Übersicht
Diese Version führt **Section-basierte Links** ein, verbessert das Chunking durch **Atomic Section Logic** und implementiert **Registry-First Profiling** für konsistentere Konfigurationsauflösung. Die Änderungen erfordern einen **vollständigen Re-Import** bestehender Vaults.
---
## ✨ Neue Features
### Section-basierte Links (Deep-Links)
Mindnet unterstützt nun **Deep-Links** zu spezifischen Abschnitten innerhalb einer Note:
```markdown
[[rel:based_on Mein Leitbild#P3 Disziplin]]
```
**Vorteile:**
- Mehrere Links zur gleichen Note möglich (verschiedene Sections)
- Präzise Kontext-Ladung (nur relevanter Abschnitt)
- Keine "Phantom-Knoten" mehr durch korrekte Trennung von Note-Name und Abschnitt
**Technische Details:**
- Links werden in `target_id="Note"` und `target_section="Section"` aufgeteilt
- Edge-ID enthält `variant` (Section) für Multigraph-Support
- Semantische Deduplizierung basiert auf `src->tgt:kind@sec` Key
### Atomic Section Logic (Chunking v3.9.9)
Das Chunking hält nun Sektions-Überschriften und deren Inhalte **atomar** zusammen:
**"Pack-and-Carry-Over" Verfahren:**
- Regel 1 & 2: Sektionen werden zusammengepackt, wenn sie in den Token-Limit passen
- Regel 3: Zu große Sektionen werden intelligent zerlegt, Rest wird zurück in Queue gelegt
- H1-Context Preservation: Dokumenttitel wird als Breadcrumb in alle Chunks injiziert
**Vorteile:**
- Keine getrennten Überschriften mehr
- Bessere semantische Kohärenz in Chunks
- Verbesserte Retrieval-Qualität durch vollständigen Kontext
### Registry-First Profiling (v2.13.12)
Die Konfigurationsauflösung folgt nun einer klaren Hierarchie:
1. **Frontmatter** (höchste Priorität)
2. **types.yaml Typ-Config**
3. **Global Defaults**
**Impact:**
- Note-Typen wie `value` erhalten automatisch das korrekte Profil (`structured_smart_edges_strict`)
- Keine manuellen Overrides mehr nötig für Standard-Typen
- Konsistente Metadaten in Qdrant
---
## 🔧 Verbesserungen
### Extraction & Parsing
- **Multi-line Callout-Blocks:** Korrekte Verarbeitung von mehrzeiligen `[!edge]` Callouts
- **Robuste Fallbacks:** "Headless" Blocks werden korrekt behandelt
- **Liberalisierte Regex:** Unterstützung für Umlaute und Sonderzeichen in Link-Targets
### Format-agnostische De-Duplizierung
- Kanten werden unabhängig vom Format (Inline, Callout, Wikilink) erkannt
- Verhindert Dopplungen, wenn Kanten bereits via `[!edge]` Callout vorhanden sind
- Ziel-basierte Prüfung statt String-Match
### Deterministic Hashing
- `full`-Hash inkludiert strategische Parameter (`split_level`, `strict_heading_split`)
- Konfigurationsänderungen im Frontmatter lösen zwingend Re-Import aus
- Zuverlässigere Change Detection
---
## 🐛 Bugfixes
- ✅ **Behoben:** Mehrere Links zur gleichen Note in einem Callout-Block wurden zu einer Kante gemergt
- ✅ **Behoben:** "Phantom-Knoten" durch Einbeziehung des Anchors in `target_id`
- ✅ **Behoben:** Redundante `[[rel:...]]` Links in Chunks
- ✅ **Behoben:** Inkonsistente Metadaten in Qdrant durch fehlerhafte Profil-Auflösung
- ✅ **Behoben:** `TypeError` durch Parameter-Mismatch zwischen Orchestrator und Strategien
---
## ⚠️ Breaking Changes & Migration
### Migration erforderlich
**WICHTIG:** Nach dem Update auf v2.9.1 ist ein **vollständiger Re-Import** erforderlich:
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
**Warum?**
- Behebt "Phantom-Knoten" durch korrekte Aufteilung von `[[Note#Section]]` Links
- Konsolidiert Edge-Struktur mit neuem `target_section` Feld
- Aktualisiert Chunking basierend auf Atomic Section Logic
**Was passiert beim Re-Import?**
- Alle bestehenden Links werden neu geparst und in `target_id` + `target_section` aufgeteilt
- Chunks werden mit neuer Atomic Section Logic neu generiert
- Edge-Struktur wird konsolidiert (Multigraph-Support)
**Dauer:** Abhängig von Vault-Größe (typischerweise 5-30 Minuten)
---
## 📚 API-Änderungen
### EdgeDTO erweitert
```python
class EdgeDTO(BaseModel):
# ... bestehende Felder ...
target_section: Optional[str] = None # Neu: Abschnitts-Name
```
**Impact für API-Consumer:**
- Graph-Endpunkte (`/graph/{note_id}`) enthalten nun `target_section` in Edge-Objekten
- Frontend kann Section-Informationen für präzisere Visualisierung nutzen
---
## 📖 Dokumentation
Alle relevanten Dokumente wurden aktualisiert:
- ✅ `03_tech_data_model.md`: Edge Payload Schema mit `target_section`
- ✅ `02_concept_graph_logic.md`: Section-basierte Links & Multigraph-Support
- ✅ `03_tech_ingestion_pipeline.md`: Chunking-Strategien, Registry-First Profiling
- ✅ `03_tech_api_reference.md`: EdgeDTO mit `target_section`
- ✅ `01_knowledge_design.md`: Deep-Links dokumentiert
- ✅ `00_glossary.md`: Neue Begriffe ergänzt
- ✅ `04_admin_operations.md`: Migration-Hinweis
---
## 🔄 Technische Details
### Geänderte Module
**Graph Topology:**
- `app/core/graph/graph_utils.py`: `parse_link_target()` für Section-Extraktion
- `app/core/graph/graph_derive_edges.py`: `target_section` in Edge-Payload
- `app/core/graph/graph_extractors.py`: Multi-line Callout-Parser
**Chunking:**
- `app/core/chunking/chunking_strategies.py`: Atomic Section Logic (v3.9.9)
- `app/core/chunking/chunking_propagation.py`: Format-agnostische De-Duplizierung
**Ingestion:**
- `app/core/ingestion/ingestion_processor.py`: Registry-First Profiling (v2.13.12), Deterministic Hashing
**Database:**
- `app/core/database/qdrant.py`: Keyword-Index für `target_section`
- `app/core/database/qdrant_points.py`: Explizites Durchreichen von `target_section`
**API:**
- `app/models/dto.py`: `EdgeDTO` mit `target_section` Feld (v0.6.7)
### Versionsnummern
- Graph Topology: **v2.9.1**
- Chunking Strategies: **v3.9.9**
- Ingestion Processor: **v2.13.12**
- API DTO: **v0.6.7**
---
## 🚀 Upgrade-Pfad
### Für Administratoren
1. **Backup erstellen:**
```bash
docker stop qdrant
tar -czf qdrant_backup_$(date +%F).tar.gz ./qdrant_data
docker start qdrant
```
2. **Code aktualisieren:**
```bash
git pull origin main
source .venv/bin/activate
pip install -r requirements.txt
```
3. **Re-Import durchführen:**
```bash
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
```
4. **Services neu starten:**
```bash
sudo systemctl restart mindnet-prod
sudo systemctl restart mindnet-ui-prod
```
### Für Entwickler
- Keine Code-Änderungen erforderlich, wenn nur API genutzt wird
- Frontend kann `target_section` Feld in Edge-Objekten nutzen (optional)
---
## 📝 Bekannte Einschränkungen
- **Migration-Dauer:** Große Vaults (>10.000 Notizen) können 30+ Minuten benötigen
- **Temporärer Speicher:** Während des Re-Imports kann Qdrant-Speicher temporär ansteigen
---
## 🙏 Danksagungen
Diese Version wurde durch umfangreiche Code-Analyse und Dokumentationsprüfung ermöglicht. Besonderer Fokus lag auf:
- Konsistenz zwischen Code und Dokumentation
- Vollständige Abdeckung aller Rollen (Entwickler, Administratoren, Anwender, Tester, Deployment)
- Klare Migration-Pfade
---
**Vollständige Changelog:** Siehe Git-Commits für detaillierte Änderungen
**Support:** Bei Fragen zur Migration siehe [Admin Operations Guide](../04_Operations/04_admin_operations.md)