app/core/derive_edges.py aktualisiert
All checks were successful
Deploy mindnet to llm-node / deploy (push) Successful in 2s

This commit is contained in:
Lars 2025-11-17 11:09:05 +01:00
parent bc967f1f6e
commit 95b59e9b0a

View File

@ -1,42 +1,96 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
app/core/derive_edges.py Modul: app/core/derive_edges.py
Mindnet V2 Edge-Ableitung (real + defaults), idempotent Version: 1.5.0 (Mindnet V2)
Status: Stable
Erzeugt Kanten für eine Note aus: Ziele
1) Sequenzkanten pro Chunk: belongs_to, next, prev -----
2) Reale Referenzen aus Chunk-Text (Markdown-Links, Wikilinks) + optional Frontmatter-Refs 1) Beibehalten der bewährten Edge-Ableitung:
3) Abgeleitete Kanten je Typ-Regel (types.yaml.edge_defaults), z. B. additional relations wie "depends_on", "related_to" - belongs_to (chunk -> note)
- Regel-Tagging via rule_id="edge_defaults:<type>:<relation>" - next / prev (Chunk-Kette)
- De-Dupe via Key: (source_id, target_id, relation, rule_id) - references (chunk-scope) aus Chunk.window/text via `extract_wikilinks`
Edge-Payload-Minimum: 2) Ergänzung: typenbasierte, abgeleitete Kanten aus `config/types.yaml`:
- relation (alias: kind) - Für jede gefundene Referenz werden zusätzliche Relationen aus
- note_id (Quelle; also die ID der Note, zu der die Chunks gehören) `edge_defaults` des Notiztyps erzeugt (z. B. "depends_on", "related_to").
- source_id (Chunk-ID oder Note-ID, je nach scope) - Optional symmetrische Relationen (z. B. "related_to", "similar_to").
- target_id (Note-/Slug-/URL-ID; deterministisch normalisiert) - Dedupe bleibt kompatibel (Key: kind, source_id, target_id, scope).
- chunk_id (falls scope='chunk')
- scope: 'chunk'|'note' Hinweise
- confidence: float (bei abgeleitet z. B. 0.7) --------
- rule_id: str | None - Es werden keine Markdown-Links neu geparst; wir bleiben bei der
vorhandenen Parser-Logik (`extract_wikilinks`) zur Sicherung der Kompatibilität.
- `edge_defaults` werden sowohl für Chunk-scope-Referenzen als auch falls
`include_note_scope_refs=True` für Note-scope-Referenzen angewendet.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional, Tuple from typing import Dict, List, Optional, Iterable, Set
import os, re, yaml, hashlib
# ---------------- Registry Laden ---------------- import os
import yaml
def _env(n: str, d: Optional[str]=None) -> str: # Wikilinks-Parser beibehalten (Kompatibilität!)
from app.core.parser import extract_wikilinks
# ---------------------------- Utilities ------------------------------------
def _get(d: dict, *keys, default=None):
for k in keys:
if 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: 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,
"scope": scope, # "chunk" | "note"
"source_id": source_id,
"target_id": target_id,
"note_id": note_id, # Träger/Quelle der Kante (aktuelle Note)
}
if extra:
pl.update(extra)
return pl
# ---------------------- Typen-Registry (types.yaml) ------------------------
SYM_REL = {"related_to", "similar_to"} # symmetrische Relationstypen
def _env(n: str, default: Optional[str] = None) -> str:
v = os.getenv(n) v = os.getenv(n)
return v if v is not None else (d or "") return v if v is not None else (default or "")
def _load_types() -> dict: 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") p = _env("MINDNET_TYPES_FILE", "./config/types.yaml")
try: try:
with open(p, "r", encoding="utf-8") as f: with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
return data
except Exception: except Exception:
return {} return {}
@ -45,166 +99,115 @@ def _get_types_map(reg: dict) -> dict:
return reg["types"] return reg["types"]
return reg if isinstance(reg, dict) else {} return reg if isinstance(reg, dict) else {}
def _edge_defaults_for(note_type: str, reg: dict) -> List[str]: def _edge_defaults_for(note_type: Optional[str], reg: dict) -> List[str]:
m = _get_types_map(reg) """
if isinstance(m, dict): Liefert die edge_defaults-Liste für den gegebenen Notiztyp.
t = m.get(note_type) or {} Fallback-Reihenfolge:
if isinstance(t, dict): 1) reg['types'][note_type]['edge_defaults']
vals = t.get("edge_defaults") 2) reg['defaults']['edge_defaults'] (oder 'default'/'global')
if isinstance(vals, list): 3) []
return [str(x) for x in vals if isinstance(x, (str,))] """
types_map = _get_types_map(reg)
# 1) exakter Typ
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)]
# 2) Fallback
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)]
# 3) leer
return [] return []
# ---------------- Utils ----------------
SYM_REL = {"related_to", "similar_to"} # symmetrische Relationen # --------------------------- Hauptfunktion ---------------------------------
def _slug_id(s: str) -> str: def build_edges_for_note(
s = (s or "").strip().lower() note_id: str,
s = re.sub(r"\s+", "-", s) chunks: List[dict],
s = re.sub(r"[^\w\-:/#\.]", "", s) # lasse urls, hashes rudimentär zu note_level_references: Optional[List[str]] = None,
if not s: include_note_scope_refs: bool = False,
s = "ref" ) -> List[dict]:
return s
def _mk_edge_id(source_id: str, relation: str, target_id: str, rule_id: Optional[str]) -> str:
base = f"{source_id}|{relation}|{target_id}|{rule_id or ''}"
h = hashlib.sha1(base.encode("utf-8")).hexdigest()[:16]
return f"e_{h}"
def _add(edge_list: List[Dict[str, Any]],
dedupe: set,
note_id: str,
source_id: str,
relation: str,
target_id: str,
*,
chunk_id: Optional[str] = None,
scope: str = "chunk",
confidence: Optional[float] = None,
rule_id: Optional[str] = None) -> None:
key = (source_id, target_id, relation, rule_id or "")
if key in dedupe:
return
dedupe.add(key)
payload = {
"edge_id": _mk_edge_id(source_id, relation, target_id, rule_id),
"note_id": note_id,
"kind": relation, # alias
"relation": relation,
"scope": scope,
"source_id": source_id,
"target_id": target_id,
}
if chunk_id:
payload["chunk_id"] = chunk_id
if confidence is not None:
payload["confidence"] = float(confidence)
if rule_id is not None:
payload["rule_id"] = rule_id
edge_list.append(payload)
# ---------------- Refs Parsen ----------------
MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") # [text](target)
WIKI_LINK = re.compile(r"\[\[([^|\]]+)(?:\|[^]]+)?\]\]") # [[Title]] oder [[Title|alias]]
def _extract_refs(text: str) -> List[Tuple[str, str]]:
"""liefert Liste (label, target) target kann URL, Title, etc. sein"""
out: List[Tuple[str,str]] = []
if not text:
return out
for m in MD_LINK.finditer(text):
label = (m.group(1) or "").strip()
tgt = (m.group(2) or "").strip()
out.append((label, tgt))
for m in WIKI_LINK.finditer(text):
title = (m.group(1) or "").strip()
out.append((title, title))
return out
# ---------------- Haupt-API ----------------
def build_edges_for_note(*,
note_id: str,
chunk_payloads: List[Dict[str, Any]],
note_level_refs: Optional[List[Dict[str, Any]]] = None,
include_note_scope_refs: bool = False) -> List[Dict[str, Any]]:
""" """
Baut alle Kanten für eine Note. Erzeugt Kanten für eine Note.
- Sequenzkanten (belongs_to, next, prev)
- Referenzen aus Chunk-Text (scope=chunk) - belongs_to: für jeden Chunk (chunk -> note)
- Abgeleitete Kanten gemäß edge_defaults aus types.yaml (für jede gefundene Referenz) - next / prev: zwischen aufeinanderfolgenden Chunks
- references: pro Chunk aus window/text (via extract_wikilinks)
- optional note-scope references/backlinks: dedupliziert über alle Chunk-Funde + note_level_references
- NEU: typenbasierte, abgeleitete Kanten (edge_defaults) je gefundener Referenz
""" """
edges: List[dict] = []
# --- 0) Note-Typ ermitteln (aus erstem Chunk erwartet) ---
note_type = None note_type = None
if chunk_payloads: if chunks:
note_type = chunk_payloads[0].get("type") note_type = _get(chunks[0], "type")
reg = _load_types()
defaults = _edge_defaults_for(note_type or "concept", reg)
edges: List[Dict[str, Any]] = [] # --- 1) belongs_to ---
seen = set() 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}))
# 1) Sequenzkanten # --- 2) next/prev ---
for ch in chunk_payloads: for i in range(len(chunks) - 1):
cid = ch.get("chunk_id") or ch.get("id") a, b = chunks[i], chunks[i + 1]
nid = ch.get("note_id") or note_id a_id = _get(a, "chunk_id", "id")
idx = ch.get("index") b_id = _get(b, "chunk_id", "id")
# belongs_to if not a_id or not b_id:
_add(edges, seen, note_id=nid, source_id=cid, relation="belongs_to", continue
target_id=nid, chunk_id=cid, scope="chunk") edges.append(_edge("next", "chunk", a_id, b_id, note_id, {"chunk_id": a_id}))
# next/prev edges.append(_edge("prev", "chunk", b_id, a_id, note_id, {"chunk_id": b_id}))
for nb, rel in ((ch.get("neighbors_next"), "next"), (ch.get("neighbors_prev"), "prev")):
if not nb:
continue
# neighbors sind Listen
items = nb if isinstance(nb, list) else [nb]
for tid in items:
_add(edges, seen, note_id=nid, source_id=cid, relation=rel,
target_id=tid, chunk_id=cid, scope="chunk")
# 2) Refs aus Chunk-Text (+ derived edges je ref) # --- 3) references (chunk-scope) + abgeleitete Relationen je Ref ---
for ch in chunk_payloads: reg = _load_types_registry()
cid = ch.get("chunk_id") or ch.get("id") defaults = _edge_defaults_for(note_type, reg)
nid = ch.get("note_id") or note_id refs_all: List[str] = []
text = ch.get("text") or ""
for (label, tgt) in _extract_refs(text): for ch in chunks:
target_id = _slug_id(tgt) cid = _get(ch, "chunk_id", "id")
# real reference if not cid:
_add(edges, seen, note_id=nid, source_id=cid, relation="references", continue
target_id=target_id, chunk_id=cid, scope="chunk") txt = _chunk_text_for_refs(ch)
# defaults amplification refs = extract_wikilinks(txt) # Parser-Logik nicht verändert
for r in refs:
# reale Referenz (wie bisher)
edges.append(_edge("references", "chunk", cid, r, note_id, {"chunk_id": cid, "ref_text": r}))
# abgeleitete Kanten je default-Relation
for rel in defaults: for rel in defaults:
if rel == "references": if rel == "references":
continue continue # doppelt vermeiden
rule = f"edge_defaults:{note_type}:{rel}" edges.append(_edge(rel, "chunk", cid, r, note_id, {"chunk_id": cid, "rule_id": f"edge_defaults:{note_type}:{rel}", "confidence": 0.7}))
_add(edges, seen, note_id=nid, source_id=cid, relation=rel,
target_id=target_id, chunk_id=cid, scope="chunk",
confidence=0.7, rule_id=rule)
# symmetrisch? # symmetrisch?
if rel in SYM_REL: if rel in {"related_to", "similar_to"}:
_add(edges, seen, note_id=nid, source_id=target_id, relation=rel, edges.append(_edge(rel, "chunk", r, cid, note_id, {"chunk_id": cid, "rule_id": f"edge_defaults:{note_type}:{rel}", "confidence": 0.7}))
target_id=cid, chunk_id=cid, scope="chunk", refs_all.extend(refs)
confidence=0.7, rule_id=rule)
# 3) optionale Note-Scope-Refs aus Frontmatter (falls geliefert) # --- 4) optional: note-scope references/backlinks (+ defaults) ---
note_level_refs = note_level_refs or [] if include_note_scope_refs:
if include_note_scope_refs and note_level_refs: refs_note = refs_all[:]
nid = note_id if note_level_references:
for r in note_level_refs: refs_note.extend([r for r in note_level_references if isinstance(r, str) and r])
tgt = (r or {}).get("target_id") or (r or {}).get("target") or "" refs_note = _dedupe(refs_note)
if not tgt: for r in refs_note:
continue # echte note-scope Referenz & Backlink (wie bisher)
target_id = _slug_id(str(tgt)) edges.append(_edge("references", "note", note_id, r, note_id))
_add(edges, seen, note_id=nid, source_id=nid, relation="references", edges.append(_edge("backlink", "note", r, note_id, note_id))
target_id=target_id, chunk_id=None, scope="note") # und zusätzlich default-Relationen (note-scope)
for rel in defaults: for rel in defaults:
if rel == "references": if rel == "references":
continue continue
rule = f"edge_defaults:{note_type}:{rel}" edges.append(_edge(rel, "note", note_id, r, note_id, {"rule_id": f"edge_defaults:{note_type}:{rel}", "confidence": 0.7}))
_add(edges, seen, note_id=nid, source_id=nid, relation=rel, if rel in {"related_to", "similar_to"}:
target_id=target_id, chunk_id=None, scope="note", edges.append(_edge(rel, "note", r, note_id, note_id, {"rule_id": f"edge_defaults:{note_type}:{rel}", "confidence": 0.7}))
confidence=0.7, rule_id=rule)
return edges
# --- 5) Dedupe (unverändert kompatibel) ---
dedup = {}
for e in edges:
k = (e["kind"], e["source_id"], e["target_id"], e.get("scope", ""))
dedup[k] = e
return list(dedup.values())