All checks were successful
Deploy mindnet to llm-node / deploy (push) Successful in 4s
164 lines
7.1 KiB
Python
164 lines
7.1 KiB
Python
"""
|
|
FILE: app/core/graph/graph_extractors.py
|
|
DESCRIPTION: Regex-basierte Extraktion von Relationen aus Text.
|
|
AUDIT:
|
|
- Regex für Wikilinks liberalisiert (Umlaute, Sonderzeichen).
|
|
- Callout-Parser erweitert für Multi-Line-Listen und Header-Typen.
|
|
"""
|
|
import re
|
|
from typing import List, Tuple
|
|
|
|
# Erlaube alle Zeichen außer ']' im Target (fängt Umlaute, Emojis, '&', '#' ab)
|
|
_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([^\]]+)\]\]")
|
|
|
|
_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)
|
|
|
|
# Erkennt [!edge] Callouts mit einem oder mehreren '>' am Anfang (für verschachtelte Callouts)
|
|
_CALLOUT_START = re.compile(r"^\s*>{1,}\s*\[!edge\]\s*(.*)$", re.IGNORECASE)
|
|
# Erkennt "kind: targets..."
|
|
_REL_LINE = re.compile(r"^(?P<kind>[a-z_]+)\s*:\s*(?P<targets>.+?)\s*$", re.IGNORECASE)
|
|
# Erkennt reine Typen (z.B. "depends_on" im Header)
|
|
_SIMPLE_KIND = re.compile(r"^[a-z_]+$", re.IGNORECASE)
|
|
|
|
def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]:
|
|
"""
|
|
Findet Inline-Relationen wie [[rel:depends_on Target]].
|
|
Gibt (Liste[(kind, target)], bereinigter_text) zurück.
|
|
"""
|
|
if not text: return [], ""
|
|
pairs = []
|
|
def _collect(m):
|
|
k, t = m.group("kind").strip().lower(), m.group("target").strip()
|
|
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.
|
|
Unterstützt zwei Formate:
|
|
1. Explizit: "kind: [[Target]]"
|
|
2. Implizit (Header): "> [!edge] kind" gefolgt von "[[Target]]" Zeilen
|
|
3. Verschachtelt: ">> [!edge] kind" in verschachtelten Callouts
|
|
"""
|
|
if not text: return [], text
|
|
lines = text.splitlines()
|
|
out_pairs = []
|
|
keep_lines = []
|
|
i = 0
|
|
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
m = _CALLOUT_START.match(line)
|
|
if not m:
|
|
keep_lines.append(line)
|
|
i += 1
|
|
continue
|
|
|
|
# Callout-Block gefunden. Wir sammeln alle relevanten Zeilen.
|
|
block_lines = []
|
|
|
|
# Header Content prüfen (z.B. "type" aus "> [!edge] type" oder ">> [!edge] type")
|
|
header_raw = m.group(1).strip()
|
|
if header_raw:
|
|
block_lines.append(header_raw)
|
|
|
|
# Bestimme die Einrückungsebene (Anzahl der '>' am Anfang der ersten Zeile)
|
|
leading_gt_count = len(line) - len(line.lstrip('>'))
|
|
if leading_gt_count == 0:
|
|
leading_gt_count = 1 # Fallback für den Fall, dass kein '>' gefunden wurde
|
|
|
|
i += 1
|
|
# Sammle alle Zeilen, die mit mindestens der gleichen Anzahl '>' beginnen
|
|
while i < len(lines):
|
|
next_line = lines[i]
|
|
stripped = next_line.lstrip()
|
|
# Prüfe, ob die Zeile mit mindestens der gleichen Anzahl '>' beginnt
|
|
if not stripped.startswith('>'):
|
|
break
|
|
next_leading_gt_count = len(next_line) - len(next_line.lstrip('>'))
|
|
# Wenn die Einrückung kleiner wird, haben wir den Block verlassen
|
|
if next_leading_gt_count < leading_gt_count:
|
|
break
|
|
# Entferne genau die Anzahl der führenden '>' entsprechend der Einrückungsebene
|
|
# und dann führende Leerzeichen
|
|
if next_leading_gt_count >= leading_gt_count:
|
|
# Entferne die führenden '>' (entsprechend der Einrückungsebene)
|
|
content = stripped[leading_gt_count:].lstrip()
|
|
if content:
|
|
block_lines.append(content)
|
|
i += 1
|
|
|
|
# Verarbeitung des Blocks
|
|
current_kind = None
|
|
|
|
# Heuristik: Ist die allererste Zeile (meist aus dem Header) ein reiner Typ?
|
|
# Dann setzen wir diesen als Default für den Block.
|
|
if block_lines:
|
|
first = block_lines[0]
|
|
# Wenn es NICHT wie "Key: Value" aussieht, aber wie ein Wort:
|
|
if not _REL_LINE.match(first) and _SIMPLE_KIND.match(first):
|
|
current_kind = first.lower()
|
|
|
|
for bl in block_lines:
|
|
# Prüfe, ob diese Zeile selbst ein neuer [!edge] Callout ist (für verschachtelte Blöcke)
|
|
edge_match = re.match(r"^\s*\[!edge\]\s*(.*)$", bl, re.IGNORECASE)
|
|
if edge_match:
|
|
# Neuer Edge-Callout gefunden, setze den Typ
|
|
edge_content = edge_match.group(1).strip()
|
|
if edge_content:
|
|
# Prüfe, ob es ein "kind: targets" Format ist
|
|
mrel = _REL_LINE.match(edge_content)
|
|
if mrel:
|
|
current_kind = mrel.group("kind").strip().lower()
|
|
targets = mrel.group("targets")
|
|
# Links extrahieren
|
|
found = _WIKILINK_RE.findall(targets)
|
|
if found:
|
|
for t in found: out_pairs.append((current_kind, t.strip()))
|
|
elif _SIMPLE_KIND.match(edge_content):
|
|
# Reiner Typ ohne Targets
|
|
current_kind = edge_content.lower()
|
|
continue
|
|
|
|
# 1. Prüfen auf explizites "Kind: Targets" (überschreibt Header-Typ für diese Zeile)
|
|
mrel = _REL_LINE.match(bl)
|
|
if mrel:
|
|
line_kind = mrel.group("kind").strip().lower()
|
|
targets = mrel.group("targets")
|
|
|
|
# Links extrahieren
|
|
found = _WIKILINK_RE.findall(targets)
|
|
if found:
|
|
for t in found: out_pairs.append((line_kind, t.strip()))
|
|
else:
|
|
# Fallback für kommagetrennten Plaintext
|
|
for raw in re.split(r"[,;]", targets):
|
|
if raw.strip(): out_pairs.append((line_kind, raw.strip()))
|
|
|
|
# Aktualisiere current_kind für nachfolgende Zeilen
|
|
current_kind = line_kind
|
|
continue
|
|
|
|
# 2. Kein Key:Value Muster -> Prüfen auf Links, die den current_kind nutzen
|
|
found = _WIKILINK_RE.findall(bl)
|
|
if found:
|
|
if current_kind:
|
|
for t in found: out_pairs.append((current_kind, t.strip()))
|
|
else:
|
|
# Link ohne Typ und ohne Header-Typ.
|
|
# Wird ignoriert oder könnte als 'related_to' fallback dienen.
|
|
# Aktuell: Ignorieren, um False Positives zu vermeiden.
|
|
pass
|
|
|
|
return out_pairs, "\n".join(keep_lines)
|
|
|
|
def extract_wikilinks(text: str) -> List[str]:
|
|
"""Findet Standard-Wikilinks [[Target]] oder [[Alias|Target]]."""
|
|
if not text: return []
|
|
return [m.strip() for m in _WIKILINK_RE.findall(text) if m.strip()] |