"""Textwerkzeuge. Regel: NFKC+casefold vor JEDEM Vergleich (Lektion 27). finde_zitat = 5 Stufen (Hypothes.is-Muster, Lektion 83): exakt → casefold → alnum-gefaltet → fuzzy (8 %) → Wort-Alignment. Falsch-Anker >> fehlender Anker.""" import re import unicodedata from . import config try: import Stemmer _stemmer = Stemmer.Stemmer("german") except ImportError: # Fallback ohne C-Extension: ungestemmt _stemmer = None _WORT_RE = re.compile(r"[a-zäöüß0-9]+") _NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"} def norm(text: str) -> str: return " ".join(unicodedata.normalize("NFKC", text).casefold().split()) def tokens(text: str) -> set[str]: return set(_WORT_RE.findall(norm(text))) def stamm_tokens(text: str) -> set[str]: t = _WORT_RE.findall(norm(text)) return set(_stemmer.stemWords(t)) if _stemmer else set(t) def jaccard(a: str, b: str, gestemmt: bool = True) -> float: ta, tb = (stamm_tokens(a), stamm_tokens(b)) if gestemmt else (tokens(a), tokens(b)) if not ta or not tb: return 0.0 return len(ta & tb) / len(ta | tb) def negations_menge(text: str) -> frozenset: """Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung.""" t = tokens(text) return frozenset(w for w in t if w in _NEGATION or w.startswith("nicht")) def titel_kern(titel: str) -> str: """Alnum-Faltung (NFKD, Kombinationszeichen raus) — deterministischer Dubletten-Kandidat neben der Ähnlichkeit.""" d = unicodedata.normalize("NFKD", titel).casefold() return "".join(c for c in d if c.isalnum()) def ist_akronym_von(kurz: str, lang: str) -> bool: """Akronym↔Expansion (Lektion 28 — fehlte in creator2): 'HTTP' ↔ 'Hypertext Transfer Protocol'.""" k = titel_kern(kurz) woerter = _WORT_RE.findall(norm(lang)) if len(k) < 2 or len(woerter) < 2 or len(k) > len(woerter) + 2: return False initialen = "".join(w[0] for w in woerter) return k == initialen or k == "".join(w[0] for w in woerter if len(w) > 2) def abschnitte(text: str, max_chars: int = config.ABSCHNITT_CHARS) -> list[tuple[int, str]]: """Split an Absatzgrenzen (lost-in-the-middle-Guard) → [(offset, chunk)].""" ergebnis, start = [], 0 while start < len(text): ende = min(start + max_chars, len(text)) if ende < len(text): bruch = text.rfind("\n\n", start, ende) if bruch > start + max_chars // 4: ende = bruch ergebnis.append((start, text[start:ende])) start = ende return [(o, c) for o, c in ergebnis if c.strip()] def ueberlappung(a: tuple[int, int], b: tuple[int, int]) -> float: """Span-Überlappung relativ zum kürzeren Span [0..1].""" schnitt = max(0, min(a[1], b[1]) - max(a[0], b[0])) kuerzer = min(a[1] - a[0], b[1] - b[0]) return schnitt / kuerzer if kuerzer > 0 else 0.0 _SATZ_RE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÄÖÜ])") def satz_split(text: str) -> list[str]: saetze = [] for absatz in text.split("\n"): absatz = absatz.strip() if not absatz or absatz.startswith(("#", "```", "|", "