""" 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[a-z_]+)\s*\|\s*(?P[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_SPACE = re.compile(r"\[\[\s*rel:(?P[a-z_]+)\s+(?P[^\]]+?)\s*\]\]", re.IGNORECASE) _REL_TEXT = re.compile(r"rel\s*:\s*(?P[a-z_]+)\s*\[\[\s*(?P[^\]]+?)\s*\]\]", re.IGNORECASE) _CALLOUT_START = re.compile(r"^\s*>\s*\[!edge\]\s*(.*)$", re.IGNORECASE) # Erkennt "kind: targets..." _REL_LINE = re.compile(r"^(?P[a-z_]+)\s*:\s*(?P.+?)\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 """ 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") header_raw = m.group(1).strip() if header_raw: block_lines.append(header_raw) i += 1 while i < len(lines) and lines[i].lstrip().startswith('>'): # Entferne '>' und führende Leerzeichen content = lines[i].lstrip()[1:].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: # 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())) # Wenn wir eine explizite Zeile gefunden haben, aktualisieren wir NICHT # den current_kind für nachfolgende Zeilen (Design-Entscheidung: lokal scope), # oder wir machen es doch? # Üblicher ist: Header setzt Default, Zeile überschreibt lokal. # Wir lassen current_kind also unangetastet. 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()]