mycroft-core/mycroft/dialog/dialog.py

200 lines
7.3 KiB
Python

# Copyright 2017 Mycroft AI Inc.
#
# 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
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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.
#
"""
Provides utilities for reading dialog files and rendering dialogs populated
with custom data.
"""
import random
import os
import re
from pathlib import Path
from os.path import join
from mycroft.util import resolve_resource_file
from mycroft.util.format import expand_options
from mycroft.util.log import LOG
class MustacheDialogRenderer:
"""A dialog template renderer based on the mustache templating language."""
def __init__(self):
self.templates = {}
self.recent_phrases = []
# TODO magic numbers are bad!
self.max_recent_phrases = 3
# We cycle through lines in .dialog files to keep Mycroft from
# repeating the same phrase over and over. However, if a .dialog
# file only contains a few entries, this can cause it to loop.
#
# This offset will override max_recent_phrases on very short .dialog
# files. With the offset at 2, .dialog files with 3 or more lines will
# be managed to avoid repetition, but .dialog files with only 1 or 2
# lines will be unaffected. Dialog should never get stuck in a loop.
self.loop_prevention_offset = 2
def load_template_file(self, template_name, filename):
"""Load a template by file name into the templates cache.
Arguments:
template_name (str): a unique identifier for a group of templates
filename (str): a fully qualified filename of a mustache template.
"""
with open(filename, 'r', encoding='utf8') as f:
for line in f:
template_text = line.strip()
# Skip all lines starting with '#' and all empty lines
if (not template_text.startswith('#') and
template_text != ''):
if template_name not in self.templates:
self.templates[template_name] = []
# convert to standard python format string syntax. From
# double (or more) '{' followed by any number of
# whitespace followed by actual key followed by any number
# of whitespace followed by double (or more) '}'
template_text = re.sub(r'\{\{+\s*(.*?)\s*\}\}+', r'{\1}',
template_text)
self.templates[template_name].append(template_text)
def render(self, template_name, context=None, index=None):
"""
Given a template name, pick a template and render it using the context.
If no matching template exists use template_name as template.
Tries not to let Mycroft say exactly the same thing twice in a row.
Arguments:
template_name (str): the name of a template group.
context (dict): dictionary representing values to be rendered
index (int): optional, the specific index in the collection of
templates
Returns:
str: the rendered string
"""
context = context or {}
if template_name not in self.templates:
# When not found, return the name itself as the dialog
# This allows things like render("record.not.found") to either
# find a translation file "record.not.found.dialog" or return
# "record not found" literal.
return template_name.replace('.', ' ')
# Get the .dialog file's contents, minus any which have been spoken
# recently.
template_functions = self.templates.get(template_name)
if index is None:
template_functions = ([t for t in template_functions
if t not in self.recent_phrases] or
template_functions)
line = random.choice(template_functions)
else:
line = template_functions[index % len(template_functions)]
# Replace {key} in line with matching values from context
line = line.format(**context)
line = random.choice(expand_options(line))
# Here's where we keep track of what we've said recently. Remember,
# this is by line in the .dialog file, not by exact phrase
self.recent_phrases.append(line)
if (len(self.recent_phrases) >
min(self.max_recent_phrases, len(self.templates.get(
template_name)) - self.loop_prevention_offset)):
self.recent_phrases.pop(0)
return line
class DialogLoader:
"""Loads a collection of dialog files into a renderer implementation.
TODO: Remove in 20.02
"""
def __init__(self, renderer_factory=MustacheDialogRenderer):
LOG.warning('Deprecated, use load_dialogs() instead.')
self.__renderer = renderer_factory()
def load(self, dialog_dir):
"""Load all dialog files within the specified directory.
Args:
dialog_dir (str): directory that contains dialog files
Returns:
a loaded instance of a dialog renderer
"""
return load_dialogs(dialog_dir, self.__renderer)
def load_dialogs(dialog_dir, renderer=None):
"""Load all dialog files within the specified directory.
Arguments:
dialog_dir (str): directory that contains dialog files
Returns:
a loaded instance of a dialog renderer
"""
if renderer is None:
renderer = MustacheDialogRenderer()
directory = Path(dialog_dir)
if not directory.exists() or not directory.is_dir():
LOG.warning('No dialog files found: {}'.format(dialog_dir))
return renderer
for path, _, files in os.walk(str(directory)):
for f in files:
if f.endswith('.dialog'):
renderer.load_template_file(f.replace('.dialog', ''),
join(path, f))
return renderer
def get(phrase, lang=None, context=None):
"""Looks up a resource file for the given phrase.
If no file is found, the requested phrase is returned as the string. This
will use the default language for translations.
Arguments:
phrase (str): resource phrase to retrieve/translate
lang (str): the language to use
context (dict): values to be inserted into the string
Returns:
str: a randomized and/or translated version of the phrase
"""
if not lang:
from mycroft.configuration import Configuration
lang = Configuration.get().get('lang')
filename = join('text', lang.lower(), phrase + '.dialog')
template = resolve_resource_file(filename)
if not template:
LOG.debug('Resource file not found: {}'.format(filename))
return phrase
stache = MustacheDialogRenderer()
stache.load_template_file('template', template)
if not context:
context = {}
return stache.render('template', context)