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

174 lines
7.0 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
def _locker_mit_map(text: str) -> tuple[str, list[int]]:
"""Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf
Original-Positionen. 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 i, c in enumerate(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 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
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_ZEICHEN:
return None
pos = hay_l.find(ndl_l)
if pos < 0:
return _fuzzy_span(hay_l, map_l, ndl_l)
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
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