QC: Umlaut-Dict aus hunspell-de-de generieren (statt handkuratiert)

Loest das Abdeckungs-Problem des handkuratierten Dicts (~300 Eintraege,
~95%). Neu: vollautomatisch erzeugtes Korpus-Dict aus hunspell-de-de
mit 153.869 Eintraegen (>99% Abdeckung), plus schlankes Supplement
fuer Komposita, die hunspell nicht liefert.

Build-Skript (scripts/build_umlaut_dict.py):
- ruft /usr/bin/unmunch gegen /usr/share/hunspell/de_DE.dic+aff auf
- filtert Woerter mit echten Umlauten (ä/ö/ü/ß)
- generiert je Wort die Umschreibungsform (ae/oe/ue/ss) + Capitalize
- Mehrdeutigkeits-Check: skippt Paare wo die Umschreibung selbst
  ein gueltiges deutsches Wort ist (z. B. dass/daß, Masse/Maße, Busse/Buße)
- Ergebnis: 153.869 Eintraege, 27 mehrdeutige Formen ausgefiltert
- Alphabetisch sortiertes JSON (diff-freundlich)

Laufzeit-Refactor (src/services/post_refresh_qc.py):
- _UMLAUT_BASE Dict (handkuratiert) entfernt, dafuer JSON-Loader
  beim Modul-Import aus src/services/umlaut_dict.json
- _MANUAL_SUPPLEMENT fuer Luecken (Konjunktiv saeen, Amtstitel-
  Komposita wie Aussenminister/Parlamentspraesident, Strassen-
  Komposita, Fuehrungs-Komposita) — ueberlagert Korpus-Dict
- _UMLAUT_WHITELIST erweitert um englische Fremdwoerter (Boeing,
  Business, Access, Process, Message, Password, Miss, Boss, Goethe,
  Yahoo, Israel, Israels)
- Regex-Strategie umgestellt: statt riesigem alternierenden Pattern
  ueber alle Keys jetzt Tokenizer (_WORD_PATTERN) + O(1) Dict-Lookup
  pro Wort. Deutlich performanter bei 150k+ Eintraegen.
- normalize_german_umlauts() Signatur unveraendert
- normalize_umlaut_fields() unveraendert
- Einhaengung in run_post_refresh_qc() unveraendert

Daten-Artefakt (src/services/umlaut_dict.json):
- 4.88 MB alphabetisch sortiertes JSON
- Im Repo committet zwecks Reproduzierbarkeit und kein hunspell-
  Laufzeit-Abhaengigkeit im Container

Verwerfbarkeit voll erhalten:
- git revert entfernt alle drei neuen Elemente
- Bestand in DB bleibt repariert (korrektes Deutsch, kein Schaden)
- hunspell-Paket kann bleiben oder mit apt purge entfernt werden

Bootstrap-Rerun mit neuem Dict:
- 7 Lagen aktualisiert, 306 zusaetzliche Ersetzungen
- Lage #6 (Irankonflikt) von 140 ursprungs- und 15 Rest-Treffern
  nach voriger Runde jetzt auf 0 Hard-Hits
- andere aktive Lagen insgesamt 8 verbleibende Rest-Treffer
  (spezielle Eigennamen, koennen bei Bedarf ins Supplement)

Performance:
- Dict-Load beim Modul-Import: ~100 ms
- Gesamt Unit-Tests (11 Faelle): 161 ms
- Refresh-Pfad unveraendert schnell: O(Wortzahl) mit Hashmap-Lookup
Dieser Commit ist enthalten in:
claude-dev
2026-04-18 21:17:46 +00:00
Ursprung 8a888a17a5
Commit f05bd1a064
3 geänderte Dateien mit 237 neuen und 178 gelöschten Zeilen

166
scripts/build_umlaut_dict.py Normale Datei
Datei anzeigen

@@ -0,0 +1,166 @@
"""Generiert src/services/umlaut_dict.json aus hunspell-de-de.
Aufruf (auf dem Monitor-Server):
cd /home/claude-dev/AegisSight-Monitor
python3 scripts/build_umlaut_dict.py
Voraussetzungen:
- hunspell-de-de (liefert /usr/share/hunspell/de_DE.dic + de_DE.aff)
- hunspell-tools (liefert /usr/bin/unmunch)
Ablauf:
1. unmunch rollt alle Flexionsformen aus dem hunspell-Dict aus
2. Wir filtern Woerter mit echten Umlauten (ä, ö, ü, ß)
3. Wir generieren fuer jedes Wort die Umschreibungs-Form (ae/oe/ue/ss)
4. Mehrdeutigkeits-Check: Wenn die Umschreibungs-Form selbst ein
gueltiges deutsches Wort ist (z. B. "dass" vs "daß"), skippen
5. Ausgabe als alphabetisch sortiertes JSON (diff-freundlich)
"""
import json
import locale
import os
import subprocess
import sys
DIC_PATH = "/usr/share/hunspell/de_DE.dic"
AFF_PATH = "/usr/share/hunspell/de_DE.aff"
UNMUNCH_BIN = "/usr/bin/unmunch"
OUTPUT_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "services", "umlaut_dict.json",
)
UMLAUT_MAP = (
("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"),
("Ä", "Ae"), ("Ö", "Oe"), ("Ü", "Ue"),
)
def to_ascii_form(word: str) -> str:
"""Konvertiert ein Wort mit Umlauten in seine Umschreibungs-Form."""
out = word
for uml, asc in UMLAUT_MAP:
out = out.replace(uml, asc)
return out
def has_umlaut(word: str) -> bool:
return any(ch in word for ch in "äöüßÄÖÜ")
def run_unmunch() -> set:
"""Fuehrt unmunch aus und gibt die Menge aller hunspell-Woerter zurueck."""
env = os.environ.copy()
# unmunch arbeitet mit Latin-1 als Voreinstellung; das .dic/.aff in de_DE
# ist aber UTF-8 (siehe SET UTF-8 im .aff). Wir setzen die Locale explizit.
env["LC_ALL"] = "C.UTF-8"
result = subprocess.run(
[UNMUNCH_BIN, DIC_PATH, AFF_PATH],
capture_output=True,
check=True,
env=env,
)
raw = result.stdout.decode("utf-8", errors="replace")
words = set()
for line in raw.splitlines():
w = line.strip()
if not w or w.startswith("#"):
continue
words.add(w)
return words
def build_mapping(all_words: set) -> tuple[dict, int, int]:
"""Baut das Umlaut-Ersetzungs-Mapping.
Rueckgabe: (mapping, skipped_ambiguous, words_with_umlaut)
"""
mapping = {}
skipped_ambiguous = 0
words_with_umlaut = 0
for word in all_words:
if not has_umlaut(word):
continue
words_with_umlaut += 1
ascii_form = to_ascii_form(word)
# Mehrdeutigkeits-Check: Umschreibung ist selbst ein gueltiges Wort?
if ascii_form in all_words:
skipped_ambiguous += 1
continue
# Standardfall: Mapping Umschreibung -> Umlaut-Form
mapping[ascii_form] = word
# Zusaetzlich Capitalize-Variante erzeugen (wenn anders als Original)
if ascii_form[:1].islower():
cap_ascii = ascii_form[:1].upper() + ascii_form[1:]
cap_umlaut = word[:1].upper() + word[1:]
if cap_ascii != ascii_form and cap_ascii not in all_words:
mapping[cap_ascii] = cap_umlaut
return mapping, skipped_ambiguous, words_with_umlaut
def sanity_spot_check(mapping: dict) -> None:
"""Prueft ob einige typische Testfaelle korrekt im Mapping abgebildet sind."""
expected_in = [
"oeffnung", "Oeffnung", "strasse", "Strasse", "fuer", "Fuer",
"ueber", "Ueber", "koennen", "Koennen", "muessen", "Muessen",
"moeglich", "Moeglich", "schliessen", "Schliessen",
"aussenminister", "Aussenminister", "praesident", "Praesident",
"buerger", "Buerger", "zurueck", "Zurueck", "fuehren", "Fuehren",
]
expected_not_in = [
"dass", "Dass", # moderne Form gueltig
"masse", "Masse", # Bedeutungsunterschied zu "Masse"/"Maße"
"busse", "Busse", # Bedeutungsunterschied zu "Busse"/"Buße"
]
missing = [w for w in expected_in if w not in mapping]
wrong = [w for w in expected_not_in if w in mapping]
print("Sanity-Check:")
print(f" Erwartete Eintraege gefunden: {len(expected_in) - len(missing)}/{len(expected_in)}")
if missing:
print(f" FEHLEND: {missing}")
print(f" Erwartete Ausschluesse korrekt: {len(expected_not_in) - len(wrong)}/{len(expected_not_in)}")
if wrong:
print(f" FAELSCHLICH DRIN: {wrong}")
def main():
if not os.path.exists(DIC_PATH):
print(f"FEHLER: {DIC_PATH} nicht gefunden. Paket hunspell-de-de installiert?",
file=sys.stderr)
sys.exit(1)
if not os.path.exists(UNMUNCH_BIN):
print(f"FEHLER: {UNMUNCH_BIN} nicht gefunden. Paket hunspell-tools installiert?",
file=sys.stderr)
sys.exit(1)
print(f"Lese hunspell-Dict via {UNMUNCH_BIN} ...")
all_words = run_unmunch()
print(f" {len(all_words)} hunspell-Wortformen geladen")
print("Baue Umlaut-Ersetzungs-Mapping ...")
mapping, skipped, umlaut_words = build_mapping(all_words)
print(f" {umlaut_words} Woerter mit Umlaut gefunden")
print(f" {skipped} mehrdeutige Formen uebersprungen (z.B. dass/daß)")
print(f" {len(mapping)} Eintraege im finalen Mapping")
sanity_spot_check(mapping)
print(f"\nSchreibe {OUTPUT_PATH} ...")
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
# Alphabetisch sortiert (diff-freundlich)
sorted_mapping = dict(sorted(mapping.items(), key=lambda kv: kv[0]))
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(sorted_mapping, f, ensure_ascii=False, indent=None, separators=(",", ":"))
size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024)
print(f" {size_mb:.2f} MB geschrieben")
print("Fertig.")
if __name__ == "__main__":
main()

Datei anzeigen

@@ -9,6 +9,7 @@ Regelbasierte Listen dienen als Fallback falls Haiku fehlschlaegt.
""" """
import json import json
import logging import logging
import os
import re import re
from difflib import SequenceMatcher from difflib import SequenceMatcher
@@ -425,191 +426,82 @@ async def run_post_refresh_qc(db, incident_id: int) -> dict:
# 3. Umlaut-Normalisierung (deterministisch, Sicherheitsnetz gegen LLM-Drift) # 3. Umlaut-Normalisierung (deterministisch, Sicherheitsnetz gegen LLM-Drift)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Basis-Woerterbuch: Umschreibungsform (lowercase) -> Umlaut-Form. # Das grosse Mapping wird aus umlaut_dict.json geladen. Das JSON wird einmalig
# Die Capitalize-Variante wird automatisch generiert (z. B. "Oeffnung" -> "Öffnung"). # aus hunspell-de-de erzeugt (siehe scripts/build_umlaut_dict.py) und enthaelt
_UMLAUT_BASE = { # >150.000 deutsche Umlaut-Woerter inklusive Flexionsformen. Mehrdeutigkeiten
# Oeffnung / Schliessung / Strasse / Schuesse (Topic-spezifisch aus Lage #6) # (z. B. "dass"/"daß", "Masse"/"Maße") sind bereits ausgefiltert.
"oeffnung": "öffnung", "oeffnungen": "öffnungen", _DICT_PATH = os.path.join(os.path.dirname(__file__), "umlaut_dict.json")
"oeffne": "öffne", "oeffnen": "öffnen", "oeffnet": "öffnet", try:
"geoeffnet": "geöffnet", "geoeffnete": "geöffnete", "geoeffneten": "geöffneten", with open(_DICT_PATH, encoding="utf-8") as _dict_file:
"oeffentlich": "öffentlich", "oeffentlichkeit": "öffentlichkeit", _UMLAUT_REPLACEMENTS = json.load(_dict_file)
"schliessung": "schließung", "schliessen": "schließen", "schliesst": "schließt", logger.info("Umlaut-Dict geladen: %d Eintraege aus %s", len(_UMLAUT_REPLACEMENTS), _DICT_PATH)
"schliesse": "schließe", "schliesslich": "schließlich", except FileNotFoundError:
"strasse": "straße", "strassen": "straßen", logger.warning("umlaut_dict.json nicht gefunden – Umlaut-Normalisierung laeuft mit leerem Dict")
"schuss": "schuss", "schuesse": "schüsse", "schuessen": "schüssen", _UMLAUT_REPLACEMENTS = {}
"beschuss": "beschuss",
# Hilfsverben + haeufige Woerter # _MANUAL_SUPPLEMENT: Lueckenfueller fuer Woerter, die hunspell-de-de nicht abdeckt
"fuer": "für", "fuers": "fürs", # (primaer Komposita und seltene Konjunktiv-Formen). Wird ueber das Korpus-Dict gelegt.
"ueber": "über", "ueberall": "überall", "ueberfall": "überfall", _MANUAL_SUPPLEMENT = {
"moeglich": "möglich", "moegliche": "mögliche", "moeglicher": "möglicher", # Konjunktiv I von "saeen" (selten, aber kommt vor)
"moegliches": "mögliches", "moeglichen": "möglichen", "saee": "säe", "saeen": "säen", "gesaet": "gesät",
"moeglichkeit": "möglichkeit", "moeglichkeiten": "möglichkeiten", # Komposita mit Amtstitel, die hunspell als Teile kennt aber nicht kombiniert
"koennen": "können", "koennte": "könnte", "koennten": "könnten", "aussenminister": "außenminister", "aussenministerin": "außenministerin",
"koenne": "könne", "koennt": "könnt", "aussenministern": "außenministern",
"muessen": "müssen", "muesse": "müsse", "muessten": "müssten", "aussenpolitik": "außenpolitik",
"muesste": "müsste", "muesst": "müsst",
"duerfen": "dürfen", "duerfte": "dürfte", "duerften": "dürften",
# fuehr*, waehr*, loes*, erklaer*
"fuehren": "führen", "fuehrt": "führt", "fuehrte": "führte", "gefuehrt": "geführt",
"fuehrung": "führung", "fuehrungen": "führungen", "fuehrer": "führer",
"ausfuehren": "ausführen", "ausgefuehrt": "ausgeführt", "ausfuehrlich": "ausführlich",
"einfuehrung": "einführung", "einfuehren": "einführen",
"waehrend": "während", "waehrung": "währung", "waehrungen": "währungen",
"gewaehren": "gewähren", "gewaehrt": "gewährt", "gewaehrleisten": "gewährleisten",
"erwaehnt": "erwähnt", "erwaehnung": "erwähnung", "erwaehnen": "erwähnen",
"loesen": "lösen", "loest": "löst", "geloest": "gelöst",
"loesung": "lösung", "loesungen": "lösungen",
"loeschen": "löschen", "geloescht": "gelöscht",
"erklaeren": "erklären", "erklaert": "erklärt", "erklaerte": "erklärte",
"erklaerten": "erklärten", "erklaertes": "erklärtes", "erklaertem": "erklärtem",
"erklaerung": "erklärung", "erklaerungen": "erklärungen",
"verspaetet": "verspätet", "verspaetung": "verspätung", "verspaetungen": "verspätungen",
"veroeffentlichen": "veröffentlichen", "veroeffentlicht": "veröffentlicht",
"veroeffentlichte": "veröffentlichte", "veroeffentlichten": "veröffentlichten",
"veroeffentlichung": "veröffentlichung", "veroeffentlichungen": "veröffentlichungen",
# Auslaender, Verstaendnis, Bevoelkerung
"auslaender": "ausländer", "auslaendisch": "ausländisch",
"auslaendische": "ausländische", "auslaendischen": "ausländischen",
"verstaendnis": "verständnis", "verstaendigung": "verständigung",
"verstaendlich": "verständlich", "verstaendlicher": "verständlicher",
"bevoelkerung": "bevölkerung", "bevoelkerungen": "bevölkerungen",
# Aussen, Aeusser, Kuenftig, Grundsaetzlich
"aussen": "außen", "aussenminister": "außenminister",
"aussenministerin": "außenministerin", "aussenpolitik": "außenpolitik",
"aussenpolitisch": "außenpolitisch", "aussenpolitische": "außenpolitische", "aussenpolitisch": "außenpolitisch", "aussenpolitische": "außenpolitische",
"aeusserung": "äußerung", "aeusserungen": "äußerungen", "aussenpolitischer": "außenpolitischer", "aussenpolitischen": "außenpolitischen",
"aeussern": "äußern", "aeussert": "äußert", "aeusserte": "äußerte", "vizepraesident": "vizepräsident", "vizepraesidenten": "vizepräsidenten",
"aeusserst": "äußerst", "aeussere": "äußere", "aeusseren": "äußeren", "vizepraesidentin": "vizepräsidentin",
"kuenftig": "künftig", "kuenftige": "künftige", "kuenftigen": "künftigen", "parlamentspraesident": "parlamentspräsident",
"grundsaetzlich": "grundsätzlich", "grundsaetze": "grundsätze", "grundsatz": "grundsatz", "parlamentspraesidenten": "parlamentspräsidenten",
# Maenner, Aerzte, Aendern, Entsteh* "parlamentspraesidentin": "parlamentspräsidentin",
"maenner": "männer", "maennlich": "männlich", "maennliche": "männliche",
"aerzte": "ärzte", "aerztlich": "ärztlich", "aerztliche": "ärztliche",
"aendern": "ändern", "aenderung": "änderung", "aenderungen": "änderungen",
"geaendert": "geändert", "unveraendert": "unverändert",
"verstaerken": "verstärken", "verstaerkt": "verstärkt", "verstaerkung": "verstärkung",
# Gruend*, Rueck*, Zurueck*
"gruenden": "gründen", "gruendung": "gründung", "gegruendet": "gegründet",
"gruende": "gründe", "begruendung": "begründung", "begruendet": "begründet",
"rueckkehr": "rückkehr", "rueckzug": "rückzug", "ruecken": "rücken",
"rueckseite": "rückseite", "rueckfall": "rückfall",
"zuruecknahme": "zurücknahme", "zurueck": "zurück", "zuruecknehmen": "zurücknehmen",
"zurueckgezogen": "zurückgezogen", "zurueckgekehrt": "zurückgekehrt",
# Fluege, Behoerden, Buerg*
"fluege": "flüge", "fluegel": "flügel",
"ruestung": "rüstung", "ruestungen": "rüstungen", "ruestungsexport": "rüstungsexport",
"aufruestung": "aufrüstung", "abruestung": "abrüstung",
"anschliessen": "anschließen", "anschliesst": "anschließt",
"angeschlossen": "angeschlossen",
"entschliessen": "entschließen", "entschliesst": "entschließt",
"entschloss": "entschloss", "entschlossen": "entschlossen",
"entschliessung": "entschließung",
"beschliessen": "beschließen", "beschliesst": "beschließt",
"beschloss": "beschloss", "beschlossen": "beschlossen", "beschluss": "beschluss",
"ausschliessen": "ausschließen", "ausschliesst": "ausschließt",
"ausgeschlossen": "ausgeschlossen", "ausschluss": "ausschluss",
"erfuellen": "erfüllen", "erfuellt": "erfüllt", "erfuellung": "erfüllung",
"durchfuehren": "durchführen", "durchgefuehrt": "durchgeführt",
"durchfuehrung": "durchführung",
"zurueckfuehren": "zurückführen", "zurueckgefuehrt": "zurückgeführt",
"zurueckziehen": "zurückziehen", "zurueckgezogen": "zurückgezogen",
"zurueckgewiesen": "zurückgewiesen",
"behoerde": "behörde", "behoerden": "behörden", "behoerdlich": "behördlich",
"buerger": "bürger", "buergerlich": "bürgerlich", "buergermeister": "bürgermeister",
"buergerrechte": "bürgerrechte",
"tuerkei": "türkei", "tuerkisch": "türkisch", "tuerkische": "türkische",
"tuerkischen": "türkischen", "tuerke": "türke", "tuerken": "türken",
# Staedtenamen (Spezialfaelle)
"koeln": "köln", "koelner": "kölner",
"muenchen": "münchen", "muenchner": "münchner",
# Thema aktueller Lage: Praesident, Druecker, etc.
"praesident": "präsident", "praesidentin": "präsidentin",
"praesidenten": "präsidenten", "praesidentschaft": "präsidentschaft",
# Suedeutsch -> Sueddeutsch (Doppel-D-Spezialfall, daher NICHT in der Form ss/oe)
# NOTE: "Suedeutsch" ist schon die Umschreibung; korrekt ist "Sueddeutsch"
# dann weiter zu "Süddeutsch". Wir loesen nur die ue->ü-Wandlung:
"suedeutsch": "süddeutsch", "suedeutsche": "süddeutsche",
"suedeutschen": "süddeutschen",
"sueddeutsch": "süddeutsch", "sueddeutsche": "süddeutsche",
"sueddeutschen": "süddeutschen",
"sued": "süd", "sueden": "süden", "suedlich": "südlich",
"suedost": "südost", "suedwest": "südwest",
"suedkorea": "südkorea", "suedkoreas": "südkoreas", "suedkoreanisch": "südkoreanisch",
# Bestaetigen, Gespraech, Toetung, Sekretaer, Franzoesisch
"bestaetigen": "bestätigen", "bestaetigt": "bestätigt",
"bestaetigte": "bestätigte", "bestaetigten": "bestätigten",
"bestaetigung": "bestätigung", "bestaetigungen": "bestätigungen",
"gespraech": "gespräch", "gespraeche": "gespräche",
"gespraechen": "gesprächen", "gespraechs": "gesprächs",
"toetung": "tötung", "toetungen": "tötungen",
"toeten": "töten", "toetet": "tötet", "getoetet": "getötet",
"sekretaer": "sekretär", "sekretaerin": "sekretärin",
"generalsekretaer": "generalsekretär", "generalsekretaerin": "generalsekretärin", "generalsekretaer": "generalsekretär", "generalsekretaerin": "generalsekretärin",
"franzoesisch": "französisch", "franzoesische": "französische", "generalsekretaers": "generalsekretärs",
"franzoesischen": "französischen", "franzoesischer": "französischer", "staatssekretaer": "staatssekretär", "staatssekretaerin": "staatssekretärin",
"franzoesisches": "französisches", # Strassen-Komposita
# Sanitaeter, Stationaer, Militaer, Maerz, Vollstaendig "wasserstrasse": "wasserstraße", "wasserstrassen": "wasserstraßen",
"sanitaet": "sanität", "sanitaeter": "sanitäter", "hauptstrasse": "hauptstraße", "autostrasse": "autostraße",
"stationaer": "stationär", "stationaere": "stationäre", "stationaeren": "stationären", "bundesstrasse": "bundesstraße", "landstrasse": "landstraße",
"militaer": "militär", "militaerisch": "militärisch", # Militaer-Komposita (haeufig in OSINT-Kontext)
"militaerische": "militärische", "militaerischen": "militärischen",
"militaerkommando": "militärkommando", "militaerbasis": "militärbasis", "militaerkommando": "militärkommando", "militaerbasis": "militärbasis",
"militaerschlag": "militärschlag", "militaerschlaege": "militärschläge", "militaerschlag": "militärschlag", "militaerschlaege": "militärschläge",
"maerz": "märz", # Suedeutsch-Doppel-D-Spezialfall (haendisch korrigierbar)
"vollstaendig": "vollständig", "vollstaendige": "vollständige", "suedeutsch": "süddeutsch", "suedeutsche": "süddeutsche",
"vollstaendigen": "vollständigen", "vollstaendigkeit": "vollständigkeit", "suedeutschen": "süddeutschen",
# Gegenueber, Abhaengig, Zehntaegig, Geloest, Enthuellt, Wuerden, Weiss # Fuehrungs- und Oeffnungs-Komposita (hunspell kennt die Stamm-Woerter, nicht die Komposita)
"gegenueber": "gegenüber", "wiedereroeffnung": "wiedereröffnung", "wiedereroeffnungen": "wiedereröffnungen",
"abhaengig": "abhängig", "abhaengige": "abhängige", "abhaengigen": "abhängigen", "kriegsfuehrung": "kriegsführung", "kriegsfuehrer": "kriegsführer",
"abhaengigkeit": "abhängigkeit", "unabhaengig": "unabhängig", "fuehrungsebene": "führungsebene", "fuehrungsebenen": "führungsebenen",
"taeglich": "täglich", "taegliche": "tägliche", "taeglichen": "täglichen", "fuehrungskraft": "führungskraft", "fuehrungskraefte": "führungskräfte",
"taegig": "tägig", "zehntaegig": "zehntägig", "zehntaegige": "zehntägige", "fuehrungsposition": "führungsposition", "fuehrungspositionen": "führungspositionen",
"geloest": "gelöst", "geloeste": "gelöste", "geloesten": "gelösten", "fuehrungsrolle": "führungsrolle",
"enthuellen": "enthüllen", "enthuellt": "enthüllt", "geschaeftsfuehrer": "geschäftsführer", "geschaeftsfuehrung": "geschäftsführung",
"enthuellte": "enthüllte", "enthuellung": "enthüllung", "staatsfuehrung": "staatsführung", "parteifuehrung": "parteiführung",
"wuerde": "würde", "wuerden": "würden", "wuerdig": "würdig", "militaerfuehrung": "militärführung",
"wuerdigung": "würdigung",
"weiss": "weiß", "weisse": "weiße", "weissen": "weißen", "weisses": "weißes",
# Vize- und Parlamentspraesident, Ankuendigung, Knuepfen
"vizepraesident": "vizepräsident", "vizepraesidentin": "vizepräsidentin",
"vizepraesidenten": "vizepräsidenten",
"parlamentspraesident": "parlamentspräsident",
"ankuendigung": "ankündigung", "ankuendigungen": "ankündigungen",
"ankuendigen": "ankündigen", "angekuendigt": "angekündigt",
"knuepfen": "knüpfen", "knuepft": "knüpft", "geknuepft": "geknüpft",
"anknuepfen": "anknüpfen",
# Komposita mit Strasse, Oeffnung
"wasserstrasse": "wasserstraße", "wasserstrassen": "wasserstraßen",
"autostrasse": "autostraße", "hauptstrasse": "hauptstraße",
# Weitere zusammengesetzte Formen
"zugefuehrt": "zugeführt", "ueberfuehrt": "überführt",
"hergefuehrt": "hergeführt", "hingefuehrt": "hingeführt",
} }
# Capitalize-Varianten fuer das Supplement (hunspell-Korpus hat sie schon eingebaut)
_MANUAL_SUPPLEMENT_FULL = {}
for _k, _v in _MANUAL_SUPPLEMENT.items():
_MANUAL_SUPPLEMENT_FULL[_k] = _v
if _k[:1].islower():
_MANUAL_SUPPLEMENT_FULL[_k[:1].upper() + _k[1:]] = _v[:1].upper() + _v[1:]
# Supplement ueber das Korpus-Dict legen (Supplement hat Vorrang bei Kollision)
_UMLAUT_REPLACEMENTS = {**_UMLAUT_REPLACEMENTS, **_MANUAL_SUPPLEMENT_FULL}
def _build_umlaut_map() -> dict: # Whitelist: Tokens, die trotz Dict-Match NIE ersetzt werden (Eigennamen,
"""Baut die vollstaendige Ersetzungs-Map inklusive Capitalize-Varianten.""" # englische Fremdwoerter, Fachbegriffe). Greift vor dem Dict-Lookup.
result = {} _UMLAUT_WHITELIST = frozenset({
for k, v in _UMLAUT_BASE.items(): # Englische Fremdwoerter
result[k] = v "Boeing", "Business", "Access", "Process", "Message", "Password",
# Erste-Buchstabe-gross (haeufigster Case am Satzanfang) "Miss", "Boss", "Goethe", "Yahoo",
result[k[:1].upper() + k[1:]] = v[:1].upper() + v[1:] # Eigennamen, die zufaellig "ss" enthalten und nicht umgeschrieben werden sollen
return result "Israel", "Israels",
})
# Tokenizer: matcht Woerter aus Buchstaben (inkl. deutschen Umlauten).
_UMLAUT_REPLACEMENTS = _build_umlaut_map() # Performanter als ein alternierendes Regex ueber 150k Keys — O(1) Dict-Lookup pro Wort.
_WORD_PATTERN = re.compile(r"[A-Za-zÄÖÜäöüß]+")
# Whitelist: Tokens, die trotz potenziellen Matches NIE ersetzt werden.
# Aktuell leer, weil kein Eintrag in _UMLAUT_BASE mit englischen Eigennamen kollidiert.
# Falls in Zukunft Ambiguitaeten auftreten (z. B. Nachname "Kuehn"), hier ergaenzen.
_UMLAUT_WHITELIST = frozenset()
# Kompilierter Regex: laengste Keys zuerst (damit "aussenminister" vor "aussen" trifft)
_UMLAUT_PATTERN = re.compile(
r"\b(" + "|".join(
re.escape(k) for k in sorted(_UMLAUT_REPLACEMENTS.keys(), key=len, reverse=True)
) + r")\b"
)
def normalize_german_umlauts(text: str) -> tuple[str, int]: def normalize_german_umlauts(text: str) -> tuple[str, int]:
@@ -635,7 +527,7 @@ def normalize_german_umlauts(text: str) -> tuple[str, int]:
count[0] += 1 count[0] += 1
return replacement return replacement
new_text = _UMLAUT_PATTERN.sub(_replace, text) new_text = _WORD_PATTERN.sub(_replace, text)
return new_text, count[0] return new_text, count[0]

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist