2025-12-27 14:44:44 +01:00
|
|
|
"""
|
|
|
|
|
FILE: app/core/graph/graph_utils.py
|
|
|
|
|
DESCRIPTION: Basale Werkzeuge, ID-Generierung und Provenance-Konfiguration für den Graphen.
|
2026-01-10 10:56:47 +01:00
|
|
|
AUDIT v1.6.0:
|
|
|
|
|
- Erweitert um parse_link_target für sauberes Section-Splitting.
|
|
|
|
|
- Einführung einer gehärteten, deterministischen ID-Berechnung für Kanten (WP-24c).
|
|
|
|
|
- Integration der .env-gesteuerten Pfadauflösung für Schema und Vokabular.
|
|
|
|
|
VERSION: 1.6.0 (WP-24c: Identity & Path Enforcement)
|
2026-01-09 15:20:12 +01:00
|
|
|
STATUS: Active
|
2025-12-27 14:44:44 +01:00
|
|
|
"""
|
|
|
|
|
import os
|
2026-01-09 21:46:47 +01:00
|
|
|
import uuid
|
2026-01-10 10:56:47 +01:00
|
|
|
import hashlib
|
2025-12-29 07:58:20 +01:00
|
|
|
from typing import Iterable, List, Optional, Set, Any, Tuple
|
2025-12-27 14:44:44 +01:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import yaml
|
|
|
|
|
except ImportError:
|
|
|
|
|
yaml = None
|
|
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
# WP-15b: Prioritäten-Ranking für die De-Duplizierung von Kanten unterschiedlicher Herkunft
|
2025-12-27 14:44:44 +01:00
|
|
|
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,
|
2026-01-10 10:56:47 +01:00
|
|
|
"edge_defaults": 0.70 # Heuristik basierend auf types.yaml
|
2025-12-27 14:44:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Pfad-Auflösung (Integration der .env Umgebungsvariablen)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def get_vocab_path() -> str:
|
|
|
|
|
"""Liefert den Pfad zum Edge-Vokabular aus der .env oder den Default."""
|
|
|
|
|
return os.getenv("MINDNET_VOCAB_PATH", "/mindnet/vault/mindnet/_system/dictionary/edge_vocabulary.md")
|
|
|
|
|
|
|
|
|
|
def get_schema_path() -> str:
|
|
|
|
|
"""Liefert den Pfad zum Graph-Schema aus der .env oder den Default."""
|
|
|
|
|
return os.getenv("MINDNET_SCHEMA_PATH", "/mindnet/vault/mindnet/_system/dictionary/graph_schema.md")
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# ID & String Helper
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2025-12-27 14:44:44 +01:00
|
|
|
def _get(d: dict, *keys, default=None):
|
2026-01-10 10:56:47 +01:00
|
|
|
"""Sicherer Zugriff auf tief verschachtelte Dictionary-Keys."""
|
2025-12-27 14:44:44 +01:00
|
|
|
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]:
|
2026-01-10 10:56:47 +01:00
|
|
|
"""Dedupliziert eine Sequenz von Strings unter Beibehaltung der Reihenfolge."""
|
|
|
|
|
seen = set()
|
|
|
|
|
return [x for x in seq if not (x in seen or seen.add(x))]
|
2025-12-27 14:44:44 +01:00
|
|
|
|
2025-12-29 07:58:20 +01:00
|
|
|
def parse_link_target(raw: str, current_note_id: Optional[str] = None) -> Tuple[str, Optional[str]]:
|
|
|
|
|
"""
|
2026-01-10 10:56:47 +01:00
|
|
|
Trennt einen Obsidian-Link [[Target#Section]] in seine Bestandteile Target und Section.
|
|
|
|
|
Behandelt Self-Links (z.B. [[#Ziele]]), indem die aktuelle note_id eingesetzt wird.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Tuple (target_id, target_section)
|
2025-12-29 07:58:20 +01:00
|
|
|
"""
|
|
|
|
|
if not raw:
|
|
|
|
|
return "", None
|
|
|
|
|
|
|
|
|
|
parts = raw.split("#", 1)
|
|
|
|
|
target = parts[0].strip()
|
|
|
|
|
section = parts[1].strip() if len(parts) > 1 else None
|
|
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
# Spezialfall: Self-Link innerhalb derselben Datei
|
2025-12-29 07:58:20 +01:00
|
|
|
if not target and section and current_note_id:
|
|
|
|
|
target = current_note_id
|
|
|
|
|
|
|
|
|
|
return target, section
|
|
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
def _mk_edge_id(kind: str, source_id: str, target_id: str, scope: str = "note") -> str:
|
|
|
|
|
"""
|
|
|
|
|
WP-24c: Erzeugt eine deterministische UUIDv5 für eine Kante.
|
|
|
|
|
Garantiert, dass explizite Links und systemgenerierte Symmetrien dieselbe Point-ID
|
|
|
|
|
erzeugen, sofern Quelle und Ziel identisch aufgelöst wurden.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
kind: Typ der Relation (z.B. 'references')
|
|
|
|
|
source_id: Kanonische ID der Quell-Note
|
|
|
|
|
target_id: Kanonische ID der Ziel-Note
|
|
|
|
|
scope: Granularität (z.B. 'note' oder 'chunk')
|
|
|
|
|
"""
|
|
|
|
|
# Hard-Guard gegen None-Werte zur Vermeidung von Pydantic-Validierungsfehlern
|
|
|
|
|
if not all([kind, source_id, target_id]):
|
|
|
|
|
raise ValueError(f"Incomplete data for edge ID: kind={kind}, src={source_id}, tgt={target_id}")
|
|
|
|
|
|
|
|
|
|
# Stabiler Schlüssel für die Kollisions-Strategie (Authority-First)
|
|
|
|
|
stable_key = f"edge:{kind}:{source_id}:{target_id}:{scope}"
|
|
|
|
|
|
|
|
|
|
# Nutzt den URL-Namespace für deterministische Reproduzierbarkeit
|
|
|
|
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Registry Operations
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2025-12-27 14:44:44 +01:00
|
|
|
def load_types_registry() -> dict:
|
2026-01-10 10:56:47 +01:00
|
|
|
"""
|
|
|
|
|
Lädt die zentrale YAML-Registry (types.yaml).
|
|
|
|
|
Pfad wird über die Umgebungsvariable MINDNET_TYPES_FILE gesteuert.
|
|
|
|
|
"""
|
2025-12-27 14:44:44 +01:00
|
|
|
p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml")
|
2026-01-10 10:56:47 +01:00
|
|
|
if not os.path.isfile(p) or yaml is None:
|
|
|
|
|
return {}
|
2025-12-27 14:44:44 +01:00
|
|
|
try:
|
2026-01-10 10:56:47 +01:00
|
|
|
with open(p, "r", encoding="utf-8") as f:
|
|
|
|
|
data = yaml.safe_load(f)
|
|
|
|
|
return data if data is not None else {}
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
2025-12-27 14:44:44 +01:00
|
|
|
|
|
|
|
|
def get_edge_defaults_for(note_type: Optional[str], reg: dict) -> List[str]:
|
2026-01-09 15:20:12 +01:00
|
|
|
"""
|
2026-01-10 10:56:47 +01:00
|
|
|
Ermittelt die konfigurierten Standard-Kanten für einen Note-Typ.
|
|
|
|
|
Greift bei Bedarf auf die globalen ingestion_settings zurück.
|
2026-01-09 15:20:12 +01:00
|
|
|
"""
|
2025-12-27 14:44:44 +01:00
|
|
|
types_map = reg.get("types", reg) if isinstance(reg, dict) else {}
|
|
|
|
|
if note_type and isinstance(types_map, dict):
|
2026-01-10 10:56:47 +01:00
|
|
|
t_cfg = types_map.get(note_type)
|
|
|
|
|
if isinstance(t_cfg, dict) and isinstance(t_cfg.get("edge_defaults"), list):
|
|
|
|
|
return [str(x) for x in t_cfg["edge_defaults"]]
|
|
|
|
|
|
|
|
|
|
# Fallback auf die globalen Standardwerte der Ingestion
|
|
|
|
|
cfg_def = reg.get("ingestion_settings", {})
|
|
|
|
|
return cfg_def.get("edge_defaults", [])
|