update
This commit is contained in:
@@ -47,9 +47,48 @@ def _normiert_mit_map(text: str) -> tuple[str, list[int]]:
|
||||
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. Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren
|
||||
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
|
||||
@@ -57,7 +96,7 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]:
|
||||
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 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)
|
||||
@@ -77,7 +116,8 @@ def _casefold_mit_map(text: str) -> tuple[str, list[int]]:
|
||||
return "".join(out), mapping
|
||||
|
||||
|
||||
_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
|
||||
_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:
|
||||
@@ -107,11 +147,24 @@ def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
|
||||
return start, ende
|
||||
hay_l, map_l = _locker_mit_map(text)
|
||||
ndl_l, _ = _locker_mit_map(zitat)
|
||||
if len(ndl_l) < _LOCKER_MIN_ZEICHEN:
|
||||
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:
|
||||
return _fuzzy_span(hay_l, map_l, ndl_l)
|
||||
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
|
||||
|
||||
|
||||
@@ -138,6 +191,108 @@ def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | N
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user