From 455c3d128baa3a0fa11130eb5c017f6aa351017e Mon Sep 17 00:00:00 2001 From: Omar BENHAMID Date: Thu, 28 Jun 2018 21:00:18 +0200 Subject: [PATCH 1/6] Supporting UTF-8 in tempaltes.yaml (for French/German ...) --- flask_ask/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_ask/core.py b/flask_ask/core.py index 987659e..8a9718b 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -884,7 +884,7 @@ def __init__(self, app, path): def _reload_mapping(self): if os.path.isfile(self.path): self.last_mtime = os.path.getmtime(self.path) - with open(self.path) as f: + with open(self.path, encoding="utf-8") as f: self.mapping = yaml.safe_load(f.read()) def get_source(self, environment, template): From d02e4d479f77551cb53592e78499c154f60e5529 Mon Sep 17 00:00:00 2001 From: Omar BENHAMID Date: Fri, 29 Jun 2018 07:19:32 +0200 Subject: [PATCH 2/6] Support to generate 'workable' interactionModel --- flask_ask/core.py | 90 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/flask_ask/core.py b/flask_ask/core.py index 8a9718b..8342359 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -16,7 +16,7 @@ from .convert import to_date, to_time, to_timedelta from .cache import top_stream, set_stream import collections - +import re def find_ask(): """ @@ -650,8 +650,94 @@ def start_response(status, response_headers, _exc_info=None): # is implemented on the result object. if hasattr(result, 'close'): result.close() + + def generate_interaction_model_blueprint(self): + """ + Generates a JSON representation of the Skill Interaction Model + JSON, this is not 100% complete model but is a starting point, + it can be copy/pasted in ASK Console JSON Editor as a starting point + """ + #TODO: support types + return { + "interactionModel": { + "languageModel": { +#TODO: define invocationName in some way ? + "invocationName": "abracadabra", + "intents":list(self._gen_im_intents()), + "types": [] + } + } + } + + @staticmethod + def _gen_im_identifier_to_words(identifier): + """ Split camelcase, remove non alphas, and remove eventual trailing "Intent" """ + matches = re.finditer('.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', identifier) + return ' '.join(m.group(0) for m in matches if m.group(0).lower() != 'intent') + + def _gen_im_intents(self): + for view_name in self._intent_view_funcs.keys(): + slots = list(self._gen_im_slots(view_name)) + intent = { "name": view_name, + "slots": slots + } + intent_in_words = self._gen_im_identifier_to_words(view_name) + + slot_samples = [] + + if len(slots) > 0: + slot_samples.append(' '.join( + "{%s}"%slot["name"] for slot in slots)) + + slot_samples.append(' '.join( + "%s {%s}"%(self._gen_im_identifier_to_words(slot["name"]), + slot["name"]) for slot in slots)) + + slot_samples.append( ' and '.join( + "%s {%s}"%(self._gen_im_identifier_to_words(slot["name"]), + slot["name"]) for slot in slots)) + + slot_samples.extend(list("with "+sample for sample in slot_samples)) + else: + slot_samples = [""] + + intent["samples"] = [] + + for slot_sample in slot_samples: + intent["samples"].extend(pattern % (intent_in_words, slot_sample) for pattern in + ["I want to %s %s", "My answer is %s %s", "invoke %s %s", "do %s %s","%s %s"]) + yield intent + #TODO: utterrances should be made customizable (in af ile ?) + + + def _gen_im_slots(self, view_name): + view_func = self._intent_view_funcs.get(view_name) + argspec = inspect.getargspec(view_func) + arg_names = argspec.args + + convert = self._intent_converts.get(view_name) + default = self._intent_defaults.get(view_name) + mapping = self._intent_mappings.get(view_name) - + for arg_name in arg_names: + yield { + "name": mapping.get(arg_name, arg_name), + "type": self._gen_im_infer_slot_type(convert[arg_name]) + } + + def _gen_im_infer_slot_type(self, shorthand_or_function): + if shorthand_or_function in ('date',to_date): + return "AMAZON.DATE" + elif shorthand_or_function in ('time',to_time): + return "AMAZON.TIME" + elif shorthand_or_function in ('timedelta',to_timedelta): + return "AMAZON.DURATION" + elif shorthand_or_function in (int, float): + return "AMAZON.NUMBER" + #TODO: support for list types + #TODO: support for custom types + return "UnknowType" + def _get_user(self): if self.context: return self.context.get('System', {}).get('user', {}).get('userId') From 834cb2fba4adb1dd0d165303133e6ef1ebe53290 Mon Sep 17 00:00:00 2001 From: Omar BENHAMID Date: Fri, 29 Jun 2018 19:01:06 +0200 Subject: [PATCH 3/6] Support for generating interaction model JSON automagically --- flask_ask/core.py | 75 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/flask_ask/core.py b/flask_ask/core.py index 8342359..1f87057 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -130,6 +130,21 @@ def init_app(self, app, path='templates.yaml'): Add tabs and linebreaks to the Alexa request and response printed to the debug log. This improves readability when printing to the console, but breaks formatting when logging to CloudWatch. Default: False + + `ASK_INTERACTION_MODEL_FILE`: + + When this path is set, a JSON interaction model compatible with ASK JSON Editor + When None : no interaction model is generated. + Default: None + + `ASK_IM_INVOCATION_NAME`: + + When this name is set, it is used as invocation name in generated interaction model, + when None invocation name is a placeholder that needs to be filled by user. + Default: None + + + """ if self._route is None: raise TypeError("route is a required argument when app is not None") @@ -140,7 +155,9 @@ def init_app(self, app, path='templates.yaml'): app.add_url_rule(self._route, view_func=self._flask_view_func, methods=['POST']) app.jinja_loader = ChoiceLoader([app.jinja_loader, YamlLoader(app, path)]) - + + self._resolve_im_path(app) + def init_blueprint(self, blueprint, path='templates.yaml'): """Initialize a Flask Blueprint, similar to init_app, but without the access to the application config. @@ -260,10 +277,12 @@ def decorator(f): self._intent_mappings[intent_name] = mapping self._intent_converts[intent_name] = convert self._intent_defaults[intent_name] = default - + @wraps(f) def wrapper(*args, **kw): self._flask_view_func(*args, **kw) + + self.sync_interaction_model() return f return decorator @@ -327,6 +346,8 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): self._flask_view_func(*args, **kwargs) + + self.sync_interaction_model() return f return decorator @@ -358,6 +379,8 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): self._flask_view_func(*args, **kwargs) + + self.sync_interaction_model() return f return decorator @@ -396,6 +419,8 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): self._flask_view_func(*args, **kwargs) + + self.sync_interaction_model() return f return decorator @@ -451,6 +476,8 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): self._flask_view_func(*args, **kwargs) + + self.sync_interaction_model() return f return decorator @@ -488,6 +515,8 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): self._flask_view_func(*args, **kwargs) + + self.sync_interaction_model() return f return decorator @@ -650,24 +679,42 @@ def start_response(status, response_headers, _exc_info=None): # is implemented on the result object. if hasattr(result, 'close'): result.close() - - def generate_interaction_model_blueprint(self): + + def _resolve_im_path(self, app): + self.invocation_name = app.config.get("ASK_IM_INVOCATION_NAME","Set ASK_IM_INVOCATION_NAME or define one here") + + self.impath = app.config.get("ASK_INTERACTION_MODEL_FILE", None) + if not self.impath and ('--generate-interaction-model' in sys.argv): + idx = sys.argv.index('--generate-interaction-model') + self.impath = 'interactionModel.json' if (len(sys.argv) == idx+1) else sys.argv[idx+1] + if not self.impath: + return + logger.info("Interaction model JSON will be generated in : %s" % self.impath) + + def sync_interaction_model(self): """ Generates a JSON representation of the Skill Interaction Model JSON, this is not 100% complete model but is a starting point, it can be copy/pasted in ASK Console JSON Editor as a starting point """ - #TODO: support types - return { - "interactionModel": { - "languageModel": { -#TODO: define invocationName in some way ? - "invocationName": "abracadabra", - "intents":list(self._gen_im_intents()), - "types": [] + if not self.impath: return + try: + out = open(self.impath, 'w', encoding='utf-8') + #TODO: support types + json.dump({ + "interactionModel": { + "languageModel": { + "invocationName": self.invocation_name, + "intents":list(self._gen_im_intents()), + "types": [] + } } - } - } + }, out, indent=4) + out.close() + logger.debug("Synced interaction model to : %s" % self.impath) + except: + logger.warn("Failed synching interaction model to : %s" % self.impath, exc_info=True) + @staticmethod def _gen_im_identifier_to_words(identifier): From ca063135365ed72ba89840e37666d7a780874bd8 Mon Sep 17 00:00:00 2001 From: Omar BENHAMID Date: Fri, 29 Jun 2018 19:52:30 +0200 Subject: [PATCH 4/6] Multi-lingual Skill support in template.yaml --- README.rst | 15 +++++++++++++++ flask_ask/core.py | 21 +++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 63b4d34..5b5bf28 100644 --- a/README.rst +++ b/README.rst @@ -55,6 +55,21 @@ above: Templates are stored in a file called `templates.yaml` located in the application root. Checkout the `Tidepooler example `_ to see why it makes sense to extract speech out of the code and into templates as the number of spoken phrases grow. +Multi-Language Skills: +--------------------- + +If you write a multi-language skill, you can set many localizations of the messages +adding the locale as suffix to the message in templates UAML file, for example : + +.. code-block:: yaml + + hello_en_US: Beautiful appartement {{ firstname }} ! + hello_en_UK: Beautiful flat {{firstname}} ! + hello_fr: Bel appartement {{ firstname }} ! + hello: Beautiful appartement or flat {{firstname}} ! + +Notice that if the right localization is not found, the unlocalized version is used. + Features =============== diff --git a/flask_ask/core.py b/flask_ask/core.py index 8a9718b..1065b1f 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -736,7 +736,7 @@ def _flask_view_func(self, *args, **kwargs): ask_payload = self._alexa_request(verify=self.ask_verify_requests) dbgdump(ask_payload) request_body = models._Field(ask_payload) - + self.request = request_body.request self.version = request_body.version self.context = getattr(request_body, 'context', models._Field()) @@ -875,12 +875,13 @@ def _map_params_to_view_args(self, view_name, arg_names): class YamlLoader(BaseLoader): - + def __init__(self, app, path): self.path = app.root_path + os.path.sep + path self.mapping = {} self._reload_mapping() - + + def _reload_mapping(self): if os.path.isfile(self.path): self.last_mtime = os.path.getmtime(self.path) @@ -892,7 +893,19 @@ def get_source(self, environment, template): return None, None, None if self.last_mtime != os.path.getmtime(self.path): self._reload_mapping() - if template in self.mapping: + + locale = getattr(_app_ctx_stack.top, '_ask_request').locale + source = None + for sfx in (locale.replace('-','_'), locale.split('-',1)[0]): + key = template+"_"+sfx + if key in self.mapping: + source = self.mapping[key] + break + if not source: + logger.warn("No localized template found for locale %r and template %r, falling back to default.", locale, template) source = self.mapping[template] + + if source: return source, None, lambda: source == self.mapping.get(template) + raise TemplateNotFound(template) From 25aa9c6fe6d32c48242c608caf69510f9b3833f5 Mon Sep 17 00:00:00 2001 From: Omar BENHAMID Date: Tue, 3 Jul 2018 17:16:27 +0200 Subject: [PATCH 5/6] Keeping samples of old interaction model while regenerating --- flask_ask/core.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/flask_ask/core.py b/flask_ask/core.py index 8c9c310..47778ad 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -684,12 +684,16 @@ def _resolve_im_path(self, app): self.invocation_name = app.config.get("ASK_IM_INVOCATION_NAME","Set ASK_IM_INVOCATION_NAME or define one here") self.impath = app.config.get("ASK_INTERACTION_MODEL_FILE", None) - if not self.impath and ('--generate-interaction-model' in sys.argv): - idx = sys.argv.index('--generate-interaction-model') + if not self.impath and ('--interaction-model-file' in sys.argv): + idx = sys.argv.index('--interaction-model-file') self.impath = 'interactionModel.json' if (len(sys.argv) == idx+1) else sys.argv[idx+1] if not self.impath: return - logger.info("Interaction model JSON will be generated in : %s" % self.impath) + logger.info("Interaction model JSON will be synchronized : %s" % self.impath) + try: + self._old_im = json.load(open(self.impath,"r",encoding="utf-8")) + except: + self._old_im = {} def sync_interaction_model(self): """ @@ -721,13 +725,28 @@ def _gen_im_identifier_to_words(identifier): """ Split camelcase, remove non alphas, and remove eventual trailing "Intent" """ matches = re.finditer('.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', identifier) return ' '.join(m.group(0) for m in matches if m.group(0).lower() != 'intent') - + + def _get_old_im_intent(self,view_name): + i = self._old_im.get("interactionModel",{}) + i = i.get("languageModel",{}) + for intent in i.get("intents",[]): + if view_name == intent.get("name",None): + return intent + def _gen_im_intents(self): for view_name in self._intent_view_funcs.keys(): slots = list(self._gen_im_slots(view_name)) - intent = { "name": view_name, - "slots": slots + intent = { + "name": view_name, + "slots": slots } + + old_int = self._get_old_im_intent(view_name) + if old_int: + intent["samples"] = old_int.get("samples",[]) + yield intent + continue + intent_in_words = self._gen_im_identifier_to_words(view_name) slot_samples = [] From 7be07d758d80028ce753c5f17a8cba685178b24c Mon Sep 17 00:00:00 2001 From: Omar BENHAMID Date: Tue, 3 Jul 2018 17:24:26 +0200 Subject: [PATCH 6/6] Warning when slots change as compared to interaction model --- flask_ask/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flask_ask/core.py b/flask_ask/core.py index 47778ad..e2c9f64 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -740,9 +740,13 @@ def _gen_im_intents(self): "name": view_name, "slots": slots } - + old_int = self._get_old_im_intent(view_name) if old_int: + if set(json.dumps(o, sort_keys=True) for o in old_int.get("slots",[])) != set(json.dumps(o, sort_keys=True) for o in intent.get("slots",[])): + logger.warn("Slots of intent %s changed between old and new " + +"interaction model (May be you want to review your " + +"samples ?)", view_name) intent["samples"] = old_int.get("samples",[]) yield intent continue