2017-10-04 06:28:44 +00:00
|
|
|
# Copyright 2017 Mycroft AI Inc.
|
2016-05-26 16:16:13 +00:00
|
|
|
#
|
2017-10-04 06:28:44 +00:00
|
|
|
# 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
|
2016-05-26 16:16:13 +00:00
|
|
|
#
|
2017-10-04 06:28:44 +00:00
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
2016-05-26 16:16:13 +00:00
|
|
|
#
|
2017-10-04 06:28:44 +00:00
|
|
|
# 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.
|
2016-05-26 16:16:13 +00:00
|
|
|
#
|
2017-09-18 19:14:21 +00:00
|
|
|
import time
|
|
|
|
|
|
|
|
from adapt.context import ContextManagerFrame
|
2016-05-20 14:16:01 +00:00
|
|
|
from adapt.engine import IntentDeterminationEngine
|
2016-09-04 01:24:18 +00:00
|
|
|
|
2017-09-23 12:13:50 +00:00
|
|
|
from mycroft.configuration import Configuration
|
2016-05-20 14:16:01 +00:00
|
|
|
from mycroft.messagebus.message import Message
|
2017-04-04 23:48:40 +00:00
|
|
|
from mycroft.skills.core import open_intent_envelope
|
2017-09-18 18:55:58 +00:00
|
|
|
from mycroft.util.log import LOG
|
2017-02-24 09:52:07 +00:00
|
|
|
from mycroft.util.parse import normalize
|
2017-08-17 17:09:17 +00:00
|
|
|
|
2016-05-26 20:28:28 +00:00
|
|
|
|
2017-06-19 14:36:24 +00:00
|
|
|
class ContextManager(object):
|
|
|
|
"""
|
|
|
|
ContextManager
|
|
|
|
Use to track context throughout the course of a conversational session.
|
|
|
|
How to manage a session's lifecycle is not captured here.
|
|
|
|
"""
|
2017-08-17 17:09:17 +00:00
|
|
|
|
2017-06-26 12:46:53 +00:00
|
|
|
def __init__(self, timeout):
|
2017-06-19 14:36:24 +00:00
|
|
|
self.frame_stack = []
|
2017-06-26 12:46:53 +00:00
|
|
|
self.timeout = timeout * 60 # minutes to seconds
|
2017-06-19 14:36:24 +00:00
|
|
|
|
|
|
|
def clear_context(self):
|
|
|
|
self.frame_stack = []
|
|
|
|
|
|
|
|
def remove_context(self, context_id):
|
2017-06-27 11:51:36 +00:00
|
|
|
self.frame_stack = [(f, t) for (f, t) in self.frame_stack
|
|
|
|
if context_id in f.entities[0].get('data', [])]
|
2017-06-19 14:36:24 +00:00
|
|
|
|
|
|
|
def inject_context(self, entity, metadata={}):
|
|
|
|
"""
|
|
|
|
Args:
|
|
|
|
entity(object):
|
|
|
|
format {'data': 'Entity tag as <str>',
|
|
|
|
'key': 'entity proper name as <str>',
|
|
|
|
'confidence': <float>'
|
|
|
|
}
|
|
|
|
metadata(object): dict, arbitrary metadata about the entity being
|
|
|
|
added
|
|
|
|
"""
|
2017-09-13 13:06:43 +00:00
|
|
|
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):
|
2017-06-19 14:36:24 +00:00
|
|
|
"""
|
|
|
|
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
|
|
|
|
"""
|
2017-09-13 13:06:43 +00:00
|
|
|
missing_entities = missing_entities or []
|
|
|
|
|
2017-06-19 14:36:24 +00:00
|
|
|
relevant_frames = [frame[0] for frame in self.frame_stack if
|
2017-06-26 12:46:53 +00:00
|
|
|
time.time() - frame[1] < self.timeout]
|
2017-06-19 14:36:24 +00:00
|
|
|
if not max_frames or max_frames > len(relevant_frames):
|
|
|
|
max_frames = len(relevant_frames)
|
|
|
|
|
|
|
|
missing_entities = list(missing_entities)
|
|
|
|
context = []
|
|
|
|
for i in xrange(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) \
|
2017-09-18 19:14:21 +00:00
|
|
|
/ (2.0 + i)
|
2017-06-19 14:36:24 +00:00
|
|
|
context += frame_entities
|
|
|
|
|
|
|
|
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
|
2017-07-06 20:01:52 +00:00
|
|
|
|
|
|
|
# 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
|
2017-06-19 14:36:24 +00:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
2017-04-14 08:53:29 +00:00
|
|
|
class IntentService(object):
|
2017-04-04 23:48:40 +00:00
|
|
|
def __init__(self, emitter):
|
2017-09-23 12:13:50 +00:00
|
|
|
self.config = Configuration.get().get('context', {})
|
2016-05-20 14:16:01 +00:00
|
|
|
self.engine = IntentDeterminationEngine()
|
2017-10-28 07:50:14 +00:00
|
|
|
self.context_keywords = self.config.get('keywords', [])
|
2017-07-04 07:11:55 +00:00
|
|
|
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)
|
2017-06-26 12:46:53 +00:00
|
|
|
self.context_manager = ContextManager(self.context_timeout)
|
2017-04-04 23:48:40 +00:00
|
|
|
self.emitter = emitter
|
2016-05-20 14:16:01 +00:00
|
|
|
self.emitter.on('register_vocab', self.handle_register_vocab)
|
|
|
|
self.emitter.on('register_intent', self.handle_register_intent)
|
|
|
|
self.emitter.on('recognizer_loop:utterance', self.handle_utterance)
|
|
|
|
self.emitter.on('detach_intent', self.handle_detach_intent)
|
2017-04-17 17:25:27 +00:00
|
|
|
self.emitter.on('detach_skill', self.handle_detach_skill)
|
2017-06-19 14:36:24 +00:00
|
|
|
# Context related handlers
|
|
|
|
self.emitter.on('add_context', self.handle_add_context)
|
|
|
|
self.emitter.on('remove_context', self.handle_remove_context)
|
|
|
|
self.emitter.on('clear_context', self.handle_clear_context)
|
2017-08-17 17:09:17 +00:00
|
|
|
# Converse method
|
|
|
|
self.emitter.on('skill.converse.response',
|
|
|
|
self.handle_converse_response)
|
|
|
|
self.active_skills = [] # [skill_id , timestamp]
|
|
|
|
self.converse_timeout = 5 # minutes to prune active_skills
|
|
|
|
|
|
|
|
def do_converse(self, utterances, skill_id, lang):
|
|
|
|
self.emitter.emit(Message("skill.converse.request", {
|
|
|
|
"skill_id": skill_id, "utterances": utterances, "lang": lang}))
|
|
|
|
self.waiting = True
|
|
|
|
self.result = False
|
|
|
|
start_time = time.time()
|
|
|
|
t = 0
|
|
|
|
while self.waiting and t < 5:
|
|
|
|
t = time.time() - start_time
|
|
|
|
time.sleep(0.1)
|
|
|
|
self.waiting = False
|
|
|
|
return self.result
|
|
|
|
|
|
|
|
def handle_converse_response(self, message):
|
|
|
|
# id = message.data["skill_id"]
|
|
|
|
# no need to crosscheck id because waiting before new request is made
|
|
|
|
# no other skill will make this request is safe assumption
|
|
|
|
result = message.data["result"]
|
|
|
|
self.result = result
|
|
|
|
self.waiting = False
|
|
|
|
|
|
|
|
def remove_active_skill(self, skill_id):
|
|
|
|
for skill in self.active_skills:
|
|
|
|
if skill[0] == skill_id:
|
|
|
|
self.active_skills.remove(skill)
|
|
|
|
|
|
|
|
def add_active_skill(self, skill_id):
|
|
|
|
# search the list for an existing entry that already contains it
|
|
|
|
# and remove that reference
|
|
|
|
self.remove_active_skill(skill_id)
|
|
|
|
# add skill with timestamp to start of skill_list
|
2017-08-21 07:18:01 +00:00
|
|
|
self.active_skills.insert(0, [skill_id, time.time()])
|
2016-05-20 14:16:01 +00:00
|
|
|
|
2017-07-04 07:11:55 +00:00
|
|
|
def update_context(self, intent):
|
2017-08-31 19:34:30 +00:00
|
|
|
"""
|
|
|
|
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
|
|
|
|
"""
|
2017-07-04 07:11:55 +00:00
|
|
|
for tag in intent['__tags__']:
|
2017-08-29 06:29:51 +00:00
|
|
|
if 'entities' not in tag:
|
|
|
|
continue
|
|
|
|
context_entity = tag['entities'][0]
|
2017-07-04 07:11:55 +00:00
|
|
|
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)
|
|
|
|
|
2016-05-20 14:16:01 +00:00
|
|
|
def handle_utterance(self, message):
|
2017-02-23 12:40:46 +00:00
|
|
|
# Get language of the utterance
|
|
|
|
lang = message.data.get('lang', None)
|
|
|
|
if not lang:
|
|
|
|
lang = "en-us"
|
|
|
|
|
2016-09-04 01:24:18 +00:00
|
|
|
utterances = message.data.get('utterances', '')
|
2016-05-20 14:16:01 +00:00
|
|
|
|
2017-08-17 17:09:17 +00:00
|
|
|
# check for conversation time-out
|
|
|
|
self.active_skills = [skill for skill in self.active_skills
|
|
|
|
if time.time() - skill[
|
|
|
|
1] <= self.converse_timeout * 60]
|
|
|
|
|
|
|
|
# check if any skill wants to handle utterance
|
|
|
|
for skill in self.active_skills:
|
|
|
|
if self.do_converse(utterances, skill[0], lang):
|
|
|
|
# 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
|
|
|
|
|
|
|
|
# no skill wants to handle utterance
|
2016-05-20 14:16:01 +00:00
|
|
|
best_intent = None
|
|
|
|
for utterance in utterances:
|
|
|
|
try:
|
2017-02-23 12:40:46 +00:00
|
|
|
# normalize() changes "it's a boy" to "it is boy", etc.
|
2016-05-20 22:15:53 +00:00
|
|
|
best_intent = next(self.engine.determine_intent(
|
2017-08-17 17:09:17 +00:00
|
|
|
normalize(utterance, lang), 100,
|
|
|
|
include_tags=True,
|
|
|
|
context_manager=self.context_manager))
|
2016-05-20 22:15:53 +00:00
|
|
|
# TODO - Should Adapt handle this?
|
|
|
|
best_intent['utterance'] = utterance
|
2016-05-20 14:16:01 +00:00
|
|
|
except StopIteration, e:
|
2017-09-18 18:55:58 +00:00
|
|
|
LOG.exception(e)
|
2016-05-20 14:16:01 +00:00
|
|
|
continue
|
|
|
|
|
|
|
|
if best_intent and best_intent.get('confidence', 0.0) > 0.0:
|
2017-07-04 07:11:55 +00:00
|
|
|
self.update_context(best_intent)
|
2016-05-20 22:15:53 +00:00
|
|
|
reply = message.reply(
|
2016-09-04 01:24:18 +00:00
|
|
|
best_intent.get('intent_type'), best_intent)
|
2016-05-20 14:16:01 +00:00
|
|
|
self.emitter.emit(reply)
|
2017-08-17 17:09:17 +00:00
|
|
|
# update active skills
|
|
|
|
skill_id = int(best_intent['intent_type'].split(":")[0])
|
|
|
|
self.add_active_skill(skill_id)
|
|
|
|
|
2017-06-16 21:40:12 +00:00
|
|
|
else:
|
2016-09-04 01:24:18 +00:00
|
|
|
self.emitter.emit(Message("intent_failure", {
|
2017-02-25 05:59:00 +00:00
|
|
|
"utterance": utterances[0],
|
|
|
|
"lang": lang
|
2016-09-04 01:24:18 +00:00
|
|
|
}))
|
2016-05-20 14:16:01 +00:00
|
|
|
|
|
|
|
def handle_register_vocab(self, message):
|
2016-09-04 01:24:18 +00:00
|
|
|
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')
|
2016-05-20 14:16:01 +00:00
|
|
|
if regex_str:
|
|
|
|
self.engine.register_regex_entity(regex_str)
|
|
|
|
else:
|
2016-05-20 22:15:53 +00:00
|
|
|
self.engine.register_entity(
|
|
|
|
start_concept, end_concept, alias_of=alias_of)
|
2016-05-20 14:16:01 +00:00
|
|
|
|
|
|
|
def handle_register_intent(self, message):
|
2017-09-18 18:55:58 +00:00
|
|
|
print "Registering: " + str(message.data)
|
2016-05-20 14:16:01 +00:00
|
|
|
intent = open_intent_envelope(message)
|
|
|
|
self.engine.register_intent_parser(intent)
|
|
|
|
|
|
|
|
def handle_detach_intent(self, message):
|
2016-09-04 01:24:18 +00:00
|
|
|
intent_name = message.data.get('intent_name')
|
2016-05-20 22:15:53 +00:00
|
|
|
new_parsers = [
|
|
|
|
p for p in self.engine.intent_parsers if p.name != intent_name]
|
2016-05-20 14:16:01 +00:00
|
|
|
self.engine.intent_parsers = new_parsers
|
2017-04-17 17:25:27 +00:00
|
|
|
|
|
|
|
def handle_detach_skill(self, message):
|
2017-08-17 17:09:17 +00:00
|
|
|
skill_id = message.data.get('skill_id')
|
2017-04-17 17:25:27 +00:00
|
|
|
new_parsers = [
|
|
|
|
p for p in self.engine.intent_parsers if
|
2017-08-17 17:09:17 +00:00
|
|
|
not p.name.startswith(skill_id)]
|
2017-04-17 17:25:27 +00:00
|
|
|
self.engine.intent_parsers = new_parsers
|
2017-06-19 14:36:24 +00:00
|
|
|
|
|
|
|
def handle_add_context(self, message):
|
2017-09-13 13:06:43 +00:00
|
|
|
"""
|
|
|
|
Handles adding context from the message bus.
|
|
|
|
The data field must contain a context keyword and
|
|
|
|
may contain a word if a specific word should be injected
|
|
|
|
as a match for the provided context keyword.
|
|
|
|
|
|
|
|
"""
|
2017-06-19 14:36:24 +00:00
|
|
|
entity = {'confidence': 1.0}
|
|
|
|
context = message.data.get('context')
|
2017-08-04 10:51:06 +00:00
|
|
|
word = message.data.get('word') or ''
|
2017-09-13 13:06:43 +00:00
|
|
|
# if not a string type try creating a string from it
|
|
|
|
if not isinstance(word, basestring):
|
|
|
|
word = str(word)
|
2017-06-19 14:36:24 +00:00
|
|
|
entity['data'] = [(word, context)]
|
|
|
|
entity['match'] = word
|
|
|
|
entity['key'] = word
|
|
|
|
self.context_manager.inject_context(entity)
|
|
|
|
|
|
|
|
def handle_remove_context(self, message):
|
2017-09-13 13:06:43 +00:00
|
|
|
"""
|
|
|
|
Handles removing context from the message bus. The
|
|
|
|
data field must contain the 'context' to remove.
|
|
|
|
"""
|
2017-06-19 14:36:24 +00:00
|
|
|
context = message.data.get('context')
|
2017-09-13 13:06:43 +00:00
|
|
|
if context:
|
|
|
|
self.context_manager.remove_context(context)
|
2017-06-19 14:36:24 +00:00
|
|
|
|
|
|
|
def handle_clear_context(self, message):
|
2017-09-13 13:06:43 +00:00
|
|
|
"""
|
|
|
|
Clears all keywords from context.
|
|
|
|
"""
|
2017-06-19 14:36:24 +00:00
|
|
|
self.context_manager.clear_context()
|