Drei Schritte, die zusammen Phase 2 lauffaehig machen.
Revisionen (migrations/p2-004, revisionen.py)
Ingest schreibt jede geaenderte Fassung eines Items nach
raw_item_versionen fort. Daraus faellt ein Befund ab, den die
Spezifikation nicht vorsehen konnte: welches Haus seine Schlagzeile
nachtraeglich umschreibt, und wie. 206 von 3407 Items, acht
Einstufungen von 'formal' bis 'neufassung'. Kein Modell, kein Lexikon,
kein Netzzugriff - nur ein Wortvergleich zweier Fassungen.
Fortschreibungen sind ueber ticker_markiert und kopf_stabil abziehbar,
absichtlich als zwei Merkmale statt einer Kategorie: das eine ist eine
Selbstauskunft des Hauses, das andere eine Beobachtung am Satzbau.
Embeddings (embeddings.py)
multilingual-e5-large, 1024 Dimensionen, MIT. Jede Zeile wird auf feste
128 Token aufgefuellt, nicht nur bis zur laengsten des Stapels - sonst
haengt ein Vektor davon ab, mit welchen Nachbarn er kodiert wurde, und
ein Slot-Lauf liefert andere letzte Stellen als ein Backfill.
Nachgewiesen: 64/64 bitgleich einzeln wie im vollen Stapel, groesste
Abweichung 0.00e+00, und im Betrieb 26 Slot-Vektoren unveraendert
gegen den 3407er-Backfill.
Darum transformers statt sentence-transformers: letzteres kapselt genau
diese Stelle weg.
Container (Containerfile, phase2.py, deploy/)
Debian trixie wie der Postgres-Container, torch aus dem CPU-Index.
Die regelbasierten Module liefern darin fuer alle 3407 Items bitgleich
dieselbe Nutzlast wie auf der Entwicklungsmaschine - Portabilitaet
gemessen, nicht behauptet.
version.py bekommt den Schluessel 'revision'. Weil der Hash ueber den ganzen
Komponentensatz laeuft, aendert das auch die Version des Dublettenmoduls,
obwohl an dessen Verfahren nichts anders ist. Das ist die in version.py
beschriebene Semantik der Spezifikation, kein Versehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""Textnormalisierung fuer Phase 2.
|
||
|
||
Gemeinsame Vorstufe der Module. Jede Aenderung hier aendert die
|
||
coder_version - siehe version.py.
|
||
|
||
Grundsatz: die Normalisierung ist rein und ohne Zustand. Gleiche Eingabe,
|
||
gleiche Ausgabe, auf jeder Maschine und in jedem Prozess.
|
||
"""
|
||
|
||
import html
|
||
import re
|
||
import unicodedata
|
||
|
||
# Was hier steht, geht in den Versions-Hash ein.
|
||
REGELWERK_VERSION = "norm-3"
|
||
|
||
_TAGS = re.compile(r"<[^>]{1,200}>")
|
||
_MEHRFACH_LEER = re.compile(r"\s+")
|
||
|
||
# Anfuehrungszeichen und Striche vereinheitlichen. Verschiedene Haeuser setzen
|
||
# fuer dieselbe Agenturmeldung verschiedene Zeichen; ohne das scheitert schon
|
||
# die Trigrammbildung.
|
||
_ZEICHEN = str.maketrans({
|
||
"„": '"', "“": '"', "”": '"', "»": '"', "«": '"', "‟": '"',
|
||
"‚": "'", "‘": "'", "’": "'", "‹": "'", "›": "'", "´": "'", "`": "'",
|
||
"–": "-", "—": "-", "‑": "-", "‒": "-", "−": "-",
|
||
"…": "...", " ": " ", " ": " ", " ": " ", "": " ",
|
||
})
|
||
|
||
# "+++ Newsblog +++" und Verwandtes: redaktioneller Aufmacher, der nichts
|
||
# ueber den Inhalt sagt. Dieselbe Meldung traegt ihn bei einem Haus und beim
|
||
# naechsten nicht.
|
||
_PLUSKASTEN = re.compile(r"\+{2,}[^+]{0,120}?\+{2,}")
|
||
|
||
# Satzzeichen fallen weg: sie sind der haeufigste Unterschied zwischen zwei
|
||
# Fassungen derselben Meldung und tragen nichts zur Unterscheidung bei.
|
||
_KEIN_WORT = re.compile(r"[^\w\s]", re.UNICODE)
|
||
|
||
# Ressortkuerzel, das Haeuser ihren Titeln voranstellen: "Raumfahrt: Deutsche
|
||
# Rakete ...", "Notfaelle: Fuenf Tote ...", "Fussball: FIFA-Praesident ...".
|
||
# Dieselbe Agenturzeile bekommt bei jedem Haus ein anderes - oder gar keins.
|
||
#
|
||
# Bewusst eng gefasst: hoechstens vier Woerter vor dem Doppelpunkt, und was
|
||
# danach kommt, muss fuer sich stehen koennen. Sonst frisst das Muster
|
||
# Titel, bei denen der Teil vor dem Doppelpunkt die eigentliche Aussage
|
||
# traegt ("Habeck: Wir haben uns geirrt").
|
||
_RESSORT = re.compile(r"^\s*(?P<kopf>[^:]{2,34}):\s+(?P<rest>.+)$", re.DOTALL)
|
||
_RESSORT_MAX_WOERTER = 4
|
||
_REST_MIN_WOERTER = 5
|
||
|
||
|
||
def ressort_teilen(text):
|
||
"""(kopf, rest) - kopf ist None, wenn kein Ressortkuerzel zu erkennen ist.
|
||
|
||
Das Modul revisionen braucht beide Teile getrennt: ob ein Haus die
|
||
Ressortmarke oder die Schlagzeile selbst geaendert hat, sind zwei sehr
|
||
verschiedene Vorgaenge.
|
||
"""
|
||
treffer = _RESSORT.match(text)
|
||
if not treffer:
|
||
return None, text
|
||
kopf, rest = treffer.group("kopf"), treffer.group("rest")
|
||
if len(kopf.split()) > _RESSORT_MAX_WOERTER:
|
||
return None, text
|
||
if len(rest.split()) < _REST_MIN_WOERTER:
|
||
return None, text
|
||
return kopf, rest
|
||
|
||
|
||
def _ressort_abtrennen(text):
|
||
return ressort_teilen(text)[1]
|
||
|
||
|
||
def normalisiere(*teile, ressort_abtrennen=False, pluskasten=True):
|
||
"""Vergleichstext aus einem oder mehreren Feldern.
|
||
|
||
Reihenfolge ist bedeutsam: Tags vor Entities, NFKC vor der
|
||
Zeichentabelle, Kleinschreibung vor den Mustern, und das Ressortkuerzel
|
||
vor dem Entfernen der Satzzeichen - danach gibt es keinen Doppelpunkt
|
||
mehr, an dem es zu erkennen waere.
|
||
|
||
ressort_abtrennen gilt nur fuer Titel. Im Teaser steht der Doppelpunkt
|
||
fuer gewoehnlich mitten im Satz.
|
||
|
||
pluskasten=False laesst "++ ... ++" stehen. Beim Dublettenvergleich ist
|
||
der Kasten Beiwerk, das die Haeuser verschieden setzen. Bei den
|
||
Revisionen ist er der Text: die tagesschau fuehrt ihren Liveticker
|
||
vollstaendig darin ("++ Liveticker zur Wahl: ... ++"), und mit Entfernung
|
||
bliebe von der Schlagzeile nichts uebrig, was sich vergleichen liesse.
|
||
"""
|
||
text = " ".join(t for t in teile if t)
|
||
if not text:
|
||
return ""
|
||
text = _TAGS.sub(" ", text)
|
||
text = html.unescape(text)
|
||
text = unicodedata.normalize("NFKC", text)
|
||
text = text.translate(_ZEICHEN)
|
||
text = text.casefold()
|
||
if pluskasten:
|
||
text = _PLUSKASTEN.sub(" ", text)
|
||
if ressort_abtrennen:
|
||
text = _ressort_abtrennen(text)
|
||
text = _KEIN_WORT.sub(" ", text)
|
||
return _MEHRFACH_LEER.sub(" ", text).strip()
|
||
|
||
|
||
def bereinige(*teile):
|
||
"""Text fuer neuronale Encoder: aufgeraeumt, aber nicht zerlegt.
|
||
|
||
normalisiere() macht Text vergleichbar, indem es ihn abschleift -
|
||
Kleinschreibung, keine Satzzeichen, keine Ressortmarke. Fuer einen
|
||
Encoder ist das Zerstoerung: er ist auf natuerlichem Text trainiert,
|
||
Grossschreibung unterscheidet im Deutschen Wortarten, und die
|
||
Anfuehrungszeichen um ein Zitat sind Bedeutung.
|
||
|
||
Entfernt wird deshalb nur, was kein Text ist: Auszeichnung, Entities,
|
||
uneinheitliche Unicode-Varianten, ueberzaehliger Leerraum.
|
||
"""
|
||
text = " ".join(t for t in teile if t)
|
||
if not text:
|
||
return ""
|
||
text = _TAGS.sub(" ", text)
|
||
text = html.unescape(text)
|
||
text = unicodedata.normalize("NFKC", text)
|
||
text = text.translate(_ZEICHEN)
|
||
return _MEHRFACH_LEER.sub(" ", text).strip()
|
||
|
||
|
||
def woerter(text):
|
||
return text.split()
|
||
|
||
|
||
def trigramme(text):
|
||
"""Wort-Trigramme als Menge.
|
||
|
||
Menge, nicht Liste: der Jaccard-Wert braucht sie so, und Wiederholungen
|
||
innerhalb einer kurzen Meldung sind kein Signal.
|
||
"""
|
||
w = woerter(text)
|
||
if len(w) < 3:
|
||
# Zu kurz fuer Trigramme. Das Wort selbst ist besser als nichts,
|
||
# aber der Aufrufer prueft ohnehin auf Mindestlaenge.
|
||
return {(x,) for x in w}
|
||
return {tuple(w[i:i + 3]) for i in range(len(w) - 2)}
|