From eb785e353b23ee432d78df3170fc0357c0a70acd Mon Sep 17 00:00:00 2001 From: miro Date: Sat, 20 Jul 2024 18:07:35 +0100 Subject: [PATCH] feat/pipeline_plugins_opm subclass pipelines from the OPM placeholder base class move to maintained ovos-adapt-parser package --- .github/workflows/build_tests.yml | 3 +- .github/workflows/license_tests.yml | 2 +- .../skills/intent_services/adapt_service.py | 6 +- ovos_core/intent_services/__init__.py | 7 +- ovos_core/intent_services/adapt_service.py | 363 +----------------- ovos_core/intent_services/commonqa_service.py | 16 +- ovos_core/intent_services/converse_service.py | 7 +- ovos_core/intent_services/fallback_service.py | 8 +- ovos_core/intent_services/ocp_service.py | 14 +- .../intent_services/padacioso_service.py | 298 +------------- .../intent_services/padatious_service.py | 294 +------------- ovos_core/intent_services/stop_service.py | 41 +- requirements/lgpl.txt | 5 +- requirements/requirements.txt | 4 +- setup.py | 9 + .../ovos_tskill_fakewiki/__init__.py | 2 +- test/unittests/skills/decorator_test_skill.py | 2 +- test/unittests/skills/test_intent_service.py | 2 +- .../skills/test_intent_service_interface.py | 2 +- test/unittests/skills/test_mycroft_skill.py | 2 +- 20 files changed, 76 insertions(+), 1011 deletions(-) diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 5b56684765eb..f1972906b18d 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -30,7 +30,8 @@ jobs: - name: Install System Dependencies run: | sudo apt-get update - sudo apt install python3-dev swig libssl-dev libfann-dev portaudio19-dev libpulse-dev + sudo apt install python3-dev swig libssl-dev libfann-dev portaudio19-dev libpulse-dev python3-fann2 + python -m pip install build wheel - name: Build Source Packages run: | python setup.py sdist diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml index 1a5744d8204f..de35365f64ad 100644 --- a/.github/workflows/license_tests.yml +++ b/.github/workflows/license_tests.yml @@ -37,7 +37,7 @@ jobs: requirements: 'requirements-all.txt' fail: 'Copyleft,Other,Error' fails-only: true - exclude: '^(precise-runner|fann2|tqdm|bs4|sonopy|caldav|recurring-ical-events|x-wr-timezone|zeroconf|mutagen).*' + exclude: '^(precise-runner|fann2|ovos-adapt-parser|ovos-padatious|tqdm|bs4|sonopy|caldav|recurring-ical-events|x-wr-timezone|zeroconf|mutagen).*' exclude-license: '^(Mozilla).*$' - name: Print report if: ${{ always() }} diff --git a/mycroft/skills/intent_services/adapt_service.py b/mycroft/skills/intent_services/adapt_service.py index 1f498a8a9b31..fa8ac0881abc 100644 --- a/mycroft/skills/intent_services/adapt_service.py +++ b/mycroft/skills/intent_services/adapt_service.py @@ -13,10 +13,10 @@ # limitations under the License. # """An intent parsing service using the Adapt parser.""" -from adapt.context import ContextManagerFrame -from adapt.engine import IntentDeterminationEngine +from ovos_adapt.context import ContextManagerFrame +from ovos_adapt.engine import IntentDeterminationEngine from ovos_workshop.intents import IntentBuilder, Intent -from ovos_core.intent_services.adapt_service import ContextManager, AdaptService +from ovos_adapt.opm import ContextManager, AdaptPipeline as AdaptService class AdaptIntent(IntentBuilder): diff --git a/ovos_core/intent_services/__init__.py b/ovos_core/intent_services/__init__.py index 6ef805f2543d..4970a0e1e597 100644 --- a/ovos_core/intent_services/__init__.py +++ b/ovos_core/intent_services/__init__.py @@ -13,18 +13,19 @@ # limitations under the License. # from typing import Tuple, Callable + +from ovos_adapt.opm import AdaptPipeline as AdaptService 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 setup_locale, get_valid_languages, get_full_lang_code +from padacioso.opm import PadaciosoPipeline as PadaciosoService -from ovos_core.intent_services.adapt_service import AdaptService from ovos_core.intent_services.commonqa_service import CommonQAService from ovos_core.intent_services.converse_service import ConverseService from ovos_core.intent_services.fallback_service import FallbackService from ovos_core.intent_services.ocp_service import OCPPipelineMatcher -from ovos_core.intent_services.padacioso_service import PadaciosoService from ovos_core.intent_services.stop_service import StopService from ovos_core.transformers import MetadataTransformersService, UtteranceTransformersService from ovos_utils.log import LOG, deprecated, log_deprecation @@ -56,7 +57,7 @@ def __init__(self, bus, config=None): if self.config["padatious"].get("disabled"): LOG.info("padatious forcefully disabled in config") else: - from ovos_core.intent_services.padatious_service import PadatiousService + from ovos_padatious.opm import PadatiousPipeline as PadatiousService self.padatious_service = PadatiousService(bus, self.config["padatious"]) except ImportError: LOG.error(f'Failed to create padatious intent handlers, padatious not installed') diff --git a/ovos_core/intent_services/adapt_service.py b/ovos_core/intent_services/adapt_service.py index 233fee73553f..d4342c126dcb 100644 --- a/ovos_core/intent_services/adapt_service.py +++ b/ovos_core/intent_services/adapt_service.py @@ -1,361 +1,2 @@ -# Copyright 2020 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. -# -"""An intent parsing service using the Adapt parser.""" -from functools import lru_cache -from threading import Lock -from typing import List, Tuple, Optional - -from adapt.engine import IntentDeterminationEngine -from ovos_bus_client.message import Message -from ovos_bus_client.session import IntentContextManager as ContextManager, \ - SessionManager -from ovos_config.config import Configuration -from ovos_utils import flatten_list -from ovos_utils.log import LOG - -from ovos_plugin_manager.templates.pipeline import IntentMatch - - -def _entity_skill_id(skill_id): - """Helper converting a skill id to the format used in entities. - - Arguments: - skill_id (str): skill identifier - - Returns: - (str) skill id on the format used by skill entities - """ - skill_id = skill_id[:-1] - skill_id = skill_id.replace('.', '_') - skill_id = skill_id.replace('-', '_') - return skill_id - - -class AdaptService: - """Intent service wrapping the Adapt intent Parser.""" - - def __init__(self, config=None): - core_config = Configuration() - self.config = config or core_config.get("context", {}) - self.lang = core_config.get("lang", "en-us") - langs = core_config.get('secondary_langs') or [] - if self.lang not in langs: - langs.append(self.lang) - - self.engines = {lang: IntentDeterminationEngine() - for lang in langs} - - self.lock = Lock() - self.max_words = 50 # if an utterance contains more words than this, don't attempt to match - - # TODO sanitize config option - self.conf_high = self.config.get("conf_high") or 0.65 - self.conf_med = self.config.get("conf_med") or 0.45 - self.conf_low = self.config.get("conf_low") or 0.25 - - @property - def context_keywords(self): - LOG.warning( - "self.context_keywords has been deprecated and is unused, use self.config.get('keywords', []) instead") - return self.config.get('keywords', []) - - @context_keywords.setter - def context_keywords(self, val): - LOG.warning( - "self.context_keywords has been deprecated and is unused, edit mycroft.conf instead, setter will be ignored") - - @property - def context_max_frames(self): - LOG.warning( - "self.context_keywords has been deprecated and is unused, use self.config.get('max_frames', 3) instead") - return self.config.get('max_frames', 3) - - @context_max_frames.setter - def context_max_frames(self, val): - LOG.warning( - "self.context_max_frames has been deprecated and is unused, edit mycroft.conf instead, setter will be ignored") - - @property - def context_timeout(self): - LOG.warning("self.context_timeout has been deprecated and is unused, use self.config.get('timeout', 2) instead") - return self.config.get('timeout', 2) - - @context_timeout.setter - def context_timeout(self, val): - LOG.warning( - "self.context_timeout has been deprecated and is unused, edit mycroft.conf instead, setter will be ignored") - - @property - def context_greedy(self): - LOG.warning( - "self.context_greedy has been deprecated and is unused, use self.config.get('greedy', False) instead") - return self.config.get('greedy', False) - - @context_greedy.setter - def context_greedy(self, val): - LOG.warning( - "self.context_greedy has been deprecated and is unused, edit mycroft.conf instead, setter will be ignored") - - @property - def context_manager(self): - LOG.warning("context_manager has been deprecated, use Session.context instead") - sess = SessionManager.get() - return sess.context - - @context_manager.setter - def context_manager(self, val): - LOG.warning("context_manager has been deprecated, use Session.context instead") - assert isinstance(val, ContextManager) - sess = SessionManager.get() - sess.context = val - - def update_context(self, intent): - """Updates context with keyword from the intent. - - NOTE: This method currently won't handle one_of intent keywords - since it's not using quite the same format as other intent - keywords. This is under investigation in adapt, PR pending. - - Args: - intent: Intent to scan for keywords - """ - LOG.warning("update_context has been deprecated, use Session.context.update_context instead") - sess = SessionManager.get() - ents = [tag['entities'][0] for tag in intent['__tags__'] if 'entities' in tag] - sess.context.update_context(ents) - - def match_high(self, utterances: List[str], - lang: Optional[str] = None, - message: Optional[Message] = None): - """Intent matcher for high confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - match = self.match_intent(tuple(utterances), lang, message.serialize()) - if match and match.intent_data.get("confidence", 0.0) >= self.conf_high: - return match - return None - - def match_medium(self, utterances: List[str], - lang: Optional[str] = None, - message: Optional[Message] = None): - """Intent matcher for medium confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - match = self.match_intent(tuple(utterances), lang, message.serialize()) - if match and match.intent_data.get("confidence", 0.0) >= self.conf_med: - return match - return None - - def match_low(self, utterances: List[str], - lang: Optional[str] = None, - message: Optional[Message] = None): - """Intent matcher for low confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - match = self.match_intent(tuple(utterances), lang, message.serialize()) - if match and match.intent_data.get("confidence", 0.0) >= self.conf_low: - return match - return None - - @lru_cache(maxsize=3) # NOTE - message is a string because of this - def match_intent(self, utterances: Tuple[str], - lang: Optional[str] = None, - message: Optional[str] = None): - """Run the Adapt engine to search for an matching intent. - - Args: - utterances (iterable): utterances for consideration in intent - matching. As a practical matter, a single utterance will - be passed in most cases. But there are instances, such as - streaming STT that could pass multiple. Each utterance is - represented as a tuple containing the raw, normalized, and - possibly other variations of the utterance. - limit (float): confidence threshold for intent matching - lang (str): language to use for intent matching - message (Message): message to use for context - - Returns: - Intent structure, or None if no match was found. - """ - - if message: - message = Message.deserialize(message) - sess = SessionManager.get(message) - - # we call flatten in case someone is sending the old style list of tuples - utterances = flatten_list(utterances) - - utterances = [u for u in utterances if len(u.split()) < self.max_words] - if not utterances: - LOG.error(f"utterance exceeds max size of {self.max_words} words, skipping adapt match") - return None - - lang = lang or self.lang - if lang not in self.engines: - return None - - best_intent = {} - - def take_best(intent, utt): - nonlocal best_intent - best = best_intent.get('confidence', 0.0) if best_intent else 0.0 - conf = intent.get('confidence', 0.0) - skill = intent['intent_type'].split(":")[0] - if best < conf and intent["intent_type"] not in sess.blacklisted_intents \ - and skill not in sess.blacklisted_skills: - best_intent = intent - # TODO - Shouldn't Adapt do this? - best_intent['utterance'] = utt - - for utt in utterances: - try: - intents = [i for i in self.engines[lang].determine_intent( - utt, 100, - include_tags=True, - context_manager=sess.context)] - if intents: - utt_best = max( - intents, key=lambda x: x.get('confidence', 0.0) - ) - take_best(utt_best, utt) - - except Exception as err: - LOG.exception(err) - - if best_intent: - ents = [tag['entities'][0] for tag in best_intent['__tags__'] if 'entities' in tag] - - sess.context.update_context(ents) - - skill_id = best_intent['intent_type'].split(":")[0] - ret = IntentMatch( - 'Adapt', best_intent['intent_type'], best_intent, skill_id, - best_intent['utterance'] - ) - else: - ret = None - return ret - - def register_vocab(self, start_concept, end_concept, - alias_of, regex_str, lang): - """Register Vocabulary. DEPRECATED - - This method should not be used, it has been replaced by - register_vocabulary(). - """ - self.register_vocabulary(start_concept, end_concept, alias_of, - regex_str, lang) - - def register_vocabulary(self, entity_value, entity_type, - alias_of, regex_str, lang): - """Register skill vocabulary as adapt entity. - - This will handle both regex registration and registration of normal - keywords. if the "regex_str" argument is set all other arguments will - be ignored. - - Argument: - entity_value: the natural langauge word - entity_type: the type/tag of an entity instance - alias_of: entity this is an alternative for - """ - if lang in self.engines: - with self.lock: - if regex_str: - self.engines[lang].register_regex_entity(regex_str) - else: - self.engines[lang].register_entity( - entity_value, entity_type, alias_of=alias_of) - - def register_intent(self, intent): - """Register new intent with adapt engine. - - Args: - intent (IntentParser): IntentParser to register - """ - for lang in self.engines: - with self.lock: - self.engines[lang].register_intent_parser(intent) - - def detach_skill(self, skill_id): - """Remove all intents for skill. - - Args: - skill_id (str): skill to process - """ - with self.lock: - for lang in self.engines: - skill_parsers = [ - p.name for p in self.engines[lang].intent_parsers if - p.name.startswith(skill_id) - ] - self.engines[lang].drop_intent_parser(skill_parsers) - self._detach_skill_keywords(skill_id) - self._detach_skill_regexes(skill_id) - - def _detach_skill_keywords(self, skill_id): - """Detach all keywords registered with a particular skill. - - Arguments: - skill_id (str): skill identifier - """ - skill_id = _entity_skill_id(skill_id) - - def match_skill_entities(data): - return data and data[1].startswith(skill_id) - - for lang in self.engines: - self.engines[lang].drop_entity(match_func=match_skill_entities) - - def _detach_skill_regexes(self, skill_id): - """Detach all regexes registered with a particular skill. - - Arguments: - skill_id (str): skill identifier - """ - skill_id = _entity_skill_id(skill_id) - - def match_skill_regexes(regexp): - return any([r.startswith(skill_id) - for r in regexp.groupindex.keys()]) - - for lang in self.engines: - self.engines[lang].drop_regex_entity(match_func=match_skill_regexes) - - def detach_intent(self, intent_name): - """Detatch a single intent - - Args: - intent_name (str): Identifier for intent to remove. - """ - for lang in self.engines: - new_parsers = [ - p for p in self.engines[lang].intent_parsers if p.name != intent_name - ] - self.engines[lang].intent_parsers = new_parsers - - def shutdown(self): - for lang in self.engines: - parsers = self.engines[lang].intent_parsers - self.engines[lang].drop_intent_parser(parsers) +# backwards compat import +from ovos_adapt.opm import AdaptPipeline as AdaptService diff --git a/ovos_core/intent_services/commonqa_service.py b/ovos_core/intent_services/commonqa_service.py index e82285f2edc9..b96120189046 100644 --- a/ovos_core/intent_services/commonqa_service.py +++ b/ovos_core/intent_services/commonqa_service.py @@ -4,12 +4,11 @@ from threading import Event from typing import Dict, Optional -from ovos_config.config import Configuration - from ovos_bus_client.message import Message from ovos_bus_client.session import SessionManager +from ovos_config.config import Configuration from ovos_plugin_manager.solvers import find_multiple_choice_solver_plugins -from ovos_plugin_manager.templates.pipeline import IntentMatch +from ovos_plugin_manager.templates.pipeline import IntentMatch, PipelinePlugin from ovos_utils import flatten_list from ovos_utils.log import LOG from ovos_workshop.app import OVOSAbstractApplication @@ -31,11 +30,12 @@ class Query: selected_skill: str = "" -class CommonQAService(OVOSAbstractApplication): - def __init__(self, bus, config: Optional[Dict] = None): - super().__init__(bus=bus, - skill_id="common_query.openvoiceos", - resources_dir=f"{dirname(__file__)}") +class CommonQAService(PipelinePlugin, OVOSAbstractApplication): + def __init__(self, bus, config=None): + OVOSAbstractApplication.__init__( + self, bus=bus, skill_id="common_query.openvoiceos", + resources_dir=f"{dirname(__file__)}") + PipelinePlugin.__init__(self, config) self.active_queries: Dict[str, Query] = dict() self.common_query_skills = [] diff --git a/ovos_core/intent_services/converse_service.py b/ovos_core/intent_services/converse_service.py index 4144294aaf79..9e51db0285a4 100644 --- a/ovos_core/intent_services/converse_service.py +++ b/ovos_core/intent_services/converse_service.py @@ -1,20 +1,19 @@ +import time from threading import Event from typing import Optional -import time from ovos_bus_client.message import Message from ovos_bus_client.session import SessionManager, UtteranceState 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 IntentMatch, PipelinePlugin from ovos_utils import flatten_list from ovos_utils.log import LOG from ovos_workshop.permissions import ConverseMode, ConverseActivationMode -from ovos_plugin_manager.templates.pipeline import IntentMatch - -class ConverseService: +class ConverseService(PipelinePlugin): """Intent Service handling conversational skills.""" def __init__(self, bus): diff --git a/ovos_core/intent_services/fallback_service.py b/ovos_core/intent_services/fallback_service.py index 0ba27d733239..7d07a7f055fb 100644 --- a/ovos_core/intent_services/fallback_service.py +++ b/ovos_core/intent_services/fallback_service.py @@ -14,22 +14,21 @@ # """Intent service for Mycroft's fallback system.""" import operator +import time from collections import namedtuple from typing import Optional -import time from ovos_bus_client.session import SessionManager from ovos_config import Configuration +from ovos_plugin_manager.templates.pipeline import IntentMatch, PipelinePlugin from ovos_utils import flatten_list from ovos_utils.log import LOG from ovos_workshop.skills.fallback import FallbackMode -from ovos_plugin_manager.templates.pipeline import IntentMatch - FallbackRange = namedtuple('FallbackRange', ['start', 'stop']) -class FallbackService: +class FallbackService(PipelinePlugin): """Intent Service handling fallback skills.""" def __init__(self, bus): @@ -38,6 +37,7 @@ def __init__(self, bus): 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): skill_id = message.data.get("skill_id") diff --git a/ovos_core/intent_services/ocp_service.py b/ovos_core/intent_services/ocp_service.py index 05214725d880..d431987d4eef 100644 --- a/ovos_core/intent_services/ocp_service.py +++ b/ovos_core/intent_services/ocp_service.py @@ -1,27 +1,26 @@ import os import random import threading +import time from dataclasses import dataclass from os.path import join, dirname from threading import RLock from typing import List, Tuple, Optional, Union -import time -from padacioso import IntentContainer - from ovos_bus_client.apis.ocp import ClassicAudioServiceInterface from ovos_bus_client.apis.ocp import OCPInterface, OCPQuery from ovos_bus_client.message import Message, dig_for_message from ovos_bus_client.session import SessionManager from ovos_bus_client.util import wait_for_reply from ovos_plugin_manager.ocp import available_extractors -from ovos_plugin_manager.templates.pipeline import IntentMatch +from ovos_plugin_manager.templates.pipeline import IntentMatch, PipelinePlugin from ovos_utils import classproperty from ovos_utils.log import LOG from ovos_utils.messagebus import FakeBus from ovos_utils.ocp import MediaType, PlaybackType, PlaybackMode, PlayerState, OCP_ID, \ MediaEntry, Playlist, MediaState, TrackState, dict2entry, PluginStream from ovos_workshop.app import OVOSAbstractApplication +from padacioso import IntentContainer @dataclass @@ -35,14 +34,15 @@ class OCPPlayerProxy: media_type: MediaType = MediaType.GENERIC -class OCPPipelineMatcher(OVOSAbstractApplication): +class OCPPipelineMatcher(PipelinePlugin, OVOSAbstractApplication): intents = ["play.intent", "open.intent", "media_stop.intent", "next.intent", "prev.intent", "pause.intent", "play_favorites.intent", "resume.intent", "like_song.intent"] def __init__(self, bus=None, config=None): - super().__init__(skill_id=OCP_ID, bus=bus or FakeBus(), - resources_dir=f"{dirname(__file__)}") + OVOSAbstractApplication.__init__( + self, bus=bus or FakeBus(), skill_id=OCP_ID, resources_dir=f"{dirname(__file__)}") + PipelinePlugin.__init__(self, config) self.ocp_api = OCPInterface(self.bus) self.legacy_api = ClassicAudioServiceInterface(self.bus) diff --git a/ovos_core/intent_services/padacioso_service.py b/ovos_core/intent_services/padacioso_service.py index 0c888c48babd..8caf454e346f 100644 --- a/ovos_core/intent_services/padacioso_service.py +++ b/ovos_core/intent_services/padacioso_service.py @@ -1,297 +1,3 @@ -"""Intent service wrapping padacioso.""" -from functools import lru_cache -from os.path import isfile -from typing import List, Optional - -from ovos_bus_client.message import Message -from ovos_bus_client.session import SessionManager, Session -from ovos_config.config import Configuration -from ovos_utils import flatten_list -from ovos_utils.log import LOG +# backwards compat imports +from padacioso.opm import PadaciosoPipeline as PadaciosoService, PadaciosoIntent from padacioso import IntentContainer as FallbackIntentContainer - -from ovos_plugin_manager.templates.pipeline import IntentMatch - - -class PadaciosoIntent: - """ - A set of data describing how a query fits into an intent - Attributes: - name (str): Name of matched intent - sent (str): The input utterance associated with the intent - conf (float): Confidence (from 0.0 to 1.0) - matches (dict of str -> str): Key is the name of the entity and - value is the extracted part of the sentence - """ - - def __init__(self, name, sent, matches=None, conf=0.0): - self.name = name - self.sent = sent - self.matches = matches or {} - self.conf = conf - - def __getitem__(self, item): - return self.matches.__getitem__(item) - - def __contains__(self, item): - return self.matches.__contains__(item) - - def get(self, key, default=None): - return self.matches.get(key, default) - - def __repr__(self): - return repr(self.__dict__) - - -class PadaciosoService: - """Service class for padacioso intent matching.""" - - def __init__(self, bus, config): - self.padacioso_config = config - self.bus = bus - - core_config = Configuration() - self.lang = core_config.get("lang", "en-us") - langs = core_config.get('secondary_langs') or [] - if self.lang not in langs: - langs.append(self.lang) - - self.conf_high = self.padacioso_config.get("conf_high") or 0.95 - self.conf_med = self.padacioso_config.get("conf_med") or 0.8 - self.conf_low = self.padacioso_config.get("conf_low") or 0.5 - self.workers = self.padacioso_config.get("workers") or 4 - - try: - self.containers = { - lang: FallbackIntentContainer(self.padacioso_config.get("fuzz"), - n_workers=self.workers) - for lang in langs} - except TypeError: # old padacioso version without n_workers kwarg - self.containers = { - lang: FallbackIntentContainer(self.padacioso_config.get("fuzz")) - for lang in langs} - - self.bus.on('padatious:register_intent', self.register_intent) - self.bus.on('padatious:register_entity', self.register_entity) - self.bus.on('detach_intent', self.handle_detach_intent) - self.bus.on('detach_skill', self.handle_detach_skill) - - self.registered_intents = [] - self.registered_entities = [] - self.max_words = 50 # if an utterance contains more words than this, don't attempt to match - LOG.debug('Loaded Padacioso intent parser.') - - def _match_level(self, utterances, limit, lang=None, - message: Optional[Message] = None): - """Match intent and make sure a certain level of confidence is reached. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - limit (float): required confidence level. - """ - LOG.debug(f'Padacioso Matching confidence > {limit}') - # call flatten in case someone is sending the old style list of tuples - utterances = flatten_list(utterances) - lang = lang or self.lang - padacioso_intent = self.calc_intent(utterances, lang, message) - if padacioso_intent is not None and padacioso_intent.conf > limit: - skill_id = padacioso_intent.name.split(':')[0] - return IntentMatch( - 'Padacioso', padacioso_intent.name, - padacioso_intent.matches, skill_id, padacioso_intent.sent) - - def match_high(self, utterances, lang=None, message=None): - """Intent matcher for high confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - return self._match_level(utterances, self.conf_high, lang, message) - - def match_medium(self, utterances, lang=None, message=None): - """Intent matcher for medium confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - return self._match_level(utterances, self.conf_med, lang, message) - - def match_low(self, utterances, lang=None, message=None): - """Intent matcher for low confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - return self._match_level(utterances, self.conf_low, lang, message) - - def __detach_intent(self, intent_name): - """ Remove an intent if it has been registered. - - Args: - intent_name (str): intent identifier - """ - if intent_name in self.registered_intents: - self.registered_intents.remove(intent_name) - for lang in self.containers: - self.containers[lang].remove_intent(intent_name) - - def handle_detach_intent(self, message): - """Messagebus handler for detaching padacioso intent. - - Args: - message (Message): message triggering action - """ - self.__detach_intent(message.data.get('intent_name')) - - def __detach_entity(self, name, lang): - """ Remove an entity. - - Args: - entity name - entity lang - """ - if lang in self.containers: - self.containers[lang].remove_entity(name) - - def handle_detach_skill(self, message): - """Messagebus handler for detaching all intents for skill. - - Args: - message (Message): message triggering action - """ - skill_id = message.data['skill_id'] - remove_list = [i for i in self.registered_intents if skill_id in i] - for i in remove_list: - self.__detach_intent(i) - skill_id_colon = skill_id + ":" - for en in self.registered_entities: - if en["name"].startswith(skill_id_colon): - self.__detach_entity(en["name"], en["lang"]) - - def _register_object(self, message, object_name, register_func): - """Generic method for registering a padacioso object. - - Args: - message (Message): trigger for action - object_name (str): type of entry to register - register_func (callable): function to call for registration - """ - file_name = message.data.get('file_name') - samples = message.data.get("samples") - name = message.data['name'] - - LOG.debug('Registering Padacioso ' + object_name + ': ' + name) - - if (not file_name or not isfile(file_name)) and not samples: - LOG.error('Could not find file ' + file_name) - return - - if not samples and isfile(file_name): - with open(file_name) as f: - samples = [l.strip() for l in f.readlines()] - - register_func(name, samples) - - def register_intent(self, message): - """Messagebus handler for registering intents. - - Args: - message (Message): message triggering action - """ - lang = message.data.get('lang', self.lang) - lang = lang.lower() - if lang in self.containers: - self.registered_intents.append(message.data['name']) - try: - self._register_object(message, 'intent', - self.containers[lang].add_intent) - except RuntimeError: - name = message.data.get('name', "") - # padacioso fails on reloading a skill, just ignore - if name not in self.containers[lang].intent_samples: - raise - - def register_entity(self, message): - """Messagebus handler for registering entities. - - Args: - message (Message): message triggering action - """ - lang = message.data.get('lang', self.lang) - lang = lang.lower() - if lang in self.containers: - self.registered_entities.append(message.data) - self._register_object(message, 'entity', - self.containers[lang].add_entity) - - def calc_intent(self, utterances: List[str], lang: str = None, - message: Optional[Message] = None) -> Optional[PadaciosoIntent]: - """ - Get the best intent match for the given list of utterances. Utilizes a - thread pool for overall faster execution. Note that this method is NOT - compatible with Padacioso, but is compatible with Padacioso. - @param utterances: list of string utterances to get an intent for - @param lang: language of utterances - @return: - """ - if isinstance(utterances, str): - utterances = [utterances] # backwards compat when arg was a single string - utterances = [u for u in utterances if len(u.split()) < self.max_words] - if not utterances: - LOG.error(f"utterance exceeds max size of {self.max_words} words, skipping padacioso match") - return None - - lang = lang or self.lang - lang = lang.lower() - sess = SessionManager.get(message) - if lang in self.containers: - intent_container = self.containers.get(lang) - intents = [_calc_padacioso_intent(utt, intent_container, sess) - for utt in utterances] - intents = [i for i in intents if i is not None] - # select best - if intents: - return max(intents, key=lambda k: k.conf) - - def shutdown(self): - self.bus.remove('padatious:register_intent', self.register_intent) - self.bus.remove('padatious:register_entity', self.register_entity) - self.bus.remove('detach_intent', self.handle_detach_intent) - self.bus.remove('detach_skill', self.handle_detach_skill) - - -@lru_cache(maxsize=3) # repeat calls under different conf levels wont re-run code -def _calc_padacioso_intent(utt: str, - intent_container: FallbackIntentContainer, - sess: Session) -> \ - Optional[PadaciosoIntent]: - """ - Try to match an utterance to an intent in an intent_container - @param args: tuple of (utterance, IntentContainer) - @return: matched PadaciosoIntent - """ - try: - intents = [i for i in intent_container.calc_intents(utt) - if i is not None - and i["name"] not in sess.blacklisted_intents - and i["name"].split(":")[0] not in sess.blacklisted_skills] - if len(intents) == 0: - return None - best_conf = max(x.get("conf", 0) for x in intents if x.get("name")) - ties = [i for i in intents if i.get("conf", 0) == best_conf] - if not ties: - return None - # TODO - how to disambiguate ? - intent = ties[0] - if "entities" in intent: - intent["matches"] = intent.pop("entities") - intent["sent"] = utt - intent = PadaciosoIntent(**intent) - intent.sent = utt - return intent - except Exception as e: - LOG.error(e) diff --git a/ovos_core/intent_services/padatious_service.py b/ovos_core/intent_services/padatious_service.py index 67d27c3235d8..3b3d5fefd2de 100644 --- a/ovos_core/intent_services/padatious_service.py +++ b/ovos_core/intent_services/padatious_service.py @@ -1,292 +1,2 @@ -# Copyright 2020 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. -# -"""Intent service wrapping padatious.""" -from functools import lru_cache -from os.path import expanduser, isfile -from threading import Event -from typing import List, Optional - -import padatious -from ovos_bus_client.message import Message -from ovos_bus_client.session import SessionManager, Session -from ovos_config.config import Configuration -from ovos_config.meta import get_xdg_base -from ovos_utils import flatten_list -from ovos_utils.log import LOG -from ovos_utils.xdg_utils import xdg_data_home -from padatious.match_data import MatchData as PadatiousIntent - -from ovos_plugin_manager.templates.pipeline import IntentMatch - - -class PadatiousMatcher: - """Matcher class to avoid redundancy in padatious intent matching.""" - - def __init__(self, service): - self.service = service - - def _match_level(self, utterances, limit, lang=None, message: Optional[Message] = None): - """Match intent and make sure a certain level of confidence is reached. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - limit (float): required confidence level. - """ - LOG.debug(f'Padatious Matching confidence > {limit}') - # call flatten in case someone is sending the old style list of tuples - utterances = flatten_list(utterances) - lang = lang or self.service.lang - padatious_intent = self.service.calc_intent(utterances, lang, message) - if padatious_intent is not None and padatious_intent.conf > limit: - skill_id = padatious_intent.name.split(':')[0] - return IntentMatch( - 'Padatious', padatious_intent.name, - padatious_intent.matches, skill_id, padatious_intent.sent) - - def match_high(self, utterances, lang=None, message=None): - """Intent matcher for high confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - return self._match_level(utterances, self.service.conf_high, lang, message) - - def match_medium(self, utterances, lang=None, message=None): - """Intent matcher for medium confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - return self._match_level(utterances, self.service.conf_med, lang, message) - - def match_low(self, utterances, lang=None, message=None): - """Intent matcher for low confidence. - - Args: - utterances (list of tuples): Utterances to parse, originals paired - with optional normalized version. - """ - return self._match_level(utterances, self.service.conf_low, lang, message) - - -class PadatiousService: - """Service class for padatious intent matching.""" - - def __init__(self, bus, config): - self.padatious_config = config - self.bus = bus - - core_config = Configuration() - self.lang = core_config.get("lang", "en-us") - langs = core_config.get('secondary_langs') or [] - if self.lang not in langs: - langs.append(self.lang) - - self.conf_high = self.padatious_config.get("conf_high") or 0.95 - self.conf_med = self.padatious_config.get("conf_med") or 0.8 - self.conf_low = self.padatious_config.get("conf_low") or 0.5 - - intent_cache = expanduser(self.padatious_config.get('intent_cache') or - f"{xdg_data_home()}/{get_xdg_base()}/intent_cache") - self.containers = {lang: padatious.IntentContainer(f"{intent_cache}/{lang}") - for lang in langs} - - self.bus.on('padatious:register_intent', self.register_intent) - self.bus.on('padatious:register_entity', self.register_entity) - self.bus.on('detach_intent', self.handle_detach_intent) - self.bus.on('detach_skill', self.handle_detach_skill) - self.bus.on('mycroft.skills.initialized', self.train) - - self.finished_training_event = Event() - self.finished_initial_train = False - - self.registered_intents = [] - self.registered_entities = [] - self.max_words = 50 # if an utterance contains more words than this, don't attempt to match - LOG.debug('Loaded Padatious intent parser.') - - def train(self, message=None): - """Perform padatious training. - - Args: - message (Message): optional triggering message - """ - self.finished_training_event.clear() - padatious_single_thread = self.padatious_config.get('single_thread', False) - if message is None: - single_thread = padatious_single_thread - else: - single_thread = message.data.get('single_thread', - padatious_single_thread) - for lang in self.containers: - self.containers[lang].train(single_thread=single_thread) - - LOG.debug('Training complete.') - self.finished_training_event.set() - if not self.finished_initial_train: - self.bus.emit(Message('mycroft.skills.trained')) - self.finished_initial_train = True - - def wait_and_train(self): - """Wait for minimum time between training and start training.""" - if not self.finished_initial_train: - return - self.train() - - def __detach_intent(self, intent_name): - """ Remove an intent if it has been registered. - - Args: - intent_name (str): intent identifier - """ - if intent_name in self.registered_intents: - self.registered_intents.remove(intent_name) - for lang in self.containers: - self.containers[lang].remove_intent(intent_name) - - def handle_detach_intent(self, message): - """Messagebus handler for detaching padatious intent. - - Args: - message (Message): message triggering action - """ - self.__detach_intent(message.data.get('intent_name')) - - def handle_detach_skill(self, message): - """Messagebus handler for detaching all intents for skill. - - Args: - message (Message): message triggering action - """ - skill_id = message.data['skill_id'] - remove_list = [i for i in self.registered_intents if skill_id in i] - for i in remove_list: - self.__detach_intent(i) - - def _register_object(self, message, object_name, register_func): - """Generic method for registering a padatious object. - - Args: - message (Message): trigger for action - object_name (str): type of entry to register - register_func (callable): function to call for registration - """ - file_name = message.data.get('file_name') - samples = message.data.get("samples") - name = message.data['name'] - - LOG.debug('Registering Padatious ' + object_name + ': ' + name) - - if (not file_name or not isfile(file_name)) and not samples: - LOG.error('Could not find file ' + file_name) - return - - if not samples and isfile(file_name): - with open(file_name) as f: - samples = [l.strip() for l in f.readlines()] - - register_func(name, samples) - - self.wait_and_train() - - def register_intent(self, message): - """Messagebus handler for registering intents. - - Args: - message (Message): message triggering action - """ - lang = message.data.get('lang', self.lang) - lang = lang.lower() - if lang in self.containers: - self.registered_intents.append(message.data['name']) - self._register_object(message, 'intent', self.containers[lang].add_intent) - - def register_entity(self, message): - """Messagebus handler for registering entities. - - Args: - message (Message): message triggering action - """ - lang = message.data.get('lang', self.lang) - lang = lang.lower() - if lang in self.containers: - self.registered_entities.append(message.data) - self._register_object(message, 'entity', - self.containers[lang].add_entity) - - def calc_intent(self, utterances: List[str], lang: str = None, - message: Optional[Message] = None) -> Optional[PadatiousIntent]: - """ - Get the best intent match for the given list of utterances. Utilizes a - thread pool for overall faster execution. Note that this method is NOT - compatible with Padatious, but is compatible with Padacioso. - @param utterances: list of string utterances to get an intent for - @param lang: language of utterances - @return: - """ - if isinstance(utterances, str): - utterances = [utterances] # backwards compat when arg was a single string - utterances = [u for u in utterances if len(u.split()) < self.max_words] - if not utterances: - LOG.error(f"utterance exceeds max size of {self.max_words} words, skipping padatious match") - return None - - lang = lang or self.lang - lang = lang.lower() - sess = SessionManager.get(message) - if lang in self.containers: - intent_container = self.containers.get(lang) - intents = [_calc_padatious_intent(utt, intent_container, sess) - for utt in utterances] - intents = [i for i in intents if i is not None] - # select best - if intents: - return max(intents, key=lambda k: k.conf) - - def shutdown(self): - self.bus.remove('padatious:register_intent', self.register_intent) - self.bus.remove('padatious:register_entity', self.register_entity) - self.bus.remove('detach_intent', self.handle_detach_intent) - self.bus.remove('detach_skill', self.handle_detach_skill) - self.bus.remove('mycroft.skills.initialized', self.train) - - -@lru_cache(maxsize=3) # repeat calls under different conf levels wont re-run code -def _calc_padatious_intent(utt: str, - intent_container: padatious.IntentContainer, - sess: Session) -> Optional[PadatiousIntent]: - """ - Try to match an utterance to an intent in an intent_container - @param utt: str - text to match intent against - - @return: matched PadatiousIntent - """ - try: - matches = [m for m in intent_container.calc_intents(utt) - if m.name not in sess.blacklisted_intents - and m.name.split(":")[0] not in sess.blacklisted_skills] - if len(matches) == 0: - return None - best_match = max(matches, key=lambda x: x.conf) - best_matches = ( - match for match in matches if match.conf == best_match.conf) - intent = min(best_matches, key=lambda x: sum(map(len, x.matches.values()))) - intent.sent = utt - return intent - except Exception as e: - LOG.error(e) +# backwards compat imports +from ovos_padatious.opm import PadatiousMatcher, PadatiousPipeline as PadatiousService diff --git a/ovos_core/intent_services/stop_service.py b/ovos_core/intent_services/stop_service.py index 7b129ab1ce5a..86a45e3f47cf 100644 --- a/ovos_core/intent_services/stop_service.py +++ b/ovos_core/intent_services/stop_service.py @@ -7,15 +7,14 @@ 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 IntentMatch, PipelinePlugin from ovos_utils import flatten_list from ovos_utils.bracket_expansion import expand_options from ovos_utils.log import LOG from ovos_utils.parse import match_one -from ovos_plugin_manager.templates.pipeline import IntentMatch - -class StopService: +class StopService(PipelinePlugin): """Intent Service thats handles stopping skills.""" def __init__(self, bus): @@ -43,7 +42,7 @@ def config(self): """ return Configuration().get("skills", {}).get("stop") or {} - def get_active_skills(self, message: Optional[Message]=None): + def get_active_skills(self, message: Optional[Message] = None): """Active skill ids ordered by converse priority this represents the order in which stop will be called @@ -147,10 +146,10 @@ def match_stop_high(self, utterances: List[str], lang: str, message: Message) -> # emit a global stop, full stop anything OVOS is doing self.bus.emit(message.reply("mycroft.stop", {})) return IntentMatch(intent_service='Stop', - intent_type=True, - intent_data={"conf": conf}, - skill_id=None, - utterance=utterance) + intent_type=True, + intent_data={"conf": conf}, + skill_id=None, + utterance=utterance) if is_stop: # check if any skill can stop @@ -161,10 +160,10 @@ def match_stop_high(self, utterances: List[str], lang: str, message: Message) -> if self.stop_skill(skill_id, message): return IntentMatch(intent_service='Stop', - intent_type=True, - intent_data={"conf": conf}, - skill_id=skill_id, - utterance=utterance) + intent_type=True, + intent_data={"conf": conf}, + skill_id=skill_id, + utterance=utterance) return None def match_stop_medium(self, utterances: List[str], lang: str, message: Message) -> Optional[IntentMatch]: @@ -229,19 +228,19 @@ def match_stop_low(self, utterances: List[str], lang: str, message: Message) -> if self.stop_skill(skill_id, message): return IntentMatch(intent_service='Stop', - intent_type=True, - # emit instead of intent message - intent_data={"conf": conf}, - skill_id=skill_id, utterance=utterance) + intent_type=True, + # emit instead of intent message + intent_data={"conf": conf}, + skill_id=skill_id, utterance=utterance) # emit a global stop, full stop anything OVOS is doing self.bus.emit(message.reply("mycroft.stop", {})) return IntentMatch(intent_service='Stop', - intent_type=True, - # emit instead of intent message {"conf": conf}, - intent_data={}, - skill_id=None, - utterance=utterance) + intent_type=True, + # emit instead of intent message {"conf": conf}, + intent_data={}, + skill_id=None, + utterance=utterance) def voc_match(self, utt: str, voc_filename: str, lang: str, exact: bool = False): diff --git a/requirements/lgpl.txt b/requirements/lgpl.txt index 35c25ee65b17..5979e90b3eb6 100644 --- a/requirements/lgpl.txt +++ b/requirements/lgpl.txt @@ -1,3 +1,2 @@ -padatious>=0.4.8, < 0.5.0 -fann2>=1.0.7, < 1.1.0 -padaos>=0.1, < 0.2 \ No newline at end of file +ovos_padatious>=0.1.0,<1.0.0 +fann2>=1.0.7, < 1.1.0 \ No newline at end of file diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 185f310c8ae6..41dfb557bee7 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -3,8 +3,8 @@ python-dateutil>=2.6, <3.0 watchdog>=2.1, <3.0 combo-lock>=0.2.2, <0.3 -padacioso>=0.1.0,<1.0.0 -adapt-parser>=1.0.0, <2.0.0 +padacioso>=0.2.2,<1.0.0 +ovos-adapt-parser>=0.1.0, <1.0.0 ovos-utils>=0.1.0,<1.0.0 ovos_bus_client>=0.1.0,<1.0.0 diff --git a/setup.py b/setup.py index 95c9093da2c1..bd7232b49ba1 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,14 @@ def required(requirements_file): with open(os.path.join(BASEDIR, "README.md"), "r") as f: long_description = f.read() +PLUGIN_ENTRY_POINT = [ + 'ovos-converse-pipeline-plugin=ovos_core.intent_services.converse_service:ConverseService', + 'ovos-fallback-pipeline-plugin=ovos_core.intent_services.fallback_service:FallbackService', + 'ovos-ocp-pipeline-plugin=ovos_core.intent_services.ocp_service:OCPPipelineMatcher', + 'ovos-stop-pipeline-plugin=ovos_core.intent_services.stop_service:StopService', + 'ovos-common-query-pipeline-plugin=ovos_core.intent_services.commonqa_service:CommonQAService' +] + setup( name='ovos-core', @@ -87,6 +95,7 @@ def required(requirements_file): "License :: OSI Approved :: Apache Software License", ], entry_points={ + 'opm.pipeline': PLUGIN_ENTRY_POINT, 'console_scripts': [ 'ovos-core=ovos_core.__main__:main', # TODO - remove below console_scripts in 0.1.0 (backwards compat) diff --git a/test/unittests/common_query/ovos_tskill_fakewiki/__init__.py b/test/unittests/common_query/ovos_tskill_fakewiki/__init__.py index ed3f4b7ffb0f..48c0212e50f2 100644 --- a/test/unittests/common_query/ovos_tskill_fakewiki/__init__.py +++ b/test/unittests/common_query/ovos_tskill_fakewiki/__init__.py @@ -1,4 +1,4 @@ -from adapt.intent import IntentBuilder +from ovos_adapt.intent import IntentBuilder from ovos_workshop.skills.common_query_skill import CommonQuerySkill, CQSMatchLevel from mycroft.skills.core import intent_handler diff --git a/test/unittests/skills/decorator_test_skill.py b/test/unittests/skills/decorator_test_skill.py index cff690c73d3a..3591370402c0 100644 --- a/test/unittests/skills/decorator_test_skill.py +++ b/test/unittests/skills/decorator_test_skill.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from adapt.intent import IntentBuilder +from ovos_adapt.intent import IntentBuilder from ovos_workshop.skills.ovos import OVOSSkill from ovos_workshop.decorators import intent_handler, intent_file_handler diff --git a/test/unittests/skills/test_intent_service.py b/test/unittests/skills/test_intent_service.py index 986c0550b500..83dc2f07a0b8 100644 --- a/test/unittests/skills/test_intent_service.py +++ b/test/unittests/skills/test_intent_service.py @@ -19,7 +19,7 @@ from ovos_config import Configuration from ovos_config.locale import setup_locale from ovos_core.intent_services import IntentService -from ovos_core.intent_services.adapt_service import ContextManager +from ovos_adapt.opm import ContextManager from ovos_workshop.intents import IntentBuilder, Intent as AdaptIntent from test.util import base_config diff --git a/test/unittests/skills/test_intent_service_interface.py b/test/unittests/skills/test_intent_service_interface.py index 70a381c7fc6e..691c2dea3523 100644 --- a/test/unittests/skills/test_intent_service_interface.py +++ b/test/unittests/skills/test_intent_service_interface.py @@ -1,6 +1,6 @@ import unittest -from adapt.intent import IntentBuilder +from ovos_adapt.intent import IntentBuilder from mycroft.skills.intent_service_interface import IntentServiceInterface diff --git a/test/unittests/skills/test_mycroft_skill.py b/test/unittests/skills/test_mycroft_skill.py index 0434cdd7b4dd..2797a57f23a4 100644 --- a/test/unittests/skills/test_mycroft_skill.py +++ b/test/unittests/skills/test_mycroft_skill.py @@ -21,7 +21,7 @@ from os.path import join, dirname, abspath from unittest.mock import MagicMock, patch -from adapt.intent import IntentBuilder +from ovos_adapt.intent import IntentBuilder from ovos_config import Configuration from mycroft.skills.skill_data import (load_regex_from_file, load_regex,