diff --git a/README.md b/README.md index b6918560..3b82389f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-20) — Voice-Command Router](#whats-new-2026-06-20--voice-command-router) - [What's new (2026-06-20) — Locale-Aware Number, Currency & Date Parsing](#whats-new-2026-06-20--locale-aware-number-currency--date-parsing) - [What's new (2026-06-20) — Perceptual-Hash Image Dedupe](#whats-new-2026-06-20--perceptual-hash-image-dedupe) - [What's new (2026-06-20) — S3-Compatible Artifact Store](#whats-new-2026-06-20--s3-compatible-artifact-store) @@ -96,6 +97,12 @@ --- +## What's new (2026-06-20) — Voice-Command Router + +Trigger flows hands-free from recognized speech. Full reference: [`docs/source/Eng/doc/new_features/v44_features_doc.rst`](docs/source/Eng/doc/new_features/v44_features_doc.rst). + +- **`VoiceRouter`** (`AC_voice_register` / `AC_voice_dispatch` / `AC_voice_list` / `AC_voice_clear`, `ac_*`): map spoken trigger phrases to `AC_*` action lists; feed it recognized text and it runs the closest registered command (phrase matching reuses the fuzzy matcher, so "save the file" fires "save file"). **Speech-to-text is out of scope and injectable** — the router takes text and a `recognizer`/`runner` callable, so routing is fully unit-tested without audio or any speech dependency (a real Vosk/mic recogniser plugs into `listen_once`). + ## What's new (2026-06-20) — Locale-Aware Number, Currency & Date Parsing Parse localized numbers/currency/dates. Full reference: [`docs/source/Eng/doc/new_features/v43_features_doc.rst`](docs/source/Eng/doc/new_features/v43_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index ef8bec1e..7696d8af 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-20) — 语音指令路由器](#本次更新-2026-06-20--语音指令路由器) - [本次更新 (2026-06-20) — 区域设置感知的数字、货币与日期解析](#本次更新-2026-06-20--区域设置感知的数字货币与日期解析) - [本次更新 (2026-06-20) — 感知哈希图像去重](#本次更新-2026-06-20--感知哈希图像去重) - [本次更新 (2026-06-20) — S3 兼容成品存储](#本次更新-2026-06-20--s3-兼容成品存储) @@ -95,6 +96,12 @@ --- +## 本次更新 (2026-06-20) — 语音指令路由器 + +以已识别语音免手动触发流程。完整参考:[`docs/source/Zh/doc/new_features/v44_features_doc.rst`](../docs/source/Zh/doc/new_features/v44_features_doc.rst)。 + +- **`VoiceRouter`**(`AC_voice_register` / `AC_voice_dispatch` / `AC_voice_list` / `AC_voice_clear`、`ac_*`):将语音触发短语映射到 `AC_*` 动作列表;喂入已识别文本即执行最接近的已注册指令(短语匹配重用模糊匹配器,因此「save the file」会触发「save file」)。**语音转文本不在范围内且可注入** —— 路由器接受文本与 `recognizer`/`runner` 可调用对象,因此路由在无音频、无任何语音依赖下完整单元测试(真实 Vosk/麦克风识别器接入 `listen_once`)。 + ## 本次更新 (2026-06-20) — 区域设置感知的数字、货币与日期解析 解析本地化的数字/货币/日期。完整参考:[`docs/source/Zh/doc/new_features/v43_features_doc.rst`](../docs/source/Zh/doc/new_features/v43_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c24c907b..7eadc21f 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-20) — 語音指令路由器](#本次更新-2026-06-20--語音指令路由器) - [本次更新 (2026-06-20) — 區域設定感知的數字、貨幣與日期解析](#本次更新-2026-06-20--區域設定感知的數字貨幣與日期解析) - [本次更新 (2026-06-20) — 感知雜湊影像去重](#本次更新-2026-06-20--感知雜湊影像去重) - [本次更新 (2026-06-20) — S3 相容成品儲存](#本次更新-2026-06-20--s3-相容成品儲存) @@ -95,6 +96,12 @@ --- +## 本次更新 (2026-06-20) — 語音指令路由器 + +以已辨識語音免手動觸發流程。完整參考:[`docs/source/Zh/doc/new_features/v44_features_doc.rst`](../docs/source/Zh/doc/new_features/v44_features_doc.rst)。 + +- **`VoiceRouter`**(`AC_voice_register` / `AC_voice_dispatch` / `AC_voice_list` / `AC_voice_clear`、`ac_*`):將語音觸發片語對應到 `AC_*` 動作清單;餵入已辨識文字即執行最接近的已註冊指令(片語比對重用模糊比對器,因此「save the file」會觸發「save file」)。**語音轉文字不在範圍內且可注入** —— 路由器接受文字與 `recognizer`/`runner` 可呼叫物件,因此路由在無音訊、無任何語音相依下完整單元測試(真實 Vosk/麥克風辨識器接入 `listen_once`)。 + ## 本次更新 (2026-06-20) — 區域設定感知的數字、貨幣與日期解析 解析在地化的數字/貨幣/日期。完整參考:[`docs/source/Zh/doc/new_features/v43_features_doc.rst`](../docs/source/Zh/doc/new_features/v43_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v44_features_doc.rst b/docs/source/Eng/doc/new_features/v44_features_doc.rst new file mode 100644 index 00000000..a7b7092f --- /dev/null +++ b/docs/source/Eng/doc/new_features/v44_features_doc.rst @@ -0,0 +1,57 @@ +Voice-Command Router +==================== + +``VoiceRouter`` maps spoken trigger *phrases* to ``AC_*`` action lists: feed it +the text of a recognized utterance and it runs the closest registered command — +hands-free triggering of automation flows. Phrase matching reuses the project's +fuzzy matcher, so "save the file" still fires a ``"save file"`` command despite +recogniser noise. + +Speech-to-text is intentionally **out of scope and injectable**: the router takes +already-recognised *text*. A real microphone/Vosk recogniser is supplied as a +``recognizer`` callable to :meth:`VoiceRouter.listen_once`, which keeps the +routing logic fully unit-testable without audio or any speech dependency. Imports +no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import VoiceRouter + + router = VoiceRouter(threshold=0.7) + router.register("save file", [["AC_hotkey", {"keys": ["ctrl", "s"]}]]) + router.register("close window", [["AC_close_window", {}]]) + + router.dispatch("save the file") # fuzzy-matches -> runs the save actions + + # with a real recogniser (any callable returning text): + def vosk_listen() -> str: + ... # capture audio, return transcript + router.listen_once(vosk_listen) + +``dispatch`` (and ``listen_once``) accept a ``runner`` to execute the action list +— it defaults to the executor; inject a fake to test routing without running real +automation. ``match`` returns the best ``VoiceCommand`` at or above ``threshold`` +(or ``None``); ``register`` replaces an existing phrase; ``phrases`` / ``clear`` +inspect and reset. + +Executor commands +----------------- + +A module-level default router backs the executor/MCP surfaces: + +================================ =================================================== +Command Effect +================================ =================================================== +``AC_voice_register`` Map a ``phrase`` to an ``actions`` list. +``AC_voice_dispatch`` Run the command best matching recognized ``text``. +``AC_voice_list`` List registered phrases. +``AC_voice_clear`` Remove all registered commands. +================================ =================================================== + +``actions`` accepts a list or a JSON-string list (so the visual builder works). +The same operations are exposed as MCP tools (``ac_voice_register`` / +``ac_voice_dispatch`` / ``ac_voice_list`` / ``ac_voice_clear``) and as Script +Builder commands under **Agent**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 76c1e7a5..df05947f 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -66,6 +66,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v41_features_doc doc/new_features/v42_features_doc doc/new_features/v43_features_doc + doc/new_features/v44_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v44_features_doc.rst b/docs/source/Zh/doc/new_features/v44_features_doc.rst new file mode 100644 index 00000000..fc3a95da --- /dev/null +++ b/docs/source/Zh/doc/new_features/v44_features_doc.rst @@ -0,0 +1,51 @@ +語音指令路由器 +============== + +``VoiceRouter`` 將語音觸發*片語*對應到 ``AC_*`` 動作清單:餵入一段已辨識語句的文字,它 +就會執行最接近的已註冊指令 —— 免手動觸發自動化流程。片語比對重用本專案的模糊比對器,因 +此即使辨識有雜訊,「save the file」仍會觸發 ``"save file"`` 指令。 + +語音轉文字刻意**不在範圍內且可注入**:路由器接受的是已辨識的*文字*。真實的麥克風/Vosk +辨識器以 ``recognizer`` 可呼叫物件傳入 :meth:`VoiceRouter.listen_once`,如此路由邏輯可 +在無音訊、無任何語音相依的情況下完整單元測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import VoiceRouter + + router = VoiceRouter(threshold=0.7) + router.register("save file", [["AC_hotkey", {"keys": ["ctrl", "s"]}]]) + router.register("close window", [["AC_close_window", {}]]) + + router.dispatch("save the file") # 模糊比對 -> 執行儲存動作 + + # 搭配真實辨識器(任何回傳文字的可呼叫物件): + def vosk_listen() -> str: + ... # 擷取音訊、回傳逐字稿 + router.listen_once(vosk_listen) + +``dispatch``(與 ``listen_once``)接受 ``runner`` 來執行動作清單 —— 預設為執行器;注入假 +物件即可在不執行真實自動化下測試路由。``match`` 回傳達到或高於 ``threshold`` 的最佳 +``VoiceCommand``(否則 ``None``);``register`` 會取代既有片語;``phrases`` / ``clear`` +則用於檢視與重置。 + +執行器指令 +---------- + +模組層級的預設路由器支撐 executor/MCP 介面: + +================================ =================================================== +指令 效果 +================================ =================================================== +``AC_voice_register`` 將 ``phrase`` 對應到 ``actions`` 清單。 +``AC_voice_dispatch`` 執行最符合已辨識 ``text`` 的指令。 +``AC_voice_list`` 列出已註冊片語。 +``AC_voice_clear`` 移除所有已註冊指令。 +================================ =================================================== + +``actions`` 接受清單或 JSON 字串清單(因此視覺化建構器可用)。相同操作亦提供為 MCP 工具 +(``ac_voice_register`` / ``ac_voice_dispatch`` / ``ac_voice_list`` / +``ac_voice_clear``),以及 Script Builder 中 **Agent** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index c3844372..a4d71f19 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -66,6 +66,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v41_features_doc doc/new_features/v42_features_doc doc/new_features/v43_features_doc + doc/new_features/v44_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 592aef6a..3dec62a8 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -247,6 +247,10 @@ from je_auto_control.utils.locale_parse import ( format_currency, format_date, format_decimal, parse_decimal, parse_number, ) +# Voice-command router (injectable speech-to-text) +from je_auto_control.utils.voice import ( + VoiceCommand, VoiceRouter, default_voice_router, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -700,6 +704,7 @@ def start_autocontrol_gui(*args, **kwargs): "images_similar", "format_currency", "format_date", "format_decimal", "parse_decimal", "parse_number", + "VoiceCommand", "VoiceRouter", "default_voice_router", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index bdf01b67..96a8a478 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -994,6 +994,31 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Format an ISO date string for a locale.", )) + specs.append(CommandSpec( + "AC_voice_register", "Agent", "Voice: Register Command", + fields=( + FieldSpec("phrase", FieldType.STRING, placeholder="save file"), + FieldSpec("actions", FieldType.STRING, + placeholder='[["AC_hotkey", {"keys": ["ctrl", "s"]}]]'), + ), + description="Map a spoken phrase to an action list (JSON).", + )) + specs.append(CommandSpec( + "AC_voice_dispatch", "Agent", "Voice: Dispatch Text", + fields=(FieldSpec("text", FieldType.STRING, + placeholder="save the file"),), + description="Run the command best matching recognized text.", + )) + specs.append(CommandSpec( + "AC_voice_list", "Agent", "Voice: List Commands", + fields=(), + description="List registered voice-command phrases.", + )) + specs.append(CommandSpec( + "AC_voice_clear", "Agent", "Voice: Clear Commands", + fields=(), + description="Remove all registered voice commands.", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index c76e9d89..442f8f2b 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3168,6 +3168,33 @@ def _format_date(value: str, locale: str = "en_US", return {"text": format_date(value, locale, fmt)} +def _voice_register(phrase: str, actions: Any) -> Dict[str, Any]: + """Adapter: register a voice command on the default router.""" + from je_auto_control.utils.voice import default_voice_router + default_voice_router.register(phrase, _coerce_list(actions)) + return {"phrases": default_voice_router.phrases()} + + +def _voice_dispatch(text: str) -> Dict[str, Any]: + """Adapter: run the command best matching recognized ``text``.""" + from je_auto_control.utils.voice import default_voice_router + outcome = default_voice_router.dispatch(text) + return {"matched": outcome["matched"], "phrase": outcome["phrase"]} + + +def _voice_list() -> Dict[str, Any]: + """Adapter: list registered voice-command phrases.""" + from je_auto_control.utils.voice import default_voice_router + return {"phrases": default_voice_router.phrases()} + + +def _voice_clear() -> Dict[str, Any]: + """Adapter: clear all registered voice commands.""" + from je_auto_control.utils.voice import default_voice_router + default_voice_router.clear() + return {"cleared": True} + + class Executor: """ Executor @@ -3434,6 +3461,10 @@ def __init__(self): "AC_format_decimal": _format_decimal, "AC_format_currency": _format_currency, "AC_format_date": _format_date, + "AC_voice_register": _voice_register, + "AC_voice_dispatch": _voice_dispatch, + "AC_voice_list": _voice_list, + "AC_voice_clear": _voice_clear, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 40b37c6f..b91cdf87 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3008,6 +3008,47 @@ def locale_tools() -> List[MCPTool]: ] +def voice_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_voice_register", + description=("Register a voice command: a trigger 'phrase' and an " + "'actions' list (AC_* steps) to run when recognized " + "speech best-matches it. Returns {phrases}."), + input_schema=schema( + {"phrase": {"type": "string"}, + "actions": {"type": "array", "items": {"type": "object"}}}, + ["phrase", "actions"]), + handler=h.voice_register, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_voice_dispatch", + description=("Run the command whose phrase best matches recognized " + "'text' (fuzzy). Returns {matched, phrase}."), + input_schema=schema({"text": {"type": "string"}}, ["text"]), + handler=h.voice_dispatch, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_voice_list", + description="List registered voice-command phrases. Returns " + "{phrases}.", + input_schema=schema({}), + handler=h.voice_list, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_voice_clear", + description="Remove all registered voice commands. Returns " + "{cleared}.", + input_schema=schema({}), + handler=h.voice_clear, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -4069,7 +4110,7 @@ def media_assert_tools() -> List[MCPTool]: credential_lease_tools, egress_tools, approval_testing_tools, trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, - locale_tools, + locale_tools, voice_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 51b502d1..8685dcb9 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1448,6 +1448,29 @@ def format_date(value, locale="en_US", fmt="medium"): return {"text": _fmt(value, locale, fmt)} +def voice_register(phrase, actions): + from je_auto_control.utils.voice import default_voice_router + default_voice_router.register(phrase, actions) + return {"phrases": default_voice_router.phrases()} + + +def voice_dispatch(text): + from je_auto_control.utils.voice import default_voice_router + outcome = default_voice_router.dispatch(text) + return {"matched": outcome["matched"], "phrase": outcome["phrase"]} + + +def voice_list(): + from je_auto_control.utils.voice import default_voice_router + return {"phrases": default_voice_router.phrases()} + + +def voice_clear(): + from je_auto_control.utils.voice import default_voice_router + default_voice_router.clear() + return {"cleared": True} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/je_auto_control/utils/voice/__init__.py b/je_auto_control/utils/voice/__init__.py new file mode 100644 index 00000000..4f169a3e --- /dev/null +++ b/je_auto_control/utils/voice/__init__.py @@ -0,0 +1,6 @@ +"""Voice-command router: map recognized phrases to AC_* action lists.""" +from je_auto_control.utils.voice.voice_router import ( + VoiceCommand, VoiceRouter, default_voice_router, +) + +__all__ = ["VoiceCommand", "VoiceRouter", "default_voice_router"] diff --git a/je_auto_control/utils/voice/voice_router.py b/je_auto_control/utils/voice/voice_router.py new file mode 100644 index 00000000..80e4c6e7 --- /dev/null +++ b/je_auto_control/utils/voice/voice_router.py @@ -0,0 +1,81 @@ +"""Route recognized speech to automation actions, hands-free. + +A ``VoiceRouter`` maps trigger *phrases* to ``AC_*`` action lists: feed it the +text of a recognized utterance and it runs the closest registered command. Phrase +matching reuses the project's fuzzy matcher, so "save the file" still fires a +``"save file"`` command despite recogniser noise. + +Speech-to-text is intentionally **out of scope and injectable**: the router takes +already-recognised *text*. A real microphone/Vosk recogniser is supplied as a +``recognizer`` callable to :meth:`VoiceRouter.listen_once`, which keeps the +routing logic fully unit-testable without audio or any speech dependency. Imports +no ``PySide6``. +""" +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + + +@dataclass +class VoiceCommand: + """A trigger phrase and the action list it runs.""" + + phrase: str + actions: List[Any] = field(default_factory=list) + + +class VoiceRouter: + """Match recognized text to a registered command and dispatch its actions.""" + + def __init__(self, *, threshold: float = 0.7) -> None: + """``threshold`` is the minimum fuzzy score (0..1) for a phrase match.""" + self._commands: List[VoiceCommand] = [] + self._threshold = threshold + + def register(self, phrase: str, actions: List[Any]) -> None: + """Register (or replace) the command for ``phrase``.""" + self._commands = [c for c in self._commands if c.phrase != phrase] + self._commands.append(VoiceCommand(phrase, list(actions))) + + def phrases(self) -> List[str]: + """Return the registered trigger phrases.""" + return [command.phrase for command in self._commands] + + def clear(self) -> None: + """Remove all registered commands.""" + self._commands.clear() + + def match(self, text: str) -> Optional[VoiceCommand]: + """Return the command whose phrase best matches ``text`` (or ``None``).""" + from je_auto_control.utils.fuzzy import fuzzy_best_match + best = fuzzy_best_match(text, self.phrases(), + score_cutoff=self._threshold) + return self._commands[best[2]] if best else None + + def dispatch(self, text: str, + runner: Optional[Callable[[List[Any]], Any]] = None + ) -> Dict[str, Any]: + """Match ``text`` and run the command's actions; report what fired. + + ``runner`` runs an action list (defaults to the executor); inject a fake + to test routing without executing real automation. + """ + command = self.match(text) + if command is None: + return {"matched": False, "phrase": None, "result": None} + run = runner or _default_runner + return {"matched": True, "phrase": command.phrase, + "result": run(command.actions)} + + def listen_once(self, recognizer: Callable[[], str], + runner: Optional[Callable[[List[Any]], Any]] = None + ) -> Dict[str, Any]: + """Recognise one utterance via ``recognizer()`` then dispatch it.""" + return self.dispatch(recognizer(), runner) + + +def _default_runner(actions: List[Any]) -> Any: + from je_auto_control.utils.executor.action_executor import execute_action + return execute_action(actions) + + +default_voice_router = VoiceRouter() diff --git a/test/unit_test/headless/test_voice_router_batch.py b/test/unit_test/headless/test_voice_router_batch.py new file mode 100644 index 00000000..6343dad9 --- /dev/null +++ b/test/unit_test/headless/test_voice_router_batch.py @@ -0,0 +1,108 @@ +"""Headless tests for the voice-command router. Speech-to-text is out of scope: +the router takes recognized text and the action runner is injected, so routing +is fully tested without audio or any speech dependency. Pure stdlib, no Qt.""" +import je_auto_control as ac +from je_auto_control.utils.voice import VoiceRouter, default_voice_router + + +def _recording_runner(): + ran = [] + return ran, (lambda actions: ran.append(actions) or {"ok": True}) + + +def test_register_and_phrases(): + router = VoiceRouter() + router.register("save file", [["AC_noop", {}]]) + router.register("close window", [["AC_noop", {}]]) + assert router.phrases() == ["save file", "close window"] + + +def test_register_replaces_same_phrase(): + router = VoiceRouter() + router.register("go", [["A", {}]]) + router.register("go", [["B", {}]]) + assert len(router.phrases()) == 1 + assert router.match("go").actions == [["B", {}]] + + +def test_fuzzy_match_tolerates_noise(): + router = VoiceRouter(threshold=0.6) + router.register("save file", [["AC_save", {}]]) + matched = router.match("save the file") + assert matched is not None and matched.phrase == "save file" + + +def test_no_match_below_threshold(): + router = VoiceRouter(threshold=0.9) + router.register("save file", [["AC_save", {}]]) + assert router.match("totally different") is None + + +def test_dispatch_runs_matched_actions(): + router = VoiceRouter(threshold=0.6) + router.register("save file", [["AC_save", {}]]) + ran, runner = _recording_runner() + outcome = router.dispatch("save the file", runner=runner) + assert outcome["matched"] is True + assert outcome["phrase"] == "save file" + assert ran == [[["AC_save", {}]]] + + +def test_dispatch_no_match_runs_nothing(): + router = VoiceRouter(threshold=0.9) + router.register("save file", [["AC_save", {}]]) + ran, runner = _recording_runner() + outcome = router.dispatch("xyzzy", runner=runner) + assert outcome["matched"] is False + assert ran == [] + + +def test_listen_once_uses_recognizer(): + router = VoiceRouter(threshold=0.6) + router.register("open menu", [["AC_open", {}]]) + ran, runner = _recording_runner() + outcome = router.listen_once(lambda: "open the menu", runner=runner) + assert outcome["phrase"] == "open menu" + assert len(ran) == 1 + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + default_voice_router.clear() + try: + ac.execute_action([[ + "AC_voice_register", + {"phrase": "list windows", "actions": [["AC_list_windows", {}]]}, + ]]) + rec = ac.execute_action([["AC_voice_list", {}]]) + phrases = next(v for v in rec.values() if isinstance(v, dict))["phrases"] + assert "list windows" in phrases + rec2 = ac.execute_action([ + ["AC_voice_dispatch", {"text": "list the windows"}], + ]) + outcome = next(v for v in rec2.values() if isinstance(v, dict)) + assert outcome["matched"] is True + finally: + default_voice_router.clear() # leave global state clean + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_voice_register", "AC_voice_dispatch", "AC_voice_list", + "AC_voice_clear"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_voice_register", "ac_voice_dispatch", "ac_voice_list", + "ac_voice_clear"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_voice_register", "AC_voice_dispatch", "AC_voice_list", + "AC_voice_clear"} <= cmds + + +def test_facade_exports(): + for attr in ("VoiceRouter", "VoiceCommand", "default_voice_router"): + assert hasattr(ac, attr) + assert attr in ac.__all__