"""Deterministische Text-Helfer: Normalisierung, Verbatim-Zitatsuche mit Offset- Mapping, Abschnitts-Split. Lektionen 27 (Casefold+NFKC), 31 (Negations-Guard), 43 (Dash-Toleranz), 47 (Abschnitte).""" import re import unicodedata from config import ABSCHNITT_CHARS, FUZZY_FEHLERQUOTE, FUZZY_MIN_ZEICHEN _WS = re.compile(r"\s+") _WORT = re.compile(r"[a-zäöüß0-9]+") def norm(text: str) -> str: """NFKC + casefold + Whitespace-Faltung — für jeden Ähnlichkeits-/Gleichheitsvergleich.""" return _WS.sub(" ", unicodedata.normalize("NFKC", text).casefold()).strip() def tokens(text: str) -> set[str]: return set(_WORT.findall(norm(text))) _NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne", "un"} def negations_menge(text: str) -> frozenset[str]: """Antonyme messen 0.91–0.95 Cosinus — gleiche Negationsmenge ist harte Merge-Vorbedingung.""" return frozenset(t for t in tokens(text) if t in _NEGATION or t.startswith("nicht")) def _normiert_mit_map(text: str) -> tuple[str, list[int]]: """Whitespace-gefaltete Kopie + Map: Position in Kopie → Position im Original.""" out: list[str] = [] mapping: list[int] = [] in_ws = False for i, c in enumerate(text): if c.isspace(): if not in_ws and out: out.append(" ") mapping.append(i) in_ws = True else: out.append(c) mapping.append(i) in_ws = False return "".join(out), mapping # LaTeX-Kontrollwörter für den Vergleich neutralisieren: das Modell zitiert die # GERENDERTE Lesart („k ∈ ℕ"), die tex-Quelle enthält Markup ($k \in \mathbb{N}$) # — Anker-Identität muss unter diesem Rendering invariant sein (aak: 57/146 Atome # ohne Anker). Griechische Befehle → Unicode-Buchstabe (überlebt die alnum-Faltung # auf beiden Seiten wie das σ im Zitat); alle anderen Befehle fallen weg — ihre # gerenderten Symbole (∈, ⊆, ≥) verwirft die alnum-Faltung ebenfalls. # \begin{itemize} usw. KOMPLETT schlucken (erste Alternative) — sonst bleibt der # Umgebungsname als Residuum in der Faltung („itemize") und Zitate über # Listenstarts scheitern; danach normale Befehlswörter _LATEX_WORT = re.compile(r"\\(?:begin|end)\{[^}]*\}|\\[a-zA-Z]+") _GRIECHISCH = {g: chr(c) for g, c in ( ("alpha", 0x3B1), ("beta", 0x3B2), ("gamma", 0x3B3), ("delta", 0x3B4), ("epsilon", 0x3B5), ("varepsilon", 0x3B5), ("zeta", 0x3B6), ("eta", 0x3B7), ("theta", 0x3B8), ("iota", 0x3B9), ("kappa", 0x3BA), ("lambda", 0x3BB), ("mu", 0x3BC), ("nu", 0x3BD), ("xi", 0x3BE), ("pi", 0x3C0), ("rho", 0x3C1), ("sigma", 0x3C3), ("tau", 0x3C4), ("phi", 0x3C6), ("varphi", 0x3C6), ("chi", 0x3C7), ("psi", 0x3C8), ("omega", 0x3C9), ("Gamma", 0x393), ("Delta", 0x394), ("Theta", 0x398), ("Lambda", 0x39B), ("Xi", 0x39E), ("Pi", 0x3A0), ("Sigma", 0x3A3), ("Phi", 0x3A6), ("Psi", 0x3A8), ("Omega", 0x3A9))} def _latex_falten(text: str) -> list[tuple[str, int]]: """→ (Zeichen, Original-Position)-Paare mit neutralisierten LaTeX-Befehlen.""" paare: list[tuple[str, int]] = [] pos = 0 for m in _LATEX_WORT.finditer(text): for i in range(pos, m.start()): paare.append((text[i], i)) if "{" not in m.group(0): # Umgebungen komplett schlucken for ch in _GRIECHISCH.get(m.group(0)[1:], ""): paare.append((ch, m.start())) pos = m.end() for i in range(pos, len(text)): paare.append((text[i], i)) return paare def _locker_mit_map(text: str) -> tuple[str, list[int]]: """Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf Original-Positionen, LaTeX-Befehle neutralisiert (s. o.). Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute („a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen komponiert NFKC nie. Also BEIDE Seiten auf den Basisbuchstaben falten (ä→a); ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker).""" out: list[str] = [] mapping: list[int] = [] for c, i in _latex_falten(text): for cn in unicodedata.normalize("NFKD", c).casefold(): if cn.isalnum() and not unicodedata.combining(cn): out.append(cn) mapping.append(i) return "".join(out), mapping def _casefold_mit_map(text: str) -> tuple[str, list[int]]: """Gefaltete Kopie + Map auf die Original-Positionen. casefold() kann ein Zeichen zu mehreren expandieren (ß→ss) — die Map hält den Rückweg eindeutig.""" out: list[str] = [] mapping: list[int] = [] for i, c in enumerate(text): for cn in c.casefold(): out.append(cn) mapping.append(i) return "".join(out), mapping _LOCKER_MIN_ZEICHEN = 20 # darunter: nur bei EINDEUTIGEM Vorkommen matchen _LOCKER_MIN_EINDEUTIG = 10 # darunter: nie (zu wenig Signal, auch wenn einmalig) def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None: """Zitat im Quelltext finden. Vier Stufen (Hypothes.is-Muster, Lektion 83): 1. exakt (nur Whitespace-tolerant), 2. + casefold (PDF-Small-Caps), 3. alnum-gefaltet (Interpunktion/Dashes/Quotes egal, Wortlaut identisch), 4. fuzzy auf der Faltung (fuzzysearch, harte Fehlerquote + Eindeutigkeits-Guard) — fängt Reader-„Verschönerungen": weggelassene Listing-Zeilennummern, rekonstruierte Glyphen, geglättete Formeln. → (start, ende) im Original oder None. Paraphrasen bleiben draußen (Distanzschranke, keine Wort-Umstellungen).""" if not zitat.strip(): return None haystack, mapping = _normiert_mit_map(text) needle = _WS.sub(" ", zitat).strip() pos = haystack.find(needle) if pos >= 0: return mapping[pos], mapping[min(pos + len(needle) - 1, len(mapping) - 1)] + 1 # Stufe 2: casefold — eigene Map, weil casefold die Länge ändert (ß→ss, fi→fi). # Direkt in `mapping` zu indizieren verschob den Span um jede Expansion vor dem # Treffer und lief am Textende in einen IndexError. hay_cf, sub = _casefold_mit_map(haystack) ndl_cf = needle.casefold() pos = hay_cf.find(ndl_cf) if pos >= 0: start = mapping[sub[pos]] ende = mapping[sub[min(pos + len(ndl_cf) - 1, len(sub) - 1)]] + 1 return start, ende hay_l, map_l = _locker_mit_map(text) ndl_l, _ = _locker_mit_map(zitat) if len(ndl_l) < _LOCKER_MIN_EINDEUTIG: return None if len(ndl_l) < _LOCKER_MIN_ZEICHEN: # Eindeutigkeit war der eigentliche Grund des Längen-Gates — direkt # prüfen statt Proxy-Länge: kurze Formel-Zitate ($LPT(I)=\OPT(I)$) # sind oft einmalig und damit sichere Anker. Kein Fuzzy für Kurze. if hay_l.count(ndl_l) != 1: return None pos = hay_l.find(ndl_l) return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1 pos = hay_l.find(ndl_l) if pos < 0: span = _fuzzy_span(hay_l, map_l, ndl_l) # Negations-Guard auch für Stufe 4: eine eingefügte Negation ist nur # ~5 Zeichen Distanz — semantisch aber das Gegenteil (nie ankern) if span and negations_menge(text[span[0]:span[1]]) != negations_menge(zitat): span = None return span or _wort_span(text, zitat) return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1 def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | None: """Stufe 4: Levenshtein-Substring auf der alnum-Faltung. Nur ab FUZZY_MIN_ZEICHEN, Distanz ≤ FUZZY_FEHLERQUOTE·Länge. Eindeutigkeits-Guard (Falsch-Anker >> fehlender Anker): gibt es einen NICHT überlappenden Zweittreffer fast gleicher Distanz → None (Realfall: SubSetSum- und Partition-Definition teilen den Satzanfang).""" if len(ndl_l) < FUZZY_MIN_ZEICHEN: return None try: from fuzzysearch import find_near_matches except ImportError: return None max_dist = max(2, int(len(ndl_l) * FUZZY_FEHLERQUOTE)) treffer = find_near_matches(ndl_l, hay_l, max_l_dist=max_dist) if not treffer: return None best = min(treffer, key=lambda m: (m.dist, m.start)) schranke = best.dist + max(3, int(len(ndl_l) * 0.05)) for m in treffer: if (m.end <= best.start or m.start >= best.end) and m.dist <= schranke: return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1 # ── Stufe 5: Wort-Alignment ────────────────────────────────────────────────── # Modelle zitieren semantisch, nie byte-treu: \cdot → „mal", \sqrt{} → „sqrt()", # kleine Auslassungen. Auf Zeichen-Ebene sprengt jede dieser Abweichungen die # 8-%-Schranke — auf WORT-Ebene ist es je 1 Edit. Echte Paraphrasen bleiben # draußen: dort sind die Wörter selbst anders (Wort-Distanz > Schranke). WORT_MIN = 8 # kürzere Zitate: Wort-Matching nicht mehr eindeutig genug WORT_FEHLERQUOTE = 0.25 # max. Wort-Edits als Anteil der Zitat-Wortzahl def _wort_folge(text: str) -> tuple[list[str], list[tuple[int, int]]]: """Gefaltete Wörter + (start, ende)-Spans im Original. Gleiche Faltung wie Stufe 3 (LaTeX neutralisiert, NFKD-alnum), aber mit Wortgrenzen.""" woerter: list[str] = [] spans: list[tuple[int, int]] = [] aktuell: list[str] = [] w_start = w_ende = -1 for c, i in _latex_falten(text): emittiert = False for cn in unicodedata.normalize("NFKD", c).casefold(): if cn.isalnum() and not unicodedata.combining(cn): if not aktuell: w_start = i aktuell.append(cn) w_ende = i emittiert = True if not emittiert and aktuell: woerter.append("".join(aktuell)) spans.append((w_start, w_ende + 1)) aktuell = [] if aktuell: woerter.append("".join(aktuell)) spans.append((w_start, w_ende + 1)) return woerter, spans def _wort_distanz(a: list[str], b: list[str], limit: int) -> int: """Levenshtein über Wortfolgen, mit Abbruch oberhalb von limit.""" if abs(len(a) - len(b)) > limit: return limit + 1 vorher = list(range(len(b) + 1)) for i, wa in enumerate(a, 1): zeile = [i] minimum = i for j, wb in enumerate(b, 1): d = min(vorher[j] + 1, zeile[j - 1] + 1, vorher[j - 1] + (wa != wb)) zeile.append(d) minimum = min(minimum, d) if minimum > limit: return limit + 1 vorher = zeile return vorher[-1] def _negationen(woerter: list[str]) -> frozenset[str]: return frozenset(w for w in woerter if w in _NEGATION or w.startswith("nicht")) def _wort_span(text: str, zitat: str) -> tuple[int, int] | None: """Bestes Fenster im Text mit minimaler WORT-Edit-Distanz zum Zitat. Kandidaten über die seltensten Zitat-Wörter (kein Voll-Scan); Schranken: Fehlerquote, Eindeutigkeits-Guard, Negations-Gleichheit (Falsch-Anker >> fehlender Anker).""" ndl, _ = _wort_folge(zitat) if len(ndl) < WORT_MIN: return None hay, spans = _wort_folge(text) limit = max(2, int(len(ndl) * WORT_FEHLERQUOTE)) vorkommen: dict[str, list[int]] = {} for i, w in enumerate(hay): vorkommen.setdefault(w, []).append(i) # seltenste Zitat-Wörter als Kandidaten-Anker (bis 3 Wörter, je ≤ 50 Stellen) selten = sorted(((len(vorkommen.get(w, [])), idx, w) for idx, w in enumerate(ndl) if 0 < len(vorkommen.get(w, [])) <= 50))[:3] starts: set[int] = set() for _, idx, w in selten: for p in vorkommen[w]: for versatz in (-2, -1, 0, 1, 2): s = p - idx + versatz if 0 <= s < len(hay): starts.add(s) treffer: list[tuple[int, int, int]] = [] # (dist, start, ende) for s in starts: for laenge in range(max(WORT_MIN, len(ndl) - 2), len(ndl) + 4): fenster = hay[s:s + laenge] if len(fenster) < WORT_MIN: continue d = _wort_distanz(ndl, fenster, limit) if d <= limit: treffer.append((d, s, s + len(fenster))) if not treffer: return None best = min(treffer) schranke = best[0] + max(1, int(len(ndl) * 0.08)) for d, s, e in treffer: if (e <= best[1] or s >= best[2]) and d <= schranke: return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen if _negationen(ndl) != _negationen(hay[best[1]:best[2]]): return None # Negations-Guard: nie „regulär" auf „nicht regulär" ankern return spans[best[1]][0], spans[best[2] - 1][1] def titel_kern(titel: str) -> str: """Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als deterministischer Dubletten-Kandidaten-Generator neben dem Embedding.""" kern, _ = _locker_mit_map(titel) return kern def abschnitte(text: str, max_chars: int = ABSCHNITT_CHARS) -> list[tuple[int, str]]: """Text an Absatzgrenzen in ≤max_chars-Abschnitte splitten (lost in the middle). → [(offset, abschnitt)].""" if len(text) <= max_chars: return [(0, text)] out: list[tuple[int, str]] = [] start = 0 while start < len(text): ende = min(start + max_chars, len(text)) if ende < len(text): brk = text.rfind("\n\n", start, ende) if brk <= start: brk = text.rfind("\n", start, ende) if brk > start: ende = brk out.append((start, text[start:ende])) start = ende return out 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