Update graph_utils.py to version 1.6.1: Restore '_edge' function to address ImportError, revert to UUIDv5 for Qdrant compatibility, and maintain section logic in ID generation. Enhance documentation for clarity and refine edge ID generation process.

This commit is contained in:
Lars 2026-01-10 10:58:44 +01:00
parent 7cc823e2f4
commit c33b1c644a

View File

@ -1,11 +1,12 @@
"""
FILE: app/core/graph/graph_utils.py
DESCRIPTION: Basale Werkzeuge, ID-Generierung und Provenance-Konfiguration für den Graphen.
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)
AUDIT v1.6.1:
- Wiederherstellung der Funktion '_edge' (Fix für ImportError).
- Rückkehr zu UUIDv5 für Qdrant-Kompatibilität (Fix für Pydantic-Crash).
- Beibehaltung der Section-Logik (variant) in der ID-Generierung.
- Integration der .env Pfad-Auflösung.
VERSION: 1.6.1 (WP-24c: Circular Dependency & Identity Fix)
STATUS: Active
"""
import os
@ -18,7 +19,7 @@ try:
except ImportError:
yaml = None
# WP-15b: Prioritäten-Ranking für die De-Duplizierung von Kanten unterschiedlicher Herkunft
# WP-15b: Prioritäten-Ranking für die De-Duplizierung
PROVENANCE_PRIORITY = {
"explicit:wikilink": 1.00,
"inline:rel": 0.95,
@ -28,7 +29,7 @@ PROVENANCE_PRIORITY = {
"structure:order": 0.95, # next/prev
"explicit:note_scope": 1.00,
"derived:backlink": 0.90,
"edge_defaults": 0.70 # Heuristik basierend auf types.yaml
"edge_defaults": 0.70 # Heuristik (types.yaml)
}
# ---------------------------------------------------------------------------
@ -48,24 +49,58 @@ def get_schema_path() -> str:
# ---------------------------------------------------------------------------
def _get(d: dict, *keys, default=None):
"""Sicherer Zugriff auf tief verschachtelte Dictionary-Keys."""
"""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 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))]
"""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, variant: Optional[str] = None) -> str:
"""
Erzeugt eine deterministische UUIDv5.
WP-Fix: Wir nutzen UUIDv5 statt BLAKE2s-Hex, um 100% kompatibel zu den
Pydantic-Erwartungen von Qdrant (Step 1) zu bleiben.
"""
# Basis-String für den deterministischen Hash
base = f"edge:{kind}:{s}->{t}#{scope}"
if rule_id:
base += f"|{rule_id}"
if variant:
base += f"|{variant}" # Ermöglicht eindeutige IDs für verschiedene Abschnitte
# Nutzt den URL-Namespace für deterministische UUIDs
return str(uuid.uuid5(uuid.NAMESPACE_URL, base))
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.
Wiederhergestellt v1.6.1 (Erforderlich für graph_derive_edges.py).
"""
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 parse_link_target(raw: str, current_note_id: Optional[str] = None) -> Tuple[str, Optional[str]]:
"""
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)
Trennt [[Target#Section]] in Target und Section.
Behandelt Self-Links ('#Section'), indem current_note_id eingesetzt wird.
"""
if not raw:
return "", None
@ -74,64 +109,35 @@ def parse_link_target(raw: str, current_note_id: Optional[str] = None) -> Tuple[
target = parts[0].strip()
section = parts[1].strip() if len(parts) > 1 else None
# Spezialfall: Self-Link innerhalb derselben Datei
if not target and section and current_note_id:
target = current_note_id
return target, section
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
# ---------------------------------------------------------------------------
def load_types_registry() -> dict:
"""
Lädt die zentrale YAML-Registry (types.yaml).
Pfad wird über die Umgebungsvariable MINDNET_TYPES_FILE gesteuert.
"""
"""Lädt die YAML-Registry."""
p = os.getenv("MINDNET_TYPES_FILE", "./config/types.yaml")
if not os.path.isfile(p) or yaml is None:
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)
return data if data is not None else {}
except Exception:
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 die konfigurierten Standard-Kanten für einen Note-Typ.
Greift bei Bedarf auf die globalen ingestion_settings zurück.
"""
"""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_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", [])
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 []