diff --git a/ovos_utils/bracket_expansion.py b/ovos_utils/bracket_expansion.py index 35f02c1..acfe752 100644 --- a/ovos_utils/bracket_expansion.py +++ b/ovos_utils/bracket_expansion.py @@ -1,7 +1,80 @@ +import itertools import re -from typing import List +from typing import List, Dict +from ovos_utils.log import deprecated + +def expand_template(template: str) -> List[str]: + def expand_optional(text): + """Replace [optional] with two options: one with and one without.""" + return re.sub(r"\[([^\[\]]+)\]", lambda m: f"({m.group(1)}|)", text) + + def expand_alternatives(text): + """Expand (alternative|choices) into a list of choices.""" + parts = [] + for segment in re.split(r"(\([^\(\)]+\))", text): + if segment.startswith("(") and segment.endswith(")"): + options = segment[1:-1].split("|") + parts.append(options) + else: + parts.append([segment]) + return itertools.product(*parts) + + def fully_expand(texts): + """Iteratively expand alternatives until all possibilities are covered.""" + result = set(texts) + while True: + expanded = set() + for text in result: + options = list(expand_alternatives(text)) + expanded.update(["".join(option).strip() for option in options]) + if expanded == result: # No new expansions found + break + result = expanded + return sorted(result) # Return a sorted list for consistency + + # Expand optional items first + template = expand_optional(template) + + # Fully expand all combinations of alternatives + return fully_expand([template]) + + +def expand_slots(template: str, slots: Dict[str, List[str]]) -> List[str]: + """Expand a template by first expanding alternatives and optional components, + then substituting slot placeholders with their corresponding options. + + Args: + template (str): The input string template to expand. + slots (dict): A dictionary where keys are slot names and values are lists of possible replacements. + + Returns: + list[str]: A list of all expanded combinations. + """ + # Expand alternatives and optional components + base_expansions = expand_template(template) + + # Process slots + all_sentences = [] + for sentence in base_expansions: + matches = re.findall(r"\{([^\{\}]+)\}", sentence) + if matches: + # Create all combinations for slots in the sentence + slot_options = [slots.get(match, [f"{{{match}}}"]) for match in matches] + for combination in itertools.product(*slot_options): + filled_sentence = sentence + for slot, replacement in zip(matches, combination): + filled_sentence = filled_sentence.replace(f"{{{slot}}}", replacement) + all_sentences.append(filled_sentence) + else: + # No slots to expand + all_sentences.append(sentence) + + return all_sentences + + +@deprecated("use 'expand_template' directly instead", "1.0.0") def expand_parentheses(sent: List[str]) -> List[str]: """ ['1', '(', '2', '|', '3, ')'] -> [['1', '2'], ['1', '3']] @@ -22,6 +95,7 @@ def expand_parentheses(sent: List[str]) -> List[str]: return SentenceTreeParser(sent).expand_parentheses() +@deprecated("use 'expand_template' directly instead", "1.0.0") def expand_options(parentheses_line: str) -> list: """ Convert 'test (a|b)' -> ['test a', 'test b'] @@ -38,6 +112,7 @@ def expand_options(parentheses_line: str) -> list: class Fragment: """(Abstract) empty sentence fragment""" + @deprecated("use 'expand_template' function directly instead", "1.0.0") def __init__(self, tree): """ Construct a sentence tree fragment which is merely a wrapper for @@ -73,6 +148,11 @@ class Word(Fragment): Construct with a string as argument. """ + @deprecated("use 'expand_template' function directly instead", "1.0.0") + def __init__(self, tree): + """DEPRECATED""" + super().__init__(tree) + def expand(self): """ Creates one sentence that contains exactly that word. @@ -89,6 +169,11 @@ class Sentence(Fragment): Construct with a List as argument. """ + @deprecated("use 'expand_template' function directly instead", "1.0.0") + def __init__(self, tree): + """DEPRECATED""" + super().__init__(tree) + def expand(self): """ Creates a combination of all sub-sentences. @@ -114,6 +199,11 @@ class Options(Fragment): Construct with List as argument. """ + @deprecated("use 'expand_template' function directly instead", "1.0.0") + def __init__(self, tree): + """DEPRECATED""" + super().__init__(tree) + def expand(self): """ Returns all of its options as seperated sub-sentences. @@ -133,6 +223,7 @@ class SentenceTreeParser: ['1', '(', '2', '|', '3, ')'] -> [['1', '2'], ['1', '3']] """ + @deprecated("use 'expand_template' function directly instead", "1.0.0") def __init__(self, tokens): self.tokens = tokens diff --git a/ovos_utils/dialog.py b/ovos_utils/dialog.py index 237b1c1..d44636d 100644 --- a/ovos_utils/dialog.py +++ b/ovos_utils/dialog.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Optional -from ovos_utils.bracket_expansion import expand_options +from ovos_utils.bracket_expansion import expand_template from ovos_utils.file_utils import resolve_resource_file from ovos_utils.lang import translate_word from ovos_utils.log import LOG, log_deprecation @@ -92,7 +92,7 @@ def render(self, template_name, context=None, index=None): 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)) + line = random.choice(expand_template(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 diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index 0b02f6b..003f21e 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -12,7 +12,7 @@ from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer -from ovos_utils.bracket_expansion import expand_options +from ovos_utils.bracket_expansion import expand_template from ovos_utils.log import LOG, log_deprecation @@ -241,7 +241,7 @@ def read_vocab_file(path: str) -> List[List[str]]: for line in voc_file.readlines(): if line.startswith('#') or line.strip() == '': continue - vocab.append(expand_options(line.lower())) + vocab.append(expand_template(line.lower())) return vocab diff --git a/test/unittests/test_bracket_expansion.py b/test/unittests/test_bracket_expansion.py index 8e1c31e..5d6bcbe 100644 --- a/test/unittests/test_bracket_expansion.py +++ b/test/unittests/test_bracket_expansion.py @@ -1,31 +1,118 @@ import unittest +from ovos_utils.bracket_expansion import expand_template, expand_slots -class TestBracketExpansion(unittest.TestCase): - def test_expand_parentheses(self): - from ovos_utils.bracket_expansion import expand_parentheses - # TODO - def test_expand_options(self): - from ovos_utils.bracket_expansion import expand_options - # TODO +class TestTemplateExpansion(unittest.TestCase): - def test_fragment(self): - from ovos_utils.bracket_expansion import Fragment - # TODO + def test_expand_slots(self): + # Test for expanding slots + template = "change [the ]brightness to {brightness_level} and color to {color_name}" + slots = { + "brightness_level": ["low", "medium", "high"], + "color_name": ["red", "green", "blue"] + } - def test_word(self): - from ovos_utils.bracket_expansion import Word - # TODO + expanded_sentences = expand_slots(template, slots) - def test_sentence(self): - from ovos_utils.bracket_expansion import Sentence - # TODO + expected_sentences = ['change brightness to low and color to red', + 'change brightness to low and color to green', + 'change brightness to low and color to blue', + 'change brightness to medium and color to red', + 'change brightness to medium and color to green', + 'change brightness to medium and color to blue', + 'change brightness to high and color to red', + 'change brightness to high and color to green', + 'change brightness to high and color to blue', + 'change the brightness to low and color to red', + 'change the brightness to low and color to green', + 'change the brightness to low and color to blue', + 'change the brightness to medium and color to red', + 'change the brightness to medium and color to green', + 'change the brightness to medium and color to blue', + 'change the brightness to high and color to red', + 'change the brightness to high and color to green', + 'change the brightness to high and color to blue'] + self.assertEqual(expanded_sentences, expected_sentences) - def test_options(self): - from ovos_utils.bracket_expansion import Options - # TODO + def test_expand_template(self): + # Test for template expansion + templates = [ + "[hello,] (call me|my name is) {name}", + "Expand (alternative|choices) into a list of choices.", + "sentences have [optional] words ", + "alternative words can be (used|written)", + "sentence[s] can have (pre|suf)fixes mid word too", + "do( the | )thing(s|) (old|with) style and( no | )spaces", + "[(this|that) is optional]", + "tell me a [{joke_type}] joke", + "play {query} [in ({device_name}|{skill_name}|{zone_name})]" + ] - def test_sentence_tree_parser(self): - from ovos_utils.bracket_expansion import SentenceTreeParser - # TODO + expected_outputs = { + "[hello,] (call me|my name is) {name}": [ + "call me {name}", + "hello, call me {name}", + "hello, my name is {name}", + "my name is {name}" + ], + "Expand (alternative|choices) into a list of choices.": [ + "Expand alternative into a list of choices.", + "Expand choices into a list of choices." + ], + "sentences have [optional] words ": [ + "sentences have words", + "sentences have optional words" + ], + "alternative words can be (used|written)": [ + "alternative words can be used", + "alternative words can be written" + ], + "sentence[s] can have (pre|suf)fixes mid word too": [ + "sentence can have prefixes mid word too", + "sentence can have suffixes mid word too", + "sentences can have prefixes mid word too", + "sentences can have suffixes mid word too" + ], + "do( the | )thing(s|) (old|with) style and( no | )spaces": [ + "do the thing old style and no spaces", + "do the thing old style and spaces", + "do the thing with style and no spaces", + "do the thing with style and spaces", + "do the things old style and no spaces", + "do the things old style and spaces", + "do the things with style and no spaces", + "do the things with style and spaces", + "do thing old style and no spaces", + "do thing old style and spaces", + "do thing with style and no spaces", + "do thing with style and spaces", + "do things old style and no spaces", + "do things old style and spaces", + "do things with style and no spaces", + "do things with style and spaces" + ], + "[(this|that) is optional]": [ + '', + 'that is optional', + 'this is optional'], + "tell me a [{joke_type}] joke": [ + "tell me a joke", + "tell me a {joke_type} joke" + ], + "play {query} [in ({device_name}|{skill_name}|{zone_name})]": [ + "play {query}", + "play {query} in {device_name}", + "play {query} in {skill_name}", + "play {query} in {zone_name}" + ] + } + + for template, expected_sentences in expected_outputs.items(): + with self.subTest(template=template): + expanded_sentences = expand_template(template) + self.assertEqual(expanded_sentences, expected_sentences) + + +if __name__ == '__main__': + unittest.main()