Dateien
AegisSight-Monitor/scripts/build_umlaut_dict.py
claude-dev f05bd1a064 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
2026-04-18 21:17:46 +00:00

167 Zeilen
5.7 KiB
Python

"""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()