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()
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren