init
This commit is contained in:
155
backend/textkit.py
Normal file
155
backend/textkit.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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:
|
||||
pos = haystack.casefold().find(needle.casefold())
|
||||
if pos >= 0:
|
||||
start = mapping[pos]
|
||||
ende = mapping[min(pos + len(needle) - 1, len(mapping) - 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
|
||||
Reference in New Issue
Block a user