diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3eae6909a8c..160667d9d80 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -29,10 +29,6 @@ on: jobs: unit_tests: - strategy: - max-parallel: 3 - matrix: - python-version: ["3.11", "3.12"] runs-on: ubuntu-latest permissions: # Gives the action the necessary permissions for publishing new @@ -45,10 +41,10 @@ jobs: timeout-minutes: 35 steps: - uses: actions/checkout@v4 - - name: Set up python ${{ matrix.python-version }} + - name: Set up python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.11" - name: Install System Dependencies run: | sudo apt-get update diff --git a/ovos_core/intent_services/__init__.py b/ovos_core/intent_services/__init__.py index 4c46650fcfb..b729cc75da8 100644 --- a/ovos_core/intent_services/__init__.py +++ b/ovos_core/intent_services/__init__.py @@ -1,973 +1 @@ -# Copyright 2017 Mycroft AI Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import json -import warnings -import time -from collections import defaultdict -from typing import Tuple, Callable, Union, List - -import requests -from ovos_bus_client.message import Message -from ovos_bus_client.session import SessionManager -from ovos_bus_client.util import get_message_lang -from ovos_plugin_manager.templates.pipeline import PipelineMatch, IntentHandlerMatch -from ovos_utils.lang import standardize_lang_tag -from ovos_utils.log import LOG, log_deprecation, deprecated -from ovos_utils.metrics import Stopwatch -from ovos_utils.thread_utils import create_daemon -from padacioso.opm import PadaciosoPipeline as PadaciosoService - -from ocp_pipeline.opm import OCPPipelineMatcher -from ovos_adapt.opm import AdaptPipeline -from ovos_commonqa.opm import CommonQAService -from ovos_config.config import Configuration -from ovos_config.locale import get_valid_languages -from ovos_core.intent_services.converse_service import ConverseService -from ovos_core.intent_services.fallback_service import FallbackService -from ovos_core.intent_services.stop_service import StopService -from ovos_core.transformers import MetadataTransformersService, UtteranceTransformersService, IntentTransformersService -from ovos_persona import PersonaService - -# TODO - to be dropped once pluginified -# just a placeholder during alphas until https://github.com/OpenVoiceOS/ovos-core/pull/570 -try: - from ovos_ollama_intent_pipeline import LLMIntentPipeline -except ImportError: - LLMIntentPipeline = None -try: - from ovos_m2v_pipeline import Model2VecIntentPipeline -except ImportError: - Model2VecIntentPipeline = None - - -class IntentService: - """OVOS intent service. parses utterances using a variety of systems. - - The intent service also provides the internal API for registering and - querying the intent service. - """ - - def __init__(self, bus, config=None): - """ - Initializes the IntentService with all intent parsing pipelines, transformer services, and messagebus event handlers. - - Args: - bus: The messagebus connection used for event-driven communication. - config: Optional configuration dictionary for intent services. - - Sets up skill name mapping, loads all supported intent matching pipelines (including Adapt, Padatious, Padacioso, Fallback, Converse, CommonQA, Stop, OCP, Persona, and optionally LLM and Model2Vec pipelines), initializes utterance and metadata transformer services, connects the session manager, and registers all relevant messagebus event handlers for utterance processing, context management, intent queries, and skill deactivation tracking. - """ - self.bus = bus - self.config = config or Configuration().get("intents", {}) - - # Dictionary for translating a skill id to a name - self.skill_names = {} - - self._adapt_service = None - self._padatious_service = None - self._padacioso_service = None - self._fallback = None - self._converse = None - self._common_qa = None - self._stop = None - self._ocp = None - self._ollama = None - self._m2v = None - self._load_pipeline_plugins() - - self.utterance_plugins = UtteranceTransformersService(bus) - self.metadata_plugins = MetadataTransformersService(bus) - self.intent_plugins = IntentTransformersService(bus) - - # connection SessionManager to the bus, - # this will sync default session across all components - SessionManager.connect_to_bus(self.bus) - - self.bus.on('recognizer_loop:utterance', self.handle_utterance) - - # Context related handlers - self.bus.on('add_context', self.handle_add_context) - self.bus.on('remove_context', self.handle_remove_context) - self.bus.on('clear_context', self.handle_clear_context) - - # Intents API - self.registered_vocab = [] - self.bus.on('intent.service.intent.get', self.handle_get_intent) - self.bus.on('intent.service.skills.get', self.handle_get_skills) - self.bus.on('mycroft.skills.loaded', self.update_skill_name_dict) - - # internal, track skills that call self.deactivate to avoid reactivating them again - self._deactivations = defaultdict(list) - self.bus.on('intent.service.skills.deactivate', self._handle_deactivate) - - def _load_pipeline_plugins(self): - # TODO - replace with plugin loader from OPM - """ - Initializes and configures all intent matching pipeline plugins for the service. - - Sets up Adapt, Padatious, Padacioso, Fallback, Converse, CommonQA, Stop, OCP, Persona, and optionally LLM and Model2Vec intent pipelines based on the current configuration. Handles conditional loading and disabling of Padatious and Padacioso pipelines, and logs relevant status or errors. - """ - self._adapt_service = AdaptPipeline(bus=self.bus, config=self.config.get("adapt", {})) - if "padatious" not in self.config: - self.config["padatious"] = Configuration().get("padatious", {}) - try: - if self.config["padatious"].get("disabled"): - LOG.info("padatious forcefully disabled in config") - else: - from ovos_padatious.opm import PadatiousPipeline - if "instant_train" not in self.config["padatious"]: - self.config["padatious"]["instant_train"] = False - self._padatious_service = PadatiousPipeline(self.bus, self.config["padatious"]) - except ImportError: - LOG.error(f'Failed to create padatious intent handlers, padatious not installed') - - # by default only load padacioso is padatious is not available - # save memory if padacioso isnt needed - disable_padacioso = self.config.get("disable_padacioso", self._padatious_service is not None) - if not disable_padacioso: - self._padacioso_service = PadaciosoService(self.bus, self.config["padatious"]) - elif "disable_padacioso" not in self.config: - LOG.debug("Padacioso pipeline is disabled, only padatious is loaded. " - "set 'disable_padacioso': false in mycroft.conf if you want it to load alongside padatious") - self._fallback = FallbackService(self.bus) - self._converse = ConverseService(self.bus) - self._common_qa = CommonQAService(self.bus, self.config.get("common_query")) - self._stop = StopService(self.bus) - self._ocp = OCPPipelineMatcher(self.bus, config=self.config.get("OCP", {})) - self._persona = PersonaService(self.bus, config=self.config.get("persona", {})) - if LLMIntentPipeline is not None: - try: - self._ollama = LLMIntentPipeline(self.bus, config=self.config.get("ovos-ollama-intent-pipeline", {})) - except Exception as e: - LOG.error(f"Failed to load LLMIntentPipeline ({e})") - if Model2VecIntentPipeline is not None: - try: - self._m2v = Model2VecIntentPipeline(self.bus, config=self.config.get("ovos-m2v-pipeline", {})) - except Exception as e: - LOG.error(f"Failed to load Model2VecIntentPipeline ({e})") - - LOG.debug(f"Default pipeline: {SessionManager.get().pipeline}") - - def update_skill_name_dict(self, message): - """ - Updates the internal mapping of skill IDs to skill names from a message event. - - Args: - message: A message object containing 'id' and 'name' fields for the skill. - """ - self.skill_names[message.data['id']] = message.data['name'] - - def get_skill_name(self, skill_id): - """Get skill name from skill ID. - - Args: - skill_id: a skill id as encoded in Intent handlers. - - Returns: - (str) Skill name or the skill id if the skill wasn't found - """ - return self.skill_names.get(skill_id, skill_id) - - def _handle_transformers(self, message): - """ - Pipe utterance through transformer plugins to get more metadata. - Utterances may be modified by any parser and context overwritten - """ - lang = get_message_lang(message) # per query lang or default Configuration lang - original = utterances = message.data.get('utterances', []) - message.context["lang"] = lang - utterances, message.context = self.utterance_plugins.transform(utterances, message.context) - if original != utterances: - message.data["utterances"] = utterances - LOG.debug(f"utterances transformed: {original} -> {utterances}") - message.context = self.metadata_plugins.transform(message.context) - return message - - @staticmethod - def disambiguate_lang(message): - """ disambiguate language of the query via pre-defined context keys - 1 - stt_lang -> tagged in stt stage (STT used this lang to transcribe speech) - 2 - request_lang -> tagged in source message (wake word/request volunteered lang info) - 3 - detected_lang -> tagged by transformers (text classification, free form chat) - 4 - config lang (or from message.data) - """ - default_lang = get_message_lang(message) - valid_langs = get_valid_languages() - valid_langs = [standardize_lang_tag(l) for l in valid_langs] - lang_keys = ["stt_lang", - "request_lang", - "detected_lang"] - for k in lang_keys: - if k in message.context: - v = standardize_lang_tag(message.context[k]) - if v in valid_langs: # TODO - use lang distance instead to choose best dialect - if v != default_lang: - LOG.info(f"replaced {default_lang} with {k}: {v}") - return v - else: - LOG.warning(f"ignoring {k}, {v} is not in enabled languages: {valid_langs}") - - return default_lang - - def get_pipeline(self, skips=None, session=None) -> Tuple[str, Callable]: - """ - Constructs and returns the ordered list of intent matcher functions for the current session. - - The pipeline sequence is determined by the session's configuration and may be filtered by - an optional list of pipeline keys to skip. Each entry in the returned list is a tuple of - the pipeline key and its corresponding matcher function, in the order they will be applied - for intent matching. If a requested pipeline component is unavailable, it is skipped and a - warning is logged. - - Args: - skips: Optional list of pipeline keys to exclude from the matcher sequence. - session: Optional session object; if not provided, the current session is used. - - Returns: - A list of (pipeline_key, matcher_function) tuples representing the active intent - matching pipeline for the session. - """ - session = session or SessionManager.get() - - # Create matchers - # TODO - from plugins - padatious_matcher = None - if self._padatious_service is None: - needs_pada = any("padatious" in p for p in session.pipeline) - if self._padacioso_service is not None: - if needs_pada: - LOG.warning("padatious is not available! using padacioso in it's place, " - "intent matching will be extremely slow in comparison") - padatious_matcher = self._padacioso_service - elif needs_pada: - LOG.warning("padatious is not available! only adapt (keyword based) intents will match!") - else: - padatious_matcher = self._padatious_service - - matchers = { - "converse": self._converse.converse_with_skills, - "stop_high": self._stop.match_stop_high, - "stop_medium": self._stop.match_stop_medium, - "stop_low": self._stop.match_stop_low, - "adapt_high": self._adapt_service.match_high, - "common_qa": self._common_qa.match, - "fallback_high": self._fallback.high_prio, - "adapt_medium": self._adapt_service.match_medium, - "fallback_medium": self._fallback.medium_prio, - "adapt_low": self._adapt_service.match_low, - "fallback_low": self._fallback.low_prio, - "ovos-persona-pipeline-plugin-high": self._persona.match_high, - "ovos-persona-pipeline-plugin-low": self._persona.match_low - } - if self._ollama is not None: - matchers["ovos-ollama-intent-pipeline"] = self._ollama.match_low - if self._m2v is not None: - matchers["ovos-m2v-pipeline-high"] = self._m2v.match_high - matchers["ovos-m2v-pipeline-medium"] = self._m2v.match_medium - matchers["ovos-m2v-pipeline-low"] = self._m2v.match_low - if self._padacioso_service is not None: - matchers.update({ - "padacioso_high": self._padacioso_service.match_high, - "padacioso_medium": self._padacioso_service.match_medium, - "padacioso_low": self._padacioso_service.match_low, - - }) - if padatious_matcher is not None: - matchers.update({ - "padatious_high": padatious_matcher.match_high, - "padatious_medium": padatious_matcher.match_medium, - "padatious_low": padatious_matcher.match_low, - - }) - if self._ocp is not None: - matchers.update({ - "ocp_high": self._ocp.match_high, - "ocp_medium": self._ocp.match_medium, - "ocp_fallback": self._ocp.match_fallback, - "ocp_legacy": self._ocp.match_legacy}) - skips = skips or [] - pipeline = [k for k in session.pipeline if k not in skips] - if any(k not in matchers for k in pipeline): - LOG.warning(f"Requested some invalid pipeline components! " - f"filtered {[k for k in pipeline if k not in matchers]}") - pipeline = [k for k in pipeline if k in matchers] - LOG.debug(f"Session pipeline: {pipeline}") - return [(k, matchers[k]) for k in pipeline] - - @staticmethod - def _validate_session(message, lang): - # get session - lang = standardize_lang_tag(lang) - sess = SessionManager.get(message) - if sess.session_id == "default": - updated = False - # Default session, check if it needs to be (re)-created - if sess.expired(): - sess = SessionManager.reset_default_session() - updated = True - if lang != sess.lang: - sess.lang = lang - updated = True - if updated: - SessionManager.update(sess) - SessionManager.sync(message) - else: - sess.lang = lang - SessionManager.update(sess) - sess.touch() - return sess - - def _handle_deactivate(self, message): - """internal helper, track if a skill asked to be removed from active list during intent match - in this case we want to avoid reactivating it again - This only matters in PipelineMatchers, such as fallback and converse - in those cases the activation is only done AFTER the match, not before unlike intents - """ - sess = SessionManager.get(message) - skill_id = message.data.get("skill_id") - self._deactivations[sess.session_id].append(skill_id) - - def _emit_match_message(self, match: Union[IntentHandlerMatch, PipelineMatch], message: Message, lang: str): - """ - Emit a reply message for a matched intent, updating session and skill activation. - - This method processes matched intents from either a pipeline matcher or an intent handler, - creating a reply message with matched intent details and managing skill activation. - - Args: - match (Union[IntentHandlerMatch, PipelineMatch]): The matched intent object containing - utterance and matching information. - message (Message): The original messagebus message that triggered the intent match. - lang (str): The language of the pipeline plugin match - - Details: - - Handles two types of matches: PipelineMatch and IntentHandlerMatch - - Creates a reply message with matched intent data - - Activates the corresponding skill if not previously deactivated - - Updates session information - - Emits the reply message on the messagebus - - Side Effects: - - Modifies session state - - Emits a messagebus event - - Can trigger skill activation events - - Returns: - None - """ - try: - match = self.intent_plugins.transform(match) - except Exception as e: - LOG.error(f"Error in IntentTransformers: {e}") - - reply = None - sess = match.updated_session or SessionManager.get(message) - sess.lang = lang # ensure it is updated - - # utterance fully handled by pipeline matcher - if isinstance(match, PipelineMatch): - if match.handled: - reply = message.reply("ovos.utterance.handled", {"skill_id": match.skill_id}) - - # upload intent metrics if enabled - create_daemon(self._upload_match_data, (match.utterance, - match.skill_id, - lang, - match.match_data)) - - # Launch skill if not handled by the match function - elif isinstance(match, IntentHandlerMatch) and match.match_type: - # keep all original message.data and update with intent match - data = dict(message.data) - data.update(match.match_data) - reply = message.reply(match.match_type, data) - - # upload intent metrics if enabled - create_daemon(self._upload_match_data, (match.utterance, - match.match_type, - lang, - match.match_data)) - - if reply is not None: - reply.data["utterance"] = match.utterance - reply.data["lang"] = lang - - # update active skill list - if match.skill_id: - # ensure skill_id is present in message.context - reply.context["skill_id"] = match.skill_id - - # NOTE: do not re-activate if the skill called self.deactivate - # we could also skip activation if skill is already active, - # but we still want to update the timestamp - was_deactivated = match.skill_id in self._deactivations[sess.session_id] - if not was_deactivated: - sess.activate_skill(match.skill_id) - # emit event for skills callback -> self.handle_activate - self.bus.emit(reply.forward(f"{match.skill_id}.activate")) - - # update Session if modified by pipeline - reply.context["session"] = sess.serialize() - - # finally emit reply message - self.bus.emit(reply) - - else: # upload intent metrics if enabled - create_daemon(self._upload_match_data, (match.utterance, - "complete_intent_failure", - lang, - match.match_data)) - - @staticmethod - def _upload_match_data(utterance: str, intent: str, lang: str, match_data: dict): - """if enabled upload the intent match data to a server, allowing users and developers - to collect metrics/datasets to improve the pipeline plugins and skills. - - There isn't a default server to upload things too, users needs to explicitly configure one - - https://github.com/OpenVoiceOS/ovos-opendata-server - """ - config = Configuration().get("open_data", {}) - endpoints: List[str] = config.get("intent_urls", []) # eg. "http://localhost:8000/intents" - if not endpoints: - return # user didn't configure any endpoints to upload metrics to - if isinstance(endpoints, str): - endpoints = [endpoints] - headers = {"Content-Type": "application/x-www-form-urlencoded", - "User-Agent": config.get("user_agent", "ovos-metrics")} - data = { - "utterance": utterance, - "intent": intent, - "lang": lang, - "match_data": json.dumps(match_data, ensure_ascii=False) - } - for url in endpoints: - try: - # Add a timeout to prevent hanging - response = requests.post(url, data=data, headers=headers, timeout=3) - LOG.info(f"Uploaded intent metrics to '{url}' - Response: {response.status_code}") - except Exception as e: - LOG.warning(f"Failed to upload metrics: {e}") - - def send_cancel_event(self, message): - """ - Emit events and play a sound when an utterance is canceled. - - Logs the cancellation with the specific cancel word, plays a predefined cancel sound, - and emits multiple events to signal the utterance cancellation. - - Parameters: - message (Message): The original message that triggered the cancellation. - - Events Emitted: - - 'mycroft.audio.play_sound': Plays a cancel sound from configuration - - 'ovos.utterance.cancelled': Signals that the utterance was canceled - - 'ovos.utterance.handled': Indicates the utterance processing is complete - - Notes: - - Uses the default cancel sound path 'snd/cancel.mp3' if not specified in configuration - - Ensures events are sent as replies to the original message - """ - LOG.info("utterance canceled, cancel_word:" + message.context.get("cancel_word")) - # play dedicated cancel sound - sound = Configuration().get('sounds', {}).get('cancel', "snd/cancel.mp3") - # NOTE: message.reply to ensure correct message destination - self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound})) - self.bus.emit(message.reply("ovos.utterance.cancelled")) - self.bus.emit(message.reply("ovos.utterance.handled")) - - def handle_utterance(self, message: Message): - """Main entrypoint for handling user utterances - - Monitor the messagebus for 'recognizer_loop:utterance', typically - generated by a spoken interaction but potentially also from a CLI - or other method of injecting a 'user utterance' into the system. - - Utterances then work through this sequence to be handled: - 1) UtteranceTransformers can modify the utterance and metadata in message.context - 2) MetadataTransformers can modify the metadata in message.context - 3) Language is extracted from message - 4) Active skills attempt to handle using converse() - 5) Padatious high match intents (conf > 0.95) - 6) Adapt intent handlers - 7) CommonQuery Skills - 8) High Priority Fallbacks - 9) Padatious near match intents (conf > 0.8) - 10) General Fallbacks - 11) Padatious loose match intents (conf > 0.5) - 12) Catch all fallbacks including Unknown intent handler - - If all these fail the complete_intent_failure message will be sent - and a generic error sound played. - - Args: - message (Message): The messagebus data - """ - # Get utterance utterance_plugins additional context - message = self._handle_transformers(message) - - if message.context.get("canceled"): - self.send_cancel_event(message) - return - - # tag language of this utterance - lang = self.disambiguate_lang(message) - - utterances = message.data.get('utterances', []) - LOG.info(f"Parsing utterance: {utterances}") - - stopwatch = Stopwatch() - - # get session - sess = self._validate_session(message, lang) - message.context["session"] = sess.serialize() - - # match - match = None - with stopwatch: - self._deactivations[sess.session_id] = [] - # Loop through the matching functions until a match is found. - for pipeline, match_func in self.get_pipeline(session=sess): - langs = [lang] - if self.config.get("multilingual_matching"): - # if multilingual matching is enabled, attempt to match all user languages if main fails - langs += [l for l in get_valid_languages() if l != lang] - for intent_lang in langs: - match = match_func(utterances, intent_lang, message) - if match: - LOG.info(f"{pipeline} match ({intent_lang}): {match}") - if match.skill_id and match.skill_id in sess.blacklisted_skills: - LOG.debug( - f"ignoring match, skill_id '{match.skill_id}' blacklisted by Session '{sess.session_id}'") - continue - if isinstance(match, IntentHandlerMatch) and match.match_type in sess.blacklisted_intents: - LOG.debug( - f"ignoring match, intent '{match.match_type}' blacklisted by Session '{sess.session_id}'") - continue - try: - self._emit_match_message(match, message, intent_lang) - break - except: - LOG.exception(f"{match_func} returned an invalid match") - else: - LOG.debug(f"no match from {match_func}") - continue - break - else: - # Nothing was able to handle the intent - # Ask politely for forgiveness for failing in this vital task - self.send_complete_intent_failure(message) - - LOG.debug(f"intent matching took: {stopwatch.time}") - - # sync any changes made to the default session, eg by ConverseService - if sess.session_id == "default": - SessionManager.sync(message) - elif sess.session_id in self._deactivations: - self._deactivations.pop(sess.session_id) - return match, message.context, stopwatch - - def send_complete_intent_failure(self, message): - """Send a message that no skill could handle the utterance. - - Args: - message (Message): original message to forward from - """ - sound = Configuration().get('sounds', {}).get('error', "snd/error.mp3") - # NOTE: message.reply to ensure correct message destination - self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound})) - self.bus.emit(message.reply('complete_intent_failure')) - self.bus.emit(message.reply("ovos.utterance.handled")) - - @staticmethod - def handle_add_context(message: Message): - """Add context - - Args: - message: data contains the 'context' item to add - optionally can include 'word' to be injected as - an alias for the context item. - """ - entity = {'confidence': 1.0} - context = message.data.get('context') - word = message.data.get('word') or '' - origin = message.data.get('origin') or '' - # if not a string type try creating a string from it - if not isinstance(word, str): - word = str(word) - entity['data'] = [(word, context)] - entity['match'] = word - entity['key'] = word - entity['origin'] = origin - sess = SessionManager.get(message) - sess.context.inject_context(entity) - - @staticmethod - def handle_remove_context(message: Message): - """Remove specific context - - Args: - message: data contains the 'context' item to remove - """ - context = message.data.get('context') - if context: - sess = SessionManager.get(message) - sess.context.remove_context(context) - - @staticmethod - def handle_clear_context(message: Message): - """Clears all keywords from context """ - sess = SessionManager.get(message) - sess.context.clear_context() - - def handle_get_intent(self, message): - """Get intent from either adapt or padatious. - - Args: - message (Message): message containing utterance - """ - utterance = message.data["utterance"] - lang = get_message_lang(message) - sess = SessionManager.get(message) - match = None - # Loop through the matching functions until a match is found. - for pipeline, match_func in self.get_pipeline(skips=["converse", - "common_qa", - "fallback_high", - "fallback_medium", - "fallback_low"], - session=sess): - s = time.monotonic() - match = match_func([utterance], lang, message) - LOG.debug(f"matching '{pipeline}' took: {time.monotonic() - s} seconds") - if match: - if match.match_type: - intent_data = dict(match.match_data) - intent_data["intent_name"] = match.match_type - intent_data["intent_service"] = pipeline - intent_data["skill_id"] = match.skill_id - intent_data["handler"] = match_func.__name__ - LOG.debug(f"final intent match: {intent_data}") - m = message.reply("intent.service.intent.reply", - {"intent": intent_data, "utterance": utterance}) - self.bus.emit(m) - return - LOG.error(f"bad pipeline match! {match}") - # signal intent failure - self.bus.emit(message.reply("intent.service.intent.reply", - {"intent": None, "utterance": utterance})) - - def handle_get_skills(self, message): - """Send registered skills to caller. - - Argument: - message: query message to reply to. - """ - self.bus.emit(message.reply("intent.service.skills.reply", - {"skills": self.skill_names})) - - def shutdown(self): - self.utterance_plugins.shutdown() - self.metadata_plugins.shutdown() - self._adapt_service.shutdown() - if self._padacioso_service: - self._padacioso_service.shutdown() - if self._padatious_service: - self._padatious_service.shutdown() - self._common_qa.shutdown() - self._converse.shutdown() - self._fallback.shutdown() - if self._ocp: - self._ocp.shutdown() - - self.bus.remove('recognizer_loop:utterance', self.handle_utterance) - self.bus.remove('add_context', self.handle_add_context) - self.bus.remove('remove_context', self.handle_remove_context) - self.bus.remove('clear_context', self.handle_clear_context) - self.bus.remove('mycroft.skills.loaded', self.update_skill_name_dict) - self.bus.remove('intent.service.intent.get', self.handle_get_intent) - self.bus.remove('intent.service.skills.get', self.handle_get_skills) - - ########### - # DEPRECATED STUFF - @property - def registered_intents(self): - log_deprecation("direct access to self.adapt_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - warnings.warn( - "direct access to self.adapt_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - lang = get_message_lang() - return [parser.__dict__ - for parser in self._adapt_service.engines[lang].intent_parsers] - - @property - def adapt_service(self): - warnings.warn( - "direct access to self.adapt_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.adapt_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._adapt_service - - @property - def padatious_service(self): - warnings.warn( - "direct access to self.padatious_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.padatious_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._padatious_service - - @property - def padacioso_service(self): - warnings.warn( - "direct access to self.padatious_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.padacioso_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._padacioso_service - - @property - def fallback(self): - warnings.warn( - "direct access to self.fallback is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.fallback is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._fallback - - @property - def converse(self): - warnings.warn( - "direct access to self.converse is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.converse is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._converse - - @property - def common_qa(self): - warnings.warn( - "direct access to self.common_qa is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.common_qa is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._common_qa - - @property - def stop(self): - warnings.warn( - "direct access to self.stop is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.stop is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._stop - - @property - def ocp(self): - warnings.warn( - "direct access to self.ocp is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.ocp is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - return self._ocp - - @adapt_service.setter - def adapt_service(self, value): - warnings.warn( - "direct access to self.adapt_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.adapt_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._adapt_service = value - - @padatious_service.setter - def padatious_service(self, value): - warnings.warn( - "direct access to self.padatious_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.padatious_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._padatious_service = value - - @padacioso_service.setter - def padacioso_service(self, value): - warnings.warn( - "direct access to self.padacioso_service is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.padacioso_service is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._padacioso_service = value - - @fallback.setter - def fallback(self, value): - warnings.warn( - "direct access to self.fallback is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.fallback is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._fallback = value - - @converse.setter - def converse(self, value): - warnings.warn( - "direct access to self.converse is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.converse is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._converse = value - - @common_qa.setter - def common_qa(self, value): - warnings.warn( - "direct access to self.common_qa is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.common_qa is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._common_qa = value - - @stop.setter - def stop(self, value): - warnings.warn( - "direct access to self.stop is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.stop is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._stop = value - - @ocp.setter - def ocp(self, value): - warnings.warn( - "direct access to self.ocp is deprecated", - DeprecationWarning, - stacklevel=2, - ) - log_deprecation("direct access to self.ocp is deprecated, " - "pipelines are in the progress of being replaced with plugins", "1.0.0") - self._ocp = value - - @deprecated("handle_get_adapt moved to adapt service, this method does nothing", "1.0.0") - def handle_get_adapt(self, message: Message): - warnings.warn( - "moved to adapt service, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_adapt_manifest moved to adapt service, this method does nothing", "1.0.0") - def handle_adapt_manifest(self, message): - warnings.warn( - "moved to adapt service, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_vocab_manifest moved to adapt service, this method does nothing", "1.0.0") - def handle_vocab_manifest(self, message): - warnings.warn( - "moved to adapt service, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_get_padatious moved to padatious service, this method does nothing", "1.0.0") - def handle_get_padatious(self, message): - warnings.warn( - "moved to padatious service, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_padatious_manifest moved to padatious service, this method does nothing", "1.0.0") - def handle_padatious_manifest(self, message): - warnings.warn( - "moved to padatious service, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_entity_manifest moved to padatious service, this method does nothing", "1.0.0") - def handle_entity_manifest(self, message): - warnings.warn( - "moved to padatious service, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_register_vocab moved to individual pipeline services, this method does nothing", "1.0.0") - def handle_register_vocab(self, message): - warnings.warn( - "moved to pipeline plugins, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_register_intent moved to individual pipeline services, this method does nothing", "1.0.0") - def handle_register_intent(self, message): - warnings.warn( - "moved to pipeline plugins, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_detach_intent moved to individual pipeline services, this method does nothing", "1.0.0") - def handle_detach_intent(self, message): - warnings.warn( - "moved to pipeline plugins, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("handle_detach_skill moved to individual pipeline services, this method does nothing", "1.0.0") - def handle_detach_skill(self, message): - warnings.warn( - "moved to pipeline plugins, this method does nothing", - DeprecationWarning, - stacklevel=2, - ) +from ovos_core.intent_services.service import IntentService diff --git a/ovos_core/intent_services/adapt_service.py b/ovos_core/intent_services/adapt_service.py deleted file mode 100644 index 982fe78831d..00000000000 --- a/ovos_core/intent_services/adapt_service.py +++ /dev/null @@ -1,12 +0,0 @@ -# backwards compat import -from ovos_adapt.opm import AdaptPipeline as AdaptService -from ovos_utils.log import log_deprecation -log_deprecation("adapt service moved to 'ovos-adapt-pipeline-plugin'. this import is deprecated", "1.0.0") - -import warnings - -warnings.warn( - "adapt service moved to 'ovos-adapt-pipeline-plugin'", - DeprecationWarning, - stacklevel=2, -) \ No newline at end of file diff --git a/ovos_core/intent_services/commonqa_service.py b/ovos_core/intent_services/commonqa_service.py deleted file mode 100644 index d292ceacd6f..00000000000 --- a/ovos_core/intent_services/commonqa_service.py +++ /dev/null @@ -1,11 +0,0 @@ -from ovos_commonqa.opm import Query, CommonQAService -from ovos_utils.log import log_deprecation -log_deprecation("adapt service moved to 'ovos-common-query-pipeline-plugin'. this import is deprecated", "1.0.0") - -import warnings - -warnings.warn( - "adapt service moved to 'ovos-common-query-pipeline-plugin'", - DeprecationWarning, - stacklevel=2, -) \ No newline at end of file diff --git a/ovos_core/intent_services/converse_service.py b/ovos_core/intent_services/converse_service.py index 4d6421f97ed..b517de62a74 100644 --- a/ovos_core/intent_services/converse_service.py +++ b/ovos_core/intent_services/converse_service.py @@ -1,36 +1,47 @@ import time from threading import Event -from typing import Optional, List +from typing import Optional, Dict, List, Union +from ovos_bus_client.client import MessageBusClient from ovos_bus_client.message import Message from ovos_bus_client.session import SessionManager, UtteranceState, Session -from ovos_bus_client.util import get_message_lang from ovos_config.config import Configuration -from ovos_config.locale import setup_locale -from ovos_plugin_manager.templates.pipeline import PipelineMatch, PipelinePlugin from ovos_utils import flatten_list +from ovos_utils.fakebus import FakeBus from ovos_utils.lang import standardize_lang_tag from ovos_utils.log import LOG + +from ovos_plugin_manager.templates.pipeline import PipelinePlugin, IntentHandlerMatch from ovos_workshop.permissions import ConverseMode, ConverseActivationMode class ConverseService(PipelinePlugin): """Intent Service handling conversational skills.""" - def __init__(self, bus): - self.bus = bus + def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, + config: Optional[Dict] = None): + """ + Initializes the ConverseService with optional message bus and configuration. + + Registers event handlers for skill activation, deactivation, active skill queries, and response mode toggling on the message bus. + """ + config = config or Configuration().get("skills", {}).get("converse", {}) + super().__init__(bus, config) self._consecutive_activations = {} - self.bus.on('mycroft.speech.recognition.unknown', self.reset_converse) self.bus.on('intent.service.skills.deactivate', self.handle_deactivate_skill_request) self.bus.on('intent.service.skills.activate', self.handle_activate_skill_request) - self.bus.on('active_skill_request', self.handle_activate_skill_request) # TODO backwards compat, deprecate self.bus.on('intent.service.active_skills.get', self.handle_get_active_skills) self.bus.on("skill.converse.get_response.enable", self.handle_get_response_enable) self.bus.on("skill.converse.get_response.disable", self.handle_get_response_disable) - super().__init__(config=Configuration().get("skills", {}).get("converse") or {}) @property def active_skills(self): + """ + Gets the list of currently active skill IDs for the current session. + + Returns: + A list of skill IDs representing the active skills in the session. + """ session = SessionManager.get() return session.active_skills @@ -208,18 +219,24 @@ def _converse_allowed(self, skill_id: str) -> bool: return True def _collect_converse_skills(self, message: Message) -> List[str]: - """use the messagebus api to determine which skills want to converse - This includes all skills and external applications""" - session = SessionManager.get(message) - + """ + Queries active skills in INTENT state to determine which want to handle the next utterance. + + Sends a "converse.ping" event to each active skill in INTENT state and collects those that respond affirmatively within 0.5 seconds. + + Args: + message: The message containing session and utterance context. + + Returns: + A list of skill IDs that indicate they want to converse. + """ skill_ids = [] - # include all skills in get_response state - want_converse = [skill_id for skill_id, state in session.utterance_states.items() - if state == UtteranceState.RESPONSE] - skill_ids += want_converse # dont wait for these pong answers (optimization) - - active_skills = self.get_active_skills() + want_converse = [] + session = SessionManager.get(message) + # note: this is sorted by priority already + active_skills = [skill_id for skill_id in self.get_active_skills(message) + if session.utterance_states.get(skill_id, UtteranceState.INTENT) == UtteranceState.INTENT] if not active_skills: return want_converse @@ -245,9 +262,10 @@ def handle_ack(msg): self.bus.on("skill.converse.pong", handle_ack) # ask skills if they want to converse + data = message.data for skill_id in active_skills: - self.bus.emit(message.forward(f"{skill_id}.converse.ping", - {"skill_id": skill_id})) + data["skill_id"] = skill_id + self.bus.emit(message.forward(f"{skill_id}.converse.ping", data)) # wait for all skills to acknowledge they want to converse event.wait(timeout=0.5) @@ -256,7 +274,11 @@ def handle_ack(msg): return want_converse def _check_converse_timeout(self, message: Message): - """ filter active skill list based on timestamps """ + """ + Removes skills from the active skills list if their activation time exceeds the configured timeout. + + Filters the session's active skills, retaining only those whose activation timestamp is within the allowed timeout period, as specified per skill or by the default timeout. + """ timeouts = self.config.get("skill_timeouts") or {} def_timeout = self.config.get("timeout", 300) session = SessionManager.get(message) @@ -264,100 +286,62 @@ def _check_converse_timeout(self, message: Message): skill for skill in session.active_skills if time.time() - skill[1] <= timeouts.get(skill[0], def_timeout)] - def converse(self, utterances: List[str], skill_id: str, lang: str, message: Message) -> bool: - """Call skill and ask if they want to process the utterance. - - Args: - utterances (list of tuples): utterances paired with normalized - versions. - skill_id: skill to query. - lang (str): current language - message (Message): message containing interaction info. - - Returns: - handled (bool): True if handled otherwise False. + def match(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: """ - lang = standardize_lang_tag(lang) - session = SessionManager.get(message) - session.lang = lang - - state = session.utterance_states.get(skill_id, UtteranceState.INTENT) - if state == UtteranceState.RESPONSE: - converse_msg = message.reply(f"{skill_id}.converse.get_response", - {"utterances": utterances, - "lang": lang}) - self.bus.emit(converse_msg) - return True - - if self._converse_allowed(skill_id): - converse_msg = message.reply(f"{skill_id}.converse.request", - {"utterances": utterances, - "lang": lang}) - result = self.bus.wait_for_response(converse_msg, - 'skill.converse.response', - timeout=self.config.get("max_skill_runtime", 10)) - if result and 'error' in result.data: - error_msg = result.data['error'] - LOG.error(f"{skill_id}: {error_msg}") - return False - elif result is not None: - return result.data.get('result', False) - else: - # abort any ongoing converse - # if skill crashed or returns False, all good - # if it is just taking a long time, more than 1 skill would end up answering - self.bus.emit(message.forward("ovos.skills.converse.force_timeout", - {"skill_id": skill_id})) - LOG.warning(f"{skill_id} took too long to answer, " - f'increasing "max_skill_runtime" in mycroft.conf might help alleviate this issue') - return False - - def converse_with_skills(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: - """ - Attempt to converse with active skills for a given set of utterances. + Attempts to find an active skill to handle the given utterances in the current session. - Iterates through active skills to find one that can handle the utterance. Filters skills based on timeout and blacklist status. + Checks for skills in response mode (get_response), then filters active skills by timeout and blacklist status, and returns an intent match for the first eligible skill allowed to converse. Returns None if no skill matches. Args: - utterances (List[str]): List of utterance strings to process - lang (str): 4-letter ISO language code for the utterances - message (Message): Message context for generating a reply + utterances: List of utterance strings to process. + lang: ISO language code for the utterances. + message: Message context for the session. Returns: - PipelineMatch: Match details if a skill successfully handles the utterance, otherwise None - - handled (bool): Whether the utterance was fully handled - - match_data (dict): Additional match metadata - - skill_id (str): ID of the skill that handled the utterance - - updated_session (Session): Current session state after skill interaction - - utterance (str): The original utterance processed - - Notes: - - Standardizes language tag - - Filters out blacklisted skills - - Checks for skill conversation timeouts - - Attempts conversation with each eligible skill + An IntentHandlerMatch if a skill is found to handle the utterance; otherwise, None. """ lang = standardize_lang_tag(lang) session = SessionManager.get(message) # we call flatten in case someone is sending the old style list of tuples utterances = flatten_list(utterances) + + # note: this is sorted by priority already + gr_skills = [skill_id for skill_id in self.get_active_skills(message) + if session.utterance_states.get(skill_id, UtteranceState.INTENT) == UtteranceState.RESPONSE] + + # check if any skill wants to capture utterance for self.get_response method + for skill_id in gr_skills: + if skill_id in session.blacklisted_skills: + LOG.debug(f"ignoring match, skill_id '{skill_id}' blacklisted by Session '{session.session_id}'") + continue + LOG.debug(f"utterance captured by skill.get_response method: {skill_id}") + return IntentHandlerMatch( + match_type=f"{skill_id}.converse.get_response", + match_data={"utterances": utterances, "lang": lang}, + skill_id=skill_id, + utterance=utterances[0], + updated_session=session + ) + # filter allowed skills self._check_converse_timeout(message) - # check if any skill wants to handle utterance + + # check if any skill wants to converse for skill_id in self._collect_converse_skills(message): if skill_id in session.blacklisted_skills: LOG.debug(f"ignoring match, skill_id '{skill_id}' blacklisted by Session '{session.session_id}'") continue LOG.debug(f"Attempting to converse with skill: {skill_id}") - if self.converse(utterances, skill_id, lang, message): - state = session.utterance_states.get(skill_id, UtteranceState.INTENT) - return PipelineMatch(handled=state != UtteranceState.RESPONSE, - # handled == True -> emit "ovos.utterance.handled" - match_data={}, - skill_id=skill_id, - updated_session=session, - utterance=utterances[0]) + if self._converse_allowed(skill_id): + return IntentHandlerMatch( + match_type=f"{skill_id}.converse.request", + match_data={"utterances": utterances, "lang": lang}, + skill_id=skill_id, + utterance=utterances[0], + updated_session=session + ) + return None @staticmethod @@ -393,6 +377,11 @@ def handle_deactivate_skill_request(self, message: Message): # someone can forge this message and emit it raw, but in ovos-core all # skill message should have skill_id in context, so let's make sure # this doesnt happen accidentally + """ + Handles a request to deactivate a skill within the current session. + + Removes the specified skill from the active skills list if permitted, using the skill ID from the message data and the source skill from the message context or data. If the session is the default session, synchronizes the session state. + """ skill_id = message.data['skill_id'] source_skill = message.context.get("skill_id") or skill_id self.deactivate_skill(skill_id, source_skill, message) @@ -400,25 +389,22 @@ def handle_deactivate_skill_request(self, message: Message): if sess.session_id == "default": SessionManager.sync(message) - def reset_converse(self, message: Message): - """Let skills know there was a problem with speech recognition""" - lang = get_message_lang() - self.converse_with_skills([], lang, message) - def handle_get_active_skills(self, message: Message): - """Send active skills to caller. - - Argument: - message: query message to reply to. + """ + Sends a reply containing the list of currently active skills for the session. + + Args: + message: The message requesting the list of active skills. """ self.bus.emit(message.reply("intent.service.active_skills.reply", {"skills": self.get_active_skills(message)})) def shutdown(self): - self.bus.remove('mycroft.speech.recognition.unknown', self.reset_converse) + """ + Removes all event handlers related to skill activation, deactivation, active skill queries, and response mode toggling from the message bus. + """ self.bus.remove('intent.service.skills.deactivate', self.handle_deactivate_skill_request) self.bus.remove('intent.service.skills.activate', self.handle_activate_skill_request) - self.bus.remove('active_skill_request', self.handle_activate_skill_request) # TODO backwards compat, deprecate self.bus.remove('intent.service.active_skills.get', self.handle_get_active_skills) self.bus.remove("skill.converse.get_response.enable", self.handle_get_response_enable) self.bus.remove("skill.converse.get_response.disable", self.handle_get_response_disable) diff --git a/ovos_core/intent_services/fallback_service.py b/ovos_core/intent_services/fallback_service.py index df2d5cb042f..43241e0b3bb 100644 --- a/ovos_core/intent_services/fallback_service.py +++ b/ovos_core/intent_services/fallback_service.py @@ -12,17 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Intent service for Mycroft's fallback system.""" import operator import time from collections import namedtuple -from typing import Optional, List +from typing import Optional, Dict, List, Union +from ovos_bus_client.client import MessageBusClient from ovos_bus_client.message import Message from ovos_bus_client.session import SessionManager from ovos_config import Configuration -from ovos_plugin_manager.templates.pipeline import PipelineMatch, PipelinePlugin +from ovos_plugin_manager.templates.pipeline import ConfidenceMatcherPipeline, IntentHandlerMatch from ovos_utils import flatten_list +from ovos_utils.fakebus import FakeBus from ovos_utils.lang import standardize_lang_tag from ovos_utils.log import LOG from ovos_workshop.permissions import FallbackMode @@ -30,23 +31,33 @@ FallbackRange = namedtuple('FallbackRange', ['start', 'stop']) -class FallbackService(PipelinePlugin): +class FallbackService(ConfidenceMatcherPipeline): """Intent Service handling fallback skills.""" - def __init__(self, bus): - self.bus = bus - self.fallback_config = Configuration()["skills"].get("fallbacks", {}) + def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, + config: Optional[Dict] = None): + """ + Initializes the FallbackService with an optional message bus and configuration. + + Registers event handlers for fallback skill registration and deregistration, and sets up internal tracking for registered fallback skills and their priorities. + """ + config = config or Configuration().get("skills", {}).get("fallbacks", {}) + super().__init__(bus, config) self.registered_fallbacks = {} # skill_id: priority self.bus.on("ovos.skills.fallback.register", self.handle_register_fallback) self.bus.on("ovos.skills.fallback.deregister", self.handle_deregister_fallback) - super().__init__(self.fallback_config) def handle_register_fallback(self, message: Message): + """ + Handles the registration of a fallback skill by storing its priority. + + If a priority override for the skill exists in the configuration, it is applied; otherwise, the provided or default priority is used. + """ skill_id = message.data.get("skill_id") priority = message.data.get("priority") or 101 # check if .conf is overriding the priority for this skill - priority_overrides = self.fallback_config.get("fallback_priorities", {}) + priority_overrides = self.config.get("fallback_priorities", {}) if skill_id in priority_overrides: new_priority = priority_overrides.get(skill_id) LOG.info(f"forcing {skill_id} fallback priority from {priority} to {new_priority}") @@ -60,30 +71,38 @@ def handle_deregister_fallback(self, message: Message): self.registered_fallbacks.pop(skill_id) def _fallback_allowed(self, skill_id: str) -> bool: - """Checks if a skill_id is allowed to fallback - - - is the skill blacklisted from fallback - - is fallback configured to only allow specific skills - + """ + Determines whether a skill is permitted to handle fallback requests. + + A skill is allowed if it is not blacklisted when in blacklist mode, or if it is present in the whitelist when in whitelist mode. In accept-all mode, all skills are permitted. + Args: - skill_id (str): identifier of skill that wants to fallback. - + skill_id: The identifier of the skill to check. + Returns: - permitted (bool): True if skill can fallback + True if the skill is allowed to handle fallback; otherwise, False. """ - opmode = self.fallback_config.get("fallback_mode", FallbackMode.ACCEPT_ALL) + opmode = self.config.get("fallback_mode", FallbackMode.ACCEPT_ALL) if opmode == FallbackMode.BLACKLIST and skill_id in \ - self.fallback_config.get("fallback_blacklist", []): + self.config.get("fallback_blacklist", []): return False elif opmode == FallbackMode.WHITELIST and skill_id not in \ - self.fallback_config.get("fallback_whitelist", []): + self.config.get("fallback_whitelist", []): return False return True def _collect_fallback_skills(self, message: Message, fb_range: FallbackRange = FallbackRange(0, 100)) -> List[str]: - """use the messagebus api to determine which skills have registered fallback handlers - This includes all skills and external applications""" + """ + Queries registered fallback skills via the message bus to identify those willing to handle a fallback request within a specified priority range. + + Args: + message: The message triggering the fallback query, used for context and session information. + fb_range: The priority range to filter fallback skills (default is 0 to 100). + + Returns: + A list of skill IDs that have indicated willingness to handle the fallback request. + """ skill_ids = [] # skill_ids that already answered to ping fallback_skills = [] # skill_ids that want to handle fallback @@ -109,7 +128,7 @@ def handle_ack(msg): if in_range: # no need to search if no skills available self.bus.on("ovos.skills.fallback.pong", handle_ack) - LOG.info("checking for FallbackSkillsV2 candidates") + LOG.info("checking for FallbackSkill candidates") message.data["range"] = (fb_range.start, fb_range.stop) # wait for all skills to acknowledge they want to answer fallback queries self.bus.emit(message.forward("ovos.skills.fallback.ping", @@ -122,62 +141,22 @@ def handle_ack(msg): self.bus.remove("ovos.skills.fallback.pong", handle_ack) return fallback_skills - def attempt_fallback(self, utterances: List[str], skill_id: str, lang: str, message: Message) -> bool: - """Call skill and ask if they want to process the utterance. - - Args: - utterances (list of tuples): utterances paired with normalized - versions. - skill_id: skill to query. - lang (str): current language - message (Message): message containing interaction info. - - Returns: - handled (bool): True if handled otherwise False. - """ - sess = SessionManager.get(message) - if skill_id in sess.blacklisted_skills: - LOG.debug(f"ignoring match, skill_id '{skill_id}' blacklisted by Session '{sess.session_id}'") - return False - if self._fallback_allowed(skill_id): - fb_msg = message.reply(f"ovos.skills.fallback.{skill_id}.request", - {"skill_id": skill_id, - "utterances": utterances, - "utterance": utterances[0], # backwards compat, we send all transcripts now - "lang": lang}) - result = self.bus.wait_for_response(fb_msg, - f"ovos.skills.fallback.{skill_id}.response", - timeout=self.fallback_config.get("max_skill_runtime", 10)) - if result and 'error' in result.data: - error_msg = result.data['error'] - LOG.error(f"{skill_id}: {error_msg}") - return False - elif result is not None: - return result.data.get('result', False) - else: - # abort any ongoing fallback - # if skill crashed or returns False, all good - # if it is just taking a long time, more than 1 fallback would end up answering - self.bus.emit(message.forward("ovos.skills.fallback.force_timeout", - {"skill_id": skill_id})) - LOG.warning(f"{skill_id} took too long to answer, " - f'increasing "max_skill_runtime" in mycroft.conf might help alleviate this issue') - return False - def _fallback_range(self, utterances: List[str], lang: str, - message: Message, fb_range: FallbackRange) -> Optional[PipelineMatch]: - """Send fallback request for a specified priority range. - - Args: - utterances (list): List of tuples, - utterances and normalized version - lang (str): Langauge code - message: Message for session context - fb_range (FallbackRange): fallback order start and stop. - - Returns: - PipelineMatch or None + message: Message, fb_range: FallbackRange) -> Optional[IntentHandlerMatch]: """ + Attempts to find a fallback skill match within a specified priority range. + + Sends a fallback request for the given utterances and language, filtering available fallback skills by priority and session context. Returns an `IntentHandlerMatch` for the first eligible fallback skill, or `None` if no suitable skill is found. + + Args: + utterances: List of utterances to process. + lang: Language code for the utterances. + message: Message object containing session context. + fb_range: Priority range to consider for fallback skills. + + Returns: + An `IntentHandlerMatch` if a suitable fallback skill is found; otherwise, `None`. + """ lang = standardize_lang_tag(lang) # we call flatten in case someone is sending the old style list of tuples utterances = flatten_list(utterances) @@ -190,30 +169,56 @@ def _fallback_range(self, utterances: List[str], lang: str, fallbacks = [(k, v) for k, v in self.registered_fallbacks.items() if k in available_skills] sorted_handlers = sorted(fallbacks, key=operator.itemgetter(1)) + for skill_id, prio in sorted_handlers: if skill_id in sess.blacklisted_skills: LOG.debug(f"ignoring match, skill_id '{skill_id}' blacklisted by Session '{sess.session_id}'") continue - result = self.attempt_fallback(utterances, skill_id, lang, message) - if result: - return PipelineMatch(handled=True, - match_data={}, - skill_id=skill_id, - utterance=utterances[0]) + + if self._fallback_allowed(skill_id): + return IntentHandlerMatch( + match_type=f"ovos.skills.fallback.{skill_id}.request", + match_data={"skill_id": skill_id, + "utterances": utterances, + "lang": lang}, + utterance=utterances[0], + updated_session=sess + ) + return None - def high_prio(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: - """Pre-padatious fallbacks.""" + def match_high(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: + """ + Attempts to find a high-priority fallback skill match for the given utterances. + + Searches for a fallback skill within the highest priority range (0 to 5) that is eligible to handle the provided utterances and language, based on current configuration and session context. + + Args: + utterances: List of user utterances to match. + lang: Language code for the utterances. + message: Message object containing context and session data. + + Returns: + An IntentHandlerMatch if a suitable high-priority fallback skill is found; otherwise, None. + """ return self._fallback_range(utterances, lang, message, FallbackRange(0, 5)) - def medium_prio(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: - """General fallbacks.""" + def match_medium(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: + """ + Attempts to find a fallback skill match within the medium-priority range. + + Returns an IntentHandlerMatch if a suitable fallback skill is found for the given utterances and language; otherwise, returns None. + """ return self._fallback_range(utterances, lang, message, FallbackRange(5, 90)) - def low_prio(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: - """Low prio fallbacks with general matching such as chat-bot.""" + def match_low(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: + """ + Attempts to find a low-priority fallback skill match for the given utterances. + + Searches for fallback skills within the lowest priority range (90–101), typically used for general-purpose or chat-bot style responses. Returns an `IntentHandlerMatch` if a suitable fallback skill is found, or `None` if no match is available. + """ return self._fallback_range(utterances, lang, message, FallbackRange(90, 101)) diff --git a/ovos_core/intent_services/ocp_service.py b/ovos_core/intent_services/ocp_service.py deleted file mode 100644 index 790e79f5d60..00000000000 --- a/ovos_core/intent_services/ocp_service.py +++ /dev/null @@ -1,12 +0,0 @@ -# backwards compat imports -from ocp_pipeline.opm import OCPPipelineMatcher, OCPFeaturizer, OCPPlayerProxy -from ovos_utils.log import log_deprecation -log_deprecation("adapt service moved to 'ovos-ocp-pipeline-plugin'. this import is deprecated", "1.0.0") - -import warnings - -warnings.warn( - "adapt service moved to 'ovos-ocp-pipeline-plugin'", - DeprecationWarning, - stacklevel=2, -) \ No newline at end of file diff --git a/ovos_core/intent_services/padacioso_service.py b/ovos_core/intent_services/padacioso_service.py deleted file mode 100644 index 7bd3fd645d1..00000000000 --- a/ovos_core/intent_services/padacioso_service.py +++ /dev/null @@ -1,13 +0,0 @@ -# backwards compat imports -from padacioso.opm import PadaciosoPipeline as PadaciosoService, PadaciosoIntent -from padacioso import IntentContainer as FallbackIntentContainer -from ovos_utils.log import log_deprecation -log_deprecation("adapt service moved to 'padacioso.opm'. this import is deprecated", "1.0.0") - -import warnings - -warnings.warn( - "adapt service moved to 'padacioso'", - DeprecationWarning, - stacklevel=2, -) \ No newline at end of file diff --git a/ovos_core/intent_services/padatious_service.py b/ovos_core/intent_services/padatious_service.py deleted file mode 100644 index b0f421b732c..00000000000 --- a/ovos_core/intent_services/padatious_service.py +++ /dev/null @@ -1,12 +0,0 @@ -# backwards compat imports -from ovos_padatious.opm import PadatiousMatcher, PadatiousPipeline as PadatiousService -from ovos_utils.log import log_deprecation -log_deprecation("adapt service moved to 'ovos-padatious-pipeline-plugin'. this import is deprecated", "1.0.0") - -import warnings - -warnings.warn( - "adapt service moved to 'ovos-padatious-pipeline-plugin'", - DeprecationWarning, - stacklevel=2, -) \ No newline at end of file diff --git a/ovos_core/intent_services/service.py b/ovos_core/intent_services/service.py new file mode 100644 index 00000000000..372dae5e513 --- /dev/null +++ b/ovos_core/intent_services/service.py @@ -0,0 +1,624 @@ +# Copyright 2017 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import re +import time +from collections import defaultdict +from typing import Tuple, Callable, List + +import requests +from ovos_bus_client.message import Message +from ovos_bus_client.session import SessionManager +from ovos_bus_client.util import get_message_lang +from ovos_config.config import Configuration +from ovos_config.locale import get_valid_languages +from ovos_utils.lang import standardize_lang_tag +from ovos_utils.log import LOG +from ovos_utils.metrics import Stopwatch +from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap +from ovos_utils.thread_utils import create_daemon + +from ovos_core.transformers import MetadataTransformersService, UtteranceTransformersService, IntentTransformersService +from ovos_plugin_manager.pipeline import OVOSPipelineFactory +from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch, ConfidenceMatcherPipeline + + +def on_started(): + """ + Logs that the IntentService is starting up. + """ + LOG.info('IntentService is starting up.') + + +def on_alive(): + """ + Logs that the IntentService process is alive. + """ + LOG.info('IntentService is alive.') + + +def on_ready(): + """ + Logs that the IntentService is ready for operation. + """ + LOG.info('IntentService is ready.') + + +def on_error(e='Unknown'): + """ + Logs an informational message indicating that the IntentService failed to launch. + + Args: + e: The error message or exception that caused the failure. Defaults to 'Unknown'. + """ + LOG.info(f'IntentService failed to launch ({e})') + + +def on_stopping(): + """ + Logs a message indicating that the IntentService is shutting down. + """ + LOG.info('IntentService is shutting down...') + + +class IntentService: + """OVOS intent service. parses utterances using a variety of systems. + + The intent service also provides the internal API for registering and + querying the intent service. + """ + + def __init__(self, bus, config=None, preload_pipelines=True, + alive_hook=on_alive, started_hook=on_started, + ready_hook=on_ready, + error_hook=on_error, stopping_hook=on_stopping): + """ + Initializes the IntentService with intent parsing pipelines, transformer services, and event handlers. + + Sets up the process status callbacks, loads configuration, initializes utterance, metadata, and intent transformer services, connects the session manager to the message bus, and registers all relevant messagebus event handlers for utterance processing, context management, intent queries, and skill deactivation tracking. Optionally preloads all supported intent matching pipelines. + """ + callbacks = StatusCallbackMap(on_started=started_hook, + on_alive=alive_hook, + on_ready=ready_hook, + on_error=error_hook, + on_stopping=stopping_hook) + self.bus = bus + self.status = ProcessStatus('intents', bus=self.bus, callback_map=callbacks) + self.status.set_started() + self.config = config or Configuration().get("intents", {}) + + # load and cache the plugins right away so they receive all bus messages + self.pipeline_plugins = {} + + self.utterance_plugins = UtteranceTransformersService(bus) + self.metadata_plugins = MetadataTransformersService(bus) + self.intent_plugins = IntentTransformersService(bus) + + # connection SessionManager to the bus, + # this will sync default session across all components + SessionManager.connect_to_bus(self.bus) + + self.bus.on('recognizer_loop:utterance', self.handle_utterance) + + # Context related handlers + self.bus.on('add_context', self.handle_add_context) + self.bus.on('remove_context', self.handle_remove_context) + self.bus.on('clear_context', self.handle_clear_context) + + # Intents API + self.bus.on('intent.service.intent.get', self.handle_get_intent) + + # internal, track skills that call self.deactivate to avoid reactivating them again + self._deactivations = defaultdict(list) + self.bus.on('intent.service.skills.deactivate', self._handle_deactivate) + self.bus.on('intent.service.pipelines.reload', self.handle_reload_pipelines) + + self.status.set_alive() + if preload_pipelines: + self.bus.emit(Message('intent.service.pipelines.reload')) + + def handle_reload_pipelines(self, message: Message): + """ + Reloads all installed intent pipeline plugins and updates the internal plugin cache. + + Iterates through available pipeline plugin IDs, attempts to load each plugin, and stores successfully loaded plugins in the internal cache. Logs the outcome for each plugin. Marks the service as ready after reloading. + """ + pipeline_plugins = OVOSPipelineFactory.get_installed_pipeline_ids() + LOG.debug(f"Installed pipeline plugins: {pipeline_plugins}") + for p in pipeline_plugins: + try: + self.pipeline_plugins[p] = OVOSPipelineFactory.load_plugin(p, bus=self.bus) + LOG.debug(f"Loaded pipeline plugin: '{p}'") + except Exception as e: + LOG.error(f"Failed to load pipeline plugin '{p}': {e}") + self.status.set_ready() + + def _handle_transformers(self, message): + """ + Processes the utterance and context through transformer plugins to update utterances and enrich context metadata. + + The function applies utterance transformers, which may modify the utterances, and metadata transformers, which may update the context. The message is updated in place and returned. + """ + lang = get_message_lang(message) # per query lang or default Configuration lang + original = utterances = message.data.get('utterances', []) + message.context["lang"] = lang + utterances, message.context = self.utterance_plugins.transform(utterances, message.context) + if original != utterances: + message.data["utterances"] = utterances + LOG.debug(f"utterances transformed: {original} -> {utterances}") + message.context = self.metadata_plugins.transform(message.context) + return message + + @staticmethod + def disambiguate_lang(message): + """ + Determines the most appropriate language for a query based on prioritized context keys. + + Checks for language indicators in the message context in the following order: 'stt_lang', 'request_lang', and 'detected_lang'. Returns the first valid language found that matches the enabled languages; otherwise, falls back to the default language from the message. + """ + default_lang = get_message_lang(message) + valid_langs = get_valid_languages() + valid_langs = [standardize_lang_tag(l) for l in valid_langs] + lang_keys = ["stt_lang", + "request_lang", + "detected_lang"] + for k in lang_keys: + if k in message.context: + v = standardize_lang_tag(message.context[k]) + if v in valid_langs: # TODO - use lang distance instead to choose best dialect + if v != default_lang: + LOG.info(f"replaced {default_lang} with {k}: {v}") + return v + else: + LOG.warning(f"ignoring {k}, {v} is not in enabled languages: {valid_langs}") + + return default_lang + + def get_pipeline_matcher(self, matcher_id: str): + """ + Returns the matcher function corresponding to the specified pipeline matcher ID. + + If the matcher ID is recognized, returns the appropriate callable matcher function from the loaded pipeline plugins. Returns None and logs an error if the matcher ID is unknown. + """ + migration_map = { + "converse": "ovos-converse-pipeline-plugin", + "common_qa": "ovos-common-query-pipeline-plugin", + "fallback_high": "ovos-fallback-pipeline-plugin-high", + "fallback_medium": "ovos-fallback-pipeline-plugin-medium", + "fallback_low": "ovos-fallback-pipeline-plugin-low", + "stop_high": "ovos-stop-pipeline-plugin-high", + "stop_medium": "ovos-stop-pipeline-plugin-medium", + "stop_low": "ovos-stop-pipeline-plugin-low", + "adapt_high": "ovos-adapt-pipeline-plugin-high", + "adapt_medium": "ovos-adapt-pipeline-plugin-medium", + "adapt_low": "ovos-adapt-pipeline-plugin-low", + "padacioso_high": "ovos-padacioso-pipeline-plugin-high", + "padacioso_medium": "ovos-padacioso-pipeline-plugin-medium", + "padacioso_low": "ovos-padacioso-pipeline-plugin-low", + "padatious_high": "ovos-padatious-pipeline-plugin-high", + "padatious_medium": "ovos-padatious-pipeline-plugin-medium", + "padatious_low": "ovos-padatious-pipeline-plugin-low", + "ocp_high": "ovos-ocp-pipeline-plugin-high", + "ocp_medium": "ovos-ocp-pipeline-plugin-medium", + "ocp_low": "ovos-ocp-pipeline-plugin-low", + "ocp_legacy": "ovos-ocp-pipeline-plugin-legacy" + } + + matcher_id = migration_map.get(matcher_id, matcher_id) + pipe_id = re.sub(r'-(high|medium|low)$', '', matcher_id) + plugin = self.pipeline_plugins.get(pipe_id) + if not plugin: + LOG.error(f"Unknown pipeline matcher: {matcher_id}") + return None + + if isinstance(plugin, ConfidenceMatcherPipeline): + if matcher_id.endswith("-high"): + return plugin.match_high + if matcher_id.endswith("-medium"): + return plugin.match_medium + if matcher_id.endswith("-low"): + return plugin.match_low + return plugin.match + + def get_pipeline(self, session=None) -> List[Tuple[str, Callable]]: + """ + Returns an ordered list of intent matcher functions for the current session's pipeline. + + Each matcher is paired with its identifier and filtered to exclude any invalid or missing components. The pipeline order is determined by the session configuration, and a warning is logged if any requested matchers are unavailable. + + Returns: + A list of tuples containing matcher IDs and their corresponding callable functions, ordered by priority. + """ + session = session or SessionManager.get() + matchers = [(p, self.get_pipeline_matcher(p)) for p in session.pipeline] + matchers = [m for m in matchers if m[1] is not None] # filter any that failed to load + final_pipeline = [k[0] for k in matchers] + if session.pipeline != final_pipeline: + LOG.warning(f"Requested some invalid pipeline components! " + f"filtered: {[k for k in session.pipeline if k not in final_pipeline]}") + LOG.debug(f"Session final pipeline: {final_pipeline}") + return matchers + + @staticmethod + def _validate_session(message, lang): + # get session + """ + Validates and updates the session associated with a message for the specified language. + + If the session is the default and expired, it is reset. The session language is updated if necessary, and the session's timestamp is refreshed. The session state is synchronized with the message when changes occur. + + Args: + message: The message containing session context. + lang: The language code to set for the session. + + Returns: + The validated and updated session object. + """ + lang = standardize_lang_tag(lang) + sess = SessionManager.get(message) + if sess.session_id == "default": + updated = False + # Default session, check if it needs to be (re)-created + if sess.expired(): + sess = SessionManager.reset_default_session() + updated = True + if lang != sess.lang: + sess.lang = lang + updated = True + if updated: + SessionManager.update(sess) + SessionManager.sync(message) + else: + sess.lang = lang + SessionManager.update(sess) + sess.touch() + return sess + + def _handle_deactivate(self, message): + """ + Tracks skills that request deactivation during intent matching to prevent their reactivation within the same session. + + This is relevant for pipeline matchers where skill activation occurs after a match, such as fallback and converse pipelines. + """ + sess = SessionManager.get(message) + skill_id = message.data.get("skill_id") + self._deactivations[sess.session_id].append(skill_id) + + def _emit_match_message(self, match: IntentHandlerMatch, message: Message, lang: str): + """ + Emits a reply message for a matched intent, updating session state and managing skill activation. + + Transforms the matched intent, constructs a reply message with intent details, updates the session language and context, and emits the reply on the message bus. Activates the matched skill unless it was previously deactivated in the session. Asynchronously uploads intent match metrics. If no reply is generated, uploads failure metrics instead. + """ + try: + match = self.intent_plugins.transform(match) + except Exception as e: + LOG.error(f"Error in IntentTransformers: {e}") + + reply = None + sess = match.updated_session or SessionManager.get(message) + sess.lang = lang # ensure it is updated + + # Launch intent handler + if match.match_type: + # keep all original message.data and update with intent match + data = dict(message.data) + data.update(match.match_data) + reply = message.reply(match.match_type, data) + + # upload intent metrics if enabled + create_daemon(self._upload_match_data, (match.utterance, + match.match_type, + lang, + match.match_data)) + + if reply is not None: + reply.data["utterance"] = match.utterance + reply.data["lang"] = lang + + # update active skill list + if match.skill_id: + # ensure skill_id is present in message.context + reply.context["skill_id"] = match.skill_id + + # NOTE: do not re-activate if the skill called self.deactivate + # we could also skip activation if skill is already active, + # but we still want to update the timestamp + was_deactivated = match.skill_id in self._deactivations[sess.session_id] + if not was_deactivated: + sess.activate_skill(match.skill_id) + # emit event for skills callback -> self.handle_activate + self.bus.emit(reply.forward(f"{match.skill_id}.activate")) + + # update Session if modified by pipeline + reply.context["session"] = sess.serialize() + + # finally emit reply message + self.bus.emit(reply) + + else: # upload intent metrics if enabled + create_daemon(self._upload_match_data, (match.utterance, + "complete_intent_failure", + lang, + match.match_data)) + + @staticmethod + def _upload_match_data(utterance: str, intent: str, lang: str, match_data: dict): + """ + Uploads intent match data to configured remote endpoints for metrics collection. + + If one or more upload URLs are specified in the configuration, sends the utterance, + intent, language, and match data as a POST request to each endpoint. Skips upload + if no endpoints are configured. + """ + config = Configuration().get("open_data", {}) + endpoints: List[str] = config.get("intent_urls", []) # eg. "http://localhost:8000/intents" + if not endpoints: + return # user didn't configure any endpoints to upload metrics to + if isinstance(endpoints, str): + endpoints = [endpoints] + headers = {"Content-Type": "application/x-www-form-urlencoded", + "User-Agent": config.get("user_agent", "ovos-metrics")} + data = { + "utterance": utterance, + "intent": intent, + "lang": lang, + "match_data": json.dumps(match_data, ensure_ascii=False) + } + for url in endpoints: + try: + # Add a timeout to prevent hanging + response = requests.post(url, data=data, headers=headers, timeout=3) + LOG.info(f"Uploaded intent metrics to '{url}' - Response: {response.status_code}") + except Exception as e: + LOG.warning(f"Failed to upload metrics: {e}") + + def send_cancel_event(self, message): + """ + Handles utterance cancellation by playing a cancel sound and emitting cancellation events. + + Logs the cancellation, plays a configured cancel sound, and emits events to indicate that the utterance was canceled and processing is complete. + """ + LOG.info("utterance canceled, cancel_word:" + message.context.get("cancel_word")) + # play dedicated cancel sound + sound = Configuration().get('sounds', {}).get('cancel', "snd/cancel.mp3") + # NOTE: message.reply to ensure correct message destination + self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound})) + self.bus.emit(message.reply("ovos.utterance.cancelled")) + self.bus.emit(message.reply("ovos.utterance.handled")) + + def handle_utterance(self, message: Message): + """ + Processes a user utterance message, applies transformers, matches intents using configured pipelines, and emits the appropriate response. + + The function handles utterance transformation, language disambiguation, session validation, and sequentially attempts intent matching across multiple pipelines and languages. If a match is found and not blacklisted, it emits a match message; otherwise, it signals a complete intent failure. Session state is synchronized after processing. + + Args: + message (Message): The incoming message containing user utterances and context. + + Returns: + A tuple containing the matched intent (or None), the updated message context, and a stopwatch object with timing information. + """ + # Get utterance utterance_plugins additional context + message = self._handle_transformers(message) + + if message.context.get("canceled"): + self.send_cancel_event(message) + return + + # tag language of this utterance + lang = self.disambiguate_lang(message) + + utterances = message.data.get('utterances', []) + LOG.info(f"Parsing utterance: {utterances}") + + stopwatch = Stopwatch() + + # get session + sess = self._validate_session(message, lang) + message.context["session"] = sess.serialize() + + # match + match = None + with stopwatch: + self._deactivations[sess.session_id] = [] + # Loop through the matching functions until a match is found. + for pipeline, match_func in self.get_pipeline(session=sess): + langs = [lang] + if self.config.get("multilingual_matching"): + # if multilingual matching is enabled, attempt to match all user languages if main fails + langs += [l for l in get_valid_languages() if l != lang] + for intent_lang in langs: + match = match_func(utterances, intent_lang, message) + if match: + LOG.info(f"{pipeline} match ({intent_lang}): {match}") + if match.skill_id and match.skill_id in sess.blacklisted_skills: + LOG.debug( + f"ignoring match, skill_id '{match.skill_id}' blacklisted by Session '{sess.session_id}'") + continue + if isinstance(match, IntentHandlerMatch) and match.match_type in sess.blacklisted_intents: + LOG.debug( + f"ignoring match, intent '{match.match_type}' blacklisted by Session '{sess.session_id}'") + continue + try: + self._emit_match_message(match, message, intent_lang) + break + except: + LOG.exception(f"{match_func} returned an invalid match") + else: + LOG.debug(f"no match from {match_func}") + continue + break + else: + # Nothing was able to handle the intent + # Ask politely for forgiveness for failing in this vital task + self.send_complete_intent_failure(message) + + LOG.debug(f"intent matching took: {stopwatch.time}") + + # sync any changes made to the default session, eg by ConverseService + if sess.session_id == "default": + SessionManager.sync(message) + elif sess.session_id in self._deactivations: + self._deactivations.pop(sess.session_id) + return match, message.context, stopwatch + + def send_complete_intent_failure(self, message): + """ + Emits events indicating that no skill could handle the given utterance. + + Plays an error sound and notifies listeners of the intent failure and utterance handling completion. + """ + sound = Configuration().get('sounds', {}).get('error', "snd/error.mp3") + # NOTE: message.reply to ensure correct message destination + self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound})) + self.bus.emit(message.reply('complete_intent_failure')) + self.bus.emit(message.reply("ovos.utterance.handled")) + + @staticmethod + def handle_add_context(message: Message): + """ + Adds a context entity to the current session for intent recognition. + + The context entity is defined by the provided context value and an optional alias word and origin. This enables subsequent utterances to be matched with additional contextual information. + """ + entity = {'confidence': 1.0} + context = message.data.get('context') + word = message.data.get('word') or '' + origin = message.data.get('origin') or '' + # if not a string type try creating a string from it + if not isinstance(word, str): + word = str(word) + entity['data'] = [(word, context)] + entity['match'] = word + entity['key'] = word + entity['origin'] = origin + sess = SessionManager.get(message) + sess.context.inject_context(entity) + + @staticmethod + def handle_remove_context(message: Message): + """ + Removes a specific context item from the current session. + + The context item to remove is specified in the message data under the 'context' key. + """ + context = message.data.get('context') + if context: + sess = SessionManager.get(message) + sess.context.remove_context(context) + + @staticmethod + def handle_clear_context(message: Message): + """ + Removes all context keywords from the current session. + + This clears any stored context entities, resetting the session's context state. + """ + sess = SessionManager.get(message) + sess.context.clear_context() + + def handle_get_intent(self, message): + """ + Processes an intent query for a given utterance and emits a reply with the matched intent or failure. + + Attempts to match the provided utterance against all configured intent pipelines in order. If a match is found, emits a reply message containing intent details; otherwise, emits a reply indicating no intent was matched. + """ + utterance = message.data["utterance"] + lang = get_message_lang(message) + sess = SessionManager.get(message) + match = None + # Loop through the matching functions until a match is found. + for pipeline, match_func in self.get_pipeline(session=sess): + s = time.monotonic() + match = match_func([utterance], lang, message) + LOG.debug(f"matching '{pipeline}' took: {time.monotonic() - s} seconds") + if match: + if match.match_type: + intent_data = dict(match.match_data) + intent_data["intent_name"] = match.match_type + intent_data["intent_service"] = pipeline + intent_data["skill_id"] = match.skill_id + intent_data["handler"] = match_func.__name__ + LOG.debug(f"final intent match: {intent_data}") + m = message.reply("intent.service.intent.reply", + {"intent": intent_data, "utterance": utterance}) + self.bus.emit(m) + return + LOG.error(f"bad pipeline match! {match}") + # signal intent failure + self.bus.emit(message.reply("intent.service.intent.reply", + {"intent": None, "utterance": utterance})) + + def shutdown(self): + """ + Shuts down the IntentService and its components. + + Stops all transformer services and pipeline plugins, unregisters message bus event handlers, and updates the service status to indicate it is stopping. + """ + self.utterance_plugins.shutdown() + self.metadata_plugins.shutdown() + for pipeline in self.pipeline_plugins.values(): + if hasattr(pipeline, "stop"): + try: + pipeline.stop() + except: + continue + if hasattr(pipeline, "shutdown"): + try: + pipeline.shutdown() + except: + continue + + self.bus.remove('recognizer_loop:utterance', self.handle_utterance) + self.bus.remove('add_context', self.handle_add_context) + self.bus.remove('remove_context', self.handle_remove_context) + self.bus.remove('clear_context', self.handle_clear_context) + self.bus.remove('intent.service.intent.get', self.handle_get_intent) + + self.status.set_stopping() + + +def launch_standalone(): + """ + Runs the IntentService as a standalone process. + + Initializes logging and locale, connects to the message bus, starts the IntentService, waits for an exit signal, and then shuts down the service cleanly. + """ + from ovos_bus_client import MessageBusClient + from ovos_utils import wait_for_exit_signal + from ovos_config.locale import setup_locale + from ovos_utils.log import init_service_logger + + LOG.info("Launching IntentService in standalone mode") + init_service_logger("intents") + setup_locale() + + bus = MessageBusClient() + bus.run_in_thread() + bus.connected_event.wait() + + intents = IntentService(bus) + + wait_for_exit_signal() + + intents.shutdown() + + LOG.info('IntentService shutdown complete!') + + +if __name__ == "__main__": + launch_standalone() \ No newline at end of file diff --git a/ovos_core/intent_services/stop_service.py b/ovos_core/intent_services/stop_service.py index e48463fc61f..858335849fa 100644 --- a/ovos_core/intent_services/stop_service.py +++ b/ovos_core/intent_services/stop_service.py @@ -2,31 +2,46 @@ import re from os.path import dirname from threading import Event -from typing import Optional, List +from typing import Optional, Dict, List, Union from langcodes import closest_match - +from ovos_bus_client.client import MessageBusClient from ovos_bus_client.message import Message from ovos_bus_client.session import SessionManager + from ovos_config.config import Configuration -from ovos_plugin_manager.templates.pipeline import PipelineMatch, PipelinePlugin +from ovos_plugin_manager.templates.pipeline import ConfidenceMatcherPipeline, IntentHandlerMatch from ovos_utils import flatten_list +from ovos_utils.fakebus import FakeBus from ovos_utils.bracket_expansion import expand_template from ovos_utils.lang import standardize_lang_tag -from ovos_utils.log import LOG +from ovos_utils.log import LOG, deprecated from ovos_utils.parse import match_one -class StopService(PipelinePlugin): +class StopService(ConfidenceMatcherPipeline): """Intent Service thats handles stopping skills.""" - def __init__(self, bus): - self.bus = bus + def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, + config: Optional[Dict] = None): + """ + Initializes the StopService with optional message bus and configuration. + + Loads stop-related vocabulary resources for multiple languages into a cache for intent matching. + """ + config = config or Configuration().get("skills", {}).get("stop") or {} + super().__init__(config=config, bus=bus) self._voc_cache = {} self.load_resource_files() - super().__init__(config=Configuration().get("skills", {}).get("stop") or {}) def load_resource_files(self): + """ + Loads and caches stop-related vocabulary files for all supported languages. + + Scans the locale directory for language folders, reads vocabulary files within each, + expands templates, and flattens the resulting lists. The processed vocabulary is + stored in an internal cache, organized by standardized language tags and vocabulary names. + """ base = f"{dirname(__file__)}/locale" for lang in os.listdir(base): lang2 = standardize_lang_tag(lang) @@ -51,23 +66,9 @@ def get_active_skills(message: Optional[Message] = None) -> List[str]: def _collect_stop_skills(self, message: Message) -> List[str]: """ - Collect skills that can be stopped based on a ping-pong mechanism. - - This method determines which active skills can handle a stop request by sending - a stop ping to each active skill and waiting for their acknowledgment. - - Parameters: - message (Message): The original message triggering the stop request. - - Returns: - List[str]: A list of skill IDs that can be stopped. If no skills explicitly - indicate they can stop, returns all active skills. + Identifies which active skills can be stopped by sending a stop ping and collecting acknowledgments. - Notes: - - Excludes skills that are blacklisted in the current session - - Uses a non-blocking event mechanism to collect skill responses - - Waits up to 0.5 seconds for skills to respond - - Falls back to all active skills if no explicit stop confirmation is received + Sends a stop request to each active, non-blacklisted skill and waits up to 0.5 seconds for responses indicating their ability to stop. Returns a list of skill IDs that confirm they can handle a stop request; if none explicitly confirm, returns all active skills. """ sess = SessionManager.get(message) @@ -84,21 +85,9 @@ def _collect_stop_skills(self, message: Message) -> List[str]: def handle_ack(msg): """ - Handle acknowledgment from skills during the stop process. - - This method is a nested function used in skill stopping negotiation. It validates and tracks skill responses to a stop request. - - Parameters: - msg (Message): Message containing skill acknowledgment details. + Processes acknowledgment messages from skills during the stop negotiation process. - Side Effects: - - Modifies the `want_stop` list with skills that can handle stopping - - Updates the `skill_ids` list to track which skills have responded - - Sets the threading event when all active skills have responded - - Notes: - - Checks if a skill can handle stopping based on multiple conditions - - Ensures all active skills provide a response before proceeding + Adds skills that confirm their ability to handle a stop request to the tracking list, records which skills have responded, and signals completion when all active skills have replied. """ nonlocal event, skill_ids skill_id = msg.data["skill_id"] @@ -129,79 +118,38 @@ def handle_ack(msg): self.bus.remove("skill.stop.pong", handle_ack) return want_stop or active_skills - def stop_skill(self, skill_id: str, message: Message) -> bool: + def handle_stop_confirmation(self, message: Message): """ - Stop a skill's ongoing activities and manage its session state. - - Sends a stop command to a specific skill and handles its response, ensuring - that any active interactions or processes are terminated. The method checks - for errors, verifies the skill's stopped status, and emits additional signals - to forcibly abort ongoing actions like conversations, questions, or speech. - - Args: - skill_id (str): Unique identifier of the skill to be stopped. - message (Message): The original message context containing interaction details. - - Returns: - bool: True if the skill was successfully stopped, False otherwise. + Handles confirmation responses from skills after a stop request. - Raises: - Logs error if skill stop request encounters an issue. - - Notes: - - Emits multiple bus messages to ensure complete skill termination - - Checks and handles different skill interaction states - - Supports force-stopping of conversations, questions, and speech + If the response contains an error, logs the error message. If the stop was successful, emits events to abort any ongoing question, conversation, or speech synthesis associated with the skill. """ - stop_msg = message.reply(f"{skill_id}.stop") - result = self.bus.wait_for_response(stop_msg, f"{skill_id}.stop.response") - if result and 'error' in result.data: - error_msg = result.data['error'] + skill_id = (message.data.get("skill_id") or + message.context.get("skill_id") or + message.msg_type.split(".stop.response")[0]) + if 'error' in message.data: + error_msg = message.data['error'] LOG.error(f"{skill_id}: {error_msg}") - return False - elif result is not None: - stopped = result.data.get('result', False) - else: - stopped = False - - if stopped: - sess = SessionManager.get(message) - state = sess.utterance_states.get(skill_id, "intent") - LOG.debug(f"skill response status: {state}") - if state == "response": # TODO this is never happening and it should... - LOG.debug(f"stopping {skill_id} in middle of get_response!") - + elif message.data.get('result', False): # force-kill any ongoing get_response/converse/TTS - see @killable_event decorator self.bus.emit(message.forward("mycroft.skills.abort_question", {"skill_id": skill_id})) self.bus.emit(message.forward("ovos.skills.converse.force_timeout", {"skill_id": skill_id})) # TODO - track if speech is coming from this skill! not currently tracked - self.bus.emit(message.reply("mycroft.audio.speech.stop",{"skill_id": skill_id})) + self.bus.emit(message.reply("mycroft.audio.speech.stop", {"skill_id": skill_id})) - return stopped - - def match_stop_high(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: + def match_high(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: """ - Handles high-confidence stop requests by matching exact stop vocabulary and managing skill stopping. + Performs high-confidence matching for stop commands and initiates stopping of active skills. - Attempts to stop skills when an exact "stop" or "global_stop" command is detected. Performs the following actions: - - Identifies the closest language match for vocabulary - - Checks for global stop command when no active skills exist - - Emits a global stop message if applicable - - Attempts to stop individual skills if a stop command is detected - - Disables response mode for stopped skills + Checks user utterances for exact matches to stop or global stop vocabulary in the closest supported language. If a global stop is detected and no active skills are present, emits a global stop intent. If a stop command is detected and active skills exist, attempts to stop each skill by disabling its response mode and registering a one-time listener for its stop confirmation. Returns an `IntentHandlerMatch` indicating the stop action, or None if no match is found. - Parameters: - utterances (List[str]): List of user utterances to match against stop vocabulary - lang (str): Four-letter ISO language code for language-specific matching - message (Message): Message context for generating appropriate responses + Args: + utterances: User utterances to evaluate for stop intent. + lang: Language code used for vocabulary matching. + message: Contextual message for the stop request. Returns: - Optional[PipelineMatch]: Match result indicating whether stop was handled, with optional skill and session information - - Returns None if no stop action could be performed - - Returns PipelineMatch with handled=True for successful global or skill-specific stop - - Raises: - No explicit exceptions raised, but may log debug/info messages during processing + An `IntentHandlerMatch` if a stop or global stop intent is detected and handled; otherwise, None. """ lang = self._get_closest_lang(lang) if lang is None: # no vocs registered for this lang @@ -221,47 +169,35 @@ def match_stop_high(self, utterances: List[str], lang: str, message: Message) -> if is_global_stop: LOG.info(f"Emitting global stop, {len(self.get_active_skills(message))} active skills") # emit a global stop, full stop anything OVOS is doing - self.bus.emit(message.reply("mycroft.stop", {})) - return PipelineMatch(handled=True, - match_data={"conf": conf}, - skill_id=None, - utterance=utterance) + return IntentHandlerMatch( + match_type="mycroft.stop", + match_data={"conf": conf}, + updated_session=sess, + utterance=utterance, + skill_id="stop.openvoiceos" + ) if is_stop: # check if any skill can stop for skill_id in self._collect_stop_skills(message): - LOG.debug(f"Checking if skill wants to stop: {skill_id}") - if self.stop_skill(skill_id, message): - LOG.info(f"Skill stopped: {skill_id}") - sess.disable_response_mode(skill_id) - return PipelineMatch(handled=True, - match_data={"conf": conf}, - skill_id=skill_id, - utterance=utterance, - updated_session=sess) + LOG.debug(f"Telling skill to stop: {skill_id}") + sess.disable_response_mode(skill_id) + self.bus.once(f"{skill_id}.stop.response", self.handle_stop_confirmation) + return IntentHandlerMatch( + match_type=f"{skill_id}.stop", + match_data={"conf": conf}, + updated_session=sess, + utterance=utterance, + skill_id="stop.openvoiceos" + ) + return None - def match_stop_medium(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: + def match_medium(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: """ - Handle stop intent with additional context beyond simple stop commands. - - This method processes utterances that contain "stop" or global stop vocabulary but may include - additional words not explicitly defined in intent files. It performs a medium-confidence - intent matching for stop requests. + Performs medium-confidence matching for stop intents with fuzzy vocabulary analysis. - Parameters: - utterances (List[str]): List of input utterances to analyze - lang (str): Four-letter ISO language code for localization - message (Message): Message context for generating appropriate responses - - Returns: - Optional[PipelineMatch]: A pipeline match if the stop intent is successfully processed, - otherwise None if no stop intent is detected - - Notes: - - Attempts to match stop vocabulary with fuzzy matching - - Falls back to low-confidence matching if medium-confidence match is inconclusive - - Handles global stop scenarios when no active skills are present + Analyzes utterances for stop or global stop commands using fuzzy matching, allowing for additional context or words beyond exact stop phrases. If a medium-confidence match is not found, falls back to low-confidence matching. Returns an intent match if a stop intent is detected, or None otherwise. """ lang = self._get_closest_lang(lang) if lang is None: # no vocs registered for this lang @@ -277,39 +213,21 @@ def match_stop_medium(self, utterances: List[str], lang: str, message: Message) if not is_global_stop: return None - return self.match_stop_low(utterances, lang, message) + return self.match_low(utterances, lang, message) - def _get_closest_lang(self, lang: str) -> Optional[str]: - if self._voc_cache: - lang = standardize_lang_tag(lang) - closest, score = closest_match(lang, list(self._voc_cache.keys())) - # https://langcodes-hickford.readthedocs.io/en/sphinx/index.html#distance-values - # 0 -> These codes represent the same language, possibly after filling in values and normalizing. - # 1- 3 -> These codes indicate a minor regional difference. - # 4 - 10 -> These codes indicate a significant but unproblematic regional difference. - if score < 10: - return closest - return None - - def match_stop_low(self, utterances: List[str], lang: str, message: Message) -> Optional[PipelineMatch]: + def match_low(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentHandlerMatch]: """ - Perform a low-confidence fuzzy match for stop intent before fallback processing. + Performs a low-confidence fuzzy match for stop intent and initiates stopping of active skills. - This method attempts to match stop-related vocabulary with low confidence and handle stopping of active skills. + Attempts to match user utterances against stop-related vocabulary with low confidence. If the confidence threshold is met, disables response mode for stoppable skills and registers for their stop confirmation. If no skills respond, emits a global stop intent. Returns an intent handler match if a stop action is handled, otherwise None. - Parameters: - utterances (List[str]): List of input utterances to match against stop vocabulary - lang (str): Four-letter ISO language code for vocabulary matching - message (Message): Message context used for generating replies and managing session + Args: + utterances: List of user utterances to evaluate for stop intent. + lang: ISO language code for vocabulary matching. + message: Message context for session and reply management. Returns: - Optional[PipelineMatch]: A pipeline match object if a stop action is handled, otherwise None - - Notes: - - Increases confidence if active skills are present - - Attempts to stop individual skills before emitting a global stop signal - - Handles language-specific vocabulary matching - - Configurable minimum confidence threshold for stop intent + An IntentHandlerMatch if a stop action is handled; otherwise, None. """ lang = self._get_closest_lang(lang) if lang is None: # no vocs registered for this lang @@ -328,51 +246,60 @@ def match_stop_low(self, utterances: List[str], lang: str, message: Message) -> # check if any skill can stop for skill_id in self._collect_stop_skills(message): - LOG.debug(f"Checking if skill wants to stop: {skill_id}") - if self.stop_skill(skill_id, message): - sess.disable_response_mode(skill_id) - return PipelineMatch(handled=True, - match_data={"conf": conf}, - skill_id=skill_id, - utterance=utterance, - updated_session=sess) + LOG.debug(f"Telling skill to stop: {skill_id}") + sess.disable_response_mode(skill_id) + self.bus.once(f"{skill_id}.stop.response", self.handle_stop_confirmation) + return IntentHandlerMatch( + match_type=f"{skill_id}.stop", + match_data={"conf": conf}, + updated_session=sess, + utterance=utterance, + skill_id="stop.openvoiceos" + ) # emit a global stop, full stop anything OVOS is doing LOG.debug(f"Emitting global stop signal, {len(self.get_active_skills(message))} active skills") - self.bus.emit(message.reply("mycroft.stop", {})) - return PipelineMatch(handled=True, - # emit instead of intent message {"conf": conf}, - match_data={"conf": conf}, - skill_id=None, - utterance=utterance) + return IntentHandlerMatch( + match_type="mycroft.stop", + match_data={"conf": conf}, + updated_session=sess, + utterance=utterance, + skill_id="stop.openvoiceos" + ) - def voc_match(self, utt: str, voc_filename: str, lang: str, - exact: bool = False): + def _get_closest_lang(self, lang: str) -> Optional[str]: """ - TODO - should use ovos_workshop method instead of reimplementing here - look into subclassing from OVOSAbstractApp - - Determine if the given utterance contains the vocabulary provided. - - By default the method checks if the utterance contains the given vocab - thereby allowing the user to say things like "yes, please" and still - match against "Yes.voc" containing only "yes". An exact match can be - requested. - - The method first checks in the current Skill's .voc files and secondly - in the "res/text" folder of mycroft-core. The result is cached to - avoid hitting the disk each time the method is called. - - Args: - utt (str): Utterance to be tested - voc_filename (str): Name of vocabulary file (e.g. 'yes' for - 'res/text/en-us/yes.voc') - lang (str): Language code, defaults to self.lang - exact (bool): Whether the vocab must exactly match the utterance + Finds the closest matching language tag from the vocabulary cache. + + Returns the closest language tag if the match score is less than 10, indicating a significant but acceptable regional difference; otherwise, returns None. + """ + if self._voc_cache: + lang = standardize_lang_tag(lang) + closest, score = closest_match(lang, list(self._voc_cache.keys())) + # https://langcodes-hickford.readthedocs.io/en/sphinx/index.html#distance-values + # 0 -> These codes represent the same language, possibly after filling in values and normalizing. + # 1- 3 -> These codes indicate a minor regional difference. + # 4 - 10 -> These codes indicate a significant but unproblematic regional difference. + if score < 10: + return closest + return None - Returns: - bool: True if the utterance has the given vocabulary it + def voc_match(self, utt: str, voc_filename: str, lang: str, + exact: bool = False): """ + Checks if an utterance matches vocabulary from cached files for a given language. + + Searches the cached vocabulary for the specified language and file, supporting exact or partial word boundary matching. Returns True if the utterance matches any vocabulary entry; otherwise, returns False. + + Args: + utt: The utterance to test. + voc_filename: The base name of the vocabulary file (without extension). + lang: The language code to use for matching. + exact: If True, requires an exact match; otherwise, matches on word boundaries. + + Returns: + True if the utterance matches the vocabulary; False otherwise. + """ lang = self._get_closest_lang(lang) if lang is None: # no vocs registered for this lang return False @@ -389,3 +316,4 @@ def voc_match(self, utt: str, voc_filename: str, lang: str, return any([re.match(r'.*\b' + i + r'\b.*', utt, re.IGNORECASE) for i in _vocs]) return False + diff --git a/ovos_core/skill_manager.py b/ovos_core/skill_manager.py index 059da67b84e..df838c9a0dc 100644 --- a/ovos_core/skill_manager.py +++ b/ovos_core/skill_manager.py @@ -14,49 +14,28 @@ # """Load, update and manage skills on this device.""" import os -from os.path import basename +import threading from threading import Thread, Event, Lock -from time import monotonic from ovos_bus_client.apis.enclosure import EnclosureAPI from ovos_bus_client.client import MessageBusClient from ovos_bus_client.message import Message from ovos_config.config import Configuration from ovos_config.locations import get_xdg_config_save_path -from ovos_plugin_manager.skills import find_skill_plugins -from ovos_plugin_manager.skills import get_skill_directories from ovos_utils.file_utils import FileWatcher from ovos_utils.gui import is_gui_connected -from ovos_utils.log import LOG, deprecated +from ovos_utils.log import LOG from ovos_utils.network_utils import is_connected_http from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap, ProcessState -from ovos_workshop.skill_launcher import SKILL_MAIN_MODULE -from ovos_workshop.skill_launcher import SkillLoader, PluginSkillLoader -import warnings - - -def _shutdown_skill(instance): - """Shutdown a skill. - - Call the default_shutdown method of the skill, will produce a warning if - the shutdown process takes longer than 1 second. +from ovos_workshop.skill_launcher import PluginSkillLoader - Args: - instance (MycroftSkill): Skill instance to shutdown - """ - try: - ref_time = monotonic() - # Perform the shutdown - instance.default_shutdown() - - shutdown_time = monotonic() - ref_time - if shutdown_time > 1: - LOG.warning(f'{instance.skill_id} shutdown took {shutdown_time} seconds') - except Exception: - LOG.exception(f'Failed to shut down skill: {instance.skill_id}') +from ovos_plugin_manager.skills import find_skill_plugins def on_started(): + """ + Logs that the Skills Manager is starting up. + """ LOG.info('Skills Manager is starting up.') @@ -81,17 +60,11 @@ class SkillManager(Thread): def __init__(self, bus, watchdog=None, alive_hook=on_alive, started_hook=on_started, ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping): - """Constructor - - Args: - bus (event emitter): Mycroft messagebus connection - watchdog (callable): optional watchdog function - alive_hook (callable): callback function for skill alive status - started_hook (callable): callback function for skill started status - ready_hook (callable): callback function for skill ready status - error_hook (callable): callback function for skill error status - stopping_hook (callable): callback function for skill stopping status """ + Initializes the SkillManager thread for managing plugin skill lifecycles. + + Sets up status callbacks, event synchronization primitives, configuration, and internal data structures for plugin skill management. Registers message bus event handlers, initializes a file watcher for skill settings changes, and binds process status to the message bus. Marks the thread as a daemon for asynchronous operation. + """ super(SkillManager, self).__init__() self.bus = bus self._settings_watchdog = None @@ -105,7 +78,6 @@ def __init__(self, bus, watchdog=None, alive_hook=on_alive, started_hook=on_star self.status = ProcessStatus('skills', callback_map=callbacks) self.status.set_started() - self._lock = Lock() self._setup_event = Event() self._stop_event = Event() self._connected_event = Event() @@ -124,7 +96,6 @@ def __init__(self, bus, watchdog=None, alive_hook=on_alive, started_hook=on_star self.config = Configuration() - self.skill_loaders = {} self.plugin_skills = {} self.enclosure = EnclosureAPI(bus) self.num_install_retries = 0 @@ -138,16 +109,20 @@ def __init__(self, bus, watchdog=None, alive_hook=on_alive, started_hook=on_star @property def blacklist(self): - """Get the list of blacklisted skills from the configuration. - + """ + Returns the list of skill IDs that are blacklisted in the configuration. + Returns: - list: List of blacklisted skill ids. + list: Blacklisted skill IDs. """ - return Configuration().get("skills", {}).get("blacklisted_skills", - ["skill-ovos-stop.openvoiceos"]) + return Configuration().get("skills", {}).get("blacklisted_skills", []) def _init_filewatcher(self): - """Initialize the file watcher to monitor skill settings files for changes.""" + """ + Initializes a file watcher to monitor skill settings files for changes. + + Sets up a file watcher on the skills settings directory to detect modifications, triggering a callback when a skill's settings file changes. + """ sspath = f"{get_xdg_config_save_path()}/skills/" os.makedirs(sspath, exist_ok=True) self._settings_watchdog = FileWatcher([sspath], @@ -283,11 +258,17 @@ def handle_network_connected(self, message): self._load_on_network() def load_plugin_skills(self, network=None, internet=None): - """Load plugin skills based on network and internet status. - + """ + Loads new plugin skills according to current network and internet connectivity. + + If a skill is blacklisted, it is skipped and a warning is logged. Only skills whose runtime requirements are satisfied by the current connectivity state are loaded. Returns True if any new skills were loaded. + Args: - network (bool): Network connection status. - internet (bool): Internet connection status. + network: If specified, overrides the detected network connection status. + internet: If specified, overrides the detected internet connection status. + + Returns: + True if any new plugin skills were loaded; otherwise, False. """ loaded_new = False if network is None: @@ -295,7 +276,6 @@ def load_plugin_skills(self, network=None, internet=None): if internet is None: internet = self._connected_event.is_set() plugins = find_skill_plugins() - loaded_skill_ids = [basename(p) for p in self.skill_loaders] for skill_id, plug in plugins.items(): if skill_id in self.blacklist: if skill_id not in self._logged_skill_warnings: @@ -303,7 +283,7 @@ def load_plugin_skills(self, network=None, internet=None): LOG.warning(f"{skill_id} is blacklisted, it will NOT be loaded") LOG.info(f"Consider uninstalling {skill_id} instead of blacklisting it") continue - if skill_id not in self.plugin_skills and skill_id not in loaded_skill_ids: + if skill_id not in self.plugin_skills: skill_loader = self._get_plugin_skill_loader(skill_id, init_bus=False, skill_class=plug) requirements = skill_loader.runtime_requirements @@ -351,14 +331,17 @@ def _get_plugin_skill_loader(self, skill_id, init_bus=True, skill_class=None): return loader def _load_plugin_skill(self, skill_id, skill_plugin): - """Load a plugin skill. - + """ + Attempts to load a plugin skill and registers its loader. + + If loading fails, logs the exception and still registers the loader in the internal dictionary. + Args: - skill_id (str): ID of the skill. - skill_plugin: Plugin skill instance. - + skill_id: The unique identifier of the skill. + skill_plugin: The plugin skill class or instance to be loaded. + Returns: - PluginSkillLoader: Loaded plugin skill loader instance if successful, None otherwise. + The PluginSkillLoader instance if the skill was loaded successfully, or None if loading failed. """ skill_loader = self._get_plugin_skill_loader(skill_id, skill_class=skill_plugin) try: @@ -371,10 +354,33 @@ def _load_plugin_skill(self, skill_id, skill_plugin): return skill_loader if load_status else None + def wait_for_intent_service(self): + """ + Blocks execution until the IntentService reports readiness to receive skill messages. + + This method repeatedly queries the IntentService via the message bus and waits until + a positive readiness response is received before returning. + """ + response = self.bus.wait_for_response( + Message(f'mycroft.intents.is_ready', + context={"source": "skills", "destination": "intents"})) + if response and response.data['status']: + return + threading.Event().wait(1) + self.wait_for_intent_service() + def run(self): - """Run the skill manager thread.""" + """ + Main loop for the SkillManager thread, handling skill loading and lifecycle events. + + Waits for the IntentService to become ready, loads offline skills, synchronizes skill loading state, emits initialization events, and periodically checks for new or updated skills. Continues running until signaled to stop. + """ self.status.set_alive() + LOG.debug("Waiting for IntentService startup") + self.wait_for_intent_service() + LOG.debug("IntentService reported ready") + self._load_on_startup() # trigger a sync so we dont need to wait for the plugin to volunteer info @@ -397,7 +403,6 @@ def run(self): # unload the existing version from memory and reload from the disk. while not self._stop_event.wait(30): try: - self._unload_removed_skills() self._load_new_skills() self._watchdog() except Exception: @@ -421,54 +426,47 @@ def _load_on_internet(self): self._network_loaded.set() def _unload_on_network_disconnect(self): - """Unload skills that require a network connection to work.""" - with self._lock: - for skill_dir in self._get_skill_directories(): - skill_id = os.path.basename(skill_dir) - skill_loader = self._get_skill_loader(skill_dir, init_bus=False) - requirements = skill_loader.runtime_requirements - if requirements.requires_network and \ - not requirements.no_network_fallback: - # Unload skills until the network is back - self._unload_skill(skill_dir) + """ + Placeholder for unloading skills that require a network connection when disconnected. + + Currently not implemented. + """ + # TODO - implementation missing def _unload_on_internet_disconnect(self): - """Unload skills that require an internet connection to work.""" - with self._lock: - for skill_dir in self._get_skill_directories(): - skill_id = os.path.basename(skill_dir) - skill_loader = self._get_skill_loader(skill_dir, init_bus=False) - requirements = skill_loader.runtime_requirements - if requirements.requires_internet and \ - not requirements.no_internet_fallback: - # Unload skills until the internet is back - self._unload_skill(skill_dir) + """ + Placeholder for unloading skills that require an internet connection when connectivity is lost. + """ + # TODO - implementation missing def _unload_on_gui_disconnect(self): - """Unload skills that require a GUI to work.""" - with self._lock: - for skill_dir in self._get_skill_directories(): - skill_id = os.path.basename(skill_dir) - skill_loader = self._get_skill_loader(skill_dir, init_bus=False) - requirements = skill_loader.runtime_requirements - if requirements.requires_gui and \ - not requirements.no_gui_fallback: - # Unload skills until the GUI is back - self._unload_skill(skill_dir) + """ + Placeholder for unloading skills that require a GUI when the GUI disconnects. + + This method is not yet implemented. + """ + # TODO - implementation missing def _load_on_startup(self): - """Handle offline skills load on startup.""" + """ + Loads all offline plugin skills during startup. + + This method checks for installed skills and initiates loading of skills that do not require network or internet connectivity. + """ if self._detected_installed_skills: # ensure we have skills installed LOG.info('Loading offline skills...') self._load_new_skills(network=False, internet=False) def _load_new_skills(self, network=None, internet=None, gui=None): - """Handle loading of skills installed since startup. - + """ + Loads any newly installed plugin skills based on current connectivity status. + + If new skills are loaded, triggers intent training and logs the outcome. + Args: - network (bool): Network connection status. - internet (bool): Internet connection status. - gui (bool): GUI connection status. + network: Optional; current network connection status. + internet: Optional; current internet connection status. + gui: Optional; current GUI connection status. """ if network is None: network = self._network_event.is_set() @@ -477,170 +475,29 @@ def _load_new_skills(self, network=None, internet=None, gui=None): if gui is None: gui = self._gui_event.is_set() or is_gui_connected(self.bus) - # A lock is used because this can be called via state events or as part of the main loop. - # There is a possible race condition where this handler would be executing several times otherwise. - with self._lock: - - loaded_new = self.load_plugin_skills(network=network, internet=internet) - - for skill_dir in self._get_skill_directories(): - replaced_skills = [] - skill_id = os.path.basename(skill_dir) - skill_loader = self._get_skill_loader(skill_dir, init_bus=False) - requirements = skill_loader.runtime_requirements - if not network and requirements.network_before_load: - continue - if not internet and requirements.internet_before_load: - continue - if not gui and requirements.gui_before_load: - # TODO - companion PR adding this one - continue - - # A local source install is replacing this plugin, unload it! - if skill_id in self.plugin_skills: - LOG.info(f"{skill_id} plugin will be replaced by a local version: {skill_dir}") - self._unload_plugin_skill(skill_id) - - for old_skill_dir, skill_loader in self.skill_loaders.items(): - if old_skill_dir != skill_dir and \ - skill_loader.skill_id == skill_id: - # A higher priority equivalent has been detected! - replaced_skills.append(old_skill_dir) - - for old_skill_dir in replaced_skills: - # Unload the old skill - self._unload_skill(old_skill_dir) - - if skill_dir not in self.skill_loaders: - self._load_skill(skill_dir) - loaded_new = True + loaded_new = self.load_plugin_skills(network=network, internet=internet) if loaded_new: - LOG.info("Requesting padatious intent training") + LOG.debug("Requesting pipeline intent training") try: response = self.bus.wait_for_response(Message("mycroft.skills.train"), "mycroft.skills.trained", timeout=60) # 60 second timeout if not response: - LOG.error("Padatious training timed out") + LOG.error("Intent training timed out") elif response.data.get('error'): - LOG.error(f"Padatious training failed: {response.data['error']}") + LOG.error(f"Intent training failed: {response.data['error']}") + else: + LOG.debug(f"pipelines trained and ready to go") except Exception as e: - LOG.exception(f"Error during padatious training: {e}") - - def _get_skill_loader(self, skill_directory, init_bus=True): - """Get a skill loader instance. - - Args: - skill_directory (str): Directory path of the skill. - init_bus (bool): Whether to initialize the internal skill bus. - - Returns: - SkillLoader: Skill loader instance. - """ - bus = None - if init_bus: - bus = self._get_internal_skill_bus() - return SkillLoader(bus, skill_directory) - - def _load_skill(self, skill_directory): - """Load an old-style skill. - - Args: - skill_directory (str): Directory path of the skill. - - Returns: - SkillLoader: Loaded skill loader instance if successful, None otherwise. - """ - LOG.warning(f"Found deprecated skill directory: {skill_directory}\n" - f"please create a setup.py for this skill") - skill_id = basename(skill_directory) - if skill_id in self.blacklist: - if skill_id not in self._logged_skill_warnings: - self._logged_skill_warnings.append(skill_id) - LOG.warning(f"{skill_id} is blacklisted, it will NOT be loaded") - LOG.info(f"Consider deleting {skill_directory} instead of blacklisting it") - return None - - skill_loader = self._get_skill_loader(skill_directory) - try: - load_status = skill_loader.load() - except Exception: - LOG.exception(f'Load of skill {skill_directory} failed!') - load_status = False - finally: - self.skill_loaders[skill_directory] = skill_loader - if load_status: - LOG.info(f"Loaded old style skill: {skill_directory}") - else: - LOG.error(f"Failed to load old style skill: {skill_directory}") - return skill_loader if load_status else None - - def _unload_skill(self, skill_dir): - """Unload a skill. - - Args: - skill_dir (str): Directory path of the skill. - """ - if skill_dir in self.skill_loaders: - skill = self.skill_loaders[skill_dir] - LOG.info(f'Removing {skill.skill_id}') - try: - skill.unload() - except Exception: - LOG.exception('Failed to shutdown skill ' + skill.id) - del self.skill_loaders[skill_dir] - - def _get_skill_directories(self): - """Get valid skill directories. - - Returns: - list: List of valid skill directories. - """ - skillmap = {} - valid_skill_roots = ["/opt/mycroft/skills"] + get_skill_directories() - for skills_dir in valid_skill_roots: - if not os.path.isdir(skills_dir): - continue - for skill_id in os.listdir(skills_dir): - skill = os.path.join(skills_dir, skill_id) - # NOTE: empty folders mean the skill should NOT be loaded - if os.path.isdir(skill): - skillmap[skill_id] = skill - - for skill_id, skill_dir in skillmap.items(): - # TODO: all python packages must have __init__.py! Better way? - # check if folder is a skill (must have __init__.py) - if SKILL_MAIN_MODULE in os.listdir(skill_dir): - if skill_dir in self.empty_skill_dirs: - self.empty_skill_dirs.discard(skill_dir) - else: - if skill_dir not in self.empty_skill_dirs: - self.empty_skill_dirs.add(skill_dir) - LOG.debug('Found skills directory with no skill: ' + - skill_dir) - - return skillmap.values() - - def _unload_removed_skills(self): - """Shutdown removed skills. - - Finds and unloads skills that were removed from the disk. - """ - skill_dirs = self._get_skill_directories() - # Find loaded skills that don't exist on disk - removed_skills = [ - s for s in self.skill_loaders.keys() if s not in skill_dirs - ] - for skill_dir in removed_skills: - self._unload_skill(skill_dir) - return removed_skills + LOG.exception(f"Error during Intent training: {e}") def _unload_plugin_skill(self, skill_id): - """Unload a plugin skill. - + """ + Unloads a plugin skill by shutting it down and removing it from the manager. + Args: - skill_id (str): Identifier of the plugin skill to unload. + skill_id (str): The identifier of the plugin skill to unload. """ if skill_id in self.plugin_skills: LOG.info('Unloading plugin skill: ' + skill_id) @@ -661,12 +518,15 @@ def is_all_loaded(self, message=None): return self.status.state == ProcessState.READY def send_skill_list(self, message=None): - """Send list of loaded skills.""" + """ + Emits a message containing the list of currently loaded plugin skills and their active status. + + The message is sent on the bus with the type 'mycroft.skills.list' and includes each skill's ID and whether it is active and loaded. + """ try: message_data = {} # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for - skills = {**self.skill_loaders, **self.plugin_skills} - + skills = self.plugin_skills for skill_loader in skills.values(): message_data[skill_loader.skill_id] = { "active": skill_loader.active and skill_loader.loaded, @@ -677,10 +537,14 @@ def send_skill_list(self, message=None): LOG.exception('Failed to send skill list') def deactivate_skill(self, message): - """Deactivate a skill.""" + """ + Deactivates a specified plugin skill in response to a message. + + If the skill is found, it is deactivated and a response is emitted on the message bus. If deactivation fails, an error response is emitted. + """ try: # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for - skills = {**self.skill_loaders, **self.plugin_skills} + skills = self.plugin_skills for skill_loader in skills.values(): if message.data['skill'] == skill_loader.skill_id: LOG.info("Deactivating skill: " + skill_loader.skill_id) @@ -691,12 +555,16 @@ def deactivate_skill(self, message): self.bus.emit(message.response({'error': f'failed: {err}'})) def deactivate_except(self, message): - """Deactivate all skills except the provided.""" + """ + Deactivates all plugin skills except the specified one. + + The skill to remain active is identified by the 'skill' field in the message data. + """ try: skill_to_keep = message.data['skill'] LOG.info(f'Deactivating all skills except {skill_to_keep}') # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for - skills = {**self.skill_loaders, **self.plugin_skills} + skills = self.plugin_skills for skill in skills.values(): if skill.skill_id != skill_to_keep: skill.deactivate() @@ -705,10 +573,14 @@ def deactivate_except(self, message): LOG.exception('An error occurred during skill deactivation!') def activate_skill(self, message): - """Activate a deactivated skill.""" + """ + Activates a specified deactivated plugin skill or all plugin skills. + + If the skill name in the message is "all", all inactive plugin skills are activated. Emits a response message upon activation or if an error occurs. + """ try: # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for - skills = {**self.skill_loaders, **self.plugin_skills} + skills = self.plugin_skills for skill_loader in skills.values(): if (message.data['skill'] in ('all', skill_loader.skill_id) and not skill_loader.active): @@ -719,68 +591,17 @@ def activate_skill(self, message): self.bus.emit(message.response({'error': f'failed: {err}'})) def stop(self): - """Tell the manager to shutdown.""" + """ + Signals the skill manager to stop and performs a clean shutdown of all plugin skills. + + Shuts down all loaded plugin skills and the settings file watcher if active. + """ self.status.set_stopping() self._stop_event.set() # Do a clean shutdown of all skills - for skill_loader in self.skill_loaders.values(): - if skill_loader.instance is not None: - _shutdown_skill(skill_loader.instance) - - # Do a clean shutdown of all plugin skills for skill_id in list(self.plugin_skills.keys()): self._unload_plugin_skill(skill_id) if self._settings_watchdog: self._settings_watchdog.shutdown() - - ############ - # Deprecated stuff - @deprecated("priority skills have been deprecated for a long time", "1.0.0") - def load_priority(self): - warnings.warn( - "priority skills have been deprecated", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("mycroft.ready event has moved to finished booting skill", "1.0.0") - def is_device_ready(self): - """Check if the device is ready by waiting for various services to start. - - Returns: - bool: True if the device is ready, False otherwise. - Raises: - TimeoutError: If the device is not ready within a specified timeout. - """ - warnings.warn( - "mycroft.ready event has moved to finished booting skill", - DeprecationWarning, - stacklevel=2, - ) - return True - - @deprecated("mycroft.ready event has moved to finished booting skill", "1.0.0") - def handle_check_device_readiness(self, message): - warnings.warn( - "mycroft.ready event has moved to finished booting skill", - DeprecationWarning, - stacklevel=2, - ) - - @deprecated("mycroft.ready event has moved to finished booting skill", "1.0.0") - def check_services_ready(self, services): - """Report if all specified services are ready. - - Args: - services (iterable): Service names to check. - Returns: - bool: True if all specified services are ready, False otherwise. - """ - warnings.warn( - "mycroft.ready event has moved to finished booting skill", - DeprecationWarning, - stacklevel=2, - ) - return True diff --git a/ovos_core/transformers.py b/ovos_core/transformers.py index 3bd10887265..1ee2274e6a7 100644 --- a/ovos_core/transformers.py +++ b/ovos_core/transformers.py @@ -1,10 +1,10 @@ -from typing import Optional, List, Union +from typing import Optional, List from ovos_config import Configuration from ovos_plugin_manager.intent_transformers import find_intent_transformer_plugins from ovos_plugin_manager.metadata_transformers import find_metadata_transformer_plugins from ovos_plugin_manager.text_transformers import find_utterance_transformer_plugins -from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch, PipelineMatch +from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch from ovos_utils.json_helper import merge_dict from ovos_utils.log import LOG @@ -195,7 +195,7 @@ def plugins(self): def shutdown(self): """ - Shuts down all loaded plugins, suppressing any exceptions raised during shutdown. + Shuts down all loaded plugins, ignoring any exceptions that occur during shutdown. """ for module in self.plugins: try: @@ -203,17 +203,11 @@ def shutdown(self): except: pass - def transform(self, intent: Union[IntentHandlerMatch, PipelineMatch]) -> Union[IntentHandlerMatch, PipelineMatch]: + def transform(self, intent: IntentHandlerMatch) -> IntentHandlerMatch: """ - Sequentially applies all loaded intent transformer plugins to the given intent object. - - Each plugin's `transform` method is called in order of priority. Exceptions raised by individual plugins are logged as warnings, and processing continues with the next plugin. The final, transformed intent object is returned. - - Args: - intent: The intent match object to be transformed. - - Returns: - The transformed intent match object after all plugins have been applied. + Applies all loaded intent transformer plugins in sequence to an intent match object. + + Each plugin's `transform` method is invoked in descending order of priority. If a plugin raises an exception, it is logged as a warning and processing continues with the next plugin. Returns the final transformed intent match object. """ for module in self.plugins: try: diff --git a/requirements/lgpl.txt b/requirements/lgpl.txt index a69b6b849b6..106b0b899ca 100644 --- a/requirements/lgpl.txt +++ b/requirements/lgpl.txt @@ -1,2 +1,2 @@ -ovos_padatious>=1.1.0, <2.0.0 -fann2>=1.0.7, < 1.1.0 +ovos_padatious>=1.4.2,<2.0.0 +fann2>=1.0.7,<1.1.0 diff --git a/requirements/mycroft.txt b/requirements/mycroft.txt index 433a17466f8..ff4ad1a3184 100644 --- a/requirements/mycroft.txt +++ b/requirements/mycroft.txt @@ -1,6 +1,7 @@ # all ovos core modules, a full install like mycroft-core used to do -ovos_PHAL[extras]>=0.2.7,<1.0.0 -ovos-audio[extras]>=0.3.1,<1.0.0 -ovos-gui[extras]>=0.2.2,<2.0.0 +ovos_PHAL[extras]>=0.2.9,<1.0.0 +ovos-audio[extras]>=1.0.1,<2.0.0 +ovos-audio>=1.0.1,<2.0.0 +ovos-gui[extras]>=1.3.3,<2.0.0 ovos-messagebus>=0.0.7,<1.0.0 -ovos-dinkum-listener[extras]>=0.3.2,<1.0.0 \ No newline at end of file +ovos-dinkum-listener[extras]>=0.4.1,<1.0.0 \ No newline at end of file diff --git a/requirements/plugins.txt b/requirements/plugins.txt index 816c8fcfd7f..2ac495659ed 100644 --- a/requirements/plugins.txt +++ b/requirements/plugins.txt @@ -2,10 +2,18 @@ ovos-utterance-corrections-plugin>=0.0.2, <1.0.0 ovos-utterance-plugin-cancel>=0.2.3, <1.0.0 ovos-bidirectional-translation-plugin>=0.1.0, <1.0.0 ovos-translate-server-plugin>=0.0.2, <1.0.0 -ovos-utterance-normalizer>=0.2.1, <1.0.0 +ovos-utterance-normalizer>=0.2.2, <1.0.0 ovos-number-parser>=0.0.1,<1.0.0 ovos-date-parser>=0.0.3,<1.0.0 -ovos-m2v-pipeline>=0.0.5,<1.0.0 -ovos-ollama-intent-pipeline-plugin>=0.0.1,<1.0.0 + +# pipeline plugins +ovos-m2v-pipeline>=0.0.6,<1.0.0 +ovos-common-query-pipeline-plugin>=1.1.8, <2.0.0 +ovos-adapt-parser>=1.0.6, <2.0.0 +ovos_ocp_pipeline_plugin>=1.1.16, <2.0.0 +ovos-persona>=0.6.23,<1.0.0 +padacioso>=1.0.0, <2.0.0 + +# intent transformer plugins keyword-template-matcher>=0.0.1,<1.0.0 ahocorasick-ner>=0.0.1,<1.0.0 \ No newline at end of file diff --git a/requirements/requirements.txt b/requirements/requirements.txt index c3d6fb7ffda..9bf7cc18c6d 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -3,14 +3,8 @@ python-dateutil>=2.6, <3.0 watchdog>=2.1, <3.0 combo-lock>=0.2.2, <0.4 -padacioso>=1.0.0, <2.0.0 -ovos-adapt-parser>=1.0.5, <2.0.0 -ovos_ocp_pipeline_plugin>=1.0.10, <2.0.0 -ovos-common-query-pipeline-plugin>=1.0.5,<2.0.0 -ovos-persona>=0.4.4,<1.0.0 - ovos-utils[extras]>=0.6.0,<1.0.0 ovos_bus_client>=0.1.4,<2.0.0 -ovos-plugin-manager>=0.9.0,<1.0.0 +ovos-plugin-manager>=1.0.3,<2.0.0 ovos-config>=0.0.13,<2.0.0 -ovos-workshop>=3.3.4,<4.0.0 +ovos-workshop>=7.0.2,<8.0.0 diff --git a/requirements/skills-audio.txt b/requirements/skills-audio.txt index 3ccf0f146f6..4db9c465036 100644 --- a/requirements/skills-audio.txt +++ b/requirements/skills-audio.txt @@ -3,5 +3,5 @@ ovos-skill-boot-finished>=0.4.8,<1.0.0 ovos-skill-audio-recording>=0.2.4,<1.0.0 ovos-skill-dictation>=0.2.5,<1.0.0 ovos-skill-parrot>=0.1.9,<1.0.0 -ovos-skill-volume>=0.1.7,<1.0.0 -ovos-skill-naptime>=0.3.8,<1.0.0 +ovos-skill-volume>=0.1.16,<1.0.0 +ovos-skill-naptime>=0.3.15,<1.0.0 diff --git a/requirements/skills-desktop.txt b/requirements/skills-desktop.txt index 822399564f6..35c09b68cf1 100644 --- a/requirements/skills-desktop.txt +++ b/requirements/skills-desktop.txt @@ -1,4 +1,4 @@ # skills that require a linux desktop environment -ovos-skill-application-launcher>=0.5.6,<1.0.0 +ovos-skill-application-launcher>=0.5.14,<1.0.0 ovos-skill-wallpapers>=1.0.2,<3.0.0 ovos-skill-screenshot>=0.0.2,<1.0.0 diff --git a/requirements/skills-en.txt b/requirements/skills-en.txt index 09714f0e766..35507b62e2a 100644 --- a/requirements/skills-en.txt +++ b/requirements/skills-en.txt @@ -1,2 +1,4 @@ # skills providing english specific functionality ovos-skill-word-of-the-day +# skills below need translation before they are moved to skill-extras.txt +ovos-skill-days-in-history>=0.3.11,<1.0.0 diff --git a/requirements/skills-essential.txt b/requirements/skills-essential.txt index a2db9274f22..0a506f4f13b 100644 --- a/requirements/skills-essential.txt +++ b/requirements/skills-essential.txt @@ -1,8 +1,8 @@ # skills providing core functionality (offline) -ovos-skill-fallback-unknown>=0.1.5,<1.0.0 +ovos-skill-fallback-unknown>=0.1.9,<1.0.0 ovos-skill-alerts>=0.1.10,<1.0.0 -ovos-skill-personal>=0.1.7,<1.0.0 -ovos-skill-date-time>=0.4.2,<2.0.0 +ovos-skill-personal>=0.1.19,<1.0.0 +ovos-skill-date-time>=1.1.3,<2.0.0 ovos-skill-hello-world>=0.1.10,<1.0.0 ovos-skill-spelling>=0.2.5,<1.0.0 ovos-skill-diagnostics>=0.0.2,<1.0.0 diff --git a/requirements/skills-extra.txt b/requirements/skills-extra.txt index 12c049da2ee..2e034ca983c 100644 --- a/requirements/skills-extra.txt +++ b/requirements/skills-extra.txt @@ -1,12 +1,11 @@ # skills providing non essential functionality -ovos-skill-wordnet>=0.0.10,<1.0.0 +ovos-skill-wordnet>=0.2.5,<1.0.0 ovos-skill-randomness>=0.1.1,<1.0.0; python_version >= "3.10" -ovos-skill-days-in-history>=0.3.6,<1.0.0 ovos-skill-laugh>=0.1.1,<1.0.0 -ovos-skill-number-facts>=0.1.4,<1.0.0 -ovos-skill-iss-location>=0.2.2,<1.0.0 -ovos-skill-cmd>=0.2.5,<1.0.0 -ovos-skill-moviemaster>=0.0.7,<1.0.0 -ovos-skill-confucius-quotes>=0.1.7,<1.0.0 -ovos-skill-icanhazdadjokes>=0.3.1,<1.0.0 +ovos-skill-number-facts>=0.1.12,<1.0.0 +ovos-skill-iss-location>=0.2.16,<1.0.0 +ovos-skill-cmd>=0.2.11,<1.0.0 +ovos-skill-moviemaster>=0.0.12,<1.0.0 +ovos-skill-confucius-quotes>=0.1.13,<1.0.0 +ovos-skill-icanhazdadjokes>=0.3.7,<1.0.0 ovos-skill-camera diff --git a/requirements/skills-gui.txt b/requirements/skills-gui.txt index b214c582386..e6544b7d6c7 100644 --- a/requirements/skills-gui.txt +++ b/requirements/skills-gui.txt @@ -1,3 +1,3 @@ -ovos-skill-homescreen>=3.0.2,<4.0.0 +ovos-skill-homescreen>=3.0.3,<4.0.0 ovos-skill-screenshot>=0.0.2,<1.0.0 ovos-skill-color-picker>=0.0.2,<1.0.0 \ No newline at end of file diff --git a/requirements/skills-internet.txt b/requirements/skills-internet.txt index 3d3198b22a6..4ff3ee3e5c6 100644 --- a/requirements/skills-internet.txt +++ b/requirements/skills-internet.txt @@ -1,8 +1,8 @@ # skills that require internet connectivity, should not be installed in offline devices -ovos-skill-weather>=0.1.11,<2.0.0 -ovos-skill-ddg>=0.1.9,<1.0.0 -ovos-skill-wolfie>=0.2.9,<1.0.0 -ovos-skill-wikipedia>=0.5.3,<1.0.0 -ovos-skill-wikihow>=0.2.5,<1.0.0 -ovos-skill-speedtest>=0.3.2,<1.0.0 +ovos-skill-weather>=1.0.3,<2.0.0 +ovos-skill-ddg>=0.3.5,<1.0.0 +ovos-skill-wolfie>=0.5.8,<1.0.0 +ovos-skill-wikipedia>=0.8.13,<1.0.0 +ovos-skill-wikihow>=0.3.3,<1.0.0 +ovos-skill-speedtest>=0.3.6,<1.0.0 ovos-skill-ip>=0.2.5,<1.0.0 diff --git a/requirements/skills-media.txt b/requirements/skills-media.txt index ad229eba61d..c19a3838900 100644 --- a/requirements/skills-media.txt +++ b/requirements/skills-media.txt @@ -1,6 +1,6 @@ # skills for OCP, require audio playback plugins (usually mpv) ovos-skill-somafm>=0.1.3,<1.0.0 -ovos-skill-news>=0.1.8,<1.0.0 -ovos-skill-pyradios>=0.1.4,<1.0.0 -ovos-skill-local-media>=0.2.4,<1.0.0 -ovos-skill-youtube-music>=0.1.6,<1.0.0 +ovos-skill-news>=0.4.5,<1.0.0 +ovos-skill-pyradios>=0.1.5,<1.0.0 +ovos-skill-local-media>=0.2.12,<1.0.0 +ovos-skill-youtube-music>=0.1.7,<1.0.0 diff --git a/requirements/tests.txt b/requirements/tests.txt index 098263f32ea..cfd5458e70f 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -5,4 +5,4 @@ pytest-cov>=2.8.1 pytest-testmon>=2.1.3 pytest-randomly>=3.16.0 cov-core>=1.15.0 -ovoscope>=0.3.0,<1.0.0 \ No newline at end of file +ovoscope>=0.4.0,<1.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 697c087b602..b453a160a19 100644 --- a/setup.py +++ b/setup.py @@ -99,7 +99,8 @@ def required(requirements_file): entry_points={ 'opm.pipeline': PLUGIN_ENTRY_POINT, 'console_scripts': [ - 'ovos-core=ovos_core.__main__:main' + 'ovos-core=ovos_core.__main__:main', + 'ovos-intent-service=ovos_core.intent_services.service:launch_standalone' ] } ) diff --git a/test/end2end/test_fallback.py b/test/end2end/test_fallback.py new file mode 100644 index 00000000000..4f0e3bb07ac --- /dev/null +++ b/test/end2end/test_fallback.py @@ -0,0 +1,74 @@ +from unittest import TestCase + +from ovos_bus_client.message import Message +from ovos_bus_client.session import Session +from ovos_utils.log import LOG + +from ovoscope import End2EndTest, get_minicroft + + +class TestFallback(TestCase): + + def setUp(self): + """ + Sets up the test environment for fallback skill testing. + + Initializes the logging level to DEBUG, sets the fallback skill ID, and creates a MiniCroft instance with the fallback skill loaded for use in end-to-end tests. + """ + LOG.set_level("DEBUG") + self.skill_id = "ovos-skill-fallback-unknown.openvoiceos" + self.minicroft = get_minicroft([self.skill_id]) # reuse for speed, but beware if skills keeping internal state + + def tearDown(self): + """ + Cleans up the test environment by stopping the MiniCroft instance and resetting the logging level to CRITICAL. + """ + if self.minicroft: + self.minicroft.stop() + LOG.set_level("CRITICAL") + + def test_fallback_match(self): + """ + Tests that the fallback skill correctly handles an unrecognized utterance in an end-to-end scenario. + + Simulates a user utterance that cannot be handled by standard skills, triggering the fallback pipeline. Verifies that the expected sequence of messages is exchanged, including fallback ping/pong, skill request, response, and final handling confirmation. + """ + session = Session("123") + session.pipeline = ['ovos-fallback-pipeline-plugin-low'] + message = Message("recognizer_loop:utterance", + {"utterances": ["hello world"], "lang": "en-US"}, + {"session": session.serialize(), "source": "A", "destination": "B"}) + + test = End2EndTest( + minicroft=self.minicroft, + skill_ids=[self.skill_id], + eof_msgs=["ovos.utterance.handled"], + flip_points=["recognizer_loop:utterance"], + keep_original_src=["ovos.skills.fallback.ping"], # for routing tests this is an exception + source_message=message, + expected_messages=[ + message, + Message("ovos.skills.fallback.ping", + {"utterances": ["hello world"], "lang": "en-US", "range": [90, 101]}), + Message("ovos.skills.fallback.pong", {"skill_id": self.skill_id, "can_handle": True}), + Message(f"ovos.skills.fallback.{self.skill_id}.request", + {"utterances": ["hello world"], "lang": "en-US", "range": [90, 101], "skill_id": self.skill_id}), + Message(f"ovos.skills.fallback.{self.skill_id}.start", {}), + Message("speak", + data={"lang": "en-US", + "expect_response": False, + "meta": { + "dialog": "unknown", + "data": {}, + "skill": self.skill_id + }}, + context={"skill_id": self.skill_id}), + Message(f"ovos.skills.fallback.{self.skill_id}.response", + data={"fallback_handler":"UnknownSkill.handle_fallback"}, + context={"skill_id": self.skill_id}), + + Message("ovos.utterance.handled", {}) + ] + ) + + test.execute(timeout=10) diff --git a/test/end2end/test_helloworld.py b/test/end2end/test_helloworld.py index b99caeebf38..aa21b4aab8c 100644 --- a/test/end2end/test_helloworld.py +++ b/test/end2end/test_helloworld.py @@ -2,36 +2,59 @@ from ovos_bus_client.message import Message from ovos_bus_client.session import Session - -from ovoscope import End2EndTest +from ovos_utils.log import LOG +from ovoscope import End2EndTest, get_minicroft class TestAdaptIntent(TestCase): + def setUp(self): + """ + Initializes the test environment before each test. + + Sets the logging level to DEBUG, assigns the skill ID for the "hello world" skill, and creates a Minicroft instance with the skill loaded for use in tests. + """ + LOG.set_level("DEBUG") + self.skill_id = "ovos-skill-hello-world.openvoiceos" + self.minicroft = get_minicroft([self.skill_id]) # reuse for speed, but beware if skills keeping internal state + + def tearDown(self): + """ + Stops the minicroft instance if running and resets the logging level to CRITICAL after each test. + """ + if self.minicroft: + self.minicroft.stop() + LOG.set_level("CRITICAL") + def test_adapt_match(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that the Adapt pipeline correctly recognizes and handles the "hello world" utterance. + + Simulates an end-to-end interaction using the Adapt intent parsing pipeline, verifying that the expected sequence of messages is produced for a successful intent match and skill response. + """ session = Session("123") - session.pipeline = ["adapt_high"] + session.pipeline = ['ovos-adapt-pipeline-plugin-high'] message = Message("recognizer_loop:utterance", {"utterances": ["hello world"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, expected_messages=[ message, - Message(f"{skill_id}.activate", + Message(f"{self.skill_id}.activate", data={}, - context={"skill_id": skill_id}), - Message(f"{skill_id}:HelloWorldIntent", + context={"skill_id": self.skill_id}), + Message(f"{self.skill_id}:HelloWorldIntent", data={"utterance": "hello world", "lang": "en-US"}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("mycroft.skill.handler.start", data={"name": "HelloWorldSkill.handle_hello_world_intent"}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("speak", data={"utterance": "Hello world", "lang": "en-US", @@ -39,31 +62,36 @@ def test_adapt_match(self): "meta": { "dialog": "hello.world", "data": {}, - "skill": skill_id + "skill": self.skill_id }}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("mycroft.skill.handler.complete", data={"name": "HelloWorldSkill.handle_hello_world_intent"}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("ovos.utterance.handled", data={}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), ] ) test.execute(timeout=10) def test_skill_blacklist(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that a blacklisted skill does not handle an utterance in the Adapt pipeline. + + Verifies that when the skill is blacklisted in the session, the utterance results in an error sound, intent failure, and handled confirmation, without activating the skill. + """ session = Session("123") - session.pipeline = ["adapt_high"] - session.blacklisted_skills = [skill_id] + session.pipeline = ['ovos-adapt-pipeline-plugin-high'] + session.blacklisted_skills = [self.skill_id] message = Message("recognizer_loop:utterance", {"utterances": ["hello world"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, @@ -78,16 +106,21 @@ def test_skill_blacklist(self): test.execute(timeout=10) def test_intent_blacklist(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that blacklisting a specific intent prevents it from being triggered. + + Creates a session using the Adapt pipeline with the `HelloWorldIntent` blacklisted. Sends a "hello world" utterance and verifies that the system responds with an error sound, intent failure, and utterance handled messages, confirming the intent is blocked. + """ session = Session("123") - session.pipeline = ["adapt_high"] - session.blacklisted_intents = [f"{skill_id}:HelloWorldIntent"] + session.pipeline = ['ovos-adapt-pipeline-plugin-high'] + session.blacklisted_intents = [f"{self.skill_id}:HelloWorldIntent"] message = Message("recognizer_loop:utterance", {"utterances": ["hello world"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, @@ -102,15 +135,20 @@ def test_intent_blacklist(self): test.execute(timeout=10) def test_padatious_no_match(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that the Padatious pipeline does not match the "hello world" utterance. + + Verifies that when using the Padatious pipeline with an utterance that has no matching intent, the system emits an error sound, a complete intent failure message, and marks the utterance as handled. + """ session = Session("123") - session.pipeline = ["padatious_high"] + session.pipeline = ["ovos-padatious-pipeline-plugin-high"] message = Message("recognizer_loop:utterance", {"utterances": ["hello world"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, @@ -127,61 +165,87 @@ def test_padatious_no_match(self): class TestPadatiousIntent(TestCase): + def setUp(self): + """ + Initializes the test environment before each test. + + Sets the logging level to DEBUG, assigns the skill ID for the hello world skill, and creates a minicroft instance with the skill loaded. + """ + LOG.set_level("DEBUG") + self.skill_id = "ovos-skill-hello-world.openvoiceos" + self.minicroft = get_minicroft([self.skill_id]) + + def tearDown(self): + """ + Stops the minicroft instance if running and resets the logging level to CRITICAL after each test. + """ + if self.minicroft: + self.minicroft.stop() + LOG.set_level("CRITICAL") + def test_padatious_match(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that the Padatious pipeline correctly matches the "good morning" utterance and triggers the expected skill activation, intent recognition, handler execution, and response messages. + """ session = Session("123") - session.pipeline = ["padatious_high"] + session.pipeline = ["ovos-padatious-pipeline-plugin-high"] message = Message("recognizer_loop:utterance", {"utterances": ["good morning"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, expected_messages=[ message, - Message(f"{skill_id}.activate", + Message(f"{self.skill_id}.activate", data={}, - context={"skill_id": skill_id}), - Message(f"{skill_id}:Greetings.intent", + context={"skill_id": self.skill_id}), + Message(f"{self.skill_id}:Greetings.intent", data={"utterance": "good morning", "lang": "en-US"}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("mycroft.skill.handler.start", data={"name": "HelloWorldSkill.handle_greetings"}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("speak", data={"lang": "en-US", "expect_response": False, "meta": { "dialog": "hello", "data": {}, - "skill": skill_id + "skill": self.skill_id }}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("mycroft.skill.handler.complete", data={"name": "HelloWorldSkill.handle_greetings"}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), Message("ovos.utterance.handled", data={}, - context={"skill_id": skill_id}), + context={"skill_id": self.skill_id}), ] ) test.execute(timeout=10) def test_skill_blacklist(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that a blacklisted skill does not handle an utterance in the Padatious pipeline. + + Verifies that when the skill is blacklisted in the session, the utterance results in an error sound, intent failure, and utterance handled messages, confirming the skill is blocked from activation. + """ session = Session("123") - session.pipeline = ["padatious_high"] - session.blacklisted_skills = [skill_id] + session.pipeline = ["ovos-padatious-pipeline-plugin-high"] + session.blacklisted_skills = [self.skill_id] message = Message("recognizer_loop:utterance", {"utterances": ["good morning"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, @@ -196,16 +260,21 @@ def test_skill_blacklist(self): test.execute(timeout=10) def test_intent_blacklist(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that blacklisting a specific intent prevents it from being recognized and handled. + + Simulates an utterance that would normally match the blacklisted intent using the Padatious pipeline. Verifies that the system responds with an error sound, completes intent failure, and marks the utterance as handled without activating the skill. + """ session = Session("123") - session.pipeline = ["padatious_high"] - session.blacklisted_intents = [f"{skill_id}:Greetings.intent"] + session.pipeline = ["ovos-padatious-pipeline-plugin-high"] + session.blacklisted_intents = [f"{self.skill_id}:Greetings.intent"] message = Message("recognizer_loop:utterance", {"utterances": ["good morning"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, @@ -220,15 +289,146 @@ def test_intent_blacklist(self): test.execute(timeout=10) def test_adapt_no_match(self): - skill_id = "ovos-skill-hello-world.openvoiceos" + """ + Tests that the Adapt pipeline does not match an unrelated utterance and triggers intent failure. + + Sends a "good morning" utterance using the Adapt pipeline and verifies that the system responds with an error sound, a complete intent failure message, and an utterance handled message, indicating no skill or intent was matched. + """ + session = Session("123") + session.pipeline = ['ovos-adapt-pipeline-plugin-high'] + message = Message("recognizer_loop:utterance", + {"utterances": ["good morning"], "lang": "en-US"}, + {"session": session.serialize(), "source": "A", "destination": "B"}) + + test = End2EndTest( + minicroft=self.minicroft, + skill_ids=[self.skill_id], + eof_msgs=["ovos.utterance.handled"], + flip_points=["recognizer_loop:utterance"], + source_message=message, + expected_messages=[ + message, + Message("mycroft.audio.play_sound", {"uri": "snd/error.mp3"}), + Message("complete_intent_failure", {}), + Message("ovos.utterance.handled", {}) + ] + ) + + test.execute(timeout=10) + + +class TestModel2VecIntent(TestCase): + + def setUp(self): + """ + Initializes the test environment before each test. + + Sets the logging level to DEBUG, assigns the skill ID for the hello world skill, and creates a minicroft instance with the skill loaded. + """ + LOG.set_level("DEBUG") + self.skill_id = "ovos-skill-hello-world.openvoiceos" + self.minicroft = get_minicroft([self.skill_id]) + + def tearDown(self): + """ + Stops the minicroft instance if running and resets the logging level to CRITICAL after each test. + """ + if self.minicroft: + self.minicroft.stop() + LOG.set_level("CRITICAL") + + def test_m2v_match(self): + """ + Tests that the Model2Vec pipeline correctly matches the "good morning" utterance to the Greetings intent and triggers the expected sequence of skill activation, intent handling, and response messages. + """ + session = Session("123") + session.pipeline = ["ovos-m2v-pipeline-high"] + message = Message("recognizer_loop:utterance", + {"utterances": ["good morning"], "lang": "en-US"}, + {"session": session.serialize(), "source": "A", "destination": "B"}) + + test = End2EndTest( + minicroft=self.minicroft, + skill_ids=[self.skill_id], + eof_msgs=["ovos.utterance.handled"], + flip_points=["recognizer_loop:utterance"], + source_message=message, + expected_messages=[ + message, + Message(f"{self.skill_id}.activate", + data={}, + context={"skill_id": self.skill_id}), + Message(f"{self.skill_id}:Greetings.intent", + data={"utterance": "good morning", "lang": "en-US"}, + context={"skill_id": self.skill_id}), + Message("mycroft.skill.handler.start", + data={"name": "HelloWorldSkill.handle_greetings"}, + context={"skill_id": self.skill_id}), + Message("speak", + data={"lang": "en-US", + "expect_response": False, + "meta": { + "dialog": "hello", + "data": {}, + "skill": self.skill_id + }}, + context={"skill_id": self.skill_id}), + Message("mycroft.skill.handler.complete", + data={"name": "HelloWorldSkill.handle_greetings"}, + context={"skill_id": self.skill_id}), + Message("ovos.utterance.handled", + data={}, + context={"skill_id": self.skill_id}), + ] + ) + + test.execute(timeout=10) + + def test_skill_blacklist(self): + """ + Tests that a blacklisted skill does not handle an utterance in the Model2Vec pipeline. + + Verifies that when the skill is blacklisted in the session, the utterance results in an error sound, intent failure, and utterance handled messages, confirming the skill is blocked from activation. + """ + session = Session("123") + session.pipeline = ["ovos-m2v-pipeline-high"] + session.blacklisted_skills = [self.skill_id] + message = Message("recognizer_loop:utterance", + {"utterances": ["good morning"], "lang": "en-US"}, + {"session": session.serialize(), "source": "A", "destination": "B"}) + + test = End2EndTest( + minicroft=self.minicroft, + skill_ids=[self.skill_id], + eof_msgs=["ovos.utterance.handled"], + flip_points=["recognizer_loop:utterance"], + source_message=message, + expected_messages=[ + message, + Message("mycroft.audio.play_sound", {"uri": "snd/error.mp3"}), + Message("complete_intent_failure", {}), + Message("ovos.utterance.handled", {}) + ] + ) + + test.execute(timeout=10) + + def test_intent_blacklist(self): + """ + Tests that blacklisting a specific intent prevents it from being recognized and handled. + + Sends a "good morning" utterance using the Model2Vec pipeline with the `Greetings.intent` blacklisted. Verifies that the system responds with an error sound, completes intent failure, and marks the utterance as handled without activating the skill. + """ session = Session("123") - session.pipeline = ["adapt_high"] + session.pipeline = ["ovos-m2v-pipeline-high"] + session.blacklisted_intents = [f"{self.skill_id}:Greetings.intent"] message = Message("recognizer_loop:utterance", {"utterances": ["good morning"], "lang": "en-US"}, {"session": session.serialize(), "source": "A", "destination": "B"}) test = End2EndTest( - skill_ids=[skill_id], + minicroft=self.minicroft, + skill_ids=[self.skill_id], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], source_message=message, diff --git a/test/end2end/test_no_skills.py b/test/end2end/test_no_skills.py index f0404c574dc..777892b6f66 100644 --- a/test/end2end/test_no_skills.py +++ b/test/end2end/test_no_skills.py @@ -1,17 +1,41 @@ from unittest import TestCase from ovos_bus_client.message import Message +from ovos_utils.log import LOG -from ovoscope import End2EndTest +from ovoscope import End2EndTest, get_minicroft class TestNoSkills(TestCase): + def setUp(self): + """ + Sets up the test environment before each test. + + Initializes logging to DEBUG level and creates a minicroft instance with no skills loaded for use in tests. + """ + LOG.set_level("DEBUG") + self.minicroft = get_minicroft([]) # reuse for speed, but beware if skills keeping internal state + + def tearDown(self): + """ + Cleans up after each test by stopping the minicroft instance and resetting logging level to CRITICAL. + """ + if self.minicroft: + self.minicroft.stop() + LOG.set_level("CRITICAL") + def test_complete_failure(self): + """ + Tests system behavior when no skills are loaded and an utterance is received. + + Verifies that the system responds to an unhandled utterance by playing an error sound, emitting a complete intent failure message, and marking the utterance as handled. + """ message = Message("recognizer_loop:utterance", {"utterances": ["hello world"]}) test = End2EndTest( + minicroft=self.minicroft, skill_ids=[], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], @@ -28,11 +52,18 @@ def test_complete_failure(self): def test_routing(self): # this test will validate source and destination are handled properly + # done automatically if "source" and "destination" are in message.context + """ + Tests that message routing with 'source' and 'destination' context fields is handled correctly when no skills are loaded. + + Verifies that the system produces the expected sequence of messages, including correct propagation of routing context, when processing an utterance event. + """ message = Message("recognizer_loop:utterance", {"utterances": ["hello world"]}, {"source": "A", "destination": "B"}) test = End2EndTest( + minicroft=self.minicroft, skill_ids=[], eof_msgs=["ovos.utterance.handled"], flip_points=["recognizer_loop:utterance"], diff --git a/test/unittests/test_intent_service.py b/test/unittests/test_intent_service.py index b8f2a38a270..b5da55b6ccf 100644 --- a/test/unittests/test_intent_service.py +++ b/test/unittests/test_intent_service.py @@ -13,6 +13,7 @@ # limitations under the License. # import time +import unittest from copy import deepcopy from unittest import TestCase, mock @@ -80,41 +81,3 @@ def test_lang_exists(self): msg = Message('test msg', data={'lang': 'sv-se'}) self.assertEqual(get_message_lang(msg), 'sv-SE') - -class TestIntentServiceApi(TestCase): - def setUp(self): - self.bus = FakeBus() - self.emitted = [] - - def on_msg(m): - self.emitted.append(Message.deserialize(m)) - - self.bus.on("message", on_msg) - - self.intent_service = IntentService(self.bus) - - msg = Message('register_vocab', - {'entity_value': 'test', 'entity_type': 'testKeyword'}) - self.intent_service._adapt_service.handle_register_vocab(msg) - - intent = IntentBuilder('skill:testIntent').require('testKeyword') - msg = Message('register_intent', intent.__dict__) - self.intent_service._adapt_service.handle_register_intent(msg) - - def test_get_intent_no_match(self): - """Check that if the intent doesn't match at all None is returned.""" - # Check that no intent is matched - msg = Message('intent.service.intent.get', - data={'utterance': 'five'}) - self.intent_service.handle_get_intent(msg) - reply = self.emitted[-1] - self.assertEqual(reply.data['intent'], None) - - def test_get_intent_match(self): - # Check that intent is matched - msg = Message('intent.service.intent.get', - data={'utterance': 'test'}) - self.intent_service.handle_get_intent(msg) - reply = self.emitted[-1] - time.sleep(3) - self.assertEqual(reply.data['intent']['intent_name'], 'skill:testIntent') diff --git a/test/unittests/test_manager.py b/test/unittests/test_manager.py index 4f67d073a56..a4d4499c850 100644 --- a/test/unittests/test_manager.py +++ b/test/unittests/test_manager.py @@ -148,6 +148,11 @@ def test_get_internal_skill_bus_shared_connection(self, mock_MessageBusClient): @patch('ovos_core.skill_manager.MessageBusClient', autospec=True) def test_get_internal_skill_bus_not_shared_connection(self, mock_MessageBusClient): # Set the configuration to use shared_connection=False + """ + Tests that _get_internal_skill_bus creates a new MessageBusClient when shared_connection is False. + + Verifies that the skill manager instantiates a new MessageBusClient with caching enabled and starts its thread when shared connections are disabled in the configuration. + """ self.skill_manager.config = {'websocket': {'shared_connection': False}} # Call the method under test @@ -157,36 +162,6 @@ def test_get_internal_skill_bus_not_shared_connection(self, mock_MessageBusClien mock_MessageBusClient.assert_called_once_with(cache=True) self.assertTrue(result.run_in_thread.called) - @patch('ovos_core.skill_manager.LOG') - def test_load_new_skills_with_blacklisted_skill(self, mock_log): - # Mocking find_skill_plugins to return a blacklisted skill - with patch('ovos_core.skill_manager.find_skill_plugins', return_value={'blacklisted_skill': ''}): - # Mocking _load_skill method to prevent actual loading - with patch.object(self.skill_manager, '_load_skill', return_value=None): - self.skill_manager._load_skill = MagicMock() - - # Setting up blacklisted skill in the configuration - self.skill_manager.config['skills']['blacklisted_skills'] = ['blacklisted_skill'] - - # Calling _load_new_skills - self.skill_manager._load_new_skills(network=True, internet=True, gui=True) - self.assertEqual(self.skill_manager._logged_skill_warnings, ["blacklisted_skill"]) - self.skill_manager._load_new_skills(network=True, internet=True, gui=True) - - # Assert that a warning log message is generated once for the blacklisted skill - mock_log.warning.assert_called_once_with("blacklisted_skill is blacklisted, it will NOT be loaded") - mock_log.info.assert_called_once_with( - "Consider uninstalling blacklisted_skill instead of blacklisting it") - - # Mock loading a local directory that is blacklisted - self.skill_manager.config['skills']['blacklisted_skills'].append("local_skill.test") - test_skill_path = join(dirname(__file__), 'local_skill.test') - self.skill_manager._load_skill(test_skill_path) - mock_log.warning.assert_called_with("local_skill.test is blacklisted, it will NOT be loaded") - mock_log.info.assert_called_with( - f"Consider deleting {test_skill_path} instead of blacklisting it") - self.assertIn("local_skill.test", self.skill_manager._logged_skill_warnings) - if __name__ == '__main__': unittest.main() diff --git a/test/unittests/test_skill_manager.py b/test/unittests/test_skill_manager.py index 9bbab883dee..e1edc1d0294 100644 --- a/test/unittests/test_skill_manager.py +++ b/test/unittests/test_skill_manager.py @@ -82,6 +82,11 @@ def tearDown(self): rmtree(str(self.temp_dir)) def _mock_skill_loader_instance(self): + """ + Creates and assigns a mocked SkillLoader instance for testing. + + Sets up a mock SkillLoader with a test skill directory and configures its methods and attributes for use in SkillManager tests. + """ self.skill_dir = self.temp_dir.joinpath('test_skill') self.skill_loader_mock = Mock(spec=SkillLoader) self.skill_loader_mock.instance = Mock() @@ -89,11 +94,14 @@ def _mock_skill_loader_instance(self): self.skill_loader_mock.instance.converse = Mock() self.skill_loader_mock.instance.converse.return_value = True self.skill_loader_mock.skill_id = 'test_skill' - self.skill_manager.skill_loaders = { + self.skill_manager.plugin_skills = { str(self.skill_dir): self.skill_loader_mock } def test_instantiate(self): + """ + Verifies that SkillManager subscribes to the expected set of message bus event handlers upon instantiation. + """ expected_result = [ 'skillmanager.list', 'skillmanager.deactivate', @@ -114,13 +122,13 @@ def test_instantiate(self): self.assertListEqual(expected_result, self.message_bus_mock.event_handlers) - def test_unload_removed_skills(self): - self.skill_manager._unload_removed_skills() - - self.assertDictEqual({}, self.skill_manager.skill_loaders) - self.skill_loader_mock.unload.assert_called_once_with() def test_send_skill_list(self): + """ + Tests that the skill manager emits a skill list message with correct skill data. + + Verifies that when `send_skill_list` is called, the message bus receives a `mycroft.skills.list` message containing the test skill marked as active. + """ self.skill_loader_mock.active = True self.skill_loader_mock.loaded = True self.skill_manager.send_skill_list(None) @@ -149,6 +157,11 @@ def test_deactivate_skill(self): message.response.assert_called_once() def test_deactivate_except(self): + """ + Tests that all skills except the specified one are deactivated. + + Verifies that the `deactivate` method is called on all skill loaders except the one matching the skill specified in the message. + """ message = Message("test.message", {'skill': 'test_skill'}) message.response = Mock() self.skill_loader_mock.active = True @@ -158,9 +171,9 @@ def test_deactivate_except(self): foo2_skill_loader.skill_id = 'foo2' test_skill_loader = Mock(spec=SkillLoader) test_skill_loader.skill_id = 'test_skill' - self.skill_manager.skill_loaders['foo'] = foo_skill_loader - self.skill_manager.skill_loaders['foo2'] = foo2_skill_loader - self.skill_manager.skill_loaders['test_skill'] = test_skill_loader + self.skill_manager.plugin_skills['foo'] = foo_skill_loader + self.skill_manager.plugin_skills['foo2'] = foo2_skill_loader + self.skill_manager.plugin_skills['test_skill'] = test_skill_loader self.skill_manager.deactivate_except(message) foo_skill_loader.deactivate.assert_called_once() @@ -168,14 +181,19 @@ def test_deactivate_except(self): self.assertFalse(test_skill_loader.deactivate.called) def test_activate_skill(self): + """ + Tests that activating a skill calls its activate method and sends a response. + + Verifies that when a skill is inactive, invoking activate_skill on the SkillManager triggers the skill loader's activate method and sends a response to the original message. + """ message = Message("test.message", {'skill': 'test_skill'}) message.response = Mock() test_skill_loader = Mock(spec=SkillLoader) test_skill_loader.skill_id = 'test_skill' test_skill_loader.active = False - self.skill_manager.skill_loaders = {} - self.skill_manager.skill_loaders['test_skill'] = test_skill_loader + self.skill_manager.plugin_skills = {} + self.skill_manager.plugin_skills['test_skill'] = test_skill_loader self.skill_manager.activate_skill(message) test_skill_loader.activate.assert_called_once()