From 834ab9d30b84f0fde760ebedebc46b1afe7823b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Forslund?= Date: Tue, 27 Apr 2021 07:10:44 +0200 Subject: [PATCH] refactor/adapt_ContextManager (mycroft-core/pull/2886) cherry pick of https://github.com/MycroftAI/mycroft-core/pull/2886 Timed Context Manager Makes the Mycroft-core Adapt Context Manager depend on the ContextManager in Adapt. This removes a bunch of duplicated code. The current state of the class reflects _only_ the modifications done to integrate the context with the Mycroft intent system. The key differences from the default Adapt context manager are - Frames are timedout after a set time - get_context will only return the latest match - methods for removing context (all and specific) --- mycroft/skills/intent_service.py | 4 +- .../skills/intent_services/adapt_service.py | 117 ++++++------------ 2 files changed, 42 insertions(+), 79 deletions(-) diff --git a/mycroft/skills/intent_service.py b/mycroft/skills/intent_service.py index 8d96a06282ad..0b4ff3dba7e5 100644 --- a/mycroft/skills/intent_service.py +++ b/mycroft/skills/intent_service.py @@ -485,7 +485,7 @@ def handle_add_context(self, message): entity['match'] = word entity['key'] = word entity['origin'] = origin - self.adapt_service.context_manager.inject_context(entity) + self.adapt_service.add_context(entity) def handle_remove_context(self, message): """Remove specific context @@ -499,7 +499,7 @@ def handle_remove_context(self, message): def handle_clear_context(self, _): """Clears all keywords from context """ - self.adapt_service.context_manager.clear_context() + self.adapt_service.reset_context() def handle_get_intent(self, message): """Get intent from either adapt or padatious. diff --git a/mycroft/skills/intent_services/adapt_service.py b/mycroft/skills/intent_services/adapt_service.py index ad74656846ff..2624672fc2a4 100644 --- a/mycroft/skills/intent_services/adapt_service.py +++ b/mycroft/skills/intent_services/adapt_service.py @@ -16,7 +16,7 @@ from threading import Lock import time -from adapt.context import ContextManagerFrame +from adapt.context import ContextManager as AdaptContextManager from adapt.engine import IntentDeterminationEngine from adapt.intent import IntentBuilder @@ -65,58 +65,50 @@ def _strip_result(context_features): return stripped -class ContextManager: +def _frame_timedout(frame, timeout): + """Check if a frame has timed out using it's metadata. + + frame (ContextManagerFrame): + """ + current_time = time.monotonic() + return current_time > frame.metadata['timestamp'] + timeout + + +class ContextManager(AdaptContextManager): """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. + This class extends the default ContextManager in adapt to "timeout" + context frames after a set time. it also limits the context returned + to a single entry. """ def __init__(self, timeout): - self.frame_stack = [] + super().__init__() 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. Args: 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', [])] + self.frame_stack = [frame for frame in self.frame_stack + if context_id in frame.entities[0].get('data', [])] def inject_context(self, entity, metadata=None): - """ - Args: - entity(object): Format example... - {'data': 'Entity tag as ', - 'key': 'entity proper name as ', - 'confidence': ' - } - metadata(object): dict, arbitrary metadata about entity injected - """ + """Inject context and add timestamp to metadata if missing.""" 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 + if 'timestamp' not in metadata: + metadata['timestamp'] = time.monotonic() + super().inject_context(entity, metadata) - def get_context(self, max_frames=None, missing_entities=None): - """ Constructs a list of entities from the context. + def get_context(self, max_frames=None, missing_entities=[]): + """Extends the get_context from Adapt's ContextManager. - Args: + Extensions on top of parent class: + - Timeout Context frames after a set time + - Only return the most recent matching entity + + Arguments: max_frames(int): maximum number of frames to look back missing_entities(list of str): a list or set of tag names, as strings @@ -124,45 +116,10 @@ def get_context(self, max_frames=None, missing_entities=None): 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 + self.frame_stack = [frame for frame in self.frame_stack + if not _frame_timedout(frame, self.timeout)] + result = super().get_context(max_frames, missing_entities) + # Only use the latest keyword return _strip_result(result) @@ -171,13 +128,18 @@ class AdaptService: def __init__(self, config): self.config = config self.engine = IntentDeterminationEngine() + self.lock = Lock() # 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 = None + self.reset_context() + + def reset_context(self): + """Reset the context manager used by the service.""" self.context_manager = ContextManager(self.context_timeout) - self.lock = Lock() def update_context(self, intent): """Updates context with keyword from the intent. @@ -208,6 +170,7 @@ def match_intent(self, utterances, _=None, __=None): Returns: Intent structure, or None if no match was found. """ + adapt_context = self.context_manager best_intent = {} def take_best(intent, utt): @@ -225,7 +188,7 @@ def take_best(intent, utt): intents = [i for i in self.engine.determine_intent( utt, 100, include_tags=True, - context_manager=self.context_manager)] + context_manager=adapt_context)] if intents: take_best(intents[0], utt_tup[0])