Этап 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
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user