From c47064ed9a479aafae62e74911af9aff062c6ccb Mon Sep 17 00:00:00 2001 From: wonyoHwang Date: Sat, 17 Nov 2018 15:42:55 +0900 Subject: [PATCH 1/2] Add test code for multi turn DM features --- tests/test_core.py | 80 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index e12fcb5..f6c052a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,7 +3,7 @@ import mock from flask import Flask -from flask_clova import Clova, session, request +from flask_clova import Clova, session, request, question @unittest.skipIf(six.PY2, "Not yet supported on Python 2.x") class SmokeTestUsingSamples(unittest.TestCase): @@ -167,5 +167,81 @@ def session_func(): self.assertEqual(counter.call_count, 1) + def test_follow_up_intent(self): + @self.clova.intent( + 'parent_intent' + ) + def parent_intent(p_value): + return question("go to follow up intent") + + child_mock = mock.MagicMock() + @self.clova.intent( + 'child_intent', + follow_up=['parent_intent'] + ) + def child_intent(p_value, c_value): + child_mock() + self.assertEqual(p_value, "from parent") + self.assertEqual(c_value, "from child") + return "ok" + + orphan_mock = mock.MagicMock() + @self.clova.intent( + 'child_intent', + follow_up=['other_parent'] + ) + def orphan_intent(): + orphan_mock() + return "ok" + + req_p = { + "version": "0.1.0", + "session": {}, + "context": {}, + "request": { + "type": "IntentRequest", + "intent": { + "name": "parent_intent", + "slots": { + 'p_value': { + 'name': 'p_value', + 'value': 'from parent' + } + } + } + } + } + + req_c = { + "version": "0.1.0", + "session": {}, + "context": {}, + "request": { + "type": "IntentRequest", + "intent": { + "name": "child_intent", + "slots": { + 'c_value': { + 'name': 'c_value', + 'value': 'from child' + } + } + } + } + } + + with self.app.test_client() as client: + rv = client.post('/', json=req_p) + self.assertEqual('200 OK', rv.status) + + req_c['session']['sessionAttributes'] = rv.json.get('sessionAttributes') + rv = client.post('/', json=req_c) + self.assertEqual('200 OK', rv.status) + + self.assertEqual(child_mock.call_count, 1) + self.assertEqual(orphan_mock.call_count, 0) + + + if __name__ == "__main__": - unittest.run() \ No newline at end of file + unittest.main() \ No newline at end of file From 93970a57b436c3761fb14e6d4f6a6dadb8088334 Mon Sep 17 00:00:00 2001 From: wonyoHwang Date: Sat, 17 Nov 2018 15:43:27 +0900 Subject: [PATCH 2/2] Support follow up intent --- flask_clova/core.py | 69 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/flask_clova/core.py b/flask_clova/core.py index e276cd5..d65b600 100644 --- a/flask_clova/core.py +++ b/flask_clova/core.py @@ -206,7 +206,7 @@ def session_ended(): return f - def intent(self, intent_name, mapping=None, convert=None, default=None): + def intent(self, intent_name, mapping=None, convert=None, default=None, follow_up=None): """Decorator routes an CEK IntentRequest and provides the slot parameters to the wrapped function. Functions decorated as an intent are registered as the view function for the Intent's URL, @@ -229,6 +229,8 @@ def weather(city): default {dict} -- Provides default values for Intent slots if CEK reuqest returns no corresponding slot, or a slot with an empty value default: {} + + follow_up {list} -- List parent nodes to follow up """ if mapping is None: mapping = dict() @@ -236,12 +238,20 @@ def weather(city): convert = dict() if default is None: default = dict() + if follow_up is None: + follow_up = ["__private_default"] def decorator(f): - self._intent_view_funcs[intent_name] = f - self._intent_mappings[intent_name] = mapping - self._intent_converts[intent_name] = convert - self._intent_defaults[intent_name] = default + if intent_name not in self._intent_view_funcs: + self._intent_view_funcs[intent_name] = dict() + self._intent_mappings[intent_name] = dict() + self._intent_converts[intent_name] = dict() + self._intent_defaults[intent_name] = dict() + for fintent in follow_up: + self._intent_view_funcs[intent_name][fintent] = f + self._intent_mappings[intent_name][fintent] = mapping + self._intent_converts[intent_name][fintent] = convert + self._intent_defaults[intent_name][fintent] = default return f return decorator @@ -350,6 +360,7 @@ def _flask_view_func(self, *args, **kwargs): result = self._map_intent_to_view_func(self.request.intent)() if result is not None: + self._private_contexts_handler(request.intent) if isinstance(result, models._Response): result = result.render_response() response = make_response(result) @@ -358,30 +369,48 @@ def _flask_view_func(self, *args, **kwargs): logger.warning(request_type + " handler is not defined.") return "", 400 + def _private_contexts_handler(self, context): + if session.sessionAttributes is None or '__private_contexts' not in session.sessionAttributes: + session.sessionAttributes['__private_contexts'] = list() + session.sessionAttributes['__private_contexts'].append(context) + def _map_intent_to_view_func(self, intent): """Provides appropiate parameters to the intent functions.""" + arg_values = [] + if intent.name in self._intent_view_funcs: - view_func = self._intent_view_funcs[intent.name] + contexts = self.session.sessionAttributes.get('__private_contexts', {}) + follow_up_context = {'name': '__private_default'} + current_intents = self._intent_view_funcs[intent.name] + # consider parent intent + for context in contexts: + parent_intent = context.get('name') + if parent_intent in current_intents: + follow_up_context = context + break + view_func = current_intents[follow_up_context.get('name')] + + argspec = inspect.getfullargspec(view_func) + arg_names = argspec.args + arg_values = self._map_params_to_view_args(intent.name, arg_names, follow_up_context) + elif self._default_intent_view_func is not None: view_func = self._default_intent_view_func else: raise NotImplementedError('Intent "{}" not found and no default intent specified.'.format(intent.name)) - argspec = inspect.getfullargspec(view_func) - arg_names = argspec.args - arg_values = self._map_params_to_view_args(intent.name, arg_names) - return partial(view_func, *arg_values) - def _map_params_to_view_args(self, view_name, arg_names): + def _map_params_to_view_args(self, view_name, arg_names, follow_up_context): """ find and invoke appropriate function """ arg_values = [] - convert = self._intent_converts.get(view_name) - default = self._intent_defaults.get(view_name) - mapping = self._intent_mappings.get(view_name) + fname = follow_up_context.get('name') + convert = self._intent_converts[view_name][fname] + default = self._intent_defaults[view_name][fname] + mapping = self._intent_mappings[view_name][fname] convert_errors = {} @@ -393,9 +422,15 @@ def _map_params_to_view_args(self, view_name, arg_names): slot_object = getattr(intent.slots, slot_key) request_data[slot_object.name] = getattr(slot_object, 'value', None) - else: - for param_name in self.request: - request_data[param_name] = getattr(self.request, param_name, None) + # else: + # for param_name in self.request: + # request_data[param_name] = getattr(self.request, param_name, None) + + follow_up_slots = follow_up_context.get('slots') + if follow_up_slots is not None: + for slot_key in follow_up_slots.keys(): + slot_object = follow_up_slots.get(slot_key) + request_data[slot_object.get('name')] = slot_object.get('value') for arg_name in arg_names: param_or_slot = mapping.get(arg_name, arg_name)