2025-11-18 07:50:46 +01:00
|
|
|
|
# app/core/derive_edges.py
|
2025-11-08 15:21:17 +01:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2025-11-17 16:18:10 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
Edge-Ableitung (V2)
|
|
|
|
|
|
Beibehaltung der bestehenden Funktionalität + Erweiterung:
|
|
|
|
|
|
- Mehrere Inline-Referenzen in einer Zeile: rel: <relation> [[A]] [[B]] ...
|
|
|
|
|
|
Kompatibel mit:
|
|
|
|
|
|
- Strukturkanten: belongs_to / next / prev
|
|
|
|
|
|
- Explizite Wikilinks -> references
|
|
|
|
|
|
- Inline-Relationen -> inline:rel
|
|
|
|
|
|
- Callout-Kanten -> callout:edge
|
|
|
|
|
|
- Typbasierte Default-Kanten (edge_defaults aus types.yaml)
|
2025-11-17 16:18:10 +01:00
|
|
|
|
"""
|
2025-11-17 16:27:40 +01:00
|
|
|
|
|
2025-11-08 14:25:01 +01:00
|
|
|
|
from __future__ import annotations
|
2025-11-17 12:21:28 +01:00
|
|
|
|
import re
|
2025-11-18 07:50:46 +01:00
|
|
|
|
from typing import Dict, List, Iterable, Tuple, Set
|
2025-11-17 11:09:05 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# Regex-Bausteine
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
2025-11-17 15:53:38 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
# Wikilinks: [[Title]] oder [[Title|Alias]]
|
|
|
|
|
|
RE_WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
|
2025-11-17 15:53:38 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
# Inline-Relationen (Variante B – von dir im Einsatz):
|
|
|
|
|
|
# rel: <relation> [[Target A]] [[Target B]] ...
|
|
|
|
|
|
RE_INLINE_REL_LINE = re.compile(
|
|
|
|
|
|
r"(?i)\brel\s*:\s*(?P<rel>[a-z_][a-z0-9_]+)\s+(?P<body>.+)$"
|
|
|
|
|
|
)
|
2025-11-17 15:53:38 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
# Callout:
|
|
|
|
|
|
# > [!edge] <relation>: [[A]] [[B]]
|
|
|
|
|
|
RE_CALLOUT_HEADER = re.compile(r"^\s{0,3}>\s*\[\!edge\]\s*(?P<rel>[a-z_][a-z0-9_]+)\s*:\s*(?P<body>.*)$", re.IGNORECASE)
|
2025-11-17 21:42:26 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# Utilities
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
2025-11-17 21:42:26 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
def _neighbors_chain(chunk_ids: List[str]) -> Iterable[Tuple[str, str]]:
|
|
|
|
|
|
"""Erzeugt (prev, next) Paare entlang der Chunk-Sequenz."""
|
|
|
|
|
|
for i in range(len(chunk_ids) - 1):
|
|
|
|
|
|
yield chunk_ids[i], chunk_ids[i + 1]
|
|
|
|
|
|
|
|
|
|
|
|
def _mk_edge_payload(
|
|
|
|
|
|
*,
|
|
|
|
|
|
kind: str,
|
|
|
|
|
|
scope: str,
|
|
|
|
|
|
note_id: str,
|
|
|
|
|
|
chunk_id: str | None = None,
|
|
|
|
|
|
source_id: str,
|
|
|
|
|
|
target_id: str,
|
|
|
|
|
|
rule_id: str,
|
|
|
|
|
|
confidence: float,
|
|
|
|
|
|
) -> Dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Einheitliches Edge-Payload-Format.
|
|
|
|
|
|
"""
|
2025-11-17 21:42:26 +01:00
|
|
|
|
pl = {
|
2025-11-18 07:50:46 +01:00
|
|
|
|
"kind": kind, # z.B. references, depends_on, related_to, similar_to
|
|
|
|
|
|
"scope": scope, # "chunk" oder "note"
|
|
|
|
|
|
"note_id": note_id, # Note-Kontext (Quelle)
|
|
|
|
|
|
"source_id": source_id, # id der Quelle (Chunk-ID oder Note-ID)
|
|
|
|
|
|
"target_id": target_id, # Ziel (Note-ID oder Titel, falls Auflösung extern erfolgt)
|
|
|
|
|
|
"rule_id": rule_id,
|
|
|
|
|
|
"confidence": confidence,
|
2025-11-17 11:09:05 +01:00
|
|
|
|
}
|
2025-11-18 07:50:46 +01:00
|
|
|
|
if chunk_id:
|
|
|
|
|
|
pl["chunk_id"] = chunk_id
|
2025-11-17 21:42:26 +01:00
|
|
|
|
return pl
|
|
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
def _extract_wikilinks(text: str) -> List[str]:
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
Extrahiert alle Wikilink-Ziele (als Titel-Strings).
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
return [m.group(1).strip() for m in RE_WIKILINK.finditer(text or "")]
|
2025-11-17 21:42:26 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
def _extract_inline_relations_lines(text: str) -> List[Tuple[str, List[str]]]:
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
Findet Inline-Relationen in Zeilen wie:
|
|
|
|
|
|
rel: <relation> [[Target A]] [[Target B]]
|
|
|
|
|
|
Liefert Liste von (relation, [targets...]).
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
out: List[Tuple[str, List[str]]] = []
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return out
|
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
|
m = RE_INLINE_REL_LINE.search(line)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
continue
|
|
|
|
|
|
rel = m.group("rel").strip().lower()
|
|
|
|
|
|
body = m.group("body")
|
|
|
|
|
|
# alle [[...]] Ziele aus body herausziehen:
|
|
|
|
|
|
targets = _extract_wikilinks(body)
|
|
|
|
|
|
# falls im Body keine [[...]] vorkommen, versuche verbleibenden Text als ein Ziel (robust):
|
|
|
|
|
|
if not targets:
|
|
|
|
|
|
cleaned = body.strip()
|
|
|
|
|
|
if cleaned:
|
|
|
|
|
|
targets = [cleaned]
|
|
|
|
|
|
if targets:
|
|
|
|
|
|
out.append((rel, targets))
|
|
|
|
|
|
return out
|
2025-11-17 21:42:26 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
def _extract_callout_edges(text: str) -> List[Tuple[str, List[str]]]:
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
Callout-Edges:
|
|
|
|
|
|
> [!edge] <relation>: [[A]] [[B]]
|
|
|
|
|
|
pro Zeile eine Relation + 1..n Ziele
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
out: List[Tuple[str, List[str]]] = []
|
2025-11-17 21:42:26 +01:00
|
|
|
|
if not text:
|
2025-11-18 07:50:46 +01:00
|
|
|
|
return out
|
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
|
m = RE_CALLOUT_HEADER.match(line)
|
2025-11-17 21:42:26 +01:00
|
|
|
|
if not m:
|
|
|
|
|
|
continue
|
2025-11-18 07:50:46 +01:00
|
|
|
|
rel = m.group("rel").strip().lower()
|
|
|
|
|
|
body = m.group("body")
|
|
|
|
|
|
targets = _extract_wikilinks(body)
|
|
|
|
|
|
# Robustheit: wenn keine [[...]] vorhanden, restlicher body als ein Ziel
|
|
|
|
|
|
if not targets:
|
|
|
|
|
|
cleaned = body.strip()
|
|
|
|
|
|
if cleaned:
|
|
|
|
|
|
targets = [cleaned]
|
|
|
|
|
|
if targets:
|
|
|
|
|
|
out.append((rel, targets))
|
|
|
|
|
|
return out
|
2025-11-17 16:18:10 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# Haupt-API
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
2025-11-17 21:42:26 +01:00
|
|
|
|
|
2025-11-18 07:50:46 +01:00
|
|
|
|
def derive_edges(
|
|
|
|
|
|
note: Dict,
|
|
|
|
|
|
chunks: List[Dict],
|
|
|
|
|
|
types_cfg: Dict | None = None,
|
|
|
|
|
|
) -> List[Dict]:
|
2025-11-17 15:53:38 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
Leitet Kanten für eine Note ab.
|
|
|
|
|
|
|
|
|
|
|
|
Erwartete Felder:
|
|
|
|
|
|
note: {
|
|
|
|
|
|
"note_id": str,
|
|
|
|
|
|
"title": str,
|
|
|
|
|
|
"type": str,
|
|
|
|
|
|
"text": str
|
|
|
|
|
|
}
|
|
|
|
|
|
chunks: [{
|
|
|
|
|
|
"chunk_id": str,
|
|
|
|
|
|
"index": int,
|
|
|
|
|
|
"text": str,
|
|
|
|
|
|
...
|
|
|
|
|
|
}, ...]
|
|
|
|
|
|
|
|
|
|
|
|
types_cfg (aus types.yaml geladen) mit:
|
|
|
|
|
|
types_cfg["types"][<type>]["edge_defaults"] = [relation, ...]
|
|
|
|
|
|
(optional)
|
2025-11-17 21:42:26 +01:00
|
|
|
|
"""
|
2025-11-18 07:50:46 +01:00
|
|
|
|
edges: List[Dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
note_id = note.get("note_id") or note.get("id")
|
|
|
|
|
|
note_title = note.get("title") or ""
|
|
|
|
|
|
note_type = (note.get("type") or "").strip().lower()
|
|
|
|
|
|
note_text = note.get("text") or ""
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 1) Strukturkanten je Chunk: belongs_to / next / prev
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
chunk_ids = [c.get("chunk_id") for c in chunks if c.get("chunk_id")]
|
|
|
|
|
|
# belongs_to
|
|
|
|
|
|
for c in chunks:
|
|
|
|
|
|
cid = c.get("chunk_id")
|
2025-11-17 21:42:26 +01:00
|
|
|
|
if not cid:
|
|
|
|
|
|
continue
|
2025-11-18 07:50:46 +01:00
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind="belongs_to",
|
|
|
|
|
|
scope="chunk",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
chunk_id=cid,
|
|
|
|
|
|
source_id=cid,
|
|
|
|
|
|
target_id=note_id,
|
|
|
|
|
|
rule_id="structure:belongs_to",
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# next/prev
|
|
|
|
|
|
for prev_id, next_id in _neighbors_chain(chunk_ids):
|
|
|
|
|
|
# next
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind="next",
|
|
|
|
|
|
scope="chunk",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
chunk_id=prev_id,
|
|
|
|
|
|
source_id=prev_id,
|
|
|
|
|
|
target_id=next_id,
|
|
|
|
|
|
rule_id="structure:next",
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# prev
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind="prev",
|
|
|
|
|
|
scope="chunk",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
chunk_id=next_id,
|
|
|
|
|
|
source_id=next_id,
|
|
|
|
|
|
target_id=prev_id,
|
|
|
|
|
|
rule_id="structure:prev",
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 2) Explizite Referenzen (Wikilinks) + Inline-Relationen + Callouts
|
|
|
|
|
|
# - Alles chunk-scope, Quelle = chunk_id (falls vorhanden),
|
|
|
|
|
|
# sonst Note-scope als Fallback.
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Sammle alle expliziten Ziele (für spätere edge_defaults)
|
|
|
|
|
|
explicit_targets: Set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
# pro Chunk prüfen
|
|
|
|
|
|
for c in chunks:
|
|
|
|
|
|
cid = c.get("chunk_id")
|
|
|
|
|
|
ctxt = c.get("text") or ""
|
|
|
|
|
|
|
|
|
|
|
|
# 2a) Wikilinks -> references
|
|
|
|
|
|
for tgt in _extract_wikilinks(ctxt):
|
|
|
|
|
|
explicit_targets.add(tgt)
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind="references",
|
|
|
|
|
|
scope="chunk",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
chunk_id=cid,
|
|
|
|
|
|
source_id=cid,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id="explicit:wikilink",
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 2b) Inline-Relationen (mehrere Ziele erlaubt)
|
|
|
|
|
|
for rel, targets in _extract_inline_relations_lines(ctxt):
|
|
|
|
|
|
for tgt in targets:
|
|
|
|
|
|
explicit_targets.add(tgt)
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind=rel,
|
|
|
|
|
|
scope="chunk",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
chunk_id=cid,
|
|
|
|
|
|
source_id=cid,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id="inline:rel",
|
|
|
|
|
|
confidence=0.95,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 2c) Callout-Edges (mehrere Ziele erlaubt)
|
|
|
|
|
|
for rel, targets in _extract_callout_edges(ctxt):
|
|
|
|
|
|
for tgt in targets:
|
|
|
|
|
|
explicit_targets.add(tgt)
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind=rel,
|
|
|
|
|
|
scope="chunk",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
chunk_id=cid,
|
|
|
|
|
|
source_id=cid,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id="callout:edge",
|
|
|
|
|
|
confidence=0.9,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: Falls Note keinen Chunk-Text enthielt (theoretisch),
|
|
|
|
|
|
# prüfe Note-Text einmal global (liefert note-scope Kanten).
|
|
|
|
|
|
if not chunks and note_text:
|
|
|
|
|
|
# Wikilinks
|
|
|
|
|
|
for tgt in _extract_wikilinks(note_text):
|
|
|
|
|
|
explicit_targets.add(tgt)
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind="references",
|
|
|
|
|
|
scope="note",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
source_id=note_id,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id="explicit:wikilink",
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# Inline
|
|
|
|
|
|
for rel, targets in _extract_inline_relations_lines(note_text):
|
|
|
|
|
|
for tgt in targets:
|
|
|
|
|
|
explicit_targets.add(tgt)
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind=rel,
|
|
|
|
|
|
scope="note",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
source_id=note_id,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id="inline:rel",
|
|
|
|
|
|
confidence=0.95,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# Callouts
|
|
|
|
|
|
for rel, targets in _extract_callout_edges(note_text):
|
|
|
|
|
|
for tgt in targets:
|
|
|
|
|
|
explicit_targets.add(tgt)
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind=rel,
|
|
|
|
|
|
scope="note",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
source_id=note_id,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id="callout:edge",
|
|
|
|
|
|
confidence=0.9,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 3) Typbasierte Default-Kanten (edge_defaults)
|
|
|
|
|
|
# - nur, wenn es explizite Ziele gibt (sonst kein Ableitungsanker)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
if types_cfg and explicit_targets:
|
|
|
|
|
|
type_entry = (types_cfg.get("types") or {}).get(note_type) or {}
|
|
|
|
|
|
defaults: List[str] = type_entry.get("edge_defaults") or []
|
|
|
|
|
|
defaults = [str(d).strip().lower() for d in defaults if str(d).strip()]
|
|
|
|
|
|
if defaults:
|
|
|
|
|
|
# default-Kanten als "note"-Scope (Konzeption: vom Note-Kontext aus)
|
2025-11-17 21:42:26 +01:00
|
|
|
|
for rel in defaults:
|
2025-11-18 07:50:46 +01:00
|
|
|
|
rule = f"edge_defaults:{note_type}:{rel}"
|
|
|
|
|
|
for tgt in sorted(explicit_targets):
|
|
|
|
|
|
edges.append(
|
|
|
|
|
|
_mk_edge_payload(
|
|
|
|
|
|
kind=rel,
|
|
|
|
|
|
scope="note",
|
|
|
|
|
|
note_id=note_id,
|
|
|
|
|
|
source_id=note_id,
|
|
|
|
|
|
target_id=tgt,
|
|
|
|
|
|
rule_id=rule,
|
|
|
|
|
|
confidence=0.7,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 4) De-Duplizierung (idempotent): Schlüssel (kind, scope, source_id, target_id, rule_id)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
seen: Set[Tuple[str, str, str, str, str]] = set()
|
|
|
|
|
|
uniq: List[Dict] = []
|
2025-11-17 21:42:26 +01:00
|
|
|
|
for e in edges:
|
2025-11-18 07:50:46 +01:00
|
|
|
|
key = (e["kind"], e["scope"], e["source_id"], e["target_id"], e["rule_id"])
|
2025-11-17 21:42:26 +01:00
|
|
|
|
if key in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(key)
|
2025-11-18 07:50:46 +01:00
|
|
|
|
uniq.append(e)
|
|
|
|
|
|
|
|
|
|
|
|
return uniq
|