corrector.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. # -*- coding: utf-8 -*-
  2. """
  3. STT-Korrektur-Service.
  4. Mehrstufiger Post-Processor fuer STT-Ausgaben. Registriert sich mit
  5. HIGH-Prioritaet auf speech_recognized und korrigiert den Text in-place,
  6. bevor das NLP-Plugin (NORMAL-Prioritaet) ihn verarbeitet.
  7. """
  8. from trixy_core.service.iservice import IService
  9. from trixy_core.service.enums import ServicePriority, ServiceGroup
  10. from trixy_core.events.decorators import TrixyEvent
  11. from trixy_core.events.enums import EventPriority
  12. from trixy_core.events.event_data.basic import SpeechRecognized
  13. from trixy_core.stt.layers.base import CorrectionLayer
  14. from trixy_core.utils.debug import pinfo, pdebug, pwarn
  15. class STTCorrectorService(IService):
  16. """
  17. Service fuer STT-Nachkorrektur.
  18. Wendet konfigurierbare Korrektur-Schichten sequenziell auf
  19. erkannten Text an, bevor er vom NLP-Plugin verarbeitet wird.
  20. """
  21. PRIORITY = ServicePriority.OPTIONAL
  22. GROUP = ServiceGroup.CONVERSATION
  23. DEPENDENCIES: list[str] = []
  24. NAME = "STTCorrectorService"
  25. def __init__(self, application) -> None:
  26. super().__init__(application)
  27. self._active_layers: list[CorrectionLayer] = []
  28. self._custom_aliases: dict[str, str] = {}
  29. self._enabled: bool = False
  30. async def start(self) -> None:
  31. """Startet den STT-Korrektur-Service."""
  32. pinfo("Starte STTCorrectorService...")
  33. # Konfiguration lesen
  34. config = self._get_config()
  35. if config is None:
  36. pwarn("[STT-Korrektur] Keine Konfiguration gefunden")
  37. return
  38. if not config.enabled:
  39. pinfo("[STT-Korrektur] Deaktiviert per Konfiguration")
  40. return
  41. self._enabled = True
  42. self._custom_aliases = {k.lower(): v for k, v in config.custom_aliases.items()}
  43. # Config als dict fuer Layer-Initialisierung
  44. config_dict = {
  45. "symspell_max_edit_distance": config.symspell_max_edit_distance,
  46. "symspell_dictionary_path": config.symspell_dictionary_path,
  47. "kenlm_model_path": config.kenlm_model_path,
  48. "jamspell_model_path": config.jamspell_model_path,
  49. "hunspell_dict_path": config.hunspell_dict_path,
  50. "languagetool_language": config.languagetool_language,
  51. }
  52. # Layers in konfigurierter Reihenfolge initialisieren
  53. from trixy_core.stt.layers import LAYER_REGISTRY
  54. for layer_name in config.layers:
  55. layer_class = LAYER_REGISTRY.get(layer_name)
  56. if layer_class is None:
  57. pwarn(f"[STT-Korrektur] Unbekannte Schicht: '{layer_name}'")
  58. continue
  59. layer = layer_class()
  60. if not layer.is_available():
  61. pinfo(f"[STT-Korrektur] Schicht '{layer_name}' nicht verfuegbar (Library fehlt)")
  62. continue
  63. if layer.initialize(config_dict, config.language, config.protected_words):
  64. self._active_layers.append(layer)
  65. pinfo(f"[STT-Korrektur] Schicht '{layer_name}' aktiviert")
  66. else:
  67. pinfo(f"[STT-Korrektur] Schicht '{layer_name}' konnte nicht initialisiert werden")
  68. if self._active_layers:
  69. layer_names = [l.NAME for l in self._active_layers]
  70. pinfo(f"[STT-Korrektur] Gestartet mit Schichten: {layer_names}")
  71. elif self._custom_aliases:
  72. pinfo(f"[STT-Korrektur] Gestartet nur mit Alias-Korrektur ({len(self._custom_aliases)} Aliases)")
  73. else:
  74. pinfo("[STT-Korrektur] Keine Schichten verfuegbar, nur Passthrough")
  75. async def stop(self) -> None:
  76. """Stoppt den STT-Korrektur-Service."""
  77. for layer in self._active_layers:
  78. try:
  79. layer.shutdown()
  80. except Exception as e:
  81. pdebug(f"[STT-Korrektur] Fehler beim Shutdown von '{layer.NAME}': {e}")
  82. self._active_layers.clear()
  83. self._custom_aliases.clear()
  84. self._enabled = False
  85. pinfo("STTCorrectorService gestoppt")
  86. @TrixyEvent(["speech_recognized"], priority=EventPriority.HIGH)
  87. async def on_speech_recognized(self, event_name: str, event_data: SpeechRecognized) -> None:
  88. """Korrigiert STT-Text bevor das NLP-Plugin ihn verarbeitet."""
  89. if not self._enabled:
  90. return
  91. # Keyboard-Input nicht korrigieren
  92. if getattr(event_data, "source", "") == "keyboard":
  93. return
  94. text = event_data.text
  95. if not text or not text.strip():
  96. return
  97. corrected = text
  98. # 1. Custom-Aliases anwenden (einfache Wort-Ersetzung)
  99. corrected = self._apply_aliases(corrected)
  100. # 2. Korrektur-Schichten durchlaufen
  101. for layer in self._active_layers:
  102. try:
  103. corrected = layer.correct(corrected)
  104. except Exception as e:
  105. pdebug(f"[STT-Korrektur] Fehler in Schicht '{layer.NAME}': {e}")
  106. # In-place Modifikation des EventData-Objekts
  107. if corrected != text:
  108. pinfo(f"[STT-Korrektur] '{text}' → '{corrected}'")
  109. event_data.text = corrected
  110. def _load_plugin_aliases(self) -> None:
  111. """
  112. Laedt Plugin-eigene STT-Aliases aus plugins/*/stt_aliases.json.
  113. Jedes Plugin kann eine stt_aliases.json bereitstellen mit
  114. Korrekturen die nur relevant sind wenn das Plugin aktiv ist.
  115. Format: {"falsche erkennung": "korrekt", ...}
  116. """
  117. import json
  118. from pathlib import Path
  119. plugins_dir = Path("plugins")
  120. if not plugins_dir.is_dir():
  121. return
  122. loaded_count = 0
  123. for alias_file in sorted(plugins_dir.glob("*/stt_aliases.json")):
  124. plugin_name = alias_file.parent.name
  125. # Nur laden wenn Plugin aktiviert ist (config.json → enabled: true)
  126. plugin_config = alias_file.parent / "config.json"
  127. if plugin_config.is_file():
  128. try:
  129. with open(plugin_config) as f:
  130. pcfg = json.load(f)
  131. if not pcfg.get("enabled", True):
  132. continue
  133. except Exception:
  134. pass
  135. try:
  136. with open(alias_file, encoding="utf-8") as f:
  137. aliases = json.load(f)
  138. count = 0
  139. for key, value in aliases.items():
  140. if key.startswith("_"): # _comment etc. ueberspringen
  141. continue
  142. self._custom_aliases[key.lower()] = value
  143. count += 1
  144. if count > 0:
  145. loaded_count += count
  146. pdebug(f"[STT-Korrektur] {count} Aliases aus Plugin '{plugin_name}' geladen")
  147. except (json.JSONDecodeError, OSError) as e:
  148. pdebug(f"[STT-Korrektur] Fehler beim Laden von {alias_file}: {e}")
  149. if loaded_count > 0:
  150. pinfo(f"[STT-Korrektur] {loaded_count} Plugin-Aliases geladen")
  151. def _apply_aliases(self, text: str) -> str:
  152. """
  153. Wendet benutzerdefinierte Wort-Aliases an.
  154. Unterstuetzt Einzelwort- und Mehrwort-Phrasen:
  155. - "shop" → "stopp" (Einzelwort)
  156. - "nach utopie" → "naruto" (Mehrwort-Phrase)
  157. - "etc anime" → "ecchi anime" (Mehrwort-Phrase)
  158. """
  159. if not self._custom_aliases:
  160. return text
  161. result = text
  162. changed = False
  163. # Erst Mehrwort-Phrasen ersetzen (laengere zuerst)
  164. multi_word = {k: v for k, v in self._custom_aliases.items() if " " in k}
  165. for phrase in sorted(multi_word, key=len, reverse=True):
  166. if phrase in result.lower():
  167. # Position finden und ersetzen (Case-insensitive)
  168. lower = result.lower()
  169. idx = lower.find(phrase)
  170. while idx >= 0:
  171. result = result[:idx] + multi_word[phrase] + result[idx + len(phrase):]
  172. changed = True
  173. lower = result.lower()
  174. idx = lower.find(phrase, idx + len(multi_word[phrase]))
  175. # Dann Einzelwort-Ersetzungen
  176. words = result.split()
  177. for i, word in enumerate(words):
  178. replacement = self._custom_aliases.get(word.lower())
  179. if replacement and " " not in replacement:
  180. words[i] = replacement
  181. changed = True
  182. if changed:
  183. result = " ".join(words)
  184. pdebug(f"[STT-Korrektur] Alias: '{text}' → '{result}'")
  185. return result
  186. return text
  187. def _get_config(self):
  188. """Liest die STT-Korrektur-Konfiguration aus der Anwendung."""
  189. app = self._application
  190. # Standalone-Modus
  191. if hasattr(app, "standalone_config"):
  192. return app.standalone_config.stt_correction
  193. # Server-Modus
  194. if hasattr(app, "server_config"):
  195. return app.server_config.stt_correction
  196. return None