Skip to content

Commit

Permalink
feat:fallback_plugins (#263)
Browse files Browse the repository at this point in the history
* feat:fallback_plugins

similar to WakeWords, allows defining a plugin to load if the primary fails

this is DIFFERENT from fallback TTS/STT, it isnt loaded at same time but instead when main plugin fails to load for any reason

* unittests

* unittests

* unittests
  • Loading branch information
JarbasAl authored Sep 11, 2024
1 parent fee6afc commit 4e447d8
Show file tree
Hide file tree
Showing 10 changed files with 303 additions and 216 deletions.
21 changes: 10 additions & 11 deletions ovos_plugin_manager/g2p.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,6 @@ def get_g2p_config(config: Optional[dict] = None) -> dict:


class OVOSG2PFactory:
""" replicates the base mycroft class, but uses only OPM enabled plugins"""
MAPPINGS = {
"dummy": "ovos-g2p-plugin-dummy",
"phoneme_guesser": "neon-g2p-plugin-phoneme-guesser",
"gruut": "neon-g2p-plugin-gruut"
}

@staticmethod
def get_class(config=None):
Expand All @@ -97,15 +91,13 @@ def get_class(config=None):
"""
config = get_g2p_config(config)
g2p_module = config.get("module") or 'dummy'
if g2p_module in OVOSG2PFactory.MAPPINGS:
g2p_module = OVOSG2PFactory.MAPPINGS[g2p_module]
if g2p_module == 'ovos-g2p-plugin-dummy':
if g2p_module == 'dummy':
return Grapheme2PhonemePlugin

return load_g2p_plugin(g2p_module)

@staticmethod
def create(config=None):
@classmethod
def create(cls, config=None):
"""Factory method to create a G2P engine based on configuration.
The configuration file ``mycroft.conf`` contains a ``g2p`` section with
Expand All @@ -115,13 +107,20 @@ def create(config=None):
"module": <engine_name>
}
"""
if "g2p" in config:
config = config["g2p"]
g2p_config = get_g2p_config(config)
g2p_module = g2p_config.get('module', 'dummy')
fallback = g2p_config.get("fallback_module")
try:
clazz = OVOSG2PFactory.get_class(g2p_config)
g2p = clazz(g2p_config)
LOG.debug(f'Loaded plugin {g2p_module}')
except Exception:
LOG.exception('The selected G2P plugin could not be loaded.')
if fallback in config and fallback != g2p_module:
LOG.info(f"Attempting to load fallback plugin instead: {fallback}")
config["module"] = fallback
return cls.create(config)
raise
return g2p
76 changes: 20 additions & 56 deletions ovos_plugin_manager/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,24 +83,7 @@ def get_lang_detect_module_configs(module_name: str):
return load_plugin_configs(module_name, PluginConfigTypes.LANG_DETECT)


_fallback_lang_detect_plugin = "ovos-lang-detect-ngram-lm"
_fallback_translate_plugin = "ovos-translate-plugin-server"


class OVOSLangDetectionFactory:
"""
replicates the base neon class, but uses only OPM enabled plugins
"""
MAPPINGS = {
"libretranslate": "libretranslate_detection_plug",
"google": "googletranslate_detection_plug",
"amazon": "amazontranslate_detection_plug",
"cld2": "cld2_plug",
"cld3": "cld3_plug",
"langdetect": "langdetect_plug",
"fastlang": "fastlang_plug",
"lingua_podre": "lingua_podre_plug"
}

@staticmethod
def get_class(config=None):
Expand All @@ -120,12 +103,10 @@ def get_class(config=None):
lang_module = config.get("detection_module", config.get("module"))
if not lang_module:
raise ValueError("`language.detection_module` not configured")
if lang_module in OVOSLangDetectionFactory.MAPPINGS:
lang_module = OVOSLangDetectionFactory.MAPPINGS[lang_module]
return load_lang_detect_plugin(lang_module)

@staticmethod
def create(config=None) -> LanguageDetector:
@classmethod
def create(cls, config=None) -> LanguageDetector:
"""
Factory method to create a LangDetection engine based on configuration
Expand All @@ -140,37 +121,26 @@ def create(config=None) -> LanguageDetector:
if "language" in config:
config = config["language"]
lang_module = config.get("detection_module", config.get("module"))
cfg = config.get(lang_module, {})
fallback = cfg.get("fallback_module")
try:
config["module"] = lang_module
clazz = OVOSLangDetectionFactory.get_class(config)
if clazz is None:
raise ValueError(f"Failed to load module: {lang_module}")
LOG.info(f'Loaded the Language Detection plugin {lang_module}')
if lang_module in OVOSLangDetectionFactory.MAPPINGS:
lang_module = OVOSLangDetectionFactory.MAPPINGS[lang_module]
return clazz(config=get_plugin_config(config, "language",
lang_module))
except Exception:
# The Language Detection backend failed to start, fall back if appropriate.
if lang_module != _fallback_lang_detect_plugin:
lang_module = _fallback_lang_detect_plugin
LOG.error(f'Language Detection plugin {lang_module} not found. '
f'Falling back to {_fallback_lang_detect_plugin}')
clazz = load_lang_detect_plugin(_fallback_lang_detect_plugin)
if clazz:
return clazz(config=get_plugin_config(config, "language",
lang_module))

LOG.exception(f'Language Detection plugin {lang_module} could not be loaded!')
if fallback in config and fallback != lang_module:
LOG.info(f"Attempting to load fallback plugin instead: {fallback}")
config["detection_module"] = fallback
return cls.create(config)
raise


class OVOSLangTranslationFactory:
""" replicates the base neon class, but uses only OPM enabled plugins"""
MAPPINGS = {
"libretranslate": "libretranslate_plug",
"google": "googletranslate_plug",
"amazon": "amazontranslate_plug",
"apertium": "apertium_plug"
}

@staticmethod
def get_class(config=None):
Expand All @@ -190,12 +160,10 @@ def get_class(config=None):
lang_module = config.get("translation_module", config.get("module"))
if not lang_module:
raise ValueError("`language.translation_module` not configured")
if lang_module in OVOSLangTranslationFactory.MAPPINGS:
lang_module = OVOSLangTranslationFactory.MAPPINGS[lang_module]
return load_tx_plugin(lang_module)

@staticmethod
def create(config=None) -> LanguageTranslator:
@classmethod
def create(cls, config=None) -> LanguageTranslator:
"""
Factory method to create a LangTranslation engine based on configuration
Expand All @@ -210,24 +178,20 @@ def create(config=None) -> LanguageTranslator:
if "language" in config:
config = config["language"]
lang_module = config.get("translation_module", config.get("module"))
cfg = config.get(lang_module, {})
fallback = cfg.get("fallback_module")
try:
config["module"] = lang_module
clazz = OVOSLangTranslationFactory.get_class(config)
if clazz is None:
raise ValueError(f"Failed to load module: {lang_module}")
LOG.info(f'Loaded the Language Translation plugin {lang_module}')
if lang_module in OVOSLangTranslationFactory.MAPPINGS:
lang_module = OVOSLangTranslationFactory.MAPPINGS[lang_module]
return clazz(config=get_plugin_config(config, "language",
lang_module))
except Exception:
# The Language Translation backend failed to start, fall back if appropriate.
if lang_module != _fallback_translate_plugin:
lang_module = _fallback_translate_plugin
LOG.error(f'Language Translation plugin {lang_module} '
f'not found. Falling back to {_fallback_translate_plugin}')
clazz = load_tx_plugin(_fallback_translate_plugin)
if clazz:
return clazz(config=get_plugin_config(config, "language",
lang_module))

LOG.exception(f'Language Translation plugin {lang_module} could not be loaded!')
if fallback in config and fallback != lang_module:
LOG.info(f"Attempting to load fallback plugin instead: {fallback}")
config["translation_module"] = fallback
return cls.create(config)
raise
13 changes: 11 additions & 2 deletions ovos_plugin_manager/microphone.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ def get_class(config=None):
microphone_module = config.get("module")
return load_microphone_plugin(microphone_module)

@staticmethod
def create(config=None):
@classmethod
def create(cls, config=None):
"""Factory method to create a microphone engine based on configuration.
The configuration file ``mycroft.conf`` contains a ``microphone`` section with
Expand All @@ -60,18 +60,27 @@ def create(config=None):
"module": <engine_name>
}
"""
if "microphone" in config:
config = config["microphone"]
microphone_config = get_microphone_config(config)
microphone_module = microphone_config.get('module')
fallback = microphone_config.get("fallback_module")
try:
clazz = OVOSMicrophoneFactory.get_class(microphone_config)
# Note that configuration is expanded for this class of plugins
# since they are dataclasses and don't have the same init signature
# as other plugin types
microphone_config.pop('lang')
microphone_config.pop('module')
if fallback:
microphone_config.pop('fallback_module')
microphone = clazz(**microphone_config)
LOG.debug(f'Loaded microphone plugin {microphone_module}')
except Exception:
LOG.exception('The selected microphone plugin could not be loaded.')
if fallback in config and fallback != microphone_module:
LOG.info(f"Attempting to load fallback plugin instead: {fallback}")
config["module"] = fallback
return cls.create(config)
raise
return microphone
33 changes: 17 additions & 16 deletions ovos_plugin_manager/vad.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,6 @@ def get_vad_config(config: dict = None) -> dict:


class OVOSVADFactory:
""" replicates the base mycroft class, but uses only OPM enabled plugins"""
MAPPINGS = {
"silero": "ovos-vad-plugin-silero",
"webrtcvad": "ovos-vad-plugin-webrtcvad"
}

@staticmethod
def get_class(config=None):
Expand All @@ -84,12 +79,10 @@ def get_class(config=None):
raise ValueError(f"VAD Plugin not configured in: {config}")
if vad_module == "dummy":
return VADEngine
if vad_module in OVOSVADFactory.MAPPINGS:
vad_module = OVOSVADFactory.MAPPINGS[vad_module]
return load_vad_plugin(vad_module)

@staticmethod
def create(config=None):
@classmethod
def create(cls, config=None):
"""Factory method to create a VAD engine based on configuration.
The configuration file ``mycroft.conf`` contains a ``VAD`` section with
Expand All @@ -99,16 +92,24 @@ def create(config=None):
"module": <engine_name>
}
"""
vad_config = get_vad_config(config)
plugin = vad_config.get("module")
if "listener" in config:
config = config["listener"]
if "VAD" in config:
config = config["VAD"]
plugin = config.get("module")
if not plugin:
raise ValueError(f"VAD Plugin not configured in: {vad_config}")
raise ValueError(f"VAD Plugin not configured in: {config}")

plugin_config = config.get(plugin, {})
fallback = plugin_config.get("fallback_module")

try:
clazz = OVOSVADFactory.get_class(vad_config)
# module name not expected in config; don't change passed config
plugin_config = dict(vad_config)
plugin_config.pop("module")
clazz = OVOSVADFactory.get_class(config)
return clazz(plugin_config)
except Exception:
LOG.exception(f'VAD plugin {plugin} could not be loaded!')
if fallback in config and fallback != plugin:
LOG.info(f"Attempting to load fallback plugin instead: {fallback}")
config["module"] = fallback
return cls.create(config)
raise
10 changes: 0 additions & 10 deletions ovos_plugin_manager/wakewords.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,6 @@ def get_wws(scan=False):


class OVOSWakeWordFactory:
""" replicates the base mycroft class, but uses only OPM enabled plugins"""
MAPPINGS = {
"dummy": "ovos-ww-plugin-dummy",
"pocketsphinx": "ovos-ww-plugin-pocketsphinx",
"precise": "ovos-ww-plugin-precise",
"snowboy": "ovos-ww-plugin-snowboy",
"porcupine": "porcupine_wakeword_plug"
}

@staticmethod
def get_class(hotword: str, config: Optional[dict] = None) -> type:
Expand All @@ -128,8 +120,6 @@ def get_class(hotword: str, config: Optional[dict] = None) -> type:
f"Returning base HotWordEngine")
return HotWordEngine
ww_module = hotword_config[hotword]["module"]
if ww_module in OVOSWakeWordFactory.MAPPINGS:
ww_module = OVOSWakeWordFactory.MAPPINGS[ww_module]
return load_wake_word_plugin(ww_module)

@staticmethod
Expand Down
71 changes: 67 additions & 4 deletions test/unittests/test_g2p.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import unittest

from unittest.mock import patch
from copy import deepcopy
from enum import Enum
from unittest.mock import patch, Mock

from ovos_plugin_manager.utils import PluginTypes, PluginConfigTypes

_TEST_CONFIG = {
"g2p": {
"module": "good",
"good": {"a": "b"}
}
}
_FALLBACK_CONFIG = {
"g2p": {
"module": "bad",
"bad": {"fallback_module": "good"},
"good": {"a": "b"}
}
}


class TestG2PTemplate(unittest.TestCase):
def test_phoneme_alphabet(self):
Expand Down Expand Up @@ -72,5 +87,53 @@ def test_get_config(self, get_config):


class TestG2PFactory(unittest.TestCase):
from ovos_plugin_manager.g2p import OVOSG2PFactory
# TODO
def test_create_g2p(self):
from ovos_plugin_manager.g2p import OVOSG2PFactory
real_get_class = OVOSG2PFactory.get_class
mock_class = Mock()
call_args = None

def _copy_args(*args):
nonlocal call_args
call_args = deepcopy(args)
return mock_class

mock_get_class = Mock(side_effect=_copy_args)
OVOSG2PFactory.get_class = mock_get_class

OVOSG2PFactory.create(config=_TEST_CONFIG)
mock_get_class.assert_called_once()
self.assertEqual(call_args, ({**_TEST_CONFIG['g2p']['good'],
**{"module": "good",
"lang": "en-us"}},))
mock_class.assert_called_once_with({**_TEST_CONFIG['g2p']['good'],
**{"module": "good",
"lang": "en-us"}})
OVOSG2PFactory.get_class = real_get_class

def test_create_fallback(self):
from ovos_plugin_manager.g2p import OVOSG2PFactory
real_get_class = OVOSG2PFactory.get_class
mock_class = Mock()
call_args = None
bad_call_args = None

def _copy_args(*args):
nonlocal call_args, bad_call_args
if args[0]["module"] == "bad":
bad_call_args = deepcopy(args)
return None
call_args = deepcopy(args)
return mock_class

mock_get_class = Mock(side_effect=_copy_args)
OVOSG2PFactory.get_class = mock_get_class

OVOSG2PFactory.create(config=_FALLBACK_CONFIG)
mock_get_class.assert_called()
self.assertEqual(call_args[0]["module"], 'good')
self.assertEqual(bad_call_args[0]["module"], 'bad')
mock_class.assert_called_once_with({**_FALLBACK_CONFIG['g2p']['good'],
**{"module": "good",
"lang": "en-us"}})
OVOSG2PFactory.get_class = real_get_class
Loading

0 comments on commit 4e447d8

Please sign in to comment.