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:
166
scripts/build_umlaut_dict.py
Normale Datei
166
scripts/build_umlaut_dict.py
Normale Datei
@@ -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()
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
src/services/umlaut_dict.json
Normale Datei
1
src/services/umlaut_dict.json
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
In neuem Issue referenzieren
Einen Benutzer sperren