Этап 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"])