Этап 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:
co-authored by
Claude Opus 5
parent
84d8aaa848
commit
04029f3750
@@ -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+")
|
||||
Reference in New Issue
Block a user