symspell_layer.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. # -*- coding: utf-8 -*-
  2. """
  3. SymSpell-Korrekturschicht.
  4. Nutzt symspellpy fuer Wort-Segmentierung und offensichtliche Nicht-Wort-Fehler.
  5. Sehr schnell (~0.03ms/Wort).
  6. """
  7. from pathlib import Path
  8. from trixy_core.stt.layers.base import CorrectionLayer
  9. from trixy_core.utils.debug import pdebug, pwarn
  10. try:
  11. from symspellpy import SymSpell, Verbosity
  12. _HAS_SYMSPELL = True
  13. except ImportError:
  14. _HAS_SYMSPELL = False
  15. class SymSpellLayer(CorrectionLayer):
  16. """SymSpell-basierte Wortkorrektur und Segmentierung."""
  17. NAME = "symspell"
  18. def __init__(self) -> None:
  19. self._sym_spell: "SymSpell | None" = None
  20. self._max_edit_distance: int = 2
  21. self._protected_words: set[str] = set()
  22. def is_available(self) -> bool:
  23. """Prueft ob symspellpy installiert ist."""
  24. return _HAS_SYMSPELL
  25. def initialize(self, config: dict, language: str, protected_words: list[str]) -> bool:
  26. """Initialisiert SymSpell mit Frequency-Dictionary."""
  27. if not _HAS_SYMSPELL:
  28. return False
  29. self._max_edit_distance = config.get("symspell_max_edit_distance", 2)
  30. self._protected_words = {w.lower() for w in protected_words}
  31. try:
  32. self._sym_spell = SymSpell(
  33. max_dictionary_edit_distance=self._max_edit_distance,
  34. prefix_length=7,
  35. )
  36. # Dictionary laden — Suchprioriaet:
  37. # 1. Explizit konfigurierter Pfad
  38. # 2. Bundled Dictionary in models/symspell/
  39. # 3. symspellpy-Paket (Fallback, hat nur Englisch)
  40. dict_path = config.get("symspell_dictionary_path", "")
  41. dict_loaded = False
  42. if dict_path and Path(dict_path).exists():
  43. self._sym_spell.load_dictionary(
  44. dict_path, term_index=0, count_index=1,
  45. )
  46. pdebug(f"[SymSpell] Dictionary geladen: {dict_path}")
  47. dict_loaded = True
  48. if not dict_loaded:
  49. # Bundled Dictionary in models/symspell/
  50. bundled = Path("models") / "symspell" / f"frequency_dictionary_{language}.txt"
  51. if bundled.exists():
  52. self._sym_spell.load_dictionary(
  53. str(bundled), term_index=0, count_index=1,
  54. )
  55. pdebug(f"[SymSpell] Bundled Dictionary geladen: {bundled}")
  56. dict_loaded = True
  57. if not dict_loaded:
  58. # Fallback: symspellpy-Paket
  59. import pkg_resources
  60. pkg_base = Path(pkg_resources.resource_filename("symspellpy", ""))
  61. for pattern in [
  62. f"frequency_dictionary_{language}_*.txt",
  63. f"frequency_dictionary_{language}*.txt",
  64. ]:
  65. found = list(pkg_base.glob(pattern))
  66. if found:
  67. self._sym_spell.load_dictionary(
  68. str(found[0]), term_index=0, count_index=1,
  69. )
  70. pdebug(f"[SymSpell] Paket-Dictionary geladen: {found[0]}")
  71. dict_loaded = True
  72. break
  73. if not dict_loaded:
  74. pwarn(f"[SymSpell] Kein {language}-Dictionary gefunden")
  75. return False
  76. # Geschuetzte Woerter mit hoher Frequenz hinzufuegen
  77. # (verhindert Korrektur von z.B. "Eminem" zu "einem")
  78. for word in protected_words:
  79. self._sym_spell.create_dictionary_entry(word.lower(), 1_000_000)
  80. pdebug(f"[SymSpell] Initialisiert (max_edit_distance={self._max_edit_distance})")
  81. return True
  82. except Exception as e:
  83. pwarn(f"[SymSpell] Initialisierung fehlgeschlagen: {e}")
  84. self._sym_spell = None
  85. return False
  86. def correct(self, text: str) -> str:
  87. """Korrigiert Text mit SymSpell lookup_compound und word_segmentation."""
  88. if not self._sym_spell or not text.strip():
  89. return text
  90. try:
  91. # lookup_compound fuer Multi-Wort-Korrektur
  92. suggestions = self._sym_spell.lookup_compound(
  93. text,
  94. max_edit_distance=self._max_edit_distance,
  95. )
  96. if suggestions:
  97. corrected = suggestions[0].term
  98. # Geschuetzte Woerter zuruecksetzen falls faelschlich korrigiert
  99. corrected = self._restore_protected(text, corrected)
  100. if corrected != text.lower():
  101. pdebug(f"[SymSpell] '{text}' → '{corrected}'")
  102. return corrected
  103. return text
  104. except Exception as e:
  105. pdebug(f"[SymSpell] Fehler bei Korrektur: {e}")
  106. return text
  107. def _restore_protected(self, original: str, corrected: str) -> str:
  108. """Stellt geschuetzte Woerter wieder her falls korrigiert."""
  109. orig_words = original.lower().split()
  110. corr_words = corrected.split()
  111. # Nur wenn gleiche Wortanzahl
  112. if len(orig_words) != len(corr_words):
  113. return corrected
  114. result = []
  115. for ow, cw in zip(orig_words, corr_words):
  116. if ow in self._protected_words and ow != cw:
  117. result.append(ow)
  118. else:
  119. result.append(cw)
  120. return " ".join(result)
  121. def shutdown(self) -> None:
  122. """Gibt Ressourcen frei."""
  123. self._sym_spell = None