""" 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) # 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[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 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()]