"""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 # Sprache kommt aus config.SPRACHE (Lektion 106) — kein hartkodiertes Deutsch try: import Stemmer _name = config.STEMMER_NAME.get(config.SPRACHE) _stemmer = Stemmer.Stemmer(_name) if _name else None except ImportError: # Fallback ohne C-Extension: ungestemmt _stemmer = None _WORT_RE = re.compile(r"\w+", re.UNICODE) # alle Schriften, nicht nur Latein _NEGATION = config.NEGATIONEN.get(config.SPRACHE, set()) _NEG_PRAEFIX = config.NEGATIONS_PRAEFIX.get(config.SPRACHE, "\0") 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. Lexikon je Sprache; ohne Eintrag ist der Guard bewusst aus (dokumentiert).""" t = tokens(text) return frozenset(w for w in t if w in _NEGATION or w.startswith(_NEG_PRAEFIX)) 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+") def satz_split(text: str) -> list[str]: """Terminator + Whitespace; Teile, die klein weitergehen (Abkürzungen, „z. B."), werden wieder angefügt — Unicode-Groß statt [A-ZÄÖÜ]-Klasse.""" saetze = [] for absatz in text.split("\n"): absatz = absatz.strip() if not absatz or absatz.startswith(("#", "```", "|", "