Files
creator3/backend/textkit.py
2026-07-23 16:07:36 +02:00

271 lines
9.5 KiB
Python

"""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
try:
import Stemmer
_stemmer = Stemmer.Stemmer("german")
except ImportError: # Fallback ohne C-Extension: ungestemmt
_stemmer = None
_WORT_RE = re.compile(r"[a-zäöüß0-9]+")
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"}
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."""
t = tokens(text)
return frozenset(w for w in t if w in _NEGATION or w.startswith("nicht"))
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+(?=[A-ZÄÖÜ])")
def satz_split(text: str) -> list[str]:
saetze = []
for absatz in text.split("\n"):
absatz = absatz.strip()
if not absatz or absatz.startswith(("#", "```", "|", "<!--")):
continue
saetze += [s.strip() for s in _SATZ_RE.split(absatz) if s.strip()]
return saetze
# ── Positions-Maps für Anker-Suche ──
def _map_bauen(text: str, wandler) -> tuple[str, list[int]]:
teile, positionen = [], []
for i, zeichen in enumerate(text):
for t in wandler(zeichen):
teile.append(t)
positionen.append(i)
return "".join(teile), positionen
def _ws_falten(text: str) -> tuple[str, list[int]]:
out, pos, war_ws = [], [], False
for i, c in enumerate(text):
if c.isspace():
if not war_ws and out:
out.append(" ")
pos.append(i)
war_ws = True
else:
out.append(c)
pos.append(i)
war_ws = False
return "".join(out), pos
def _locker(text: str) -> tuple[str, list[int]]:
"""Nur alnum, NFKD-Basisbuchstaben (dekomponierte Umlaute! Lektion 82)."""
def wandler(c: str):
for d in unicodedata.normalize("NFKD", c).casefold():
if d.isalnum() and not unicodedata.combining(d):
yield d
return _map_bauen(text, wandler)
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
zitat = zitat.strip()
if not zitat:
return None
# Stufe 1+2: whitespace-tolerant, dann casefold
t_flach, t_pos = _ws_falten(text)
z_flach, _ = _ws_falten(zitat)
for haystack, needle in ((t_flach, z_flach),
(t_flach.casefold(), z_flach.casefold())):
i = haystack.find(needle)
if i >= 0:
return t_pos[i], t_pos[min(i + len(needle), len(t_pos)) - 1] + 1
# Stufe 3: alnum-gefaltet
t_locker, tl_pos = _locker(text)
z_locker, _ = _locker(zitat)
if len(z_locker) >= 10:
erst = t_locker.find(z_locker)
if erst >= 0 and (len(z_locker) >= 20
or t_locker.find(z_locker, erst + 1) < 0):
return tl_pos[erst], tl_pos[min(erst + len(z_locker), len(tl_pos)) - 1] + 1
# Stufe 4: fuzzy (Levenshtein auf der Faltung)
span = _fuzzy_span(t_locker, tl_pos, z_locker)
if span and negations_menge(text[span[0]:span[1]]) == negations_menge(zitat):
return span
# Stufe 5: Wort-Alignment
span = _wort_span(text, zitat)
if span and negations_menge(text[span[0]:span[1]]) == negations_menge(zitat):
return span
return None
def _fuzzy_span(t_locker: str, tl_pos: list[int], z_locker: str):
if len(z_locker) < config.FUZZY_MIN_ZEICHEN:
return None
try:
from fuzzysearch import find_near_matches
except ImportError:
return None
dist = max(1, int(len(z_locker) * config.FUZZY_FEHLERQUOTE))
treffer = find_near_matches(z_locker, t_locker, max_l_dist=dist)
if not treffer:
return None
treffer.sort(key=lambda m: m.dist)
best = treffer[0]
# Eindeutigkeits-Guard: nicht-überlappender Zweittreffer fast gleicher Distanz
for m in treffer[1:]:
if m.start >= best.end or m.end <= best.start:
if m.dist <= best.dist + 1:
return None
break
return tl_pos[best.start], tl_pos[min(best.end, len(tl_pos)) - 1] + 1
def _woerter_mit_spans(text: str) -> list[tuple[str, int, int]]:
out = []
for m in re.finditer(r"\S+", text):
gefaltet = "".join(c for c in unicodedata.normalize("NFKD", m.group()).casefold()
if c.isalnum() and not unicodedata.combining(c))
if gefaltet:
out.append((gefaltet, m.start(), m.end()))
return out
def _wort_span(text: str, zitat: str):
z_woerter = [w for w, _, _ in _woerter_mit_spans(zitat)]
if len(z_woerter) < config.WORT_MIN:
return None
t_woerter = _woerter_mit_spans(text)
if not t_woerter:
return None
limit = int(len(z_woerter) * config.WORT_FEHLERQUOTE)
# Kandidaten über das seltenste Zitat-Wort (kein Voll-Scan)
haeufigkeit = {}
for w, _, _ in t_woerter:
haeufigkeit[w] = haeufigkeit.get(w, 0) + 1
selten = min((w for w in z_woerter if w in haeufigkeit),
key=lambda w: haeufigkeit[w], default=None)
if selten is None:
return None
beste, beste_d = None, limit + 1
for i, (w, _, _) in enumerate(t_woerter):
if w != selten:
continue
von = max(0, i - len(z_woerter))
bis = min(len(t_woerter), i + len(z_woerter))
fenster = [x[0] for x in t_woerter[von:bis]]
d = _wort_distanz(fenster, z_woerter, limit)
if d is not None and d < beste_d:
beste, beste_d = (von, bis), d
elif d is not None and d == beste_d and beste and von != beste[0]:
return None # mehrdeutig
if beste is None:
return None
return t_woerter[beste[0]][1], t_woerter[beste[1] - 1][2]
def _wort_distanz(a: list[str], b: list[str], limit: int) -> int | None:
zeile = list(range(len(b) + 1))
for i, wa in enumerate(a, 1):
neu = [i]
for j, wb in enumerate(b, 1):
neu.append(min(zeile[j] + 1, neu[-1] + 1,
zeile[j - 1] + (wa != wb)))
if min(neu) > limit:
return None
zeile = neu
return zeile[-1] if zeile[-1] <= limit else None
# ── DELIMITED-Parser (===BLOCK===-Format, R5/R9: kein JSON) ──
def bloecke(text: str, marke: str) -> list[dict]:
"""'===MARKE===\nKEY: wert\n' → [{key: wert}]. Mehrzeilige Werte bis zum
nächsten GROSS-KEY. Unbekannte Zeilen hängen am letzten Key."""
ergebnis = []
teile = re.split(rf"^===\s*{re.escape(marke)}\s*===\s*$", text,
flags=re.MULTILINE)
for teil in teile[1:]:
felder: dict[str, str] = {}
key = None
for zeile in teil.splitlines():
if zeile.strip().startswith("==="):
break
m = re.match(r"^([A-ZÄÖÜ_]{2,20}):\s?(.*)$", zeile)
if m:
key = m.group(1).lower()
felder[key] = m.group(2)
elif key and zeile.strip():
felder[key] += "\n" + zeile
if felder:
ergebnis.append({k: v.strip() for k, v in felder.items()})
return ergebnis
def block_text(text: str, marke: str) -> str:
"""Inhalt zwischen ===MARKE=== und dem nächsten ===…=== (oder Ende)."""
m = re.search(rf"^===\s*{re.escape(marke)}\s*===\s*$(.*?)(?=^===|\Z)", text,
flags=re.MULTILINE | re.DOTALL)
return m.group(1).strip() if m else ""