Этап 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
+1
View File
@@ -7,3 +7,4 @@ dist/
# Локальный конфиг может содержать API-ключи # Локальный конфиг может содержать API-ключи
config.yaml config.yaml
commands.yaml
+39
View File
@@ -7,6 +7,8 @@
в течение пары минут после ответа видят предыдущие вопросы в течение пары минут после ответа видят предыдущие вопросы
- Журнал запросов и ответов со стримингом - Журнал запросов и ответов со стримингом
- Долговременная память: «запомни…», «забудь…», а устойчивые факты о вас модель сохраняет сама - Долговременная память: «запомни…», «забудь…», а устойчивые факты о вас модель сохраняет сама
- Настраиваемые команды: запуск программ и скриптов, ссылки и папки, HTTP-запросы (Home Assistant),
клавиши и медиа — через модель или мгновенно по точной фразе, опасные — с подтверждением
- Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются - Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются
- Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD) - Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD)
- Слово активации («ассистент») через Vosk — без нажатия клавиш - Слово активации («ассистент») через Vosk — без нажатия клавиш
@@ -50,6 +52,43 @@ Silero читает только кириллицу: числа переводя
(например, `qwen2.5:7b` в Ollama или большинство моделей OpenRouter). С другими моделями (например, `qwen2.5:7b` в Ollama или большинство моделей OpenRouter). С другими моделями
ассистент просто отвечает без памяти и один раз предупреждает об этом. ассистент просто отвечает без памяти и один раз предупреждает об этом.
### Команды
Команды описываются в `commands.yaml` рядом с `config.yaml`: при первом запуске он создаётся
с рабочими примерами (пауза, громкость, поиск, папка «Загрузки», блокировка) и
закомментированными шаблонами для скриптов, Home Assistant и выключения компьютера.
После сохранения файл перечитывается сам; список команд и ошибки видны в настройках.
```yaml
commands:
- name: room_light
description: Включить или выключить свет в комнате
phrases: ["свет {state}"] # мгновенно, без модели
parameters:
state: {type: string, enum: ["on", "off"]}
confirm: false # true — спросить «да/нет» перед выполнением
action:
type: http
method: POST
url: http://homeassistant.local:8123/api/services/light/turn_{state}
headers: {Authorization: "Bearer ${HA_TOKEN}"}
json: {entity_id: light.room}
```
Как команда выполняется:
- **Модель** выбирает команду по описанию и подставляет параметры («сделай потише на десять
шагов»). Результат (вывод скрипта, ответ сервера) возвращается модели, и она отвечает.
- **Точная фраза** выполняется сразу, без модели и даже без интернета, если запрос совпал
с ней целиком (регистр, «ё» и знаки препинания не важны).
- **Подтверждение** (`confirm: true`): ассистент спрашивает «Выполнить …?» и ждёт «да» или «нет»;
если вопрос был голосовым, микрофон включается сам.
Безопасность: модель может только выбрать команду из файла и передать параметры, которые
проверяются по описанию. Программы запускаются без командной оболочки; для `cmd`, PowerShell
и `.bat`/`.cmd` значения со спецсимволами отклоняются. `${ПЕРЕМЕННЫЕ}` подставляются только
из шаблона, поэтому секреты не попадают ни в модель, ни в параметры.
### Голосовой ввод ### Голосовой ввод
Нажмите `Win+Alt+Space` (настраивается в `voice.hotkey`), дождитесь короткого сигнала и говорите — Нажмите `Win+Alt+Space` (настраивается в `voice.hotkey`), дождитесь короткого сигнала и говорите —
+4 -1
View File
@@ -7,7 +7,10 @@ from pathlib import Path
import nvidia import nvidia
from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules
datas = [("src/agr_assistent/default_config.yaml", "agr_assistent")] datas = [
("src/agr_assistent/default_config.yaml", "agr_assistent"),
("src/agr_assistent/default_commands.yaml", "agr_assistent"),
]
datas += collect_data_files("silero_vad") datas += collect_data_files("silero_vad")
datas += collect_data_files("faster_whisper") datas += collect_data_files("faster_whisper")
+16 -3
View File
@@ -11,13 +11,14 @@ import threading
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QLibraryInfo, QLocale, QTranslator from PySide6.QtCore import QLibraryInfo, QLocale, Qt, QTranslator
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from agr_assistent import APP_NAME, __version__, system from agr_assistent import APP_NAME, __version__, system
from agr_assistent.audio.player import AudioPlayer from agr_assistent.audio.player import AudioPlayer
from agr_assistent.audio.recorder import SpeechRecorder from agr_assistent.audio.recorder import SpeechRecorder
from agr_assistent.audio.wakeword import VoskWakeWord from agr_assistent.audio.wakeword import VoskWakeWord
from agr_assistent.commands.catalog import COMMANDS_FILE_NAME, CommandCatalog
from agr_assistent.config import AppConfig, ConfigError, data_dir, load_config from agr_assistent.config import AppConfig, ConfigError, data_dir, load_config
from agr_assistent.core.assistant import Assistant from agr_assistent.core.assistant import Assistant
from agr_assistent.core.memory import MemoryStore from agr_assistent.core.memory import MemoryStore
@@ -106,6 +107,8 @@ def main(argv: list[str] | None = None) -> int:
memory = MemoryStore(data_dir() / "memory.sqlite3") memory = MemoryStore(data_dir() / "memory.sqlite3")
app.aboutToQuit.connect(memory.close) app.aboutToQuit.connect(memory.close)
commands = CommandCatalog(config.path.parent / COMMANDS_FILE_NAME, app)
commands.ensure_file()
assistant = Assistant( assistant = Assistant(
config.llm, config.llm,
speaker, speaker,
@@ -113,17 +116,27 @@ def main(argv: list[str] | None = None) -> int:
wake_word, wake_word,
memory=memory, memory=memory,
memory_auto_save=config.memory.auto_save, memory_auto_save=config.memory.auto_save,
commands=commands,
) )
app.setWindowIcon(state_icon(assistant.state)) app.setWindowIcon(state_icon(assistant.state))
window = ChatWindow(assistant) window = ChatWindow(assistant)
instance.activated.connect(window.show_and_raise) instance.activated.connect(window.show_and_raise)
for error in commands.errors:
assistant.error_occurred.emit(error)
dialog: SettingsDialog | None = None dialog: SettingsDialog | None = None
def forget_dialog() -> None:
nonlocal dialog
dialog = None
def open_settings() -> None: def open_settings() -> None:
nonlocal dialog nonlocal dialog
if dialog is None or not dialog.isVisible(): if dialog is None:
dialog = SettingsDialog(settings, memory, window) dialog = SettingsDialog(settings, memory, commands, window)
# Окно удаляется при закрытии, чтобы не копить подписки на каталог команд
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.destroyed.connect(forget_dialog)
dialog.show() dialog.show()
dialog.raise_() dialog.raise_()
dialog.activateWindow() dialog.activateWindow()
+246
View File
@@ -0,0 +1,246 @@
"""Выполнение действий команд: запуск программ, открытие ссылок, HTTP-запросы, клавиши."""
from __future__ import annotations
import ctypes
import json
import logging
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from dataclasses import dataclass
from typing import Any
from agr_assistent.commands.model import PLACEHOLDER, Command, parse_key_combo
from agr_assistent.config import expand_env
log = logging.getLogger(__name__)
# Сколько текста вывода отдаём модели
MAX_OUTPUT_CHARS = 4000
_MAX_HTTP_BYTES = 256_000
# Программы запускаются без оболочки, но cmd и PowerShell сами разбирают свои аргументы
# (а .bat/.cmd всегда исполняет cmd). Через значение параметра в них можно было бы
# подсунуть лишнюю команду, поэтому значения с такими символами не пропускаем
_CMD_METACHARACTERS = frozenset('&|<>^%!"()\r\n')
_POWERSHELL_METACHARACTERS = frozenset(";&|<>`$(){}[]@\"'#\r\n")
_INTERPRETERS = {
"cmd.exe": _CMD_METACHARACTERS,
"cmd": _CMD_METACHARACTERS,
"powershell.exe": _POWERSHELL_METACHARACTERS,
"powershell": _POWERSHELL_METACHARACTERS,
"pwsh.exe": _POWERSHELL_METACHARACTERS,
"pwsh": _POWERSHELL_METACHARACTERS,
}
_KEYEVENTF_EXTENDEDKEY = 0x0001
_KEYEVENTF_KEYUP = 0x0002
_EXTENDED_KEYS = {
0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x2D, 0x2E, # навигация
0x5B, # win
0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, # громкость и медиа
} # fmt: skip
_KEY_PAUSE_SECONDS = 0.01
@dataclass(frozen=True)
class Outcome:
ok: bool
content: str # для модели
display: str # для журнала
def execute(command: Command, arguments: dict[str, Any]) -> Outcome:
values = command.with_defaults(arguments)
action = command.action
handlers = {"run": _run, "open": _open, "http": _http, "keys": _keys}
log.info("Команда %s: %s %s", command.name, command.action_type, values)
return handlers[command.action_type](command, action, values)
def substitute(template: str, values: dict[str, Any], *, url_encode: bool = False) -> str:
"""Подставляет {параметр}; отсутствующий необязательный параметр даёт пустую строку."""
def replace(match: Any) -> str:
value = values.get(match.group(1))
text = "" if value is None else _as_text(value)
return urllib.parse.quote(text, safe="") if url_encode else text
return PLACEHOLDER.sub(replace, template)
def substitute_json(value: Any, values: dict[str, Any]) -> Any:
"""Строка ровно «{параметр}» заменяется значением с сохранением типа (число, true/false);
${VAR} раскрываются только в шаблоне, не в значениях параметров."""
if isinstance(value, str):
if match := PLACEHOLDER.fullmatch(value):
return values.get(match.group(1))
return substitute(expand_env(value), values)
if isinstance(value, dict):
return {key: substitute_json(item, values) for key, item in value.items()}
if isinstance(value, list):
return [substitute_json(item, values) for item in value]
return value
def open_target(target: str) -> None:
"""Вынесено отдельно, чтобы тесты не открывали браузер."""
if target.startswith(("http://", "https://")):
webbrowser.open(target)
elif sys.platform == "win32":
os.startfile(target) # type: ignore[attr-defined]
else:
subprocess.Popen(["xdg-open", target])
def send_keys(codes: list[int]) -> None:
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
for code in codes:
user32.keybd_event(code, 0, _extended(code), 0)
time.sleep(_KEY_PAUSE_SECONDS)
for code in reversed(codes):
user32.keybd_event(code, 0, _extended(code) | _KEYEVENTF_KEYUP, 0)
time.sleep(_KEY_PAUSE_SECONDS)
def _run(command: Command, action: dict[str, Any], values: dict[str, Any]) -> Outcome:
program = substitute(_expand(action["program"]), values)
args = [substitute(_expand(arg), values) for arg in action["args"]]
resolved = shutil.which(program) or program
if not os.path.exists(resolved):
return _failed(f"Программа не найдена: {program}")
forbidden = _interpreter_metacharacters(resolved)
if forbidden and any(set(_as_text(value)) & forbidden for value in values.values()):
return _failed(
f"Параметры для {os.path.basename(resolved)} содержат недопустимые символы — "
"команда не выполнена"
)
flags = 0
if sys.platform == "win32" and action["hidden"]:
flags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
cwd = action.get("cwd")
cwd = substitute(_expand(str(cwd)), values) if cwd else None
if not action["wait"]:
subprocess.Popen([resolved, *args], cwd=cwd, creationflags=flags, close_fds=True)
return Outcome(True, "Программа запущена", f"Запустил: {command.summary(values)}")
try:
completed = subprocess.run(
[resolved, *args],
cwd=cwd,
capture_output=True,
timeout=action["timeout_seconds"],
creationflags=flags,
)
except subprocess.TimeoutExpired:
return _failed(f"Программа не завершилась за {action['timeout_seconds']:g} с")
output = (_decode(completed.stdout) + _decode(completed.stderr)).strip()
output = _truncate(output) or "(вывод пуст)"
ok = completed.returncode == 0
display = f"{'Выполнил' if ok else 'Ошибка'}: {command.summary(values)}"
if not ok:
display += f" — код {completed.returncode}"
return Outcome(ok, f"Код завершения {completed.returncode}. Вывод:\n{output}", display)
def _open(command: Command, action: dict[str, Any], values: dict[str, Any]) -> Outcome:
template = _expand(str(action["target"]))
is_url = template.startswith(("http://", "https://"))
target = substitute(template, values, url_encode=is_url)
open_target(target)
return Outcome(True, f"Открыто: {target}", f"Открыл: {command.summary(values)}")
def _http(command: Command, action: dict[str, Any], values: dict[str, Any]) -> Outcome:
url = substitute(expand_env(str(action["url"])), values, url_encode=True)
headers = {key: substitute(expand_env(value), values) for key, value in action["headers"].items()}
data = None
if "json" in action:
data = json.dumps(substitute_json(action["json"], values), ensure_ascii=False).encode("utf-8")
headers.setdefault("Content-Type", "application/json")
request = urllib.request.Request(url, data=data, headers=headers, method=action["method"])
try:
with urllib.request.urlopen(request, timeout=action["timeout_seconds"]) as response:
status = response.status
body = response.read(_MAX_HTTP_BYTES)
except urllib.error.HTTPError as exc:
status = exc.code
body = exc.read(_MAX_HTTP_BYTES)
except (urllib.error.URLError, TimeoutError, OSError) as exc:
reason = getattr(exc, "reason", exc)
return _failed(f"Запрос не выполнен: {reason}")
ok = 200 <= status < 300
text = _truncate(body.decode("utf-8", errors="replace").strip()) or "(пустой ответ)"
display = f"{'Выполнил' if ok else 'Ошибка'}: {command.summary(values)}"
if not ok:
display += f" — HTTP {status}"
return Outcome(ok, f"HTTP {status}. Ответ:\n{text}", display)
def _keys(command: Command, action: dict[str, Any], values: dict[str, Any]) -> Outcome:
if sys.platform != "win32":
return _failed("Нажатие клавиш пока поддерживается только в Windows")
codes = parse_key_combo(substitute(str(action["keys"]), values))
try:
repeat = int(substitute(str(action.get("repeat", 1)), values) or 1)
except ValueError:
return _failed("repeat должен быть целым числом")
for _ in range(max(1, min(repeat, 100))):
send_keys(codes)
return Outcome(True, "Клавиши нажаты", f"Выполнил: {command.summary(values)}")
def _interpreter_metacharacters(program: str) -> frozenset[str]:
name = os.path.basename(program).lower()
if name.endswith((".bat", ".cmd")):
return _CMD_METACHARACTERS
return _INTERPRETERS.get(name, frozenset())
def _expand(template: str) -> str:
"""${VAR} и %VAR% раскрываются в шаблоне до подстановки параметров,
чтобы значение от модели не могло сослаться на переменную окружения с секретом."""
return os.path.expandvars(expand_env(template))
def _failed(message: str) -> Outcome:
return Outcome(False, message, message)
def _extended(code: int) -> int:
return _KEYEVENTF_EXTENDEDKEY if code in _EXTENDED_KEYS else 0
def _as_text(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _truncate(text: str) -> str:
if len(text) <= MAX_OUTPUT_CHARS:
return text
return text[:MAX_OUTPUT_CHARS] + "\n…(вывод обрезан)"
def _decode(data: bytes) -> str:
"""Консольные программы Windows пишут в кодировке OEM (cp866), новые — в UTF-8."""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
pass
if sys.platform == "win32":
return data.decode(f"cp{ctypes.windll.kernel32.GetOEMCP()}", errors="replace") # type: ignore[attr-defined]
return data.decode("utf-8", errors="replace")
+93
View File
@@ -0,0 +1,93 @@
"""Загруженные команды: файл commands.yaml, перезагрузка при изменении, инструменты для модели."""
from __future__ import annotations
import logging
from importlib import resources
from pathlib import Path
from typing import Any
from PySide6.QtCore import QFileSystemWatcher, QObject, QTimer, Signal
from agr_assistent.commands import actions
from agr_assistent.commands.model import Command, load_commands
from agr_assistent.llm.tools import Tool, ToolResult
log = logging.getLogger(__name__)
COMMANDS_FILE_NAME = "commands.yaml"
# Редакторы сохраняют файл в несколько приёмов — перечитываем после паузы
_RELOAD_DELAY_MS = 300
def default_commands_text() -> str:
return (
resources.files("agr_assistent").joinpath("default_commands.yaml").read_text(encoding="utf-8")
)
def command_tool(command: Command) -> Tool:
def handler(arguments: dict[str, Any]) -> ToolResult:
outcome = actions.execute(command, arguments)
return ToolResult(outcome.ok, outcome.content, outcome.display)
return Tool(
name=command.name,
description=command.description,
parameters=command.schema(),
handler=handler,
confirm=command.confirm,
describe=command.summary,
)
class CommandCatalog(QObject):
changed = Signal()
def __init__(self, path: Path, parent: QObject | None = None) -> None:
super().__init__(parent)
self._path = path
self._commands: list[Command] = []
self._errors: list[str] = []
self._reload_timer = QTimer(self)
self._reload_timer.setSingleShot(True)
self._reload_timer.setInterval(_RELOAD_DELAY_MS)
self._reload_timer.timeout.connect(self.reload)
self._watcher = QFileSystemWatcher(self)
self._watcher.fileChanged.connect(lambda _path: self._reload_timer.start())
self.reload()
@property
def path(self) -> Path:
return self._path
@property
def commands(self) -> list[Command]:
return self._commands
@property
def errors(self) -> list[str]:
return self._errors
def ensure_file(self) -> None:
"""Создаёт commands.yaml с примерами, если его ещё нет."""
if not self._path.exists():
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(default_commands_text(), encoding="utf-8")
log.info("Создан файл команд: %s", self._path)
self.reload()
def tools(self) -> list[Tool]:
return [command_tool(command) for command in self._commands]
def reload(self) -> None:
self._commands, self._errors = load_commands(self._path)
# После сохранения «заменой файла» наблюдение слетает — ставим заново
if self._path.exists() and str(self._path) not in self._watcher.files():
self._watcher.addPath(str(self._path))
log.info("Команды: %d загружено, %d ошибок", len(self._commands), len(self._errors))
for error in self._errors:
log.warning(error)
self.changed.emit()
+59
View File
@@ -0,0 +1,59 @@
"""Точные фразы команд без LLM и ответы «да/нет» на подтверждение."""
from __future__ import annotations
import re
from typing import Any
from agr_assistent.commands.model import PLACEHOLDER, Command
from agr_assistent.llm.tools import validate_arguments
_NON_WORD = re.compile(r"[^\w\s{}]+")
_SPACES = re.compile(r"\s+")
_YES = {"да", "ага", "конечно", "подтверждаю", "выполняй", "давай", "да давай", "да выполняй", "угу"}
_NO = {"нет", "отмена", "отмени", "не надо", "не нужно", "стоп", "не выполняй", "нет не надо"}
def normalize(text: str) -> str:
"""Нижний регистр, ё → е, без знаков препинания (фигурные скобки сохраняются для шаблонов)."""
text = text.lower().replace("ё", "е")
return _SPACES.sub(" ", _NON_WORD.sub(" ", text)).strip()
def match_phrase(commands: list[Command], text: str) -> tuple[Command, dict[str, Any]] | None:
"""Первая команда, фраза которой совпала с запросом целиком, и её аргументы."""
request = normalize(text.replace("{", " ").replace("}", " "))
if not request:
return None
for command in commands:
for phrase in command.phrases:
match = _phrase_pattern(phrase).fullmatch(request)
if match is None:
continue
arguments, errors = validate_arguments(command.schema(), match.groupdict())
if not errors:
return command, arguments
return None
def confirmation_decision(text: str) -> bool | None:
"""True — «да», False — «нет», None — это не ответ на вопрос."""
request = normalize(text)
if request in _YES:
return True
if request in _NO:
return False
return None
def _phrase_pattern(phrase: str) -> re.Pattern[str]:
parts = []
position = 0
normalized = normalize(phrase)
for placeholder in PLACEHOLDER.finditer(normalized):
parts.append(re.escape(normalized[position : placeholder.start()]))
parts.append(f"(?P<{placeholder.group(1)}>.+?)")
position = placeholder.end()
parts.append(re.escape(normalized[position:]))
return re.compile("".join(parts))
+285
View File
@@ -0,0 +1,285 @@
"""Описание пользовательских команд из commands.yaml и их проверка."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from agr_assistent.hotkey import key_code
NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
# Только имена параметров: никаких выражений вроде {x.attr} или {x[0]}.
# ${VAR} — переменная окружения, а не параметр
PLACEHOLDER = re.compile(r"(?<!\$)\{(\w+)\}")
RESERVED_NAMES = frozenset({"remember", "update_memory", "forget"})
PARAMETER_TYPES = frozenset({"string", "integer", "number", "boolean"})
HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
MODIFIER_KEYS = {"ctrl": 0x11, "control": 0x11, "alt": 0x12, "shift": 0x10, "win": 0x5B}
MEDIA_KEYS = {
"volume_up": 0xAF,
"volume_down": 0xAE,
"volume_mute": 0xAD,
"media_next": 0xB0,
"media_previous": 0xB1,
"media_prev": 0xB1,
"media_stop": 0xB2,
"media_play_pause": 0xB3,
}
_DEFAULT_TIMEOUT_SECONDS = 30
class CommandError(Exception):
pass
@dataclass(frozen=True)
class Parameter:
name: str
type: str
description: str
enum: tuple[Any, ...] | None
minimum: float | None
maximum: float | None
default: Any
optional: bool
def schema(self) -> dict[str, Any]:
schema: dict[str, Any] = {"type": self.type}
if self.description:
schema["description"] = self.description
if self.enum is not None:
schema["enum"] = list(self.enum)
if self.minimum is not None:
schema["minimum"] = self.minimum
if self.maximum is not None:
schema["maximum"] = self.maximum
return schema
@dataclass(frozen=True)
class Command:
name: str
description: str
parameters: tuple[Parameter, ...]
action: dict[str, Any]
phrases: tuple[str, ...]
confirm: bool
reply: str
@property
def action_type(self) -> str:
return str(self.action["type"])
def schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {parameter.name: parameter.schema() for parameter in self.parameters},
"required": [p.name for p in self.parameters if not p.optional],
}
def with_defaults(self, arguments: dict[str, Any]) -> dict[str, Any]:
values = {p.name: p.default for p in self.parameters if p.default is not None}
values.update(arguments)
return values
def summary(self, arguments: dict[str, Any]) -> str:
values = self.with_defaults(arguments)
details = ", ".join(f"{name}: {value}" for name, value in values.items())
return f"{self.description} ({details})" if details else self.description
def load_commands(path: Path) -> tuple[list[Command], list[str]]:
"""Возвращает корректные команды и описания ошибок; одна ошибка не ломает остальные."""
if not path.exists():
return [], []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
return [], [f"{path.name}: не удалось разобрать YAML — {exc}"]
items = data.get("commands") if isinstance(data, dict) else None
if items is None:
return [], []
if not isinstance(items, list):
return [], [f"{path.name}: commands должен быть списком"]
commands: list[Command] = []
errors: list[str] = []
seen: set[str] = set()
for index, item in enumerate(items, start=1):
label = item.get("name") if isinstance(item, dict) and item.get("name") else f"{index}"
try:
command = parse_command(item)
except CommandError as exc:
errors.append(f"{path.name}, команда {label}: {exc}")
continue
if command.name in seen:
errors.append(f"{path.name}, команда {label}: имя уже используется")
continue
seen.add(command.name)
commands.append(command)
return commands, errors
def parse_command(item: Any) -> Command:
if not isinstance(item, dict):
raise CommandError("ожидалось описание команды (словарь)")
name = str(item.get("name") or "")
if not NAME_PATTERN.fullmatch(name):
raise CommandError("name — латиница, цифры, _ и -, до 64 символов")
if name in RESERVED_NAMES:
raise CommandError("это имя занято встроенным инструментом памяти")
description = str(item.get("description") or "").strip()
if not description:
raise CommandError("нужно описание description — по нему модель выбирает команду")
parameters = tuple(_parse_parameters(item.get("parameters")))
names = {parameter.name for parameter in parameters}
action = _parse_action(item.get("action"))
unknown = _placeholders(action) - names
if unknown:
raise CommandError(f"в action используются неизвестные параметры: {', '.join(sorted(unknown))}")
phrases = item.get("phrases") or []
if isinstance(phrases, str):
phrases = [phrases]
phrases = tuple(str(phrase).strip() for phrase in phrases if str(phrase).strip())
required = {p.name for p in parameters if not p.optional and p.default is None}
for phrase in phrases:
used = set(PLACEHOLDER.findall(phrase))
if used - names:
raise CommandError(f"во фразе «{phrase}» неизвестные параметры: {', '.join(sorted(used - names))}")
if required - used:
raise CommandError(
f"во фразе «{phrase}» не хватает обязательных параметров: {', '.join(sorted(required - used))}"
)
return Command(
name=name,
description=description,
parameters=parameters,
action=action,
phrases=phrases,
confirm=bool(item.get("confirm", False)),
reply=str(item.get("reply") or "").strip(),
)
def parse_key_combo(spec: str) -> list[int]:
"""«ctrl+shift+esc» или «volume_up» -> виртуальные коды в порядке нажатия."""
parts = [part.strip().lower() for part in str(spec).split("+")]
if not all(parts):
raise CommandError(f"некорректное сочетание клавиш «{spec}»")
*modifiers, key = parts
codes = []
for modifier in modifiers:
if modifier not in MODIFIER_KEYS:
raise CommandError(f"неизвестный модификатор «{modifier}»")
codes.append(MODIFIER_KEYS[modifier])
code = MEDIA_KEYS.get(key) or key_code(key)
if code is None:
raise CommandError(f"неизвестная клавиша «{key}»")
codes.append(code)
return codes
def _parse_parameters(data: Any) -> list[Parameter]:
if data is None:
return []
if not isinstance(data, dict):
raise CommandError("parameters — словарь «имя: описание»")
parameters = []
for name, spec in data.items():
name = str(name)
if not re.fullmatch(r"\w+", name):
raise CommandError(f"некорректное имя параметра «{name}»")
if not isinstance(spec, dict):
spec = {"type": "string", "description": str(spec or "")}
kind = str(spec.get("type", "string"))
if kind not in PARAMETER_TYPES:
raise CommandError(f"параметр {name}: тип должен быть одним из {', '.join(sorted(PARAMETER_TYPES))}")
enum = spec.get("enum")
if enum is not None and not isinstance(enum, list):
raise CommandError(f"параметр {name}: enum должен быть списком")
default = spec.get("default")
parameters.append(
Parameter(
name=name,
type=kind,
description=str(spec.get("description") or ""),
enum=tuple(enum) if enum is not None else None,
minimum=spec.get("minimum"),
maximum=spec.get("maximum"),
default=default,
optional=bool(spec.get("optional", default is not None)),
)
)
return parameters
def _parse_action(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise CommandError("нужно действие action с полем type")
kind = data.get("type")
action = dict(data)
if kind == "run":
if not str(data.get("program") or "").strip():
raise CommandError("для run нужна program")
args = data.get("args") or []
if not isinstance(args, list):
raise CommandError("args должен быть списком")
action["args"] = [str(arg) for arg in args]
action["wait"] = bool(data.get("wait", False))
action["hidden"] = bool(data.get("hidden", action["wait"]))
action["timeout_seconds"] = _positive(data.get("timeout_seconds", _DEFAULT_TIMEOUT_SECONDS))
elif kind == "open":
if not str(data.get("target") or "").strip():
raise CommandError("для open нужен target — ссылка, файл или папка")
elif kind == "http":
if not str(data.get("url") or "").startswith(("http://", "https://")):
raise CommandError("для http нужен url, начинающийся с http:// или https://")
method = str(data.get("method", "GET")).upper()
if method not in HTTP_METHODS:
raise CommandError(f"метод должен быть одним из {', '.join(sorted(HTTP_METHODS))}")
headers = data.get("headers") or {}
if not isinstance(headers, dict):
raise CommandError("headers должен быть словарём")
action["method"] = method
action["headers"] = {str(key): str(value) for key, value in headers.items()}
action["timeout_seconds"] = _positive(data.get("timeout_seconds", _DEFAULT_TIMEOUT_SECONDS))
elif kind == "keys":
if not str(data.get("keys") or "").strip():
raise CommandError("для keys нужно поле keys, например volume_up или ctrl+shift+esc")
parse_key_combo(data["keys"])
repeat = data.get("repeat", 1)
if not (isinstance(repeat, int) or PLACEHOLDER.fullmatch(str(repeat))):
raise CommandError("repeat — целое число или параметр вида {steps}")
else:
raise CommandError("type действия должен быть run, open, http или keys")
return action
def _placeholders(value: Any) -> set[str]:
if isinstance(value, str):
return set(PLACEHOLDER.findall(value))
if isinstance(value, dict):
return set().union(*(_placeholders(item) for item in value.values())) if value else set()
if isinstance(value, list):
return set().union(*(_placeholders(item) for item in value)) if value else set()
return set()
def _positive(value: Any) -> float:
try:
number = float(value)
except (TypeError, ValueError):
number = 0
if number <= 0:
raise CommandError("timeout_seconds должен быть положительным числом")
return number
+175 -9
View File
@@ -2,14 +2,19 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import threading import threading
import time
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from PySide6.QtCore import QObject, Signal, Slot from PySide6.QtCore import QObject, QTimer, Signal, Slot
from agr_assistent.commands.catalog import CommandCatalog
from agr_assistent.commands.matching import confirmation_decision, match_phrase
from agr_assistent.config import LLMConfig from agr_assistent.config import LLMConfig
from agr_assistent.core.context import FollowUpContext, build_messages from agr_assistent.core.context import FollowUpContext, build_messages
from agr_assistent.core.memory import MemoryStore, memory_prompt, memory_tools from agr_assistent.core.memory import MemoryStore, memory_prompt, memory_tools
@@ -24,12 +29,33 @@ from agr_assistent.llm.client import (
ToolCalls, ToolCalls,
ToolsNotSupportedError, ToolsNotSupportedError,
) )
from agr_assistent.llm.tools import ToolRegistry from agr_assistent.llm.tools import Tool, ToolRegistry, ToolResult
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Сколько раз подряд модель может вызвать инструменты в одном ответе # Сколько раз подряд модель может вызвать инструменты в одном ответе
MAX_TOOL_ROUNDS = 5 MAX_TOOL_ROUNDS = 5
# Сколько ждать ответа «да/нет» на вопрос-подтверждение
CONFIRMATION_TIMEOUT_SECONDS = 60
COMMANDS_PROMPT = (
"Тебе доступны команды для управления компьютером — это инструменты, кроме инструментов "
"памяти. Если просьба пользователя соответствует команде, вызови её сразу, без лишних "
"уточнений. Сообщай результат коротко и только тот, что вернула команда; не придумывай "
"его. Если команда вернула, что нужно подтверждение, задай пользователю короткий вопрос "
"«да или нет» и больше ничего не делай."
)
_CONFIRMATION_RESULT = (
"Команда НЕ выполнена: требуется подтверждение пользователя. Одним коротким вопросом спроси, "
"выполнить ли «{summary}». Пользователь ответит «да» или «нет» — это обработается само."
)
@dataclass(frozen=True)
class PendingConfirmation:
tool: Tool
arguments: dict[str, Any]
created_at: float
class AssistantState(Enum): class AssistantState(Enum):
@@ -58,6 +84,7 @@ class Assistant(QObject):
# Мост из фонового потока в главный; int — номер генерации # Мост из фонового потока в главный; int — номер генерации
_worker_chunk = Signal(int, str) _worker_chunk = Signal(int, str)
_worker_tool = Signal(int, str, bool) _worker_tool = Signal(int, str, bool)
_worker_confirm = Signal(int, str, str) # имя инструмента, аргументы JSON
_worker_notice = Signal(int, str) _worker_notice = Signal(int, str)
_worker_failed = Signal(int, str) _worker_failed = Signal(int, str)
_worker_done = Signal(int) _worker_done = Signal(int)
@@ -71,6 +98,7 @@ class Assistant(QObject):
*, *,
memory: MemoryStore | None = None, memory: MemoryStore | None = None,
memory_auto_save: bool = True, memory_auto_save: bool = True,
commands: CommandCatalog | None = None,
parent: QObject | None = None, parent: QObject | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
@@ -80,7 +108,13 @@ class Assistant(QObject):
self._wake_word = wake_word self._wake_word = wake_word
self._memory = memory self._memory = memory
self._memory_auto_save = memory_auto_save self._memory_auto_save = memory_auto_save
self._tools = ToolRegistry(memory_tools(memory) if memory is not None else ()) self._commands = commands
self._tools = ToolRegistry()
self._rebuild_tools()
self._pending: PendingConfirmation | None = None
self._request_by_voice = False
# После вопроса-подтверждения на голосовой запрос сразу слушаем ответ
self._listen_after_reply = False
# Модели, которые отказались работать с инструментами: больше не предлагаем им инструменты # Модели, которые отказались работать с инструментами: больше не предлагаем им инструменты
self._models_without_tools: set[tuple[str, str]] = set() self._models_without_tools: set[tuple[str, str]] = set()
self._client: LLMClient | None = None self._client: LLMClient | None = None
@@ -94,6 +128,7 @@ class Assistant(QObject):
self._worker_chunk.connect(self._on_worker_chunk) self._worker_chunk.connect(self._on_worker_chunk)
self._worker_tool.connect(self._on_worker_tool) self._worker_tool.connect(self._on_worker_tool)
self._worker_confirm.connect(self._on_worker_confirm)
self._worker_notice.connect(self._on_worker_notice) self._worker_notice.connect(self._on_worker_notice)
self._worker_failed.connect(self._on_worker_failed) self._worker_failed.connect(self._on_worker_failed)
self._worker_done.connect(self._on_worker_done) self._worker_done.connect(self._on_worker_done)
@@ -106,6 +141,8 @@ class Assistant(QObject):
voice.finished.connect(self._update_state) voice.finished.connect(self._update_state)
voice.recognized.connect(self._on_voice_recognized) voice.recognized.connect(self._on_voice_recognized)
voice.error_occurred.connect(self.error_occurred) voice.error_occurred.connect(self.error_occurred)
if commands is not None:
commands.changed.connect(self._on_commands_changed)
if wake_word is not None: if wake_word is not None:
wake_word.detected.connect(self._on_wake_word) wake_word.detected.connect(self._on_wake_word)
wake_word.enabled_changed.connect(self.wake_word_enabled_changed) wake_word.enabled_changed.connect(self.wake_word_enabled_changed)
@@ -201,12 +238,40 @@ class Assistant(QObject):
self._speaker.set_enabled(enabled) self._speaker.set_enabled(enabled)
self.speech_enabled_changed.emit(enabled) self.speech_enabled_changed.emit(enabled)
def send(self, text: str) -> None: def send(self, text: str, *, by_voice: bool = False) -> None:
"""Новый запрос прерывает озвучку предыдущего ответа.""" """Новый запрос прерывает озвучку предыдущего ответа.
Порядок: ответ «да/нет» на ожидающее подтверждение, точная фраза команды, модель.
"""
text = text.strip() text = text.strip()
if not text or self._generating: if not text or self._generating:
return return
self._speaker.stop() self._speaker.stop()
self._request_by_voice = by_voice
pending, self._pending = self._pending, None
if pending and time.monotonic() - pending.created_at <= CONFIRMATION_TIMEOUT_SECONDS:
decision = confirmation_decision(text)
if decision is not None:
self.request_added.emit(text, True)
if decision:
self._start_direct(text, pending.tool, pending.arguments)
else:
self._start_direct(text, reply="Хорошо, не выполняю.")
return
if self._commands is not None and (match := match_phrase(self._commands.commands, text)):
command, arguments = match
tool = self._tools.get(command.name)
if tool is not None:
self.request_added.emit(text, False)
if command.confirm:
self._ask_confirmation(tool, arguments)
self._start_direct(text, reply=_confirmation_question(tool.summary(arguments)))
else:
self._start_direct(text, tool, arguments, reply=command.reply or None)
return
context = self._context.recent() context = self._context.recent()
self.request_added.emit(text, bool(context)) self.request_added.emit(text, bool(context))
@@ -223,6 +288,8 @@ class Assistant(QObject):
sections = [] sections = []
if self._memory is not None: if self._memory is not None:
sections.append(memory_prompt(self._memory.facts(), self._memory_auto_save)) sections.append(memory_prompt(self._memory.facts(), self._memory_auto_save))
if self._commands is not None and self._commands.commands:
sections.append(COMMANDS_PROMPT)
messages = build_messages( messages = build_messages(
self._config.system_prompt, context, text, datetime.now(), sections self._config.system_prompt, context, text, datetime.now(), sections
) )
@@ -233,13 +300,14 @@ class Assistant(QObject):
self.reply_started.emit() self.reply_started.emit()
threading.Thread( threading.Thread(
target=self._run_reply, target=self._run_reply,
args=(self._generation, client, messages, use_tools, model_key), args=(self._generation, client, messages, self._tools, use_tools, model_key),
name=f"llm-reply-{self._generation}", name=f"llm-reply-{self._generation}",
daemon=True, daemon=True,
).start() ).start()
def cancel(self) -> None: def cancel(self) -> None:
"""Прерывает запись голоса, генерацию и озвучку.""" """Прерывает запись голоса, генерацию и озвучку."""
self._listen_after_reply = False
if self._voice is not None: if self._voice is not None:
self._voice.cancel() self._voice.cancel()
if self._generating: if self._generating:
@@ -251,8 +319,65 @@ class Assistant(QObject):
"""Очищает журнал и забывает контекст уточнений.""" """Очищает журнал и забывает контекст уточнений."""
self.cancel() self.cancel()
self._context.clear() self._context.clear()
self._pending = None
self.journal_cleared.emit() self.journal_cleared.emit()
def _start_direct(
self,
request: str,
tool: Tool | None = None,
arguments: dict[str, Any] | None = None,
*,
reply: str | None = None,
) -> None:
"""Ответ без модели: выполнить команду (если есть) и сказать короткую фразу."""
self._generation += 1
self._generating = True
self._request = request
self._reply_parts = []
self._speaker.begin()
self._update_state()
self.reply_started.emit()
threading.Thread(
target=self._run_direct,
args=(self._generation, tool, arguments or {}, reply),
name=f"command-{self._generation}",
daemon=True,
).start()
def _run_direct(
self, generation: int, tool: Tool | None, arguments: dict[str, Any], reply: str | None
) -> None:
"""Выполняется в фоновом потоке."""
try:
if tool is not None:
result = ToolRegistry.run(tool, arguments)
if generation != self._generation:
return
self._worker_tool.emit(generation, result.display, result.ok)
if not result.ok:
reply = "Не получилось."
elif reply is None:
reply = "Готово."
if reply:
self._worker_chunk.emit(generation, reply)
except Exception as exc:
log.exception("Сбой выполнения команды")
self._worker_failed.emit(generation, f"Непредвиденная ошибка: {exc}")
else:
self._worker_done.emit(generation)
def _ask_confirmation(self, tool: Tool, arguments: dict[str, Any]) -> None:
self._pending = PendingConfirmation(tool, arguments, time.monotonic())
self._listen_after_reply = self._request_by_voice
def _rebuild_tools(self) -> None:
tools = memory_tools(self._memory) if self._memory is not None else []
if self._commands is not None:
tools += self._commands.tools()
# Запросы, которые уже выполняются, продолжают работать со своим набором
self._tools = ToolRegistry(tools)
def _get_client(self) -> LLMClient: def _get_client(self) -> LLMClient:
if self._client is None: if self._client is None:
self._client = LLMClient( self._client = LLMClient(
@@ -267,6 +392,7 @@ class Assistant(QObject):
generation: int, generation: int,
client: LLMClient, client: LLMClient,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
registry: ToolRegistry,
use_tools: bool, use_tools: bool,
model_key: tuple[str, str], model_key: tuple[str, str],
) -> None: ) -> None:
@@ -274,7 +400,7 @@ class Assistant(QObject):
try: try:
for _round in range(MAX_TOOL_ROUNDS + 1): for _round in range(MAX_TOOL_ROUNDS + 1):
# На последнем круге инструменты не предлагаем — модель обязана ответить текстом # На последнем круге инструменты не предлагаем — модель обязана ответить текстом
tools = self._tools.schemas() if use_tools and _round < MAX_TOOL_ROUNDS else None tools = registry.schemas() if use_tools and _round < MAX_TOOL_ROUNDS else None
try: try:
calls, text = self._stream_round(generation, client, messages, tools) calls, text = self._stream_round(generation, client, messages, tools)
except ToolsNotSupportedError as exc: except ToolsNotSupportedError as exc:
@@ -288,7 +414,7 @@ class Assistant(QObject):
break break
messages.append(_assistant_tool_message(text, calls)) messages.append(_assistant_tool_message(text, calls))
for call in calls: for call in calls:
result = self._tools.execute(call.name, call.arguments) result = self._execute_tool_call(generation, registry, call)
log.info("Инструмент %s(%s): %s", call.name, call.arguments, result.content) log.info("Инструмент %s(%s): %s", call.name, call.arguments, result.content)
if generation != self._generation: if generation != self._generation:
return return
@@ -304,6 +430,21 @@ class Assistant(QObject):
else: else:
self._worker_done.emit(generation) self._worker_done.emit(generation)
def _execute_tool_call(
self, generation: int, registry: ToolRegistry, call: ToolCall
) -> ToolResult:
prepared = registry.prepare(call.name, call.arguments)
if isinstance(prepared, ToolResult):
return prepared
tool, arguments = prepared
if not tool.confirm:
return registry.run(tool, arguments)
summary = tool.summary(arguments)
self._worker_confirm.emit(generation, tool.name, json.dumps(arguments, ensure_ascii=False))
return ToolResult(
True, _CONFIRMATION_RESULT.format(summary=summary), f"Ждёт подтверждения: {summary}"
)
def _stream_round( def _stream_round(
self, self,
generation: int, generation: int,
@@ -335,10 +476,28 @@ class Assistant(QObject):
@Slot(str) @Slot(str)
def _on_voice_recognized(self, text: str) -> None: def _on_voice_recognized(self, text: str) -> None:
if text.strip(): if text.strip():
self.send(text) self.send(text, by_voice=True)
else: else:
self.error_occurred.emit("Не удалось разобрать речь") self.error_occurred.emit("Не удалось разобрать речь")
@Slot()
def _on_commands_changed(self) -> None:
self._rebuild_tools()
assert self._commands is not None
for error in self._commands.errors:
self.error_occurred.emit(error)
@Slot(int, str, str)
def _on_worker_confirm(self, generation: int, name: str, arguments_json: str) -> None:
if generation == self._generation and (tool := self._tools.get(name)) is not None:
self._ask_confirmation(tool, json.loads(arguments_json))
@Slot()
def _listen_for_confirmation(self) -> None:
voice = self._voice
if voice is not None and self._pending is not None and self._state is AssistantState.IDLE:
voice.start()
@Slot(int, str) @Slot(int, str)
def _on_worker_chunk(self, generation: int, piece: str) -> None: def _on_worker_chunk(self, generation: int, piece: str) -> None:
if generation == self._generation: if generation == self._generation:
@@ -396,6 +555,9 @@ class Assistant(QObject):
if state is not self._state: if state is not self._state:
self._state = state self._state = state
self.state_changed.emit(state) self.state_changed.emit(state)
if state is AssistantState.IDLE and self._listen_after_reply:
self._listen_after_reply = False
QTimer.singleShot(0, self._listen_for_confirmation)
self._sync_wake_word() self._sync_wake_word()
def _sync_wake_word(self) -> None: def _sync_wake_word(self) -> None:
@@ -421,3 +583,7 @@ def _assistant_tool_message(text: str, calls: list[ToolCall]) -> dict[str, Any]:
for call in calls for call in calls
], ],
} }
def _confirmation_question(summary: str) -> str:
return f"Выполнить: {summary}? Скажите «да» или «нет»."
+153
View File
@@ -0,0 +1,153 @@
# Команды ассистента. Файл перечитывается автоматически после сохранения.
#
# Модель выбирает команду по описанию (description) и сама подставляет параметры.
# Фразы (phrases) выполняются мгновенно, без обращения к модели, если запрос совпал
# с ними целиком: регистр, «ё» и знаки препинания не важны. Во фразе можно указать
# параметр: «громкость {level}».
#
# Поля команды:
# name — латиница, цифры, _ и -
# description — что делает команда (по-русски, для модели)
# phrases — необязательно: точные фразы без LLM
# parameters — необязательно: имя -> {type: string|integer|number|boolean,
# description, enum, minimum, maximum, default, optional}
# confirm — true: спросить «да/нет» перед выполнением
# reply — необязательно: что ответить после выполнения по точной фразе
# action — что сделать, type одно из:
# run — program, args (список), wait (дождаться и вернуть вывод модели),
# timeout_seconds, hidden (без окна), cwd
# open — target: ссылка, файл или папка
# http — url, method, headers, json, timeout_seconds
# keys — keys: сочетание (ctrl+shift+esc) или volume_up, volume_down, volume_mute,
# media_play_pause, media_next, media_previous, media_stop; repeat
#
# В строках действий {параметр} заменяется значением, ${ПЕРЕМЕННАЯ} и %ПЕРЕМЕННАЯ% —
# переменными окружения (удобно для токенов: они не попадают в модель).
# Программы запускаются без командной оболочки: параметр не может превратиться в
# отдельную команду. cmd, PowerShell и .bat/.cmd сами разбирают аргументы, поэтому
# значения параметров с их спецсимволами (& | ; $ кавычки скобки и т. п.) отклоняются.
commands:
- name: media_play_pause
description: Поставить воспроизведение музыки или видео на паузу либо продолжить
phrases: [пауза, продолжи, поставь на паузу, продолжи воспроизведение]
reply: Готово.
action:
type: keys
keys: media_play_pause
- name: media_next
description: Включить следующий трек
phrases: [следующий трек, дальше, следующая песня]
reply: Следующий.
action:
type: keys
keys: media_next
- name: media_previous
description: Включить предыдущий трек
phrases: [предыдущий трек, предыдущая песня]
reply: Предыдущий.
action:
type: keys
keys: media_previous
- name: volume_up
description: Сделать звук громче
phrases: [громче, погромче]
parameters:
steps:
type: integer
description: На сколько шагов (один шаг — 2%)
minimum: 1
maximum: 50
default: 5
reply: Громче.
action:
type: keys
keys: volume_up
repeat: "{steps}"
- name: volume_down
description: Сделать звук тише
phrases: [тише, потише]
parameters:
steps:
type: integer
description: На сколько шагов (один шаг — 2%)
minimum: 1
maximum: 50
default: 5
reply: Тише.
action:
type: keys
keys: volume_down
repeat: "{steps}"
- name: volume_mute
description: Выключить или снова включить звук
phrases: [выключи звук, включи звук]
action:
type: keys
keys: volume_mute
- name: web_search
description: Найти что-нибудь в интернете — открывает поиск в браузере
parameters:
query:
type: string
description: Поисковый запрос
action:
type: open
target: https://www.google.com/search?q={query}
- name: open_downloads
description: Открыть папку «Загрузки»
phrases: [открой загрузки]
reply: Открываю.
action:
type: open
target: "%USERPROFILE%\\Downloads"
- name: lock_computer
description: Заблокировать компьютер
phrases: [заблокируй компьютер]
action:
type: run
program: rundll32.exe
args: [user32.dll,LockWorkStation]
# --- Примеры: раскомментируйте и поправьте под себя
# - name: shutdown_computer
# description: Выключить компьютер через минуту
# phrases: [выключи компьютер]
# confirm: true
# action:
# type: run
# program: shutdown
# args: [/s, /t, "60"]
# - name: disk_space
# description: Узнать, сколько свободного места на дисках
# action:
# type: run
# program: powershell
# args: [-NoProfile, -Command, "Get-PSDrive -PSProvider FileSystem | Format-Table Name, Free, Used"]
# wait: true
# - name: room_light
# description: Включить или выключить свет в комнате (Home Assistant)
# parameters:
# state:
# type: string
# enum: ["on", "off"]
# description: on — включить, off — выключить
# action:
# type: http
# method: POST
# url: http://homeassistant.local:8123/api/services/light/turn_{state}
# headers:
# Authorization: Bearer ${HA_TOKEN}
# json:
# entity_id: light.room
+14 -7
View File
@@ -70,17 +70,24 @@ def parse_hotkey(spec: str) -> tuple[int, int]:
raise HotkeyError(f"Неизвестный модификатор '{name}' в горячей клавише '{spec}'") raise HotkeyError(f"Неизвестный модификатор '{name}' в горячей клавише '{spec}'")
modifiers |= _MODIFIERS[name] modifiers |= _MODIFIERS[name]
if len(key) == 1 and key.isascii() and key.isalnum(): virtual_key = key_code(key)
virtual_key = ord(key.upper()) if virtual_key is None:
elif key in _NAMED_KEYS:
virtual_key = _NAMED_KEYS[key]
elif match := _FUNCTION_KEY.fullmatch(key):
virtual_key = 0x70 + int(match.group(1)) - 1
else:
raise HotkeyError(f"Неизвестная клавиша '{key}' в горячей клавише '{spec}'") raise HotkeyError(f"Неизвестная клавиша '{key}' в горячей клавише '{spec}'")
return modifiers, virtual_key return modifiers, virtual_key
def key_code(key: str) -> int | None:
"""Виртуальный код обычной клавиши: буква, цифра, f1–f24 или имя (space, enter…)."""
key = key.strip().lower()
if len(key) == 1 and key.isascii() and key.isalnum():
return ord(key.upper())
if key in _NAMED_KEYS:
return _NAMED_KEYS[key]
if match := _FUNCTION_KEY.fullmatch(key):
return 0x70 + int(match.group(1)) - 1
return None
class GlobalHotkey(QObject): class GlobalHotkey(QObject):
"""Слушает клавишу в отдельном потоке с собственной очередью сообщений Windows.""" """Слушает клавишу в отдельном потоке с собственной очередью сообщений Windows."""
+28
View File
@@ -28,6 +28,13 @@ class Tool:
description: str description: str
parameters: dict[str, Any] # JSON Schema объекта аргументов parameters: dict[str, Any] # JSON Schema объекта аргументов
handler: Callable[[dict[str, Any]], ToolResult] handler: Callable[[dict[str, Any]], ToolResult]
# Выполнять только после явного «да» пользователя
confirm: bool = False
# Человекочитаемое описание вызова для вопроса-подтверждения и журнала
describe: Callable[[dict[str, Any]], str] | None = None
def summary(self, arguments: dict[str, Any]) -> str:
return self.describe(arguments) if self.describe else self.description
def schema(self) -> dict[str, Any]: def schema(self) -> dict[str, Any]:
return { return {
@@ -55,7 +62,17 @@ class ToolRegistry:
def schemas(self) -> list[dict[str, Any]]: def schemas(self) -> list[dict[str, Any]]:
return [tool.schema() for tool in self._tools.values()] return [tool.schema() for tool in self._tools.values()]
def get(self, name: str) -> Tool | None:
return self._tools.get(name)
def execute(self, name: str, arguments_json: str) -> ToolResult: def execute(self, name: str, arguments_json: str) -> ToolResult:
prepared = self.prepare(name, arguments_json)
if isinstance(prepared, ToolResult):
return prepared
return self.run(*prepared)
def prepare(self, name: str, arguments_json: str) -> tuple[Tool, dict[str, Any]] | ToolResult:
"""Находит инструмент и проверяет аргументы; ToolResult — описание ошибки."""
tool = self._tools.get(name) tool = self._tools.get(name)
if tool is None: if tool is None:
return ToolResult(False, f"Инструмента {name} не существует", f"Неизвестный инструмент {name}") return ToolResult(False, f"Инструмента {name} не существует", f"Неизвестный инструмент {name}")
@@ -70,6 +87,11 @@ class ToolRegistry:
if errors: if errors:
message = "; ".join(errors) message = "; ".join(errors)
return ToolResult(False, f"Некорректные аргументы: {message}", f"{name}: {message}") return ToolResult(False, f"Некорректные аргументы: {message}", f"{name}: {message}")
return tool, arguments
@staticmethod
def run(tool: Tool, arguments: dict[str, Any]) -> ToolResult:
name = tool.name
try: try:
return tool.handler(arguments) return tool.handler(arguments)
except ToolError as exc: except ToolError as exc:
@@ -104,6 +126,12 @@ def validate_arguments(
allowed = ", ".join(str(option) for option in spec["enum"]) allowed = ", ".join(str(option) for option in spec["enum"])
errors.append(f"{name}: недопустимое значение {converted!r} (допустимы: {allowed})") errors.append(f"{name}: недопустимое значение {converted!r} (допустимы: {allowed})")
continue continue
if "minimum" in spec and converted < spec["minimum"]:
errors.append(f"{name}: значение меньше {spec['minimum']}")
continue
if "maximum" in spec and converted > spec["maximum"]:
errors.append(f"{name}: значение больше {spec['maximum']}")
continue
result[name] = converted result[name] = converted
return result, errors return result, errors
+67 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import html
import logging import logging
import threading import threading
from pathlib import Path from pathlib import Path
@@ -28,12 +29,14 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QTabWidget, QTabWidget,
QTextBrowser,
QToolButton, QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from agr_assistent import APP_NAME, system from agr_assistent import APP_NAME, system
from agr_assistent.commands.catalog import CommandCatalog
from agr_assistent.config import ConfigError, data_dir, expand_env, get_value from agr_assistent.config import ConfigError, data_dir, expand_env, get_value
from agr_assistent.core.memory import SOURCE_USER, MemoryStore from agr_assistent.core.memory import SOURCE_USER, MemoryStore
from agr_assistent.core.settings import Settings, needs_restart from agr_assistent.core.settings import Settings, needs_restart
@@ -41,6 +44,7 @@ from agr_assistent.core.settings import Settings, needs_restart
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_AUTO_FACT_COLOR = "#757575" _AUTO_FACT_COLOR = "#757575"
_ERROR_COLOR = "#e53935"
_TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"] _TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"]
_STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"] _STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"]
@@ -70,11 +74,16 @@ class _ModelListLoader(QObject):
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
def __init__( def __init__(
self, settings: Settings, memory: MemoryStore | None = None, parent: QWidget | None = None self,
settings: Settings,
memory: MemoryStore | None = None,
commands: CommandCatalog | None = None,
parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._settings = settings self._settings = settings
self._memory = memory self._memory = memory
self._commands = commands
self._raw = settings.raw() self._raw = settings.raw()
self._provider_edits: dict[str, dict[str, str]] = { self._provider_edits: dict[str, dict[str, str]] = {
name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")} name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")}
@@ -90,6 +99,7 @@ class SettingsDialog(QDialog):
tabs.addTab(self._build_speech_tab(), "Озвучка") tabs.addTab(self._build_speech_tab(), "Озвучка")
tabs.addTab(self._build_voice_tab(), "Голосовой ввод") tabs.addTab(self._build_voice_tab(), "Голосовой ввод")
tabs.addTab(self._build_memory_tab(), "Память") tabs.addTab(self._build_memory_tab(), "Память")
tabs.addTab(self._build_commands_tab(), "Команды")
tabs.addTab(self._build_general_tab(), "Общие") tabs.addTab(self._build_general_tab(), "Общие")
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
@@ -255,6 +265,62 @@ class SettingsDialog(QDialog):
self._reload_facts() self._reload_facts()
return page return page
def _build_commands_tab(self) -> QWidget:
self._command_list = QTextBrowser()
self._command_errors = QLabel()
self._command_errors.setWordWrap(True)
self._command_errors.setStyleSheet(f"color: {_ERROR_COLOR}")
self._command_errors.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
open_file = QPushButton("Открыть commands.yaml")
reload_button = QPushButton("Перечитать")
buttons = QHBoxLayout()
buttons.addWidget(open_file)
buttons.addWidget(reload_button)
buttons.addStretch()
hint = QLabel(
"Команды описываются в файле commands.yaml — в нём есть примеры и описание всех полей. "
"После сохранения файл перечитывается автоматически."
)
hint.setWordWrap(True)
page = QWidget()
layout = QVBoxLayout(page)
layout.addWidget(hint)
layout.addWidget(self._command_list, 1)
layout.addWidget(self._command_errors)
layout.addLayout(buttons)
if self._commands is None:
page.setEnabled(False)
return page
catalog = self._commands
open_file.clicked.connect(lambda: _open_path(catalog.path))
reload_button.clicked.connect(catalog.reload)
catalog.changed.connect(self._show_commands)
self._show_commands()
return page
def _show_commands(self) -> None:
assert self._commands is not None
blocks = []
for command in self._commands.commands:
details = [f"{command.name}, {command.action_type}"]
if command.confirm:
details.append("с подтверждением")
lines = [
f"<b>{html.escape(command.description)}</b> "
f"<span style='color:{_AUTO_FACT_COLOR}'>({html.escape(', '.join(details))})</span>"
]
if command.phrases:
phrases = ", ".join(f"«{phrase}»" for phrase in command.phrases)
lines.append(f"фразы: {html.escape(phrases)}")
blocks.append(f"<p>{'<br>'.join(lines)}</p>")
self._command_list.setHtml("".join(blocks) or "<p>Команд пока нет.</p>")
self._command_errors.setText("\n".join(self._commands.errors))
self._command_errors.setVisible(bool(self._commands.errors))
def _build_general_tab(self) -> QWidget: def _build_general_tab(self) -> QWidget:
self._start_minimized = QCheckBox("Запускаться свёрнутым в трей") self._start_minimized = QCheckBox("Запускаться свёрнутым в трей")
self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized"))) self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized")))
+206 -3
View File
@@ -6,9 +6,11 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication, QObject, Signal
from PySide6.QtWidgets import QApplication 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.config import LLMConfig, ProviderConfig
from agr_assistent.core.assistant import Assistant from agr_assistent.core.assistant import Assistant
from agr_assistent.core.memory import MemoryStore from agr_assistent.core.memory import MemoryStore
@@ -52,7 +54,14 @@ def memory(tmp_path: Path) -> MemoryStore:
store.close() 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( config = LLMConfig(
provider="fake", provider="fake",
providers={"fake": ProviderConfig("fake", server.base_url, "key", "fake-model")}, 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, timeout_seconds=10,
) )
speaker = Speaker(_SilentEngine(), _SilentPlayer(), enabled=False) 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( def test_remember_request_runs_tool_and_answers(
@@ -139,3 +155,190 @@ def test_auto_save_can_be_disabled(
_wait_until(lambda: bool(finished)) _wait_until(lambda: bool(finished))
assert "requested_by_user=false" not in fake_llm.requests[0]["messages"][0]["content"] 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