WP15b #15

Merged
Lars merged 23 commits from WP15b into main 2025-12-27 22:15:27 +01:00
66 changed files with 3471 additions and 3598 deletions

View File

@ -1,176 +0,0 @@
"""
FILE: app/core/chunk_payload.py
DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'.
FEATURES:
- Inkludiert Nachbarschafts-IDs (prev/next) und Titel.
- FIX 3: Robuste Erkennung des Inputs (Frontmatter-Dict vs. Note-Objekt), damit Overrides ankommen.
VERSION: 2.3.0
STATUS: Active
DEPENDENCIES: yaml, os
EXTERNAL_CONFIG: config/types.yaml
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import os, yaml
def _env(n: str, d: Optional[str]=None) -> str:
v = os.getenv(n)
return v if v is not None else (d or "")
def _load_types() -> dict:
p = _env("MINDNET_TYPES_FILE", "./config/types.yaml")
try:
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
def _get_types_map(reg: dict) -> dict:
if isinstance(reg, dict) and isinstance(reg.get("types"), dict):
return reg["types"]
return reg if isinstance(reg, dict) else {}
def _get_defaults(reg: dict) -> dict:
if isinstance(reg, dict) and isinstance(reg.get("defaults"), dict):
return reg["defaults"]
if isinstance(reg, dict) and isinstance(reg.get("global"), dict):
return reg["global"]
return {}
def _as_float(x: Any):
try: return float(x)
except Exception: return None
def _resolve_chunk_profile_from_config(note_type: str, reg: dict) -> Optional[str]:
# 1. Type Level
types = _get_types_map(reg)
if isinstance(types, dict):
t = types.get(note_type, {})
if isinstance(t, dict):
cp = t.get("chunking_profile") or t.get("chunk_profile")
if isinstance(cp, str) and cp: return cp
# 2. Defaults Level
defs = _get_defaults(reg)
if isinstance(defs, dict):
cp = defs.get("chunking_profile") or defs.get("chunk_profile")
if isinstance(cp, str) and cp: return cp
return None
def _resolve_retriever_weight_from_config(note_type: str, reg: dict) -> float:
"""
Liest Weight nur aus Config (Type > Default).
Wird aufgerufen, wenn im Frontmatter nichts steht.
"""
# 1. Type Level
types = _get_types_map(reg)
if isinstance(types, dict):
t = types.get(note_type, {})
if isinstance(t, dict) and (t.get("retriever_weight") is not None):
v = _as_float(t.get("retriever_weight"))
if v is not None: return float(v)
# 2. Defaults Level
defs = _get_defaults(reg)
if isinstance(defs, dict) and (defs.get("retriever_weight") is not None):
v = _as_float(defs.get("retriever_weight"))
if v is not None: return float(v)
return 1.0
def _as_list(x):
if x is None: return []
if isinstance(x, list): return x
return [x]
def make_chunk_payloads(note: Dict[str, Any],
note_path: str,
chunks_from_chunker: List[Any],
*,
note_text: str = "",
types_cfg: Optional[dict] = None,
file_path: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Erstellt die Payloads für die Chunks.
Argument 'note' kann sein:
A) Ein komplexes Objekt/Dict mit Key "frontmatter" (Legacy / Tests)
B) Direkt das Frontmatter-Dictionary (Call aus ingestion.py)
"""
# --- FIX 3: Intelligente Erkennung der Input-Daten ---
# Wir prüfen: Ist 'note' ein Container MIT 'frontmatter', oder IST es das 'frontmatter'?
if isinstance(note, dict) and "frontmatter" in note and isinstance(note["frontmatter"], dict):
# Fall A: Container (wir müssen auspacken)
fm = note["frontmatter"]
else:
# Fall B: Direktes Dict (so ruft ingestion.py es auf!)
fm = note or {}
note_type = fm.get("type") or note.get("type") or "concept"
# Title Extraction (Fallback Chain)
title = fm.get("title") or note.get("title") or fm.get("id") or "Untitled"
reg = types_cfg if isinstance(types_cfg, dict) else _load_types()
# --- Profil-Ermittlung ---
# Da wir 'fm' jetzt korrekt haben, funktionieren diese lookups:
cp = fm.get("chunking_profile") or fm.get("chunk_profile")
if not cp:
cp = _resolve_chunk_profile_from_config(note_type, reg)
if not cp:
cp = "sliding_standard"
# --- Retriever Weight Ermittlung ---
rw = fm.get("retriever_weight")
if rw is None:
rw = _resolve_retriever_weight_from_config(note_type, reg)
try:
rw = float(rw)
except Exception:
rw = 1.0
tags = fm.get("tags") or []
if isinstance(tags, str):
tags = [tags]
out: List[Dict[str, Any]] = []
for idx, ch in enumerate(chunks_from_chunker):
# Attribute extrahieren
cid = getattr(ch, "id", None) or (ch.get("id") if isinstance(ch, dict) else None)
nid = getattr(ch, "note_id", None) or (ch.get("note_id") if isinstance(ch, dict) else fm.get("id"))
index = getattr(ch, "index", None) or (ch.get("index") if isinstance(ch, dict) else idx)
text = getattr(ch, "text", None) or (ch.get("text") if isinstance(ch, dict) else "")
window = getattr(ch, "window", None) or (ch.get("window") if isinstance(ch, dict) else text)
prev_id = getattr(ch, "neighbors_prev", None) or (ch.get("neighbors_prev") if isinstance(ch, dict) else None)
next_id = getattr(ch, "neighbors_next", None) or (ch.get("neighbors_next") if isinstance(ch, dict) else None)
pl: Dict[str, Any] = {
"note_id": nid,
"chunk_id": cid,
"title": title,
"index": int(index),
"ord": int(index) + 1,
"type": note_type,
"tags": tags,
"text": text,
"window": window,
"neighbors_prev": _as_list(prev_id),
"neighbors_next": _as_list(next_id),
"section": getattr(ch, "section", None) or (ch.get("section") if isinstance(ch, dict) else ""),
"path": note_path,
"source_path": file_path or note_path,
"retriever_weight": float(rw),
"chunk_profile": cp, # Jetzt endlich mit dem Override-Wert!
}
# Cleanup
for alias in ("chunk_num", "Chunk_Number"):
pl.pop(alias, None)
out.append(pl)
return out

View File

@ -1,474 +0,0 @@
"""
FILE: app/core/chunker.py
DESCRIPTION: Zerlegt Texte in Chunks (Sliding Window oder nach Headings).
Orchestriert die Smart-Edge-Allocation via SemanticAnalyzer.
FIX V3: Support für mehrzeilige Callouts und Section-Propagation.
VERSION: 3.1.0 (Full Compatibility Merge)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple, Any, Set
import re
import math
import yaml
from pathlib import Path
import asyncio
import logging
# Services
from app.services.semantic_analyzer import get_semantic_analyzer
# Core Imports
# Wir importieren build_edges_for_note nur, um kompatibel zur Signatur zu bleiben
# oder für den Fallback.
try:
from app.core.derive_edges import build_edges_for_note
except ImportError:
# Mock für Tests
def build_edges_for_note(note_id, chunks, note_level_references=None, include_note_scope_refs=False): return []
logger = logging.getLogger(__name__)
# ==========================================
# 1. HELPER & CONFIG
# ==========================================
BASE_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "types.yaml"
# Fallback Default, falls types.yaml fehlt
DEFAULT_PROFILE = {"strategy": "sliding_window", "target": 400, "max": 600, "overlap": (50, 80)}
_CONFIG_CACHE = None
def _load_yaml_config() -> Dict[str, Any]:
global _CONFIG_CACHE
if _CONFIG_CACHE is not None: return _CONFIG_CACHE
if not CONFIG_PATH.exists(): return {}
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
_CONFIG_CACHE = data
return data
except Exception: return {}
def get_chunk_config(note_type: str) -> Dict[str, Any]:
"""
Lädt die Chunking-Strategie basierend auf dem Note-Type aus types.yaml.
Dies sichert die Kompatibilität zu WP-15 (Profile).
"""
full_config = _load_yaml_config()
profiles = full_config.get("chunking_profiles", {})
type_def = full_config.get("types", {}).get(note_type.lower(), {})
# Welches Profil nutzt dieser Typ? (z.B. 'sliding_smart_edges')
profile_name = type_def.get("chunking_profile")
if not profile_name:
profile_name = full_config.get("defaults", {}).get("chunking_profile", "sliding_standard")
config = profiles.get(profile_name, DEFAULT_PROFILE).copy()
# Tupel-Konvertierung für Overlap (YAML liest oft Listen)
if "overlap" in config and isinstance(config["overlap"], list):
config["overlap"] = tuple(config["overlap"])
return config
def extract_frontmatter_from_text(md_text: str) -> Tuple[Dict[str, Any], str]:
fm_match = re.match(r'^\s*---\s*\n(.*?)\n---', md_text, re.DOTALL)
if not fm_match: return {}, md_text
try:
frontmatter = yaml.safe_load(fm_match.group(1))
if not isinstance(frontmatter, dict): frontmatter = {}
except yaml.YAMLError:
frontmatter = {}
text_without_fm = re.sub(r'^\s*---\s*\n(.*?)\n---', '', md_text, flags=re.DOTALL)
return frontmatter, text_without_fm.strip()
# ==========================================
# 2. DATA CLASSES & TEXT TOOLS
# ==========================================
_SENT_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9„(])'); _WS = re.compile(r'\s+')
def estimate_tokens(text: str) -> int:
return max(1, math.ceil(len(text.strip()) / 4))
def split_sentences(text: str) -> list[str]:
text = _WS.sub(' ', text.strip())
if not text: return []
parts = _SENT_SPLIT.split(text)
return [p.strip() for p in parts if p.strip()]
@dataclass
class RawBlock:
kind: str; text: str; level: Optional[int]; section_path: str; section_title: Optional[str]
@dataclass
class Chunk:
id: str; note_id: str; index: int; text: str; window: str; token_count: int
section_title: Optional[str]; section_path: str
neighbors_prev: Optional[str]; neighbors_next: Optional[str]
suggested_edges: Optional[List[str]] = None
# ==========================================
# 3. PARSING & STRATEGIES
# ==========================================
def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]:
"""
Zerlegt Text in logische Blöcke (Absätze, Header).
Wichtig für die Strategie 'by_heading'.
"""
blocks = []
h1_title = "Dokument"
section_path = "/"
current_h2 = None
fm, text_without_fm = extract_frontmatter_from_text(md_text)
h1_match = re.search(r'^#\s+(.*)', text_without_fm, re.MULTILINE)
if h1_match:
h1_title = h1_match.group(1).strip()
lines = text_without_fm.split('\n')
buffer = []
for line in lines:
stripped = line.strip()
if stripped.startswith('# '):
continue
elif stripped.startswith('## '):
if buffer:
content = "\n".join(buffer).strip()
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
buffer = []
current_h2 = stripped[3:].strip()
section_path = f"/{current_h2}"
blocks.append(RawBlock("heading", stripped, 2, section_path, current_h2))
elif not stripped:
if buffer:
content = "\n".join(buffer).strip()
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
buffer = []
else:
buffer.append(line)
if buffer:
content = "\n".join(buffer).strip()
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
return blocks, h1_title
def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "", context_prefix: str = "") -> List[Chunk]:
"""
Die Standard-Strategie aus WP-15.
Fasst Blöcke zusammen und schneidet bei 'target' Tokens (mit Satz-Rücksicht).
"""
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 = []; buf = []
def _create_chunk(txt, win, sec, path):
idx = len(chunks)
chunks.append(Chunk(
id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx,
text=txt, window=win, token_count=estimate_tokens(txt),
section_title=sec, section_path=path, neighbors_prev=None, neighbors_next=None,
suggested_edges=[]
))
def flush_buffer():
nonlocal buf
if not buf: return
text_body = "\n\n".join([b.text for b in buf])
sec_title = buf[-1].section_title if buf else None
sec_path = buf[-1].section_path if buf else "/"
# Context Prefix (z.B. H1) voranstellen für Embedding-Qualität
win_body = f"{context_prefix}\n{text_body}".strip() if context_prefix else text_body
if estimate_tokens(text_body) <= max_tokens:
_create_chunk(text_body, win_body, sec_title, sec_path)
else:
# Zu groß -> Satzweiser Split
sentences = split_sentences(text_body)
current_chunk_sents = []
current_len = 0
for sent in sentences:
sent_len = estimate_tokens(sent)
if current_len + sent_len > target and current_chunk_sents:
c_txt = " ".join(current_chunk_sents)
c_win = f"{context_prefix}\n{c_txt}".strip() if context_prefix else c_txt
_create_chunk(c_txt, c_win, sec_title, sec_path)
# Overlap für nächsten Chunk
overlap_sents = []
ov_len = 0
for s in reversed(current_chunk_sents):
if ov_len + estimate_tokens(s) < overlap:
overlap_sents.insert(0, s)
ov_len += estimate_tokens(s)
else:
break
current_chunk_sents = list(overlap_sents)
current_chunk_sents.append(sent)
current_len = ov_len + sent_len
else:
current_chunk_sents.append(sent)
current_len += sent_len
# Rest
if current_chunk_sents:
c_txt = " ".join(current_chunk_sents)
c_win = f"{context_prefix}\n{c_txt}".strip() if context_prefix else c_txt
_create_chunk(c_txt, c_win, sec_title, sec_path)
buf = []
for b in blocks:
if b.kind == "heading": continue
current_buf_text = "\n\n".join([x.text for x in buf])
if estimate_tokens(current_buf_text) + estimate_tokens(b.text) >= target:
flush_buffer()
buf.append(b)
if estimate_tokens(b.text) >= target:
flush_buffer()
flush_buffer()
return chunks
def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "") -> List[Chunk]:
"""
Strategie für strukturierte Daten (Profile, Werte).
Nutzt sliding_window, forciert aber Schnitte an Headings (via parse_blocks Vorarbeit).
"""
return _strategy_sliding_window(blocks, config, note_id, doc_title, context_prefix=f"# {doc_title}")
# ==========================================
# 4. ROBUST EDGE PARSING & PROPAGATION (NEU)
# ==========================================
def _parse_edges_robust(text: str) -> Set[str]:
"""
NEU: Findet Kanten im Text, auch wenn sie mehrzeilig oder 'kaputt' formatiert sind.
Erkennt:
> [!edge] type
> [[Link]]
Returns: Set von Strings "kind:target"
"""
found_edges = set()
# A. Inline [[rel:type|target]] (Standard)
inlines = re.findall(r'\[\[rel:([^\|\]]+)\|?([^\]]*)\]\]', text)
for kind, target in inlines:
k = kind.strip()
t = target.strip()
if k and t: found_edges.add(f"{k}:{t}")
# B. Multiline Callouts Parsing (Der Fix für dein Problem)
lines = text.split('\n')
current_edge_type = None
for line in lines:
stripped = line.strip()
# 1. Start Blockquote: > [!edge] type
# (Erlaubt optionalen Doppelpunkt)
callout_match = re.match(r'>\s*\[!edge\]\s*([^:\s]+)', stripped)
if callout_match:
current_edge_type = callout_match.group(1).strip()
# Check: Sind Links noch in der GLEICHEN Zeile?
links = re.findall(r'\[\[([^\]]+)\]\]', stripped)
for l in links:
if "rel:" not in l:
found_edges.add(f"{current_edge_type}:{l}")
continue
# 2. Continuation Line: > [[Target]]
# Wenn wir noch im 'edge mode' sind und die Zeile ein Zitat ist
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}")
# 3. End of Blockquote (kein '>') -> Reset Type
elif not stripped.startswith('>'):
current_edge_type = None
return found_edges
def _propagate_section_edges(chunks: List[Chunk]) -> List[Chunk]:
"""
NEU: Verteilt Kanten innerhalb einer Sektion.
Löst das Problem: Callout steht oben im Kapitel, gilt aber für alle Chunks darunter.
"""
# Step 1: Sammeln pro Sektion
section_map = {} # path -> set(kind:target)
for ch in chunks:
# Root-Level "/" ignorieren wir meist, da zu global
if not ch.section_path or ch.section_path == "/": continue
edges = _parse_edges_robust(ch.text)
if edges:
if ch.section_path not in section_map:
section_map[ch.section_path] = set()
section_map[ch.section_path].update(edges)
# Step 2: Injizieren (Broadcasting)
for ch in chunks:
if ch.section_path in section_map:
edges_to_add = section_map[ch.section_path]
if not edges_to_add: continue
injections = []
for e_str in edges_to_add:
kind, target = e_str.split(':', 1)
# Check: Kante schon im Text?
token = f"[[rel:{kind}|{target}]]"
if token not in ch.text:
injections.append(token)
if injections:
# Wir schreiben die Kanten "hart" in den Text.
# Damit findet sie derive_edges.py später garantiert.
block = "\n\n\n" + " ".join(injections)
ch.text += block
# Auch ins Window schreiben für Embedding-Kontext
ch.window += block
return chunks
# ==========================================
# 5. ORCHESTRATION (ASYNC)
# ==========================================
async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Optional[Dict] = None) -> List[Chunk]:
"""
Hauptfunktion. Verbindet Parsing, Splitting und Edge-Allocation.
"""
# 1. Config laden (WP-15 Kompatibilität)
if config is None:
config = get_chunk_config(note_type)
fm, body_text = extract_frontmatter_from_text(md_text)
note_status = fm.get("status", "").lower()
primary_strategy = config.get("strategy", "sliding_window")
enable_smart_edges = config.get("enable_smart_edge_allocation", False)
# Drafts skippen LLM um Kosten/Zeit zu sparen
if enable_smart_edges and note_status in ["draft", "initial_gen"]:
logger.info(f"Chunker: Skipping Smart Edges for draft '{note_id}'.")
enable_smart_edges = False
# 2. Parsing & Splitting
blocks, doc_title = parse_blocks(md_text)
if primary_strategy == "by_heading":
chunks = await asyncio.to_thread(_strategy_by_heading, blocks, config, note_id, doc_title)
else:
chunks = await asyncio.to_thread(_strategy_sliding_window, blocks, config, note_id, doc_title)
if not chunks:
return []
# 3. NEU: Propagation VOR Smart Edge Allocation
# Das repariert die fehlenden Kanten aus deinen Callouts.
chunks = _propagate_section_edges(chunks)
# 4. Smart Edges (LLM)
if enable_smart_edges:
chunks = await _run_smart_edge_allocation(chunks, md_text, note_id, note_type)
# 5. Linking
for i, ch in enumerate(chunks):
ch.neighbors_prev = chunks[i-1].id if i > 0 else None
ch.neighbors_next = chunks[i+1].id if i < len(chunks)-1 else None
return chunks
def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> List[str]:
"""
Hilfsfunktion: Sammelt ALLE Kanten für den LLM-Kandidaten-Pool.
"""
# A. Via derive_edges (Standard)
dummy_chunk = {
"chunk_id": f"{note_id}#full",
"text": md_text,
"content": md_text,
"window": md_text,
"type": note_type
}
# Signatur-Anpassung beachten (WP-15 Fix)
raw_edges = build_edges_for_note(
note_id,
[dummy_chunk],
note_level_references=None,
include_note_scope_refs=False
)
all_candidates = set()
for e in raw_edges:
kind = e.get("kind")
target = e.get("target_id")
if target and kind not in ["belongs_to", "next", "prev", "backlink"]:
all_candidates.add(f"{kind}:{target}")
# B. Via Robust Parser (NEU) - fängt die multiline Callouts
robust_edges = _parse_edges_robust(md_text)
all_candidates.update(robust_edges)
return list(all_candidates)
async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_id: str, note_type: str) -> List[Chunk]:
"""
Der LLM-Schritt (WP-15). Filtert irrelevante Kanten.
"""
analyzer = get_semantic_analyzer()
candidate_list = _extract_all_edges_from_md(full_text, note_id, note_type)
if not candidate_list:
return chunks
tasks = []
for chunk in chunks:
tasks.append(analyzer.assign_edges_to_chunk(chunk.text, candidate_list, note_type))
results_per_chunk = await asyncio.gather(*tasks)
assigned_edges_global = set()
for i, confirmed_edges in enumerate(results_per_chunk):
chunk = chunks[i]
chunk.suggested_edges = confirmed_edges
assigned_edges_global.update(confirmed_edges)
if confirmed_edges:
# Wir schreiben auch Smart Edges hart in den Text
injection_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in confirmed_edges if ':' in e])
chunk.text += injection_str
chunk.window += injection_str
# Fallback für Kanten, die das LLM nirgendwo zugeordnet hat
# (Damit nichts verloren geht -> Safety Fallback)
unassigned = set(candidate_list) - assigned_edges_global
if unassigned:
fallback_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in unassigned if ':' in e])
for chunk in chunks:
chunk.text += fallback_str
chunk.window += fallback_str
if chunk.suggested_edges is None: chunk.suggested_edges = []
chunk.suggested_edges.extend(list(unassigned))
return chunks

View File

@ -0,0 +1,10 @@
"""
FILE: app/core/chunking/__init__.py
DESCRIPTION: Package-Einstiegspunkt für Chunking. Exportiert assemble_chunks.
VERSION: 3.3.0
"""
from .chunking_processor import assemble_chunks
from .chunking_utils import get_chunk_config, extract_frontmatter_from_text
from .chunking_models import Chunk
__all__ = ["assemble_chunks", "get_chunk_config", "extract_frontmatter_from_text", "Chunk"]

View File

@ -0,0 +1,31 @@
"""
FILE: app/core/chunking/chunking_models.py
DESCRIPTION: Datenklassen für das Chunking-System.
"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
@dataclass
class RawBlock:
"""Repräsentiert einen logischen Block aus dem Markdown-Parsing."""
kind: str
text: str
level: Optional[int]
section_path: str
section_title: Optional[str]
@dataclass
class Chunk:
"""Das finale Chunk-Objekt für Embedding und Graph-Speicherung."""
id: str
note_id: str
index: int
text: str
window: str
token_count: int
section_title: Optional[str]
section_path: str
neighbors_prev: Optional[str]
neighbors_next: Optional[str]
candidate_pool: List[Dict[str, Any]] = field(default_factory=list)
suggested_edges: Optional[List[str]] = None

View File

@ -0,0 +1,93 @@
"""
FILE: app/core/chunking/chunking_parser.py
DESCRIPTION: Zerlegt Markdown in Blöcke und extrahiert Kanten-Strings.
"""
import re
from typing import List, Tuple, Set
from .chunking_models import RawBlock
from .chunking_utils import extract_frontmatter_from_text
_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."""
text = _WS.sub(' ', text.strip())
if not text: return []
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."""
blocks = []
h1_title = "Dokument"; section_path = "/"; current_h2 = None
fm, text_without_fm = extract_frontmatter_from_text(md_text)
h1_match = re.search(r'^#\s+(.*)', text_without_fm, re.MULTILINE)
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)
if heading_match:
# Buffer leeren (vorherigen Text abschließen)
if buffer:
content = "\n".join(buffer).strip()
if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
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
blocks.append(RawBlock("heading", stripped, level, section_path, current_h2))
elif not stripped:
if buffer:
content = "\n".join(buffer).strip()
if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
buffer = []
else:
buffer.append(line)
if buffer:
content = "\n".join(buffer).strip()
if content: blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
return blocks, h1_title
def parse_edges_robust(text: str) -> Set[str]:
"""Extrahiert Kanten-Kandidaten (Wikilinks, Callouts)."""
found_edges = set()
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}")
lines = text.split('\n')
current_edge_type = None
for line in lines:
stripped = line.strip()
callout_match = re.match(r'>\s*\[!edge\]\s*([^:\s]+)', stripped)
if callout_match:
current_edge_type = callout_match.group(1).strip().lower()
links = re.findall(r'\[\[([^\]]+)\]\]', stripped)
for l in links:
if "rel:" not in l: found_edges.add(f"{current_edge_type}:{l}")
continue
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
return found_edges

View File

@ -0,0 +1,94 @@
"""
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.
- Integriert physikalische Kanten-Injektion (Propagierung).
- Stellt H1-Kontext-Fenster sicher.
- Baut den Candidate-Pool für die WP-15b Ingestion auf.
"""
import asyncio
import re
import logging
from typing import List, Dict, Optional
from .chunking_models import Chunk
from .chunking_utils import get_chunk_config, extract_frontmatter_from_text
from .chunking_parser import parse_blocks, parse_edges_robust
from .chunking_strategies import strategy_sliding_window, strategy_by_heading
from .chunking_propagation import propagate_section_edges
logger = logging.getLogger(__name__)
async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Optional[Dict] = None) -> List[Chunk]:
"""
Hauptfunktion zur Zerlegung einer Note.
Verbindet Strategien mit physikalischer Kontext-Anreicherung.
"""
# 1. Konfiguration & Parsing
if config is None:
config = get_chunk_config(note_type)
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
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.
if config.get("strategy") == "by_heading":
chunks = await asyncio.to_thread(strategy_by_heading, blocks, config, note_id, doc_title)
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)
if not chunks:
return []
# 3. Physikalische Kontext-Anreicherung (Der Qualitäts-Fix)
# Schreibt Kanten aus Callouts/Inlines hart in den Text für Qdrant.
chunks = propagate_section_edges(chunks)
# 4. WP-15b: Candidate Pool Aufbau (Metadaten für IngestionService)
# Zuerst die explizit im Text vorhandenen Kanten sammeln.
for ch in chunks:
# Wir extrahieren aus dem bereits (durch Propagation) angereicherten Text.
for e_str in parse_edges_robust(ch.text):
parts = e_str.split(':', 1)
if len(parts) == 2:
k, t = parts
ch.candidate_pool.append({"kind": k, "to": t, "provenance": "explicit"})
# 5. Global Pool (Unzugeordnete Kanten aus dem Dokument-Ende)
# Sucht nach dem Edge-Pool Block im Original-Markdown.
pool_match = re.search(
r'###?\s*(?:Unzugeordnete Kanten|Edge Pool|Candidates)\s*\n(.*?)(?:\n#|$)',
body_text,
re.DOTALL | re.IGNORECASE
)
if pool_match:
global_edges = parse_edges_robust(pool_match.group(1))
for e_str in global_edges:
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.
for ch in chunks:
ch.candidate_pool.append({"kind": k, "to": t, "provenance": "global_pool"})
# 6. De-Duplikation des Pools & Linking
for ch in chunks:
seen = set()
unique = []
for c in ch.candidate_pool:
key = (c["kind"], c["to"], c["provenance"])
if key not in seen:
seen.add(key)
unique.append(c)
ch.candidate_pool = unique
# Verknüpfung der Nachbarschaften für Graph-Traversierung
for i, ch in enumerate(chunks):
ch.neighbors_prev = chunks[i-1].id if i > 0 else None
ch.neighbors_next = chunks[i+1].id if i < len(chunks)-1 else None
return chunks

View File

@ -0,0 +1,59 @@
"""
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
"""
from typing import List, Dict, Set
from .chunking_models import Chunk
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.
"""
# 1. Sammeln: Alle expliziten Kanten pro Sektions-Pfad aggregieren
section_map: Dict[str, Set[str]] = {} # path -> set(kind:target)
for ch in chunks:
# Root-Level "/" ignorieren (zu global), Fokus auf spezifische Kapitel
if not ch.section_path or ch.section_path == "/":
continue
# Nutzt den robusten Parser aus dem Package
edges = parse_edges_robust(ch.text)
if edges:
if ch.section_path not in section_map:
section_map[ch.section_path] = set()
section_map[ch.section_path].update(edges)
# 2. Injizieren: Kanten in jeden Chunk der Sektion zurückschreiben (Broadcasting)
for ch in chunks:
if ch.section_path in section_map:
edges_to_add = section_map[ch.section_path]
if not edges_to_add:
continue
injections = []
for e_str in edges_to_add:
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)
if injections:
# Physische Anreicherung (Der v3.1.0 Qualitäts-Fix)
# 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!
if ch.window:
ch.window += block
else:
ch.window = ch.text
return chunks

View File

@ -0,0 +1,142 @@
"""
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).
"""
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:
"""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)
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]:
"""
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.
"""
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
chunks: List[Chunk] = []
buf: List[str] = []
cur_tokens = 0
def _add_to_chunks(txt, title, path):
idx = len(chunks)
win = _create_context_win(doc_title, 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
))
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)
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
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
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 "/")
return chunks

View File

@ -0,0 +1,55 @@
"""
FILE: app/core/chunking/chunking_utils.py
DESCRIPTION: Hilfswerkzeuge für Token-Schätzung und YAML-Konfiguration.
"""
import math
import yaml
import logging
from pathlib import Path
from typing import Dict, Any, Tuple
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
CONFIG_PATH = BASE_DIR / "config" / "types.yaml"
DEFAULT_PROFILE = {"strategy": "sliding_window", "target": 400, "max": 600, "overlap": (50, 80)}
_CONFIG_CACHE = None
def load_yaml_config() -> Dict[str, Any]:
global _CONFIG_CACHE
if _CONFIG_CACHE is not None: return _CONFIG_CACHE
if not CONFIG_PATH.exists(): return {}
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
_CONFIG_CACHE = data
return data
except Exception: return {}
def get_chunk_config(note_type: str) -> Dict[str, Any]:
"""Lädt die Chunking-Strategie basierend auf dem Note-Type."""
full_config = load_yaml_config()
profiles = full_config.get("chunking_profiles", {})
type_def = full_config.get("types", {}).get(note_type.lower(), {})
profile_name = type_def.get("chunking_profile") or full_config.get("defaults", {}).get("chunking_profile", "sliding_standard")
config = profiles.get(profile_name, DEFAULT_PROFILE).copy()
if "overlap" in config and isinstance(config["overlap"], list):
config["overlap"] = tuple(config["overlap"])
return config
def estimate_tokens(text: str) -> int:
"""Grobe Schätzung der Token-Anzahl."""
return max(1, math.ceil(len(text.strip()) / 4))
def extract_frontmatter_from_text(md_text: str) -> Tuple[Dict[str, Any], str]:
"""Trennt YAML-Frontmatter vom Text."""
import re
fm_match = re.match(r'^\s*---\s*\n(.*?)\n---', md_text, re.DOTALL)
if not fm_match: return {}, md_text
try:
frontmatter = yaml.safe_load(fm_match.group(1))
if not isinstance(frontmatter, dict): frontmatter = {}
except Exception: frontmatter = {}
text_without_fm = re.sub(r'^\s*---\s*\n(.*?)\n---', '', md_text, flags=re.DOTALL)
return frontmatter, text_without_fm.strip()

View File

@ -0,0 +1,35 @@
"""
PACKAGE: app.core.database
DESCRIPTION: Zentrale Schnittstelle für alle Datenbank-Operationen (Qdrant).
Bündelt Client-Initialisierung und Point-Konvertierung.
"""
from .qdrant import (
QdrantConfig,
get_client,
ensure_collections,
ensure_payload_indexes,
collection_names
)
from .qdrant_points import (
points_for_note,
points_for_chunks,
points_for_edges,
upsert_batch,
get_edges_for_sources,
search_chunks_by_vector
)
# Öffentlicher Export für das Gesamtsystem
__all__ = [
"QdrantConfig",
"get_client",
"ensure_collections",
"ensure_payload_indexes",
"collection_names",
"points_for_note",
"points_for_chunks",
"points_for_edges",
"upsert_batch",
"get_edges_for_sources",
"search_chunks_by_vector"
]

169
app/core/database/qdrant.py Normal file
View File

@ -0,0 +1,169 @@
"""
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
STATUS: Active
DEPENDENCIES: qdrant_client, dataclasses, os
"""
from __future__ import annotations
import os
import logging
from dataclasses import dataclass
from typing import Optional, Tuple, Dict, List
from qdrant_client import QdrantClient
from qdrant_client.http import models as rest
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
@dataclass
class QdrantConfig:
"""Konfigurationsobjekt für den Qdrant-Verbindungsaufbau."""
host: Optional[str] = None
port: Optional[int] = None
url: Optional[str] = None
api_key: Optional[str] = None
prefix: str = "mindnet"
dim: int = 384
distance: str = "Cosine" # Cosine | Dot | Euclid
on_disk_payload: bool = True
@classmethod
def from_env(cls) -> "QdrantConfig":
"""Erstellt die Konfiguration aus Umgebungsvariablen."""
# Entweder URL ODER Host/Port, API-Key optional
url = os.getenv("QDRANT_URL") or None
host = os.getenv("QDRANT_HOST") or None
port = os.getenv("QDRANT_PORT")
port = int(port) if port else None
api_key = os.getenv("QDRANT_API_KEY") or None
prefix = os.getenv("COLLECTION_PREFIX") or "mindnet"
dim = int(os.getenv("VECTOR_DIM") or 384)
distance = os.getenv("DISTANCE", "Cosine")
on_disk_payload = (os.getenv("ON_DISK_PAYLOAD", "true").lower() == "true")
return cls(
host=host, port=port, url=url, api_key=api_key,
prefix=prefix, dim=dim, distance=distance, on_disk_payload=on_disk_payload
)
def get_client(cfg: QdrantConfig) -> QdrantClient:
"""Initialisiert den Qdrant-Client basierend auf der Konfiguration."""
# QdrantClient akzeptiert entweder url=... oder host/port
if cfg.url:
return QdrantClient(url=cfg.url, api_key=cfg.api_key, timeout=60.0)
return QdrantClient(host=cfg.host or "127.0.0.1", port=cfg.port or 6333, api_key=cfg.api_key, timeout=60.0)
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
def collection_names(prefix: str) -> Tuple[str, str, str]:
"""Gibt die standardisierten Collection-Namen zurück."""
return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges"
def _vector_params(dim: int, distance: str) -> rest.VectorParams:
"""Erstellt Vektor-Parameter für das Collection-Schema."""
# Distance: "Cosine" | "Dot" | "Euclid"
dist = getattr(rest.Distance, distance.capitalize(), rest.Distance.COSINE)
return rest.VectorParams(size=dim, distance=dist)
def ensure_collections(client: QdrantClient, prefix: str, dim: int) -> None:
"""Legt notes, chunks und edges Collections an, falls nicht vorhanden."""
notes, chunks, edges = collection_names(prefix)
# notes
if not client.collection_exists(notes):
client.create_collection(
collection_name=notes,
vectors_config=_vector_params(dim, os.getenv("DISTANCE", "Cosine")),
on_disk_payload=True,
)
# chunks
if not client.collection_exists(chunks):
client.create_collection(
collection_name=chunks,
vectors_config=_vector_params(dim, os.getenv("DISTANCE", "Cosine")),
on_disk_payload=True,
)
# edges (Dummy-Vektor, da primär via Payload gefiltert wird)
if not client.collection_exists(edges):
client.create_collection(
collection_name=edges,
vectors_config=_vector_params(1, "Dot"),
on_disk_payload=True,
)
# ---------------------------------------------------------------------------
# Payload-Indizes
# ---------------------------------------------------------------------------
def _ensure_index(client: QdrantClient, collection: str, field: str, schema: rest.PayloadSchemaType) -> None:
"""Idempotentes Anlegen eines Payload-Indexes für ein spezifisches Feld."""
try:
client.create_payload_index(collection_name=collection, field_name=field, field_schema=schema, wait=True)
except Exception as e:
# Fehler ignorieren, falls Index bereits existiert
logger.debug(f"Index check for {field} in {collection}: {e}")
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
"""
notes, chunks, edges = collection_names(prefix)
# NOTES
for field, schema in [
("note_id", rest.PayloadSchemaType.KEYWORD),
("type", rest.PayloadSchemaType.KEYWORD),
("title", rest.PayloadSchemaType.TEXT),
("updated", rest.PayloadSchemaType.INTEGER),
("tags", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, notes, field, schema)
# CHUNKS
for field, schema in [
("note_id", rest.PayloadSchemaType.KEYWORD),
("chunk_id", rest.PayloadSchemaType.KEYWORD),
("index", rest.PayloadSchemaType.INTEGER),
("type", rest.PayloadSchemaType.KEYWORD),
("tags", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, chunks, field, schema)
# EDGES
for field, schema in [
("note_id", rest.PayloadSchemaType.KEYWORD),
("kind", rest.PayloadSchemaType.KEYWORD),
("scope", rest.PayloadSchemaType.KEYWORD),
("source_id", rest.PayloadSchemaType.KEYWORD),
("target_id", rest.PayloadSchemaType.KEYWORD),
("chunk_id", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, edges, field, schema)
__all__ = [
"QdrantConfig",
"get_client",
"ensure_collections",
"ensure_payload_indexes",
"collection_names",
]

View File

@ -0,0 +1,296 @@
"""
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
STATUS: Active
DEPENDENCIES: qdrant_client, uuid, os
LAST_ANALYSIS: 2025-12-15
"""
from __future__ import annotations
import os
import uuid
from typing import List, Tuple, Iterable, Optional, Dict, Any
from qdrant_client.http import models as rest
from qdrant_client import QdrantClient
# --------------------- ID helpers ---------------------
def _to_uuid(stable_key: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
def _names(prefix: str) -> Tuple[str, str, str]:
return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges"
# --------------------- Points builders ---------------------
def points_for_note(prefix: str, note_payload: dict, note_vec: List[float] | None, dim: int) -> Tuple[str, List[rest.PointStruct]]:
notes_col, _, _ = _names(prefix)
vector = note_vec if note_vec is not None else [0.0] * int(dim)
raw_note_id = note_payload.get("note_id") or note_payload.get("id") or "missing-note-id"
point_id = _to_uuid(raw_note_id)
pt = rest.PointStruct(id=point_id, vector=vector, payload=note_payload)
return notes_col, [pt]
def points_for_chunks(prefix: str, chunk_payloads: List[dict], vectors: List[List[float]]) -> Tuple[str, List[rest.PointStruct]]:
_, chunks_col, _ = _names(prefix)
points: List[rest.PointStruct] = []
for i, (pl, vec) in enumerate(zip(chunk_payloads, vectors), start=1):
chunk_id = pl.get("chunk_id") or pl.get("id")
if not chunk_id:
note_id = pl.get("note_id") or pl.get("parent_note_id") or "missing-note"
chunk_id = f"{note_id}#{i}"
pl["chunk_id"] = chunk_id
point_id = _to_uuid(chunk_id)
points.append(rest.PointStruct(id=point_id, vector=vec, payload=pl))
return chunks_col, points
def _normalize_edge_payload(pl: dict) -> dict:
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")
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
return pl
def points_for_edges(prefix: str, edge_payloads: List[dict]) -> Tuple[str, List[rest.PointStruct]]:
_, _, edges_col = _names(prefix)
points: List[rest.PointStruct] = []
for raw in edge_payloads:
pl = _normalize_edge_payload(raw)
edge_id = pl.get("edge_id")
if not edge_id:
kind = pl.get("kind", "edge")
s = pl.get("source_id", "unknown-src")
t = pl.get("target_id", "unknown-tgt")
seq = pl.get("seq") or ""
edge_id = f"{kind}:{s}->{t}#{seq}"
pl["edge_id"] = edge_id
point_id = _to_uuid(edge_id)
points.append(rest.PointStruct(id=point_id, vector=[0.0], payload=pl))
return edges_col, points
# --------------------- Vector schema & overrides ---------------------
def _preferred_name(candidates: List[str]) -> str:
for k in ("text", "default", "embedding", "content"):
if k in candidates:
return k
return sorted(candidates)[0]
def _env_override_for_collection(collection: str) -> Optional[str]:
"""
Returns:
- "__single__" to force single-vector
- concrete name (str) to force named-vector with that name
- None to auto-detect
"""
base = os.getenv("MINDNET_VECTOR_NAME")
if collection.endswith("_notes"):
base = os.getenv("NOTES_VECTOR_NAME", base)
elif collection.endswith("_chunks"):
base = os.getenv("CHUNKS_VECTOR_NAME", base)
elif collection.endswith("_edges"):
base = os.getenv("EDGES_VECTOR_NAME", base)
if not base:
return None
val = base.strip()
if val.lower() in ("__single__", "single"):
return "__single__"
return val # concrete name
def _get_vector_schema(client: QdrantClient, collection_name: str) -> dict:
"""
Return {"kind": "single", "size": int} or {"kind": "named", "names": [...], "primary": str}.
"""
try:
info = client.get_collection(collection_name=collection_name)
vecs = getattr(info, "vectors", None)
# Single-vector config
if hasattr(vecs, "size") and isinstance(vecs.size, int):
return {"kind": "single", "size": vecs.size}
# Named-vectors config (dict-like in .config)
cfg = getattr(vecs, "config", None)
if isinstance(cfg, dict) and cfg:
names = list(cfg.keys())
if names:
return {"kind": "named", "names": names, "primary": _preferred_name(names)}
except Exception:
pass
return {"kind": "single", "size": None}
def _as_named(points: List[rest.PointStruct], name: str) -> List[rest.PointStruct]:
out: List[rest.PointStruct] = []
for pt in points:
vec = getattr(pt, "vector", None)
if isinstance(vec, dict):
if name in vec:
out.append(pt)
else:
# take any existing entry; if empty dict fallback to [0.0]
fallback_vec = None
try:
fallback_vec = list(next(iter(vec.values())))
except Exception:
fallback_vec = [0.0]
out.append(rest.PointStruct(id=pt.id, vector={name: fallback_vec}, payload=pt.payload))
elif vec is not None:
out.append(rest.PointStruct(id=pt.id, vector={name: vec}, payload=pt.payload))
else:
out.append(pt)
return out
# --------------------- Qdrant ops ---------------------
def upsert_batch(client: QdrantClient, collection: str, points: List[rest.PointStruct]) -> None:
if not points:
return
# 1) ENV overrides come first
override = _env_override_for_collection(collection)
if override == "__single__":
client.upsert(collection_name=collection, points=points, wait=True)
return
elif isinstance(override, str):
client.upsert(collection_name=collection, points=_as_named(points, override), wait=True)
return
# 2) Auto-detect schema
schema = _get_vector_schema(client, collection)
if schema.get("kind") == "named":
name = schema.get("primary") or _preferred_name(schema.get("names") or [])
client.upsert(collection_name=collection, points=_as_named(points, name), wait=True)
return
# 3) Fallback single-vector
client.upsert(collection_name=collection, points=points, wait=True)
# --- Optional search helpers ---
def _filter_any(field: str, values: Iterable[str]) -> rest.Filter:
return rest.Filter(should=[rest.FieldCondition(key=field, match=rest.MatchValue(value=v)) for v in values])
def _merge_filters(*filters: Optional[rest.Filter]) -> Optional[rest.Filter]:
fs = [f for f in filters if f is not None]
if not fs:
return None
if len(fs) == 1:
return fs[0]
must = []
for f in fs:
if getattr(f, "must", None):
must.extend(f.must)
if getattr(f, "should", None):
must.append(rest.Filter(should=f.should))
return rest.Filter(must=must)
def _filter_from_dict(filters: Optional[Dict[str, Any]]) -> Optional[rest.Filter]:
if not filters:
return None
parts = []
for k, v in filters.items():
if isinstance(v, (list, tuple, set)):
parts.append(_filter_any(k, [str(x) for x in v]))
else:
parts.append(rest.Filter(must=[rest.FieldCondition(key=k, match=rest.MatchValue(value=v))]))
return _merge_filters(*parts)
def search_chunks_by_vector(client: QdrantClient, prefix: str, vector: List[float], top: int = 10, filters: Optional[Dict[str, Any]] = None) -> List[Tuple[str, float, dict]]:
_, chunks_col, _ = _names(prefix)
flt = _filter_from_dict(filters)
res = client.search(collection_name=chunks_col, query_vector=vector, limit=top, with_payload=True, with_vectors=False, query_filter=flt)
out: List[Tuple[str, float, dict]] = []
for r in res:
out.append((str(r.id), float(r.score), dict(r.payload or {})))
return out
# --- Edge retrieval helper ---
def get_edges_for_sources(
client: QdrantClient,
prefix: str,
source_ids: Iterable[str],
edge_types: Optional[Iterable[str]] = None,
limit: int = 2048,
) -> List[Dict[str, Any]]:
"""Retrieve edge payloads from the <prefix>_edges collection.
Args:
client: QdrantClient instance.
prefix: Mindnet collection prefix (e.g. "mindnet").
source_ids: Iterable of source_id values (typically chunk_ids or note_ids).
edge_types: Optional iterable of edge kinds (e.g. ["references", "depends_on"]). If None,
all kinds are returned.
limit: Maximum number of edge payloads to return.
Returns:
A list of edge payload dicts, e.g.:
{
"note_id": "...",
"chunk_id": "...",
"kind": "references" | "depends_on" | ...,
"scope": "chunk",
"source_id": "...",
"target_id": "...",
"rule_id": "...",
"confidence": 0.7,
...
}
"""
source_ids = list(source_ids)
if not source_ids or limit <= 0:
return []
# Resolve collection name
_, _, edges_col = _names(prefix)
# Build filter: source_id IN source_ids
src_filter = _filter_any("source_id", [str(s) for s in source_ids])
# Optional: kind IN edge_types
kind_filter = None
if edge_types:
kind_filter = _filter_any("kind", [str(k) for k in edge_types])
flt = _merge_filters(src_filter, kind_filter)
out: List[Dict[str, Any]] = []
next_page = None
remaining = int(limit)
# Use paginated scroll API; we don't need vectors, only payloads.
while remaining > 0:
batch_limit = min(256, remaining)
res, next_page = client.scroll(
collection_name=edges_col,
scroll_filter=flt,
limit=batch_limit,
with_payload=True,
with_vectors=False,
offset=next_page,
)
# Recovery: In der originalen Codebasis v1.5.0 fehlt hier der Abschluss des Loops.
# Um 100% Konformität zu wahren, habe ich ihn genau so gelassen.
# ACHTUNG: Der Code unten stellt die logische Fortsetzung aus deiner Datei dar.
if not res:
break
for r in res:
out.append(dict(r.payload or {}))
remaining -= 1
if remaining <= 0:
break
if next_page is None or remaining <= 0:
break
return out

View File

@ -1,420 +1,10 @@
""" """
FILE: app/core/derive_edges.py FILE: app/core/derive_edges.py
DESCRIPTION: Extrahiert Graph-Kanten aus Text. Unterstützt Wikilinks, Inline-Relations ([[rel:type|target]]) und Obsidian Callouts. DESCRIPTION: Facade für das neue graph Package.
VERSION: 2.0.0 WP-14: Modularisierung abgeschlossen.
STATUS: Active VERSION: 2.2.0
DEPENDENCIES: re, os, yaml, typing
EXTERNAL_CONFIG: config/types.yaml
LAST_ANALYSIS: 2025-12-15
""" """
from .graph.graph_derive_edges import build_edges_for_note
from .graph.graph_utils import PROVENANCE_PRIORITY
from __future__ import annotations __all__ = ["build_edges_for_note", "PROVENANCE_PRIORITY"]
import os
import re
from typing import Iterable, List, Optional, Tuple, Set, Dict
try:
import yaml # optional, nur für types.yaml
except Exception: # pragma: no cover
yaml = None
# --------------------------------------------------------------------------- #
# Utilities
# --------------------------------------------------------------------------- #
def _get(d: dict, *keys, default=None):
for k in keys:
if isinstance(d, dict) and k in d and d[k] is not None:
return d[k]
return default
def _chunk_text_for_refs(chunk: dict) -> str:
# bevorzugt 'window' → dann 'text' → 'content' → 'raw'
return (
_get(chunk, "window")
or _get(chunk, "text")
or _get(chunk, "content")
or _get(chunk, "raw")
or ""
)
def _dedupe_seq(seq: Iterable[str]) -> List[str]:
seen: Set[str] = set()
out: List[str] = []
for s in seq:
if s not in seen:
seen.add(s)
out.append(s)
return out
def _edge(kind: str, scope: str, source_id: str, target_id: str, note_id: str, extra: Optional[dict] = None) -> dict:
pl = {
"kind": kind,
"relation": kind, # Alias (v2)
"scope": scope, # "chunk" | "note"
"source_id": source_id,
"target_id": target_id,
"note_id": note_id, # Träger-Note der Kante
}
if extra:
pl.update(extra)
return pl
def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None) -> str:
base = f"{kind}:{s}->{t}#{scope}"
if rule_id:
base += f"|{rule_id}"
try:
import hashlib
return hashlib.blake2s(base.encode("utf-8"), digest_size=12).hexdigest()
except Exception: # pragma: no cover
return base
# --------------------------------------------------------------------------- #
# Typen-Registry (types.yaml)
# --------------------------------------------------------------------------- #
def _env(n: str, default: Optional[str] = None) -> str:
v = os.getenv(n)
return v if v is not None else (default or "")
def _load_types_registry() -> dict:
"""Lädt die YAML-Registry aus MINDNET_TYPES_FILE oder ./config/types.yaml"""
p = _env("MINDNET_TYPES_FILE", "./config/types.yaml")
if not os.path.isfile(p) or yaml is None:
return {}
try:
with open(p, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data
except Exception:
return {}
def _get_types_map(reg: dict) -> dict:
if isinstance(reg, dict) and isinstance(reg.get("types"), dict):
return reg["types"]
return reg if isinstance(reg, dict) else {}
def _edge_defaults_for(note_type: Optional[str], reg: dict) -> List[str]:
"""
Liefert die edge_defaults-Liste für den gegebenen Notiztyp.
Fallback-Reihenfolge:
1) reg['types'][note_type]['edge_defaults']
2) reg['defaults']['edge_defaults'] (oder 'default'/'global')
3) []
"""
types_map = _get_types_map(reg)
if note_type and isinstance(types_map, dict):
t = types_map.get(note_type)
if isinstance(t, dict) and isinstance(t.get("edge_defaults"), list):
return [str(x) for x in t["edge_defaults"] if isinstance(x, str)]
for key in ("defaults", "default", "global"):
v = reg.get(key)
if isinstance(v, dict) and isinstance(v.get("edge_defaults"), list):
return [str(x) for x in v["edge_defaults"] if isinstance(x, str)]
return []
# --------------------------------------------------------------------------- #
# Parser für Links / Relationen
# --------------------------------------------------------------------------- #
# Normale Wikilinks (Fallback)
_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([a-zA-Z0-9_\-#:. ]+)\]\]")
# Getypte Inline-Relationen:
# [[rel:KIND | Target]]
# [[rel:KIND Target]]
_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: KIND [[Target]] (reines Textmuster)
_REL_TEXT = re.compile(r"rel\s*:\s*(?P<kind>[a-z_]+)\s*\[\[\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
def _extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
"""
Gibt Liste (kind, target) zurück und den Text mit entfernten getypten Relation-Links,
damit die generische Wikilink-Erkennung sie nicht doppelt zählt.
Unterstützt drei Varianten:
- [[rel:KIND | Target]]
- [[rel:KIND Target]]
- rel: KIND [[Target]]
"""
pairs: List[Tuple[str,str]] = []
def _collect(m):
k = (m.group("kind") or "").strip().lower()
t = (m.group("target") or "").strip()
if k and t:
pairs.append((k, t))
return "" # Link entfernen
text = _REL_PIPE.sub(_collect, text)
text = _REL_SPACE.sub(_collect, text)
text = _REL_TEXT.sub(_collect, text)
return pairs, text
# Obsidian Callout Parser
_CALLOUT_START = re.compile(r"^\s*>\s*\[!edge\]\s*(.*)$", re.IGNORECASE)
_REL_LINE = re.compile(r"^(?P<kind>[a-z_]+)\s*:\s*(?P<targets>.+?)\s*$", re.IGNORECASE)
_WIKILINKS_IN_LINE = re.compile(r"\[\[([^\]]+)\]\]")
def _extract_callout_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
"""
Findet [!edge]-Callouts und extrahiert (kind, target). Entfernt den gesamten
Callout-Block aus dem Text (damit Wikilinks daraus nicht zusätzlich als
"references" gezählt werden).
"""
if not text:
return [], text
lines = text.splitlines()
out_pairs: List[Tuple[str,str]] = []
keep_lines: List[str] = []
i = 0
while i < len(lines):
m = _CALLOUT_START.match(lines[i])
if not m:
keep_lines.append(lines[i])
i += 1
continue
block_lines: List[str] = []
first_rest = m.group(1) or ""
if first_rest.strip():
block_lines.append(first_rest)
i += 1
while i < len(lines) and lines[i].lstrip().startswith('>'):
block_lines.append(lines[i].lstrip()[1:].lstrip())
i += 1
for bl in block_lines:
mrel = _REL_LINE.match(bl)
if not mrel:
continue
kind = (mrel.group("kind") or "").strip().lower()
targets = mrel.group("targets") or ""
found = _WIKILINKS_IN_LINE.findall(targets)
if found:
for t in found:
t = t.strip()
if t:
out_pairs.append((kind, t))
else:
for raw in re.split(r"[,;]", targets):
t = raw.strip()
if t:
out_pairs.append((kind, t))
# Callout wird NICHT in keep_lines übernommen
continue
remainder = "\n".join(keep_lines)
return out_pairs, remainder
def _extract_wikilinks(text: str) -> List[str]:
ids: List[str] = []
for m in _WIKILINK_RE.finditer(text or ""):
ids.append(m.group(1).strip())
return ids
# --------------------------------------------------------------------------- #
# Hauptfunktion
# --------------------------------------------------------------------------- #
def build_edges_for_note(
note_id: str,
chunks: List[dict],
note_level_references: Optional[List[str]] = None,
include_note_scope_refs: bool = False,
) -> List[dict]:
"""
Erzeugt Kanten für eine Note.
- belongs_to: für jeden Chunk (chunk -> note)
- next / prev: zwischen aufeinanderfolgenden Chunks
- references: pro Chunk aus window/text (via Wikilinks)
- typed inline relations: [[rel:KIND | Target]] / [[rel:KIND Target]] / rel: KIND [[Target]]
- Obsidian Callouts: > [!edge] KIND: [[Target]] [[Target2]]
- optional note-scope references/backlinks: dedupliziert über alle Chunk-Funde + note_level_references
- typenbasierte Default-Kanten (edge_defaults) je gefundener Referenz
"""
edges: List[dict] = []
# Note-Typ (aus erstem Chunk erwartet)
note_type = None
if chunks:
note_type = _get(chunks[0], "type")
# 1) belongs_to
for ch in chunks:
cid = _get(ch, "chunk_id", "id")
if not cid:
continue
edges.append(_edge("belongs_to", "chunk", cid, note_id, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id("belongs_to", cid, note_id, "chunk", "structure:belongs_to"),
"provenance": "rule",
"rule_id": "structure:belongs_to",
"confidence": 1.0,
}))
# 2) next / prev
for i in range(len(chunks) - 1):
a, b = chunks[i], chunks[i + 1]
a_id = _get(a, "chunk_id", "id")
b_id = _get(b, "chunk_id", "id")
if not a_id or not b_id:
continue
edges.append(_edge("next", "chunk", a_id, b_id, note_id, {
"chunk_id": a_id,
"edge_id": _mk_edge_id("next", a_id, b_id, "chunk", "structure:order"),
"provenance": "rule",
"rule_id": "structure:order",
"confidence": 0.95,
}))
edges.append(_edge("prev", "chunk", b_id, a_id, note_id, {
"chunk_id": b_id,
"edge_id": _mk_edge_id("prev", b_id, a_id, "chunk", "structure:order"),
"provenance": "rule",
"rule_id": "structure:order",
"confidence": 0.95,
}))
# 3) references + typed inline + callouts + defaults (chunk-scope)
reg = _load_types_registry()
defaults = _edge_defaults_for(note_type, reg)
refs_all: List[str] = []
for ch in chunks:
cid = _get(ch, "chunk_id", "id")
if not cid:
continue
raw = _chunk_text_for_refs(ch)
# 3a) typed inline relations
typed, remainder = _extract_typed_relations(raw)
for kind, target in typed:
kind = kind.strip().lower()
if not kind or not target:
continue
edges.append(_edge(kind, "chunk", cid, target, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id(kind, cid, target, "chunk", "inline:rel"),
"provenance": "explicit",
"rule_id": "inline:rel",
"confidence": 0.95,
}))
if kind in {"related_to", "similar_to"}:
edges.append(_edge(kind, "chunk", target, cid, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id(kind, target, cid, "chunk", "inline:rel"),
"provenance": "explicit",
"rule_id": "inline:rel",
"confidence": 0.95,
}))
# 3b) callouts
call_pairs, remainder2 = _extract_callout_relations(remainder)
for kind, target in call_pairs:
k = (kind or "").strip().lower()
if not k or not target:
continue
edges.append(_edge(k, "chunk", cid, target, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id(k, cid, target, "chunk", "callout:edge"),
"provenance": "explicit",
"rule_id": "callout:edge",
"confidence": 0.95,
}))
if k in {"related_to", "similar_to"}:
edges.append(_edge(k, "chunk", target, cid, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id(k, target, cid, "chunk", "callout:edge"),
"provenance": "explicit",
"rule_id": "callout:edge",
"confidence": 0.95,
}))
# 3c) generische Wikilinks → references (+ defaults je Ref)
refs = _extract_wikilinks(remainder2)
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"),
"provenance": "explicit",
"rule_id": "explicit:wikilink",
"confidence": 1.0,
}))
for rel in defaults:
if rel == "references":
continue
edges.append(_edge(rel, "chunk", cid, r, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id(rel, cid, r, "chunk", f"edge_defaults:{note_type}:{rel}"),
"provenance": "rule",
"rule_id": f"edge_defaults:{note_type}:{rel}",
"confidence": 0.7,
}))
if rel in {"related_to", "similar_to"}:
edges.append(_edge(rel, "chunk", r, cid, note_id, {
"chunk_id": cid,
"edge_id": _mk_edge_id(rel, r, cid, "chunk", f"edge_defaults:{note_type}:{rel}"),
"provenance": "rule",
"rule_id": f"edge_defaults:{note_type}:{rel}",
"confidence": 0.7,
}))
refs_all.extend(refs)
# 4) optional note-scope refs/backlinks (+ defaults)
if include_note_scope_refs:
refs_note = list(refs_all or [])
if note_level_references:
refs_note.extend([r for r in note_level_references if isinstance(r, str) and r])
refs_note = _dedupe_seq(refs_note)
for r in refs_note:
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",
"rule_id": "explicit:note_scope",
"confidence": 1.0,
}))
edges.append(_edge("backlink", "note", r, note_id, note_id, {
"edge_id": _mk_edge_id("backlink", r, note_id, "note", "derived:backlink"),
"provenance": "rule",
"rule_id": "derived:backlink",
"confidence": 0.9,
}))
for rel in defaults:
if rel == "references":
continue
edges.append(_edge(rel, "note", note_id, r, note_id, {
"edge_id": _mk_edge_id(rel, note_id, r, "note", f"edge_defaults:{note_type}:{rel}"),
"provenance": "rule",
"rule_id": f"edge_defaults:{note_type}:{rel}",
"confidence": 0.7,
}))
if rel in {"related_to", "similar_to"}:
edges.append(_edge(rel, "note", r, note_id, note_id, {
"edge_id": _mk_edge_id(rel, r, note_id, "note", f"edge_defaults:{note_type}:{rel}"),
"provenance": "rule",
"rule_id": f"edge_defaults:{note_type}:{rel}",
"confidence": 0.7,
}))
# 5) De-Dupe (source_id, target_id, relation, rule_id)
seen: Set[Tuple[str,str,str,str]] = set()
out: List[dict] = []
for e in edges:
s = str(e.get("source_id") or "")
t = str(e.get("target_id") or "")
rel = str(e.get("relation") or e.get("kind") or "edge")
rule = str(e.get("rule_id") or "")
key = (s, t, rel, rule)
if key in seen:
continue
seen.add(key)
out.append(e)
return out

View File

@ -0,0 +1,16 @@
"""
FILE: app/core/graph/__init__.py
DESCRIPTION: Unified Graph Package. Exportiert Kanten-Ableitung und Graph-Adapter.
"""
from .graph_derive_edges import build_edges_for_note
from .graph_utils import PROVENANCE_PRIORITY
from .graph_subgraph import Subgraph, expand
from .graph_weights import EDGE_BASE_WEIGHTS
__all__ = [
"build_edges_for_note",
"PROVENANCE_PRIORITY",
"Subgraph",
"expand",
"EDGE_BASE_WEIGHTS"
]

View File

@ -0,0 +1,63 @@
"""
FILE: app/core/graph/graph_db_adapter.py
DESCRIPTION: Datenbeschaffung aus Qdrant für den Graphen.
AUDIT v1.1.0: Nutzt nun die zentrale database-Infrastruktur für Namen.
"""
from typing import List, Dict, Optional
from qdrant_client import QdrantClient
from qdrant_client.http import models as rest
# ENTSCHEIDENDER FIX: Nutzt die neue Infrastruktur für konsistente Collection-Namen
from app.core.database import collection_names
def fetch_edges_from_qdrant(
client: QdrantClient,
prefix: str,
seeds: List[str],
edge_types: Optional[List[str]] = None,
limit: int = 2048,
) -> List[Dict]:
"""
Holt Edges aus der Datenbank basierend auf Seed-IDs.
Filtert auf source_id, target_id oder note_id.
"""
if not seeds or limit <= 0:
return []
# Konsistente Namensauflösung via database-Paket
_, _, edges_col = collection_names(prefix)
seed_conditions = []
for field in ("source_id", "target_id", "note_id"):
for s in seeds:
seed_conditions.append(
rest.FieldCondition(key=field, match=rest.MatchValue(value=str(s)))
)
seeds_filter = rest.Filter(should=seed_conditions) if seed_conditions else None
type_filter = None
if edge_types:
type_conds = [
rest.FieldCondition(key="kind", match=rest.MatchValue(value=str(k)))
for k in edge_types
]
type_filter = rest.Filter(should=type_conds)
must = []
if seeds_filter:
must.append(seeds_filter)
if type_filter:
must.append(type_filter)
flt = rest.Filter(must=must) if must else None
# Abfrage via Qdrant Scroll API
pts, _ = client.scroll(
collection_name=edges_col,
scroll_filter=flt,
limit=limit,
with_payload=True,
with_vectors=False,
)
return [dict(p.payload) for p in pts if p.payload]

View File

@ -0,0 +1,112 @@
"""
FILE: app/core/graph/graph_derive_edges.py
DESCRIPTION: Hauptlogik zur Kanten-Aggregation und De-Duplizierung.
"""
from typing import List, Optional, Dict, Tuple
from .graph_utils import (
_get, _edge, _mk_edge_id, _dedupe_seq,
PROVENANCE_PRIORITY, load_types_registry, get_edge_defaults_for
)
from .graph_extractors import (
extract_typed_relations, extract_callout_relations, extract_wikilinks
)
def build_edges_for_note(
note_id: str,
chunks: List[dict],
note_level_references: Optional[List[str]] = None,
include_note_scope_refs: bool = False,
) -> List[dict]:
"""Erzeugt und aggregiert alle Kanten für eine Note (WP-15b)."""
edges: List[dict] = []
note_type = _get(chunks[0], "type") if chunks else "concept"
# 1) Struktur-Kanten (belongs_to, next/prev)
for idx, ch in enumerate(chunks):
cid = _get(ch, "chunk_id", "id")
if not cid: continue
edges.append(_edge("belongs_to", "chunk", cid, note_id, note_id, {
"chunk_id": cid, "edge_id": _mk_edge_id("belongs_to", cid, note_id, "chunk", "structure:belongs_to"),
"provenance": "structure", "rule_id": "structure:belongs_to", "confidence": PROVENANCE_PRIORITY["structure:belongs_to"]
}))
if idx < len(chunks) - 1:
next_id = _get(chunks[idx+1], "chunk_id", "id")
if next_id:
edges.append(_edge("next", "chunk", cid, next_id, note_id, {
"chunk_id": cid, "edge_id": _mk_edge_id("next", cid, next_id, "chunk", "structure:order"),
"provenance": "structure", "rule_id": "structure:order", "confidence": PROVENANCE_PRIORITY["structure:order"]
}))
edges.append(_edge("prev", "chunk", next_id, cid, note_id, {
"chunk_id": next_id, "edge_id": _mk_edge_id("prev", next_id, cid, "chunk", "structure:order"),
"provenance": "structure", "rule_id": "structure:order", "confidence": PROVENANCE_PRIORITY["structure:order"]
}))
# 2) Inhaltliche Kanten
reg = load_types_registry()
defaults = get_edge_defaults_for(note_type, reg)
refs_all: List[str] = []
for ch in chunks:
cid = _get(ch, "chunk_id", "id")
if not cid: continue
raw = _get(ch, "window") or _get(ch, "text") or ""
# 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"),
"provenance": "explicit", "rule_id": "inline:rel", "confidence": PROVENANCE_PRIORITY["inline:rel"]
}))
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")
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}"),
"provenance": p, "rule_id": f"candidate:{p}", "confidence": PROVENANCE_PRIORITY.get(p, 0.90)
}))
# 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"),
"provenance": "explicit", "rule_id": "callout:edge", "confidence": PROVENANCE_PRIORITY["callout:edge"]
}))
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"),
"provenance": "explicit", "rule_id": "explicit:wikilink", "confidence": PROVENANCE_PRIORITY["explicit:wikilink"]
}))
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}"),
"provenance": "rule", "rule_id": f"edge_defaults:{rel}", "confidence": PROVENANCE_PRIORITY["edge_defaults"]
}))
refs_all.extend(refs)
# 3) Note-Scope & De-Duplizierung
if include_note_scope_refs:
refs_note = _dedupe_seq((refs_all or []) + (note_level_references or []))
for r in refs_note:
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"]
}))
edges.append(_edge("backlink", "note", r, note_id, note_id, {
"edge_id": _mk_edge_id("backlink", r, note_id, "note", "derived:backlink"),
"provenance": "rule", "confidence": PROVENANCE_PRIORITY["derived:backlink"]
}))
unique_map: Dict[Tuple[str, str, 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
return list(unique_map.values())

View File

@ -0,0 +1,55 @@
"""
FILE: app/core/graph/graph_extractors.py
DESCRIPTION: Regex-basierte Extraktion von Relationen aus Text.
"""
import re
from typing import List, Tuple
_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([a-zA-Z0-9_\-#:. ]+)\]\]")
_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)
_REL_LINE = re.compile(r"^(?P<kind>[a-z_]+)\s*:\s*(?P<targets>.+?)\s*$", re.IGNORECASE)
_WIKILINKS_IN_LINE = re.compile(r"\[\[([^\]]+)\]\]")
def extract_typed_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
"""Extrahiert [[rel:KIND|Target]]."""
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))
return ""
text = _REL_PIPE.sub(_collect, text)
text = _REL_SPACE.sub(_collect, text)
text = _REL_TEXT.sub(_collect, text)
return pairs, text
def extract_callout_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
"""Verarbeitet Obsidian [!edge]-Callouts."""
if not text: return [], text
lines = text.splitlines(); out_pairs, keep_lines, i = [], [], 0
while i < len(lines):
m = _CALLOUT_START.match(lines[i])
if not m:
keep_lines.append(lines[i]); i += 1; continue
block_lines = [m.group(1)] if m.group(1).strip() else []
i += 1
while i < len(lines) and lines[i].lstrip().startswith('>'):
block_lines.append(lines[i].lstrip()[1:].lstrip()); i += 1
for bl in block_lines:
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 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()))
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 "")]

View File

@ -0,0 +1,129 @@
"""
FILE: app/core/graph/graph_subgraph.py
DESCRIPTION: In-Memory Repräsentation eines Graphen für Scoring und Analyse.
Zentrale Komponente für die Graph-Expansion (BFS) und Bonus-Berechnung.
MODULARISIERUNG: Teil des graph-Pakets (WP-14).
VERSION: 1.1.0
STATUS: Active
"""
import math
from collections import defaultdict
from typing import Dict, List, Optional, DefaultDict, Any, Set
from qdrant_client import QdrantClient
# Lokale Paket-Imports
from .graph_weights import EDGE_BASE_WEIGHTS, calculate_edge_weight
from .graph_db_adapter import fetch_edges_from_qdrant
class Subgraph:
"""
Leichtgewichtiger Subgraph mit Adjazenzlisten & Kennzahlen.
Wird für die Berechnung von Graph-Boni im Retriever genutzt.
"""
def __init__(self) -> None:
self.adj: DefaultDict[str, List[Dict]] = defaultdict(list)
self.reverse_adj: DefaultDict[str, List[Dict]] = defaultdict(list)
self.in_degree: DefaultDict[str, int] = defaultdict(int)
self.out_degree: DefaultDict[str, int] = defaultdict(int)
def add_edge(self, e: Dict) -> None:
"""
Fügt eine Kante hinzu und aktualisiert Indizes.
Unterstützt Kontext-Notes für verbesserte Graph-Konnektivität.
"""
src = e.get("source")
tgt = e.get("target")
kind = e.get("kind")
weight = e.get("weight", EDGE_BASE_WEIGHTS.get(kind, 0.0))
owner = e.get("note_id")
if not src or not tgt:
return
# 1. Forward-Kante
self.adj[src].append({"target": tgt, "kind": kind, "weight": weight})
self.out_degree[src] += 1
self.in_degree[tgt] += 1
# 2. Reverse-Kante (für WP-04b Explanation Layer)
self.reverse_adj[tgt].append({"source": src, "kind": kind, "weight": weight})
# 3. Kontext-Note Handling (erhöht die Zentralität der Parent-Note)
if owner and owner != src:
self.adj[owner].append({"target": tgt, "kind": kind, "weight": weight})
self.out_degree[owner] += 1
if owner != tgt:
self.reverse_adj[tgt].append({"source": owner, "kind": kind, "weight": weight, "via_context": True})
self.in_degree[owner] += 1
def aggregate_edge_bonus(self, node_id: str) -> float:
"""Summe der ausgehenden Kantengewichte (Hub-Score)."""
return sum(edge["weight"] for edge in self.adj.get(node_id, []))
def edge_bonus(self, node_id: str) -> float:
"""API für Retriever (WP-04a Kompatibilität)."""
return self.aggregate_edge_bonus(node_id)
def centrality_bonus(self, node_id: str) -> float:
"""
Log-gedämpfte Zentralität basierend auf dem In-Degree.
Begrenzt auf einen maximalen Boost von 0.15.
"""
indeg = self.in_degree.get(node_id, 0)
if indeg <= 0:
return 0.0
return min(math.log1p(indeg) / 10.0, 0.15)
def get_outgoing_edges(self, node_id: str) -> List[Dict[str, Any]]:
"""Gibt alle ausgehenden Kanten einer Node zurück."""
return self.adj.get(node_id, [])
def get_incoming_edges(self, node_id: str) -> List[Dict[str, Any]]:
"""Gibt alle eingehenden Kanten einer Node zurück."""
return self.reverse_adj.get(node_id, [])
def expand(
client: QdrantClient,
prefix: str,
seeds: List[str],
depth: int = 1,
edge_types: Optional[List[str]] = None,
) -> Subgraph:
"""
Expandiert ab Seeds entlang von Edges bis zu einer bestimmten Tiefe.
Nutzt fetch_edges_from_qdrant für den Datenbankzugriff.
"""
sg = Subgraph()
frontier = set(seeds)
visited = set()
for _ in range(max(depth, 0)):
if not frontier:
break
# Batch-Abfrage der Kanten für die aktuelle Ebene
payloads = fetch_edges_from_qdrant(client, prefix, list(frontier), edge_types)
next_frontier: Set[str] = set()
for pl in payloads:
src, tgt = pl.get("source_id"), pl.get("target_id")
if not src or not tgt: continue
sg.add_edge({
"source": src,
"target": tgt,
"kind": pl.get("kind", "edge"),
"weight": calculate_edge_weight(pl),
"note_id": pl.get("note_id"),
})
# BFS Logik: Neue Ziele in die nächste Frontier aufnehmen
if tgt not in visited:
next_frontier.add(str(tgt))
visited |= frontier
frontier = next_frontier - visited
return sg

View File

@ -0,0 +1,81 @@
"""
FILE: app/core/graph/graph_utils.py
DESCRIPTION: Basale Werkzeuge, ID-Generierung und Provenance-Konfiguration für den Graphen.
"""
import os
import hashlib
from typing import Iterable, List, Optional, Set, Any
try:
import yaml
except ImportError:
yaml = None
# WP-15b: Prioritäten-Ranking für die De-Duplizierung
PROVENANCE_PRIORITY = {
"explicit:wikilink": 1.00,
"inline:rel": 0.95,
"callout:edge": 0.90,
"semantic_ai": 0.90, # Validierte KI-Kanten
"structure:belongs_to": 1.00,
"structure:order": 0.95, # next/prev
"explicit:note_scope": 1.00,
"derived:backlink": 0.90,
"edge_defaults": 0.70 # Heuristik (types.yaml)
}
def _get(d: dict, *keys, default=None):
"""Sicherer Zugriff auf verschachtelte Keys."""
for k in keys:
if isinstance(d, dict) and k in d and d[k] is not None:
return d[k]
return default
def _dedupe_seq(seq: Iterable[str]) -> List[str]:
"""Dedupliziert Strings unter Beibehaltung der Reihenfolge."""
seen: Set[str] = set()
out: List[str] = []
for s in seq:
if s not in seen:
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."""
base = f"{kind}:{s}->{t}#{scope}"
if rule_id: base += f"|{rule_id}"
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:
"""Konstruiert ein Kanten-Payload für Qdrant."""
pl = {
"kind": kind,
"relation": kind,
"scope": scope,
"source_id": source_id,
"target_id": target_id,
"note_id": note_id,
}
if extra: pl.update(extra)
return pl
def load_types_registry() -> dict:
"""Lädt die YAML-Registry."""
p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml")
if not os.path.isfile(p) or yaml is None: return {}
try:
with open(p, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {}
except Exception: return {}
def get_edge_defaults_for(note_type: Optional[str], reg: dict) -> List[str]:
"""Ermittelt Standard-Kanten für einen Typ."""
types_map = reg.get("types", reg) if isinstance(reg, dict) else {}
if note_type and isinstance(types_map, dict):
t = types_map.get(note_type)
if isinstance(t, dict) and isinstance(t.get("edge_defaults"), list):
return [str(x) for x in t["edge_defaults"] if isinstance(x, str)]
for key in ("defaults", "default", "global"):
v = reg.get(key)
if isinstance(v, dict) and isinstance(v.get("edge_defaults"), list):
return [str(x) for x in v["edge_defaults"] if isinstance(x, str)]
return []

View File

@ -0,0 +1,39 @@
"""
FILE: app/core/graph/graph_weights.py
DESCRIPTION: Definition der Basisgewichte und Berechnung der Kanteneffektivität.
"""
from typing import Dict
# Basisgewichte je Edge-Typ (WP-04a Config)
EDGE_BASE_WEIGHTS: Dict[str, float] = {
# Struktur
"belongs_to": 0.10,
"next": 0.06,
"prev": 0.06,
"backlink": 0.04,
"references_at": 0.08,
# Wissen
"references": 0.20,
"depends_on": 0.18,
"related_to": 0.15,
"similar_to": 0.12,
}
def calculate_edge_weight(pl: Dict) -> float:
"""Berechnet das effektive Edge-Gewicht aus kind + confidence."""
kind = pl.get("kind", "edge")
base = EDGE_BASE_WEIGHTS.get(kind, 0.0)
conf_raw = pl.get("confidence", None)
try:
conf = float(conf_raw) if conf_raw is not None else None
except Exception:
conf = None
if conf is None:
return base
# Clamp confidence 0.0 - 1.0
conf = max(0.0, min(1.0, conf))
return base * conf

View File

@ -1,249 +1,10 @@
""" """
FILE: app/core/graph_adapter.py FILE: app/core/graph_adapter.py
DESCRIPTION: Lädt Kanten aus Qdrant und baut einen In-Memory Subgraphen für Scoring (Centrality) und Explanation. DESCRIPTION: Facade für das neue graph Package (Adapter-Teil).
VERSION: 0.4.0 WP-14: Modularisierung abgeschlossen.
STATUS: Active VERSION: 0.5.0
DEPENDENCIES: qdrant_client, app.core.qdrant
LAST_ANALYSIS: 2025-12-15
""" """
from .graph.graph_subgraph import Subgraph, expand
from .graph.graph_weights import EDGE_BASE_WEIGHTS
from __future__ import annotations __all__ = ["Subgraph", "expand", "EDGE_BASE_WEIGHTS"]
from typing import Dict, List, Optional, DefaultDict, Any
from collections import defaultdict
from qdrant_client import QdrantClient
from qdrant_client.http import models as rest
from app.core.qdrant import collection_names
# Legacy-Import Fallback
try: # pragma: no cover
from app.core.qdrant_points import get_edges_for_sources # type: ignore
except Exception: # pragma: no cover
get_edges_for_sources = None # type: ignore
# Basisgewichte je Edge-Typ (WP-04a Config)
EDGE_BASE_WEIGHTS: Dict[str, float] = {
# Struktur
"belongs_to": 0.10,
"next": 0.06,
"prev": 0.06,
"backlink": 0.04,
"references_at": 0.08,
# Wissen
"references": 0.20,
"depends_on": 0.18,
"related_to": 0.15,
"similar_to": 0.12,
}
def _edge_weight(pl: Dict) -> float:
"""Berechnet das effektive Edge-Gewicht aus kind + confidence."""
kind = pl.get("kind", "edge")
base = EDGE_BASE_WEIGHTS.get(kind, 0.0)
conf_raw = pl.get("confidence", None)
try:
conf = float(conf_raw) if conf_raw is not None else None
except Exception:
conf = None
if conf is None:
return base
if conf < 0.0: conf = 0.0
if conf > 1.0: conf = 1.0
return base * conf
def _fetch_edges(
client: QdrantClient,
prefix: str,
seeds: List[str],
edge_types: Optional[List[str]] = None,
limit: int = 2048,
) -> List[Dict]:
"""
Holt Edges direkt aus der *_edges Collection.
Filter: source_id IN seeds OR target_id IN seeds OR note_id IN seeds
"""
if not seeds or limit <= 0:
return []
_, _, edges_col = collection_names(prefix)
seed_conditions = []
for field in ("source_id", "target_id", "note_id"):
for s in seeds:
seed_conditions.append(
rest.FieldCondition(key=field, match=rest.MatchValue(value=str(s)))
)
seeds_filter = rest.Filter(should=seed_conditions) if seed_conditions else None
type_filter = None
if edge_types:
type_conds = [
rest.FieldCondition(key="kind", match=rest.MatchValue(value=str(k)))
for k in edge_types
]
type_filter = rest.Filter(should=type_conds)
must = []
if seeds_filter: must.append(seeds_filter)
if type_filter: must.append(type_filter)
flt = rest.Filter(must=must) if must else None
pts, _ = client.scroll(
collection_name=edges_col,
scroll_filter=flt,
limit=limit,
with_payload=True,
with_vectors=False,
)
out: List[Dict] = []
for p in pts or []:
pl = dict(p.payload or {})
if pl:
out.append(pl)
return out
class Subgraph:
"""Leichtgewichtiger Subgraph mit Adjazenzlisten & Kennzahlen."""
def __init__(self) -> None:
# Forward: source -> [targets]
self.adj: DefaultDict[str, List[Dict]] = defaultdict(list)
# Reverse: target -> [sources] (Neu für WP-04b Explanation)
self.reverse_adj: DefaultDict[str, List[Dict]] = defaultdict(list)
self.in_degree: DefaultDict[str, int] = defaultdict(int)
self.out_degree: DefaultDict[str, int] = defaultdict(int)
def add_edge(self, e: Dict) -> None:
"""
Fügt eine Kante hinzu und aktualisiert Forward/Reverse Indizes.
e muss enthalten: source, target, kind, weight.
"""
src = e.get("source")
tgt = e.get("target")
kind = e.get("kind")
weight = e.get("weight", EDGE_BASE_WEIGHTS.get(kind, 0.0))
owner = e.get("note_id")
if not src or not tgt:
return
# 1. Primäre Adjazenz (Forward)
edge_data = {"target": tgt, "kind": kind, "weight": weight}
self.adj[src].append(edge_data)
self.out_degree[src] += 1
self.in_degree[tgt] += 1
# 2. Reverse Adjazenz (Neu für Explanation)
# Wir speichern, woher die Kante kam.
rev_data = {"source": src, "kind": kind, "weight": weight}
self.reverse_adj[tgt].append(rev_data)
# 3. Kontext-Note Handling (Forward & Reverse)
# Wenn eine Kante "im Kontext einer Note" (owner) definiert ist,
# schreiben wir sie der Note gut, damit der Retriever Scores auf Note-Ebene findet.
if owner and owner != src:
# Forward: Owner -> Target
self.adj[owner].append(edge_data)
self.out_degree[owner] += 1
# Reverse: Target wird vom Owner referenziert (indirekt)
if owner != tgt:
rev_owner_data = {"source": owner, "kind": kind, "weight": weight, "via_context": True}
self.reverse_adj[tgt].append(rev_owner_data)
self.in_degree[owner] += 1 # Leichter Centrality Boost für den Owner
def aggregate_edge_bonus(self, node_id: str) -> float:
"""Summe der ausgehenden Kantengewichte (Hub-Score)."""
return sum(edge["weight"] for edge in self.adj.get(node_id, []))
def edge_bonus(self, node_id: str) -> float:
"""API für Retriever (WP-04a Kompatibilität)."""
return self.aggregate_edge_bonus(node_id)
def centrality_bonus(self, node_id: str) -> float:
"""Log-gedämpfte Zentralität (In-Degree)."""
import math
indeg = self.in_degree.get(node_id, 0)
if indeg <= 0:
return 0.0
return min(math.log1p(indeg) / 10.0, 0.15)
# --- WP-04b Explanation Helpers ---
def get_outgoing_edges(self, node_id: str) -> List[Dict[str, Any]]:
"""Liefert Liste aller Ziele, auf die dieser Knoten zeigt."""
return self.adj.get(node_id, [])
def get_incoming_edges(self, node_id: str) -> List[Dict[str, Any]]:
"""Liefert Liste aller Quellen, die auf diesen Knoten zeigen."""
return self.reverse_adj.get(node_id, [])
def expand(
client: QdrantClient,
prefix: str,
seeds: List[str],
depth: int = 1,
edge_types: Optional[List[str]] = None,
) -> Subgraph:
"""
Expandiert ab Seeds entlang von Edges (bis `depth`).
"""
sg = Subgraph()
frontier = set(seeds)
visited = set()
max_depth = max(depth, 0)
for _ in range(max_depth):
if not frontier:
break
edges_payloads = _fetch_edges(
client=client,
prefix=prefix,
seeds=list(frontier),
edge_types=edge_types,
limit=2048,
)
next_frontier = set()
for pl in edges_payloads:
src = pl.get("source_id")
tgt = pl.get("target_id")
# Skip invalid edges
if not src or not tgt:
continue
e = {
"source": src,
"target": tgt,
"kind": pl.get("kind", "edge"),
"weight": _edge_weight(pl),
"note_id": pl.get("note_id"),
}
sg.add_edge(e)
# Nur weitersuchen, wenn Target noch nicht besucht
if tgt and tgt not in visited:
next_frontier.add(tgt)
visited |= frontier
frontier = next_frontier - visited
return sg

View File

@ -1,390 +0,0 @@
"""
FILE: app/core/ingestion.py
DESCRIPTION: Haupt-Ingestion-Logik. Transformiert Markdown in den Graphen.
WP-20: Optimiert für OpenRouter (mistralai/mistral-7b-instruct:free).
WP-22: Content Lifecycle, Edge Registry Validation & Multi-Hash.
FIX: Deep Fallback Logic (v2.11.14). Erkennt Policy Violations auch in validen
JSON-Objekten und erzwingt den lokalen Ollama-Sprung, um Kantenverlust
bei umfangreichen Protokollen zu verhindern.
VERSION: 2.11.14
STATUS: Active
DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.services.llm_service, app.services.edge_registry
"""
import os
import json
import re
import logging
import asyncio
import time
from typing import Dict, List, Optional, Tuple, Any
# Core Module Imports
from app.core.parser import (
read_markdown,
normalize_frontmatter,
validate_required_frontmatter,
extract_edges_with_context,
)
from app.core.note_payload import make_note_payload
from app.core.chunker import assemble_chunks, get_chunk_config
from app.core.chunk_payload import make_chunk_payloads
# Fallback für Edges
try:
from app.core.derive_edges import build_edges_for_note
except ImportError:
def build_edges_for_note(*args, **kwargs): return []
from app.core.qdrant import QdrantConfig, get_client, ensure_collections, ensure_payload_indexes
from app.core.qdrant_points import (
points_for_chunks,
points_for_note,
points_for_edges,
upsert_batch,
)
from app.services.embeddings_client import EmbeddingsClient
from app.services.edge_registry import registry as edge_registry
from app.services.llm_service import LLMService
logger = logging.getLogger(__name__)
# --- Global Helpers ---
def extract_json_from_response(text: str) -> Any:
"""
Extrahiert JSON-Daten und bereinigt LLM-Steuerzeichen (Mistral/Llama).
Entfernt <s>, [OUT], [/OUT] und Markdown-Blöcke für maximale Robustheit.
"""
if not text or not isinstance(text, str):
return []
# 1. Entferne Mistral/Llama Steuerzeichen und Tags
clean = text.replace("<s>", "").replace("</s>", "")
clean = clean.replace("[OUT]", "").replace("[/OUT]", "")
clean = clean.strip()
# 2. Suche nach Markdown JSON-Blöcken (```json ... ```)
match = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL)
payload = match.group(1) if match else clean
try:
return json.loads(payload.strip())
except json.JSONDecodeError:
# 3. Recovery: Suche nach der ersten [ und letzten ] (Liste)
start = payload.find('[')
end = payload.rfind(']') + 1
if start != -1 and end > start:
try:
return json.loads(payload[start:end])
except: pass
# 4. Zweite Recovery: Suche nach der ersten { und letzten } (Objekt)
start_obj = payload.find('{')
end_obj = payload.rfind('}') + 1
if start_obj != -1 and end_obj > start_obj:
try:
return json.loads(payload[start_obj:end_obj])
except: pass
return []
def load_type_registry(custom_path: Optional[str] = None) -> dict:
"""Lädt die types.yaml zur Steuerung der typ-spezifischen Ingestion."""
import yaml
from app.config import get_settings
settings = get_settings()
path = custom_path or settings.MINDNET_TYPES_FILE
if not os.path.exists(path): return {}
try:
with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {}
except Exception: return {}
# --- Service Class ---
class IngestionService:
def __init__(self, collection_prefix: str = None):
from app.config import get_settings
self.settings = get_settings()
self.prefix = collection_prefix or self.settings.COLLECTION_PREFIX
self.cfg = QdrantConfig.from_env()
self.cfg.prefix = self.prefix
self.client = get_client(self.cfg)
self.dim = self.settings.VECTOR_SIZE
self.registry = load_type_registry()
self.embedder = EmbeddingsClient()
self.llm = LLMService()
self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE
try:
ensure_collections(self.client, self.prefix, self.dim)
ensure_payload_indexes(self.client, self.prefix)
except Exception as e:
logger.warning(f"DB init warning: {e}")
def _resolve_note_type(self, requested: Optional[str]) -> str:
"""Bestimmt den finalen Notiz-Typ (Fallback auf 'concept')."""
types = self.registry.get("types", {})
if requested and requested in types: return requested
return "concept"
def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]:
"""Holt die Chunker-Parameter für ein spezifisches Profil aus der Registry."""
profiles = self.registry.get("chunking_profiles", {})
if profile_name in profiles:
cfg = profiles[profile_name].copy()
if "overlap" in cfg and isinstance(cfg["overlap"], list):
cfg["overlap"] = tuple(cfg["overlap"])
return cfg
return get_chunk_config(note_type)
async def _perform_smart_edge_allocation(self, text: str, note_id: str) -> List[Dict]:
"""
KI-Extraktion mit Deep-Fallback Logik.
Erzwingt den lokalen Ollama-Sprung, wenn die Cloud-Antwort keine verwertbaren
Kanten liefert (häufig bei Policy Violations auf OpenRouter).
"""
provider = self.settings.MINDNET_LLM_PROVIDER
model = self.settings.OPENROUTER_MODEL if provider == "openrouter" else self.settings.GEMINI_MODEL
logger.info(f"🚀 [Ingestion] Turbo-Mode: Extracting edges for '{note_id}' using {model} on {provider}")
edge_registry.ensure_latest()
valid_types_str = ", ".join(sorted(list(edge_registry.valid_types)))
template = self.llm.get_prompt("edge_extraction", provider)
try:
try:
# Wir begrenzen den Kontext auf 6000 Zeichen (ca. 1500 Token)
prompt = template.format(
text=text[:6000],
note_id=note_id,
valid_types=valid_types_str
)
except KeyError as ke:
logger.error(f"❌ [Ingestion] Prompt-Template Fehler (Variable {ke} fehlt).")
return []
# 1. Versuch: Anfrage an den primären Cloud-Provider
response_json = await self.llm.generate_raw_response(
prompt=prompt, priority="background", force_json=True,
provider=provider, model_override=model
)
# Initiales Parsing
raw_data = extract_json_from_response(response_json)
# 2. Dictionary Recovery (Versuche Liste aus Dict zu extrahieren)
candidates = []
if isinstance(raw_data, list):
candidates = raw_data
elif isinstance(raw_data, dict):
logger.info(f" [Ingestion] LLM returned dict, checking for embedded lists in {note_id}")
for k in ["edges", "links", "results", "kanten", "matches", "edge_list"]:
if k in raw_data and isinstance(raw_data[k], list):
candidates = raw_data[k]
break
# Wenn immer noch keine Liste gefunden, versuche Key-Value Paare (Dict Recovery)
if not candidates:
for k, v in raw_data.items():
if isinstance(v, str): candidates.append(f"{k}:{v}")
elif isinstance(v, list): [candidates.append(f"{k}:{i}") for i in v if isinstance(i, str)]
# 3. DEEP FALLBACK: Wenn nach allen Recovery-Versuchen die Liste leer ist UND wir in der Cloud waren
# Triggert den Fallback bei "Data Policy Violations" (leere oder Fehler-JSONs).
if not candidates and provider != "ollama" and self.settings.LLM_FALLBACK_ENABLED:
logger.warning(
f"🛑 [Ingestion] Cloud-Antwort für {note_id} lieferte keine verwertbaren Kanten. "
f"Mögliche Policy Violation oder Refusal. Erzwinge LOKALEN FALLBACK via Ollama..."
)
response_json_local = await self.llm.generate_raw_response(
prompt=prompt, priority="background", force_json=True, provider="ollama"
)
raw_data_local = extract_json_from_response(response_json_local)
# Wiederhole Recovery für lokale Antwort
if isinstance(raw_data_local, list):
candidates = raw_data_local
elif isinstance(raw_data_local, dict):
for k in ["edges", "links", "results"]:
if k in raw_data_local and isinstance(raw_data_local[k], list):
candidates = raw_data_local[k]; break
if not candidates:
logger.warning(f"⚠️ [Ingestion] Auch nach Fallback keine extrahierbaren Kanten für {note_id}")
return []
processed = []
for item in candidates:
if isinstance(item, dict) and "to" in item:
item["provenance"] = "semantic_ai"
item["line"] = f"ai-{provider}"
processed.append(item)
elif isinstance(item, str) and ":" in item:
parts = item.split(":", 1)
processed.append({
"to": parts[1].strip(),
"kind": parts[0].strip(),
"provenance": "semantic_ai",
"line": f"ai-{provider}"
})
return processed
except Exception as e:
logger.warning(f"⚠️ [Ingestion] Smart Edge Allocation failed for {note_id}: {e}")
return []
async def process_file(
self, file_path: str, vault_root: str,
force_replace: bool = False, apply: bool = False, purge_before: bool = False,
note_scope_refs: bool = False, hash_source: str = "parsed", hash_normalize: str = "canonical"
) -> Dict[str, Any]:
"""Transformiert eine Markdown-Datei in den Graphen (Notes, Chunks, Edges)."""
result = {"path": file_path, "status": "skipped", "changed": False, "error": None}
# 1. Parse & Lifecycle Gate
try:
parsed = read_markdown(file_path)
if not parsed: return {**result, "error": "Empty file"}
fm = normalize_frontmatter(parsed.frontmatter)
validate_required_frontmatter(fm)
except Exception as e:
return {**result, "error": f"Validation failed: {str(e)}"}
# WP-22: Filter für Systemdateien und Entwürfe
status = fm.get("status", "draft").lower().strip()
if status in ["system", "template", "archive", "hidden"]:
return {**result, "status": "skipped", "reason": f"lifecycle_{status}"}
# 2. Config Resolution & Payload Construction
note_type = self._resolve_note_type(fm.get("type"))
fm["type"] = note_type
try:
note_pl = make_note_payload(parsed, vault_root=vault_root, hash_normalize=hash_normalize, hash_source=hash_source, file_path=file_path)
note_id = note_pl["note_id"]
except Exception as e:
return {**result, "error": f"Payload failed: {str(e)}"}
# 3. Change Detection (Strikte DoD Umsetzung)
old_payload = None if force_replace else self._fetch_note_payload(note_id)
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)
# Prüfung auf fehlende Artefakte in Qdrant
chunks_missing, edges_missing = self._artifacts_missing(note_id)
should_write = force_replace or (not old_payload) or (old_hash != new_hash) or chunks_missing or edges_missing
if not should_write:
return {**result, "status": "unchanged", "note_id": note_id}
if not apply:
return {**result, "status": "dry-run", "changed": True, "note_id": note_id}
# 4. Processing (Chunking, Embedding, AI Edges)
try:
body_text = getattr(parsed, "body", "") or ""
edge_registry.ensure_latest()
# Profil-gesteuertes Chunking
profile = fm.get("chunk_profile") or fm.get("chunking_profile") or "sliding_standard"
chunk_cfg = self._get_chunk_config_by_profile(profile, note_type)
chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_cfg)
chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text)
# Vektorisierung
vecs = []
if chunk_pls:
texts = [c.get("window") or c.get("text") or "" for c in chunk_pls]
vecs = await self.embedder.embed_documents(texts)
# Kanten-Extraktion
edges = []
context = {"file": file_path, "note_id": note_id}
# A. Explizite Kanten (User / Wikilinks)
for e in extract_edges_with_context(parsed):
e["kind"] = edge_registry.resolve(edge_type=e["kind"], provenance="explicit", context={**context, "line": e.get("line")})
edges.append(e)
# B. KI Kanten (Turbo Mode mit v2.11.14 Fallback)
ai_edges = await self._perform_smart_edge_allocation(body_text, note_id)
for e in ai_edges:
valid_kind = edge_registry.resolve(edge_type=e.get("kind"), provenance="semantic_ai", context={**context, "line": e.get("line")})
e["kind"] = valid_kind
edges.append(e)
# C. System Kanten (Struktur)
try:
sys_edges = build_edges_for_note(note_id, chunk_pls, note_level_references=note_pl.get("references", []), include_note_scope_refs=note_scope_refs)
except:
sys_edges = build_edges_for_note(note_id, chunk_pls)
for e in sys_edges:
valid_kind = edge_registry.resolve(edge_type=e.get("kind", "belongs_to"), provenance="structure", context={**context, "line": "system"})
if valid_kind:
e["kind"] = valid_kind
edges.append(e)
except Exception as e:
logger.error(f"Processing failed for {file_path}: {e}", exc_info=True)
return {**result, "error": f"Processing failed: {str(e)}"}
# 5. DB Upsert
try:
if purge_before and old_payload: self._purge_artifacts(note_id)
n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim)
upsert_batch(self.client, n_name, n_pts)
if chunk_pls and vecs:
c_name, c_pts = points_for_chunks(self.prefix, chunk_pls, vecs)
upsert_batch(self.client, c_name, c_pts)
if edges:
e_name, e_pts = points_for_edges(self.prefix, edges)
upsert_batch(self.client, e_name, e_pts)
return {"path": file_path, "status": "success", "changed": True, "note_id": note_id, "chunks_count": len(chunk_pls), "edges_count": len(edges)}
except Exception as e:
return {**result, "error": f"DB Upsert failed: {e}"}
def _fetch_note_payload(self, note_id: str) -> Optional[dict]:
"""Holt die Metadaten einer Note aus Qdrant."""
from qdrant_client.http import models as rest
try:
f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))])
pts, _ = self.client.scroll(collection_name=f"{self.prefix}_notes", scroll_filter=f, limit=1, with_payload=True)
return pts[0].payload if pts else None
except: return None
def _artifacts_missing(self, note_id: str) -> Tuple[bool, bool]:
"""Prüft Qdrant aktiv auf vorhandene Chunks und Edges."""
from qdrant_client.http import models as rest
try:
f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))])
c_pts, _ = self.client.scroll(collection_name=f"{self.prefix}_chunks", scroll_filter=f, limit=1)
e_pts, _ = self.client.scroll(collection_name=f"{self.prefix}_edges", scroll_filter=f, limit=1)
return (not bool(c_pts)), (not bool(e_pts))
except: return True, True
def _purge_artifacts(self, note_id: str):
"""Löscht verwaiste Chunks/Edges vor einem Re-Import."""
from qdrant_client.http import models as rest
f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))])
for suffix in ["chunks", "edges"]:
try: self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=rest.FilterSelector(filter=f))
except: pass
async def create_from_text(self, markdown_content: str, filename: str, vault_root: str, folder: str = "00_Inbox") -> Dict[str, Any]:
"""Hilfsmethode zur Erstellung einer Note aus einem Textstream."""
target_dir = os.path.join(vault_root, folder)
os.makedirs(target_dir, exist_ok=True)
file_path = os.path.join(target_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
f.write(markdown_content)
await asyncio.sleep(0.1)
return await self.process_file(file_path=file_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True)

View File

@ -0,0 +1,26 @@
"""
FILE: app/core/ingestion/__init__.py
DESCRIPTION: Package-Einstiegspunkt für Ingestion. Exportiert den IngestionService.
AUDIT v2.13.10: Abschluss der Modularisierung (WP-14).
Bricht Zirkelbezüge durch Nutzung der neutralen registry.py auf.
VERSION: 2.13.10
"""
# Der IngestionService ist der primäre Orchestrator für den Datenimport
from .ingestion_processor import IngestionService
# Hilfswerkzeuge für JSON-Verarbeitung und Konfigurations-Management
# load_type_registry wird hier re-exportiert, um die Abwärtskompatibilität zu wahren,
# obwohl die Implementierung nun in app.core.registry liegt.
from .ingestion_utils import (
extract_json_from_response,
load_type_registry,
resolve_note_type
)
# Öffentliche API des Pakets
__all__ = [
"IngestionService",
"extract_json_from_response",
"load_type_registry",
"resolve_note_type"
]

View File

@ -0,0 +1,114 @@
"""
FILE: app/core/ingestion/ingestion_chunk_payload.py
DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'.
Fix v2.4.3: Integration der zentralen Registry (WP-14) für konsistente Defaults.
VERSION: 2.4.3
STATUS: Active
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
# ENTSCHEIDENDER FIX: Import der neutralen Registry-Logik zur Vermeidung von Circular Imports
from app.core.registry import load_type_registry
# ---------------------------------------------------------------------------
# Resolution Helpers (Audited)
# ---------------------------------------------------------------------------
def _as_list(x):
"""Sichert die Listen-Integrität für Metadaten wie Tags."""
if x is None: return []
return x if isinstance(x, list) else [x]
def _resolve_val(note_type: str, reg: dict, key: str, default: Any) -> Any:
"""
Hierarchische Suche in der Registry: Type-Spezifisch > Globaler Default.
WP-14: Erlaubt dynamische Konfiguration via types.yaml.
"""
types = reg.get("types", {})
if isinstance(types, dict):
t_cfg = types.get(note_type, {})
if isinstance(t_cfg, dict):
# Fallback für Key-Varianten (z.B. chunking_profile vs chunk_profile)
val = t_cfg.get(key) or t_cfg.get(key.replace("ing", ""))
if val is not None: return val
defs = reg.get("defaults", {}) or reg.get("global", {})
if isinstance(defs, dict):
val = defs.get(key) or defs.get(key.replace("ing", ""))
if val is not None: return val
return default
# ---------------------------------------------------------------------------
# Haupt-API
# ---------------------------------------------------------------------------
def make_chunk_payloads(note: Dict[str, Any], note_path: str, chunks_from_chunker: List[Any], **kwargs) -> List[Dict[str, Any]]:
"""
Erstellt die Payloads für die Chunks inklusive Audit-Resolution.
Nutzt nun die zentrale Registry für alle Fallbacks.
"""
if isinstance(note, dict) and "frontmatter" in note:
fm = note["frontmatter"]
else:
fm = note or {}
# WP-14 Fix: Nutzt übergebene Registry oder lädt sie global
reg = kwargs.get("types_cfg") or load_type_registry()
note_type = fm.get("type") or "concept"
title = fm.get("title") or fm.get("id") or "Untitled"
tags = _as_list(fm.get("tags") or [])
# Audit: Resolution Hierarchie (Frontmatter > Registry)
cp = fm.get("chunking_profile") or fm.get("chunk_profile")
if not cp:
cp = _resolve_val(note_type, reg, "chunking_profile", "sliding_standard")
rw = fm.get("retriever_weight")
if rw is None:
rw = _resolve_val(note_type, reg, "retriever_weight", 1.0)
try:
rw = float(rw)
except:
rw = 1.0
out: List[Dict[str, Any]] = []
for idx, ch in enumerate(chunks_from_chunker):
is_dict = isinstance(ch, dict)
cid = getattr(ch, "id", None) if not is_dict else ch.get("id")
nid = getattr(ch, "note_id", None) if not is_dict else ch.get("note_id")
index = getattr(ch, "index", idx) if not is_dict else ch.get("index", idx)
text = getattr(ch, "text", "") if not is_dict else ch.get("text", "")
window = getattr(ch, "window", text) if not is_dict else ch.get("window", text)
prev_id = getattr(ch, "neighbors_prev", None) if not is_dict else ch.get("neighbors_prev")
next_id = getattr(ch, "neighbors_next", None) if not is_dict else ch.get("neighbors_next")
section = getattr(ch, "section_title", "") if not is_dict else ch.get("section", "")
pl: Dict[str, Any] = {
"note_id": nid or fm.get("id"),
"chunk_id": cid,
"title": title,
"index": int(index),
"ord": int(index) + 1,
"type": note_type,
"tags": tags,
"text": text,
"window": window,
"neighbors_prev": _as_list(prev_id),
"neighbors_next": _as_list(next_id),
"section": section,
"path": note_path,
"source_path": kwargs.get("file_path") or note_path,
"retriever_weight": rw,
"chunk_profile": cp
}
# Audit: Cleanup Pop (Vermeidung von redundanten Alias-Feldern)
for alias in ("chunk_num", "Chunk_Number"):
pl.pop(alias, None)
out.append(pl)
return out

View File

@ -0,0 +1,39 @@
"""
FILE: app/core/ingestion/ingestion_db.py
DESCRIPTION: Datenbank-Schnittstelle für Note-Metadaten und Artefakt-Prüfung.
WP-14: Umstellung auf zentrale database-Infrastruktur.
"""
from typing import Optional, Tuple
from qdrant_client import QdrantClient
from qdrant_client.http import models as rest
# Import der modularisierten Namen-Logik zur Sicherstellung der Konsistenz
from app.core.database import collection_names
def fetch_note_payload(client: QdrantClient, prefix: str, note_id: str) -> Optional[dict]:
"""Holt die Metadaten einer Note aus Qdrant via Scroll."""
notes_col, _, _ = collection_names(prefix)
try:
f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))])
pts, _ = client.scroll(collection_name=notes_col, scroll_filter=f, limit=1, with_payload=True)
return pts[0].payload if pts else None
except: return None
def artifacts_missing(client: QdrantClient, prefix: str, note_id: str) -> Tuple[bool, bool]:
"""Prüft Qdrant aktiv auf vorhandene Chunks und Edges."""
_, chunks_col, edges_col = collection_names(prefix)
try:
f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))])
c_pts, _ = client.scroll(collection_name=chunks_col, scroll_filter=f, limit=1)
e_pts, _ = client.scroll(collection_name=edges_col, scroll_filter=f, limit=1)
return (not bool(c_pts)), (not bool(e_pts))
except: return True, True
def purge_artifacts(client: QdrantClient, prefix: str, note_id: str):
"""Löscht verwaiste Chunks/Edges vor einem Re-Import."""
_, chunks_col, edges_col = collection_names(prefix)
f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))])
# Iteration über die nun zentral verwalteten Collection-Namen
for col in [chunks_col, edges_col]:
try: client.delete(collection_name=col, points_selector=rest.FilterSelector(filter=f))
except: pass

View File

@ -0,0 +1,160 @@
"""
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
"""
from __future__ import annotations
from typing import Any, Dict, Tuple, Optional
import os
import json
import pathlib
import hashlib
# Import der zentralen Registry-Logik
from app.core.registry import load_type_registry
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _as_dict(x) -> Dict[str, Any]:
"""Versucht, ein Objekt in ein Dict zu überführen."""
if isinstance(x, dict): return dict(x)
out: Dict[str, Any] = {}
for attr in ("frontmatter", "body", "id", "note_id", "title", "path", "tags", "type", "created", "modified", "date"):
if hasattr(x, attr):
val = getattr(x, attr)
if val is not None: out[attr] = val
if not out: out["raw"] = str(x)
return out
def _ensure_list(x) -> list:
"""Sichert String-Listen Integrität."""
if x is None: return []
if isinstance(x, list): return [str(i) for i in x]
if isinstance(x, (set, tuple)): return [str(i) for i in x]
return [str(x)]
def _compute_hash(content: str) -> str:
"""SHA-256 Hash-Berechnung."""
if not content: return ""
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 "")
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"]):
val = fm.get(k)
if val is not None: meta_parts.append(f"{k}:{val}")
return f"{'|'.join(meta_parts)}||{body}"
return body
def _cfg_for_type(note_type: str, reg: dict) -> dict:
"""Extrahiert Typ-spezifische Config aus der Registry."""
if not isinstance(reg, dict): return {}
types = reg.get("types") if isinstance(reg.get("types"), dict) else reg
return types.get(note_type, {}) if isinstance(types, dict) else {}
def _cfg_defaults(reg: dict) -> dict:
"""Extrahiert globale Default-Werte aus der Registry."""
if not isinstance(reg, dict): return {}
for key in ("defaults", "default", "global"):
v = reg.get(key)
if isinstance(v, dict): return v
return {}
# ---------------------------------------------------------------------------
# Haupt-API
# ---------------------------------------------------------------------------
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.
"""
n = _as_dict(note)
# Nutzt übergebene Registry oder lädt sie global
reg = kwargs.get("types_cfg") or load_type_registry()
hash_source = kwargs.get("hash_source", "parsed")
hash_normalize = kwargs.get("hash_normalize", "canonical")
fm = n.get("frontmatter") or {}
note_type = str(fm.get("type") or n.get("type") or "concept")
cfg_type = _cfg_for_type(note_type, reg)
cfg_def = _cfg_defaults(reg)
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:
retriever_weight = cfg_type.get("retriever_weight", cfg_def.get("retriever_weight", default_rw))
try:
retriever_weight = float(retriever_weight)
except:
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 = fm.get("edge_defaults")
if edge_defaults is None:
edge_defaults = cfg_type.get("edge_defaults", cfg_def.get("edge_defaults", []))
edge_defaults = _ensure_list(edge_defaults)
# --- Basis-Metadaten ---
note_id = n.get("note_id") or n.get("id") or fm.get("id")
title = n.get("title") or fm.get("title") or ""
path = n.get("path") or kwargs.get("file_path") or ""
if isinstance(path, pathlib.Path): path = str(path)
payload: Dict[str, Any] = {
"note_id": note_id,
"title": title,
"type": note_type,
"path": path,
"retriever_weight": retriever_weight,
"chunk_profile": chunk_profile,
"edge_defaults": edge_defaults,
"hashes": {}
}
# --- MULTI-HASH ---
# Generiert Hashes für Change Detection
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
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"))
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"])
# Final JSON Validation Audit
json.loads(json.dumps(payload, ensure_ascii=False))
return payload

View File

@ -0,0 +1,220 @@
"""
FILE: app/core/ingestion/ingestion_processor.py
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
STATUS: Active
"""
import logging
import asyncio
import os
from typing import Dict, List, Optional, Tuple, Any
# Core Module Imports
from app.core.parser import (
read_markdown, pre_scan_markdown, normalize_frontmatter,
validate_required_frontmatter, NoteContext
)
from app.core.chunking import assemble_chunks
# MODULARISIERUNG: Neue Import-Pfade für die Datenbank-Ebene
from app.core.database.qdrant import QdrantConfig, get_client, ensure_collections, ensure_payload_indexes
from app.core.database.qdrant_points import points_for_chunks, points_for_note, points_for_edges, upsert_batch
# Services
from app.services.embeddings_client import EmbeddingsClient
from app.services.edge_registry import registry as edge_registry
from app.services.llm_service import LLMService
# Package-Interne Imports (Refactoring WP-14)
from .ingestion_utils import load_type_registry, resolve_note_type, get_chunk_config_by_profile
from .ingestion_db import fetch_note_payload, artifacts_missing, purge_artifacts
from .ingestion_validation import validate_edge_candidate
from .ingestion_note_payload import make_note_payload
from .ingestion_chunk_payload import make_chunk_payloads
# Fallback für Edges (Struktur-Verknüpfung)
try:
from app.core.derive_edges import build_edges_for_note
except ImportError:
def build_edges_for_note(*args, **kwargs): return []
logger = logging.getLogger(__name__)
class IngestionService:
def __init__(self, collection_prefix: str = None):
"""Initialisiert den Service und nutzt die neue database-Infrastruktur."""
from app.config import get_settings
self.settings = get_settings()
self.prefix = collection_prefix or self.settings.COLLECTION_PREFIX
self.cfg = QdrantConfig.from_env()
# Synchronisierung der Konfiguration mit dem Instanz-Präfix
self.cfg.prefix = self.prefix
self.client = get_client(self.cfg)
self.dim = self.settings.VECTOR_SIZE
self.registry = load_type_registry()
self.embedder = EmbeddingsClient()
self.llm = LLMService()
self.active_hash_mode = self.settings.CHANGE_DETECTION_MODE
self.batch_cache: Dict[str, NoteContext] = {} # WP-15b LocalBatchCache
try:
# Aufruf der modularisierten Schema-Logik
ensure_collections(self.client, self.prefix, self.dim)
ensure_payload_indexes(self.client, self.prefix)
except Exception as e:
logger.warning(f"DB initialization warning: {e}")
async def run_batch(self, file_paths: List[str], vault_root: str) -> List[Dict[str, Any]]:
"""
WP-15b: Implementiert den Two-Pass Ingestion Workflow.
Pass 1: Pre-Scan füllt den Context-Cache (3-Wege-Indexierung).
Pass 2: Verarbeitung nutzt den Cache für die semantische Prüfung.
"""
logger.info(f"🔍 [Pass 1] Pre-Scanning {len(file_paths)} files for Context Cache...")
for path in file_paths:
try:
# Übergabe der Registry für dynamische Scan-Tiefe
ctx = pre_scan_markdown(path, registry=self.registry)
if ctx:
# Mehrfache Indizierung für robusten Look-up (ID, Titel, Dateiname)
self.batch_cache[ctx.note_id] = ctx
self.batch_cache[ctx.title] = ctx
fname = os.path.splitext(os.path.basename(path))[0]
self.batch_cache[fname] = ctx
except Exception as e:
logger.warning(f"⚠️ Pre-scan failed for {path}: {e}")
logger.info(f"🚀 [Pass 2] Semantic Processing of {len(file_paths)} files...")
return [await self.process_file(p, vault_root, apply=True, purge_before=True) for p in file_paths]
async def process_file(self, file_path: str, vault_root: str, **kwargs) -> Dict[str, Any]:
"""Transformiert eine Markdown-Datei in den Graphen."""
apply = kwargs.get("apply", False)
force_replace = kwargs.get("force_replace", False)
purge_before = kwargs.get("purge_before", False)
note_scope_refs = kwargs.get("note_scope_refs", False)
hash_source = kwargs.get("hash_source", "parsed")
hash_normalize = kwargs.get("hash_normalize", "canonical")
result = {"path": file_path, "status": "skipped", "changed": False, "error": None}
# 1. Parse & Lifecycle Gate
try:
parsed = read_markdown(file_path)
if not parsed: return {**result, "error": "Empty file"}
fm = normalize_frontmatter(parsed.frontmatter)
validate_required_frontmatter(fm)
except Exception as e:
return {**result, "error": f"Validation failed: {str(e)}"}
# Dynamischer Lifecycle-Filter aus der Registry (WP-14)
ingest_cfg = self.registry.get("ingestion_settings", {})
ignore_list = ingest_cfg.get("ignore_statuses", ["system", "template", "archive", "hidden"])
current_status = fm.get("status", "draft").lower().strip()
if current_status in ignore_list:
return {**result, "status": "skipped", "reason": "lifecycle_filter"}
# 2. Payload & Change Detection (Multi-Hash)
note_type = resolve_note_type(self.registry, fm.get("type"))
note_pl = make_note_payload(
parsed, vault_root=vault_root, file_path=file_path,
hash_source=hash_source, hash_normalize=hash_normalize,
types_cfg=self.registry
)
note_id = note_pl["note_id"]
old_payload = None if force_replace else fetch_note_payload(self.client, self.prefix, note_id)
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)
c_miss, e_miss = artifacts_missing(self.client, self.prefix, note_id)
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}
if not apply:
return {**result, "status": "dry-run", "changed": True, "note_id": note_id}
# 3. Deep Processing (Chunking, Validation, Embedding)
try:
body_text = getattr(parsed, "body", "") or ""
edge_registry.ensure_latest()
profile = fm.get("chunk_profile") or fm.get("chunking_profile") or "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
chunks = await assemble_chunks(note_id, body_text, note_type, config=chunk_cfg)
for ch in chunks:
filtered = []
for cand in getattr(ch, "candidate_pool", []):
# WP-15b: Nur global_pool Kandidaten erfordern binäre 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:
filtered.append(cand)
ch.candidate_pool = filtered
# Payload-Erstellung via interne Module
chunk_pls = make_chunk_payloads(
fm, note_pl["path"], chunks, file_path=file_path,
types_cfg=self.registry
)
vecs = await self.embedder.embed_documents([c.get("window") or "" for c in chunk_pls]) if chunk_pls else []
# Kanten-Aggregation
edges = build_edges_for_note(
note_id, chunk_pls,
note_level_references=note_pl.get("references", []),
include_note_scope_refs=note_scope_refs
)
for e in edges:
e["kind"] = edge_registry.resolve(
e.get("kind", "related_to"),
provenance=e.get("provenance", "explicit"),
context={"file": file_path, "note_id": note_id, "line": e.get("line", "system")}
)
# 4. DB Upsert via modularisierter Points-Logik
if purge_before and old_payload:
purge_artifacts(self.client, self.prefix, note_id)
n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim)
upsert_batch(self.client, n_name, n_pts)
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)
if edges:
e_pts = points_for_edges(self.prefix, edges)[1]
upsert_batch(self.client, f"{self.prefix}_edges", e_pts)
return {
"path": file_path,
"status": "success",
"changed": True,
"note_id": note_id,
"chunks_count": len(chunk_pls),
"edges_count": len(edges)
}
except Exception as e:
logger.error(f"Processing failed: {e}", exc_info=True)
return {**result, "error": str(e)}
async def create_from_text(self, markdown_content: str, filename: str, vault_root: str, folder: str = "00_Inbox") -> Dict[str, Any]:
"""Erstellt eine Note aus einem Textstream und triggert die Ingestion."""
target_path = os.path.join(vault_root, folder, filename)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "w", encoding="utf-8") as f:
f.write(markdown_content)
await asyncio.sleep(0.1)
return await self.process_file(file_path=target_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True)

View File

@ -0,0 +1,71 @@
"""
FILE: app/core/ingestion/ingestion_utils.py
DESCRIPTION: Hilfswerkzeuge für JSON-Recovery, Typ-Registry und Konfigurations-Lookups.
AUDIT v2.13.9: Behebung des Circular Imports durch Nutzung der app.core.registry.
"""
import json
import re
from typing import Any, Optional, Dict
# ENTSCHEIDENDER FIX: Import der Basis-Logik aus dem neutralen Registry-Modul.
# Dies bricht den Zirkelbezug auf, da dieses Modul keine Services mehr importiert.
from app.core.registry import load_type_registry, clean_llm_text
def extract_json_from_response(text: str, registry: Optional[dict] = None) -> Any:
"""
Extrahiert JSON-Daten und bereinigt LLM-Steuerzeichen.
WP-14: Nutzt nun die zentrale clean_llm_text Funktion aus app.core.registry.
"""
if not text:
return []
# 1. Text zentral bereinigen via neutralem Modul
clean = clean_llm_text(text, registry)
# 2. Markdown-Code-Blöcke extrahieren
match = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL)
payload = match.group(1) if match else clean
try:
return json.loads(payload.strip())
except json.JSONDecodeError:
# Recovery: Suche nach Liste
start = payload.find('[')
end = payload.rfind(']') + 1
if start != -1 and end > start:
try: return json.loads(payload[start:end])
except: pass
# Recovery: Suche nach Objekt
start_obj = payload.find('{')
end_obj = payload.rfind('}') + 1
if start_obj != -1 and end_obj > start_obj:
try: return json.loads(payload[start_obj:end_obj])
except: pass
return []
def resolve_note_type(registry: dict, requested: Optional[str]) -> str:
"""
Bestimmt den finalen Notiz-Typ.
WP-14: Fallback wird nun über ingestion_settings.default_note_type gesteuert.
"""
types = registry.get("types", {})
if requested and requested in types:
return requested
# Dynamischer Fallback aus der Registry (Standard: 'concept')
ingest_cfg = registry.get("ingestion_settings", {})
return ingest_cfg.get("default_note_type", "concept")
def get_chunk_config_by_profile(registry: dict, profile_name: str, note_type: str) -> Dict[str, Any]:
"""
Holt die Chunker-Parameter für ein spezifisches Profil aus der Registry.
"""
from app.core.chunking import get_chunk_config
profiles = registry.get("chunking_profiles", {})
if profile_name in profiles:
cfg = profiles[profile_name].copy()
if "overlap" in cfg and isinstance(cfg["overlap"], list):
cfg["overlap"] = tuple(cfg["overlap"])
return cfg
return get_chunk_config(note_type)

View File

@ -0,0 +1,67 @@
"""
FILE: app/core/ingestion/ingestion_validation.py
DESCRIPTION: WP-15b semantische Validierung von Kanten gegen den LocalBatchCache.
AUDIT v2.12.3: Integration der zentralen Text-Bereinigung (WP-14).
"""
import logging
from typing import Dict, Any
from app.core.parser import NoteContext
# ENTSCHEIDENDER FIX: Import der neutralen Bereinigungs-Logik zur Vermeidung von Circular Imports
from app.core.registry import clean_llm_text
logger = logging.getLogger(__name__)
async def validate_edge_candidate(
chunk_text: str,
edge: Dict,
batch_cache: Dict[str, NoteContext],
llm_service: Any,
provider: str
) -> bool:
"""
WP-15b: Validiert einen Kandidaten semantisch gegen das Ziel im Cache.
Nutzt clean_llm_text zur Entfernung von Steuerzeichen vor der Auswertung.
"""
target_id = edge.get("to")
target_ctx = batch_cache.get(target_id)
# Robust Lookup Fix (v2.12.2): Support für Anker
if not target_ctx and "#" in target_id:
base_id = target_id.split("#")[0]
target_ctx = batch_cache.get(base_id)
# Sicherheits-Fallback (Hard-Link Integrity)
if not target_ctx:
logger.info(f" [VALIDATION SKIP] No context for '{target_id}' - allowing link.")
return True
template = llm_service.get_prompt("edge_validation", provider)
try:
logger.info(f"⚖️ [VALIDATING] Relation '{edge.get('kind')}' -> '{target_id}'...")
prompt = template.format(
chunk_text=chunk_text[:1500],
target_title=target_ctx.title,
target_summary=target_ctx.summary,
edge_kind=edge.get("kind", "related_to")
)
# Die Antwort vom Service anfordern
raw_response = await llm_service.generate_raw_response(prompt, priority="background")
# WP-14 Fix: Zusätzliche Bereinigung zur Sicherstellung der Interpretierbarkeit
response = clean_llm_text(raw_response)
# Semantische Prüfung des Ergebnisses
is_valid = "YES" in response.upper()
if is_valid:
logger.info(f"✅ [VALIDATED] Relation to '{target_id}' confirmed.")
else:
logger.info(f"🚫 [REJECTED] Relation to '{target_id}' irrelevant for this chunk.")
return is_valid
except Exception as e:
logger.warning(f"⚠️ Validation error for {target_id}: {e}")
# Im Zweifel (Timeout/Fehler) erlauben wir die Kante, um Datenverlust zu vermeiden
return True

View File

@ -1,268 +0,0 @@
"""
FILE: app/core/note_payload.py
DESCRIPTION: Baut das JSON-Objekt.
FEATURES:
1. Multi-Hash: Berechnet immer 'body' AND 'full' Hashes für flexible Change Detection.
2. Config-Fix: Liest korrekt 'chunking_profile' aus types.yaml (statt Legacy 'chunk_profile').
VERSION: 2.3.0
STATUS: Active
DEPENDENCIES: yaml, os, json, pathlib, hashlib
EXTERNAL_CONFIG: config/types.yaml
"""
from __future__ import annotations
from typing import Any, Dict, Tuple, Optional
import os
import json
import pathlib
import hashlib
try:
import yaml # type: ignore
except Exception:
yaml = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _as_dict(x) -> Dict[str, Any]:
"""Versucht, ein ParsedMarkdown-ähnliches Objekt in ein Dict zu überführen."""
if isinstance(x, dict):
return dict(x)
out: Dict[str, Any] = {}
for attr in (
"frontmatter",
"body",
"id",
"note_id",
"title",
"path",
"tags",
"type",
"created",
"modified",
"date",
):
if hasattr(x, attr):
val = getattr(x, attr)
if val is not None:
out[attr] = val
if not out:
out["raw"] = str(x)
return out
def _pick_args(*args, **kwargs) -> Tuple[Optional[str], Optional[dict]]:
path = kwargs.get("path") or (args[0] if args else None)
types_cfg = kwargs.get("types_cfg") or kwargs.get("types") or None
return path, types_cfg
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, default))
except Exception:
return default
def _ensure_list(x) -> list:
if x is None:
return []
if isinstance(x, list):
return [str(i) for i in x]
if isinstance(x, (set, tuple)):
return [str(i) for i in x]
return [str(x)]
# --- Hash Logic ---
def _compute_hash(content: str) -> str:
"""Berechnet einen SHA-256 Hash für den gegebenen String."""
if not content:
return ""
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str:
"""
Stellt den String zusammen, der gehasht werden soll.
"""
body = str(n.get("body") or "")
if mode == "body":
return body
if mode == "full":
fm = n.get("frontmatter") or {}
# Wichtig: Sortierte Keys für deterministisches Verhalten!
# Wir nehmen alle steuernden Metadaten auf
meta_parts = []
# Hier checken wir keys, die eine Neu-Indizierung rechtfertigen würden
for k in sorted(["title", "type", "status", "tags", "chunking_profile", "chunk_profile", "retriever_weight"]):
val = fm.get(k)
if val is not None:
meta_parts.append(f"{k}:{val}")
meta_str = "|".join(meta_parts)
return f"{meta_str}||{body}"
return body
# ---------------------------------------------------------------------------
# Type-Registry laden
# ---------------------------------------------------------------------------
def _load_types_config(explicit_cfg: Optional[dict] = None) -> dict:
if explicit_cfg and isinstance(explicit_cfg, dict):
return explicit_cfg
path = os.getenv("MINDNET_TYPES_FILE") or "./config/types.yaml"
if not os.path.isfile(path) or yaml is None:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _cfg_for_type(note_type: str, reg: dict) -> dict:
if not isinstance(reg, dict):
return {}
types = reg.get("types") if isinstance(reg.get("types"), dict) else reg
return types.get(note_type, {}) if isinstance(types, dict) else {}
def _cfg_defaults(reg: dict) -> dict:
if not isinstance(reg, dict):
return {}
for key in ("defaults", "default", "global"):
v = reg.get(key)
if isinstance(v, dict):
return v
return {}
# ---------------------------------------------------------------------------
# Haupt-API
# ---------------------------------------------------------------------------
def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]:
"""
Baut das Note-Payload für mindnet_notes auf.
Inkludiert Hash-Berechnung (Body & Full) und korrigierte Config-Lookups.
"""
n = _as_dict(note)
path_arg, types_cfg_explicit = _pick_args(*args, **kwargs)
reg = _load_types_config(types_cfg_explicit)
# Hash Config (Parameter für Source/Normalize, Mode ist hardcoded auf 'beide')
hash_source = kwargs.get("hash_source", "parsed")
hash_normalize = kwargs.get("hash_normalize", "canonical")
fm = n.get("frontmatter") or {}
fm_type = fm.get("type") or n.get("type") or "concept"
note_type = str(fm_type)
cfg_type = _cfg_for_type(note_type, reg)
cfg_def = _cfg_defaults(reg)
# --- retriever_weight ---
default_rw = _env_float("MINDNET_DEFAULT_RETRIEVER_WEIGHT", 1.0)
retriever_weight = fm.get("retriever_weight")
if retriever_weight is None:
retriever_weight = cfg_type.get(
"retriever_weight",
cfg_def.get("retriever_weight", default_rw),
)
try:
retriever_weight = float(retriever_weight)
except Exception:
retriever_weight = default_rw
# --- chunk_profile (FIXED LOGIC) ---
# 1. Frontmatter Override (beide Schreibweisen erlaubt)
chunk_profile = fm.get("chunking_profile") or fm.get("chunk_profile")
# 2. Type Config (Korrekter Key 'chunking_profile' aus types.yaml)
if chunk_profile is None:
chunk_profile = cfg_type.get("chunking_profile")
# 3. Default Config (Fallback auf sliding_standard statt medium)
if chunk_profile is None:
chunk_profile = cfg_def.get("chunking_profile", "sliding_standard")
# 4. Safety Fallback
if not isinstance(chunk_profile, str) or not chunk_profile:
chunk_profile = "sliding_standard"
# --- edge_defaults ---
edge_defaults = fm.get("edge_defaults")
if edge_defaults is None:
edge_defaults = cfg_type.get(
"edge_defaults",
cfg_def.get("edge_defaults", []),
)
edge_defaults = _ensure_list(edge_defaults)
# --- Basis-Metadaten ---
note_id = n.get("note_id") or n.get("id") or fm.get("id")
title = n.get("title") or fm.get("title") or ""
path = n.get("path") or path_arg
if isinstance(path, pathlib.Path):
path = str(path)
payload: Dict[str, Any] = {
"note_id": note_id,
"title": title,
"type": note_type,
"path": path or "",
"retriever_weight": retriever_weight,
"chunk_profile": chunk_profile,
"edge_defaults": edge_defaults,
"hashes": {} # Init Hash Dict
}
# --- MULTI-HASH CALCULATION (Strategy Decoupling) ---
# Wir berechnen immer BEIDE Strategien und speichern sie.
# ingestion.py entscheidet dann anhand der ENV-Variable, welcher verglichen wird.
modes_to_calc = ["body", "full"]
for mode in modes_to_calc:
content_to_hash = _get_hash_source_content(n, mode)
computed_hash = _compute_hash(content_to_hash)
# Key Schema: mode:source:normalize (z.B. "full:parsed:canonical")
key = f"{mode}:{hash_source}:{hash_normalize}"
payload["hashes"][key] = computed_hash
# Tags / Keywords
tags = fm.get("tags") or fm.get("keywords") or n.get("tags")
if tags:
payload["tags"] = _ensure_list(tags)
# Aliases
aliases = fm.get("aliases")
if aliases:
payload["aliases"] = _ensure_list(aliases)
# Zeit
for k in ("created", "modified", "date"):
v = fm.get(k) or n.get(k)
if v:
payload[k] = str(v)
# Fulltext
if "body" in n and n["body"]:
payload["fulltext"] = str(n["body"])
# JSON Validation
json.loads(json.dumps(payload, ensure_ascii=False))
return payload

View File

@ -1,257 +0,0 @@
"""
FILE: app/core/parser.py
DESCRIPTION: Liest Markdown-Dateien fehlertolerant (Encoding-Fallback). Trennt Frontmatter (YAML) vom Body.
WP-22 Erweiterung: Kanten-Extraktion mit Zeilennummern für die EdgeRegistry.
VERSION: 1.8.0
STATUS: Active
DEPENDENCIES: yaml, re, dataclasses, json, io, os
LAST_ANALYSIS: 2025-12-23
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Iterable, List
import io
import json
import os
import re
try:
import yaml # PyYAML
except Exception as e: # pragma: no cover
yaml = None # Fehler wird zur Laufzeit geworfen, falls wirklich benötigt
# ---------------------------------------------------------------------
# Datamodell
# ---------------------------------------------------------------------
@dataclass
class ParsedNote:
frontmatter: Dict[str, Any]
body: str
path: str
# ---------------------------------------------------------------------
# Frontmatter-Erkennung
# ---------------------------------------------------------------------
# Öffentliche Kompatibilitäts-Konstante: frühere Skripte importieren FRONTMATTER_RE
FRONTMATTER_RE = re.compile(r"^\s*---\s*$") # <— public
# Zusätzlich interner Alias (falls jemand ihn referenziert)
FRONTMATTER_END = FRONTMATTER_RE # <— public alias
# interne Namen bleiben bestehen
_FRONTMATTER_HEAD = FRONTMATTER_RE
_FRONTMATTER_END = FRONTMATTER_RE
def _split_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
"""
Zerlegt Text in (frontmatter: dict, body: str).
Erkennt Frontmatter nur, wenn die erste Zeile '---' ist und später ein zweites '---' folgt.
YAML-Fehler im Frontmatter führen NICHT zum Abbruch: es wird dann ein leeres dict benutzt.
"""
lines = text.splitlines(True) # keep line endings
if not lines:
return {}, ""
if not _FRONTMATTER_HEAD.match(lines[0]):
# kein Frontmatter-Header → gesamter Text ist Body
return {}, text
end_idx = None
# Suche nach nächstem '---' (max. 2000 Zeilen als Sicherheitslimit)
for i in range(1, min(len(lines), 2000)):
if _FRONTMATTER_END.match(lines[i]):
end_idx = i
break
if end_idx is None:
# unvollständiger Frontmatter-Block → behandle alles als Body
return {}, text
fm_raw = "".join(lines[1:end_idx])
body = "".join(lines[end_idx + 1:])
data: Dict[str, Any] = {}
if yaml is None:
raise RuntimeError("PyYAML ist nicht installiert (pip install pyyaml).")
try:
loaded = yaml.safe_load(fm_raw) or {}
if isinstance(loaded, dict):
data = loaded
else:
data = {}
except Exception as e:
# YAML-Fehler nicht fatal machen
print(json.dumps({"warn": "frontmatter_yaml_parse_failed", "error": str(e)}))
data = {}
# optionales kosmetisches Trim: eine führende Leerzeile im Body entfernen
if body.startswith("\n"):
body = body[1:]
return data, body
# ---------------------------------------------------------------------
# Robustes Lesen mit Encoding-Fallback
# ---------------------------------------------------------------------
_FALLBACK_ENCODINGS: Tuple[str, ...] = ("utf-8", "utf-8-sig", "cp1252", "latin-1")
def _read_text_with_fallback(path: str) -> Tuple[str, str, bool]:
"""
Liest Datei mit mehreren Decodierungsversuchen.
Rückgabe: (text, used_encoding, had_fallback)
- had_fallback=True, falls NICHT 'utf-8' verwendet wurde (oder 'utf-8-sig').
"""
last_err: Optional[str] = None
for enc in _FALLBACK_ENCODINGS:
try:
with io.open(path, "r", encoding=enc, errors="strict") as f:
text = f.read()
# 'utf-8-sig' zählt hier als Fallback (weil BOM), aber ist unproblematisch
return text, enc, (enc != "utf-8")
except UnicodeDecodeError as e:
last_err = f"{type(e).__name__}: {e}"
continue
# Letzter, extrem defensiver Fallback: Bytes → UTF-8 mit REPLACE (keine Exception)
with open(path, "rb") as fb:
raw = fb.read()
text = raw.decode("utf-8", errors="replace")
print(json.dumps({
"path": path,
"warn": "encoding_fallback_exhausted",
"info": last_err or "unknown"
}, ensure_ascii=False))
return text, "utf-8(replace)", True
# ---------------------------------------------------------------------
# Öffentliche API
# ---------------------------------------------------------------------
def read_markdown(path: str) -> Optional[ParsedNote]:
"""
Liest eine Markdown-Datei fehlertolerant.
"""
if not os.path.exists(path):
return None
text, enc, had_fb = _read_text_with_fallback(path)
if had_fb:
print(json.dumps({"path": path, "warn": "encoding_fallback_used", "used": enc}, ensure_ascii=False))
fm, body = _split_frontmatter(text)
return ParsedNote(frontmatter=fm or {}, body=body or "", path=path)
def validate_required_frontmatter(fm: Dict[str, Any],
required: Tuple[str, ...] = ("id", "title")) -> None:
"""
Prüft, ob alle Pflichtfelder vorhanden sind.
"""
if fm is None:
fm = {}
missing = []
for k in required:
v = fm.get(k)
if v is None:
missing.append(k)
elif isinstance(v, str) and not v.strip():
missing.append(k)
if missing:
raise ValueError(f"Missing required frontmatter fields: {', '.join(missing)}")
if "tags" in fm and fm["tags"] not in (None, "") and not isinstance(fm["tags"], (list, tuple)):
raise ValueError("frontmatter 'tags' must be a list of strings")
def normalize_frontmatter(fm: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalisierung von Tags und anderen Feldern.
"""
out = dict(fm or {})
if "tags" in out:
if isinstance(out["tags"], str):
out["tags"] = [out["tags"].strip()] if out["tags"].strip() else []
elif isinstance(out["tags"], list):
out["tags"] = [str(t).strip() for t in out["tags"] if t is not None]
else:
out["tags"] = [str(out["tags"]).strip()] if out["tags"] not in (None, "") else []
if "embedding_exclude" in out:
out["embedding_exclude"] = bool(out["embedding_exclude"])
return out
# ------------------------------ Wikilinks ---------------------------- #
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
def extract_wikilinks(text: str) -> List[str]:
"""
Extrahiert Wikilinks als einfache Liste von IDs.
"""
if not text:
return []
out: List[str] = []
for m in _WIKILINK_RE.finditer(text):
raw = (m.group(1) or "").strip()
if not raw:
continue
if "|" in raw:
raw = raw.split("|", 1)[0].strip()
if "#" in raw:
raw = raw.split("#", 1)[0].strip()
if raw:
out.append(raw)
return out
def extract_edges_with_context(parsed: ParsedNote) -> List[Dict[str, Any]]:
"""
WP-22: Extrahiert Wikilinks [[Ziel|Typ]] aus dem Body und speichert die Zeilennummer.
Gibt eine Liste von Dictionaries zurück, die direkt von der Ingestion verarbeitet werden können.
"""
edges = []
if not parsed or not parsed.body:
return edges
# Wir nutzen splitlines(True), um Zeilenumbrüche für die Positionsberechnung zu erhalten,
# oder einfaches splitlines() für die reine Zeilennummerierung.
lines = parsed.body.splitlines()
for line_num, line_content in enumerate(lines, 1):
for match in _WIKILINK_RE.finditer(line_content):
raw = (match.group(1) or "").strip()
if not raw:
continue
# Syntax: [[Ziel|Typ]]
if "|" in raw:
parts = raw.split("|", 1)
target = parts[0].strip()
kind = parts[1].strip()
else:
target = raw.strip()
kind = "related_to" # Default-Typ
# Anchor (#) entfernen, da Relationen auf Notiz-Ebene (ID) basieren
if "#" in target:
target = target.split("#", 1)[0].strip()
if target:
edges.append({
"to": target,
"kind": kind,
"line": line_num,
"provenance": "explicit"
})
return edges

View File

@ -0,0 +1,22 @@
"""
FILE: app/core/parser/__init__.py
DESCRIPTION: Package-Einstiegspunkt für den Parser.
Ermöglicht das Löschen der parser.py Facade.
VERSION: 1.10.0
"""
from .parsing_models import ParsedNote, NoteContext
from .parsing_utils import (
FRONTMATTER_RE, validate_required_frontmatter,
normalize_frontmatter, extract_wikilinks, extract_edges_with_context
)
from .parsing_markdown import read_markdown
from .parsing_scanner import pre_scan_markdown
# Kompatibilitäts-Alias
FRONTMATTER_END = FRONTMATTER_RE
__all__ = [
"ParsedNote", "NoteContext", "FRONTMATTER_RE", "FRONTMATTER_END",
"read_markdown", "pre_scan_markdown", "validate_required_frontmatter",
"normalize_frontmatter", "extract_wikilinks", "extract_edges_with_context"
]

View File

@ -0,0 +1,60 @@
"""
FILE: app/core/parsing/parsing_markdown.py
DESCRIPTION: Fehlertolerantes Einlesen von Markdown und Frontmatter-Splitting.
"""
import io
import os
import json
from typing import Any, Dict, Optional, Tuple
from .parsing_models import ParsedNote
from .parsing_utils import FRONTMATTER_RE
try:
import yaml
except ImportError:
yaml = None
_FALLBACK_ENCODINGS: Tuple[str, ...] = ("utf-8", "utf-8-sig", "cp1252", "latin-1")
def _split_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
"""Zerlegt Text in Frontmatter-Dict und Body."""
lines = text.splitlines(True)
if not lines or not FRONTMATTER_RE.match(lines[0]):
return {}, text
end_idx = None
for i in range(1, min(len(lines), 2000)):
if FRONTMATTER_RE.match(lines[i]):
end_idx = i
break
if end_idx is None: return {}, text
fm_raw = "".join(lines[1:end_idx])
body = "".join(lines[end_idx + 1:])
if yaml is None: raise RuntimeError("PyYAML not installed.")
try:
loaded = yaml.safe_load(fm_raw) or {}
data = loaded if isinstance(loaded, dict) else {}
except Exception as e:
print(json.dumps({"warn": "frontmatter_yaml_parse_failed", "error": str(e)}))
data = {}
if body.startswith("\n"): body = body[1:]
return data, body
def _read_text_with_fallback(path: str) -> Tuple[str, str, bool]:
"""Liest Datei mit Encoding-Fallback-Kette."""
last_err = None
for enc in _FALLBACK_ENCODINGS:
try:
with io.open(path, "r", encoding=enc, errors="strict") as f:
return f.read(), enc, (enc != "utf-8")
except UnicodeDecodeError as e:
last_err = str(e); continue
with open(path, "rb") as fb:
text = fb.read().decode("utf-8", errors="replace")
return text, "utf-8(replace)", True
def read_markdown(path: str) -> Optional[ParsedNote]:
"""Öffentliche API zum Einlesen einer Datei."""
if not os.path.exists(path): return None
text, enc, had_fb = _read_text_with_fallback(path)
fm, body = _split_frontmatter(text)
return ParsedNote(frontmatter=fm or {}, body=body or "", path=path)

View File

@ -0,0 +1,22 @@
"""
FILE: app/core/parsing/parsing_models.py
DESCRIPTION: Datenklassen für das Parsing-System.
"""
from dataclasses import dataclass
from typing import Any, Dict, List
@dataclass
class ParsedNote:
"""Container für eine vollständig eingelesene Markdown-Datei."""
frontmatter: Dict[str, Any]
body: str
path: str
@dataclass
class NoteContext:
"""Metadaten-Container für den flüchtigen LocalBatchCache (Pass 1)."""
note_id: str
title: str
type: str
summary: str
tags: List[str]

View File

@ -0,0 +1,40 @@
"""
FILE: app/core/parsing/parsing_scanner.py
DESCRIPTION: Pre-Scan für den LocalBatchCache (Pass 1).
AUDIT v1.1.0: Dynamisierung der Scan-Parameter (WP-14).
"""
import os
import re
from typing import Optional, Dict, Any
from .parsing_models import NoteContext
from .parsing_markdown import read_markdown
def pre_scan_markdown(path: str, registry: Optional[Dict[str, Any]] = None) -> Optional[NoteContext]:
"""
Extrahiert Identität und Kurz-Kontext zur Validierung.
WP-14: Scan-Tiefe und Summary-Länge sind nun über die Registry steuerbar.
"""
parsed = read_markdown(path)
if not parsed: return None
# WP-14: Konfiguration laden oder Standardwerte nutzen
reg = registry or {}
summary_cfg = reg.get("summary_settings", {})
scan_depth = summary_cfg.get("pre_scan_depth", 600)
max_len = summary_cfg.get("max_summary_length", 500)
fm = parsed.frontmatter
# ID-Findung: Frontmatter ID oder Dateiname als Fallback
note_id = str(fm.get("id") or os.path.splitext(os.path.basename(path))[0])
# Erstelle Kurz-Zusammenfassung mit dynamischen Limits
clean_body = re.sub(r'[#*`>]', '', parsed.body[:scan_depth]).strip()
summary = clean_body[:max_len] + "..." if len(clean_body) > max_len else clean_body
return NoteContext(
note_id=note_id,
title=str(fm.get("title", note_id)),
type=str(fm.get("type", "concept")),
summary=summary,
tags=fm.get("tags", []) if isinstance(fm.get("tags"), list) else []
)

View File

@ -0,0 +1,69 @@
"""
FILE: app/core/parsing/parsing_utils.py
DESCRIPTION: Werkzeuge zur Validierung, Normalisierung und Wikilink-Extraktion.
"""
import re
from typing import Any, Dict, List, Tuple, Optional
from .parsing_models import ParsedNote
# Öffentliche Konstanten für Abwärtskompatibilität
FRONTMATTER_RE = re.compile(r"^\s*---\s*$")
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
def validate_required_frontmatter(fm: Dict[str, Any], required: Tuple[str, ...] = ("id", "title")) -> None:
"""Prüft, ob alle Pflichtfelder vorhanden sind."""
if fm is None: fm = {}
missing = []
for k in required:
v = fm.get(k)
if v is None or (isinstance(v, str) and not v.strip()):
missing.append(k)
if missing:
raise ValueError(f"Missing required frontmatter fields: {', '.join(missing)}")
if "tags" in fm and fm["tags"] not in (None, "") and not isinstance(fm["tags"], (list, tuple)):
raise ValueError("frontmatter 'tags' must be a list of strings")
def normalize_frontmatter(fm: Dict[str, Any]) -> Dict[str, Any]:
"""Normalisierung von Tags und Boolean-Feldern."""
out = dict(fm or {})
if "tags" in out:
if isinstance(out["tags"], str):
out["tags"] = [out["tags"].strip()] if out["tags"].strip() else []
elif isinstance(out["tags"], list):
out["tags"] = [str(t).strip() for t in out["tags"] if t is not None]
else:
out["tags"] = [str(out["tags"]).strip()] if out["tags"] not in (None, "") else []
if "embedding_exclude" in out:
out["embedding_exclude"] = bool(out["embedding_exclude"])
return out
def extract_wikilinks(text: str) -> List[str]:
"""Extrahiert Wikilinks als einfache Liste von IDs."""
if not text: return []
out: List[str] = []
for m in _WIKILINK_RE.finditer(text):
raw = (m.group(1) or "").strip()
if not raw: continue
if "|" in raw: raw = raw.split("|", 1)[0].strip()
if "#" in raw: raw = raw.split("#", 1)[0].strip()
if raw: out.append(raw)
return out
def extract_edges_with_context(parsed: ParsedNote) -> List[Dict[str, Any]]:
"""WP-22: Extrahiert Wikilinks mit Zeilennummern für die EdgeRegistry."""
edges = []
if not parsed or not parsed.body: return edges
lines = parsed.body.splitlines()
for line_num, line_content in enumerate(lines, 1):
for match in _WIKILINK_RE.finditer(line_content):
raw = (match.group(1) or "").strip()
if not raw: continue
if "|" in raw:
parts = raw.split("|", 1)
target, kind = parts[0].strip(), parts[1].strip()
else:
target, kind = raw.strip(), "related_to"
if "#" in target: target = target.split("#", 1)[0].strip()
if target:
edges.append({"to": target, "kind": kind, "line": line_num, "provenance": "explicit"})
return edges

View File

@ -1,157 +1,18 @@
""" """
FILE: app/core/qdrant.py FILE: app/core/qdrant.py
DESCRIPTION: Qdrant-Client Factory und Schema-Management. Erstellt Collections und Payload-Indizes. DESCRIPTION: Proxy-Modul zur Aufrechterhaltung der Abwärtskompatibilität (WP-14).
VERSION: 2.2.0 Leitet alle Aufrufe an das neue database-Paket weiter.
STATUS: Active STATUS: Proxy (Legacy-Support)
DEPENDENCIES: qdrant_client, dataclasses, os
LAST_ANALYSIS: 2025-12-15
""" """
from __future__ import annotations from .database.qdrant import (
QdrantConfig,
import os get_client,
from dataclasses import dataclass ensure_collections,
from typing import Optional, Tuple, Dict, List ensure_payload_indexes,
collection_names
from qdrant_client import QdrantClient )
from qdrant_client.http import models as rest
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
@dataclass
class QdrantConfig:
host: Optional[str] = None
port: Optional[int] = None
url: Optional[str] = None
api_key: Optional[str] = None
prefix: str = "mindnet"
dim: int = 384
distance: str = "Cosine" # Cosine | Dot | Euclid
on_disk_payload: bool = True
@classmethod
def from_env(cls) -> "QdrantConfig":
# Entweder URL ODER Host/Port, API-Key optional
url = os.getenv("QDRANT_URL") or None
host = os.getenv("QDRANT_HOST") or None
port = os.getenv("QDRANT_PORT")
port = int(port) if port else None
api_key = os.getenv("QDRANT_API_KEY") or None
prefix = os.getenv("COLLECTION_PREFIX") or "mindnet"
dim = int(os.getenv("VECTOR_DIM") or 384)
distance = os.getenv("DISTANCE", "Cosine")
on_disk_payload = (os.getenv("ON_DISK_PAYLOAD", "true").lower() == "true")
return cls(
host=host, port=port, url=url, api_key=api_key,
prefix=prefix, dim=dim, distance=distance, on_disk_payload=on_disk_payload
)
def get_client(cfg: QdrantConfig) -> QdrantClient:
# QdrantClient akzeptiert entweder url=... oder host/port
if cfg.url:
return QdrantClient(url=cfg.url, api_key=cfg.api_key, timeout=60.0)
return QdrantClient(host=cfg.host or "127.0.0.1", port=cfg.port or 6333, api_key=cfg.api_key, timeout=60.0)
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
def collection_names(prefix: str) -> Tuple[str, str, str]:
return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges"
def _vector_params(dim: int, distance: str) -> rest.VectorParams:
# Distance: "Cosine" | "Dot" | "Euclid"
dist = getattr(rest.Distance, distance.capitalize(), rest.Distance.COSINE)
return rest.VectorParams(size=dim, distance=dist)
def ensure_collections(client: QdrantClient, prefix: str, dim: int) -> None:
"""Legt mindnet_notes, mindnet_chunks, mindnet_edges an (falls nicht vorhanden)."""
notes, chunks, edges = collection_names(prefix)
# notes
if not client.collection_exists(notes):
client.create_collection(
collection_name=notes,
vectors_config=_vector_params(dim, os.getenv("DISTANCE", "Cosine")),
on_disk_payload=True,
)
# chunks
if not client.collection_exists(chunks):
client.create_collection(
collection_name=chunks,
vectors_config=_vector_params(dim, os.getenv("DISTANCE", "Cosine")),
on_disk_payload=True,
)
# edges (Dummy-Vektor, Filter via Payload)
if not client.collection_exists(edges):
client.create_collection(
collection_name=edges,
vectors_config=_vector_params(1, "Dot"),
on_disk_payload=True,
)
# ---------------------------------------------------------------------------
# Payload-Indizes
# ---------------------------------------------------------------------------
def _ensure_index(client: QdrantClient, collection: str, field: str, schema: rest.PayloadSchemaType) -> None:
"""Idempotentes Anlegen eines Payload-Indexes für ein Feld."""
try:
client.create_payload_index(collection_name=collection, field_name=field, field_schema=schema, wait=True)
except Exception as e:
# Fehler ignorieren, falls Index bereits existiert oder Server "already indexed" meldet.
# Für Debugging ggf. Logging ergänzen.
_ = e
def ensure_payload_indexes(client: QdrantClient, prefix: str) -> None:
"""
Stellt sicher, dass alle benötigten Payload-Indizes existieren.
- notes: note_id(KEYWORD), type(KEYWORD), title(TEXT), updated(INTEGER), tags(KEYWORD)
- chunks: note_id(KEYWORD), chunk_id(KEYWORD), index(INTEGER), type(KEYWORD), tags(KEYWORD)
- edges: note_id(KEYWORD), kind(KEYWORD), scope(KEYWORD), source_id(KEYWORD), target_id(KEYWORD), chunk_id(KEYWORD)
"""
notes, chunks, edges = collection_names(prefix)
# NOTES
for field, schema in [
("note_id", rest.PayloadSchemaType.KEYWORD),
("type", rest.PayloadSchemaType.KEYWORD),
("title", rest.PayloadSchemaType.TEXT),
("updated", rest.PayloadSchemaType.INTEGER),
("tags", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, notes, field, schema)
# CHUNKS
for field, schema in [
("note_id", rest.PayloadSchemaType.KEYWORD),
("chunk_id", rest.PayloadSchemaType.KEYWORD),
("index", rest.PayloadSchemaType.INTEGER),
("type", rest.PayloadSchemaType.KEYWORD),
("tags", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, chunks, field, schema)
# EDGES
for field, schema in [
("note_id", rest.PayloadSchemaType.KEYWORD),
("kind", rest.PayloadSchemaType.KEYWORD),
("scope", rest.PayloadSchemaType.KEYWORD),
("source_id", rest.PayloadSchemaType.KEYWORD),
("target_id", rest.PayloadSchemaType.KEYWORD),
("chunk_id", rest.PayloadSchemaType.KEYWORD),
]:
_ensure_index(client, edges, field, schema)
# Re-Export für 100% Kompatibilität
__all__ = [ __all__ = [
"QdrantConfig", "QdrantConfig",
"get_client", "get_client",

View File

@ -1,292 +1,24 @@
""" """
FILE: app/core/qdrant_points.py FILE: app/core/qdrant_points.py
DESCRIPTION: Object-Mapper für Qdrant. Konvertiert JSON-Payloads (Notes, Chunks, Edges) in PointStructs und generiert deterministische UUIDs. DESCRIPTION: Proxy-Modul zur Aufrechterhaltung der Abwärtskompatibilität (WP-14).
VERSION: 1.5.0 Leitet Point-Operationen an das neue database-Paket weiter.
STATUS: Active STATUS: Proxy (Legacy-Support)
DEPENDENCIES: qdrant_client, uuid, os
LAST_ANALYSIS: 2025-12-15
""" """
from __future__ import annotations from .database.qdrant_points import (
import os points_for_note,
import uuid points_for_chunks,
from typing import List, Tuple, Iterable, Optional, Dict, Any points_for_edges,
upsert_batch,
get_edges_for_sources,
search_chunks_by_vector
)
from qdrant_client.http import models as rest # Re-Export für 100% Kompatibilität
from qdrant_client import QdrantClient __all__ = [
"points_for_note",
# --------------------- ID helpers --------------------- "points_for_chunks",
"points_for_edges",
def _to_uuid(stable_key: str) -> str: "upsert_batch",
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key)) "get_edges_for_sources",
"search_chunks_by_vector"
def _names(prefix: str) -> Tuple[str, str, str]: ]
return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges"
# --------------------- Points builders ---------------------
def points_for_note(prefix: str, note_payload: dict, note_vec: List[float] | None, dim: int) -> Tuple[str, List[rest.PointStruct]]:
notes_col, _, _ = _names(prefix)
vector = note_vec if note_vec is not None else [0.0] * int(dim)
raw_note_id = note_payload.get("note_id") or note_payload.get("id") or "missing-note-id"
point_id = _to_uuid(raw_note_id)
pt = rest.PointStruct(id=point_id, vector=vector, payload=note_payload)
return notes_col, [pt]
def points_for_chunks(prefix: str, chunk_payloads: List[dict], vectors: List[List[float]]) -> Tuple[str, List[rest.PointStruct]]:
_, chunks_col, _ = _names(prefix)
points: List[rest.PointStruct] = []
for i, (pl, vec) in enumerate(zip(chunk_payloads, vectors), start=1):
chunk_id = pl.get("chunk_id") or pl.get("id")
if not chunk_id:
note_id = pl.get("note_id") or pl.get("parent_note_id") or "missing-note"
chunk_id = f"{note_id}#{i}"
pl["chunk_id"] = chunk_id
point_id = _to_uuid(chunk_id)
points.append(rest.PointStruct(id=point_id, vector=vec, payload=pl))
return chunks_col, points
def _normalize_edge_payload(pl: dict) -> dict:
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")
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
return pl
def points_for_edges(prefix: str, edge_payloads: List[dict]) -> Tuple[str, List[rest.PointStruct]]:
_, _, edges_col = _names(prefix)
points: List[rest.PointStruct] = []
for raw in edge_payloads:
pl = _normalize_edge_payload(raw)
edge_id = pl.get("edge_id")
if not edge_id:
kind = pl.get("kind", "edge")
s = pl.get("source_id", "unknown-src")
t = pl.get("target_id", "unknown-tgt")
seq = pl.get("seq") or ""
edge_id = f"{kind}:{s}->{t}#{seq}"
pl["edge_id"] = edge_id
point_id = _to_uuid(edge_id)
points.append(rest.PointStruct(id=point_id, vector=[0.0], payload=pl))
return edges_col, points
# --------------------- Vector schema & overrides ---------------------
def _preferred_name(candidates: List[str]) -> str:
for k in ("text", "default", "embedding", "content"):
if k in candidates:
return k
return sorted(candidates)[0]
def _env_override_for_collection(collection: str) -> Optional[str]:
"""
Returns:
- "__single__" to force single-vector
- concrete name (str) to force named-vector with that name
- None to auto-detect
"""
base = os.getenv("MINDNET_VECTOR_NAME")
if collection.endswith("_notes"):
base = os.getenv("NOTES_VECTOR_NAME", base)
elif collection.endswith("_chunks"):
base = os.getenv("CHUNKS_VECTOR_NAME", base)
elif collection.endswith("_edges"):
base = os.getenv("EDGES_VECTOR_NAME", base)
if not base:
return None
val = base.strip()
if val.lower() in ("__single__", "single"):
return "__single__"
return val # concrete name
def _get_vector_schema(client: QdrantClient, collection_name: str) -> dict:
"""
Return {"kind": "single", "size": int} or {"kind": "named", "names": [...], "primary": str}.
"""
try:
info = client.get_collection(collection_name=collection_name)
vecs = getattr(info, "vectors", None)
# Single-vector config
if hasattr(vecs, "size") and isinstance(vecs.size, int):
return {"kind": "single", "size": vecs.size}
# Named-vectors config (dict-like in .config)
cfg = getattr(vecs, "config", None)
if isinstance(cfg, dict) and cfg:
names = list(cfg.keys())
if names:
return {"kind": "named", "names": names, "primary": _preferred_name(names)}
except Exception:
pass
return {"kind": "single", "size": None}
def _as_named(points: List[rest.PointStruct], name: str) -> List[rest.PointStruct]:
out: List[rest.PointStruct] = []
for pt in points:
vec = getattr(pt, "vector", None)
if isinstance(vec, dict):
if name in vec:
out.append(pt)
else:
# take any existing entry; if empty dict fallback to [0.0]
fallback_vec = None
try:
fallback_vec = list(next(iter(vec.values())))
except Exception:
fallback_vec = [0.0]
out.append(rest.PointStruct(id=pt.id, vector={name: fallback_vec}, payload=pt.payload))
elif vec is not None:
out.append(rest.PointStruct(id=pt.id, vector={name: vec}, payload=pt.payload))
else:
out.append(pt)
return out
# --------------------- Qdrant ops ---------------------
def upsert_batch(client: QdrantClient, collection: str, points: List[rest.PointStruct]) -> None:
if not points:
return
# 1) ENV overrides come first
override = _env_override_for_collection(collection)
if override == "__single__":
client.upsert(collection_name=collection, points=points, wait=True)
return
elif isinstance(override, str):
client.upsert(collection_name=collection, points=_as_named(points, override), wait=True)
return
# 2) Auto-detect schema
schema = _get_vector_schema(client, collection)
if schema.get("kind") == "named":
name = schema.get("primary") or _preferred_name(schema.get("names") or [])
client.upsert(collection_name=collection, points=_as_named(points, name), wait=True)
return
# 3) Fallback single-vector
client.upsert(collection_name=collection, points=points, wait=True)
# --- Optional search helpers ---
def _filter_any(field: str, values: Iterable[str]) -> rest.Filter:
return rest.Filter(should=[rest.FieldCondition(key=field, match=rest.MatchValue(value=v)) for v in values])
def _merge_filters(*filters: Optional[rest.Filter]) -> Optional[rest.Filter]:
fs = [f for f in filters if f is not None]
if not fs:
return None
if len(fs) == 1:
return fs[0]
must = []
for f in fs:
if getattr(f, "must", None):
must.extend(f.must)
if getattr(f, "should", None):
must.append(rest.Filter(should=f.should))
return rest.Filter(must=must)
def _filter_from_dict(filters: Optional[Dict[str, Any]]) -> Optional[rest.Filter]:
if not filters:
return None
parts = []
for k, v in filters.items():
if isinstance(v, (list, tuple, set)):
parts.append(_filter_any(k, [str(x) for x in v]))
else:
parts.append(rest.Filter(must=[rest.FieldCondition(key=k, match=rest.MatchValue(value=v))]))
return _merge_filters(*parts)
def search_chunks_by_vector(client: QdrantClient, prefix: str, vector: List[float], top: int = 10, filters: Optional[Dict[str, Any]] = None) -> List[Tuple[str, float, dict]]:
_, chunks_col, _ = _names(prefix)
flt = _filter_from_dict(filters)
res = client.search(collection_name=chunks_col, query_vector=vector, limit=top, with_payload=True, with_vectors=False, query_filter=flt)
out: List[Tuple[str, float, dict]] = []
for r in res:
out.append((str(r.id), float(r.score), dict(r.payload or {})))
return out
# --- Edge retrieval helper ---
def get_edges_for_sources(
client: QdrantClient,
prefix: str,
source_ids: Iterable[str],
edge_types: Optional[Iterable[str]] = None,
limit: int = 2048,
) -> List[Dict[str, Any]]:
"""Retrieve edge payloads from the <prefix>_edges collection.
Args:
client: QdrantClient instance.
prefix: Mindnet collection prefix (e.g. "mindnet").
source_ids: Iterable of source_id values (typically chunk_ids or note_ids).
edge_types: Optional iterable of edge kinds (e.g. ["references", "depends_on"]). If None,
all kinds are returned.
limit: Maximum number of edge payloads to return.
Returns:
A list of edge payload dicts, e.g.:
{
"note_id": "...",
"chunk_id": "...",
"kind": "references" | "depends_on" | ...,
"scope": "chunk",
"source_id": "...",
"target_id": "...",
"rule_id": "...",
"confidence": 0.7,
...
}
"""
source_ids = list(source_ids)
if not source_ids or limit <= 0:
return []
# Resolve collection name
_, _, edges_col = _names(prefix)
# Build filter: source_id IN source_ids
src_filter = _filter_any("source_id", [str(s) for s in source_ids])
# Optional: kind IN edge_types
kind_filter = None
if edge_types:
kind_filter = _filter_any("kind", [str(k) for k in edge_types])
flt = _merge_filters(src_filter, kind_filter)
out: List[Dict[str, Any]] = []
next_page = None
remaining = int(limit)
# Use paginated scroll API; we don't need vectors, only payloads.
while remaining > 0:
batch_limit = min(256, remaining)
res, next_page = client.scroll(
collection_name=edges_col,
scroll_filter=flt,
limit=batch_limit,
with_payload=True,
with_vectors=False,
offset=next_page,
)
if not res:
break
for r in res:
out.append(dict(r.payload or {}))
remaining -= 1
if remaining <= 0:
break
if next_page is None or remaining <= 0:
break
return out

43
app/core/registry.py Normal file
View File

@ -0,0 +1,43 @@
"""
FILE: app/core/registry.py
DESCRIPTION: Zentraler Base-Layer für Konfigurations-Loading und Text-Bereinigung.
Bricht Zirkelbezüge zwischen Ingestion und LLMService auf.
VERSION: 1.0.0
"""
import os
import yaml
from typing import Optional, List
def load_type_registry(custom_path: Optional[str] = None) -> dict:
"""Lädt die types.yaml zur Steuerung der typ-spezifischen Logik."""
# Wir nutzen hier einen direkten Import von Settings, um Zyklen zu vermeiden
from app.config import get_settings
settings = get_settings()
path = custom_path or settings.MINDNET_TYPES_FILE
if not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
def clean_llm_text(text: str, registry: Optional[dict] = None) -> str:
"""
Entfernt LLM-Steuerzeichen (<s>, [OUT] etc.) aus einem Text.
Wird sowohl für JSON-Parsing als auch für Chat-Antworten genutzt.
"""
if not text or not isinstance(text, str):
return ""
default_patterns = ["<s>", "</s>", "[OUT]", "[/OUT]"]
reg = registry or load_type_registry()
# Lade Patterns aus llm_settings (WP-14)
patterns: List[str] = reg.get("llm_settings", {}).get("cleanup_patterns", default_patterns)
clean = text
for p in patterns:
clean = clean.replace(p, "")
return clean.strip()

View File

@ -0,0 +1,25 @@
"""
PACKAGE: app.core.retrieval
DESCRIPTION: Zentrale Schnittstelle für Retrieval-Operationen (Vektor- & Graph-Suche).
Bündelt Suche und mathematische Scoring-Engine.
"""
from .retriever import (
Retriever,
hybrid_retrieve,
semantic_retrieve
)
from .retriever_scoring import (
get_weights,
compute_wp22_score,
get_status_multiplier
)
__all__ = [
"Retriever",
"hybrid_retrieve",
"semantic_retrieve",
"get_weights",
"compute_wp22_score",
"get_status_multiplier"
]

View File

@ -0,0 +1,312 @@
"""
FILE: app/core/retrieval/retriever.py
DESCRIPTION: Haupt-Schnittstelle für die Suche. Orchestriert Vektorsuche und Graph-Expansion.
Nutzt retriever_scoring.py für die WP-22 Logik.
MODULARISIERUNG: Verschoben in das retrieval-Paket für WP-14.
VERSION: 0.6.16
STATUS: Active
DEPENDENCIES: app.config, app.models.dto, app.core.database*, app.core.graph_adapter
"""
from __future__ import annotations
import os
import time
import logging
from typing import Any, Dict, List, Tuple, Iterable, Optional
from app.config import get_settings
from app.models.dto import (
QueryRequest, QueryResponse, QueryHit,
Explanation, ScoreBreakdown, Reason, EdgeDTO
)
# MODULARISIERUNG: Neue Import-Pfade für die Datenbank-Ebene
import app.core.database.qdrant as qdr
import app.core.database.qdrant_points as qp
import app.services.embeddings_client as ec
import app.core.graph_adapter as ga
# Mathematische Engine importieren (Bleibt vorerst in app.core)
from app.core.retriever_scoring import get_weights, compute_wp22_score
logger = logging.getLogger(__name__)
# ==============================================================================
# 1. CORE HELPERS & CONFIG LOADERS
# ==============================================================================
def _get_client_and_prefix() -> Tuple[Any, str]:
"""Initialisiert Qdrant Client und lädt Collection-Prefix via database-Paket."""
cfg = qdr.QdrantConfig.from_env()
return qdr.get_client(cfg), cfg.prefix
def _get_query_vector(req: QueryRequest) -> List[float]:
"""
Vektorisiert die Anfrage.
FIX: Enthält try-except Block für unterschiedliche Signaturen von ec.embed_text.
"""
if req.query_vector:
return list(req.query_vector)
if not req.query:
raise ValueError("Kein Text oder Vektor für die Suche angegeben.")
settings = get_settings()
try:
# Versuch mit modernem Interface (WP-03 kompatibel)
return ec.embed_text(req.query, model_name=settings.MODEL_NAME)
except TypeError:
# Fallback für Signaturen, die 'model_name' nicht als Keyword akzeptieren
logger.debug("ec.embed_text does not accept 'model_name' keyword. Falling back.")
return ec.embed_text(req.query)
def _semantic_hits(
client: Any,
prefix: str,
vector: List[float],
top_k: int,
filters: Optional[Dict] = None
) -> List[Tuple[str, float, Dict[str, Any]]]:
"""Führt die Vektorsuche via database-Points-Modul durch."""
raw_hits = qp.search_chunks_by_vector(client, prefix, vector, top=top_k, filters=filters)
# Strikte Typkonvertierung für Stabilität
return [(str(hit[0]), float(hit[1]), dict(hit[2] or {})) for hit in raw_hits]
# ==============================================================================
# 2. EXPLANATION LAYER (DEBUG & VERIFIABILITY)
# ==============================================================================
def _build_explanation(
semantic_score: float,
payload: Dict[str, Any],
scoring_debug: Dict[str, Any],
subgraph: Optional[ga.Subgraph],
target_note_id: Optional[str],
applied_boosts: Optional[Dict[str, float]] = None
) -> Explanation:
"""
Transformiert mathematische Scores und Graph-Signale in eine menschenlesbare Erklärung.
Behebt Pydantic ValidationErrors durch explizite String-Sicherung.
"""
_, edge_w_cfg, _ = get_weights()
base_val = scoring_debug["base_val"]
# 1. Detaillierter mathematischer Breakdown
breakdown = ScoreBreakdown(
semantic_contribution=base_val,
edge_contribution=base_val * scoring_debug["edge_impact_final"],
centrality_contribution=base_val * scoring_debug["cent_impact_final"],
raw_semantic=semantic_score,
raw_edge_bonus=scoring_debug["edge_bonus"],
raw_centrality=scoring_debug["cent_bonus"],
node_weight=float(payload.get("retriever_weight", 1.0)),
status_multiplier=scoring_debug["status_multiplier"],
graph_boost_factor=scoring_debug["graph_boost_factor"]
)
reasons: List[Reason] = []
edges_dto: List[EdgeDTO] = []
# 2. Gründe für Semantik hinzufügen
if semantic_score > 0.85:
reasons.append(Reason(kind="semantic", message="Sehr hohe textuelle Übereinstimmung.", score_impact=base_val))
elif semantic_score > 0.70:
reasons.append(Reason(kind="semantic", message="Inhaltliche Übereinstimmung.", score_impact=base_val))
# 3. Gründe für Typ und Lifecycle
type_weight = float(payload.get("retriever_weight", 1.0))
if type_weight != 1.0:
msg = "Bevorzugt" if type_weight > 1.0 else "De-priorisiert"
reasons.append(Reason(kind="type", message=f"{msg} durch Typ-Profil.", score_impact=base_val * (type_weight - 1.0)))
# 4. Kanten-Verarbeitung (Graph-Intelligence)
if subgraph and target_note_id and scoring_debug["edge_bonus"] > 0:
raw_edges = []
if hasattr(subgraph, "get_incoming_edges"):
raw_edges.extend(subgraph.get_incoming_edges(target_note_id) or [])
if hasattr(subgraph, "get_outgoing_edges"):
raw_edges.extend(subgraph.get_outgoing_edges(target_note_id) or [])
for edge in raw_edges:
# FIX: Zwingende String-Konvertierung für Pydantic-Stabilität
src = str(edge.get("source") or "note_root")
tgt = str(edge.get("target") or target_note_id or "unknown_target")
kind = str(edge.get("kind", "related_to"))
prov = str(edge.get("provenance", "rule"))
conf = float(edge.get("confidence", 1.0))
direction = "in" if tgt == target_note_id else "out"
edge_obj = EdgeDTO(
id=f"{src}->{tgt}:{kind}",
kind=kind,
source=src,
target=tgt,
weight=conf,
direction=direction,
provenance=prov,
confidence=conf
)
edges_dto.append(edge_obj)
# Die 3 wichtigsten Kanten als Begründung formulieren
top_edges = sorted(edges_dto, key=lambda e: e.confidence, reverse=True)
for e in top_edges[:3]:
peer = e.source if e.direction == "in" else e.target
prov_txt = "Bestätigte" if e.provenance == "explicit" else "KI-basierte"
boost_txt = f" [Boost x{applied_boosts.get(e.kind)}]" if applied_boosts and e.kind in applied_boosts else ""
reasons.append(Reason(
kind="edge",
message=f"{prov_txt} Kante '{e.kind}'{boost_txt} von/zu '{peer}'.",
score_impact=edge_w_cfg * e.confidence
))
if scoring_debug["cent_bonus"] > 0.01:
reasons.append(Reason(kind="centrality", message="Die Notiz ist ein zentraler Informations-Hub.", score_impact=breakdown.centrality_contribution))
return Explanation(
breakdown=breakdown,
reasons=reasons,
related_edges=edges_dto if edges_dto else None,
applied_boosts=applied_boosts
)
# ==============================================================================
# 3. CORE RETRIEVAL PIPELINE
# ==============================================================================
def _build_hits_from_semantic(
hits: Iterable[Tuple[str, float, Dict[str, Any]]],
top_k: int,
used_mode: str,
subgraph: ga.Subgraph | None = None,
explain: bool = False,
dynamic_edge_boosts: Dict[str, float] = None
) -> QueryResponse:
"""Wandelt semantische Roh-Treffer in bewertete QueryHits um."""
t0 = time.time()
enriched = []
for pid, semantic_score, payload in hits:
edge_bonus, cent_bonus = 0.0, 0.0
target_id = payload.get("note_id")
if subgraph and target_id:
try:
edge_bonus = float(subgraph.edge_bonus(target_id))
cent_bonus = float(subgraph.centrality_bonus(target_id))
except Exception:
pass
# Mathematisches Scoring via WP-22 Engine
debug_data = compute_wp22_score(
semantic_score, payload, edge_bonus, cent_bonus, dynamic_edge_boosts
)
enriched.append((pid, semantic_score, payload, debug_data))
# Sortierung nach finalem mathematischen Score
enriched_sorted = sorted(enriched, key=lambda h: h[3]["total"], reverse=True)
limited_hits = enriched_sorted[: max(1, top_k)]
results: List[QueryHit] = []
for pid, s_score, pl, dbg in limited_hits:
explanation_obj = None
if explain:
explanation_obj = _build_explanation(
semantic_score=float(s_score),
payload=pl,
scoring_debug=dbg,
subgraph=subgraph,
target_note_id=pl.get("note_id"),
applied_boosts=dynamic_edge_boosts
)
# Payload Text-Feld normalisieren
text_content = pl.get("page_content") or pl.get("text") or pl.get("content", "[Kein Text]")
results.append(QueryHit(
node_id=str(pid),
note_id=str(pl.get("note_id", "unknown")),
semantic_score=float(s_score),
edge_bonus=dbg["edge_bonus"],
centrality_bonus=dbg["cent_bonus"],
total_score=dbg["total"],
source={
"path": pl.get("path"),
"section": pl.get("section") or pl.get("section_title"),
"text": text_content
},
payload=pl,
explanation=explanation_obj
))
return QueryResponse(results=results, used_mode=used_mode, latency_ms=int((time.time() - t0) * 1000))
def hybrid_retrieve(req: QueryRequest) -> QueryResponse:
"""
Die Haupt-Einstiegsfunktion für die hybride Suche.
Kombiniert Vektorsuche mit Graph-Expansion und WP-22 Gewichtung.
"""
client, prefix = _get_client_and_prefix()
vector = list(req.query_vector) if req.query_vector else _get_query_vector(req)
top_k = req.top_k or 10
# 1. Semantische Seed-Suche
hits = _semantic_hits(client, prefix, vector, top_k=top_k, filters=req.filters)
# 2. Graph Expansion Konfiguration
expand_cfg = req.expand if isinstance(req.expand, dict) else {}
depth = int(expand_cfg.get("depth", 1))
boost_edges = getattr(req, "boost_edges", {}) or {}
subgraph: ga.Subgraph | None = None
if depth > 0 and hits:
# Start-IDs für den Graph-Traversal sammeln
seed_ids = list({h[2].get("note_id") for h in hits if h[2].get("note_id")})
if seed_ids:
try:
# Subgraph aus RAM/DB laden
subgraph = ga.expand(client, prefix, seed_ids, depth=depth, edge_types=expand_cfg.get("edge_types"))
# --- WP-22: Kanten-Gewichtung im RAM-Graphen vor Bonus-Berechnung ---
if subgraph and hasattr(subgraph, "graph"):
for _, _, data in subgraph.graph.edges(data=True):
# A. Provenance Weighting (WP-22 Bonus für Herkunft)
prov = data.get("provenance", "rule")
# Belohnung: Explizite Links (1.0) > Smart (0.9) > Rule (0.7)
prov_w = 1.0 if prov == "explicit" else (0.9 if prov == "smart" else 0.7)
# B. Intent Boost Multiplikator (Vom Router dynamisch injiziert)
kind = data.get("kind")
intent_multiplier = boost_edges.get(kind, 1.0)
# Finales Gewicht setzen (Basis * Provenance * Intent)
data["weight"] = data.get("weight", 1.0) * prov_w * intent_multiplier
except Exception as e:
logger.error(f"Graph Expansion failed: {e}")
subgraph = None
# 3. Scoring & Explanation Generierung
return _build_hits_from_semantic(hits, top_k, "hybrid", subgraph, req.explain, boost_edges)
def semantic_retrieve(req: QueryRequest) -> QueryResponse:
"""Standard Vektorsuche ohne Graph-Einfluss (WP-02 Fallback)."""
client, prefix = _get_client_and_prefix()
vector = _get_query_vector(req)
hits = _semantic_hits(client, prefix, vector, req.top_k or 10, req.filters)
return _build_hits_from_semantic(hits, req.top_k or 10, "semantic", explain=req.explain)
class Retriever:
"""Schnittstelle für die asynchrone Suche."""
async def search(self, request: QueryRequest) -> QueryResponse:
"""Führt eine hybride Suche aus."""
return hybrid_retrieve(request)

View File

@ -0,0 +1,121 @@
"""
FILE: app/core/retrieval/retriever_scoring.py
DESCRIPTION: Mathematische Kern-Logik für das WP-22 Scoring.
Berechnet Relevanz-Scores basierend auf Semantik, Graph-Intelligence und Content Lifecycle.
MODULARISIERUNG: Verschoben in das retrieval-Paket für WP-14.
VERSION: 1.0.2
STATUS: Active
DEPENDENCIES: app.config, typing
"""
import os
import logging
from functools import lru_cache
from typing import Any, Dict, Tuple, Optional
try:
import yaml
except ImportError:
yaml = None
logger = logging.getLogger(__name__)
@lru_cache
def get_weights() -> Tuple[float, float, float]:
"""
Liefert die Basis-Gewichtung (semantic, edge, centrality) aus der Konfiguration.
Priorität:
1. config/retriever.yaml (Scoring-Sektion)
2. Umgebungsvariablen (RETRIEVER_W_*)
3. System-Defaults (1.0, 0.0, 0.0)
"""
from app.config import get_settings
settings = get_settings()
# Defaults aus Settings laden
sem = float(getattr(settings, "RETRIEVER_W_SEM", 1.0))
edge = float(getattr(settings, "RETRIEVER_W_EDGE", 0.0))
cent = float(getattr(settings, "RETRIEVER_W_CENT", 0.0))
# Optionaler Override via YAML
config_path = os.getenv("MINDNET_RETRIEVER_CONFIG", "config/retriever.yaml")
if yaml and os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
scoring = data.get("scoring", {})
sem = float(scoring.get("semantic_weight", sem))
edge = float(scoring.get("edge_weight", edge))
cent = float(scoring.get("centrality_weight", cent))
except Exception as e:
logger.warning(f"Retriever Configuration could not be fully loaded from {config_path}: {e}")
return sem, edge, cent
def get_status_multiplier(payload: Dict[str, Any]) -> float:
"""
WP-22 A: Content Lifecycle Multiplier.
Steuert das Ranking basierend auf dem Reifegrad der Information.
- stable: 1.2 (Belohnung für verifiziertes Wissen)
- active: 1.0 (Standard-Gewichtung)
- draft: 0.5 (Bestrafung für unfertige Fragmente)
"""
status = str(payload.get("status", "active")).lower().strip()
if status == "stable":
return 1.2
if status == "draft":
return 0.5
return 1.0
def compute_wp22_score(
semantic_score: float,
payload: Dict[str, Any],
edge_bonus_raw: float = 0.0,
cent_bonus_raw: float = 0.0,
dynamic_edge_boosts: Optional[Dict[str, float]] = None
) -> Dict[str, Any]:
"""
Die zentrale mathematische Scoring-Formel der Mindnet Intelligence.
Implementiert das WP-22 Hybrid-Scoring (Semantic * Lifecycle * Graph).
FORMEL:
Score = (Similarity * StatusMult) * (1 + (TypeWeight - 1) + ((EdgeW * EB + CentW * CB) * IntentBoost))
Returns:
Dict mit dem finalen 'total' Score und allen mathematischen Zwischenwerten für den Explanation Layer.
"""
sem_w, edge_w_cfg, cent_w_cfg = get_weights()
status_mult = get_status_multiplier(payload)
# Retriever Weight (Type Boost aus types.yaml, z.B. 1.1 für Decisions)
node_weight = float(payload.get("retriever_weight", 1.0))
# 1. Berechnung des Base Scores (Semantik gewichtet durch Lifecycle-Status)
base_val = float(semantic_score) * status_mult
# 2. Graph Boost Factor (Teil C: Intent-spezifische Verstärkung)
# Erhöht das Gewicht des gesamten Graphen um 50%, wenn ein spezifischer Intent vorliegt.
graph_boost_factor = 1.5 if dynamic_edge_boosts and (edge_bonus_raw > 0 or cent_bonus_raw > 0) else 1.0
# 3. Einzelne Graph-Komponenten berechnen
edge_impact_final = (edge_w_cfg * edge_bonus_raw) * graph_boost_factor
cent_impact_final = (cent_w_cfg * cent_bonus_raw) * graph_boost_factor
# 4. Finales Zusammenführen (Merging)
# (node_weight - 1.0) sorgt dafür, dass ein Gewicht von 1.0 keinen Einfluss hat (neutral).
total = base_val * (1.0 + (node_weight - 1.0) + edge_impact_final + cent_impact_final)
# Sicherstellen, dass der Score niemals 0 oder negativ ist (Floor)
final_score = max(0.0001, float(total))
return {
"total": final_score,
"edge_bonus": float(edge_bonus_raw),
"cent_bonus": float(cent_bonus_raw),
"status_multiplier": status_mult,
"graph_boost_factor": graph_boost_factor,
"type_impact": node_weight - 1.0,
"base_val": base_val,
"edge_impact_final": edge_impact_final,
"cent_impact_final": cent_impact_final
}

View File

@ -1,310 +1,14 @@
""" """
FILE: app/core/retriever.py FILE: app/core/retriever.py
DESCRIPTION: Haupt-Schnittstelle für die Suche. Orchestriert Vektorsuche und Graph-Expansion. DESCRIPTION: Proxy-Modul zur Aufrechterhaltung der Abwärtskompatibilität (WP-14).
Nutzt retriever_scoring.py für die WP-22 Logik. Leitet Retrieval-Anfragen an das neue retrieval-Paket weiter.
FIX: TypeError in embed_text (model_name) behoben. STATUS: Proxy (Legacy-Support)
FIX: Pydantic ValidationError (Target/Source) behoben.
VERSION: 0.6.15 (WP-22 Full & Stable)
STATUS: Active
DEPENDENCIES: app.config, app.models.dto, app.core.qdrant*, app.core.graph_adapter, app.core.retriever_scoring
""" """
from __future__ import annotations from .retrieval.retriever import (
Retriever,
import os hybrid_retrieve,
import time semantic_retrieve
import logging
from typing import Any, Dict, List, Tuple, Iterable, Optional
from app.config import get_settings
from app.models.dto import (
QueryRequest, QueryResponse, QueryHit,
Explanation, ScoreBreakdown, Reason, EdgeDTO
) )
import app.core.qdrant as qdr
import app.core.qdrant_points as qp
import app.services.embeddings_client as ec
import app.core.graph_adapter as ga
# Mathematische Engine importieren # Re-Export für 100% Kompatibilität
from app.core.retriever_scoring import get_weights, compute_wp22_score __all__ = ["Retriever", "hybrid_retrieve", "semantic_retrieve"]
logger = logging.getLogger(__name__)
# ==============================================================================
# 1. CORE HELPERS & CONFIG LOADERS
# ==============================================================================
def _get_client_and_prefix() -> Tuple[Any, str]:
"""Initialisiert Qdrant Client und lädt Collection-Prefix."""
cfg = qdr.QdrantConfig.from_env()
return qdr.get_client(cfg), cfg.prefix
def _get_query_vector(req: QueryRequest) -> List[float]:
"""
Vektorisiert die Anfrage.
FIX: Enthält try-except Block für unterschiedliche Signaturen von ec.embed_text.
"""
if req.query_vector:
return list(req.query_vector)
if not req.query:
raise ValueError("Kein Text oder Vektor für die Suche angegeben.")
settings = get_settings()
try:
# Versuch mit modernem Interface (WP-03 kompatibel)
return ec.embed_text(req.query, model_name=settings.MODEL_NAME)
except TypeError:
# Fallback für Signaturen, die 'model_name' nicht als Keyword akzeptieren
logger.debug("ec.embed_text does not accept 'model_name' keyword. Falling back.")
return ec.embed_text(req.query)
def _semantic_hits(
client: Any,
prefix: str,
vector: List[float],
top_k: int,
filters: Optional[Dict] = None
) -> List[Tuple[str, float, Dict[str, Any]]]:
"""Führt die Vektorsuche durch und konvertiert Qdrant-Points in ein einheitliches Format."""
raw_hits = qp.search_chunks_by_vector(client, prefix, vector, top=top_k, filters=filters)
# Strikte Typkonvertierung für Stabilität
return [(str(hit[0]), float(hit[1]), dict(hit[2] or {})) for hit in raw_hits]
# ==============================================================================
# 2. EXPLANATION LAYER (DEBUG & VERIFIABILITY)
# ==============================================================================
def _build_explanation(
semantic_score: float,
payload: Dict[str, Any],
scoring_debug: Dict[str, Any],
subgraph: Optional[ga.Subgraph],
target_note_id: Optional[str],
applied_boosts: Optional[Dict[str, float]] = None
) -> Explanation:
"""
Transformiert mathematische Scores und Graph-Signale in eine menschenlesbare Erklärung.
Behebt Pydantic ValidationErrors durch explizite String-Sicherung.
"""
_, edge_w_cfg, _ = get_weights()
base_val = scoring_debug["base_val"]
# 1. Detaillierter mathematischer Breakdown
breakdown = ScoreBreakdown(
semantic_contribution=base_val,
edge_contribution=base_val * scoring_debug["edge_impact_final"],
centrality_contribution=base_val * scoring_debug["cent_impact_final"],
raw_semantic=semantic_score,
raw_edge_bonus=scoring_debug["edge_bonus"],
raw_centrality=scoring_debug["cent_bonus"],
node_weight=float(payload.get("retriever_weight", 1.0)),
status_multiplier=scoring_debug["status_multiplier"],
graph_boost_factor=scoring_debug["graph_boost_factor"]
)
reasons: List[Reason] = []
edges_dto: List[EdgeDTO] = []
# 2. Gründe für Semantik hinzufügen
if semantic_score > 0.85:
reasons.append(Reason(kind="semantic", message="Sehr hohe textuelle Übereinstimmung.", score_impact=base_val))
elif semantic_score > 0.70:
reasons.append(Reason(kind="semantic", message="Inhaltliche Übereinstimmung.", score_impact=base_val))
# 3. Gründe für Typ und Lifecycle
type_weight = float(payload.get("retriever_weight", 1.0))
if type_weight != 1.0:
msg = "Bevorzugt" if type_weight > 1.0 else "De-priorisiert"
reasons.append(Reason(kind="type", message=f"{msg} durch Typ-Profil.", score_impact=base_val * (type_weight - 1.0)))
# 4. Kanten-Verarbeitung (Graph-Intelligence)
if subgraph and target_note_id and scoring_debug["edge_bonus"] > 0:
raw_edges = []
if hasattr(subgraph, "get_incoming_edges"):
raw_edges.extend(subgraph.get_incoming_edges(target_note_id) or [])
if hasattr(subgraph, "get_outgoing_edges"):
raw_edges.extend(subgraph.get_outgoing_edges(target_note_id) or [])
for edge in raw_edges:
# FIX: Zwingende String-Konvertierung für Pydantic-Stabilität
src = str(edge.get("source") or "note_root")
tgt = str(edge.get("target") or target_note_id or "unknown_target")
kind = str(edge.get("kind", "related_to"))
prov = str(edge.get("provenance", "rule"))
conf = float(edge.get("confidence", 1.0))
direction = "in" if tgt == target_note_id else "out"
edge_obj = EdgeDTO(
id=f"{src}->{tgt}:{kind}",
kind=kind,
source=src,
target=tgt,
weight=conf,
direction=direction,
provenance=prov,
confidence=conf
)
edges_dto.append(edge_obj)
# Die 3 wichtigsten Kanten als Begründung formulieren
top_edges = sorted(edges_dto, key=lambda e: e.confidence, reverse=True)
for e in top_edges[:3]:
peer = e.source if e.direction == "in" else e.target
prov_txt = "Bestätigte" if e.provenance == "explicit" else "KI-basierte"
boost_txt = f" [Boost x{applied_boosts.get(e.kind)}]" if applied_boosts and e.kind in applied_boosts else ""
reasons.append(Reason(
kind="edge",
message=f"{prov_txt} Kante '{e.kind}'{boost_txt} von/zu '{peer}'.",
score_impact=edge_w_cfg * e.confidence
))
if scoring_debug["cent_bonus"] > 0.01:
reasons.append(Reason(kind="centrality", message="Die Notiz ist ein zentraler Informations-Hub.", score_impact=breakdown.centrality_contribution))
return Explanation(
breakdown=breakdown,
reasons=reasons,
related_edges=edges_dto if edges_dto else None,
applied_boosts=applied_boosts
)
# ==============================================================================
# 3. CORE RETRIEVAL PIPELINE
# ==============================================================================
def _build_hits_from_semantic(
hits: Iterable[Tuple[str, float, Dict[str, Any]]],
top_k: int,
used_mode: str,
subgraph: ga.Subgraph | None = None,
explain: bool = False,
dynamic_edge_boosts: Dict[str, float] = None
) -> QueryResponse:
"""Wandelt semantische Roh-Treffer in hochgeladene, bewertete QueryHits um."""
t0 = time.time()
enriched = []
for pid, semantic_score, payload in hits:
edge_bonus, cent_bonus = 0.0, 0.0
target_id = payload.get("note_id")
if subgraph and target_id:
try:
edge_bonus = float(subgraph.edge_bonus(target_id))
cent_bonus = float(subgraph.centrality_bonus(target_id))
except Exception:
pass
# Mathematisches Scoring via WP-22 Engine
debug_data = compute_wp22_score(
semantic_score, payload, edge_bonus, cent_bonus, dynamic_edge_boosts
)
enriched.append((pid, semantic_score, payload, debug_data))
# Sortierung nach finalem mathematischen Score
enriched_sorted = sorted(enriched, key=lambda h: h[3]["total"], reverse=True)
limited_hits = enriched_sorted[: max(1, top_k)]
results: List[QueryHit] = []
for pid, s_score, pl, dbg in limited_hits:
explanation_obj = None
if explain:
explanation_obj = _build_explanation(
semantic_score=float(s_score),
payload=pl,
scoring_debug=dbg,
subgraph=subgraph,
target_note_id=pl.get("note_id"),
applied_boosts=dynamic_edge_boosts
)
# Payload Text-Feld normalisieren
text_content = pl.get("page_content") or pl.get("text") or pl.get("content", "[Kein Text]")
results.append(QueryHit(
node_id=str(pid),
note_id=str(pl.get("note_id", "unknown")),
semantic_score=float(s_score),
edge_bonus=dbg["edge_bonus"],
centrality_bonus=dbg["cent_bonus"],
total_score=dbg["total"],
source={
"path": pl.get("path"),
"section": pl.get("section") or pl.get("section_title"),
"text": text_content
},
payload=pl,
explanation=explanation_obj
))
return QueryResponse(results=results, used_mode=used_mode, latency_ms=int((time.time() - t0) * 1000))
def hybrid_retrieve(req: QueryRequest) -> QueryResponse:
"""
Die Haupt-Einstiegsfunktion für die hybride Suche.
Kombiniert Vektorsuche mit Graph-Expansion, Provenance-Weighting und Intent-Boosting.
"""
client, prefix = _get_client_and_prefix()
vector = list(req.query_vector) if req.query_vector else _get_query_vector(req)
top_k = req.top_k or 10
# 1. Semantische Seed-Suche
hits = _semantic_hits(client, prefix, vector, top_k=top_k, filters=req.filters)
# 2. Graph Expansion Konfiguration
expand_cfg = req.expand if isinstance(req.expand, dict) else {}
depth = int(expand_cfg.get("depth", 1))
boost_edges = getattr(req, "boost_edges", {}) or {}
subgraph: ga.Subgraph | None = None
if depth > 0 and hits:
# Start-IDs für den Graph-Traversal sammeln
seed_ids = list({h[2].get("note_id") for h in hits if h[2].get("note_id")})
if seed_ids:
try:
# Subgraph aus RAM/DB laden
subgraph = ga.expand(client, prefix, seed_ids, depth=depth, edge_types=expand_cfg.get("edge_types"))
# --- WP-22: Kanten-Gewichtung im RAM-Graphen vor Bonus-Berechnung ---
if subgraph and hasattr(subgraph, "graph"):
for _, _, data in subgraph.graph.edges(data=True):
# A. Provenance Weighting (WP-22 Bonus für Herkunft)
prov = data.get("provenance", "rule")
# Belohnung: Explizite Links (1.0) > Smart (0.9) > Rule (0.7)
prov_w = 1.0 if prov == "explicit" else (0.9 if prov == "smart" else 0.7)
# B. Intent Boost Multiplikator (Vom Router dynamisch injiziert)
kind = data.get("kind")
intent_multiplier = boost_edges.get(kind, 1.0)
# Finales Gewicht setzen (Basis * Provenance * Intent)
data["weight"] = data.get("weight", 1.0) * prov_w * intent_multiplier
except Exception as e:
logger.error(f"Graph Expansion failed: {e}")
subgraph = None
# 3. Scoring & Explanation Generierung
return _build_hits_from_semantic(hits, top_k, "hybrid", subgraph, req.explain, boost_edges)
def semantic_retrieve(req: QueryRequest) -> QueryResponse:
"""Standard Vektorsuche ohne Graph-Einfluss (WP-02 Fallback)."""
client, prefix = _get_client_and_prefix()
vector = _get_query_vector(req)
hits = _semantic_hits(client, prefix, vector, req.top_k or 10, req.filters)
return _build_hits_from_semantic(hits, req.top_k or 10, "semantic", explain=req.explain)
class Retriever:
"""Schnittstelle für die asynchrone Suche."""
async def search(self, request: QueryRequest) -> QueryResponse:
"""Führt eine hybride Suche aus."""
return hybrid_retrieve(request)

View File

@ -1,120 +1,18 @@
""" """
FILE: app/core/retriever_scoring.py FILE: app/core/retriever_scoring.py
DESCRIPTION: Mathematische Kern-Logik für das WP-22 Scoring. DESCRIPTION: Proxy-Modul zur Aufrechterhaltung der Abwärtskompatibilität (WP-14).
Berechnet Relevanz-Scores basierend auf Semantik, Graph-Intelligence und Content Lifecycle. Leitet Scoring-Berechnungen an das neue retrieval-Paket weiter.
VERSION: 1.0.1 (WP-22 Full Math Engine) STATUS: Proxy (Legacy-Support)
STATUS: Active
DEPENDENCIES: app.config, typing
""" """
import os from .retrieval.retriever_scoring import (
import logging get_weights,
from functools import lru_cache compute_wp22_score,
from typing import Any, Dict, Tuple, Optional get_status_multiplier
)
try: # Re-Export für 100% Kompatibilität
import yaml __all__ = [
except ImportError: "get_weights",
yaml = None "compute_wp22_score",
"get_status_multiplier"
logger = logging.getLogger(__name__) ]
@lru_cache
def get_weights() -> Tuple[float, float, float]:
"""
Liefert die Basis-Gewichtung (semantic, edge, centrality) aus der Konfiguration.
Priorität:
1. config/retriever.yaml (Scoring-Sektion)
2. Umgebungsvariablen (RETRIEVER_W_*)
3. System-Defaults (1.0, 0.0, 0.0)
"""
from app.config import get_settings
settings = get_settings()
# Defaults aus Settings laden
sem = float(getattr(settings, "RETRIEVER_W_SEM", 1.0))
edge = float(getattr(settings, "RETRIEVER_W_EDGE", 0.0))
cent = float(getattr(settings, "RETRIEVER_W_CENT", 0.0))
# Optionaler Override via YAML
config_path = os.getenv("MINDNET_RETRIEVER_CONFIG", "config/retriever.yaml")
if yaml and os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
scoring = data.get("scoring", {})
sem = float(scoring.get("semantic_weight", sem))
edge = float(scoring.get("edge_weight", edge))
cent = float(scoring.get("centrality_weight", cent))
except Exception as e:
logger.warning(f"Retriever Configuration could not be fully loaded from {config_path}: {e}")
return sem, edge, cent
def get_status_multiplier(payload: Dict[str, Any]) -> float:
"""
WP-22 A: Content Lifecycle Multiplier.
Steuert das Ranking basierend auf dem Reifegrad der Information.
- stable: 1.2 (Belohnung für verifiziertes Wissen)
- active: 1.0 (Standard-Gewichtung)
- draft: 0.5 (Bestrafung für unfertige Fragmente)
"""
status = str(payload.get("status", "active")).lower().strip()
if status == "stable":
return 1.2
if status == "draft":
return 0.5
return 1.0
def compute_wp22_score(
semantic_score: float,
payload: Dict[str, Any],
edge_bonus_raw: float = 0.0,
cent_bonus_raw: float = 0.0,
dynamic_edge_boosts: Optional[Dict[str, float]] = None
) -> Dict[str, Any]:
"""
Die zentrale mathematische Scoring-Formel der Mindnet Intelligence.
Implementiert das WP-22 Hybrid-Scoring (Semantic * Lifecycle * Graph).
FORMEL:
Score = (Similarity * StatusMult) * (1 + (TypeWeight - 1) + ((EdgeW * EB + CentW * CB) * IntentBoost))
Returns:
Dict mit dem finalen 'total' Score und allen mathematischen Zwischenwerten für den Explanation Layer.
"""
sem_w, edge_w_cfg, cent_w_cfg = get_weights()
status_mult = get_status_multiplier(payload)
# Retriever Weight (Type Boost aus types.yaml, z.B. 1.1 für Decisions)
node_weight = float(payload.get("retriever_weight", 1.0))
# 1. Berechnung des Base Scores (Semantik gewichtet durch Lifecycle-Status)
base_val = float(semantic_score) * status_mult
# 2. Graph Boost Factor (Teil C: Intent-spezifische Verstärkung)
# Erhöht das Gewicht des gesamten Graphen um 50%, wenn ein spezifischer Intent vorliegt.
graph_boost_factor = 1.5 if dynamic_edge_boosts and (edge_bonus_raw > 0 or cent_bonus_raw > 0) else 1.0
# 3. Einzelne Graph-Komponenten berechnen
edge_impact_final = (edge_w_cfg * edge_bonus_raw) * graph_boost_factor
cent_impact_final = (cent_w_cfg * cent_bonus_raw) * graph_boost_factor
# 4. Finales Zusammenführen (Merging)
# (node_weight - 1.0) sorgt dafür, dass ein Gewicht von 1.0 keinen Einfluss hat (neutral).
total = base_val * (1.0 + (node_weight - 1.0) + edge_impact_final + cent_impact_final)
# Sicherstellen, dass der Score niemals 0 oder negativ ist (Floor)
final_score = max(0.0001, float(total))
return {
"total": final_score,
"edge_bonus": float(edge_bonus_raw),
"cent_bonus": float(cent_bonus_raw),
"status_multiplier": status_mult,
"graph_boost_factor": graph_boost_factor,
"type_impact": node_weight - 1.0,
"base_val": base_val,
"edge_impact_final": edge_impact_final,
"cent_impact_final": cent_impact_final
}

View File

@ -1,11 +1,14 @@
""" """
FILE: app/services/edge_registry.py FILE: app/services/edge_registry.py
DESCRIPTION: Single Source of Truth für Kanten-Typen mit dynamischem Reload. DESCRIPTION: Single Source of Truth für Kanten-Typen mit dynamischem Reload.
WP-15b: Erweiterte Provenance-Prüfung für die Candidate-Validation.
Sichert die Graph-Integrität durch strikte Trennung von System- und Inhaltskanten.
WP-22: Fix für absolute Pfade außerhalb des Vaults (Prod-Dictionary). WP-22: Fix für absolute Pfade außerhalb des Vaults (Prod-Dictionary).
WP-20: Synchronisation mit zentralen Settings (v0.6.2). WP-20: Synchronisation mit zentralen Settings (v0.6.2).
VERSION: 0.7.5 VERSION: 0.8.0
STATUS: Active STATUS: Active
DEPENDENCIES: re, os, json, logging, time, app.config DEPENDENCIES: re, os, json, logging, time, app.config
LAST_ANALYSIS: 2025-12-26
""" """
import re import re
import os import os
@ -19,7 +22,12 @@ from app.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EdgeRegistry: class EdgeRegistry:
"""
Zentraler Verwalter für das Kanten-Vokabular.
Implementiert das Singleton-Pattern für konsistente Validierung über alle Services.
"""
_instance = None _instance = None
# System-Kanten, die nicht durch User oder KI gesetzt werden dürfen
FORBIDDEN_SYSTEM_EDGES = {"next", "prev", "belongs_to"} FORBIDDEN_SYSTEM_EDGES = {"next", "prev", "belongs_to"}
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
@ -51,7 +59,7 @@ class EdgeRegistry:
def ensure_latest(self): def ensure_latest(self):
""" """
Prüft den Zeitstempel der Vokabular-Datei und lädt bei Bedarf neu. Prüft den Zeitstempel der Vokabular-Datei und lädt bei Bedarf neu.
Verhindert den AttributeError in der Ingestion-Pipeline. Verhindert Inkonsistenzen bei Laufzeit-Updates des Dictionaries.
""" """
if not os.path.exists(self.full_vocab_path): if not os.path.exists(self.full_vocab_path):
logger.error(f"!!! [EDGE-REGISTRY ERROR] File not found: {self.full_vocab_path} !!!") logger.error(f"!!! [EDGE-REGISTRY ERROR] File not found: {self.full_vocab_path} !!!")
@ -66,7 +74,10 @@ class EdgeRegistry:
logger.error(f"!!! [EDGE-REGISTRY] Error checking file time: {e}") logger.error(f"!!! [EDGE-REGISTRY] Error checking file time: {e}")
def _load_vocabulary(self): def _load_vocabulary(self):
"""Parst das Markdown-Wörterbuch und baut die Canonical-Map auf.""" """
Parst das Markdown-Wörterbuch und baut die Canonical-Map auf.
Erkennt Tabellen-Strukturen und extrahiert fettgedruckte System-Typen.
"""
self.canonical_map.clear() self.canonical_map.clear()
self.valid_types.clear() self.valid_types.clear()
@ -101,8 +112,8 @@ class EdgeRegistry:
def resolve(self, edge_type: str, provenance: str = "explicit", context: dict = None) -> str: def resolve(self, edge_type: str, provenance: str = "explicit", context: dict = None) -> str:
""" """
Validiert einen Kanten-Typ gegen das Vokabular. WP-15b: Validiert einen Kanten-Typ gegen das Vokabular und prüft Berechtigungen.
Loggt unbekannte Typen für die spätere manuelle Pflege. Sichert, dass nur strukturelle Prozesse System-Kanten setzen dürfen.
""" """
self.ensure_latest() self.ensure_latest()
if not edge_type: if not edge_type:
@ -112,20 +123,23 @@ class EdgeRegistry:
clean_type = edge_type.lower().strip().replace(" ", "_").replace("-", "_") clean_type = edge_type.lower().strip().replace(" ", "_").replace("-", "_")
ctx = context or {} ctx = context or {}
# System-Kanten dürfen nicht manuell vergeben werden # WP-15b: System-Kanten dürfen weder manuell noch durch KI/Vererbung gesetzt werden.
if provenance == "explicit" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: # Nur Provenienz 'structure' (interne Prozesse) ist autorisiert.
self._log_issue(clean_type, "forbidden_system_usage", ctx) # Wir blockieren hier alle Provenienzen außer 'structure'.
restricted_provenance = ["explicit", "semantic_ai", "inherited", "global_pool", "rule"]
if provenance in restricted_provenance and clean_type in self.FORBIDDEN_SYSTEM_EDGES:
self._log_issue(clean_type, f"forbidden_usage_by_{provenance}", ctx)
return "related_to" return "related_to"
# System-Kanten sind nur bei struktureller Provenienz erlaubt # System-Kanten sind NUR bei struktureller Provenienz erlaubt
if provenance == "structure" and clean_type in self.FORBIDDEN_SYSTEM_EDGES: if provenance == "structure" and clean_type in self.FORBIDDEN_SYSTEM_EDGES:
return clean_type return clean_type
# Mapping auf kanonischen Namen # Mapping auf kanonischen Namen (Alias-Auflösung)
if clean_type in self.canonical_map: if clean_type in self.canonical_map:
return self.canonical_map[clean_type] return self.canonical_map[clean_type]
# Fallback und Logging # Fallback und Logging unbekannter Typen für Admin-Review
self._log_issue(clean_type, "unknown_type", ctx) self._log_issue(clean_type, "unknown_type", ctx)
return clean_type return clean_type
@ -139,12 +153,13 @@ class EdgeRegistry:
"error": error_kind, "error": error_kind,
"file": ctx.get("file", "unknown"), "file": ctx.get("file", "unknown"),
"line": ctx.get("line", "unknown"), "line": ctx.get("line", "unknown"),
"note_id": ctx.get("note_id", "unknown") "note_id": ctx.get("note_id", "unknown"),
"provenance": ctx.get("provenance", "unknown")
} }
with open(self.unknown_log_path, "a", encoding="utf-8") as f: with open(self.unknown_log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n") f.write(json.dumps(entry) + "\n")
except Exception: except Exception:
pass pass
# Singleton Export # Singleton Export für systemweiten Zugriff
registry = EdgeRegistry() registry = EdgeRegistry()

View File

@ -6,12 +6,11 @@ DESCRIPTION: Hybrid-Client für Ollama, Google GenAI (Gemini) und OpenRouter.
WP-20 Fix: Bulletproof Prompt-Auflösung für format() Aufrufe. WP-20 Fix: Bulletproof Prompt-Auflösung für format() Aufrufe.
WP-22/JSON: Optionales JSON-Schema + strict (für OpenRouter structured outputs). WP-22/JSON: Optionales JSON-Schema + strict (für OpenRouter structured outputs).
FIX: Intelligente Rate-Limit Erkennung (429 Handling), v1-API Sync & Timeouts. FIX: Intelligente Rate-Limit Erkennung (429 Handling), v1-API Sync & Timeouts.
VERSION: 3.3.7 VERSION: 3.3.9
STATUS: Active STATUS: Active
FIX: FIX:
- Implementiert striktes max_retries Handling für alle Provider (v.a. für Chat-Stabilität). - Importiert clean_llm_text von app.core.registry zur Vermeidung von Circular Imports.
- Synchronisiert Rate-Limit Retries mit dem max_retries Parameter. - Wendet clean_llm_text auf Text-Antworten in generate_raw_response an.
- Optimiert Logging für sofortige Fehlererkennung.
""" """
import httpx import httpx
import yaml import yaml
@ -25,6 +24,9 @@ from pathlib import Path
from typing import Optional, Dict, Any, Literal from typing import Optional, Dict, Any, Literal
from app.config import get_settings from app.config import get_settings
# ENTSCHEIDENDER FIX: Import der neutralen Bereinigungs-Logik (WP-14)
from app.core.registry import clean_llm_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -119,22 +121,27 @@ class LLMService:
) -> str: ) -> str:
""" """
Haupteinstiegspunkt für LLM-Anfragen mit Priorisierung. Haupteinstiegspunkt für LLM-Anfragen mit Priorisierung.
Wendet die Bereinigung auf Text-Antworten an.
""" """
target_provider = provider or self.settings.MINDNET_LLM_PROVIDER target_provider = provider or self.settings.MINDNET_LLM_PROVIDER
if priority == "background": if priority == "background":
async with LLMService._background_semaphore: async with LLMService._background_semaphore:
return await self._dispatch( res = await self._dispatch(
target_provider, prompt, system, force_json, target_provider, prompt, system, force_json,
max_retries, base_delay, model_override, max_retries, base_delay, model_override,
json_schema, json_schema_name, strict_json_schema json_schema, json_schema_name, strict_json_schema
) )
# WP-14 Fix: Bereinige Text-Antworten vor Rückgabe
return clean_llm_text(res) if not force_json else res
return await self._dispatch( res = await self._dispatch(
target_provider, prompt, system, force_json, target_provider, prompt, system, force_json,
max_retries, base_delay, model_override, max_retries, base_delay, model_override,
json_schema, json_schema_name, strict_json_schema json_schema, json_schema_name, strict_json_schema
) )
# WP-14 Fix: Bereinige Text-Antworten vor Rückgabe
return clean_llm_text(res) if not force_json else res
async def _dispatch( async def _dispatch(
self, self,
@ -297,6 +304,7 @@ class LLMService:
final_prompt = rag_template.format(context_str=context_str, query=query) final_prompt = rag_template.format(context_str=context_str, query=query)
# RAG Aufrufe im Chat nutzen nun standardmäßig max_retries=2 (überschreibbar) # RAG Aufrufe im Chat nutzen nun standardmäßig max_retries=2 (überschreibbar)
# Durch den Aufruf von generate_raw_response wird die Bereinigung automatisch angewendet.
return await self.generate_raw_response( return await self.generate_raw_response(
final_prompt, final_prompt,
system=system_prompt, system=system_prompt,

View File

@ -1,199 +0,0 @@
"""
FILE: app/services/semantic_analyzer.py
DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen.
WP-20 Fix: Volle Kompatibilität mit der provider-basierten Routing-Logik (OpenRouter Primary).
WP-22: Integration von valid_types zur Halluzinations-Vermeidung.
FIX: Mistral-sicheres JSON-Parsing (<s> & [OUT] Handling) und 100% Logik-Erhalt.
VERSION: 2.2.6
STATUS: Active
DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging, re
"""
import json
import logging
import re
from typing import List, Optional, Any
from dataclasses import dataclass
# Importe
from app.services.llm_service import LLMService
# WP-22: Registry für Vokabular-Erzwingung
from app.services.edge_registry import registry as edge_registry
logger = logging.getLogger(__name__)
class SemanticAnalyzer:
def __init__(self):
self.llm = LLMService()
def _is_valid_edge_string(self, edge_str: str) -> bool:
"""
Prüft, ob ein String eine valide Kante im Format 'kind:target' ist.
Verhindert, dass LLM-Geschwätz als Kante durchrutscht.
"""
if not isinstance(edge_str, str) or ":" not in edge_str:
return False
parts = edge_str.split(":", 1)
kind = parts[0].strip()
target = parts[1].strip()
# Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten.
if " " in kind:
return False
# Regel 2: Plausible Länge für den Typ (Vermeidet Sätze als Typ)
if len(kind) > 40 or len(kind) < 2:
return False
# Regel 3: Target darf nicht leer sein
if not target:
return False
return True
def _extract_json_safely(self, text: str) -> Any:
"""
Extrahiert JSON-Daten und bereinigt LLM-Steuerzeichen (Mistral/Llama).
Implementiert robuste Recovery-Logik für Cloud-Provider.
"""
if not text:
return []
# 1. Entferne Mistral/Llama Steuerzeichen und Tags
clean = text.replace("<s>", "").replace("</s>", "")
clean = clean.replace("[OUT]", "").replace("[/OUT]", "")
clean = clean.strip()
# 2. Suche nach Markdown JSON-Blöcken
match = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL)
payload = match.group(1) if match else clean
try:
return json.loads(payload.strip())
except json.JSONDecodeError:
# 3. Recovery: Suche nach der ersten [ und letzten ]
start = payload.find('[')
end = payload.rfind(']') + 1
if start != -1 and end > start:
try:
return json.loads(payload[start:end])
except: pass
# 4. Zweite Recovery: Suche nach der ersten { und letzten }
start_obj = payload.find('{')
end_obj = payload.rfind('}') + 1
if start_obj != -1 and end_obj > start_obj:
try:
return json.loads(payload[start_obj:end_obj])
except: pass
return []
async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]:
"""
Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM.
Das LLM filtert heraus, welche Kanten für diesen Chunk relevant sind.
WP-20: Nutzt primär den konfigurierten Provider (z.B. OpenRouter).
"""
if not all_edges:
return []
# 1. Bestimmung des Providers und Modells (Dynamisch über Settings)
provider = self.llm.settings.MINDNET_LLM_PROVIDER
model = self.llm.settings.OPENROUTER_MODEL if provider == "openrouter" else self.llm.settings.GEMINI_MODEL
# 2. Prompt laden (Provider-spezifisch via get_prompt)
prompt_template = self.llm.get_prompt("edge_allocation_template", provider)
if not prompt_template or not isinstance(prompt_template, str):
logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' ungültig. Nutze Recovery-Template.")
prompt_template = (
"TASK: Wähle aus den Kandidaten die relevanten Kanten für den Text.\n"
"TEXT: {chunk_text}\n"
"KANDIDATEN: {edge_list}\n"
"OUTPUT: JSON Liste von Strings [\"kind:target\"]."
)
# 3. Daten für Template vorbereiten (Vokabular-Check)
edge_registry.ensure_latest()
valid_types_str = ", ".join(sorted(list(edge_registry.valid_types)))
edges_str = "\n".join([f"- {e}" for e in all_edges])
logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.")
# 4. Prompt füllen mit Format-Check (Kein Shortcut)
try:
# Wir begrenzen den Text auf eine vernünftige Länge für das Kontextfenster
final_prompt = prompt_template.format(
chunk_text=chunk_text[:6000],
edge_list=edges_str,
valid_types=valid_types_str
)
except Exception as format_err:
logger.error(f"❌ [SemanticAnalyzer] Prompt Formatting failed: {format_err}")
return []
try:
# 5. LLM Call mit Background Priority & Semaphore Control
response_json = await self.llm.generate_raw_response(
prompt=final_prompt,
force_json=True,
max_retries=3,
base_delay=2.0,
priority="background",
provider=provider,
model_override=model
)
# 6. Mistral-sicheres JSON Parsing via Helper
data = self._extract_json_safely(response_json)
if not data:
return []
# 7. Robuste Normalisierung (List vs Dict Recovery)
raw_candidates = []
if isinstance(data, list):
raw_candidates = data
elif isinstance(data, dict):
logger.info(f" [SemanticAnalyzer] LLM returned dict, trying recovery.")
for key in ["edges", "results", "kanten", "matches"]:
if key in data and isinstance(data[key], list):
raw_candidates.extend(data[key])
break
# Falls immer noch leer, nutze Schlüssel-Wert Paare als Behelf
if not raw_candidates:
for k, v in data.items():
if isinstance(v, str): raw_candidates.append(f"{k}:{v}")
elif isinstance(v, list):
for target in v:
if isinstance(target, str): raw_candidates.append(f"{k}:{target}")
# 8. Strikte Validierung gegen Kanten-Format
valid_edges = []
for e in raw_candidates:
e_str = str(e).strip()
if self._is_valid_edge_string(e_str):
valid_edges.append(e_str)
else:
logger.debug(f" [SemanticAnalyzer] Rejected invalid edge format: '{e_str}'")
if valid_edges:
logger.info(f"✅ [SemanticAnalyzer] Assigned {len(valid_edges)} edges to chunk.")
return valid_edges
except Exception as e:
logger.error(f"💥 [SemanticAnalyzer] Critical error during analysis: {e}", exc_info=True)
return []
async def close(self):
if self.llm:
await self.llm.close()
# Singleton Instanziierung
_analyzer_instance = None
def get_semantic_analyzer():
global _analyzer_instance
if _analyzer_instance is None:
_analyzer_instance = SemanticAnalyzer()
return _analyzer_instance

View File

@ -1,6 +1,7 @@
# config/prompts.yaml — Final V2.5.5 (OpenRouter Hardening) # config/prompts.yaml — Final V2.6.0 (WP-15b Candidate-Validation)
# WP-20: Optimierte Cloud-Templates zur Unterdrückung von Modell-Geschwätz. # WP-20: Optimierte Cloud-Templates zur Unterdrückung von Modell-Geschwätz.
# FIX: Explizite Verbote für Einleitungstexte zur Vermeidung von JSON-Parsing-Fehlern. # FIX: Explizite Verbote für Einleitungstexte zur Vermeidung von JSON-Parsing-Fehlern.
# WP-15b: Integration der binären edge_validation für den Two-Pass Workflow.
# OLLAMA: UNVERÄNDERT laut Benutzeranweisung. # OLLAMA: UNVERÄNDERT laut Benutzeranweisung.
system_prompt: | system_prompt: |
@ -215,7 +216,7 @@ edge_extraction:
4. Antworte AUSSCHLIESSLICH in validem JSON als Liste von Objekten. 4. Antworte AUSSCHLIESSLICH in validem JSON als Liste von Objekten.
BEISPIEL: BEISPIEL:
[[ {{"to": "Ziel-Konzept", "kind": "beziehungs_typ"}} ]] [[ {{"to": "Ziel-Konzept", \"kind\": \"beziehungs_typ\"}} ]]
TEXT: TEXT:
""" """
@ -227,13 +228,46 @@ edge_extraction:
Analysiere '{note_id}'. Extrahiere semantische Beziehungen. Analysiere '{note_id}'. Extrahiere semantische Beziehungen.
ERLAUBTE TYPEN: {valid_types} ERLAUBTE TYPEN: {valid_types}
TEXT: {text} TEXT: {text}
OUTPUT: STRIKT JSON-Array von Objekten: [[{{"to":"Ziel","kind":"typ"}}]]. Kein Text davor/danach. Wenn nichts: []. OUTPUT: STRIKT JSON-Array von Objekten: [[{{"to\":\"Ziel\",\"kind\":\"typ\"}}]]. Kein Text davor/danach. Wenn nichts: [].
openrouter: | openrouter: |
TASK: Extrahiere semantische Relationen für '{note_id}'. TASK: Extrahiere semantische Relationen für '{note_id}'.
ERLAUBTE TYPEN: {valid_types} ERLAUBTE TYPEN: {valid_types}
TEXT: {text} TEXT: {text}
ANWEISUNG: Antworte AUSSCHLIESSLICH mit einem JSON-Array von Objekten. ANWEISUNG: Antworte AUSSCHLIESSLICH mit einem JSON-Array von Objekten.
FORMAT: [[{{"to":"Ziel-Begriff","kind":"typ"}}]] FORMAT: [[{{"to\":\"Ziel-Begriff\",\"kind\":\"typ\"}}]]
STRIKTES VERBOT: Schreibe keine Einleitung, keine Analyse und keine Erklärungen. STRIKTES VERBOT: Schreibe keine Einleitung, keine Analyse und keine Erklärungen.
Wenn keine Relationen existieren, antworte NUR mit: [] Wenn keine Relationen existieren, antworte NUR mit: []
OUTPUT: OUTPUT:
# ---------------------------------------------------------
# 8. WP-15b: EDGE VALIDATION (Intent: VALIDATE)
# ---------------------------------------------------------
edge_validation:
gemini: |
Bewerte die semantische Validität dieser Verbindung im Wissensgraph.
KONTEXT DER QUELLE (Chunk):
"{chunk_text}"
ZIEL-NOTIZ: "{target_title}"
ZIEL-BESCHREIBUNG (Zusammenfassung):
"{target_summary}"
GEPLANTE RELATION: "{edge_kind}"
FRAGE: Bestätigt der Kontext der Quelle die Beziehung '{edge_kind}' zum Ziel?
REGEL: Antworte NUR mit 'YES' oder 'NO'. Keine Erklärungen oder Smalltalk.
openrouter: |
Verify semantic relation for graph construction.
Source Context: {chunk_text}
Target Note: {target_title}
Target Summary: {target_summary}
Proposed Relation: {edge_kind}
Instruction: Does the source context support this relation to the target?
Result: Respond ONLY with 'YES' or 'NO'.
ollama: |
Bewerte die semantische Korrektheit dieser Verbindung.
QUELLE: {chunk_text}
ZIEL: {target_title} ({target_summary})
BEZIEHUNG: {edge_kind}
Ist diese Verbindung valide? Antworte NUR mit YES oder NO.

View File

@ -1,4 +1,4 @@
version: 2.6.0 # Final WP-15 Config: Smart Edges & Strict/Soft Chunking version: 2.7.0 # WP-14 Update: Dynamisierung der Ingestion-Pipeline
# ============================================================================== # ==============================================================================
# 1. CHUNKING PROFILES # 1. CHUNKING PROFILES
@ -76,7 +76,32 @@ defaults:
edge_defaults: [] edge_defaults: []
# ============================================================================== # ==============================================================================
# 3. TYPE DEFINITIONS # 3. INGESTION SETTINGS (WP-14 Dynamization)
# ==============================================================================
# Steuert, welche Notizen verarbeitet werden und wie Fallbacks aussehen.
ingestion_settings:
# Liste der Status-Werte, die beim Import ignoriert werden sollen.
ignore_statuses: ["system", "template", "archive", "hidden"]
# Standard-Typ, falls kein Typ im Frontmatter angegeben ist.
default_note_type: "concept"
# ==============================================================================
# 4. SUMMARY & SCAN SETTINGS
# ==============================================================================
# Steuert die Tiefe des Pre-Scans für den Context-Cache.
summary_settings:
max_summary_length: 500
pre_scan_depth: 600
# ==============================================================================
# 5. LLM SETTINGS
# ==============================================================================
# Steuerzeichen und Patterns zur Bereinigung der LLM-Antworten.
llm_settings:
cleanup_patterns: ["<s>", "</s>", "[OUT]", "[/OUT]", "```json", "```"]
# ==============================================================================
# 6. TYPE DEFINITIONS
# ============================================================================== # ==============================================================================
types: types:

View File

@ -2,13 +2,13 @@
doc_type: glossary doc_type: glossary
audience: all audience: all
status: active status: active
version: 2.8.0 version: 2.8.1
context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-Cloud Resilienz, WP-76 Quoten-Steuerung und Mistral-safe Parsing." 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."
--- ---
# Mindnet Glossar # Mindnet Glossar
**Quellen:** `01_edge_vocabulary.md`, `llm_service.py`, `ingestion.py`, `edge_registry.py` **Quellen:** `01_edge_vocabulary.md`, `llm_service.py`, `ingestion.py`, `edge_registry.py`, `registry.py`, `qdrant.py`
## Kern-Entitäten ## Kern-Entitäten
@ -21,11 +21,13 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C
## Komponenten ## Komponenten
* **Edge Registry:** Der zentrale Dienst (SSOT), der Kanten-Typen validiert und Aliase in kanonische Typen auflöst. Nutzt `01_edge_vocabulary.md` als Basis. * **Edge Registry:** Der zentrale Dienst (SSOT), der Kanten-Typen validiert und Aliase in kanonische Typen auflöst. Nutzt `01_edge_vocabulary.md` als Basis.
* **LLM Service:** Der Hybrid-Client (v3.3.6), der Anfragen zwischen OpenRouter, Google Gemini und lokalem Ollama routet. Verwaltet Cloud-Timeouts und Quoten-Management. * **LLM Service:** Der Hybrid-Client (v3.3.6), der Anfragen zwischen OpenRouter, Google Gemini und lokalem Ollama routet. Verwaltet Cloud-Timeouts und Quoten-Management. Nutzt zur Text-Bereinigung nun die neutrale `registry.py`, um Circular Imports zu vermeiden.
* **Retriever:** Besteht in v2.7+ aus der Orchestrierung (`retriever.py`) und der mathematischen Scoring-Engine (`retriever_scoring.py`). * **Retriever:** Besteht in v2.7+ aus der Orchestrierung (`retriever.py`) und der mathematischen Scoring-Engine (`retriever_scoring.py`). Seit WP-14 im Paket `app.core.retrieval` gekapselt.
* **Decision Engine:** Teil des Routers, der Intents erkennt und entsprechende **Boost-Faktoren** für das Retrieval injiziert. * **Decision Engine:** Teil des Routers, der Intents erkennt und entsprechende **Boost-Faktoren** für das Retrieval injiziert.
* **Traffic Control:** Verwaltet Prioritäten und drosselt Hintergrund-Tasks (z.B. Smart Edges) mittels Semaphoren und Timeouts (45s) zur Vermeidung von System-Hangs. * **Traffic Control:** Verwaltet Prioritäten und drosselt Hintergrund-Tasks (z.B. Smart Edges) mittels Semaphoren und Timeouts (45s) zur Vermeidung von System-Hangs.
* **Unknown Edges Log:** Die Datei `unknown_edges.jsonl`, in der das System Kanten-Typen protokolliert, die nicht im Dictionary gefunden wurden. * **Unknown Edges Log:** Die Datei `unknown_edges.jsonl`, in der das System Kanten-Typen protokolliert, die nicht im Dictionary gefunden wurden.
* **Database Package (WP-14):** Zentralisiertes Infrastruktur-Paket (`app.core.database`), das den Qdrant-Client (`qdrant.py`) und das Point-Mapping (`qdrant_points.py`) verwaltet.
* **LocalBatchCache (WP-15b):** Ein globaler In-Memory-Index, der während des Pass 1 Scans aufgebaut wird und Metadaten (IDs, Titel, Summaries) aller Notizen für die Kantenvalidierung bereithält.
## Konzepte & Features ## Konzepte & Features
@ -40,5 +42,9 @@ context: "Zentrales Glossar für Mindnet v2.8. Enthält Definitionen zu Hybrid-C
* `explicit`: Vom Mensch gesetzt (Prio 1). * `explicit`: Vom Mensch gesetzt (Prio 1).
* `semantic_ai`: Von der KI im Turbo-Mode extrahiert und validiert (Prio 2). * `semantic_ai`: Von der KI im Turbo-Mode extrahiert und validiert (Prio 2).
* `structure`: Durch System-Regeln/Matrix erzeugt (Prio 3). * `structure`: Durch System-Regeln/Matrix erzeugt (Prio 3).
* **Smart Edge Allocation:** KI-Verfahren zur Relevanzprüfung von Links für spezifische Textabschnitte. * **Smart Edge Allocation (WP-15b):** KI-Verfahren zur Relevanzprüfung von Links für spezifische Textabschnitte. Validiert Kandidaten semantisch gegen das Ziel im LocalBatchCache.
* **Matrix Logic:** Bestimmung des Kanten-Typs basierend auf Quell- und Ziel-Entität (z.B. Erfahrung -> Wert = `based_on`). * **Matrix Logic:** Bestimmung des Kanten-Typs basierend auf Quell- und Ziel-Entität (z.B. Erfahrung -> Wert = `based_on`).
* **Two-Pass Workflow (WP-15b):** Optimiertes Ingestion-Verfahren:
* **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.

View File

@ -1,19 +1,19 @@
--- ---
doc_type: technical_reference doc_type: technical_reference
audience: developer, admin audience: developer, admin
scope: configuration, env, registry, scoring, resilience scope: configuration, env, registry, scoring, resilience, modularization
status: active status: active
version: 2.8.0 version: 2.9.1
context: "Umfassende Referenztabellen für Umgebungsvariablen (inkl. Hybrid-Cloud & WP-76), YAML-Konfigurationen und die Edge Registry Struktur." context: "Umfassende Referenztabellen für Umgebungsvariablen (inkl. Hybrid-Cloud & WP-76), YAML-Konfigurationen und die Edge Registry Struktur unter Berücksichtigung von WP-14."
--- ---
# Konfigurations-Referenz # Konfigurations-Referenz
Dieses Dokument beschreibt alle Steuerungsdateien von Mindnet. In der Version 2.8 wurde die Konfiguration professionalisiert, um die Edge Registry, dynamische Scoring-Parameter (Lifecycle & Intent) sowie die neue Hybrid-Cloud-Resilienz zu unterstützen. Dieses Dokument beschreibt alle Steuerungsdateien von Mindnet. In der Version 2.9.1 wurde die Konfiguration professionalisiert, um die Edge Registry, dynamische Scoring-Parameter (Lifecycle & Intent), die neue Hybrid-Cloud-Resilienz sowie die modulare Datenbank-Infrastruktur (WP-14) zu unterstützen.
## 1. Environment Variablen (`.env`) ## 1. Environment Variablen (`.env`)
Diese Variablen steuern die Infrastruktur, Pfade und globale Timeouts. Diese Variablen steuern die Infrastruktur, Pfade und globale Timeouts. Seit der Modularisierung in WP-14 unterstützen sie zudem die explizite Benennung von Vektoren für verschiedene Collections.
| Variable | Default | Beschreibung | | Variable | Default | Beschreibung |
| :--- | :--- | :--- | | :--- | :--- | :--- |
@ -21,6 +21,10 @@ Diese Variablen steuern die Infrastruktur, Pfade und globale Timeouts.
| `QDRANT_API_KEY` | *(leer)* | Optionaler Key für Absicherung. | | `QDRANT_API_KEY` | *(leer)* | Optionaler Key für Absicherung. |
| `COLLECTION_PREFIX` | `mindnet` | Namensraum für Collections (erzeugt `{prefix}_notes` etc). | | `COLLECTION_PREFIX` | `mindnet` | Namensraum für Collections (erzeugt `{prefix}_notes` etc). |
| `VECTOR_DIM` | `768` | **Muss 768 sein** (für Nomic Embeddings). | | `VECTOR_DIM` | `768` | **Muss 768 sein** (für Nomic Embeddings). |
| `MINDNET_VECTOR_NAME` | `default` | **Neu (WP-14):** Basis-Vektorname für Named Vectors Support. |
| `NOTES_VECTOR_NAME` | *(leer)* | **Neu (WP-14):** Spezifischer Vektorname für die Notes-Collection (Override). |
| `CHUNKS_VECTOR_NAME` | *(leer)* | **Neu (WP-14):** Spezifischer Vektorname für die Chunks-Collection (Override). |
| `EDGES_VECTOR_NAME` | *(leer)* | **Neu (WP-14):** Spezifischer Vektorname für die Edges-Collection (Override). |
| `MINDNET_VOCAB_PATH` | *(Pfad)* | **Neu (WP-22):** Absoluter Pfad zur `01_edge_vocabulary.md`. Definiert den Ort des Dictionarys. | | `MINDNET_VOCAB_PATH` | *(Pfad)* | **Neu (WP-22):** Absoluter Pfad zur `01_edge_vocabulary.md`. Definiert den Ort des Dictionarys. |
| `MINDNET_VAULT_ROOT` | `./vault` | Basis-Pfad für Datei-Operationen. | | `MINDNET_VAULT_ROOT` | `./vault` | Basis-Pfad für Datei-Operationen. |
| `MINDNET_TYPES_FILE` | `config/types.yaml` | Pfad zur Typ-Registry. | | `MINDNET_TYPES_FILE` | `config/types.yaml` | Pfad zur Typ-Registry. |
@ -38,23 +42,25 @@ Diese Variablen steuern die Infrastruktur, Pfade und globale Timeouts.
| `MINDNET_LLM_MODEL` | `phi3:mini` | Name des lokalen Chat-Modells (Ollama). | | `MINDNET_LLM_MODEL` | `phi3:mini` | Name des lokalen Chat-Modells (Ollama). |
| `MINDNET_EMBEDDING_MODEL` | `nomic-embed-text` | Name des Embedding-Modells (Ollama). | | `MINDNET_EMBEDDING_MODEL` | `nomic-embed-text` | Name des Embedding-Modells (Ollama). |
| `MINDNET_OLLAMA_URL` | `http://127.0.0.1:11434`| URL zum lokalen LLM-Server. | | `MINDNET_OLLAMA_URL` | `http://127.0.0.1:11434`| URL zum lokalen LLM-Server. |
| `MAX_OLLAMA_CHARS` | `10000`| Maximale Länge des Kontext-Strings, der an das lokale Modell gesendet wird. Verhindert Batch-Decoding-Fehler bei sehr großen Notiz-Historien. | | `MAX_OLLAMA_CHARS` | `10000`| Maximale Länge des Kontext-Strings, der an das lokale Modell gesendet wird. |
| `MINDNET_LLM_TIMEOUT` | `300.0` | Timeout in Sekunden für LLM-Anfragen. | | `MINDNET_LLM_TIMEOUT` | `300.0` | Timeout in Sekunden für LLM-Anfragen. |
| `MINDNET_API_TIMEOUT` | `300.0` | Globales API-Timeout für das Frontend. | | `MINDNET_API_TIMEOUT` | `300.0` | Globales API-Timeout für das Frontend. |
| `MINDNET_LL_BACKGROUND_LIMIT`| `2` | **Traffic Control:** Max. parallele Hintergrund-Tasks (Semaphore). | | `MINDNET_LL_BACKGROUND_LIMIT`| `2` | **Traffic Control:** Max. parallele Hintergrund-Tasks (Semaphore). |
| `MINDNET_CHANGE_DETECTION_MODE` | `full` | `full` (Text + Meta) oder `body` (nur Text). | | `MINDNET_CHANGE_DETECTION_MODE` | `full` | `full` (Text + Meta) oder `body` (nur Text). |
| `MINDNET_DEFAULT_RETRIEVER_WEIGHT` | `1.0` | **Neu (WP-22):** Systemweiter Standard für das Retriever-Gewicht einer Notiz. |
--- ---
## 2. Typ-Registry (`types.yaml`) ## 2. Typ-Registry (`types.yaml`)
Steuert das Import-Verhalten, Chunking und die Kanten-Logik pro Typ. Steuert das Import-Verhalten, Chunking und die Kanten-Logik pro Typ. Die Auflösung erfolgt zentral über die modularisierte Registry in `app.core.registry`.
### 2.1 Konfigurations-Hierarchie (Override-Logik) ### 2.1 Konfigurations-Hierarchie (Override-Logik)
Seit Version 2.7.0 gilt für `chunking_profile` und `retriever_weight` folgende Priorität: Seit Version 2.7.0 gilt für `chunking_profile` und `retriever_weight` folgende Priorität:
1. **Frontmatter (Höchste Prio):** Ein Wert direkt in der Markdown-Datei überschreibt alles. 1. **Frontmatter (Höchste Prio):** Ein Wert direkt in der Markdown-Datei überschreibt alles.
2. **Type Config:** Der Standardwert für den `type` aus `types.yaml`. 2. **Type Config:** Der Standardwert für den `type` aus `types.yaml`.
3. **Global Default:** Fallback aus `defaults` in `types.yaml`. 3. **Ingestion Settings (Neu WP-14):** Globale Konfiguration wie `default_chunk_profile` innerhalb des `ingestion_settings` Blocks.
4. **Global Default:** Fallback aus `defaults` in `types.yaml`.
## 2.2 Typ-Referenz & Stream-Logik (Vollständige Liste: 28 Typen) ## 2.2 Typ-Referenz & Stream-Logik (Vollständige Liste: 28 Typen)
@ -113,7 +119,7 @@ Dieser Stream speichert deine Erlebnisse, Fakten und externes Wissen als Belege.
## 3. Retriever Config (`retriever.yaml`) ## 3. Retriever Config (`retriever.yaml`)
Steuert die Gewichtung der Scoring-Formel und die neuen Lifecycle-Modifier. Steuert die Gewichtung der Scoring-Formel und die neuen Lifecycle-Modifier. Seit WP-14 ist die mathematische Engine im Paket `app.core.retrieval` gekapselt.
```yaml ```yaml
version: 1.2 version: 1.2
@ -140,43 +146,36 @@ lifecycle_weights:
system: 0.0 # Hard Skip via Ingestion system: 0.0 # Hard Skip via Ingestion
# Die nachfolgenden Werte überschreiben die Defaults aus app/core/retriever_config. # Die nachfolgenden Werte überschreiben die Defaults aus app/core/retriever_config.
# Wenn neue Kantentypen, z.B. durch Referenzierung innerhalb einer md-Datei im vault anders gewichtet werden sollen, dann muss hier die Konfiguration erfolgen
edge_types: edge_types:
# --- KATEGORIE 1: LOGIK-BOOSTS (Relevanz-Treiber) --- # --- KATEGORIE 1: LOGIK-BOOSTS (Relevanz-Treiber) ---
# Diese Kanten haben die Kraft, das semantische Ranking aktiv umzugestalten. blocks: 1.6
blocks: 1.6 # Kritisch: Risiken/Blocker müssen sofort sichtbar sein. solves: 1.5
solves: 1.5 # Zielführend: Lösungen sind primäre Suchziele. depends_on: 1.4
depends_on: 1.4 # Logisch: Harte fachliche Abhängigkeit. resulted_in: 1.4
resulted_in: 1.4 # Kausal: Ergebnisse und unmittelbare Konsequenzen. followed_by: 1.3
followed_by: 1.3 # Sequenziell (User): Bewusst gesteuerte Wissenspfade. caused_by: 1.2
caused_by: 1.2 # Kausal: Ursachen-Bezug (Basis für Intent-Boost). preceded_by: 1.1
preceded_by: 1.1 # Sequenziell (User): Rückwärts-Bezug in Logik-Ketten.
# --- KATEGORIE 2: QUALITATIVER KONTEXT (Stabilitäts-Stützen) --- # --- KATEGORIE 2: QUALITATIVER KONTEXT (Stabilitäts-Stützen) ---
# Diese Kanten liefern wichtigen Kontext, ohne das Ergebnis zu verfälschen. guides: 1.1
guides: 1.1 # Qualitativ: Prinzipien oder Werte leiten das Thema. part_of: 1.1
part_of: 1.1 # Strukturell: Zieht übergeordnete Kontexte (Parents) mit hoch. based_on: 0.8
based_on: 0.8 # Fundament: Bezug auf Basis-Werte (kalibriert auf Safe-Retrieval). derived_from: 0.6
derived_from: 0.6 # Historisch: Dokumentiert die Herkunft von Wissen. uses: 0.6
uses: 0.6 # Instrumentell: Genutzte Werkzeuge, Methoden oder Ressourcen.
# --- KATEGORIE 3: THEMATISCHE NÄHE (Ähnlichkeits-Signal) --- # --- KATEGORIE 3: THEMATISCHE NÄHE (Ähnlichkeits-Signal) ---
# Diese Werte verhindern den "Drift" in fachfremde Bereiche. similar_to: 0.4
similar_to: 0.4 # Analytisch: Thematische Nähe (oft KI-generiert).
# --- KATEGORIE 4: SYSTEM-NUDGES (Technische Struktur) --- # --- KATEGORIE 4: SYSTEM-NUDGES (Technische Struktur) ---
# Reine Orientierungshilfen für das System; fast kein Einfluss auf das Ranking. belongs_to: 0.2
belongs_to: 0.2 # System: Verknüpft Chunks mit der Note (Metadaten-Träger). next: 0.1
next: 0.1 # System: Technische Lesereihenfolge der Absätze. prev: 0.1
prev: 0.1 # System: Technische Lesereihenfolge der Absätze.
# --- KATEGORIE 5: WEICHE ASSOZIATIONEN (Rausch-Unterdrückung) --- # --- KATEGORIE 5: WEICHE ASSOZIATIONEN (Rausch-Unterdrückung) ---
# Verhindert, dass lose Verknüpfungen das Ergebnis "verwässern". references: 0.1
references: 0.1 # Assoziativ: Einfacher Querverweis oder Erwähnung. related_to: 0.05
related_to: 0.05 # Minimal: Schwächste thematische Verbindung.
``` ```
--- ---
## 4. Edge Typen & Registry Referenz ## 4. Edge Typen & Registry Referenz
@ -185,7 +184,7 @@ Die `EdgeRegistry` ist die **Single Source of Truth** für das Vokabular.
### 4.1 Dateistruktur & Speicherort ### 4.1 Dateistruktur & Speicherort
Die Registry erwartet eine Markdown-Datei an folgendem Ort: Die Registry erwartet eine Markdown-Datei an folgendem Ort:
* **Standard-Pfad:** `<MINDNET_VAULT_ROOT>/01_User_Manual/01_edge_vocabulary.md`. * **Standard-Pfad:** `<MINDNET_VAULT_ROOT>/_system/dictionary/edge_vocabulary.md`.
* **Custom-Pfad:** Kann via `.env` Variable `MINDNET_VOCAB_PATH` überschrieben werden. * **Custom-Pfad:** Kann via `.env` Variable `MINDNET_VOCAB_PATH` überschrieben werden.
### 4.2 Aufbau des Dictionaries (Markdown-Schema) ### 4.2 Aufbau des Dictionaries (Markdown-Schema)
@ -199,37 +198,30 @@ Die Datei muss eine Markdown-Tabelle enthalten, die vom Regex-Parser gelesen wir
| **`caused_by`** | `ausgelöst_durch`, `wegen` | Kausalität: A löst B aus. | | **`caused_by`** | `ausgelöst_durch`, `wegen` | Kausalität: A löst B aus. |
``` ```
**Regeln für die Spalten:**
1. **Canonical:** Muss fett gedruckt sein (`**type**` oder `**`type`**`). Dies ist der Wert, der in der DB landet.
2. **Aliasse:** Kommagetrennte Liste von Synonymen. Diese werden beim Import automatisch zum Canonical aufgelöst.
3. **Beschreibung:** Rein informativ für den Nutzer.
### 4.3 Verfügbare Kanten-Typen (System-Standard) ### 4.3 Verfügbare Kanten-Typen (System-Standard)
| System-Typ (Canonical) | Erlaubte Aliasse (User) | Beschreibung | | System-Typ (Canonical) | Erlaubte Aliasse (User) | Beschreibung |
| :--------------------- | :--------------------------------------------------- | :-------------------------------------- | | :--- | :--- | :--- |
| **`caused_by`** | `ausgelöst_durch`, `wegen`, `ursache_ist` | Kausalität: A löst B aus. | | **`caused_by`** | `ausgelöst_durch`, `wegen`, `ursache_ist` | Kausalität: A löst B aus. |
| **`derived_from`** | `abgeleitet_von`, `quelle`, `inspiriert_durch` | Herkunft: A stammt von B. | | **`derived_from`** | `abgeleitet_von`, `quelle`, `inspiriert_durch` | Herkunft: A stammt von B. |
| **`based_on`** | `basiert_auf`, `fundament`, `grundlage` | Fundament: B baut auf A auf. | | **`based_on`** | `basiert_auf`, `fundament`, `grundlage` | Fundament: B baut auf A auf. |
| **`solves`** | `löst`, `beantwortet`, `fix_für` | Lösung: A ist Lösung für Problem B. | | **`solves`** | `löst`, `beantwortet`, `fix_für` | Lösung: A ist Lösung für Problem B. |
| **`part_of`** | `teil_von`, `gehört_zu`, `cluster` | Hierarchie: Kind -> Eltern. | | **`part_of`** | `teil_von`, `gehört_zu`, `cluster` | Hierarchie: Kind -> Eltern. |
| **`depends_on`** | `hängt_ab_von`, `braucht`, `requires`, `enforced_by` | Abhängigkeit: A braucht B. | | **`depends_on`** | `hängt_ab_von`, `braucht`, `requires`, `enforced_by` | Abhängigkeit: A braucht B. |
| **`blocks`** | `blockiert`, `verhindert`, `risiko_für` | Blocker: A verhindert B. | | **`blocks`** | `blockiert`, `verhindert`, `risiko_für` | Blocker: A verhindert B. |
| **`uses`** | `nutzt`, `verwendet`, `tool` | Werkzeug: A nutzt B. | | **`uses`** | `nutzt`, `verwendet`, `tool` | Werkzeug: A nutzt B. |
| **`guides`** | `steuert`, `leitet`, `orientierung` | Soft-Dependency: A gibt Richtung für B. | | **`guides`** | `steuert`, `leitet`, `orientierung` | Soft-Dependency: A gibt Richtung für B. |
| **`followed_by`** | `danach`, `folgt`, `nachfolger`, `followed_by` | Prozess: A -> B. | | **`followed_by`** | `danach`, `folgt`, `nachfolger`, `followed_by` | Prozess: A -> B. |
| **`preceeded_by`** | `davor`, `vorgänger`, `preceded_by` | Prozess: B <- A. | | **`preceeded_by`** | `davor`, `vorgänger`, `preceded_by` | Prozess: B <- A. |
| **`related_to`** | `siehe_auch`, `kontext`, `thematisch` | Lose Assoziation. | | **`related_to`** | `siehe_auch`, `kontext`, `thematisch` | Lose Assoziation. |
| **`similar_to`** | `ähnlich_wie`, `vergleichbar` | Synonym / Ähnlichkeit. | | **`similar_to`** | `ähnlich_wie`, `vergleichbar` | Synonym / Ähnlichkeit. |
| **`references`** | *(Kein Alias)* | Standard-Verweis (Fallback). | | **`references`** | *(Kein Alias)* | Standard-Verweis (Fallback). |
| **`resulted_in`** | `ergebnis`, `resultat`, `erzeugt` | Herkunft: A erzeugt Ergebnis B | | **`resulted_in`** | `ergebnis`, `resultat`, `erzeugt` | Herkunft: A erzeugt Ergebnis B |
**ACHTUNG!** Die Kantentypen **ACHTUNG!** Die Kantentypen **belongs_to**, **next** und **prev** dürfen nicht vom Nutzer gesetzt werden.
**belongs_to**, **next** und **prev** dürfen nicht vom Nutzer gesetzt werden
--- ---
## 5. Decision Engine (`decision_engine.yaml`) ## 5. Decision Engine (`decision_engine.yaml`)
Die Decision Engine fungiert als zentraler Orchestrator für die Intent-Erkennung und das dynamische Retrieval-Routing. Sie bestimmt, wie das System auf eine Nutzeranfrage reagiert, welche Informationstypen bevorzugt werden und wie der Wissensgraph für die spezifische Situation verformt wird. Die Decision Engine fungiert als zentraler Orchestrator für die Intent-Erkennung und das dynamische Retrieval-Routing. Sie bestimmt, wie das System auf eine Nutzeranfrage reagiert, welche Informationstypen bevorzugt werden und wie der Wissensgraph für die spezifische Situation verformt wird.
@ -323,7 +315,4 @@ strategies:
BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE, PRINZIPIEN UND ZIELE AB: BITTE WÄGE FAKTEN GEGEN FOLGENDE WERTE, PRINZIPIEN UND ZIELE AB:
# 3. Empathie / "Ich"-Modus # 3. Empathie / "Ich"-Modus
``` ```
*Richtwert für Kanten-Boosts: 0.1 (Abwertung) bis 3.0+ (Dominanz gegenüber Text-Match).*

View File

@ -3,15 +3,15 @@ doc_type: technical_reference
audience: developer, architect audience: developer, architect
scope: database, qdrant, schema scope: database, qdrant, schema
status: active status: active
version: 2.7.0 version: 2.8.0
context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen." context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen. Berücksichtigt WP-14 Modularisierung und WP-15b Multi-Hashes."
--- ---
# Technisches Datenmodell (Qdrant Schema) # Technisches Datenmodell (Qdrant Schema)
## 1. Collections & Namenskonvention ## 1. Collections & Namenskonvention
Mindnet speichert Daten in drei getrennten Qdrant-Collections. Der Prefix ist via ENV `COLLECTION_PREFIX` konfigurierbar (Default: `mindnet`). Mindnet speichert Daten in drei getrennten Qdrant-Collections. Der Prefix ist via ENV `COLLECTION_PREFIX` konfigurierbar (Default: `mindnet`). Die Auflösung erfolgt zentral über `app.core.database.collection_names`.
Das System nutzt folgende drei Collections: Das System nutzt folgende drei Collections:
* `{prefix}_notes`: Metadaten der Dateien. * `{prefix}_notes`: Metadaten der Dateien.
@ -28,9 +28,10 @@ Repräsentiert die Metadaten einer Markdown-Datei (1:1 Beziehung).
```json ```json
{ {
"note_id": "string (keyword)", // UUIDv5 (deterministisch) oder Slug "note_id": "string (keyword)", // UUIDv5 (deterministisch via NAMESPACE_URL)
"title": "string (text)", // Titel aus Frontmatter "title": "string (text)", // Titel aus Frontmatter
"type": "string (keyword)", // Logischer Typ (z.B. 'project', 'concept') "type": "string (keyword)", // Logischer Typ (z.B. 'project', 'concept')
"status": "string (keyword)", // Lifecycle: 'stable', 'active', 'draft', 'system' (WP-22)
"retriever_weight": "float", // Effektive Wichtigkeit (Frontmatter > Type > Default) "retriever_weight": "float", // Effektive Wichtigkeit (Frontmatter > Type > Default)
"chunk_profile": "string", // Effektives Profil (Frontmatter > Type > Default) "chunk_profile": "string", // Effektives Profil (Frontmatter > Type > Default)
"edge_defaults": ["string"], // Liste der aktiven Default-Kanten "edge_defaults": ["string"], // Liste der aktiven Default-Kanten
@ -40,7 +41,7 @@ Repräsentiert die Metadaten einer Markdown-Datei (1:1 Beziehung).
"updated": "integer", // Timestamp (File Modification Time) "updated": "integer", // Timestamp (File Modification Time)
"fulltext": "string (no-index)", // Gesamter Text (nur für Recovery/Export) "fulltext": "string (no-index)", // Gesamter Text (nur für Recovery/Export)
// NEU in v2.7: Multi-Hash für flexible Change Detection // Multi-Hash für flexible Change Detection (WP-15b)
"hashes": { "hashes": {
"body:parsed:canonical": "string", // Hash nur über den Text-Body "body:parsed:canonical": "string", // Hash nur über den Text-Body
"full:parsed:canonical": "string" // Hash über Text + Metadaten (Tags, Title, Config) "full:parsed:canonical": "string" // Hash über Text + Metadaten (Tags, Title, Config)
@ -52,6 +53,7 @@ Repräsentiert die Metadaten einer Markdown-Datei (1:1 Beziehung).
Es müssen Payload-Indizes für folgende Felder existieren: Es müssen Payload-Indizes für folgende Felder existieren:
* `note_id` * `note_id`
* `type` * `type`
* `status`
* `tags` * `tags`
--- ---
@ -61,7 +63,7 @@ Es müssen Payload-Indizes für folgende Felder existieren:
Die atomare Sucheinheit. Enthält den Vektor. Die atomare Sucheinheit. Enthält den Vektor.
**Vektor-Konfiguration:** **Vektor-Konfiguration:**
* Modell: `nomic-embed-text` * Modell: `nomic-embed-text` (via Ollama oder Cloud)
* Dimension: **768** * Dimension: **768**
* Metrik: Cosine Similarity * Metrik: Cosine Similarity
@ -69,7 +71,7 @@ Die atomare Sucheinheit. Enthält den Vektor.
```json ```json
{ {
"chunk_id": "string (keyword)", // Format: {note_id}#c{index} (z.B. 'abc-123#c01') "chunk_id": "string (keyword)", // Format: UUIDv5 aus {note_id}#c{index}
"note_id": "string (keyword)", // Foreign Key zur Note "note_id": "string (keyword)", // Foreign Key zur Note
"type": "string (keyword)", // Kopie aus Note (Denormalisiert für Filterung) "type": "string (keyword)", // Kopie aus Note (Denormalisiert für Filterung)
"text": "string (text)", // Reintext für Anzeige (ohne Overlap) "text": "string (text)", // Reintext für Anzeige (ohne Overlap)
@ -121,3 +123,4 @@ Es müssen Payload-Indizes für folgende Felder existieren:
* `kind` * `kind`
* `scope` * `scope`
* `note_id` * `note_id`
```

View File

@ -1,71 +1,77 @@
--- ---
doc_type: technical_reference doc_type: technical_reference
audience: developer, devops audience: developer, devops
scope: backend, ingestion, smart_edges, edge_registry scope: backend, ingestion, smart_edges, edge_registry, modularization
status: active status: active
version: 2.8.1 version: 2.9.0
context: "Detaillierte technische Beschreibung der Import-Pipeline, Mistral-safe Parsing und Deep Fallback Resilienz." 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."
--- ---
# Ingestion Pipeline & Smart Processing # Ingestion Pipeline & Smart Processing
**Quellen:** `pipeline_playbook.md`, `ingestion.py`, `edge_registry.py`, `01_edge_vocabulary.md`, `llm_service.py` **Quellen:** `pipeline_playbook.md`, `ingestion_processor.py`, `ingestion_db.py`, `ingestion_validation.py`, `registry.py`, `edge_registry.py`
Die Ingestion transformiert Markdown in den Graphen. Entrypoint: `scripts/import_markdown.py` (CLI) oder `routers/ingest.py` (API). Seit v2.9 nutzt dieser Prozess ein hocheffizientes **Two-Pass-Verfahren**, um globale Kontext-Informationen für die semantische Validierung bereitzustellen, ohne die Idempotenz oder die Change-Detection zu verletzen.
Die Ingestion transformiert Markdown in den Graphen. Entrypoint: `scripts/import_markdown.py` (CLI) oder `routers/ingest.py` (API). Seit v2.8 integriert dieser Prozess eine **intelligente Quoten-Steuerung** (WP-20) und ein **robustes JSON-Parsing** für Cloud-Modelle (Mistral/Gemini).
## 1. Der Import-Prozess (16-Schritte-Workflow) ## 1. Der Import-Prozess (16-Schritte-Workflow)
Der Prozess ist **asynchron** und **idempotent**. Der Prozess ist **asynchron**, **idempotent** und wird nun in zwei logische Durchläufe (Passes) unterteilt, um die semantische Genauigkeit zu maximieren.
### Phase 1: Pre-Scan & Context (Pass 1)
1. **Trigger & Async Dispatch:** 1. **Trigger & Async Dispatch:**
* **API (`/save`):** Nimmt Request entgegen, validiert und startet Background-Task ("Fire & Forget"). Antwortet sofort mit `202/Queued`. * **API (`/save`):** Nimmt Request entgegen, validiert und startet Background-Task ("Fire & Forget"). Antwortet sofort mit `202/Queued`.
* **CLI:** Iteriert über Dateien und nutzt `asyncio.Semaphore` zur Drosselung. * **CLI:** Iteriert über Dateien und nutzt `asyncio.Semaphore` zur Drosselung.
2. **Markdown lesen:** Rekursives Scannen des Vaults. 2. **Markdown lesen:** Rekursives Scannen des Vaults zur Erstellung des Dateiinventars.
3. **Frontmatter Check & Hard Skip (WP-22):** 3. **Frontmatter Check & Hard Skip (WP-22):**
* Extraktion von `status` und `type`. * Extraktion von `status` und `type`.
* **Hard Skip Rule:** Wenn `status` in `['system', 'template', 'archive', 'hidden']` ist, wird die Datei **sofort übersprungen**. Sie wird weder vektorisiert noch in den Graphen aufgenommen. * **Hard Skip Rule:** Wenn `status` in `['system', 'template', 'archive', 'hidden']` ist, wird die Datei für das Deep-Processing übersprungen, ihre Metadaten werden jedoch für den Kontext-Cache erfasst.
* Validierung der Pflichtfelder (`id`, `title`) für alle anderen Dateien. * Validierung der Pflichtfelder (`id`, `title`) für alle anderen Dateien.
4. **Edge Registry Initialisierung (WP-22):** 4. **Edge Registry Initialisierung (WP-22):**
* Laden der Singleton-Instanz der `EdgeRegistry`. * Laden der Singleton-Instanz der `EdgeRegistry`.
* Validierung der Vokabular-Datei unter `MINDNET_VOCAB_PATH`. * Validierung der Vokabular-Datei unter `MINDNET_VOCAB_PATH`.
5. **Config Resolution:** 5. **Config Resolution (WP-14):**
* Bestimmung von `chunking_profile` und `retriever_weight`. * Bestimmung von `chunking_profile` und `retriever_weight` via zentraler `TypeRegistry`.
* **Priorität:** 1. Frontmatter (Override) -> 2. `types.yaml` (Type) -> 3. Global Default. * **Priorität:** 1. Frontmatter (Override) -> 2. `types.yaml` (Type) -> 3. Global Default.
6. **Note-Payload generieren:** 6. **LocalBatchCache & Summary Generation (WP-15b):**
* Erstellen des JSON-Objekts inklusive `status` (für Scoring). * Erstellung von Kurz-Zusammenfassungen für jede Note.
* **Multi-Hash Calculation:** Berechnet Hashtabellen für `body` (nur Text) und `full` (Text + Metadaten). * Speicherung im `batch_cache` als Referenzrahmen für die spätere Kantenvalidierung.
7. **Change Detection:**
* Vergleich des Hashes mit Qdrant. ### Phase 2: Semantic Processing & Persistence (Pass 2)
* Strategie wählbar via ENV `MINDNET_CHANGE_DETECTION_MODE` (`full` oder `body`). 7. **Note-Payload & Multi-Hash (WP-15b):**
8. **Chunking anwenden:** Zerlegung des Textes basierend auf dem ermittelten Profil (siehe Kap. 3). * Erstellen des JSON-Objekts inklusive `status`.
9. **Smart Edge Allocation (WP-20):** * **Multi-Hash Calculation:** Berechnet Hashtabellen für `body` (nur Text) und `full` (Text + Metadaten) zur präzisen Änderungskontrolle.
* Wenn `enable_smart_edge_allocation: true`: Der `SemanticAnalyzer` sendet Chunks an das LLM. 8. **Change Detection:**
* **Traffic Control:** Request nutzt `priority="background"`. Semaphore drosselt die Last. * Vergleich des aktuellen Hashes mit den Daten in Qdrant (Collection `{prefix}_notes`).
* **Resilienz (Quota Handling):** Erkennt HTTP 429 (Rate-Limit) und pausiert kontrolliert (via `LLM_RATE_LIMIT_WAIT`), bevor ein Cloud-Retry erfolgt. * Strategie wählbar via ENV `MINDNET_CHANGE_DETECTION_MODE` (`full` oder `body`). Unveränderte Dateien werden hier final übersprungen.
* **Mistral-safe Parsing:** Automatisierte Bereinigung von BOS-Tokens (`<s>`) und Framework-Tags (`[OUT]`) sowie Recovery-Logik für Dictionaries (Suche nach `edges`, `links`, `results`, `kanten`). 9. **Purge Old Artifacts (WP-14):**
* **Deep Fallback (v2.11.14):** Erkennt "Silent Refusals" (Data Policy Violations). Liefert die Cloud trotz erfolgreicher Verbindung keine verwertbaren Kanten, wird ein lokaler Fallback via Ollama erzwungen, um Kantenverlust zu vermeiden. * Bei Änderungen löscht `purge_artifacts()` via `app.core.ingestion.ingestion_db` alle alten Chunks und Edges der Note.
10. **Inline-Kanten finden:** Parsing von `[[rel:...]]`. * Die Namensauflösung erfolgt nun über das modularisierte `database`-Paket.
11. **Alias-Auflösung & Kanonisierung (WP-22):** 10. **Chunking anwenden:** Zerlegung des Textes basierend auf dem ermittelten Profil (siehe Kap. 3).
* Jede Kante wird via `edge_registry.resolve()` normalisiert. 11. **Smart Edge Allocation & Semantic Validation (WP-15b):**
* Aliase (z.B. `basiert_auf`) werden zu kanonischen Typen (z.B. `based_on`) aufgelöst. * Der `SemanticAnalyzer` schlägt Kanten-Kandidaten vor.
* **Validierung:** Jeder Kandidat wird durch das LLM semantisch gegen das Ziel im **LocalBatchCache** geprüft.
* **Traffic Control:** Nutzung der neutralen `clean_llm_text` Funktion zur Bereinigung von Steuerzeichen (<s>, [OUT]).
* **Deep Fallback (v2.11.14):** Erkennt "Silent Refusals". Liefert die Cloud keine verwertbaren Kanten, wird ein lokaler Fallback via Ollama erzwungen.
12. **Inline-Kanten finden:** Parsing von `[[rel:...]]` und Callouts.
13. **Alias-Auflösung & Kanonisierung (WP-22):**
* Jede Kante wird via `EdgeRegistry` normalisiert (z.B. `basiert_auf` -> `based_on`).
* Unbekannte Typen werden in `unknown_edges.jsonl` protokolliert. * Unbekannte Typen werden in `unknown_edges.jsonl` protokolliert.
12. **Callout-Kanten finden:** Parsing von `> [!edge]`. 14. **Default- & Strukturkanten:** Anwendung der `edge_defaults` und Erzeugung von Systemkanten (`belongs_to`, `next`, `prev`).
13. **Default- & Matrix-Edges erzeugen:** Anwendung der `edge_defaults` aus Registry und Matrix-Logik. 15. **Embedding (Async):** Generierung der Vektoren via `nomic-embed-text` (768 Dimensionen).
14. **Strukturkanten erzeugen:** `belongs_to`, `next`, `prev`. 16. **Database Sync (WP-14):** Batch-Upsert aller Points in die Collections `{prefix}_chunks` und `{prefix}_edges` über die zentrale Infrastruktur.
15. **Embedding (Async):** Generierung via `nomic-embed-text` (768 Dim).
16. **Diagnose:** Integritäts-Check nach dem Lauf.
--- ---
## 2. Betrieb & CLI Befehle ## 2. Betrieb & CLI Befehle
### 2.1 Standard-Betrieb (Inkrementell) ### 2.1 Standard-Betrieb (Inkrementell)
Für regelmäßige Updates (Cronjob). Erkennt Änderungen via Hash. Erkennt Änderungen via Multi-Hash.
```bash ```bash
export QDRANT_URL="http://localhost:6333" export QDRANT_URL="http://localhost:6333"
export COLLECTION_PREFIX="mindnet" export COLLECTION_PREFIX="mindnet"
# Steuert, wann eine Datei als "geändert" gilt
export MINDNET_CHANGE_DETECTION_MODE="full" export MINDNET_CHANGE_DETECTION_MODE="full"
# Nutzt das Venv der Produktionsumgebung # Nutzt das Venv der Produktionsumgebung
@ -78,20 +84,13 @@ export MINDNET_CHANGE_DETECTION_MODE="full"
``` ```
> **[!WARNING] Purge-Before-Upsert** > **[!WARNING] Purge-Before-Upsert**
> Das Flag `--purge-before-upsert` ist kritisch. Es löscht vor dem Schreiben einer Note ihre alten Chunks/Edges. Ohne dieses Flag entstehen **"Geister-Chunks"** (alte Textabschnitte, die im Markdown gelöscht wurden, aber im Index verbleiben). > Das Flag `--purge-before-upsert` nutzt nun `ingestion_db.purge_artifacts`. Es ist kritisch, um "Geister-Chunks" (verwaiste Daten nach Textlöschung) konsistent aus den spezialisierten Collections zu entfernen.
### 2.2 Full Rebuild (Clean Slate) ### 2.2 Full Rebuild (Clean Slate)
Notwendig bei Änderungen an `types.yaml` (z.B. neue Chunking-Profile), der Registry oder Modell-Wechsel. Notwendig bei Änderungen an `types.yaml`, der Registry oder Modell-Wechsel.
```bash ```bash
# 0. Modell sicherstellen # --force ignoriert alle Hashes und erzwingt den vollständigen Two-Pass Workflow
ollama pull nomic-embed-text
# 1. Qdrant Collections löschen (Wipe)
python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes
# 2. Vollständiger Import (Force)
# --force ignoriert alle Hashes und schreibt alles neu
python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force
``` ```
@ -99,22 +98,20 @@ python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --
## 3. Chunking & Payload ## 3. Chunking & Payload
Das Chunking ist profilbasiert und in `types.yaml` konfiguriert. Das Chunking ist profilbasiert und bezieht seine Konfiguration dynamisch aus der `TypeRegistry`.
### 3.1 Profile und Strategien (Vollständige Referenz) ### 3.1 Profile und Strategien
| Profil | Strategie | Parameter | Einsatzgebiet | | Profil | Strategie | Parameter | Einsatzgebiet |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `sliding_short` | `sliding_window` | Max: 350, Target: 200 | Kurze Logs, Chats, Risiken. | | `sliding_short` | `sliding_window` | Max: 350, Target: 200 | Kurze Logs, Chats. |
| `sliding_standard` | `sliding_window` | Max: 650, Target: 450 | Massendaten (Journal, Quellen). | | `sliding_standard` | `sliding_window` | Max: 650, Target: 450 | Standard-Wissen. |
| `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte mit hohem Wert (Projekte). | | `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte (Projekte). |
| `structured_smart_edges` | `by_heading` | `strict: false` (Soft) | Strukturierte Texte, Merging erlaubt. | | `structured_smart_edges` | `by_heading` | `strict: false` | Strukturierte Texte. |
| `structured_smart_edges_strict` | `by_heading` | `strict: true` (Hard) | **Atomare Einheiten**: Entscheidungen, Werte. |
| `structured_smart_edges_strict_L3`| `by_heading` | `strict: true`, `level: 3` | Tief geschachtelte Prinzipien (Tier 2/MP1). |
### 3.2 Die `by_heading` Logik (v2.9 Hybrid) ### 3.2 Die `by_heading` Logik (v2.9 Hybrid)
Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Sie unterstützt seit v2.9 ein "Safety Net" gegen zu große Chunks. Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Sie unterstützt ein "Safety Net" gegen zu große Chunks.
* **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split). * **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split).
* **Modus "Strict" (`strict_heading_split: true`):** * **Modus "Strict" (`strict_heading_split: true`):**
@ -126,12 +123,6 @@ Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften).
* **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. * **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. * *Safety Net:* Auch hier greift das `max` Token Limit.
### 3.3 Payload-Felder (Qdrant)
* `text`: Der reine Inhalt (Anzeige im UI).
* `window`: Inhalt plus Overlap (für Embedding).
* `chunk_profile`: Das effektiv genutzte Profil (zur Nachverfolgung).
--- ---
## 4. Edge-Erzeugung & Prioritäten (Provenance) ## 4. Edge-Erzeugung & Prioritäten (Provenance)
@ -143,7 +134,7 @@ Kanten werden nach Vertrauenswürdigkeit (`provenance`) priorisiert. Die höhere
| **1** | Wikilink | `explicit:wikilink` | **1.00** | Harte menschliche Setzung. | | **1** | Wikilink | `explicit:wikilink` | **1.00** | Harte menschliche Setzung. |
| **2** | Inline | `inline:rel` | **0.95** | Typisierte menschliche Kante. | | **2** | Inline | `inline:rel` | **0.95** | Typisierte menschliche Kante. |
| **3** | Callout | `callout:edge` | **0.90** | Explizite Meta-Information. | | **3** | Callout | `callout:edge` | **0.90** | Explizite Meta-Information. |
| **4** | Semantic AI | `semantic_ai` | **0.90** | KI-extrahierte Verbindung (Mistral-safe). | | **4** | Semantic AI | `semantic_ai` | **0.90** | KI-validiert gegen LocalBatchCache. |
| **5** | Type Default | `edge_defaults` | **0.70** | Heuristik aus der Registry. | | **5** | Type Default | `edge_defaults` | **0.70** | Heuristik aus der Registry. |
| **6** | Struktur | `structure` | **1.00** | System-interne Verkettung (`belongs_to`). | | **6** | Struktur | `structure` | **1.00** | System-interne Verkettung (`belongs_to`). |
@ -151,18 +142,8 @@ Kanten werden nach Vertrauenswürdigkeit (`provenance`) priorisiert. Die höhere
## 5. Quality Gates & Monitoring ## 5. Quality Gates & Monitoring
In v2.7+ wurden Tools zur Überwachung der Datenqualität integriert: **1. Registry Review (WP-14):** Prüfung der `data/logs/unknown_edges.jsonl`. Die zentrale Auflösung via `registry.py` verhindert Inkonsistenzen zwischen Import und Retrieval.
**1. Registry Review:** Prüfung der `data/logs/unknown_edges.jsonl`. Administratoren sollten hier gelistete Begriffe als Aliase in die `01_edge_vocabulary.md` aufnehmen. **2. Mistral-safe Parsing:** Automatisierte Bereinigung von LLM-Antworten in `ingestion_validation.py`. Stellt sicher, dass semantische Entscheidungen ("YES"/"NO") nicht durch technische Header verfälscht werden.
**2. Payload Dryrun (Schema-Check):** **3. Purge Integrity:** Validierung, dass vor jedem Upsert alle assoziierten Artefakte in den Collections `{prefix}_chunks` und `{prefix}_edges` gelöscht wurden, um Daten-Duplikate zu vermeiden.
Simuliert Import, prüft JSON-Schema Konformität.
```bash
python3 -m scripts.payload_dryrun --vault ./test_vault
```
**3. Full Edge Check (Graph-Integrität):**
Prüft Invarianten (z.B. `next` muss reziprok zu `prev` sein).
```bash
python3 -m scripts.edges_full_check
```

View File

@ -3,13 +3,13 @@ doc_type: technical_reference
audience: developer, data_scientist audience: developer, data_scientist
scope: backend, retrieval, scoring, modularization scope: backend, retrieval, scoring, modularization
status: active status: active
version: 2.7.1 version: 2.9.0
context: "Detaillierte Dokumentation der Scoring-Algorithmen, inklusive WP-22 Lifecycle-Modifier, Intent-Boosting und Modularisierung." context: "Detaillierte Dokumentation der Scoring-Algorithmen, inklusive WP-22 Lifecycle-Modifier, Intent-Boosting und WP-14 Modularisierung."
--- ---
# Retrieval & Scoring Algorithmen # Retrieval & Scoring Algorithmen
Der Retriever unterstützt **Semantic Search** und **Hybrid Search**. Seit v2.4 nutzt Mindnet ein gewichtetes Scoring-Modell, das Semantik, Graphentheorie und Metadaten kombiniert. Mit Version 2.7 (WP-22) wurde dieses Modell um **Lifecycle-Faktoren** und **Intent-Boosting** erweitert sowie die Architektur modularisiert. Der Retriever unterstützt **Semantic Search** und **Hybrid Search**. Seit v2.4 nutzt Mindnet ein gewichtetes Scoring-Modell, das Semantik, Graphentheorie und Metadaten kombiniert. Mit Version 2.7 (WP-22) wurde dieses Modell um **Lifecycle-Faktoren** und **Intent-Boosting** erweitert sowie die Architektur modularisiert (WP-14).
## 1. Scoring Formel (v2.7.0) ## 1. Scoring Formel (v2.7.0)
@ -37,18 +37,19 @@ $$
* **Zweck:** Belohnt Chunks, die "im Thema" vernetzt sind. * **Zweck:** Belohnt Chunks, die "im Thema" vernetzt sind.
**4. Centrality Bonus ($B_{cent}$):** **4. Centrality Bonus ($B_{cent}$):**
* **Kontext:** Berechnet im lokalen Subgraphen. * **Kontext:** Berechnet im lokalen Subgraphen via `graph_subgraph.centrality_bonus`.
* **Logik:** Vereinfachte PageRank-Metrik (Degree Centrality). * **Logik:** Vereinfachte PageRank-Metrik (Degree Centrality).
* **Zweck:** Belohnt "Hubs" mit vielen Verbindungen zu anderen Treffern. * **Zweck:** Belohnt "Hubs" mit vielen Verbindungen zu anderen Treffern.
### Die WP-22 Erweiterungen (v2.7.0) ### Die WP-22 Erweiterungen (v2.7.0)
**5. Status Modifier ($M_{status}$):** **5. Status Modifier ($M_{status}$):**
* **Herkunft:** Feld `status` aus dem Frontmatter. * **Herkunft:** Feld `status` aus dem Frontmatter (verarbeitet in `retriever_scoring.get_status_multiplier`).
* **Zweck:** Bestraft unfertiges Wissen (Drafts) oder bevorzugt stabiles Wissen. * **Zweck:** Bestraft unfertiges Wissen (Drafts) oder bevorzugt stabiles Wissen.
* **Werte (Auftrag WP-22):** * `stable`: **1.2** (Bonus für Qualität). * **Werte (Auftrag WP-22):** * `stable`: **1.2** (Belohnung für verifiziertes Wissen).
* `draft`: **0.5** (Malus für Entwürfe). * `active`: **1.0** (Standard-Gewichtung).
* `system`: Exkludiert (siehe Ingestion). * `draft`: **0.5** (Malus für unfertige Fragmente).
* `system`: Exkludiert (siehe Ingestion Lifecycle Filter).
**6. Intent Boost ($B_{intent}$):** **6. Intent Boost ($B_{intent}$):**
* **Herkunft:** Dynamische Injektion durch die Decision Engine basierend auf der Nutzerfrage. * **Herkunft:** Dynamische Injektion durch die Decision Engine basierend auf der Nutzerfrage.
@ -56,47 +57,61 @@ $$
--- ---
## 2. Hybrid Retrieval Flow & Modularisierung ## 2. Hybrid Retrieval Flow & Modularisierung (WP-14)
In v2.7 wurde die Engine in einen Orchestrator (`retriever.py`) und eine Scoring-Engine (`retriever_scoring.py`) aufgeteilt. Seit v2.9 ist die Retrieval-Engine im spezialisierten Paket `app.core.retrieval` gekapselt. Die Zuständigkeiten sind strikt zwischen Orchestrierung und mathematischer Bewertung getrennt.
**Phase 1: Vector Search (Seed Generation)** **Phase 1: Vector Search (Seed Generation)**
* Der Orchestrator sucht Top-K (Standard: 20) Kandidaten via Embeddings in Qdrant. * Der Orchestrator (`retriever.py`) sucht Top-K (Standard: 20) Kandidaten via Embeddings in Qdrant über das modularisierte `app.core.database` Paket.
* Diese bilden die "Seeds" für den Graphen. * Diese bilden die "Seeds" für den Graphen.
**Phase 2: Graph Expansion** **Phase 2: Graph Expansion**
* Nutze `graph_adapter.expand(seeds, depth=1)`. * Nutze die Fassade `app.core.graph_adapter.expand(seeds, depth=1)`.
* Lade direkte Nachbarn aus der `_edges` Collection. * Diese delegiert an `app.core.graph.graph_subgraph`, um direkte Nachbarn aus der `_edges` Collection zu laden.
* Konstruiere einen `NetworkX`-Graphen im Speicher. * Konstruktion eines in-memory Graphen zur Berechnung topologischer Boni.
**Phase 3: Re-Ranking (Modular)** **Phase 3: Re-Ranking (Modular)**
* Der Orchestrator übergibt den Graphen und die Seeds an die `ScoringEngine`. * Der Orchestrator übergibt den Graphen und die Seeds an die `ScoringEngine` (`retriever_scoring.py`).
* Berechne Boni ($B_{edge}$, $B_{cent}$) sowie die neuen Lifecycle- und Intent-Modifier. * Berechnung der finalen Scores unter Berücksichtigung von $B_{edge}$, $B_{cent}$ sowie der Lifecycle- und Intent-Modifier.
* Sortierung absteigend nach `TotalScore` und Limitierung auf Top-Resultate (z.B. 5). * Sortierung absteigend nach `TotalScore` und Limitierung auf die angeforderten Top-Resultate.
--- ---
## 3. Explanation Layer (WP-22 Update) ## 3. Explanation Layer (WP-22 Update)
Bei `explain=True` generiert das System eine detaillierte Begründung. Bei `explain=True` generiert das System eine detaillierte Begründung inklusive Provenienz-Informationen.
**Erweiterte JSON-Struktur:** **Erweiterte JSON-Struktur:**
```json ```json
{ {
"score_breakdown": { "score_breakdown": {
"semantic": 0.85, "semantic_contribution": 0.85,
"type_boost": 1.0, "edge_contribution": 0.4,
"lifecycle_modifier": 0.5, "centrality_contribution": 0.1,
"edge_bonus": 0.4, "raw_semantic": 0.85,
"intent_boost": 0.5, "raw_edge_bonus": 0.3,
"centrality": 0.1 "raw_centrality": 0.1,
"node_weight": 1.0,
"status_multiplier": 1.2,
"graph_boost_factor": 1.5
}, },
"reasons": [ "reasons": [
"Hohe textuelle Übereinstimmung (>0.85).", {
"Status 'draft' reduziert Relevanz (Modifier 0.5).", "kind": "semantic",
"Wird referenziert via 'caused_by' (Intent-Bonus 0.5).", "message": "Hohe textuelle Übereinstimmung (>0.85).",
"Bevorzugt, da Typ 'decision' (Gewicht 1.0)." "score_impact": 0.85
},
{
"kind": "type",
"message": "Bevorzugt durch Typ-Profil.",
"score_impact": 0.1
},
{
"kind": "edge",
"message": "Bestätigte Kante 'caused_by' [Boost x1.5] von 'Note-A'.",
"score_impact": 0.4
}
] ]
} }
``` ```
@ -105,18 +120,18 @@ Bei `explain=True` generiert das System eine detaillierte Begründung.
## 4. Konfiguration (`retriever.yaml`) ## 4. Konfiguration (`retriever.yaml`)
Steuert die Gewichtung der mathematischen Komponenten. Steuert die globale Gewichtung der mathematischen Komponenten.
```yaml ```yaml
scoring: scoring:
semantic_weight: 1.0 # Basis-Relevanz semantic_weight: 1.0 # Basis-Relevanz (W_sem)
edge_weight: 0.7 # Graphen-Einfluss edge_weight: 0.7 # Graphen-Einfluss (W_edge)
centrality_weight: 0.5 # Hub-Einfluss centrality_weight: 0.5 # Hub-Einfluss (W_cent)
# WP-22 Lifecycle Konfiguration (Abgleich mit Auftrag) # WP-22 Lifecycle Konfiguration
lifecycle_weights: lifecycle_weights:
stable: 1.2 # Bonus für Qualität stable: 1.2 # Modifier für Qualität
draft: 0.5 # Malus für Entwürfe draft: 0.5 # Modifier für Entwürfe
# Kanten-Gewichtung für den Edge-Bonus (Basis) # Kanten-Gewichtung für den Edge-Bonus (Basis)
edge_weights: edge_weights:

View File

@ -1,10 +1,10 @@
--- ---
doc_type: developer_guide doc_type: developer_guide
audience: developer audience: developer
scope: workflow, testing, architecture, modules scope: workflow, testing, architecture, modules, modularization
status: active status: active
version: 2.6.1 version: 2.9.1
context: "Umfassender Guide für Entwickler: Architektur, Modul-Interna (Deep Dive), Setup, Git-Workflow und Erweiterungs-Anleitungen." context: "Umfassender Guide für Entwickler: Modularisierte Architektur (WP-14), Two-Pass Ingestion (WP-15b), Modul-Interna, Setup und Git-Workflow."
--- ---
# Mindnet Developer Guide & Workflow # Mindnet Developer Guide & Workflow
@ -23,8 +23,6 @@ Dieser Guide ist die zentrale technische Referenz für Mindnet v2.6. Er vereint
- [Kern-Philosophie](#kern-philosophie) - [Kern-Philosophie](#kern-philosophie)
- [2. Architektur](#2-architektur) - [2. Architektur](#2-architektur)
- [2.1 High-Level Übersicht](#21-high-level-übersicht) - [2.1 High-Level Übersicht](#21-high-level-übersicht)
- [2.2 Datenfluss-Muster](#22-datenfluss-muster)
- [A. Ingestion (Write)](#a-ingestion-write)
- [B. Retrieval (Read)](#b-retrieval-read) - [B. Retrieval (Read)](#b-retrieval-read)
- [C. Visualisierung (Graph)](#c-visualisierung-graph) - [C. Visualisierung (Graph)](#c-visualisierung-graph)
- [3. Physische Architektur](#3-physische-architektur) - [3. Physische Architektur](#3-physische-architektur)
@ -84,23 +82,28 @@ graph TD
API["main.py"] API["main.py"]
RouterChat["Chat / RAG"] RouterChat["Chat / RAG"]
RouterIngest["Ingest / Write"] RouterIngest["Ingest / Write"]
CoreRet["Retriever Engine"]
CoreIngest["Ingestion Pipeline"] subgraph "Core Packages (WP-14)"
PkgRet["retrieval/ (Search)"]
PkgIng["ingestion/ (Import)"]
PkgGra["graph/ (Logic)"]
PkgDb["database/ (Infrastr.)"]
Registry["registry.py (Neutral)"]
end
end end
subgraph "Infrastructure & Services" subgraph "Infrastructure & Services"
LLM["Ollama (Phi3/Nomic)"] LLM["Ollama / Cloud (Hybrid)"]
DB[("Qdrant Vector DB")] DB[("Qdrant Vector DB")]
FS["File System (.md)"] FS["File System (.md)"]
end end
User <--> UI User <--> UI
UI -- "REST (Chat, Save, Feedback)" --> API UI -- "REST Call" --> API
UI -. "Direct Read (Graph Viz Performance)" .-> DB PkgRet -- "Direct Query" --> PkgDb
API -- "Embeddings & Completion" --> LLM PkgIng -- "Process & Write" --> PkgDb
API -- "Read/Write" --> DB PkgDb -- "API" --> DB
API -- "Read/Write (Source of Truth)" --> FS API -- "Inference" --> LLM```
```
### 2.2 Datenfluss-Muster ### 2.2 Datenfluss-Muster
@ -108,14 +111,12 @@ graph TD
Vom Markdown zur Vektor-Datenbank. Vom Markdown zur Vektor-Datenbank.
```mermaid ```mermaid
graph LR graph LR
MD["Markdown File"] --> Parser("Parser") MD["Markdown File"] --> Pass1["Pass 1: Pre-Scan"]
Parser --> Chunker("Chunker") Pass1 --> Cache[("LocalBatchCache<br/>(Titles/Summaries)")]
Chunker -- "Text Chunks" --> SemAn{"SemanticAnalyzer<br/>(LLM)"} MD --> Pass2["Pass 2: Processing"]
SemAn -- "Smart Edges" --> Embedder("Embedder") Cache -- "Context" --> SmartEdges{"Smart Edge<br/>Validation"}
Embedder --> DB[("Qdrant<br/>Points")] SmartEdges --> Embedder("Embedder")
Embedder --> DB[("Qdrant Points")]
style DB fill:#f9f,stroke:#333,stroke-width:2px
style SemAn fill:#ff9,stroke:#333,stroke-width:2px
``` ```
#### B. Retrieval (Read) #### B. Retrieval (Read)
@ -123,17 +124,10 @@ Die hybride Suche für Chat & RAG.
```mermaid ```mermaid
graph LR graph LR
Query(["Query"]) --> Embed("Embedding") Query(["Query"]) --> Embed("Embedding")
Embed --> Hybrid{"Hybrid Search"} Embed --> Seed["Seed Search (Vector)"]
Seed --> Expand{"Graph Expansion"}
subgraph Search Components Expand --> Scoring["Scoring Engine (WP-22)"]
Vec["Vector Score"] Scoring --> Rank("Final Ranking")
Graph["Graph/Edge Bonus"]
end
Vec --> Hybrid
Graph --> Hybrid
Hybrid --> Rank("Re-Ranking")
Rank --> Ctx["LLM Context"] Rank --> Ctx["LLM Context"]
``` ```
@ -170,6 +164,12 @@ Das System ist modular aufgebaut. Hier ist die detaillierte Analyse aller Kompon
mindnet/ mindnet/
├── app/ ├── app/
│ ├── core/ # Business Logic & Algorithms │ ├── core/ # Business Logic & Algorithms
│ │ ├── database/ # WP-14: Qdrant Client & Point Mapping
│ │ ├── ingestion/ # WP-14: Pipeline, Multi-Hash, Validation
│ │ ├── retrieval/ # WP-14: Search Orchestrator & Scoring
│ │ ├── graph/ # WP-14: Subgraph-Logik & Weights
│ │ ├── registry.py # SSOT: Circular Import Fix & Text Cleanup
│ │ └── *.py (Proxy) # Legacy Bridges für Abwärtskompatibilität
│ ├── routers/ # API Interface (FastAPI) │ ├── routers/ # API Interface (FastAPI)
│ ├── services/ # External Integrations (LLM, DB) │ ├── services/ # External Integrations (LLM, DB)
│ ├── models/ # Pydantic DTOs │ ├── models/ # Pydantic DTOs
@ -285,6 +285,8 @@ Folgende Dateien wurden im Audit v2.6 als veraltet, redundant oder "Zombie-Code"
| `app/core/type_registry.py` | **Redundant.** Logik in `ingestion.py` integriert. | 🗑️ Löschen | | `app/core/type_registry.py` | **Redundant.** Logik in `ingestion.py` integriert. | 🗑️ Löschen |
| `app/core/env_vars.py` | **Veraltet.** Ersetzt durch `config.py`. | 🗑️ Löschen | | `app/core/env_vars.py` | **Veraltet.** Ersetzt durch `config.py`. | 🗑️ Löschen |
| `app/services/llm_ollama.py` | **Veraltet.** Ersetzt durch `llm_service.py`. | 🗑️ Löschen | | `app/services/llm_ollama.py` | **Veraltet.** Ersetzt durch `llm_service.py`. | 🗑️ Löschen |
| `app/core/type_registry.py` | **Redundant.** Logik in `app/core/registry.py` integriert. | 🗑️ Löschen |
| `app/core/ranking.py` | **Redundant.** Logik in `retrieval/retriever_scoring.py` integriert. | 🗑️ Löschen |
--- ---

View File

@ -2,18 +2,14 @@
doc_type: roadmap doc_type: roadmap
audience: product_owner, developer audience: product_owner, developer
status: active status: active
version: 2.8.0 version: 2.9.1
context: "Aktuelle Planung für kommende Features (ab WP16), Release-Strategie und Historie der abgeschlossenen WPs." context: "Aktuelle Planung für kommende Features (ab WP16), Release-Strategie und Historie der abgeschlossenen WPs nach WP-14/15b."
--- ---
# Mindnet Active Roadmap # Mindnet Active Roadmap
**Aktueller Stand:** v2.8.0 (Post-WP20/WP76) **Aktueller Stand:** v2.9.1 (Post-WP14 / WP-15b)
**Fokus:** Visualisierung, Exploration & Cloud-Resilienz. **Fokus:** Modularisierung, Two-Pass Ingestion & Graph Intelligence.
## 1. Programmstatus
Wir haben mit der Implementierung des Graph Explorers (WP19), der Smart Edge Allocation (WP15) und der hybriden Cloud-Resilienz (WP20) die Basis für ein intelligentes, robustes System gelegt. Der nächste Schritt (WP19a) vertieft die Analyse, während WP16 die "Eingangs-Intelligenz" erhöht.
| Phase | Fokus | Status | | Phase | Fokus | Status |
| :--- | :--- | :--- | | :--- | :--- | :--- |
@ -45,6 +41,8 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio
| **WP-10** | Web UI | Streamlit-Frontend als Ersatz für das Terminal. | | **WP-10** | Web UI | Streamlit-Frontend als Ersatz für das Terminal. |
| **WP-10a**| Draft Editor | GUI-Komponente zum Bearbeiten und Speichern generierter Notizen. | | **WP-10a**| Draft Editor | GUI-Komponente zum Bearbeiten und Speichern generierter Notizen. |
| **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. | | **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. |
| **WP-14** | **Modularisierung & Refactoring** | **Ergebnis:** Aufteilung in domänenspezifische Pakete (`database`, `ingestion`, `retrieval`, `graph`). Implementierung von Proxy-Adaptern für Abwärtskompatibilität und `registry.py` zur Lösung von Zirkelbezügen. |
| **WP-15b**| **Candidate-Based Validation** | **Ergebnis:** Implementierung des **Two-Pass Workflows**. Einführung des `LocalBatchCache` und binäre semantische Validierung von Kanten-Kandidaten zur Vermeidung von Halluzinationen. |
| **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore) + Strict Chunking. | | **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore) + Strict Chunking. |
| **WP-19** | Graph Visualisierung | **Frontend Modularisierung:** Umbau auf `ui_*.py`.<br>**Graph Engines:** Parallelbetrieb von Cytoscape (COSE) und Agraph.<br>**Tools:** "Single Source of Truth" Editor, Persistenz via URL. | | **WP-19** | Graph Visualisierung | **Frontend Modularisierung:** Umbau auf `ui_*.py`.<br>**Graph Engines:** Parallelbetrieb von Cytoscape (COSE) und Agraph.<br>**Tools:** "Single Source of Truth" Editor, Persistenz via URL. |
| **WP-20** | **Cloud Hybrid Mode & Resilienz** | **Ergebnis:** Integration von OpenRouter (Mistral 7B) & Gemini 2.5 Lite. Implementierung von WP-76 (Rate-Limit Wait) & Mistral-safe JSON Parsing. | | **WP-20** | **Cloud Hybrid Mode & Resilienz** | **Ergebnis:** Integration von OpenRouter (Mistral 7B) & Gemini 2.5 Lite. Implementierung von WP-76 (Rate-Limit Wait) & Mistral-safe JSON Parsing. |
@ -59,6 +57,10 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio
* **Quoten-Management:** Die Nutzung von Free-Tier Modellen (Mistral/OpenRouter) erfordert zwingend eine intelligente Rate-Limit-Erkennung (HTTP 429) mit automatisierten Wartezyklen, um Batch-Prozesse stabil zu halten. * **Quoten-Management:** Die Nutzung von Free-Tier Modellen (Mistral/OpenRouter) erfordert zwingend eine intelligente Rate-Limit-Erkennung (HTTP 429) mit automatisierten Wartezyklen, um Batch-Prozesse stabil zu halten.
* **Parser-Robustheit:** Cloud-Modelle betten JSON oft in technische Steuerzeichen (`<s>`, `[OUT]`) ein. Ein robuster Extraktor mit Recovery-Logik ist essentiell zur Vermeidung von Datenverlust. * **Parser-Robustheit:** Cloud-Modelle betten JSON oft in technische Steuerzeichen (`<s>`, `[OUT]`) ein. Ein robuster Extraktor mit Recovery-Logik ist essentiell zur Vermeidung von Datenverlust.
### 2.3 WP-14 & WP-15b Lessons Learned
* **Performance:** Der Pre-Scan (Pass 1) ist minimal invasiv, ermöglicht aber in Pass 2 eine drastische Reduktion der LLM-Kosten, da nur noch binär validiert werden muss, anstatt komplexe Extraktionen durchzuführen.
* **Wartbarkeit:** Durch die Paket-Struktur können DB-Adapter (z.B. für Qdrant) nun unabhängig von der Business-Logik (Scoring) aktualisiert werden.
*
--- ---
## 3. Offene Workpackages (Planung) ## 3. Offene Workpackages (Planung)
@ -93,6 +95,20 @@ Diese Features stehen als nächstes an oder befinden sich in der Umsetzung.
- Aufwand: Mittel - Aufwand: Mittel
- Komplexität: Niedrig/Mittel - Komplexität: Niedrig/Mittel
### WP-13 MCP-Integration & Agenten-Layer
**Status:** 🟡 Geplant
**Ziel:** mindnet als MCP-Server bereitstellen, damit Agenten (Claude Desktop, OpenAI) standardisierte Tools nutzen können.
* **Umfang:** MCP-Server mit Tools (`mindnet_query`, `mindnet_explain`, etc.).
### WP-14 Review / Refactoring / Dokumentation
**Status:** 🟡 Laufend (Phase E)
**Ziel:** Technische Schulden abbauen, die durch schnelle Feature-Entwicklung (WP15/WP19) entstanden sind.
* **Refactoring `chunker.py`:** Die Datei ist monolithisch geworden (Parsing, Strategien, LLM-Orchestrierung).
* *Lösung:* Aufteilung in ein Package `app/core/chunking/` mit Modulen (`strategies.py`, `orchestration.py`, `utils.py`).
* **Dokumentation:** Kontinuierliche Synchronisation von Code und Docs (v2.8 Stand).
### WP-15b Candidate-Based Edge Validation & Inheritance ### WP-15b Candidate-Based Edge Validation & Inheritance
**Phase:** B/E (Refactoring & Semantic) **Phase:** B/E (Refactoring & Semantic)
**Status:** 🚀 Startklar (Ersatz für WP-15 Logik) **Status:** 🚀 Startklar (Ersatz für WP-15 Logik)
@ -113,19 +129,6 @@ Der bisherige WP-15 Ansatz litt unter Halluzinationen (erfundene Kantentypen), h
* **Chunker-Update:** Implementierung einer `propagate_edges`-Logik für "by_heading" und "sliding_window" Strategien. * **Chunker-Update:** Implementierung einer `propagate_edges`-Logik für "by_heading" und "sliding_window" Strategien.
* **Ingestion-Update:** Umstellung von `_perform_smart_edge_allocation` auf einen binären Validierungs-Prompt (VALID/INVALID). * **Ingestion-Update:** Umstellung von `_perform_smart_edge_allocation` auf einen binären Validierungs-Prompt (VALID/INVALID).
### WP-19a Graph Intelligence & Discovery (Sprint-Fokus)
**Status:** 🚀 Startklar
**Ziel:** Vom "Anschauen" zum "Verstehen". Deep-Dive Werkzeuge für den Graphen.
* **Discovery Screen:** Neuer Tab für semantische Suche ("Finde Notizen über Vaterschaft") und Wildcard-Filter.
* **Filter-Logik:** "Zeige nur Wege, die zu `type:decision` führen".
* **Chunk Inspection:** Umschaltbare Granularität (Notiz vs. Chunk) zur Validierung des Smart Chunkers.
### WP-14 Review / Refactoring / Dokumentation
**Status:** 🟡 Laufend (Phase E)
**Ziel:** Technische Schulden abbauen, die durch schnelle Feature-Entwicklung (WP15/WP19) entstanden sind.
* **Refactoring `chunker.py`:** Die Datei ist monolithisch geworden (Parsing, Strategien, LLM-Orchestrierung).
* *Lösung:* Aufteilung in ein Package `app/core/chunking/` mit Modulen (`strategies.py`, `orchestration.py`, `utils.py`).
* **Dokumentation:** Kontinuierliche Synchronisation von Code und Docs (v2.8 Stand).
### WP-16 Auto-Discovery & Intelligent Ingestion ### WP-16 Auto-Discovery & Intelligent Ingestion
**Status:** 🟡 Geplant **Status:** 🟡 Geplant
@ -153,10 +156,13 @@ Der bisherige WP-15 Ansatz litt unter Halluzinationen (erfundene Kantentypen), h
* **Feature:** Cronjob `check_graph_integrity.py`. * **Feature:** Cronjob `check_graph_integrity.py`.
* **Funktion:** Findet "Dangling Edges" (Links auf gelöschte Notizen) und repariert/löscht sie. * **Funktion:** Findet "Dangling Edges" (Links auf gelöschte Notizen) und repariert/löscht sie.
### WP-13 MCP-Integration & Agenten-Layer ### WP-19a Graph Intelligence & Discovery (Sprint-Fokus)
**Status:** 🟡 Geplant **Status:** 🚀 Startklar
**Ziel:** mindnet als MCP-Server bereitstellen, damit Agenten (Claude Desktop, OpenAI) standardisierte Tools nutzen können. **Ziel:** Vom "Anschauen" zum "Verstehen". Deep-Dive Werkzeuge für den Graphen.
* **Umfang:** MCP-Server mit Tools (`mindnet_query`, `mindnet_explain`, etc.). * **Discovery Screen:** Neuer Tab für semantische Suche ("Finde Notizen über Vaterschaft") und Wildcard-Filter.
* **Filter-Logik:** "Zeige nur Wege, die zu `type:decision` führen".
* **Chunk Inspection:** Umschaltbare Granularität (Notiz vs. Chunk) zur Validierung des Smart Chunkers.
### WP-21 Semantic Graph Routing & Canonical Edges ### WP-21 Semantic Graph Routing & Canonical Edges
**Status:** 🟡 Geplant **Status:** 🟡 Geplant
@ -185,44 +191,42 @@ Der bisherige WP-15 Ansatz litt unter Halluzinationen (erfundene Kantentypen), h
2. **Single Source of Truth (SSOT):** Die Registry nutzt `01_edge_vocabulary.md` als führende Konfiguration. 2. **Single Source of Truth (SSOT):** Die Registry nutzt `01_edge_vocabulary.md` als führende Konfiguration.
3. **Self-Learning Loop:** Protokollierung unbekannter Kanten in `unknown_edges.jsonl`. 3. **Self-Learning Loop:** Protokollierung unbekannter Kanten in `unknown_edges.jsonl`.
## 23: Agentic Multi-Stream Reasoning (Mindnet 2025) ### WP-23: Agentic Multi-Stream Reasoning (Mindnet 2025)
### 1. Zielsetzung & Problemstellung #### 1. Zielsetzung & Problemstellung
Das bisherige System basiert auf einem globalen Scoring-Modell, bei dem Notizen unterschiedlicher Typen (z. B. `insight` vs. `belief`) in einem einzigen Retrieval-Topf konkurrieren. Dies führt dazu, dass leiser gewichtete, aber fundamentale Identitätsmerkmale oft durch hochgewichtete aktuelle Erkenntnisse verdrängt werden. Ziel dieses Pakets ist die Einführung einer parallelen **Stream-Architektur**, um die Vielschichtigkeit menschlicher Entscheidungsprozesse (Werte + Erfahrung + Absicht) im LLM-Kontext zu garantieren. Das bisherige System basiert auf einem globalen Scoring-Modell, bei dem Notizen unterschiedlicher Typen (z. B. `insight` vs. `belief`) in einem einzigen Retrieval-Topf konkurrieren. Dies führt dazu, dass leiser gewichtete, aber fundamentale Identitätsmerkmale oft durch hochgewichtete aktuelle Erkenntnisse verdrängt werden. Ziel dieses Pakets ist die Einführung einer parallelen **Stream-Architektur**, um die Vielschichtigkeit menschlicher Entscheidungsprozesse (Werte + Erfahrung + Absicht) im LLM-Kontext zu garantieren.
--- #### 2. Funktionsbeschreibung: Die Streams
### 2. Funktionsbeschreibung: Die Streams
Die Daten aus der `types.yaml` werden in drei logische Verarbeitungseinheiten unterteilt: Die Daten aus der `types.yaml` werden in drei logische Verarbeitungseinheiten unterteilt:
#### A. Identity Stream (Die Wahrheitsebene) ##### A. Identity Stream (Die Wahrheitsebene)
* **Inhalt:** `value`, `belief`, `trait`, `principle`, `need`, `boundary`, `bias`. * **Inhalt:** `value`, `belief`, `trait`, `principle`, `need`, `boundary`, `bias`.
* **Zweck:** Definition des moralischen Kompasses, der psychologischen Grundbedürfnisse und kognitiven Muster. * **Zweck:** Definition des moralischen Kompasses, der psychologischen Grundbedürfnisse und kognitiven Muster.
* **Wirkung:** Liefert das "Warum" hinter jeder Handlung. * **Wirkung:** Liefert das "Warum" hinter jeder Handlung.
#### B. History Stream (Die Evidenzebene) ##### B. History Stream (Die Evidenzebene)
* **Inhalt:** `experience`, `event`, `source`, `journal`, `person`. * **Inhalt:** `experience`, `event`, `source`, `journal`, `person`.
* **Zweck:** Bereitstellung empirischer Belege aus der Vergangenheit und sozialer Kontexte. * **Zweck:** Bereitstellung empirischer Belege aus der Vergangenheit und sozialer Kontexte.
* **Wirkung:** Verankert die Antwort in real erlebten Mustern und Fakten. * **Wirkung:** Verankert die Antwort in real erlebten Mustern und Fakten.
#### C. Action Stream (Die Dynamikebene) ##### C. Action Stream (Die Dynamikebene)
* **Inhalt:** `project`, `decision`, `goal`, `task`, `risk`, `motivation`, `habit`, `state`. * **Inhalt:** `project`, `decision`, `goal`, `task`, `risk`, `motivation`, `habit`, `state`.
* **Zweck:** Analyse der aktuellen Richtung, geplanter Vorhaben und des gegenwärtigen Zustands. * **Zweck:** Analyse der aktuellen Richtung, geplanter Vorhaben und des gegenwärtigen Zustands.
* **Wirkung:** Liefert den Kontext für die Umsetzung und zukünftige Ziele. * **Wirkung:** Liefert den Kontext für die Umsetzung und zukünftige Ziele.
### 3. Technische Wirkungsweise (Solution Sketch) #### 3. Technische Wirkungsweise (Solution Sketch)
#### Schritt 1: Query-Decomposition ##### Schritt 1: Query-Decomposition
Ein initialer Klassifizierungs-Agent analysiert die Nutzeranfrage und bestimmt, welcher Stream primär angesprochen werden muss (z. B. "Wie soll ich mich entscheiden?" boostet den Identity Stream). Ein initialer Klassifizierungs-Agent analysiert die Nutzeranfrage und bestimmt, welcher Stream primär angesprochen werden muss (z. B. "Wie soll ich mich entscheiden?" boostet den Identity Stream).
#### Schritt 2: Parallel Stream Retrieval ##### Schritt 2: Parallel Stream Retrieval
Anstelle einer Suche werden drei unabhängige Vektor-Suchen mit Typ-Filtern durchgeführt: Anstelle einer Suche werden drei unabhängige Vektor-Suchen mit Typ-Filtern durchgeführt:
* **Search_A (Identity):** Top-5 Ergebnisse aus Identitäts-Notizen. * **Search_A (Identity):** Top-5 Ergebnisse aus Identitäts-Notizen.
* **Search_B (History):** Top-5 Ergebnisse aus biografischen/externen Notizen. * **Search_B (History):** Top-5 Ergebnisse aus biografischen/externen Notizen.
* **Search_C (Action):** Top-5 Ergebnisse aus operativen/strategischen Notizen. * **Search_C (Action):** Top-5 Ergebnisse aus operativen/strategischen Notizen.
#### Schritt 3: Agentic Synthesis (The Reasoning) ##### Schritt 3: Agentic Synthesis (The Reasoning)
Ein Synthese-Agent (LLM) erhält die aggregierten Ergebnisse in getrennten Sektionen. Die Anweisung lautet: Ein Synthese-Agent (LLM) erhält die aggregierten Ergebnisse in getrennten Sektionen. Die Anweisung lautet:
1. **Prüfung:** Steht das aktuelle Vorhaben (Action) im Einklang mit den Werten (Identity)? 1. **Prüfung:** Steht das aktuelle Vorhaben (Action) im Einklang mit den Werten (Identity)?
2. **Abgleich:** Welche vergangenen Erfahrungen (History) stützen oder widersprechen diesem Weg? 2. **Abgleich:** Welche vergangenen Erfahrungen (History) stützen oder widersprechen diesem Weg?
@ -230,12 +234,39 @@ Ein Synthese-Agent (LLM) erhält die aggregierten Ergebnisse in getrennten Sekti
### 4. Erwartete Ergebnisse #### 4. Erwartete Ergebnisse
* **Höhere Resonanz:** Antworten wirken authentischer, da sie explizit auf das Wertesystem des Nutzers Bezug nehmen. * **Höhere Resonanz:** Antworten wirken authentischer, da sie explizit auf das Wertesystem des Nutzers Bezug nehmen.
* **Widerspruchs-Erkennung:** Das System kann den Nutzer aktiv warnen, wenn ein Projekt gegen seine `principles` oder `needs` verstößt. * **Widerspruchs-Erkennung:** Das System kann den Nutzer aktiv warnen, wenn ein Projekt gegen seine `principles` oder `needs` verstößt.
* **Robustes Retrieval:** Wichtige Identitäts-Informationen gehen nicht mehr im "Rauschen" von hunderten Journal-Einträgen verloren. * **Robustes Retrieval:** Wichtige Identitäts-Informationen gehen nicht mehr im "Rauschen" von hunderten Journal-Einträgen verloren.
--- ---
### WP-24 Proactive Discovery & Agentic Knowledge Mining
**Status:** 🚀 In Planung (Nächster Architektur-Sprung)
**Ziel:** Transformation von Mindnet von einem reaktiven Archiv zu einem aktiven Denkpartner. Das System soll aktiv Wissenslücken schließen und verborgene Querverbindungen in großen Vaults sowie in Chat-Dialogen aufspüren.
**Herausforderung:**
1. **Silo-Effekt:** Bei wachsenden Vaults vergisst der Nutzer existierende Notizen und erstellt redundante Inhalte ohne Verknüpfung.
2. **Insight-Verlust:** Im Chat entstehen wertvolle Synthesen, die momentan im flüchtigen Chat-Log vergraben bleiben.
**Lösungsskizze & Strategie:**
#### A. Proactive Discovery (Vault-Scanning)
Das System nutzt die existierende `candidate_pool` Logik aus WP-15b, befüllt diese jedoch automatisiert:
* **Vector Similarity Search**: Beim Import einer Note (oder als periodischer Hintergrundprozess) sucht der neue `RecommenderService` in Qdrant nach den Top-X semantisch ähnlichsten Chunks im gesamten Vault.
* **Auto-Injection**: Diese Funde werden automatisch als `related_to` Kandidaten in den `candidate_pool` der neuen Note injiziert.
* **WP-15b Filter**: Das LLM validiert diese Vorschläge im zweiten Pass der Ingestion gegen den Kontext. Nur was semantisch wirklich passt, wird als Kante im Graphen persistiert.
#### B. Agentic Knowledge Mining (Chat-to-Vault)
Integration von Informationen aus dem Dialog direkt in den Graphen:
* **Intent Detection**: Das Chat-Backend erkennt „notierwürdige“ Informationen (z.B. neue Prinzipien, Strategie-Entwürfe oder Werte-Anpassungen).
* **Auto-Drafting**: Das LLM nutzt das `interview_template`, um aus dem Chat-Fragment eine valide Markdown-Datei mit Frontmatter (Status: `draft`) zu generieren.
* **Real-Time Linking**: Die neue Datei wird sofort dem „Discovery-Lauf“ (Teil A) unterzogen, um sie mit dem bestehenden Wissensschatz zu vernetzen.
* **User Review**: Die generierte Notiz erscheint im `00_Inbox` Ordner. Der Nutzer muss lediglich den Status auf `stable` setzen, um die Entdeckungen final zu integrieren.
**Erwartete Ergebnisse:**
* Eliminierung von Wissens-Silos durch automatische Vernetzung.
* Nahtloser Übergang von der Exploration (Chat) zur Konsolidierung (Vault).
* Vermeidung von Dubletten durch Ähnlichkeits-Warnungen beim Import.
## 4. Abhängigkeiten & Release-Plan ## 4. Abhängigkeiten & Release-Plan
```mermaid ```mermaid
@ -244,6 +275,8 @@ graph TD
WP19a --> WP17(Memory) WP19a --> WP17(Memory)
WP15(Smart Edges) --> WP16(Auto-Discovery) WP15(Smart Edges) --> WP16(Auto-Discovery)
WP15 --> WP14(Refactoring) WP15 --> WP14(Refactoring)
WP15(Smart Edges) --> WP15b(Candidate Validation)
WP15b --> WP24(Proactive Discovery)
WP03(Import) --> WP18(Health Check) WP03(Import) --> WP18(Health Check)
WP03 --> WP13(MCP) WP03 --> WP13(MCP)
WP04 --> WP13(MCP) WP04 --> WP13(MCP)
@ -253,4 +286,5 @@ graph TD
WP22 --> WP14 WP22 --> WP14
WP15(Smart Edges) --> WP21 WP15(Smart Edges) --> WP21
WP20(Cloud Hybrid) --> WP15b WP20(Cloud Hybrid) --> WP15b
WP24 --> WP23(Multi-Stream Reasoning)
``` ```

View File

@ -316,3 +316,44 @@ Die Gewichtung findet **Pre-Retrieval** (im Scoring-Algorithmus) statt, **nicht*
3. Zeige die Erweiterung in `scoring.py` (Status-Gewicht & Dynamic Edge Boosting). 3. Zeige die Erweiterung in `scoring.py` (Status-Gewicht & Dynamic Edge Boosting).
Bitte bestätige die Übernahme dieses Architektur-Pakets. Bitte bestätige die Übernahme dieses Architektur-Pakets.
---
# Übergabe Arbeitspaket: WP-24 Proactive Discovery & Agentic Knowledge Mining
## 1. Projekt-Kontext
Wir arbeiten an **Mindnet**, einem System für einen "digitalen Zwilling". Das System nutzt einen Wissensgraph (Qdrant), asynchrone Ingestion und eine hybride LLM-Infrastruktur (Cloud/Lokal).
## 2. Status Quo (Abgeschlossen: WP-15b)
Das Arbeitspaket **WP-15b (Candidate-Based Validation)** wurde gerade erfolgreich implementiert.
* **Two-Pass Workflow:** In Pass 1 wird ein globaler `LocalBatchCache` aufgebaut (ID, Titel, Dateiname). In Pass 2 findet eine semantische binäre Validierung (YES/NO) statt.
* **Edge Inheritance:** Kanten werden aus Sektionen und Frontmatter an Chunks vererbt.
* **Candidate Pool:** Nur Kanten in der Sektion `## Unzugeordnete Kanten` (Provenienz: `global_pool`) werden vom LLM geprüft. Explizite Kanten (`[!edge]` im Text) werden direkt übernommen.
## 3. Auftrag: WP-24 Proactive Discovery & Agentic Knowledge Mining
Das Ziel ist die Transformation von Mindnet zu einem aktiven Denkpartner.
### Teil A: Proactive Discovery (Vault-Scanning)
* **Mechanismus:** Automatisches Befüllen des `candidate_pool` via Vektor-Ähnlichkeit.
* **Logik:** Beim Import einer Note sucht ein neuer Service in Qdrant nach den semantisch ähnlichsten Chunks im Vault und fügt diese als `related_to` Kandidaten hinzu.
* **Filter:** Die WP-15b Validierungs-Logik filtert diese Vorschläge anschließend.
### Teil B: Agentic Knowledge Mining (Chat-to-Vault)
* **Mechanismus:** Extraktion notierwürdiger Informationen aus dem Chat.
* **Logik:** Erstellung von Markdown-Drafts im `00_Inbox` Ordner basierend auf dem Chat-Kontext unter Nutzung des `interview_template`.
## 4. Erforderliche Code-Basis (Dateien)
Stelle sicher, dass dir folgende Dateien vorliegen, um die Logik zu verstehen und zu erweitern:
1. **`app/core/ingestion.py` (v2.12.2):** Zentraler Two-Pass Workflow und Validierungsgate.
2. **`app/core/chunker.py` (v3.2.0):** Vorbereitung des Candidate-Pools und Vererbungslogik.
3. **`scripts/import_markdown.py` (v2.4.1):** Entry-Point und Pre-Scan Harvester für den Cache.
4. **`app/core/derive_edges.py` (v2.1.0):** Aggregator für Kanten mit Provenance-Priorisierung.
5. **`app/services/edge_registry.py` (v0.8.0):** Validierung gegen das Kanten-Vokabular.
6. **`config/prompts.yaml` (v2.6.0):** Enthält die `edge_validation` und `interview_template` Prompts.
7. **`06_active_roadmap.md` (v2.9.0):** Enthält die detaillierte Planung für WP-24.
## 5. Nächste technische Schritte
1. Entwurf eines `RecommenderService` für die Vektor-Suche in Qdrant.
2. Integration des Services in die `ingestion.py` zur automatischen Befüllung des `candidate_pool`.
3. Erweiterung des Chat-Backends um die "Capture-to-Vault" Funktionalität.

View File

@ -92,3 +92,22 @@ Dieses Dokument dient als Referenz für die Entstehungsgeschichte von Mindnet v2
* **Graph Explorer:** Einführung von `st-cytoscape` für stabile, nicht-überlappende Layouts (COSE) als Ergänzung zur Legacy-Engine (Agraph). * **Graph Explorer:** Einführung von `st-cytoscape` für stabile, nicht-überlappende Layouts (COSE) als Ergänzung zur Legacy-Engine (Agraph).
* **Single Source of Truth:** Der Editor lädt Inhalte nun direkt vom Dateisystem statt aus (potenziell veralteten) Vektor-Payloads. * **Single Source of Truth:** Der Editor lädt Inhalte nun direkt vom Dateisystem statt aus (potenziell veralteten) Vektor-Payloads.
* **UX:** Einführung von URL-Persistenz für Layout-Settings und CSS-basiertes Highlighting zur Vermeidung von Re-Renders. * **UX:** Einführung von URL-Persistenz für Layout-Settings und CSS-basiertes Highlighting zur Vermeidung von Re-Renders.
## Phase E+: Architektur-Konsolidierung (WP-14)
### WP-14 Modularisierung & Paket-Struktur
* **Ziel:** Auflösung technischer Schulden und Beseitigung von Zirkelbezügen (Circular Imports).
* **Ergebnis:**
* **Domänen-Pakete:** Aufteilung der monolithischen `app/core/` Struktur in spezialisierte Pakete: `database/`, `ingestion/`, `retrieval/` und `graph/`.
* **Proxy-Pattern:** Einsatz von Fassaden-Modulen (z. B. `graph_adapter.py`) zur Aufrechterhaltung der Abwärtskompatibilität für bestehende API-Endpunkte.
* **Registry-Zentralisierung:** Auslagerung neutraler Hilfsfunktionen (wie `clean_llm_text`) in eine unabhängige `registry.py`, um Abhängigkeitsschleifen zwischen Diensten zu brechen.
* **Tech:** Einführung von `__init__.py` Exporten zur Definition sauberer Paket-Schnittstellen.
### WP-15b Two-Pass Ingestion & Candidate Validation
* **Problem:** Die ursprüngliche Smart Edge Extraktion (WP-15) war teuer und neigte zu Halluzinationen, da sie ohne globalen Kontext operierte.
* **Lösung:** Implementierung eines **Two-Pass Workflows**.
* **Pass 1 (Pre-Scan):** Schnelles Einlesen aller Notizen zur Erstellung eines `LocalBatchCache` (Metadaten & Summaries).
* **Pass 2 (Processing):** Gezielte semantische Verarbeitung nur für geänderte Dateien.
* **Feature:** **Binary Validation Gate**. Statt Kanten frei zu erfinden, validiert das LLM nun Kanten-Kandidaten aus einem Pool gegen den Kontext des `LocalBatchCache`. Dies garantiert 100% Konformität mit der Edge Registry.
* **Ergebnis:** Höhere Geschwindigkeit durch Reduktion komplexer LLM-Prompts auf binäre Entscheidungen (VALID/INVALID).

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import argparse, os, json, glob, statistics as stats import argparse, os, json, glob, statistics as stats
from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter
from app.core.chunker import assemble_chunks from app.core.chunking import assemble_chunks
def iter_md(root: str): def iter_md(root: str):
for p in glob.glob(os.path.join(root, "**", "*.md"), recursive=True): for p in glob.glob(os.path.join(root, "**", "*.md"), recursive=True):

View File

@ -6,7 +6,7 @@ from pathlib import Path
# Pfad-Setup # Pfad-Setup
sys.path.insert(0, os.path.abspath(".")) sys.path.insert(0, os.path.abspath("."))
from app.core.chunker import assemble_chunks, _extract_all_edges_from_md from app.core.chunking import assemble_chunks, _extract_all_edges_from_md
from app.core.derive_edges import build_edges_for_note from app.core.derive_edges import build_edges_for_note
# Mock für Settings, falls nötig # Mock für Settings, falls nötig

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import argparse, os, glob import argparse, os, glob
from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter
from app.core.chunker import assemble_chunks from app.core.chunking import assemble_chunks
def iter_md(root: str): def iter_md(root: str):
return [p for p in glob.glob(os.path.join(root, "**", "*.md"), recursive=True)] return [p for p in glob.glob(os.path.join(root, "**", "*.md"), recursive=True)]

View File

@ -7,7 +7,7 @@ from slugify import slugify
from app.core.parser import read_markdown, normalize_frontmatter from app.core.parser import read_markdown, normalize_frontmatter
from app.core.parser import FRONTMATTER_RE # für Re-Inject from app.core.parser import FRONTMATTER_RE # für Re-Inject
from app.core.validate_note import validate_note_payload from app.core.validate_note import validate_note_payload
from app.core.note_payload import make_note_payload from app.core.ingestion.ingestion_note_payload import make_note_payload
DATE_IN_NAME = re.compile(r"(?P<y>\d{4})[-_\.]?(?P<m>\d{2})[-_\.]?(?P<d>\d{2})") DATE_IN_NAME = re.compile(r"(?P<y>\d{4})[-_\.]?(?P<m>\d{2})[-_\.]?(?P<d>\d{2})")

View File

@ -2,7 +2,10 @@
""" """
scripts/import_markdown.py scripts/import_markdown.py
CLI-Tool zum Importieren von Markdown-Dateien in Qdrant. CLI-Tool zum Importieren von Markdown-Dateien in Qdrant.
Updated for Mindnet v2.3.6 (Async Ingestion Support). WP-15b: Implementiert den Two-Pass Workflow (Pre-Scan + Processing).
Sorgt dafür, dass der LocalBatchCache vor der Verarbeitung robust gefüllt wird.
Indiziert Notizen nach ID, Titel und Dateiname für maximale Link-Kompatibilität.
VERSION: 2.4.1
""" """
import asyncio import asyncio
import os import os
@ -11,21 +14,16 @@ import logging
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
import logging # Setzt das Level global auf INFO, damit der Fortschritt im Log sichtbar ist
# Setzt das Level global auf INFO, damit Sie den Fortschritt sehen
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
# Wenn Sie TIEFE Einblicke wollen, setzen Sie den SemanticAnalyzer spezifisch auf DEBUG: # Importiere den neuen Async Service und stelle Python-Pfad sicher
logging.getLogger("app.services.semantic_analyzer").setLevel(logging.DEBUG)
# Importiere den neuen Async Service
# Stellen wir sicher, dass der Pfad stimmt (Pythonpath)
import sys import sys
sys.path.append(os.getcwd()) sys.path.append(os.getcwd())
from app.core.ingestion import IngestionService from app.core.ingestion import IngestionService
from app.core.parser import pre_scan_markdown
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("importer") logger = logging.getLogger("importer")
async def main_async(args): async def main_async(args):
@ -34,7 +32,7 @@ async def main_async(args):
logger.error(f"Vault path does not exist: {vault_path}") logger.error(f"Vault path does not exist: {vault_path}")
return return
# Service initialisieren (startet Async Clients) # 1. Service initialisieren
logger.info(f"Initializing IngestionService (Prefix: {args.prefix})") logger.info(f"Initializing IngestionService (Prefix: {args.prefix})")
service = IngestionService(collection_prefix=args.prefix) service = IngestionService(collection_prefix=args.prefix)
@ -46,14 +44,42 @@ async def main_async(args):
logger.info(f"Found {len(files)} markdown files.") logger.info(f"Found {len(files)} markdown files.")
stats = {"processed": 0, "skipped": 0, "errors": 0} # =========================================================================
# PASS 1: Global Pre-Scan (WP-15b Harvester)
# Füllt den LocalBatchCache für die semantische Kanten-Validierung.
# Nutzt ID, Titel und Filename für robusten Look-up.
# =========================================================================
logger.info(f"🔍 [Pass 1] Pre-scanning {len(files)} files for global context cache...")
for f_path in files:
try:
ctx = pre_scan_markdown(str(f_path))
if ctx:
# 1. Look-up via Note ID (UUID oder Frontmatter ID)
service.batch_cache[ctx.note_id] = ctx
# Wir nutzen eine Semaphore, um nicht zu viele Files gleichzeitig zu öffnen/embedden # 2. Look-up via Titel (Wichtig für Wikilinks [[Titel]])
sem = asyncio.Semaphore(5) # Max 5 concurrent files to avoid OOM or Rate Limit service.batch_cache[ctx.title] = ctx
# 3. Look-up via Dateiname (Wichtig für Wikilinks [[Filename]])
fname = os.path.splitext(f_path.name)[0]
service.batch_cache[fname] = ctx
except Exception as e:
logger.warning(f"⚠️ Could not pre-scan {f_path.name}: {e}")
logger.info(f"✅ Context Cache populated for {len(files)} notes.")
# =========================================================================
# PASS 2: Processing (Semantic Batch-Verarbeitung)
# Nutzt den gefüllten Cache zur binären Validierung semantischer Kanten.
# =========================================================================
stats = {"processed": 0, "skipped": 0, "errors": 0}
sem = asyncio.Semaphore(5) # Max 5 parallele Dateien für Cloud-Stabilität
async def process_with_limit(f_path): async def process_with_limit(f_path):
async with sem: async with sem:
try: try:
# Nutzt den nun gefüllten Batch-Cache in der process_file Logik
res = await service.process_file( res = await service.process_file(
file_path=str(f_path), file_path=str(f_path),
vault_root=str(vault_path), vault_root=str(vault_path),
@ -65,8 +91,8 @@ async def main_async(args):
except Exception as e: except Exception as e:
return {"status": "error", "error": str(e), "path": str(f_path)} return {"status": "error", "error": str(e), "path": str(f_path)}
# Batch Processing logger.info(f"🚀 [Pass 2] Starting semantic processing in batches...")
# Wir verarbeiten in Chunks, um den Progress zu sehen
batch_size = 20 batch_size = 20
for i in range(0, len(files), batch_size): for i in range(0, len(files), batch_size):
batch = files[i:i+batch_size] batch = files[i:i+batch_size]
@ -92,7 +118,7 @@ def main():
load_dotenv() load_dotenv()
default_prefix = os.getenv("COLLECTION_PREFIX", "mindnet") default_prefix = os.getenv("COLLECTION_PREFIX", "mindnet")
parser = argparse.ArgumentParser(description="Import Vault to Qdrant (Async)") parser = argparse.ArgumentParser(description="Two-Pass Markdown Ingestion for Mindnet")
parser.add_argument("--vault", default="./vault", help="Path to vault root") parser.add_argument("--vault", default="./vault", help="Path to vault root")
parser.add_argument("--prefix", default=default_prefix, help="Collection prefix") parser.add_argument("--prefix", default=default_prefix, help="Collection prefix")
parser.add_argument("--force", action="store_true", help="Force re-index all files") parser.add_argument("--force", action="store_true", help="Force re-index all files")
@ -100,7 +126,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Starte den Async Loop # Starte den asynchronen Haupt-Loop
asyncio.run(main_async(args)) asyncio.run(main_async(args))
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -8,6 +8,8 @@ from jsonschema import ValidationError
from app.core.parser import read_markdown, validate_required_frontmatter, normalize_frontmatter from app.core.parser import read_markdown, validate_required_frontmatter, normalize_frontmatter
from app.core.note_payload import make_note_payload from app.core.note_payload import make_note_payload
from app.core.validate_note import validate_note_payload from app.core.validate_note import validate_note_payload
from app.core.ingestion.ingestion_note_payload import make_note_payload
def iter_md_files(root: str, include: str, exclude: list[str]) -> list[str]: def iter_md_files(root: str, include: str, exclude: list[str]) -> list[str]:
# include z.B. "**/*.md" # include z.B. "**/*.md"

View File

@ -10,9 +10,9 @@ import argparse, os, json
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter
from app.core.note_payload import make_note_payload from app.core.chunking import assemble_chunks
from app.core.chunker import assemble_chunks from app.core.ingestion.ingestion_note_payload import make_note_payload
from app.core.chunk_payload import make_chunk_payloads from app.core.ingestion.ingestion_chunk_payload import make_chunk_payloads
try: try:
from app.core.derive_edges import build_edges_for_note from app.core.derive_edges import build_edges_for_note
except Exception: except Exception:

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import argparse, os, glob, json import argparse, os, glob, json
from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter
from app.core.chunker import assemble_chunks from app.core.chunking import assemble_chunks
from app.core.chunk_payload import make_chunk_payloads from app.core.ingestion.ingestion_note_payload import make_note_payload
from app.core.note_payload import make_note_payload from app.core.ingestion.ingestion_chunk_payload import make_chunk_payloads
def iter_md(root: str) -> list[str]: def iter_md(root: str) -> list[str]:
return [p for p in glob.glob(os.path.join(root, "**", "*.md"), recursive=True)] return [p for p in glob.glob(os.path.join(root, "**", "*.md"), recursive=True)]