From a1071468b00753d5f8774fa8e1368d9e0872e15b Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Fri, 3 Jul 2026 18:30:19 +0100 Subject: [PATCH] fix: resolve inline references when loading .intent/.blacklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OVOS-INTENT-1 §3.7 an inline reference in a .intent or .blacklist file must expand in place from the sibling vocabulary of that name. The loader passed such lines straight to the bracket expander, so survived as literal text and the intent never matched. Build a {name: members} map from every sibling .voc in the locale tree (user > skill > workshop precedence) and delegate the §3.7 resolution to ovos_spec_tools.inline_keywords before expansion. Files without inline references are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- ovos_workshop/resource_files.py | 61 ++++++++++++++++++++++-- test/unittests/test_inline_vocab_refs.py | 52 ++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 test/unittests/test_inline_vocab_refs.py diff --git a/ovos_workshop/resource_files.py b/ovos_workshop/resource_files.py index 70c6a76b..ef58d7cf 100644 --- a/ovos_workshop/resource_files.py +++ b/ovos_workshop/resource_files.py @@ -26,6 +26,7 @@ from ovos_config.locations import get_xdg_data_save_path from ovos_utils import flatten_list from ovos_utils.bracket_expansion import expand_template +from ovos_spec_tools import inline_keywords from ovos_utils.dialog import MustacheDialogRenderer, load_dialogs from ovos_utils.log import LOG @@ -442,6 +443,9 @@ class IntentFile(ResourceFile): def __init__(self, resource_type, resource_name): super().__init__(resource_type, resource_name) self.data = None + # {name: members} of the sibling .voc files, so an inline + # reference (OVOS-INTENT-1 §3.7) resolves in place at load time. + self.vocabularies = None def load(self, entities: bool = True) -> List[str]: """Load file containing intents. @@ -454,8 +458,12 @@ def load(self, entities: bool = True) -> List[str]: intents = [] if self.file_path is not None: for line in self._read(): - line = line.replace("{{", "{").replace("}}", "}") - intents.extend(flatten_list(expand_template(line.lower()))) + line = line.replace("{{", "{").replace("}}", "}").lower() + if self.vocabularies and "<" in line: + # OVOS-INTENT-1 §3.7: inline every reference from the + # sibling vocabulary as an (a|b|c) group before expanding. + line = inline_keywords(line, self.vocabularies) + intents.extend(flatten_list(expand_template(line))) if not entities: intents = [re.sub(r'{.*?}\s?', '', intent).strip() for intent in intents] elif self.data: @@ -555,6 +563,12 @@ class BlacklistFile(ResourceFile): """Defines a blacklist file, a slot-free list of phrases that should prevent the sibling intent (same base name) from matching.""" + def __init__(self, resource_type, resource_name): + super().__init__(resource_type, resource_name) + # {name: members} of the sibling .voc files, so an inline + # reference (OVOS-INTENT-1 §3.7) resolves in place at load time. + self.vocabularies = None + def load(self) -> List[str]: """Load the phrases contained in a blacklist file. @@ -564,7 +578,14 @@ def load(self) -> List[str]: phrases = [] if self.file_path is not None: for line in self._read(): - phrases.append(line.lower()) + line = line.lower() + if self.vocabularies and "<" in line: + # OVOS-INTENT-1 §3.7: inline every reference from the + # sibling vocabulary and enumerate the resulting phrases. + line = inline_keywords(line, self.vocabularies) + phrases.extend(flatten_list(expand_template(line))) + else: + phrases.append(line) return phrases @@ -681,8 +702,41 @@ def load_intent_file(self, name: str, """ intent_file = IntentFile(self.types.intent, name) intent_file.data = data + intent_file.vocabularies = self._locale_vocabularies() return intent_file.load(entities) + def _locale_vocabularies(self) -> Dict[str, List[str]]: + """Build a ``{name: members}`` map from every ``.voc`` in this + language's locale tree. + + Feeds the inline ```` resolution required by OVOS-INTENT-1 §3.7: + a reference in an ``.intent`` or ``.blacklist`` expands from the sibling + vocabulary of that name. User overrides take precedence over the skill's + own resources, which take precedence over workshop-shipped resources. + """ + vocabularies: Dict[str, List[str]] = {} + vocab_type = self.types.vocabulary + for base in (vocab_type.user_directory, + vocab_type.base_directory, + vocab_type.workshop_directory): + if not base: + continue + for directory, _, files in walk(str(base)): + for file_name in files: + if not file_name.endswith(".voc"): + continue + name = file_name[:-len(".voc")].lower() + if name in vocabularies: + continue + members = [] + with open(Path(directory, file_name)) as voc_file: + for line in (ln.strip() for ln in voc_file): + if line and not line.startswith("#"): + members.append(line.lower()) + if members: + vocabularies[name] = members + return vocabularies + def load_blacklist_file(self, name: str) -> List[str]: """ Loads the phrases contained in a blacklist file. @@ -696,6 +750,7 @@ def load_blacklist_file(self, name: str) -> List[str]: A list of blacklisted phrases """ blacklist_file = BlacklistFile(self.types.blacklist, name) + blacklist_file.vocabularies = self._locale_vocabularies() return blacklist_file.load() def locate_qml_file(self, name: str) -> str: diff --git a/test/unittests/test_inline_vocab_refs.py b/test/unittests/test_inline_vocab_refs.py new file mode 100644 index 00000000..3117602b --- /dev/null +++ b/test/unittests/test_inline_vocab_refs.py @@ -0,0 +1,52 @@ +import unittest +from tempfile import TemporaryDirectory +from pathlib import Path + + +class TestInlineVocabReferences(unittest.TestCase): + """OVOS-INTENT-1 §3.7: an inline ```` reference in a ``.intent`` or + ``.blacklist`` file must expand in place from the sibling ``.voc`` of that + name found in the same locale directory.""" + + def _skill_dir(self, files: dict) -> str: + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + locale = Path(tmp.name, "locale", "en-us") + locale.mkdir(parents=True) + for name, content in files.items(): + (locale / name).write_text(content) + return tmp.name + + def _resources(self, skill_dir): + from ovos_workshop.resource_files import SkillResources + return SkillResources(skill_dir, "en-us", skill_id="test.skill") + + def test_intent_inline_vocab(self): + skill_dir = self._skill_dir({ + "thing.voc": "lamp\nlight\n", + "foo.intent": "turn on the \n", + }) + intents = self._resources(skill_dir).load_intent_file("foo.intent") + self.assertIn("turn on the lamp", intents) + self.assertIn("turn on the light", intents) + + def test_blacklist_inline_vocab(self): + skill_dir = self._skill_dir({ + "pronoun.voc": "it\nthem\n", + "bar.blacklist": "stop \n", + }) + phrases = self._resources(skill_dir).load_blacklist_file("bar.blacklist") + self.assertIn("stop it", phrases) + self.assertIn("stop them", phrases) + + def test_intent_without_inline_ref_unchanged(self): + skill_dir = self._skill_dir({ + "foo.intent": "turn on the (lamp|light)\n", + }) + intents = self._resources(skill_dir).load_intent_file("foo.intent") + self.assertIn("turn on the lamp", intents) + self.assertIn("turn on the light", intents) + + +if __name__ == "__main__": + unittest.main()