intent_service.py
Go to the documentation of this file.
1 # Copyright 2017 Mycroft AI Inc.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14 #
15 import time
16 from adapt.context import ContextManagerFrame
17 from adapt.engine import IntentDeterminationEngine
18 from adapt.intent import IntentBuilder
19 
20 from mycroft.configuration import Configuration
21 from mycroft.messagebus.message import Message
22 from mycroft.skills.core import open_intent_envelope
23 from mycroft.util.lang import set_active_lang
24 from mycroft.util.log import LOG
25 from mycroft.util.parse import normalize
26 from mycroft.metrics import report_timing, Stopwatch
27 from mycroft.skills.padatious_service import PadatiousService
28 
29 
30 class AdaptIntent(IntentBuilder):
31  def __init__(self, name=''):
32  super().__init__(name)
33 
34 
35 def workaround_one_of_context(best_intent):
36  """ Handle Adapt issue with context injection combined with one_of.
37 
38  For all entries in the intent result where the value is None try to
39  populate using a value from the __tags__ structure.
40  """
41  for key in best_intent:
42  if best_intent[key] is None:
43  for t in best_intent['__tags__']:
44  if key in t:
45  best_intent[key] = t[key][0]['entities'][0]['key']
46  return best_intent
47 
48 
50  """
51  ContextManager
52  Use to track context throughout the course of a conversational session.
53  How to manage a session's lifecycle is not captured here.
54  """
55 
56  def __init__(self, timeout):
57  self.frame_stack = []
58  self.timeout = timeout * 60 # minutes to seconds
59 
60  def clear_context(self):
61  self.frame_stack = []
62 
63  def remove_context(self, context_id):
64  self.frame_stack = [(f, t) for (f, t) in self.frame_stack
65  if context_id in f.entities[0].get('data', [])]
66 
67  def inject_context(self, entity, metadata=None):
68  """
69  Args:
70  entity(object): Format example...
71  {'data': 'Entity tag as <str>',
72  'key': 'entity proper name as <str>',
73  'confidence': <float>'
74  }
75  metadata(object): dict, arbitrary metadata about entity injected
76  """
77  metadata = metadata or {}
78  try:
79  if len(self.frame_stack) > 0:
80  top_frame = self.frame_stack[0]
81  else:
82  top_frame = None
83  if top_frame and top_frame[0].metadata_matches(metadata):
84  top_frame[0].merge_context(entity, metadata)
85  else:
86  frame = ContextManagerFrame(entities=[entity],
87  metadata=metadata.copy())
88  self.frame_stack.insert(0, (frame, time.time()))
89  except (IndexError, KeyError):
90  pass
91 
92  def get_context(self, max_frames=None, missing_entities=None):
93  """ Constructs a list of entities from the context.
94 
95  Args:
96  max_frames(int): maximum number of frames to look back
97  missing_entities(list of str): a list or set of tag names,
98  as strings
99 
100  Returns:
101  list: a list of entities
102  """
103  missing_entities = missing_entities or []
104 
105  relevant_frames = [frame[0] for frame in self.frame_stack if
106  time.time() - frame[1] < self.timeout]
107  if not max_frames or max_frames > len(relevant_frames):
108  max_frames = len(relevant_frames)
109 
110  missing_entities = list(missing_entities)
111  context = []
112  last = ''
113  depth = 0
114  for i in range(max_frames):
115  frame_entities = [entity.copy() for entity in
116  relevant_frames[i].entities]
117  for entity in frame_entities:
118  entity['confidence'] = entity.get('confidence', 1.0) \
119  / (2.0 + depth)
120  context += frame_entities
121 
122  # Update depth
123  if entity['origin'] != last or entity['origin'] == '':
124  depth += 1
125  last = entity['origin']
126  print(depth)
127 
128  result = []
129  if len(missing_entities) > 0:
130  for entity in context:
131  if entity.get('data') in missing_entities:
132  result.append(entity)
133  # NOTE: this implies that we will only ever get one
134  # of an entity kind from context, unless specified
135  # multiple times in missing_entities. Cannot get
136  # an arbitrary number of an entity kind.
137  missing_entities.remove(entity.get('data'))
138  else:
139  result = context
140 
141  # Only use the latest instance of each keyword
142  stripped = []
143  processed = []
144  for f in result:
145  keyword = f['data'][0][1]
146  if keyword not in processed:
147  stripped.append(f)
148  processed.append(keyword)
149  result = stripped
150  return result
151 
152 
154  def __init__(self, bus):
155  self.config = Configuration.get().get('context', {})
156  self.engine = IntentDeterminationEngine()
157 
158  # Dictionary for translating a skill id to a name
159  self.skill_names = {}
160  # Context related intializations
161  self.context_keywords = self.config.get('keywords', [])
162  self.context_max_frames = self.config.get('max_frames', 3)
163  self.context_timeout = self.config.get('timeout', 2)
164  self.context_greedy = self.config.get('greedy', False)
166  self.bus = bus
167  self.bus.on('register_vocab', self.handle_register_vocab)
168  self.bus.on('register_intent', self.handle_register_intent)
169  self.bus.on('recognizer_loop:utterance', self.handle_utterance)
170  self.bus.on('detach_intent', self.handle_detach_intent)
171  self.bus.on('detach_skill', self.handle_detach_skill)
172  # Context related handlers
173  self.bus.on('add_context', self.handle_add_context)
174  self.bus.on('remove_context', self.handle_remove_context)
175  self.bus.on('clear_context', self.handle_clear_context)
176  # Converse method
177  self.bus.on('skill.converse.response', self.handle_converse_response)
178  self.bus.on('skill.converse.error', self.handle_converse_error)
179  self.bus.on('mycroft.speech.recognition.unknown', self.reset_converse)
180  self.bus.on('mycroft.skills.loaded', self.update_skill_name_dict)
181 
182  def add_active_skill_handler(message):
183  self.add_active_skill(message.data['skill_id'])
184  self.bus.on('active_skill_request', add_active_skill_handler)
185  self.active_skills = [] # [skill_id , timestamp]
186  self.converse_timeout = 5 # minutes to prune active_skills
187  self.waiting_for_converse = False
188  self.converse_result = False
190 
191  def update_skill_name_dict(self, message):
192  """
193  Messagebus handler, updates dictionary of if to skill name
194  conversions.
195  """
196  self.skill_names[message.data['id']] = message.data['name']
197 
198  def get_skill_name(self, skill_id):
199  """ Get skill name from skill ID.
200 
201  Args:
202  skill_id: a skill id as encoded in Intent handlers.
203 
204  Returns:
205  (str) Skill name or the skill id if the skill wasn't found
206  """
207  return self.skill_names.get(skill_id, skill_id)
208 
209  def reset_converse(self, message):
210  """Let skills know there was a problem with speech recognition"""
211  lang = message.data.get('lang', "en-us")
212  set_active_lang(lang)
213  for skill in self.active_skills:
214  self.do_converse(None, skill[0], lang)
215 
216  def do_converse(self, utterances, skill_id, lang):
217  self.waiting_for_converse = True
218  self.converse_result = False
219  self.converse_skill_id = skill_id
220  self.bus.emit(Message("skill.converse.request", {
221  "skill_id": skill_id, "utterances": utterances, "lang": lang}))
222  start_time = time.time()
223  t = 0
224  while self.waiting_for_converse and t < 5:
225  t = time.time() - start_time
226  time.sleep(0.1)
227  self.waiting_for_converse = False
228  self.converse_skill_id = ""
229  return self.converse_result
230 
231  def handle_converse_error(self, message):
232  skill_id = message.data["skill_id"]
233  if message.data["error"] == "skill id does not exist":
234  self.remove_active_skill(skill_id)
235  if skill_id == self.converse_skill_id:
236  self.converse_result = False
237  self.waiting_for_converse = False
238 
239  def handle_converse_response(self, message):
240  skill_id = message.data["skill_id"]
241  if skill_id == self.converse_skill_id:
242  self.converse_result = message.data.get("result", False)
243  self.waiting_for_converse = False
244 
245  def remove_active_skill(self, skill_id):
246  for skill in self.active_skills:
247  if skill[0] == skill_id:
248  self.active_skills.remove(skill)
249 
250  def add_active_skill(self, skill_id):
251  # search the list for an existing entry that already contains it
252  # and remove that reference
253  self.remove_active_skill(skill_id)
254  # add skill with timestamp to start of skill_list
255  self.active_skills.insert(0, [skill_id, time.time()])
256 
257  def update_context(self, intent):
258  """ Updates context with keyword from the intent.
259 
260  NOTE: This method currently won't handle one_of intent keywords
261  since it's not using quite the same format as other intent
262  keywords. This is under investigation in adapt, PR pending.
263 
264  Args:
265  intent: Intent to scan for keywords
266  """
267  for tag in intent['__tags__']:
268  if 'entities' not in tag:
269  continue
270  context_entity = tag['entities'][0]
271  if self.context_greedy:
272  self.context_manager.inject_context(context_entity)
273  elif context_entity['data'][0][1] in self.context_keywords:
274  self.context_manager.inject_context(context_entity)
275 
276  def send_metrics(self, intent, context, stopwatch):
277  """
278  Send timing metrics to the backend.
279 
280  NOTE: This only applies to those with Opt In.
281  """
282  ident = context['ident'] if 'ident' in context else None
283  if intent:
284  # Recreate skill name from skill id
285  parts = intent.get('intent_type', '').split(':')
286  intent_type = self.get_skill_name(parts[0])
287  if len(parts) > 1:
288  intent_type = ':'.join([intent_type] + parts[1:])
289  report_timing(ident, 'intent_service', stopwatch,
290  {'intent_type': intent_type})
291  else:
292  report_timing(ident, 'intent_service', stopwatch,
293  {'intent_type': 'intent_failure'})
294 
295  def handle_utterance(self, message):
296  """ Main entrypoint for handling user utterances with Mycroft skills
297 
298  Monitor the messagebus for 'recognizer_loop:utterance', typically
299  generated by a spoken interaction but potentially also from a CLI
300  or other method of injecting a 'user utterance' into the system.
301 
302  Utterances then work through this sequence to be handled:
303  1) Active skills attempt to handle using converse()
304  2) Padatious high match intents (conf > 0.95)
305  3) Adapt intent handlers
306  5) Fallbacks:
307  - Padatious near match intents (conf > 0.8)
308  - General fallbacks
309  - Padatious loose match intents (conf > 0.5)
310  - Unknown intent handler
311 
312  Args:
313  message (Message): The messagebus data
314  """
315  try:
316  # Get language of the utterance
317  lang = message.data.get('lang', "en-us")
318  set_active_lang(lang)
319 
320  utterances = message.data.get('utterances', [])
321  # normalize() changes "it's a boy" to "it is a boy", etc.
322  norm_utterances = [normalize(u.lower(), remove_articles=False)
323  for u in utterances]
324 
325  # Build list with raw utterance(s) first, then optionally a
326  # normalized version following.
327  combined = utterances + list(set(norm_utterances) -
328  set(utterances))
329  LOG.debug("Utterances: {}".format(combined))
330 
331  stopwatch = Stopwatch()
332  intent = None
333  padatious_intent = None
334  with stopwatch:
335  # Give active skills an opportunity to handle the utterance
336  converse = self._converse(combined, lang)
337 
338  if not converse:
339  # No conversation, use intent system to handle utterance
340  intent = self._adapt_intent_match(utterances,
341  norm_utterances, lang)
342  for utt in combined:
343  _intent = PadatiousService.instance.calc_intent(utt)
344  if _intent:
345  best = padatious_intent.conf if padatious_intent\
346  else 0.0
347  if best < _intent.conf:
348  padatious_intent = _intent
349  LOG.debug("Padatious intent: {}".format(padatious_intent))
350  LOG.debug(" Adapt intent: {}".format(intent))
351 
352  if converse:
353  # Report that converse handled the intent and return
354  LOG.debug("Handled in converse()")
355  ident = message.context['ident'] if message.context else None
356  report_timing(ident, 'intent_service', stopwatch,
357  {'intent_type': 'converse'})
358  return
359  elif (intent and intent.get('confidence', 0.0) > 0.0 and
360  not (padatious_intent and padatious_intent.conf >= 0.95)):
361  # Send the message to the Adapt intent's handler unless
362  # Padatious is REALLY sure it was directed at it instead.
363  self.update_context(intent)
364  # update active skills
365  skill_id = intent['intent_type'].split(":")[0]
366  self.add_active_skill(skill_id)
367  # Adapt doesn't handle context injection for one_of keywords
368  # correctly. Workaround this issue if possible.
369  try:
370  intent = workaround_one_of_context(intent)
371  except LookupError:
372  LOG.error('Error during workaround_one_of_context')
373  reply = message.reply(intent.get('intent_type'), intent)
374  else:
375  # Allow fallback system to handle utterance
376  # NOTE: A matched padatious_intent is handled this way, too
377  # TODO: Need to redefine intent_failure when STT can return
378  # multiple hypothesis -- i.e. len(utterances) > 1
379  reply = message.reply('intent_failure',
380  {'utterance': utterances[0],
381  'norm_utt': norm_utterances[0],
382  'lang': lang})
383  self.bus.emit(reply)
384  self.send_metrics(intent, message.context, stopwatch)
385  except Exception as e:
386  LOG.exception(e)
387 
388  def _converse(self, utterances, lang):
389  """ Give active skills a chance at the utterance
390 
391  Args:
392  utterances (list): list of utterances
393  lang (string): 4 letter ISO language code
394 
395  Returns:
396  bool: True if converse handled it, False if no skill processes it
397  """
398 
399  # check for conversation time-out
400  self.active_skills = [skill for skill in self.active_skills
401  if time.time() - skill[
402  1] <= self.converse_timeout * 60]
403 
404  # check if any skill wants to handle utterance
405  for skill in self.active_skills:
406  if self.do_converse(utterances, skill[0], lang):
407  # update timestamp, or there will be a timeout where
408  # intent stops conversing whether its being used or not
409  self.add_active_skill(skill[0])
410  return True
411  return False
412 
413  def _adapt_intent_match(self, raw_utt, norm_utt, lang):
414  """ Run the Adapt engine to search for an matching intent
415 
416  Args:
417  raw_utt (list): list of utterances
418  norm_utt (list): same list of utterances, normalized
419  lang (string): language code, e.g "en-us"
420 
421  Returns:
422  Intent structure, or None if no match was found.
423  """
424  best_intent = None
425 
426  def take_best(intent, utt):
427  nonlocal best_intent
428  best = best_intent.get('confidence', 0.0) if best_intent else 0.0
429  conf = intent.get('confidence', 0.0)
430  if conf > best:
431  best_intent = intent
432  # TODO - Shouldn't Adapt do this?
433  best_intent['utterance'] = utt
434 
435  for idx, utt in enumerate(raw_utt):
436  try:
437  intents = [i for i in self.engine.determine_intent(
438  utt, 100,
439  include_tags=True,
440  context_manager=self.context_manager)]
441  if intents:
442  take_best(intents[0], utt)
443 
444  # Also test the normalized version, but set the utternace to
445  # the raw version so skill has access to original STT
446  norm_intents = [i for i in self.engine.determine_intent(
447  norm_utt[idx], 100,
448  include_tags=True,
449  context_manager=self.context_manager)]
450  if norm_intents:
451  take_best(norm_intents[0], utt)
452  except Exception as e:
453  LOG.exception(e)
454  return best_intent
455 
456  def handle_register_vocab(self, message):
457  start_concept = message.data.get('start')
458  end_concept = message.data.get('end')
459  regex_str = message.data.get('regex')
460  alias_of = message.data.get('alias_of')
461  if regex_str:
462  self.engine.register_regex_entity(regex_str)
463  else:
464  self.engine.register_entity(
465  start_concept, end_concept, alias_of=alias_of)
466 
467  def handle_register_intent(self, message):
468  intent = open_intent_envelope(message)
469  self.engine.register_intent_parser(intent)
470 
471  def handle_detach_intent(self, message):
472  intent_name = message.data.get('intent_name')
473  new_parsers = [
474  p for p in self.engine.intent_parsers if p.name != intent_name]
475  self.engine.intent_parsers = new_parsers
476 
477  def handle_detach_skill(self, message):
478  skill_id = message.data.get('skill_id')
479  new_parsers = [
480  p for p in self.engine.intent_parsers if
481  not p.name.startswith(skill_id)]
482  self.engine.intent_parsers = new_parsers
483 
484  def handle_add_context(self, message):
485  """ Add context
486 
487  Args:
488  message: data contains the 'context' item to add
489  optionally can include 'word' to be injected as
490  an alias for the context item.
491  """
492  entity = {'confidence': 1.0}
493  context = message.data.get('context')
494  word = message.data.get('word') or ''
495  origin = message.data.get('origin') or ''
496  # if not a string type try creating a string from it
497  if not isinstance(word, str):
498  word = str(word)
499  entity['data'] = [(word, context)]
500  entity['match'] = word
501  entity['key'] = word
502  entity['origin'] = origin
503  self.context_manager.inject_context(entity)
504 
505  def handle_remove_context(self, message):
506  """ Remove specific context
507 
508  Args:
509  message: data contains the 'context' item to remove
510  """
511  context = message.data.get('context')
512  if context:
513  self.context_manager.remove_context(context)
514 
515  def handle_clear_context(self, message):
516  """ Clears all keywords from context """
517  self.context_manager.clear_context()
def report_timing(ident, system, timing, additional_data=None)
def workaround_one_of_context(best_intent)
def open_intent_envelope(message)
Definition: core.py:97
def send_metrics(self, intent, context, stopwatch)
def get_context(self, max_frames=None, missing_entities=None)
def _adapt_intent_match(self, raw_utt, norm_utt, lang)
def inject_context(self, entity, metadata=None)
def do_converse(self, utterances, skill_id, lang)
def get(phrase, lang=None, context=None)
def normalize(text, lang=None, remove_articles=True)
Definition: parse.py:281


mycroft_ros
Author(s):
autogenerated on Mon Apr 26 2021 02:35:40