Этап 8: настраиваемые команды

- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка;
  перечитывается автоматически, ошибки не ломают остальные команды
- Действия run / open / http / keys; вывод и ответы возвращаются модели
- Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами
- Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам
- Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat,
  переменные окружения раскрываются только в шаблоне
- Вкладка «Команды» в настройках
- Тесты разбора, действий, фраз и полных сценариев через Assistant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 04:38:57 +03:00
co-authored by Claude Opus 5
parent 84d8aaa848
commit 04029f3750
18 changed files with 1770 additions and 24 deletions
+206 -3
View File
@@ -6,9 +6,11 @@ from pathlib import Path
import numpy as np
import pytest
from PySide6.QtCore import QCoreApplication
from PySide6.QtCore import QCoreApplication, QObject, Signal
from PySide6.QtWidgets import QApplication
from agr_assistent.commands import actions
from agr_assistent.commands.catalog import CommandCatalog
from agr_assistent.config import LLMConfig, ProviderConfig
from agr_assistent.core.assistant import Assistant
from agr_assistent.core.memory import MemoryStore
@@ -52,7 +54,14 @@ def memory(tmp_path: Path) -> MemoryStore:
store.close()
def _assistant(server: FakeLLMServer, memory: MemoryStore, *, auto_save: bool = True) -> Assistant:
def _assistant(
server: FakeLLMServer,
memory: MemoryStore,
*,
auto_save: bool = True,
commands: CommandCatalog | None = None,
voice: object = None,
) -> Assistant:
config = LLMConfig(
provider="fake",
providers={"fake": ProviderConfig("fake", server.base_url, "key", "fake-model")},
@@ -62,7 +71,14 @@ def _assistant(server: FakeLLMServer, memory: MemoryStore, *, auto_save: bool =
timeout_seconds=10,
)
speaker = Speaker(_SilentEngine(), _SilentPlayer(), enabled=False)
return Assistant(config, speaker, None, memory=memory, memory_auto_save=auto_save)
return Assistant(
config,
speaker,
voice, # type: ignore[arg-type]
memory=memory,
memory_auto_save=auto_save,
commands=commands,
)
def test_remember_request_runs_tool_and_answers(
@@ -139,3 +155,190 @@ def test_auto_save_can_be_disabled(
_wait_until(lambda: bool(finished))
assert "requested_by_user=false" not in fake_llm.requests[0]["messages"][0]["content"]
# --- команды
_COMMANDS_YAML = """
commands:
- name: open_downloads
description: Открыть папку «Загрузки»
phrases: [открой загрузки]
reply: Открываю.
action: {type: open, target: "C:/Downloads"}
- name: web_search
description: Найти в интернете
parameters:
query: {type: string}
action: {type: open, target: "https://example.com/?q={query}"}
- name: shutdown_computer
description: Выключить компьютер
phrases: [выключи компьютер]
confirm: true
action: {type: open, target: "shutdown://now"}
"""
class _FakeVoice(QObject):
listening_started = Signal()
recognizing_started = Signal()
finished = Signal()
recognized = Signal(str)
error_occurred = Signal(str)
def __init__(self) -> None:
super().__init__()
self.started = 0
self.is_listening = False
self.is_recognizing = False
self.is_active = False
def start(self) -> None:
self.started += 1
def stop(self) -> None:
pass
def cancel(self) -> None:
pass
@pytest.fixture
def opened(monkeypatch: pytest.MonkeyPatch) -> list[str]:
targets: list[str] = []
monkeypatch.setattr(actions, "open_target", targets.append)
return targets
def _commands_assistant(
server: FakeLLMServer, memory: MemoryStore, tmp_path: Path, voice: _FakeVoice | None = None
) -> tuple[Assistant, CommandCatalog]:
path = tmp_path / "commands.yaml"
path.write_text(_COMMANDS_YAML, encoding="utf-8")
catalog = CommandCatalog(path)
return _assistant(server, memory, commands=catalog, voice=voice), catalog
def _collect(assistant: Assistant) -> dict[str, list]:
events: dict[str, list] = {"tools": [], "replies": [], "errors": []}
assistant.tool_executed.connect(lambda display, ok: events["tools"].append((display, ok)))
assistant.reply_finished.connect(events["replies"].append)
assistant.error_occurred.connect(events["errors"].append)
return events
def test_exact_phrase_runs_without_model(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Открой загрузки!")
_wait_until(lambda: bool(events["replies"]))
assert opened == ["C:/Downloads"]
assert events["replies"] == ["Открываю."]
assert events["tools"] == [("Открыл: Открыть папку «Загрузки»", True)]
assert fake_llm.requests == []
def test_model_calls_command_tool(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
fake_llm.replies += [
Reply(tool_calls=[("web_search", '{"query": "погода в Казани"}')]),
Reply(text="Открыл поиск."),
]
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Поищи погоду в Казани")
_wait_until(lambda: bool(events["replies"]))
assert opened == ["https://example.com/?q=%D0%BF%D0%BE%D0%B3%D0%BE%D0%B4%D0%B0%20%D0%B2%20%D0%9A%D0%B0%D0%B7%D0%B0%D0%BD%D0%B8"]
tool_names = {tool["function"]["name"] for tool in fake_llm.requests[0]["tools"]}
assert {"web_search", "open_downloads", "remember"} <= tool_names
assert "команды для управления компьютером" in fake_llm.requests[0]["messages"][0]["content"]
assert events["replies"] == ["Открыл поиск."]
def test_model_command_with_confirmation_waits_for_yes(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
fake_llm.replies += [
Reply(tool_calls=[("shutdown_computer", "{}")]),
Reply(text="Выключить компьютер?"),
]
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Выключи комп, пожалуйста")
_wait_until(lambda: len(events["replies"]) == 1)
assert opened == []
assert events["tools"] == [("Ждёт подтверждения: Выключить компьютер", True)]
assert "НЕ выполнена" in fake_llm.requests[1]["messages"][-1]["content"]
assistant.send("Да")
_wait_until(lambda: len(events["replies"]) == 2)
assert opened == ["shutdown://now"]
assert events["replies"][1] == "Готово."
assert len(fake_llm.requests) == 2 # «да» обработано без модели
def test_phrase_confirmation_can_be_declined_or_dropped(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
fake_llm.replies.append(Reply(text="Не понял."))
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Выключи компьютер")
_wait_until(lambda: len(events["replies"]) == 1)
assert events["replies"][0] == "Выполнить: Выключить компьютер? Скажите «да» или «нет»."
assistant.send("нет")
_wait_until(lambda: len(events["replies"]) == 2)
assert events["replies"][1] == "Хорошо, не выполняю."
# После отказа «да» уже ничего не подтверждает и уходит модели
assistant.send("да")
_wait_until(lambda: len(events["replies"]) == 3)
assert opened == []
assert len(fake_llm.requests) == 1
def test_voice_confirmation_starts_listening(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
voice = _FakeVoice()
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path, voice)
events = _collect(assistant)
assistant.send("выключи компьютер", by_voice=True)
_wait_until(lambda: voice.started == 1)
assistant.send("открой загрузки", by_voice=True)
_wait_until(lambda: len(events["replies"]) == 2)
for _ in range(20):
QCoreApplication.processEvents()
time.sleep(0.005)
assert voice.started == 1 # без вопроса микрофон сам не включается
def test_commands_file_changes_are_picked_up(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
assistant, catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
catalog.path.write_text(
_COMMANDS_YAML.replace("[открой загрузки]", "[покажи загрузки]")
+ " - name: broken\n description: x\n action: {type: nope}\n",
encoding="utf-8",
)
catalog.reload()
assistant.send("покажи загрузки")
_wait_until(lambda: bool(events["replies"]))
assert opened == ["C:/Downloads"]
assert any("broken" in error for error in events["errors"])
+218
View File
@@ -0,0 +1,218 @@
import json
import sys
import threading
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
from agr_assistent.commands import actions
from agr_assistent.commands.model import parse_command
def _command(action: dict[str, Any], parameters: dict[str, Any] | None = None): # type: ignore[no-untyped-def]
return parse_command(
{"name": "test", "description": "Тест", "parameters": parameters, "action": action}
)
def test_substitution_encodes_urls_and_keeps_json_types() -> None:
values = {"query": "кофе & чай", "level": 30, "on": True}
assert actions.substitute("q={query}", values, url_encode=True) == (
"q=%D0%BA%D0%BE%D1%84%D0%B5%20%26%20%D1%87%D0%B0%D0%B9"
)
assert actions.substitute("{missing}-{level}", values) == "-30"
assert actions.substitute_json(
{"level": "{level}", "text": "уровень {level}", "flags": ["{on}"]}, values
) == {"level": 30, "text": "уровень 30", "flags": [True]}
def test_parameter_values_cannot_reach_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_SECRET_TOKEN", "секрет")
values = {"value": "${TEST_SECRET_TOKEN}"}
assert actions.substitute_json("Bearer ${TEST_SECRET_TOKEN} {value}", values) == (
"Bearer секрет ${TEST_SECRET_TOKEN}"
)
def test_run_waits_and_returns_output() -> None:
command = _command(
{
"type": "run",
"program": sys.executable,
"args": ["-c", "import sys; print('привет', sys.argv[1])", "{name}"],
"wait": True,
},
{"name": {"type": "string"}},
)
outcome = actions.execute(command, {"name": "мир"})
assert outcome.ok
assert "привет мир" in outcome.content
def test_run_reports_exit_code_and_timeout() -> None:
failing = _command(
{"type": "run", "program": sys.executable, "args": ["-c", "raise SystemExit(3)"], "wait": True}
)
slow = _command(
{
"type": "run",
"program": sys.executable,
"args": ["-c", "import time; time.sleep(5)"],
"wait": True,
"timeout_seconds": 0.5,
}
)
failed = actions.execute(failing, {})
assert not failed.ok and "Код завершения 3" in failed.content
timed_out = actions.execute(slow, {})
assert not timed_out.ok and "не завершилась" in timed_out.content
assert not actions.execute(_command({"type": "run", "program": "no-such-program-xyz"}), {}).ok
@pytest.mark.skipif(sys.platform != "win32", reason="bat-файлы есть только в Windows")
def test_batch_file_rejects_cmd_metacharacters(tmp_path: Path) -> None:
marker = tmp_path / "ran.txt"
script = tmp_path / "echo.bat"
script.write_text(f'@echo %1 > "{marker}"\n', encoding="utf-8")
command = _command(
{"type": "run", "program": str(script), "args": ["{text}"], "wait": True},
{"text": {"type": "string"}},
)
refused = actions.execute(command, {"text": "hi & calc"})
assert not refused.ok and "недопустимые символы" in refused.content
assert not marker.exists()
assert actions.execute(command, {"text": "hello"}).ok
assert marker.exists()
@pytest.mark.skipif(sys.platform != "win32", reason="cmd и powershell есть только в Windows")
def test_shell_interpreters_reject_injection_but_run_plain_values() -> None:
cmd = _command(
{"type": "run", "program": "cmd", "args": ["/c", "echo", "{text}"], "wait": True},
{"text": {"type": "string"}},
)
powershell = _command(
{
"type": "run",
"program": "powershell",
"args": ["-NoProfile", "-Command", "Write-Output '{text}'"],
"wait": True,
},
{"text": {"type": "string"}},
)
assert not actions.execute(cmd, {"text": "hi & calc"}).ok
assert not actions.execute(powershell, {"text": "x'; Remove-Item C:\\temp"}).ok
echoed = actions.execute(cmd, {"text": "Привет"})
assert echoed.ok and "Привет" in echoed.content # вывод cmd в OEM-кодировке декодируется
written = actions.execute(powershell, {"text": "Мир"})
assert written.ok and "Мир" in written.content
def test_open_uses_encoded_url(monkeypatch: pytest.MonkeyPatch) -> None:
opened: list[str] = []
monkeypatch.setattr(actions, "open_target", opened.append)
command = _command(
{"type": "open", "target": "https://example.com/search?q={query}"},
{"query": {"type": "string"}},
)
assert actions.execute(command, {"query": "a b"}).ok
assert opened == ["https://example.com/search?q=a%20b"]
@pytest.fixture
def http_server() -> Iterator[tuple[str, list[dict[str, Any]]]]:
received: list[dict[str, Any]] = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def _respond(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
received.append(
{
"method": self.command,
"path": self.path,
"auth": self.headers.get("Authorization"),
"body": json.loads(self.rfile.read(length)) if length else None,
}
)
status = 500 if self.path.startswith("/fail") else 200
payload = json.dumps({"state": "ok"}).encode()
self.send_response(status)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
do_GET = do_POST = _respond
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", received
server.shutdown()
server.server_close()
def test_http_sends_json_and_secret_headers(
http_server: tuple[str, list[dict[str, Any]]], monkeypatch: pytest.MonkeyPatch
) -> None:
base_url, received = http_server
monkeypatch.setenv("TEST_HA_TOKEN", "token123")
command = _command(
{
"type": "http",
"method": "POST",
"url": base_url + "/light/turn_{state}",
"headers": {"Authorization": "Bearer ${TEST_HA_TOKEN}"},
"json": {"entity_id": "light.room", "brightness": "{level}"},
},
{"state": {"type": "string", "enum": ["on", "off"]}, "level": {"type": "integer"}},
)
outcome = actions.execute(command, {"state": "on", "level": 80})
assert outcome.ok and "HTTP 200" in outcome.content
assert received == [
{
"method": "POST",
"path": "/light/turn_on",
"auth": "Bearer token123",
"body": {"entity_id": "light.room", "brightness": 80},
}
]
assert "token123" not in outcome.content + outcome.display
def test_http_error_status_is_failure(http_server: tuple[str, list[dict[str, Any]]]) -> None:
base_url, _received = http_server
outcome = actions.execute(_command({"type": "http", "url": base_url + "/fail"}), {})
assert not outcome.ok and "HTTP 500" in outcome.content
assert not actions.execute(_command({"type": "http", "url": "http://127.0.0.1:9/"}), {}).ok
@pytest.mark.skipif(sys.platform != "win32", reason="клавиши эмулируются только в Windows")
def test_keys_repeat(monkeypatch: pytest.MonkeyPatch) -> None:
pressed: list[list[int]] = []
monkeypatch.setattr(actions, "send_keys", pressed.append)
command = _command(
{"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
{"steps": {"type": "integer", "default": 3}},
)
assert actions.execute(command, {}).ok
assert pressed == [[0xAF]] * 3
+118
View File
@@ -0,0 +1,118 @@
from pathlib import Path
import pytest
from agr_assistent.commands.catalog import default_commands_text
from agr_assistent.commands.model import CommandError, load_commands, parse_command, parse_key_combo
def _command(**overrides: object) -> dict:
item = {"name": "test", "description": "Тест", "action": {"type": "open", "target": "https://x.io"}}
item.update(overrides)
return item
def test_default_commands_file_is_valid(tmp_path: Path) -> None:
path = tmp_path / "commands.yaml"
path.write_text(default_commands_text(), encoding="utf-8")
commands, errors = load_commands(path)
assert errors == []
assert {command.name for command in commands} >= {"media_play_pause", "volume_up", "web_search"}
def test_commented_examples_are_valid_too(tmp_path: Path) -> None:
"""Примеры в конце файла закомментированы — раскомментированные, они тоже должны загружаться."""
lines = default_commands_text().splitlines()
start = next(i for i, line in enumerate(lines) if "--- Примеры" in line)
uncommented = lines[: start + 1] + [
line.replace(" # ", " ", 1) if line.startswith(" # ") else line
for line in lines[start + 1 :]
]
path = tmp_path / "commands.yaml"
path.write_text("\n".join(uncommented), encoding="utf-8")
commands, errors = load_commands(path)
assert errors == []
assert {"shutdown_computer", "disk_space", "room_light"} <= {c.name for c in commands}
def test_parameters_schema_defaults_and_summary() -> None:
command = parse_command(
_command(
parameters={
"level": {"type": "integer", "minimum": 0, "maximum": 100},
"steps": {"type": "integer", "default": 5},
"note": "Просто описание",
},
action={"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
)
)
assert command.schema() == {
"type": "object",
"properties": {
"level": {"type": "integer", "minimum": 0, "maximum": 100},
"steps": {"type": "integer"},
"note": {"type": "string", "description": "Просто описание"},
},
"required": ["level", "note"],
}
assert command.with_defaults({"level": 30}) == {"steps": 5, "level": 30}
assert command.summary({"level": 30}) == "Тест (steps: 5, level: 30)"
@pytest.mark.parametrize(
("overrides", "message"),
[
({"name": "плохое имя"}, "латиница"),
({"name": "remember"}, "занято"),
({"description": ""}, "описание"),
({"action": {"type": "shell", "command": "rm"}}, "run, open, http или keys"),
({"action": {"type": "run"}}, "program"),
({"action": {"type": "http", "url": "ftp://x"}}, "http://"),
({"action": {"type": "keys", "keys": "ctrl+bogus"}}, "неизвестная клавиша"),
({"action": {"type": "open", "target": "https://x.io/{query}"}}, "неизвестные параметры"),
(
{"parameters": {"level": {"type": "integer"}}, "phrases": ["громкость"]},
"не хватает обязательных",
),
],
)
def test_invalid_commands_are_rejected(overrides: dict, message: str) -> None:
with pytest.raises(CommandError, match=message):
parse_command(_command(**overrides))
def test_one_broken_command_does_not_break_others(tmp_path: Path) -> None:
path = tmp_path / "commands.yaml"
path.write_text(
"""
commands:
- name: good
description: Хорошая
action: {type: open, target: "https://x.io"}
- name: bad
description: Плохая
action: {type: nope}
- name: good
description: Дубль
action: {type: open, target: "https://y.io"}
""",
encoding="utf-8",
)
commands, errors = load_commands(path)
assert [command.name for command in commands] == ["good"]
assert len(errors) == 2
assert "bad" in errors[0] and "уже используется" in errors[1]
def test_key_combos() -> None:
assert parse_key_combo("ctrl+shift+esc") == [0x11, 0x10, 0x1B]
assert parse_key_combo("volume_mute") == [0xAD]
with pytest.raises(CommandError):
parse_key_combo("ctrl+")
+48
View File
@@ -0,0 +1,48 @@
from agr_assistent.commands.matching import confirmation_decision, match_phrase, normalize
from agr_assistent.commands.model import parse_command
_COMMANDS = [
parse_command(
{
"name": "pause",
"description": "Пауза",
"phrases": ["Поставь на паузу", "пауза"],
"action": {"type": "keys", "keys": "media_play_pause"},
}
),
parse_command(
{
"name": "volume",
"description": "Громкость",
"phrases": ["громкость {level} процентов", "громкость {level}"],
"parameters": {"level": {"type": "integer", "minimum": 0, "maximum": 100}},
"action": {"type": "keys", "keys": "volume_up"},
}
),
]
def test_normalize() -> None:
assert normalize(" Ещё, пожалуйста!! ") == "еще пожалуйста"
def test_whole_phrase_must_match() -> None:
assert match_phrase(_COMMANDS, "Пауза.")[0].name == "pause"
assert match_phrase(_COMMANDS, "поставь на паузу")[0].name == "pause"
assert match_phrase(_COMMANDS, "пауза в работе") is None
assert match_phrase(_COMMANDS, "") is None
def test_phrase_parameters_are_validated() -> None:
command, arguments = match_phrase(_COMMANDS, "Громкость 30 процентов")
assert command.name == "volume" and arguments == {"level": 30}
assert match_phrase(_COMMANDS, "громкость 30")[1] == {"level": 30}
assert match_phrase(_COMMANDS, "громкость 300") is None
assert match_phrase(_COMMANDS, "громкость много") is None
def test_confirmation_decision() -> None:
assert confirmation_decision("Да!") is True
assert confirmation_decision("не надо") is False
assert confirmation_decision("да, но сначала сохрани файл") is None