Files
creator2/backend/textkit.py
2026-07-13 12:31:25 +02:00

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.910.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