diff --git a/docs/skill-interaction.md b/docs/skill-interaction.md new file mode 100644 index 00000000..c15413ea --- /dev/null +++ b/docs/skill-interaction.md @@ -0,0 +1,357 @@ +# Skill Interaction Methods + +`OVOSSkill` provides two high-level methods for collecting structured user input: `ask_yesno` for binary yes/no questions and `ask_selection` for multiple-choice prompts. Both are backed by pluggable agent engines that can be swapped per-skill or system-wide. + +--- + +## ask_yesno + +```python +OVOSSkill.ask_yesno(prompt: str, data: Optional[dict] = None) -> Optional[str] +``` + +Speaks *prompt*, waits for the user's response, and classifies it as `"yes"`, `"no"`, or the raw response string if neither matched. + +**Source**: `OVOSSkill.ask_yesno` — `ovos_workshop/skills/ovos.py` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `prompt` | `str` | Dialog ID (looked up in `locale/`) or a literal string to speak. | +| `data` | `dict \| None` | Template variables for Mustache rendering of the dialog string. | + +### Return values + +| Value | Meaning | +|-------|---------| +| `"yes"` | User confirmed (e.g. "yeah", "sure", "of course") | +| `"no"` | User declined (e.g. "nope", "nah", "definitely not") | +| `str` | User spoke something that could not be classified — raw transcript returned | +| `None` | No response received (timeout or user said nothing) | + +### Basic usage + +```python +class MySkill(OVOSSkill): + def handle_delete_intent(self, message): + if self.ask_yesno("confirm_delete") == "yes": + self._do_delete() + self.speak_dialog("deleted") + else: + self.speak_dialog("cancelled") +``` + +`locale/en-us/confirm_delete.dialog`: +``` +Are you sure you want to delete this? +Do you really want to delete it? +``` + +### With template data + +```python +answer = self.ask_yesno("confirm_action", data={"action": "restart the server"}) +``` + +`locale/en-us/confirm_action.dialog`: +``` +Are you sure you want to {{action}}? +``` + +### Handling all return values + +```python +answer = self.ask_yesno("do_you_want_music") +if answer == "yes": + self.play_music() +elif answer == "no": + self.speak_dialog("okay_nevermind") +elif answer is None: + self.speak_dialog("no_response") +else: + # answer is the raw transcript — user said something unexpected + self.speak_dialog("did_not_understand") +``` + +### How it works internally + +1. `get_response(dialog=prompt, data=data)` — speaks the prompt, records user reply. +2. `_get_yesno_engine()` — loads the configured `YesNoEngine` plugin (if any). +3. If a plugin is loaded: `engine.yes_or_no(question=prompt, response=resp, lang=self.lang)` → `True`, `False`, or `None`. +4. If no plugin: `YesNoSolver().match_yes_or_no(resp, lang=self.lang)` (built-in fallback, always available). +5. `True` → `"yes"`, `False` → `"no"`, `None`/unmatched → raw response. + +--- + +## ask_selection + +```python +OVOSSkill.ask_selection( + options: List[str], + dialog: str = '', + data: Optional[dict] = None, + min_conf: float = 0.65, + numeric: bool = False, + num_retries: int = -1, +) -> Optional[str] +``` + +Speaks the options list to the user, optionally follows with a dialog prompt, then resolves the user's spoken response to one of the options. + +**Source**: `OVOSSkill.ask_selection` — `ovos_workshop/skills/ovos.py` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `options` | `List[str]` | — | The predefined options to offer. | +| `dialog` | `str` | `''` | Dialog ID or literal string spoken **after** the options list. | +| `data` | `dict \| None` | `None` | Template variables for the dialog string. | +| `min_conf` | `float` | `0.65` | Minimum fuzzy-match confidence for the default plugin. Passed to the `OptionMatcherEngine` config if no plugin-level config overrides it. | +| `numeric` | `bool` | `False` | If `True`, speaks each option prefixed with its number ("one, pizza; two, pasta; …"). If `False`, speaks them as a joined list ("pizza, pasta, or salad?"). | +| `num_retries` | `int` | `-1` | How many times to re-prompt on no response. `-1` means use the system default. | + +### Return values + +| Value | Meaning | +|-------|---------| +| `str` | One of the strings from `options`, exactly as provided. | +| `None` | No match, no response, or plugin failure. | + +Special cases handled before user interaction: +- Empty `options` → `None` immediately. +- Single-element `options` → returns that element immediately (no prompt). + +### Basic usage + +```python +class MySkill(OVOSSkill): + def handle_transport_intent(self, message): + modes = ["bus", "train", "bicycle"] + choice = self.ask_selection(modes, dialog="which_transport") + if choice: + self.speak_dialog("you_chose", {"mode": choice}) +``` + +`locale/en-us/which_transport.dialog`: +``` +Which would you prefer? +How would you like to travel? +``` + +The skill speaks: *"bus, train, or bicycle? Which would you prefer?"* + +The user can say: +- `"train"` — fuzzy-matched directly +- `"the second one"` / `"number two"` / `"two"` — position matched +- `"the last one"` — last-word matched +- `"option 3"` — numeric matched (requires `ovos-number-parser`) + +### Numeric menu + +```python +choice = self.ask_selection(options, numeric=True) +``` + +Speaks each option as: *"one, bus; two, train; three, bicycle"*. Useful when options are long or ambiguous. The user can then say *"two"* or *"the second one"*. + +### Handling None + +```python +choice = self.ask_selection(options, dialog="which_one", num_retries=1) +if choice is None: + self.speak_dialog("could_not_understand") + return +``` + +### How it works internally + +1. Validates `options` (raises `ValueError` if not a list; returns immediately for 0 or 1 items). +2. Speaks options (as list or numbered menu based on `numeric`). +3. `get_response(dialog=dialog, data=data, num_retries=num_retries)` — speaks optional follow-up dialog, records reply. +4. `_get_selection_engine()` — loads the configured `OptionMatcherEngine` plugin. +5. `engine.match_option(utterance=resp, options=options, lang=self.lang)` — resolves response to a slot. +6. If the engine raises or returns `None`, `ask_selection` returns `None`. + +--- + +## Plugin system + +Both methods are backed by pluggable agent engines discovered and loaded via [ovos-plugin-manager](https://github.com/OpenVoiceOS/ovos-plugin-manager). + +### ask_yesno — YesNoEngine + +**Plugin type**: `YesNoEngine` (`opm.agents.yesno`) +**Config key**: `ask_yesno_plugin` +**Built-in fallback**: `ovos-solver-yes-no-plugin` (always available, no config needed) + +`YesNoEngine` plugins implement: + +```python +def yes_or_no(self, question: str, response: str, lang: Optional[str] = None) -> Optional[bool]: + ... # True = yes, False = no, None = unclear +``` + +The `question` argument gives the plugin context about what was asked, enabling smarter inference (e.g. an LLM-backed plugin could use it to resolve ambiguous answers). + +**Available plugins**: + +| Plugin | Description | +|--------|-------------| +| `ovos-solver-yes-no-plugin` | Rule-based multilingual yes/no classifier (default fallback) | + +### ask_selection — OptionMatcherEngine + +**Plugin type**: `OptionMatcherEngine` (`opm.agents.option_matcher`) +**Config key**: `ask_selection_plugin` +**Default plugin**: `ovos-option-matcher-fuzzy-plugin` (installed as a dependency of `ovos-workshop`) + +`OptionMatcherEngine` plugins implement: + +```python +def match_option(self, utterance: str, options: List[str], lang: Optional[str] = None) -> Optional[str]: + ... # returns one of options, or None +``` + +**Available plugins**: + +| Plugin | Description | +|--------|-------------| +| `ovos-option-matcher-fuzzy-plugin` | Fuzzy + ordinal/cardinal vocab + numeric fallback. Default. | + +--- + +## Configuration + +### Global defaults — `mycroft.conf` + +```json +{ + "skills": { + "ask_yesno_plugin": "ovos-solver-yes-no-plugin", + "ask_selection_plugin": "ovos-option-matcher-fuzzy-plugin" + } +} +``` + +`ask_yesno_plugin` defaults to `None` (built-in `YesNoSolver` used). `ask_selection_plugin` defaults to `ovos-option-matcher-fuzzy-plugin`. + +### Per-skill override — `settings.json` + +Place in the skill's `settings.json` to override for that skill only: + +```json +{ + "ask_yesno_plugin": "my-llm-yesno-plugin", + "ask_selection_plugin": "my-embedding-option-matcher" +} +``` + +### Plugin config + +Pass configuration to the plugin via the same settings block: + +```json +{ + "ask_selection_plugin": "ovos-option-matcher-fuzzy-plugin", + "ask_selection_plugin_config": { + "min_conf": 0.80 + } +} +``` + +### Priority order + +``` +settings.json > mycroft.conf skills block > built-in default +``` + +Plugins are loaded lazily on first use and cached per plugin name for the lifetime of the skill instance. + +--- + +## Writing a custom YesNoEngine plugin + +```python +# my_yesno/__init__.py +from typing import Optional +from ovos_plugin_manager.templates.agents import YesNoEngine + +class MyYesNoPlugin(YesNoEngine): + def yes_or_no(self, question: str, response: str, + lang: Optional[str] = None) -> Optional[bool]: + r = response.lower() + if "yes" in r or "sure" in r: + return True + if "no" in r or "never" in r: + return False + return None # unclear +``` + +```toml +# pyproject.toml +[project.entry-points."opm.agents.yesno"] +my-yesno-plugin = "my_yesno:MyYesNoPlugin" +``` + +Activate for a skill: + +```json +{ "ask_yesno_plugin": "my-yesno-plugin" } +``` + +--- + +## Writing a custom OptionMatcherEngine plugin + +```python +# my_matcher/__init__.py +from typing import List, Optional +from ovos_plugin_manager.templates.agents import OptionMatcherEngine + +class MyOptionMatcher(OptionMatcherEngine): + def match_option(self, utterance: str, options: List[str], + lang: Optional[str] = None) -> Optional[str]: + # example: embedding similarity via a local model + scores = self._embed_and_score(utterance, options) + best_idx = max(range(len(scores)), key=lambda i: scores[i]) + if scores[best_idx] >= self.config.get("min_conf", 0.6): + return options[best_idx] + return None +``` + +```toml +# pyproject.toml +[project.entry-points."opm.agents.option_matcher"] +my-option-matcher-plugin = "my_matcher:MyOptionMatcher" +``` + +Activate system-wide: + +```json +{ "skills": { "ask_selection_plugin": "my-option-matcher-plugin" } } +``` + +--- + +## Failure behaviour + +| Scenario | ask_yesno result | ask_selection result | +|----------|-----------------|---------------------| +| User says nothing (timeout) | `None` | `None` | +| User response unclassifiable | raw transcript string | `None` | +| Plugin fails to load | falls back to `YesNoSolver` | `None` | +| Plugin raises at runtime | falls back to `YesNoSolver` | `None` | +| No plugin configured | `YesNoSolver` used | default fuzzy plugin used | + +`ask_selection` is intentionally strict: any failure returns `None` rather than guessing. Always handle the `None` case in your skill. + +--- + +## See also + +- [`ovos-solver-yes-no-plugin`](https://github.com/OpenVoiceOS/ovos-solver-YesNo-plugin) — built-in yes/no classifier +- [`ovos-option-matcher-fuzzy-plugin`](https://github.com/OpenVoiceOS/ovos-option-matcher-fuzzy-plugin) — default selection plugin, with [full docs](https://github.com/OpenVoiceOS/ovos-option-matcher-fuzzy-plugin/tree/master/docs) +- `OVOSSkill.get_response` — lower-level method used internally by both +- [OPM agent templates](https://github.com/OpenVoiceOS/ovos-plugin-manager/blob/dev/ovos_plugin_manager/templates/agents.py) — `YesNoEngine`, `OptionMatcherEngine` base classes diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index 79a182a2..a2efbfc8 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -1940,6 +1940,47 @@ def acknowledge(self): 'snd/acknowledge.mp3') self.play_audio(audio_file, instant=True) + def _get_yesno_engine(self) -> Optional[object]: + """Load the configured YesNoEngine plugin, with per-skill override support. + + Checks settings.json first, then mycroft.conf skills.ask_yesno_plugin. + Returns None if no plugin is configured, preserving built-in fallback behavior. + """ + plugin_name = (self.settings.get("ask_yesno_plugin") or + self.config_core.get("skills", {}).get("ask_yesno_plugin")) + if not plugin_name: + return None + cache_key = f"__yesno_engine_{plugin_name}" + if not hasattr(self, cache_key): + try: + from ovos_plugin_manager.agents import load_yesno_plugin + cls = load_yesno_plugin(plugin_name) + setattr(self, cache_key, cls()) + except Exception as e: + LOG.error(f"Failed to load YesNo plugin '{plugin_name}': {e}") + setattr(self, cache_key, None) + return getattr(self, cache_key) + + def _get_selection_engine(self) -> Optional[object]: + """Load the configured OptionMatcherEngine plugin, with per-skill override support. + + Checks settings.json first, then mycroft.conf skills.ask_selection_plugin, + defaulting to ovos-option-matcher-fuzzy-plugin when neither is set. + """ + plugin_name = (self.settings.get("ask_selection_plugin") or + self.config_core.get("skills", {}).get("ask_selection_plugin") or + "ovos-option-matcher-fuzzy-plugin") + cache_key = f"__selection_engine_{plugin_name}" + if not hasattr(self, cache_key): + try: + from ovos_plugin_manager.agents import load_option_matcher_plugin + cls = load_option_matcher_plugin(plugin_name) + setattr(self, cache_key, cls()) + except Exception as e: + LOG.error(f"Failed to load selection plugin '{plugin_name}': {e}") + setattr(self, cache_key, None) + return getattr(self, cache_key) + def ask_yesno(self, prompt: str, data: Optional[dict] = None) -> Optional[str]: """ @@ -1951,7 +1992,11 @@ def ask_yesno(self, prompt: str, 'no', including a response of None. """ resp = self.get_response(dialog=prompt, data=data) - answer = YesNoSolver().match_yes_or_no(resp, lang=self.lang) if resp else resp + engine = self._get_yesno_engine() + if engine is not None: + answer = engine.yes_or_no(question=prompt, response=resp, lang=self.lang) if resp else None + else: + answer = YesNoSolver().match_yes_or_no(resp, lang=self.lang) if resp else resp if answer is True: return "yes" elif answer is False: @@ -2002,17 +2047,13 @@ def ask_selection(self, options: List[str], dialog: str = '', resp = self.get_response(dialog=dialog, data=data, num_retries=num_retries) if resp: - match, score = match_one(resp, options) - if score < min_conf: - if self.voc_match(resp, 'last'): - resp = options[-1] - else: - num = extract_number(resp, ordinals=True, lang=self.lang) + engine = self._get_selection_engine() + if engine is not None: + try: + resp = engine.match_option(utterance=resp, options=options, lang=self.lang) + except Exception as e: + LOG.error(f"OptionMatcher plugin failed: {e}") resp = None - if num and num <= len(options): - resp = options[num - 1] - else: - resp = match return resp def voc_list(self, voc_filename: str, diff --git a/pyproject.toml b/pyproject.toml index 420ffda5..4654a2f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "ovos_bus_client>=1.3.8a1,<2.0.0", "ovos-config>=0.0.12,<3.0.0", "ovos-solver-yes-no-plugin>=0.0.1,<1.0.0", + "ovos-option-matcher-fuzzy-plugin>=0.0.1,<1.0.0", "ovos-number-parser>=0.0.1,<1.0.0", "rapidfuzz", "langcodes", diff --git a/test/unittests/test_skill_interaction.py b/test/unittests/test_skill_interaction.py new file mode 100644 index 00000000..d8eabe01 --- /dev/null +++ b/test/unittests/test_skill_interaction.py @@ -0,0 +1,264 @@ +# Copyright 2024 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. +"""Tests for OVOSSkill.ask_yesno and ask_selection agent-plugin integration.""" +import unittest +from unittest.mock import MagicMock, patch + + +def _make_skill(settings=None, config_skills=None): + """Build a minimal duck-typed object for testing OVOSSkill helper methods.""" + from ovos_workshop.skills.ovos import OVOSSkill + import types + + # Bind the methods under test onto a plain object to avoid OVOSSkill.__init__ + skill = MagicMock(spec=object) + skill.settings = settings or {} + skill.config_core = {"skills": config_skills or {}} + skill.lang = "en-us" + + skill._get_yesno_engine = types.MethodType(OVOSSkill._get_yesno_engine, skill) + skill._get_selection_engine = types.MethodType(OVOSSkill._get_selection_engine, skill) + skill.ask_yesno = types.MethodType(OVOSSkill.ask_yesno, skill) + skill.ask_selection = types.MethodType(OVOSSkill.ask_selection, skill) + return skill + + +class TestGetYesnoEngine(unittest.TestCase): + """Tests for OVOSSkill._get_yesno_engine().""" + + def test_no_plugin_configured_returns_none(self): + skill = _make_skill() + self.assertIsNone(skill._get_yesno_engine()) + + def test_config_core_plugin_loaded(self): + skill = _make_skill(config_skills={"ask_yesno_plugin": "fake-yesno-plugin"}) + mock_cls = MagicMock() + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + with patch("ovos_plugin_manager.agents.load_yesno_plugin", return_value=mock_cls): + engine = skill._get_yesno_engine() + self.assertIs(engine, mock_instance) + + def test_settings_overrides_config_core(self): + skill = _make_skill( + settings={"ask_yesno_plugin": "settings-plugin"}, + config_skills={"ask_yesno_plugin": "config-plugin"}, + ) + mock_cls = MagicMock() + with patch("ovos_plugin_manager.agents.load_yesno_plugin", return_value=mock_cls) as mock_load: + skill._get_yesno_engine() + mock_load.assert_called_once_with("settings-plugin") + + def test_plugin_load_failure_returns_none(self): + skill = _make_skill(config_skills={"ask_yesno_plugin": "bad-plugin"}) + with patch("ovos_plugin_manager.agents.load_yesno_plugin", side_effect=Exception("oops")): + engine = skill._get_yesno_engine() + self.assertIsNone(engine) + + def test_engine_cached_across_calls(self): + skill = _make_skill(config_skills={"ask_yesno_plugin": "fake-plugin"}) + mock_cls = MagicMock() + with patch("ovos_plugin_manager.agents.load_yesno_plugin", return_value=mock_cls) as mock_load: + skill._get_yesno_engine() + skill._get_yesno_engine() + mock_load.assert_called_once() + + +class TestGetSelectionEngine(unittest.TestCase): + """Tests for OVOSSkill._get_selection_engine().""" + + def test_no_config_defaults_to_fuzzy_plugin(self): + """When no plugin is configured, ovos-option-matcher-fuzzy-plugin is used.""" + skill = _make_skill() + mock_cls = MagicMock() + with patch("ovos_plugin_manager.agents.load_option_matcher_plugin", return_value=mock_cls) as mock_load: + skill._get_selection_engine() + mock_load.assert_called_once_with("ovos-option-matcher-fuzzy-plugin") + + def test_config_core_plugin_loaded(self): + skill = _make_skill(config_skills={"ask_selection_plugin": "fake-option-matcher"}) + mock_cls = MagicMock() + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + with patch("ovos_plugin_manager.agents.load_option_matcher_plugin", return_value=mock_cls): + engine = skill._get_selection_engine() + self.assertIs(engine, mock_instance) + + def test_settings_overrides_config_core(self): + skill = _make_skill( + settings={"ask_selection_plugin": "settings-option-matcher"}, + config_skills={"ask_selection_plugin": "config-option-matcher"}, + ) + mock_cls = MagicMock() + with patch("ovos_plugin_manager.agents.load_option_matcher_plugin", return_value=mock_cls) as mock_load: + skill._get_selection_engine() + mock_load.assert_called_once_with("settings-option-matcher") + + def test_plugin_load_failure_returns_none(self): + skill = _make_skill(config_skills={"ask_selection_plugin": "bad-plugin"}) + with patch("ovos_plugin_manager.agents.load_option_matcher_plugin", side_effect=Exception("fail")): + engine = skill._get_selection_engine() + self.assertIsNone(engine) + + +class TestAskYesno(unittest.TestCase): + """Tests for OVOSSkill.ask_yesno().""" + + def _make_skill_with_response(self, response, settings=None, config_skills=None): + skill = _make_skill(settings=settings, config_skills=config_skills) + skill.get_response = MagicMock(return_value=response) + return skill + + def test_no_plugin_uses_yesno_solver_yes(self): + skill = self._make_skill_with_response("yeah sure") + with patch("ovos_workshop.skills.ovos.YesNoSolver") as mock_solver_cls: + mock_solver = MagicMock() + mock_solver.match_yes_or_no.return_value = True + mock_solver_cls.return_value = mock_solver + result = skill.ask_yesno("Do you want tea?") + self.assertEqual(result, "yes") + + def test_no_plugin_uses_yesno_solver_no(self): + skill = self._make_skill_with_response("nope") + with patch("ovos_workshop.skills.ovos.YesNoSolver") as mock_solver_cls: + mock_solver = MagicMock() + mock_solver.match_yes_or_no.return_value = False + mock_solver_cls.return_value = mock_solver + result = skill.ask_yesno("Do you want tea?") + self.assertEqual(result, "no") + + def test_no_plugin_unmatched_returns_raw_resp(self): + skill = self._make_skill_with_response("maybe later") + with patch("ovos_workshop.skills.ovos.YesNoSolver") as mock_solver_cls: + mock_solver = MagicMock() + mock_solver.match_yes_or_no.return_value = None + mock_solver_cls.return_value = mock_solver + result = skill.ask_yesno("Do you want tea?") + self.assertEqual(result, "maybe later") + + def test_no_plugin_none_response_returns_none(self): + skill = self._make_skill_with_response(None) + result = skill.ask_yesno("Do you want tea?") + self.assertIsNone(result) + + def test_plugin_configured_calls_engine(self): + skill = self._make_skill_with_response("yes please", + config_skills={"ask_yesno_plugin": "fake-plugin"}) + mock_engine = MagicMock() + mock_engine.yes_or_no.return_value = True + with patch.object(skill, "_get_yesno_engine", return_value=mock_engine): + result = skill.ask_yesno("Do you want tea?") + mock_engine.yes_or_no.assert_called_once_with( + question="Do you want tea?", response="yes please", lang="en-us" + ) + self.assertEqual(result, "yes") + + def test_plugin_configured_no_response(self): + skill = self._make_skill_with_response(None, + config_skills={"ask_yesno_plugin": "fake-plugin"}) + mock_engine = MagicMock() + with patch.object(skill, "_get_yesno_engine", return_value=mock_engine): + result = skill.ask_yesno("Do you want tea?") + mock_engine.yes_or_no.assert_not_called() + self.assertIsNone(result) + + def test_plugin_returns_false_maps_to_no(self): + skill = self._make_skill_with_response("no way", + config_skills={"ask_yesno_plugin": "fake-plugin"}) + mock_engine = MagicMock() + mock_engine.yes_or_no.return_value = False + with patch.object(skill, "_get_yesno_engine", return_value=mock_engine): + result = skill.ask_yesno("Do you want tea?") + self.assertEqual(result, "no") + + +class TestAskSelection(unittest.TestCase): + """Tests for OVOSSkill.ask_selection().""" + + def _make_selection_skill(self, response, settings=None, config_skills=None): + skill = _make_skill(settings=settings, config_skills=config_skills) + skill.get_response = MagicMock(return_value=response) + skill.speak = MagicMock() + return skill + + def test_plugin_called_with_response(self): + """Default fuzzy plugin (or any configured plugin) receives the user response.""" + skill = self._make_selection_skill("beta") + options = ["alpha", "beta", "gamma"] + mock_engine = MagicMock() + mock_engine.match_option.return_value = "beta" + with patch.object(skill, "_get_selection_engine", return_value=mock_engine): + result = skill.ask_selection(options, numeric=True) + mock_engine.match_option.assert_called_once_with( + utterance="beta", options=options, lang="en-us" + ) + self.assertEqual(result, "beta") + + def test_plugin_runtime_failure_returns_none(self): + """If the engine raises, ask_selection returns None rather than crashing.""" + skill = self._make_selection_skill("alpha") + options = ["alpha", "beta", "gamma"] + mock_engine = MagicMock() + mock_engine.match_option.side_effect = RuntimeError("model error") + with patch.object(skill, "_get_selection_engine", return_value=mock_engine): + result = skill.ask_selection(options, numeric=True) + self.assertIsNone(result) + + def test_no_engine_no_response_returns_none(self): + """If engine load fails and user gives no response, return None.""" + skill = self._make_selection_skill(None) + options = ["alpha", "beta"] + with patch.object(skill, "_get_selection_engine", return_value=None): + result = skill.ask_selection(options, numeric=True) + self.assertIsNone(result) + + def test_no_response_returns_none(self): + skill = self._make_selection_skill(None) + options = ["alpha", "beta"] + result = skill.ask_selection(options, numeric=True) + self.assertIsNone(result) + + def test_single_option_returns_immediately(self): + skill = self._make_selection_skill(None) + result = skill.ask_selection(["only"], numeric=True) + self.assertEqual(result, "only") + skill.speak.assert_not_called() + + def test_empty_options_returns_none(self): + skill = self._make_selection_skill(None) + result = skill.ask_selection([]) + self.assertIsNone(result) + + def test_invalid_options_raises(self): + skill = self._make_selection_skill(None) + with self.assertRaises(ValueError): + skill.ask_selection("not a list") + + def test_settings_plugin_overrides_default(self): + """settings.json ask_selection_plugin takes precedence over the fuzzy default.""" + skill = self._make_selection_skill( + "first", settings={"ask_selection_plugin": "my-custom-option-matcher"} + ) + options = ["alpha", "beta", "gamma"] + mock_cls = MagicMock() + mock_instance = MagicMock() + mock_instance.match_option.return_value = "alpha" + mock_cls.return_value = mock_instance + with patch("ovos_plugin_manager.agents.load_option_matcher_plugin", return_value=mock_cls) as mock_load: + skill.ask_selection(options, numeric=True) + mock_load.assert_called_once_with("my-custom-option-matcher") + + +if __name__ == "__main__": + unittest.main()