271 lines
9.5 KiB
Python
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 ""
|