Merge pull request #2599 from forslund/refactor/intent-service

Refactor intent service
pull/2704/head
Kris Gesling 2020-09-23 10:24:16 +09:30 committed by GitHub
commit ae72ebd247
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 995 additions and 324 deletions

View File

@ -43,7 +43,6 @@ from mycroft.util.log import LOG
from .core import FallbackSkill
from .event_scheduler import EventScheduler
from .intent_service import IntentService
from .padatious_service import PadatiousService
from .skill_manager import SkillManager
RASPBERRY_PI_PLATFORMS = ('mycroft_mark_1', 'picroft', 'mycroft_mark_2pi')
@ -240,14 +239,14 @@ def _register_intent_services(bus):
bus: messagebus client to register the services on
"""
service = IntentService(bus)
try:
PadatiousService(bus, service)
except Exception as e:
LOG.exception('Failed to create padatious handlers '
'({})'.format(repr(e)))
# Register handler to trigger fallback system
bus.on(
'mycroft.skills.fallback',
FallbackSkill.make_intent_failure_handler(bus)
)
# Backwards compatibility TODO: remove in 20.08
bus.on('intent_failure', FallbackSkill.make_intent_failure_handler(bus))
return service
def _initialize_skill_manager(bus, watchdog):

View File

@ -61,18 +61,25 @@ class FallbackSkill(MycroftSkill):
"""Goes through all fallback handlers until one returns True"""
def handler(message):
start, stop = message.data.get('fallback_range', (0, 101))
# indicate fallback handling start
LOG.debug('Checking fallbacks in range '
'{} - {}'.format(start, stop))
bus.emit(message.forward("mycroft.skill.handler.start",
data={'handler': "fallback"}))
stopwatch = Stopwatch()
handler_name = None
with stopwatch:
for _, handler in sorted(cls.fallback_handlers.items(),
key=operator.itemgetter(0)):
sorted_handlers = sorted(cls.fallback_handlers.items(),
key=operator.itemgetter(0))
handlers = [f[1] for f in sorted_handlers
if start <= f[0] < stop]
for handler in handlers:
try:
if handler(message):
# indicate completion
# indicate completion
status = True
handler_name = get_handler_name(handler)
bus.emit(message.forward(
'mycroft.skill.handler.complete',
@ -81,14 +88,21 @@ class FallbackSkill(MycroftSkill):
break
except Exception:
LOG.exception('Exception in fallback.')
else: # No fallback could handle the utterance
bus.emit(message.forward('complete_intent_failure'))
warning = "No fallback could handle intent."
LOG.warning(warning)
else:
status = False
# indicate completion with exception
warning = 'No fallback could handle intent.'
bus.emit(message.forward('mycroft.skill.handler.complete',
data={'handler': "fallback",
'exception': warning}))
if 'fallback_range' not in message.data:
# Old system TODO: Remove in 20.08
# No fallback could handle the utterance
bus.emit(message.forward('complete_intent_failure'))
LOG.warning(warning)
# return if the utterance was handled to the caller
bus.emit(message.response(data={'handled': status}))
# Send timing metric
if message.context.get('ident'):

View File

@ -12,141 +12,22 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Mycroft's intent service, providing intent parsing since forever!"""
from copy import copy
import time
from adapt.context import ContextManagerFrame
from adapt.engine import IntentDeterminationEngine
from adapt.intent import IntentBuilder
from mycroft.configuration import Configuration
from mycroft.util.lang import set_active_lang
from mycroft.util.log import LOG
from mycroft.util.parse import normalize
from mycroft.metrics import report_timing, Stopwatch
from mycroft.skills.padatious_service import PadatiousService
from .intent_services import (
AdaptService, AdaptIntent, FallbackService, PadatiousService, IntentMatch
)
from .intent_service_interface import open_intent_envelope
class AdaptIntent(IntentBuilder):
def __init__(self, name=''):
super().__init__(name)
def workaround_one_of_context(best_intent):
""" Handle Adapt issue with context injection combined with one_of.
For all entries in the intent result where the value is None try to
populate using a value from the __tags__ structure.
"""
for key in best_intent:
if best_intent[key] is None:
for t in best_intent['__tags__']:
if key in t:
best_intent[key] = t[key][0]['entities'][0]['key']
return best_intent
class ContextManager:
"""
ContextManager
Use to track context throughout the course of a conversational session.
How to manage a session's lifecycle is not captured here.
"""
def __init__(self, timeout):
self.frame_stack = []
self.timeout = timeout * 60 # minutes to seconds
def clear_context(self):
self.frame_stack = []
def remove_context(self, context_id):
self.frame_stack = [(f, t) for (f, t) in self.frame_stack
if context_id in f.entities[0].get('data', [])]
def inject_context(self, entity, metadata=None):
"""
Args:
entity(object): Format example...
{'data': 'Entity tag as <str>',
'key': 'entity proper name as <str>',
'confidence': <float>'
}
metadata(object): dict, arbitrary metadata about entity injected
"""
metadata = metadata or {}
try:
if len(self.frame_stack) > 0:
top_frame = self.frame_stack[0]
else:
top_frame = None
if top_frame and top_frame[0].metadata_matches(metadata):
top_frame[0].merge_context(entity, metadata)
else:
frame = ContextManagerFrame(entities=[entity],
metadata=metadata.copy())
self.frame_stack.insert(0, (frame, time.time()))
except (IndexError, KeyError):
pass
def get_context(self, max_frames=None, missing_entities=None):
""" Constructs a list of entities from the context.
Args:
max_frames(int): maximum number of frames to look back
missing_entities(list of str): a list or set of tag names,
as strings
Returns:
list: a list of entities
"""
missing_entities = missing_entities or []
relevant_frames = [frame[0] for frame in self.frame_stack if
time.time() - frame[1] < self.timeout]
if not max_frames or max_frames > len(relevant_frames):
max_frames = len(relevant_frames)
missing_entities = list(missing_entities)
context = []
last = ''
depth = 0
for i in range(max_frames):
frame_entities = [entity.copy() for entity in
relevant_frames[i].entities]
for entity in frame_entities:
entity['confidence'] = entity.get('confidence', 1.0) \
/ (2.0 + depth)
context += frame_entities
# Update depth
if entity['origin'] != last or entity['origin'] == '':
depth += 1
last = entity['origin']
result = []
if len(missing_entities) > 0:
for entity in context:
if entity.get('data') in missing_entities:
result.append(entity)
# NOTE: this implies that we will only ever get one
# of an entity kind from context, unless specified
# multiple times in missing_entities. Cannot get
# an arbitrary number of an entity kind.
missing_entities.remove(entity.get('data'))
else:
result = context
# Only use the latest instance of each keyword
stripped = []
processed = []
for f in result:
keyword = f['data'][0][1]
if keyword not in processed:
stripped.append(f)
processed.append(keyword)
result = stripped
return result
# TODO: Remove in 20.08 (Backwards compatibility)
from .intent_services.adapt_service import ContextManager
def _get_message_lang(message):
@ -162,20 +43,57 @@ def _get_message_lang(message):
return message.data.get('lang', default_lang).lower()
class IntentService:
def __init__(self, bus):
self.config = Configuration.get().get('context', {})
self.engine = IntentDeterminationEngine()
def _normalize_all_utterances(utterances):
"""Create normalized versions and pair them with the original utterance.
This will create a list of tuples with the original utterance as the
first item and if normalizing changes the utterance the normalized version
will be set as the second item in the tuple, if normalization doesn't
change anything the tuple will only have the "raw" original utterance.
Arguments:
utterances (list): list of utterances to normalize
Returns:
list of tuples, [(original utterance, normalized) ... ]
"""
# normalize() changes "it's a boy" to "it is a boy", etc.
norm_utterances = [normalize(u.lower(), remove_articles=False)
for u in utterances]
# Create pairs of original and normalized counterparts for each entry
# in the input list.
combined = []
for utt, norm in zip(utterances, norm_utterances):
if utt == norm:
combined.append((utt,))
else:
combined.append((utt, norm))
LOG.debug("Utterances: {}".format(combined))
return combined
class IntentService:
"""Mycroft 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):
# Dictionary for translating a skill id to a name
self.skill_names = {}
# Context related intializations
self.context_keywords = self.config.get('keywords', [])
self.context_max_frames = self.config.get('max_frames', 3)
self.context_timeout = self.config.get('timeout', 2)
self.context_greedy = self.config.get('greedy', False)
self.context_manager = ContextManager(self.context_timeout)
self.bus = bus
self.skill_names = {}
config = Configuration.get()
self.adapt_service = AdaptService(config.get('context', {}))
try:
self.padatious_service = PadatiousService(bus, config['padatious'])
except Exception as err:
LOG.exception('Failed to create padatious handlers '
'({})'.format(repr(err)))
self.fallback = FallbackService(bus)
self.bus.on('register_vocab', self.handle_register_vocab)
self.bus.on('register_intent', self.handle_register_intent)
self.bus.on('recognizer_loop:utterance', self.handle_utterance)
@ -185,6 +103,7 @@ class IntentService:
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)
# Converse method
self.bus.on('mycroft.speech.recognition.unknown', self.reset_converse)
self.bus.on('mycroft.skills.loaded', self.update_skill_name_dict)
@ -197,7 +116,6 @@ class IntentService:
self.converse_timeout = 5 # minutes to prune active_skills
# Intents API
self.registered_intents = []
self.registered_vocab = []
self.bus.on('intent.service.adapt.get', self.handle_get_adapt)
self.bus.on('intent.service.intent.get', self.handle_get_intent)
@ -208,6 +126,11 @@ class IntentService:
self.bus.on('intent.service.adapt.vocab.manifest.get',
self.handle_vocab_manifest)
@property
def registered_intents(self):
return [parser.__dict__
for parser in self.adapt_service.engine.intent_parsers]
def update_skill_name_dict(self, message):
"""Messagebus handler, updates dict of id to skill name conversions."""
self.skill_names[message.data['id']] = message.data['name']
@ -231,25 +154,45 @@ class IntentService:
self.do_converse(None, skill[0], lang, message)
def do_converse(self, utterances, skill_id, lang, message):
"""Call skill and ask if they want to process the utterance.
Arguments:
utterances (list of tuples): utterances paired with normalized
versions.
skill_id: skill to query.
lang (str): current language
message (Message): message containing interaction info.
"""
converse_msg = (message.reply("skill.converse.request", {
"skill_id": skill_id, "utterances": utterances, "lang": lang}))
result = self.bus.wait_for_response(converse_msg,
'skill.converse.response')
if result and 'error' in result.data:
self.handle_converse_error(result)
return False
ret = False
elif result is not None:
return result.data.get('result', False)
ret = result.data.get('result', False)
else:
return False
ret = False
return ret
def handle_converse_error(self, message):
"""Handle error in converse system.
Arguments:
message (Message): info about the error.
"""
LOG.error(message.data['error'])
skill_id = message.data["skill_id"]
if message.data["error"] == "skill id does not exist":
self.remove_active_skill(skill_id)
def remove_active_skill(self, skill_id):
"""Remove a skill from being targetable by converse.
Arguments:
skill_id (str): skill to remove
"""
for skill in self.active_skills:
if skill[0] == skill_id:
self.active_skills.remove(skill)
@ -273,42 +216,33 @@ class IntentService:
LOG.warning('Skill ID was empty, won\'t add to list of '
'active skills.')
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
"""
for tag in intent['__tags__']:
if 'entities' not in tag:
continue
context_entity = tag['entities'][0]
if self.context_greedy:
self.context_manager.inject_context(context_entity)
elif context_entity['data'][0][1] in self.context_keywords:
self.context_manager.inject_context(context_entity)
def send_metrics(self, intent, context, stopwatch):
"""Send timing metrics to the backend.
NOTE: This only applies to those with Opt In.
Arguments:
intent (IntentMatch or None): intet match info
context (dict): context info about the interaction
stopwatch (StopWatch): Timing info about the skill parsing.
"""
ident = context['ident'] if 'ident' in context else None
if intent:
# Determine what handled the intent
if intent and intent.intent_service == 'Converse':
intent_type = '{}:{}'.format(intent.skill_id, 'converse')
elif intent and intent.intent_service == 'Fallback':
intent_type = 'fallback'
elif intent: # Handled by an other intent parser
# Recreate skill name from skill id
parts = intent.get('intent_type', '').split(':')
parts = intent.intent_type.split(':')
intent_type = self.get_skill_name(parts[0])
if len(parts) > 1:
intent_type = ':'.join([intent_type] + parts[1:])
report_timing(ident, 'intent_service', stopwatch,
{'intent_type': intent_type})
else:
report_timing(ident, 'intent_service', stopwatch,
{'intent_type': 'intent_failure'})
else: # No intent was found
intent_type = 'intent_failure'
report_timing(ident, 'intent_service', stopwatch,
{'intent_type': intent_type})
def handle_utterance(self, message):
"""Main entrypoint for handling user utterances with Mycroft skills
@ -321,13 +255,16 @@ class IntentService:
1) Active skills attempt to handle using converse()
2) Padatious high match intents (conf > 0.95)
3) Adapt intent handlers
5) Fallbacks:
- Padatious near match intents (conf > 0.8)
- General fallbacks
- Padatious loose match intents (conf > 0.5)
- Unknown intent handler
5) High Priority Fallbacks
6) Padatious near match intents (conf > 0.8)
7) General Fallbacks
8) Padatious loose match intents (conf > 0.5)
9) Catch all fallbacks including Unknown intent handler
Args:
If all these fail the complete_intent_failure message will be sent
and a generic info of the failure will be spoken.
Arguments:
message (Message): The messagebus data
"""
try:
@ -335,87 +272,57 @@ class IntentService:
set_active_lang(lang)
utterances = message.data.get('utterances', [])
# normalize() changes "it's a boy" to "it is a boy", etc.
norm_utterances = [normalize(u.lower(), remove_articles=False)
for u in utterances]
# Build list with raw utterance(s) first, then optionally a
# normalized version following.
combined = utterances + list(set(norm_utterances) -
set(utterances))
LOG.debug("Utterances: {}".format(combined))
combined = _normalize_all_utterances(utterances)
stopwatch = Stopwatch()
intent = None
padatious_intent = None
# List of functions to use to match the utterance with intent.
# These are listed in priority order.
match_funcs = [
self._converse, self.padatious_service.match_high,
self.adapt_service.match_intent, self.fallback.high_prio,
self.padatious_service.match_medium, self.fallback.medium_prio,
self.padatious_service.match_low, self.fallback.low_prio
]
match = None
with stopwatch:
# Give active skills an opportunity to handle the utterance
converse = self._converse(combined, lang, message)
# Loop through the matching functions until a match is found.
for match_func in match_funcs:
match = match_func(combined, lang, message)
if match:
break
if match:
if match.skill_id:
self.add_active_skill(match.skill_id)
# If the service didn't report back the skill_id it
# takes on the responsibility of making the skill "active"
if not converse:
# No conversation, use intent system to handle utterance
intent = self._adapt_intent_match(utterances,
norm_utterances, lang)
for utt in combined:
_intent = PadatiousService.instance.calc_intent(utt)
if _intent:
best = padatious_intent.conf if padatious_intent \
else 0.0
if best < _intent.conf:
padatious_intent = _intent
LOG.debug("Padatious intent: {}".format(padatious_intent))
LOG.debug(" Adapt intent: {}".format(intent))
# Launch skill if not handled by the match function
if match.intent_type:
reply = message.reply(match.intent_type, match.intent_data)
self.bus.emit(reply)
if converse:
# Report that converse handled the intent and return
LOG.debug("Handled in converse()")
ident = None
if message.context and 'ident' in message.context:
ident = message.context['ident']
report_timing(ident, 'intent_service', stopwatch,
{'intent_type': 'converse'})
return
elif (intent and intent.get('confidence', 0.0) > 0.0 and
not (padatious_intent and padatious_intent.conf >= 0.95)):
# Send the message to the Adapt intent's handler unless
# Padatious is REALLY sure it was directed at it instead.
self.update_context(intent)
# update active skills
skill_id = intent['intent_type'].split(":")[0]
self.add_active_skill(skill_id)
# Adapt doesn't handle context injection for one_of keywords
# correctly. Workaround this issue if possible.
try:
intent = workaround_one_of_context(intent)
except LookupError:
LOG.error('Error during workaround_one_of_context')
reply = message.reply(intent.get('intent_type'), intent)
else:
# Allow fallback system to handle utterance
# NOTE: A matched padatious_intent is handled this way, too
# TODO: Need to redefine intent_failure when STT can return
# multiple hypothesis -- i.e. len(utterances) > 1
reply = message.reply('intent_failure',
{'utterance': utterances[0],
'norm_utt': norm_utterances[0],
'lang': lang})
self.bus.emit(reply)
self.send_metrics(intent, message.context, stopwatch)
except Exception as e:
LOG.exception(e)
# Nothing was able to handle the intent
# Ask politely for forgiveness for failing in this vital task
self.send_complete_intent_failure(message)
self.send_metrics(match, message.context, stopwatch)
except Exception as err:
LOG.exception(err)
def _converse(self, utterances, lang, message):
"""Give active skills a chance at the utterance
Args:
Arguments:
utterances (list): list of utterances
lang (string): 4 letter ISO language code
message (Message): message to use to generate reply
Returns:
bool: True if converse handled it, False if no skill processes it
IntentMatch if handled otherwise None.
"""
utterances = [item for tup in utterances for item in tup]
# check for conversation time-out
self.active_skills = [skill for skill in self.active_skills
if time.time() - skill[
@ -426,81 +333,57 @@ class IntentService:
if self.do_converse(utterances, skill[0], lang, message):
# update timestamp, or there will be a timeout where
# intent stops conversing whether its being used or not
self.add_active_skill(skill[0])
return True
return False
return IntentMatch('Converse', None, None, skill[0])
return None
def _adapt_intent_match(self, raw_utt, norm_utt, lang):
"""Run the Adapt engine to search for an matching intent
def send_complete_intent_failure(self, message):
"""Send a message that no skill could handle the utterance.
Args:
raw_utt (list): list of utterances
norm_utt (list): same list of utterances, normalized
lang (string): language code, e.g "en-us"
Returns:
Intent structure, or None if no match was found.
Arguments:
message (Message): original message to forward from
"""
best_intent = None
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)
if conf > best:
best_intent = intent
# TODO - Shouldn't Adapt do this?
best_intent['utterance'] = utt
for idx, utt in enumerate(raw_utt):
try:
intents = [i for i in self.engine.determine_intent(
utt, 100,
include_tags=True,
context_manager=self.context_manager)]
if intents:
take_best(intents[0], utt)
# Also test the normalized version, but set the utterance to
# the raw version so skill has access to original STT
norm_intents = [i for i in self.engine.determine_intent(
norm_utt[idx], 100,
include_tags=True,
context_manager=self.context_manager)]
if norm_intents:
take_best(norm_intents[0], utt)
except Exception as e:
LOG.exception(e)
return best_intent
self.bus.emit(message.forward('complete_intent_failure'))
def handle_register_vocab(self, message):
"""Register adapt vocabulary.
Arguments:
message (Message): message containing vocab info
"""
start_concept = message.data.get('start')
end_concept = message.data.get('end')
regex_str = message.data.get('regex')
alias_of = message.data.get('alias_of')
if regex_str:
self.engine.register_regex_entity(regex_str)
else:
self.engine.register_entity(
start_concept, end_concept, alias_of=alias_of)
self.adapt_service.register_vocab(start_concept, end_concept,
alias_of, regex_str)
self.registered_vocab.append(message.data)
def handle_register_intent(self, message):
"""Register adapt intent.
Arguments:
message (Message): message containing intent info
"""
intent = open_intent_envelope(message)
self.engine.register_intent_parser(intent)
self.adapt_service.register_intent(intent)
def handle_detach_intent(self, message):
"""Remover adapt intent.
Arguments:
message (Message): message containing intent info
"""
intent_name = message.data.get('intent_name')
new_parsers = [
p for p in self.engine.intent_parsers if p.name != intent_name]
self.engine.intent_parsers = new_parsers
self.adapt_service.detach_intent(intent_name)
def handle_detach_skill(self, message):
"""Remove all intents registered for a specific skill.
Arguments:
message (Message): message containing intent info
"""
skill_id = message.data.get('skill_id')
new_parsers = [
p for p in self.engine.intent_parsers if
not p.name.startswith(skill_id)]
self.engine.intent_parsers = new_parsers
self.adapt_service.detach_skill(skill_id)
def handle_add_context(self, message):
"""Add context
@ -521,7 +404,7 @@ class IntentService:
entity['match'] = word
entity['key'] = word
entity['origin'] = origin
self.context_manager.inject_context(entity)
self.adapt_service.context_manager.inject_context(entity)
def handle_remove_context(self, message):
"""Remove specific context
@ -531,49 +414,77 @@ class IntentService:
"""
context = message.data.get('context')
if context:
self.context_manager.remove_context(context)
self.adapt_service.context_manager.remove_context(context)
def handle_clear_context(self, message):
def handle_clear_context(self, _):
"""Clears all keywords from context """
self.context_manager.clear_context()
self.adapt_service.context_manager.clear_context()
def handle_get_adapt(self, message):
"""handler getting the adapt response for an utterance.
Arguments:
message (Message): message containing utterance
"""
utterance = message.data["utterance"]
lang = message.data.get("lang", "en-us")
norm = normalize(utterance, lang, remove_articles=False)
intent = self._adapt_intent_match([utterance], [norm], lang)
combined = _normalize_all_utterances([utterance])
intent = self.adapt_service.match_intent(combined, lang)
intent_data = intent.intent_data if intent else None
self.bus.emit(message.reply("intent.service.adapt.reply",
{"intent": intent}))
{"intent": intent_data}))
def handle_get_intent(self, message):
"""Get intent from either adapt or padatious.
Arguments:
message (Message): message containing utterance
"""
utterance = message.data["utterance"]
lang = message.data.get("lang", "en-us")
norm = normalize(utterance, lang, remove_articles=False)
intent = self._adapt_intent_match([utterance], [norm], lang)
combined = _normalize_all_utterances([utterance])
adapt_intent = self.adapt_service.match_intent(combined, lang)
# Adapt intent's handler is used unless
# Padatious is REALLY sure it was directed at it instead.
padatious_intent = PadatiousService.instance.calc_intent(utterance)
if not padatious_intent and norm != utterance:
padatious_intent = PadatiousService.instance.calc_intent(norm)
if intent is None or (
padatious_intent and padatious_intent.conf >= 0.95):
intent = padatious_intent.__dict__
padatious_intent = self.padatious_service.match_high(combined)
intent = padatious_intent or adapt_intent
intent_data = intent.intent_data if intent else None
self.bus.emit(message.reply("intent.service.intent.reply",
{"intent": intent}))
{"intent": intent_data}))
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 handle_get_active_skills(self, message):
"""Send active skills to caller.
Argument:
message: query message to reply to.
"""
self.bus.emit(message.reply("intent.service.active_skills.reply",
{"skills": [s[0] for s in
self.active_skills]}))
def handle_manifest(self, message):
"""Send adapt intent manifest to caller.
Argument:
message: query message to reply to.
"""
self.bus.emit(message.reply("intent.service.adapt.manifest",
{"intents": self.registered_intents}))
def handle_vocab_manifest(self, message):
"""Send adapt vocabulary manifest to caller.
Argument:
message: query message to reply to.
"""
self.bus.emit(message.reply("intent.service.adapt.vocab.manifest",
{"vocab": self.registered_vocab}))

View File

@ -0,0 +1,4 @@
from .adapt_service import AdaptService, AdaptIntent
from .base import IntentMatch
from .fallback_service import FallbackService
from .padatious_service import PadatiousService

View File

@ -0,0 +1,263 @@
# 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."""
import time
from adapt.context import ContextManagerFrame
from adapt.engine import IntentDeterminationEngine
from adapt.intent import IntentBuilder
from mycroft.util.log import LOG
from .base import IntentMatch
class AdaptIntent(IntentBuilder):
"""Wrapper for IntentBuilder setting a blank name.
This is mainly here for backwards compatibility, adapt now support
automatically named IntentBulders.
"""
def _strip_result(context_features):
"""Keep only the latest instance of each keyword.
Arguments
context_features (iterable): context features to check.
"""
stripped = []
processed = []
for feature in context_features:
keyword = feature['data'][0][1]
if keyword not in processed:
stripped.append(feature)
processed.append(keyword)
return stripped
class ContextManager:
"""Adapt Context Manager
Use to track context throughout the course of a conversational session.
How to manage a session's lifecycle is not captured here.
"""
def __init__(self, timeout):
self.frame_stack = []
self.timeout = timeout * 60 # minutes to seconds
def clear_context(self):
"""Remove all contexts."""
self.frame_stack = []
def remove_context(self, context_id):
"""Remove a specific context entry.
Arguments:
context_id (str): context entry to remove
"""
self.frame_stack = [(f, t) for (f, t) in self.frame_stack
if context_id in f.entities[0].get('data', [])]
def inject_context(self, entity, metadata=None):
"""
Args:
entity(object): Format example...
{'data': 'Entity tag as <str>',
'key': 'entity proper name as <str>',
'confidence': <float>'
}
metadata(object): dict, arbitrary metadata about entity injected
"""
metadata = metadata or {}
try:
if self.frame_stack:
top_frame = self.frame_stack[0]
else:
top_frame = None
if top_frame and top_frame[0].metadata_matches(metadata):
top_frame[0].merge_context(entity, metadata)
else:
frame = ContextManagerFrame(entities=[entity],
metadata=metadata.copy())
self.frame_stack.insert(0, (frame, time.time()))
except (IndexError, KeyError):
pass
def get_context(self, max_frames=None, missing_entities=None):
""" Constructs a list of entities from the context.
Args:
max_frames(int): maximum number of frames to look back
missing_entities(list of str): a list or set of tag names,
as strings
Returns:
list: a list of entities
"""
missing_entities = missing_entities or []
relevant_frames = [frame[0] for frame in self.frame_stack if
time.time() - frame[1] < self.timeout]
if not max_frames or max_frames > len(relevant_frames):
max_frames = len(relevant_frames)
missing_entities = list(missing_entities)
context = []
last = ''
depth = 0
entity = {}
for i in range(max_frames):
frame_entities = [entity.copy() for entity in
relevant_frames[i].entities]
for entity in frame_entities:
entity['confidence'] = entity.get('confidence', 1.0) \
/ (2.0 + depth)
context += frame_entities
# Update depth
if entity['origin'] != last or entity['origin'] == '':
depth += 1
last = entity['origin']
result = []
if missing_entities:
for entity in context:
if entity.get('data') in missing_entities:
result.append(entity)
# NOTE: this implies that we will only ever get one
# of an entity kind from context, unless specified
# multiple times in missing_entities. Cannot get
# an arbitrary number of an entity kind.
missing_entities.remove(entity.get('data'))
else:
result = context
# Only use the latest keyword
return _strip_result(result)
class AdaptService:
"""Intent service wrapping the Apdapt intent Parser."""
def __init__(self, config):
self.config = config
self.engine = IntentDeterminationEngine()
# Context related intializations
self.context_keywords = self.config.get('keywords', [])
self.context_max_frames = self.config.get('max_frames', 3)
self.context_timeout = self.config.get('timeout', 2)
self.context_greedy = self.config.get('greedy', False)
self.context_manager = ContextManager(self.context_timeout)
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
"""
for tag in intent['__tags__']:
if 'entities' not in tag:
continue
context_entity = tag['entities'][0]
if self.context_greedy:
self.context_manager.inject_context(context_entity)
elif context_entity['data'][0][1] in self.context_keywords:
self.context_manager.inject_context(context_entity)
def match_intent(self, utterances, _=None, __=None):
"""Run the Adapt engine to search for an matching intent.
Arguments:
utterances (iterable): iterable of utterances, expected order
[raw, normalized, other]
Returns:
Intent structure, or None if no match was found.
"""
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)
if conf > best:
best_intent = intent
# TODO - Shouldn't Adapt do this?
best_intent['utterance'] = utt
for utt_tup in utterances:
for utt in utt_tup:
try:
intents = [i for i in self.engine.determine_intent(
utt, 100,
include_tags=True,
context_manager=self.context_manager)]
if intents:
take_best(intents[0], utt_tup[0])
except Exception as err:
LOG.exception(err)
if best_intent:
self.update_context(best_intent)
skill_id = best_intent['intent_type'].split(":")[0]
ret = IntentMatch(
'Adapt', best_intent['intent_type'], best_intent, skill_id
)
else:
ret = None
return ret
def register_vocab(self, start_concept, end_concept, alias_of, regex_str):
"""Register vocabulary."""
if regex_str:
self.engine.register_regex_entity(regex_str)
else:
self.engine.register_entity(
start_concept, end_concept, alias_of=alias_of)
def register_intent(self, intent):
"""Register new intent with adapt engine.
Arguments:
intent (IntentParser): IntentParser to register
"""
self.engine.register_intent_parser(intent)
def detach_skill(self, skill_id):
"""Remove all intents for skill.
Arguments:
skill_id (str): skill to process
"""
new_parsers = [
p for p in self.engine.intent_parsers if
not p.name.startswith(skill_id)
]
self.engine.intent_parsers = new_parsers
def detach_intent(self, intent_name):
"""Detatch a single intent
Arguments:
intent_name (str): Identifier for intent to remove.
"""
new_parsers = [
p for p in self.engine.intent_parsers if p.name != intent_name
]
self.engine.intent_parsers = new_parsers

View File

@ -0,0 +1,14 @@
from collections import namedtuple
# Intent match response tuple containing
# intent_service: Name of the service that matched the intent
# intent_type: intent name (used to call intent handler over the message bus)
# intent_data: data provided by the intent match
# skill_id: the skill this handler belongs to
IntentMatch = namedtuple('IntentMatch',
['intent_service', 'intent_type',
'intent_data', 'skill_id']
)

View File

@ -0,0 +1,66 @@
# 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 for Mycroft's fallback system."""
from collections import namedtuple
from .base import IntentMatch
FallbackRange = namedtuple('FallbackRange', ['start', 'stop'])
class FallbackService:
"""Intent Service handling fallback skills."""
def __init__(self, bus):
self.bus = bus
def _fallback_range(self, utterances, lang, message, fb_range):
"""Send fallback request for a specified priority range.
Arguments:
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:
IntentMatch or None
"""
msg = message.reply(
'mycroft.skills.fallback',
data={'utterance': utterances[0][0],
'lang': lang,
'fallback_range': (fb_range.start, fb_range.stop)}
)
response = self.bus.wait_for_response(msg, timeout=10)
if response and response.data['handled']:
ret = IntentMatch('Fallback', None, {}, None)
else:
ret = None
return ret
def high_prio(self, utterances, lang, message):
"""Pre-padatious fallbacks."""
return self._fallback_range(utterances, lang, message,
FallbackRange(0, 5))
def medium_prio(self, utterances, lang, message):
"""General fallbacks."""
return self._fallback_range(utterances, lang, message,
FallbackRange(5, 90))
def low_prio(self, utterances, lang, message):
"""Low prio fallbacks with general matching such as chat-bot."""
return self._fallback_range(utterances, lang, message,
FallbackRange(90, 101))

View File

@ -0,0 +1,281 @@
# 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 subprocess import call
from threading import Event
from time import time as get_time, sleep
from os.path import expanduser, isfile
from mycroft.configuration import Configuration
from mycroft.messagebus.message import Message
from mycroft.util.log import LOG
from .base import IntentMatch
class PadatiousService:
"""Service class for padatious intent matching."""
def __init__(self, bus, config):
self.padatious_config = config
self.bus = bus
intent_cache = expanduser(self.padatious_config['intent_cache'])
try:
from padatious import IntentContainer
except ImportError:
LOG.error('Padatious not installed. Please re-run dev_setup.sh')
try:
call(['notify-send', 'Padatious not installed',
'Please run build_host_setup and dev_setup again'])
except OSError:
pass
return
self.container = IntentContainer(intent_cache)
self._bus = bus
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.bus.on('intent.service.padatious.get', self.handle_get_padatious)
self.bus.on('intent.service.padatious.manifest.get',
self.handle_manifest)
self.bus.on('intent.service.padatious.entities.manifest.get',
self.handle_entity_manifest)
self.finished_training_event = Event()
self.finished_initial_train = False
self.train_delay = self.padatious_config['train_delay']
self.train_time = get_time() + self.train_delay
self.registered_intents = []
self.registered_entities = []
def train(self, message=None):
"""Perform padatious training.
Arguments:
message (Message): optional triggering message
"""
padatious_single_thread = Configuration.get()[
'padatious']['single_thread']
if message is None:
single_thread = padatious_single_thread
else:
single_thread = message.data.get('single_thread',
padatious_single_thread)
self.finished_training_event.clear()
LOG.info('Training... (single_thread={})'.format(single_thread))
self.container.train(single_thread=single_thread)
LOG.info('Training complete.')
self.finished_training_event.set()
if not self.finished_initial_train:
LOG.info("Mycroft is all loaded and ready to roll!")
self.bus.emit(Message('mycroft.ready'))
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
sleep(self.train_delay)
if self.train_time < 0.0:
return
if self.train_time <= get_time() + 0.01:
self.train_time = -1.0
self.train()
def __detach_intent(self, intent_name):
""" Remove an intent if it has been registered.
Arguments:
intent_name (str): intent identifier
"""
if intent_name in self.registered_intents:
self.registered_intents.remove(intent_name)
self.container.remove_intent(intent_name)
def handle_detach_intent(self, message):
"""Messagebus handler for detaching padatious intent.
Arguments:
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.
Arguments:
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.
Arguments:
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['file_name']
name = message.data['name']
LOG.debug('Registering Padatious ' + object_name + ': ' + name)
if not isfile(file_name):
LOG.warning('Could not find file ' + file_name)
return
register_func(name, file_name)
self.train_time = get_time() + self.train_delay
self.wait_and_train()
def register_intent(self, message):
"""Messagebus handler for registering intents.
Arguments:
message (Message): message triggering action
"""
self.registered_intents.append(message.data['name'])
self._register_object(message, 'intent', self.container.load_intent)
def register_entity(self, message):
"""Messagebus handler for registering entities.
Arguments:
message (Message): message triggering action
"""
self.registered_entities.append(message.data)
self._register_object(message, 'entity', self.container.load_entity)
def _match_level(self, utterances, limit):
"""Match intent and make sure a certain level of confidence is reached.
Arguments:
utterances (list of tuples): Utterances to parse, originals paired
with optional normalized version.
limit (float): required confidence level.
"""
padatious_intent = None
LOG.debug('Padatious Matching confidence > {}'.format(limit))
for utt in utterances:
for variant in utt:
intent = self.calc_intent(variant)
if intent:
best = padatious_intent.conf if padatious_intent else 0.0
if best < intent.conf:
padatious_intent = intent
padatious_intent.matches['utterance'] = utt[0]
if padatious_intent and padatious_intent.conf > limit:
skill_id = padatious_intent.name.split(':')[0]
ret = IntentMatch(
'Padatious', padatious_intent.name, padatious_intent.matches,
skill_id
)
else:
ret = None
return ret
def match_high(self, utterances, _=None, __=None):
"""Intent matcher for high confidence.
Arguments:
utterances (list of tuples): Utterances to parse, originals paired
with optional normalized version.
"""
return self._match_level(utterances, 0.95)
def match_medium(self, utterances, _=None, __=None):
"""Intent matcher for medium confidence.
Arguments:
utterances (list of tuples): Utterances to parse, originals paired
with optional normalized version.
"""
return self._match_level(utterances, 0.8)
def match_low(self, utterances, _=None, __=None):
"""Intent matcher for low confidence.
Arguments:
utterances (list of tuples): Utterances to parse, originals paired
with optional normalized version.
"""
return self._match_level(utterances, 0.5)
def handle_get_padatious(self, message):
"""messagebus handler for perfoming padatious parsing.
Arguments:
message (Message): message triggering the method
"""
utterance = message.data["utterance"]
norm = message.data.get('norm_utt', utterance)
intent = self.calc_intent(utterance)
if not intent and norm != utterance:
intent = self.calc_intent(norm)
if intent:
intent = intent.__dict__
self.bus.emit(message.reply("intent.service.padatious.reply",
{"intent": intent}))
def handle_manifest(self, message):
"""Messagebus handler returning the registered padatious intents.
Arguments:
message (Message): message triggering the method
"""
self.bus.emit(message.reply("intent.service.padatious.manifest",
{"intents": self.registered_intents}))
def handle_entity_manifest(self, message):
"""Messagebus handler returning the registered padatious entities.
Arguments:
message (Message): message triggering the method
"""
self.bus.emit(
message.reply("intent.service.padatious.entities.manifest",
{"entities": self.registered_entities}))
@lru_cache(maxsize=2) # 2 catches both raw and normalized utts in cache
def calc_intent(self, utt):
"""Cached version of container calc_intent.
This improves speed when called multiple times for different confidence
levels.
NOTE: This cache will keep a reference to this class
(PadatiousService), but we can live with that since it is used as a
singleton.
Arguments:
utt (str): utterance to calculate best intent for
"""
return self.container.calc_intent(utt)

View File

@ -13,6 +13,9 @@
# limitations under the License.
#
from unittest import TestCase, mock
from adapt.intent import IntentBuilder
from mycroft.configuration import Configuration
from mycroft.messagebus import Message
from mycroft.skills.intent_service import (ContextManager, IntentService,
@ -113,7 +116,7 @@ class ConversationTest(TestCase):
data={'lang': 'en-US',
'utterances': hello})
result = self.intent_service._converse(hello, 'en-US', utterance_msg)
self.intent_service.add_active_skill(result.skill_id)
# Check that the active skill list was updated to set the responding
# Skill first.
first_active_skill = self.intent_service.active_skills[0][0]
@ -208,3 +211,119 @@ class TestLanguageExtraction(TestCase):
self.assertEqual(_get_message_lang(msg), 'de-de')
msg = Message('test msg', data={'lang': 'sv-se'})
self.assertEqual(_get_message_lang(msg), 'sv-se')
def create_vocab_msg(keyword, value):
"""Create a message for registering an adapt keyword."""
return Message('register_vocab',
{'start': value, 'end': keyword})
def get_last_message(bus):
"""Get last sent message on mock bus."""
last = bus.emit.call_args
return last[0][0]
class TestIntentServiceApi(TestCase):
def setUp(self):
self.intent_service = IntentService(mock.Mock())
def setup_simple_adapt_intent(self):
msg = create_vocab_msg('testKeyword', 'test')
self.intent_service.handle_register_vocab(msg)
intent = IntentBuilder('skill:testIntent').require('testKeyword')
msg = Message('register_intent', intent.__dict__)
self.intent_service.handle_register_intent(msg)
def test_get_adapt_intent(self):
self.setup_simple_adapt_intent()
# Check that the intent is returned
msg = Message('intent.service.adapt.get', data={'utterance': 'test'})
self.intent_service.handle_get_adapt(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent']['intent_type'],
'skill:testIntent')
def test_get_adapt_intent_no_match(self):
"""Check that if the intent doesn't match at all None is returned."""
self.setup_simple_adapt_intent()
# Check that no intent is matched
msg = Message('intent.service.adapt.get', data={'utterance': 'five'})
self.intent_service.handle_get_adapt(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent'], None)
def test_get_intent(self):
"""Check that the registered adapt intent is triggered."""
self.setup_simple_adapt_intent()
# Check that the intent is returned
msg = Message('intent.service.adapt.get', data={'utterance': 'test'})
self.intent_service.handle_get_intent(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent']['intent_type'],
'skill:testIntent')
def test_get_intent_no_match(self):
"""Check that if the intent doesn't match at all None is returned."""
self.setup_simple_adapt_intent()
# Check that no intent is matched
msg = Message('intent.service.intent.get', data={'utterance': 'five'})
self.intent_service.handle_get_intent(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent'], None)
def test_get_intent_manifest(self):
"""Check that if the intent doesn't match at all None is returned."""
self.setup_simple_adapt_intent()
# Check that no intent is matched
msg = Message('intent.service.intent.get', data={'utterance': 'five'})
self.intent_service.handle_get_intent(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent'], None)
def test_get_adapt_intent_manifest(self):
"""Make sure the manifest returns a list of Intent Parser objects."""
self.setup_simple_adapt_intent()
msg = Message('intent.service.adapt.manifest.get')
self.intent_service.handle_manifest(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intents'][0]['name'],
'skill:testIntent')
def test_get_adapt_vocab_manifest(self):
self.setup_simple_adapt_intent()
msg = Message('intent.service.adapt.vocab.manifest.get')
self.intent_service.handle_vocab_manifest(msg)
reply = get_last_message(self.intent_service.bus)
value = reply.data['vocab'][0]['start']
keyword = reply.data['vocab'][0]['end']
self.assertEqual(keyword, 'testKeyword')
self.assertEqual(value, 'test')
def test_get_no_match_after_detach(self):
"""Check that a removed intent doesn't match."""
self.setup_simple_adapt_intent()
# Check that no intent is matched
msg = Message('detach_intent',
data={'intent_name': 'skill:testIntent'})
self.intent_service.handle_detach_intent(msg)
msg = Message('intent.service.adapt.get', data={'utterance': 'test'})
self.intent_service.handle_get_adapt(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent'], None)
def test_get_no_match_after_detach_skill(self):
"""Check that a removed skill's intent doesn't match."""
self.setup_simple_adapt_intent()
# Check that no intent is matched
msg = Message('detach_intent',
data={'skill_id': 'skill'})
self.intent_service.handle_detach_skill(msg)
msg = Message('intent.service.adapt.get', data={'utterance': 'test'})
self.intent_service.handle_get_adapt(msg)
reply = get_last_message(self.intent_service.bus)
self.assertEqual(reply.data['intent'], None)