Enhance callout relation extraction by ensuring correct termination on new headers. Update regex for simple kinds to support hyphens. Refactor block processing logic for improved clarity and functionality.

This commit is contained in:
Lars 2025-12-30 09:26:38 +01:00
parent ef8cf719f2
commit ef1046c6f5

View File

@ -2,8 +2,8 @@
FILE: app/core/graph/graph_extractors.py
DESCRIPTION: Regex-basierte Extraktion von Relationen aus Text.
AUDIT:
- FIX: extract_callout_relations stoppt nun korrekt bei neuem Header.
- 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
@ -16,10 +16,8 @@ _REL_SPACE = re.compile(r"\[\[\s*rel:(?P<kind>[a-z_]+)\s+(?P<target>[^\]]+?)\s*\
_REL_TEXT = re.compile(r"rel\s*:\s*(?P<kind>[a-z_]+)\s*\[\[\s*(?P<target>[^\]]+?)\s*\]\]", re.IGNORECASE)
_CALLOUT_START = re.compile(r"^\s*>\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)
_SIMPLE_KIND = re.compile(r"^[a-z_\-]+$", re.IGNORECASE)
def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]:
"""
@ -40,9 +38,7 @@ def extract_typed_relations(text: str) -> Tuple[List[Tuple[str, str]], str]:
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
Stoppt korrekt, wenn ein neuer Header innerhalb eines Blocks gefunden wird.
"""
if not text: return [], text
lines = text.splitlines()
@ -52,76 +48,88 @@ def extract_callout_relations(text: str) -> Tuple[List[Tuple[str,str]], str]:
while i < len(lines):
line = lines[i]
# 1. Start eines Blocks erkannt
m = _CALLOUT_START.match(line)
if not m:
keep_lines.append(line)
i += 1
continue
# Callout-Block gefunden. Wir sammeln alle relevanten Zeilen.
if m:
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
# Sammle Folgezeilen, solange sie mit '>' beginnen UND KEIN neuer Header sind
while i < len(lines) and lines[i].lstrip().startswith('>'):
# Entferne '>' und führende Leerzeichen
# STOP-CHECK: Ist das ein neuer Header?
if _CALLOUT_START.match(lines[i]):
break # Breche inneren Loop ab -> Outer Loop behandelt den neuen Header
content = lines[i].lstrip()[1:].lstrip()
if content:
block_lines.append(content)
i += 1
# Verarbeitung des Blocks
current_kind = None
_process_block(block_lines, out_pairs)
continue # Weiter im Outer Loop (i steht jetzt auf dem nächsten Header oder Text)
# 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:
# 2. "Headless" Block / Zerschnittener Chunk
# Wenn Zeile mit '>' beginnt, Links hat, aber wir nicht in einem Header-Block sind
if line.lstrip().startswith('>'):
if _WIKILINK_RE.search(line):
block_lines = []
# Sammeln bis Ende oder neuer Header
while i < len(lines) and lines[i].lstrip().startswith('>'):
if _CALLOUT_START.match(lines[i]):
break
content = lines[i].lstrip()[1:].lstrip()
if content:
block_lines.append(content)
i += 1
# Als 'related_to' retten, falls Typ fehlt
_process_block(block_lines, out_pairs, default_kind="related_to")
continue
keep_lines.append(line)
i += 1
return out_pairs, "\n".join(keep_lines)
def _process_block(lines: List[str], out_pairs: List[Tuple[str, str]], default_kind: str = None):
"""Parsen eines isolierten Blocks."""
current_kind = default_kind
if lines:
first = lines[0]
# Ist die erste Zeile ein Typ? (z.B. "based_on")
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)
for bl in lines:
# Format "kind: [[Target]]"
mrel = _REL_LINE.match(bl)
if mrel:
line_kind = mrel.group("kind").strip().lower()
k = 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()))
for t in found: out_pairs.append((k, 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.
if raw.strip(): out_pairs.append((k, raw.strip()))
continue
# 2. Kein Key:Value Muster -> Prüfen auf Links, die den current_kind nutzen
# Format "[[Target]]" (nutzt current_kind)
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)
# Fallback ohne Typ
for t in found: out_pairs.append(("related_to", t.strip()))
def extract_wikilinks(text: str) -> List[str]:
"""Findet Standard-Wikilinks [[Target]] oder [[Alias|Target]]."""
"""Findet Standard-Wikilinks."""
if not text: return []
return [m.strip() for m in _WIKILINK_RE.findall(text) if m.strip()]