Skip to content

Commit 8905267

Browse files
JarbasAlclaude
andcommitted
fix: expand inline <voc> refs before registering intents to the engine
OVOS-INTENT-1 §3.7 makes an inline <name> in a .intent an authoring convenience that MUST expand in place from the sibling vocabulary of that name before the template reaches an intent engine. The padatious/padacioso bus protocol carries only samples, never the .voc content, so register_intent_file sent the raw <name> straight through and both engines trained on the literal token — padacioso never matched and padatious matched only by coincidence. Add SkillResources.vocabularies() to build the {name: members} map from the locale tree (user > skill > workshop precedence) and pass it into register_padatious_intent, which now inlines every <name> as an (a|b|c) group via ovos_spec_tools.inline_keywords before emitting the samples. Files without inline references are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c94945a commit 8905267

4 files changed

Lines changed: 179 additions & 4 deletions

File tree

ovos_workshop/intents.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from os.path import exists
22
from threading import RLock
3-
from typing import List, Optional
3+
from typing import Dict, List, Optional
44
import warnings
55
from ovos_bus_client.message import Message, dig_for_message
66
from ovos_bus_client.util import get_mycroft_bus
@@ -11,7 +11,7 @@
1111
# their long-standing `from ovos_workshop.intents import IntentBuilder` import while
1212
# the single source of truth is the spec. The `IntentServiceInterface` producer
1313
# (and the munge_* helpers) below consume these definitions.
14-
from ovos_spec_tools import Intent, IntentBuilder, open_intent_envelope
14+
from ovos_spec_tools import Intent, IntentBuilder, inline_keywords, open_intent_envelope
1515

1616

1717
def to_alnum(skill_id: str) -> str:
@@ -270,13 +270,20 @@ def remove_adapt_context(self, context: str):
270270
self.bus.emit(msg.forward('remove_context', {'context': context}))
271271

272272
def register_padatious_intent(self, intent_name: str, filename: str,
273-
lang: str, string_blacklist: Optional[List[str]] = None):
273+
lang: str, string_blacklist: Optional[List[str]] = None,
274+
vocabs: Optional[Dict[str, List[str]]] = None):
274275
"""
275276
Register a Padatious intent file with the intent service.
276277
@param intent_name: Unique intent identifier
277278
(usually `skill_id`:`filename`)
278279
@param filename: Absolute file path to entity file
279280
@param lang: BCP-47 language code of registered intent
281+
@param string_blacklist: phrases that should suppress a match
282+
@param vocabs: ``{name: members}`` of the sibling vocabularies, so an
283+
inline ``<name>`` reference (OVOS-INTENT-1 §3.7) is baked into the
284+
samples as an ``(a|b|c)`` group before they reach the engine — the
285+
padatious/padacioso bus protocol carries only samples, never the
286+
``.voc`` content, so the reference must be resolved here.
280287
"""
281288
if not isinstance(filename, str):
282289
raise ValueError('Filename path must be a string')
@@ -285,6 +292,9 @@ def register_padatious_intent(self, intent_name: str, filename: str,
285292
with open(filename) as f:
286293
samples = [_ for _ in f.read().split("\n") if _
287294
and not _.startswith("#")]
295+
if vocabs:
296+
# OVOS-INTENT-1 §3.7: resolve every inline <name> in place
297+
samples = [inline_keywords(s, vocabs) for s in samples]
288298
data = {'file_name': filename,
289299
"samples": samples,
290300
'name': intent_name,

ovos_workshop/resource_files.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,43 @@ def load_skill_vocabulary(self, alphanumeric_skill_id: str) -> dict:
806806

807807
return skill_vocabulary
808808

809+
def vocabularies(self) -> Dict[str, List[str]]:
810+
"""Build a ``{name: members}`` map from every ``.voc`` in this
811+
language's locale tree.
812+
813+
Feeds the inline ``<name>`` resolution required by OVOS-INTENT-1 §3.7:
814+
a reference in an ``.intent`` (or ``.blacklist``) expands in place from
815+
the sibling vocabulary of that name. Padatious and padacioso never read
816+
``.voc`` files at match time, so the reference must be baked into the
817+
template before it reaches the engine. User overrides take precedence
818+
over the skill's own resources, which take precedence over
819+
workshop-shipped resources.
820+
"""
821+
vocabularies: Dict[str, List[str]] = {}
822+
vocab_type = self.types.vocabulary
823+
for base in (vocab_type.user_directory,
824+
vocab_type.base_directory,
825+
vocab_type.workshop_directory):
826+
if not base:
827+
continue
828+
for directory, _, files in walk(str(base)):
829+
for file_name in files:
830+
if not file_name.endswith(".voc"):
831+
continue
832+
name = file_name[:-len(".voc")].lower()
833+
if name in vocabularies:
834+
continue
835+
members: List[str] = []
836+
with open(Path(directory, file_name)) as voc_file:
837+
for line in (ln.strip().lower() for ln in voc_file):
838+
if line and not line.startswith("#"):
839+
# a .voc line MAY hold comma/parenthesis
840+
# alternates; every phrasing is a member
841+
members.extend(flatten_list(expand_template(line)))
842+
if members:
843+
vocabularies[name] = members
844+
return vocabularies
845+
809846
def load_skill_regex(self, alphanumeric_skill_id: str) -> List[str]:
810847
"""
811848
Load all regex files in the skill's resources

ovos_workshop/skills/ovos.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1346,7 +1346,12 @@ def register_intent_file(self, intent_file: str, handler: callable,
13461346
for enty in voc_blacklist or []:
13471347
disallowed_strings += self.voc_list(enty, lang=lang)
13481348

1349-
self.intent_service.register_padatious_intent(name, filename, lang, string_blacklist=disallowed_strings)
1349+
# OVOS-INTENT-1 §3.7: supply the sibling vocabularies so an inline
1350+
# <name> reference in the .intent is baked into the samples before
1351+
# they are sent to the engine over the bus
1352+
self.intent_service.register_padatious_intent(
1353+
name, filename, lang, string_blacklist=disallowed_strings,
1354+
vocabs=resources.vocabularies())
13501355
if handler:
13511356
self.add_event(name, handler, 'mycroft.skill.handler',
13521357
activation=True, is_intent=True)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""OVOS-INTENT-1 §3.7: an inline ``<name>`` reference in a ``.intent`` file is
2+
an authoring convenience that must expand in place from the sibling ``.voc`` of
3+
that name before the template reaches an intent engine. The padatious/padacioso
4+
bus protocol carries only samples — never the ``.voc`` content — so the
5+
reference has to be resolved during registration, otherwise the raw ``<name>``
6+
token is trained into the engine and the intent never matches."""
7+
import unittest
8+
from pathlib import Path
9+
from tempfile import TemporaryDirectory
10+
11+
from ovos_utils.fakebus import FakeBus
12+
from ovos_workshop.intents import IntentServiceInterface
13+
from ovos_workshop.resource_files import ResourceFile, SkillResources
14+
15+
16+
class TestInlineVocabResources(unittest.TestCase):
17+
def _resources(self, files):
18+
tmp = TemporaryDirectory()
19+
self.addCleanup(tmp.cleanup)
20+
locale = Path(tmp.name, "locale", "en-us")
21+
locale.mkdir(parents=True)
22+
for name, content in files.items():
23+
(locale / name).write_text(content)
24+
return SkillResources(tmp.name, "en-us", skill_id="test.skill")
25+
26+
def test_vocabularies_map(self):
27+
resources = self._resources({
28+
"thing.voc": "widget\ngadget\n# comment\n",
29+
"foo.intent": "what about <thing>\n",
30+
})
31+
self.assertEqual(resources.vocabularies().get("thing"),
32+
["widget", "gadget"])
33+
34+
def test_vocabularies_expands_alternates(self):
35+
resources = self._resources({
36+
"greeting.voc": "(hello|hi)\ngood morning\n",
37+
})
38+
self.assertEqual(sorted(resources.vocabularies()["greeting"]),
39+
["good morning", "hello", "hi"])
40+
41+
42+
class TestInlineVocabRegistration(unittest.TestCase):
43+
"""The samples emitted on the registration bus topic must already have the
44+
inline ``<name>`` resolved to an ``(a|b|c)`` alternation group."""
45+
46+
def _capture_samples(self, files, pass_vocabs):
47+
tmp = TemporaryDirectory()
48+
self.addCleanup(tmp.cleanup)
49+
locale = Path(tmp.name, "locale", "en-us")
50+
locale.mkdir(parents=True)
51+
for name, content in files.items():
52+
(locale / name).write_text(content)
53+
resources = SkillResources(tmp.name, "en-us", skill_id="test.skill")
54+
filename = str(ResourceFile(resources.types.intent, "foo.intent").file_path)
55+
56+
bus = FakeBus()
57+
captured = {}
58+
bus.on("padatious:register_intent",
59+
lambda m: captured.__setitem__("samples", m.data["samples"]))
60+
iface = IntentServiceInterface(bus)
61+
iface.set_id("test.skill")
62+
kwargs = {"vocabs": resources.vocabularies()} if pass_vocabs else {}
63+
iface.register_padatious_intent("test.skill:foo.intent", filename,
64+
"en-us", **kwargs)
65+
return captured["samples"]
66+
67+
def test_inline_reference_resolved_before_engine(self):
68+
samples = self._capture_samples({
69+
"thing.voc": "widget\ngadget\n",
70+
"foo.intent": "what about <thing>\n",
71+
}, pass_vocabs=True)
72+
self.assertEqual(samples, ["what about (widget|gadget)"])
73+
74+
def test_raw_reference_without_vocabs_is_unresolved(self):
75+
samples = self._capture_samples({
76+
"thing.voc": "widget\ngadget\n",
77+
"foo.intent": "what about <thing>\n",
78+
}, pass_vocabs=False)
79+
self.assertEqual(samples, ["what about <thing>"])
80+
81+
82+
class TestInlineVocabPadaciosoEndToEnd(unittest.TestCase):
83+
"""End-to-end proof through the real padacioso engine: a skill shipping a
84+
``foo.intent`` with ``<thing>`` and a sibling ``thing.voc`` matches an
85+
utterance built from a vocabulary member."""
86+
87+
def _register_and_match(self, utterance, pass_vocabs):
88+
from padacioso import IntentContainer
89+
90+
tmp = TemporaryDirectory()
91+
self.addCleanup(tmp.cleanup)
92+
locale = Path(tmp.name, "locale", "en-us")
93+
locale.mkdir(parents=True)
94+
(locale / "thing.voc").write_text("widget\ngadget\n")
95+
(locale / "foo.intent").write_text("what about <thing>\n")
96+
resources = SkillResources(tmp.name, "en-us", skill_id="test.skill")
97+
filename = str(ResourceFile(resources.types.intent, "foo.intent").file_path)
98+
99+
bus = FakeBus()
100+
captured = {}
101+
bus.on("padatious:register_intent",
102+
lambda m: captured.__setitem__("samples", m.data["samples"]))
103+
iface = IntentServiceInterface(bus)
104+
iface.set_id("test.skill")
105+
kwargs = {"vocabs": resources.vocabularies()} if pass_vocabs else {}
106+
iface.register_padatious_intent("test.skill:foo.intent", filename,
107+
"en-us", **kwargs)
108+
109+
engine = IntentContainer()
110+
engine.add_intent("foo", captured["samples"])
111+
return engine.calc_intent(utterance)
112+
113+
def test_inline_voc_intent_matches(self):
114+
match = self._register_and_match("what about widget", pass_vocabs=True)
115+
self.assertEqual(match.get("name"), "foo")
116+
117+
def test_raw_reference_does_not_match(self):
118+
match = self._register_and_match("what about widget", pass_vocabs=False)
119+
self.assertIsNone(match.get("name"))
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()

0 commit comments

Comments
 (0)