Compare commits

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:38:57 +03:00
mrleo1nidandClaude Opus 5 84d8aaa848 Этап 7: долговременная память
- Факты о пользователе в SQLite, в системном промпте с номерами
- Инструменты remember / update_memory / forget, автоматическое запоминание отключается
- Цикл вызова инструментов со стримингом (до 5 кругов), проверка и приведение аргументов
- Откат без инструментов для моделей, которые их не поддерживают, с одним предупреждением
- Действия в журнале, вкладка «Память» в настройках
- Фейковый OpenAI-совместимый сервер для тестов, тесты полного цикла через Assistant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:27:16 +03:00
mrleo1nidandClaude Opus 5 542389c0f9 Этап 6: отдельные запросы вместо диалога
- Каждый запрос уходит в модель отдельно: системный промпт, текущие дата и время, запрос
- Окно уточнений: цепочка обменов с паузами меньше follow_up_seconds (по умолчанию 120 с),
  не больше трёх последних
- Окно чата стало журналом с временем и пометкой уточнений, «Очистить журнал» вместо «Новый диалог»
- Настройка окна уточнений в окне настроек
- Тесты контекста уточнений и сборки запроса

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:19:33 +03:00
mrleo1nidandClaude Opus 5 20a666e0d4 Этап 5: окно настроек и сборка exe
- Окно настроек: модель и провайдер (со списком моделей с сервера), озвучка,
  голосовой ввод, слово активации, автозапуск с Windows
- Сохранение в config.yaml через ruamel.yaml с комментариями и ссылками ${VAR},
  проверка значений до записи; переключатели трея тоже сохраняются
- Настройки LLM и переключатели применяются на лету, для остального — перезапуск
- Один экземпляр приложения, лог в файл, аргументы --config и --wait-pid, русские диалоги Qt
- Сборка PyInstaller (onedir) с библиотеками CUDA, иконка приложения
- Тесты сохранения настроек и окна настроек

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:10:31 +03:00
mrleo1nidandClaude Opus 5 a719980d74 Этап 4: слово активации через Vosk
- Фоновое прослушивание микрофона маленькой моделью Vosk, фразы настраиваются
- Свободное распознавание + требование устойчивой гипотезы: грамматика давала массу ложных срабатываний
- Пауза прослушивания, пока ассистент слушает команду, думает или говорит
- Модель скачивается с зеркала HuggingFace, проверка слов по словарю модели
- Переключатель в меню трея
- Тесты поиска фразы и фонового слушателя

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 03:56:32 +03:00
mrleo1nidandClaude Opus 5 513d010c19 Этап 3: голосовой ввод по горячей клавише
- Глобальная горячая клавиша через RegisterHotKey (по умолчанию Win+Alt+Space)
- Запись с микрофона, конец фразы по паузе через Silero VAD, звуковые сигналы
- Распознавание faster-whisper large-v3-turbo на GPU, CUDA-библиотеки из pip-пакетов nvidia-*
- Состояния «слушаю» и «распознаю», кнопка «Говорить» в чате и пункт в трее
- Голосовая команда прерывает текущий ответ и озвучку
- Тесты горячей клавиши, детектора конца фразы и сеанса голосового ввода

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 03:34:09 +03:00
mrleo1nidandClaude Opus 5 fec52dd1c2 Этап 2: озвучка ответов через Silero
- Потоковая озвучка: ответ режется на фразы, синтез и воспроизведение в отдельных потоках
- Подготовка текста: числа словами, без markdown, ссылок и блоков кода
- Модель Silero скачивается в LOCALAPPDATA и прогревается при старте
- Состояние «говорю», переключатель озвучки в трее, «Стоп» прерывает речь
- Тесты нарезки текста и оркестратора озвучки

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 03:20:07 +03:00
mrleo1nidandClaude Opus 5 7933613234 Этап 1: трей, окно чата и текстовый диалог с LLM
- PySide6: значок в трее с индикацией состояния, окно чата со стримингом и markdown
- Единый OpenAI-совместимый клиент (Ollama, LM Studio, OpenRouter)
- config.yaml с автосозданием, слиянием с умолчаниями и подстановкой ${ENV}
- Тесты конфига и сборки сообщений

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 03:04:21 +03:00
68 changed files with 9285 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
build/
dist/
# Локальный конфиг может содержать API-ключи
config.yaml
commands.yaml
+143
View File
@@ -1,2 +1,145 @@
# agr-assistent
Минимальный AI-ассистент, живущий в системном трее.
- LLM: локальные модели (Ollama, LM Studio, llama.cpp) или OpenRouter — через единый OpenAI-совместимый API
- Каждый запрос отдельный — без бесконечной истории диалога; короткие уточнения («а завтра?»)
в течение пары минут после ответа видят предыдущие вопросы
- Журнал запросов и ответов со стримингом
- Долговременная память: «запомни…», «забудь…», а устойчивые факты о вас модель сохраняет сама
- Настраиваемые команды: запуск программ и скриптов, ссылки и папки, HTTP-запросы (Home Assistant),
клавиши и медиа — через модель или мгновенно по точной фразе, опасные — с подтверждением
- Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются
- Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD)
- Слово активации («ассистент») через Vosk — без нажатия клавиш
## Запуск
Нужен [uv](https://docs.astral.sh/uv/).
```bash
uv sync
uv run agr-assistent
```
При первом запуске рядом создаётся `config.yaml` (путь можно переопределить переменной
`AGR_ASSISTENT_CONFIG`). В нём выбирается провайдер и модель:
```yaml
llm:
provider: openrouter
providers:
openrouter:
api_key: ${OPENROUTER_API_KEY} # или ключ напрямую
model: openai/gpt-4o-mini
```
Для Ollama достаточно запустить сервер и скачать модель: `ollama pull qwen2.5:7b`.
Модель Silero (~145 МБ) скачивается при первом запуске в `%LOCALAPPDATA%\agr-assistent\models`.
Голос и модель задаются в секции `tts` конфига; озвучку можно выключить в меню значка.
Silero читает только кириллицу: числа переводятся в слова, латиница пропускается.
### Память
Скажите «Запомни, что у меня Škoda Octavia» или «Забудь про машину». Если включено
автоматическое запоминание (`memory.auto_save`), модель сама сохраняет устойчивые факты:
имя, близких, технику, предпочтения. Факты хранятся локально в
`%LOCALAPPDATA%\agr-assistent\memory.sqlite3` и добавляются к каждому запросу; посмотреть,
исправить и удалить их можно в настройках на вкладке «Память».
Память работает через вызов инструментов, поэтому модель должна их поддерживать
(например, `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`), дождитесь короткого сигнала и говорите —
запись закончится сама после паузы, или нажмите клавишу ещё раз. Если ассистент в этот момент
отвечает, он замолкает и слушает.
Распознаёт faster-whisper (`large-v3-turbo`, ~1.6 ГБ, скачивается при первом запуске).
При наличии видеокарты NVIDIA используется она: библиотеки CUDA ставятся pip-пакетами
`nvidia-cublas-cu12` и `nvidia-cudnn-cu12`, отдельно устанавливать CUDA Toolkit не нужно.
Без видеокарты распознавание идёт на CPU — тогда лучше выбрать модель `small` или `medium`.
### Слово активации
Включается пунктом меню значка или `wake_word.enabled: true` в конфиге. Скажите «Ассистент»,
дождитесь сигнала и произнесите команду. Фразы задаются в `wake_word.phrases`, все слова должны
быть в словаре модели — иначе приложение сообщит, каких слов не хватает.
Пока ассистент слушает команду, думает или говорит, слово активации не отслеживается:
микрофон не занят дважды, и ассистент не реагирует на собственный голос. Перебить его во время
ответа можно горячей клавишей.
Используется маленькая модель Vosk (~45 МБ) со свободным распознаванием: в простое она почти
не нагружает процессор. Слова, начинающиеся с ключевого («ассистентка»), могут давать ложные
срабатывания.
### Настройки
Окно настроек открывается из меню значка или кнопкой «Настройки» в чате: провайдер и модель
(список моделей подгружается с сервера), голос, горячая клавиша, модель Whisper, слово активации,
автозапуск с Windows. Изменения сохраняются в `config.yaml` с сохранением комментариев;
ключи вида `${ПЕРЕМЕННАЯ}` остаются ссылками. Настройки модели и переключатели применяются сразу,
для смены моделей, голоса и горячей клавиши приложение предложит перезапуститься.
Закрытие окна сворачивает приложение в трей. Клик по значку открывает чат. Повторный запуск
не создаёт второй экземпляр, а показывает окно уже запущенного.
Лог пишется в `%LOCALAPPDATA%\agr-assistent\logs`. Путь к конфигу можно передать аргументом:
`agr-assistent --config D:\path\config.yaml`.
## Сборка exe
```bash
uv run pyinstaller agr-assistent.spec --noconfirm
```
Результат — папка `dist\agr-assistent` (~2.8 ГБ, из них ~2 ГБ — библиотеки CUDA) с
`agr-assistent.exe`; её можно переносить целиком. `config.yaml` создаётся рядом с exe, модели
скачиваются в `%LOCALAPPDATA%\agr-assistent\models` при первом запуске.
## Разработка
```bash
uv run pytest
```
+47
View File
@@ -0,0 +1,47 @@
# -*- mode: python ; coding: utf-8 -*-
# Сборка: uv run pyinstaller agr-assistent.spec --noconfirm
# Результат: dist/agr-assistent/agr-assistent.exe (папка целиком переносима)
from pathlib import Path
import nvidia
from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules
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("faster_whisper")
binaries = collect_dynamic_libs("vosk") + collect_dynamic_libs("ctranslate2")
# cuBLAS и cuDNN для распознавания на видеокарте; путь к ним добавляет stt/whisper.py
for package_dir in nvidia.__path__:
for dll in Path(package_dir).glob("*/bin/*.dll"):
binaries.append((str(dll), f"nvidia/{dll.parent.parent.name}/bin"))
# num2words подключает языковые модули динамически
hiddenimports = collect_submodules("num2words")
a = Analysis(
["packaging/launcher.py"],
pathex=["src"],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
excludes=["tkinter", "matplotlib", "IPython", "pytest"],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="agr-assistent",
icon="packaging/icon.ico",
console=False,
upx=False,
)
coll = COLLECT(exe, a.binaries, a.datas, name="agr-assistent", upx=False)
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+10
View File
@@ -0,0 +1,10 @@
"""Точка входа для PyInstaller."""
import multiprocessing
import sys
from agr_assistent.app import main
if __name__ == "__main__":
multiprocessing.freeze_support()
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
[project]
name = "agr-assistent"
version = "0.1.0"
description = "Минимальный голосовой AI-ассистент в системном трее"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"faster-whisper>=1.2.1",
"num2words>=0.5.14",
"numpy>=2.4.6",
"nvidia-cublas-cu12>=12.9.2.10",
"nvidia-cudnn-cu12>=9.26.0.51",
"openai>=3.14.1",
"pyside6>=6.11.2",
"pyyaml>=6.0.3",
"ruamel-yaml>=0.19.1",
"silero-vad>=6.2.1",
"sounddevice>=0.5.6",
"torch>=2.14.0",
"vosk>=0.3.45",
]
[project.scripts]
agr-assistent = "agr_assistent.app:main"
[dependency-groups]
dev = [
"pyinstaller>=6.22.3",
"pytest>=9.1.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/agr_assistent"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
"""agr-assistent — минимальный AI-ассистент в системном трее."""
APP_NAME = "agr-assistent"
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
import sys
from agr_assistent.app import main
sys.exit(main())
+219
View File
@@ -0,0 +1,219 @@
"""Точка входа: Qt-приложение, живущее в системном трее."""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import threading
from logging.handlers import RotatingFileHandler
from pathlib import Path
from PySide6.QtCore import QLibraryInfo, QLocale, Qt, QTranslator
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from agr_assistent import APP_NAME, __version__, system
from agr_assistent.audio.player import AudioPlayer
from agr_assistent.audio.recorder import SpeechRecorder
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.core.assistant import Assistant
from agr_assistent.core.memory import MemoryStore
from agr_assistent.core.settings import Settings
from agr_assistent.core.speech import Speaker
from agr_assistent.core.voice import VoiceInput
from agr_assistent.core.wake import WakeWordListener
from agr_assistent.hotkey import GlobalHotkey, HotkeyError
from agr_assistent.single_instance import SingleInstance
from agr_assistent.stt.whisper import WhisperSTT
from agr_assistent.tts.silero import SileroTTS
from agr_assistent.ui.chat_window import ChatWindow
from agr_assistent.ui.icons import state_icon
from agr_assistent.ui.settings_dialog import SettingsDialog
from agr_assistent.ui.tray import TrayIcon
log = logging.getLogger(__name__)
_WAIT_PREVIOUS_INSTANCE_SECONDS = 15
_LOG_FILE_BYTES = 1_000_000
_LOG_FILE_COUNT = 3
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog=APP_NAME, description="AI-ассистент в системном трее")
parser.add_argument("--config", type=Path, help="путь к config.yaml")
parser.add_argument("--wait-pid", type=int, help=argparse.SUPPRESS)
_ensure_standard_streams()
args, qt_args = parser.parse_known_args(sys.argv[1:] if argv is None else argv)
_setup_logging()
log.info("%s %s", APP_NAME, __version__)
# Ctrl+C в терминале завершает приложение
signal.signal(signal.SIGINT, signal.SIG_DFL)
_set_windows_app_id()
if args.wait_pid:
system.wait_for_process_exit(args.wait_pid, _WAIT_PREVIOUS_INSTANCE_SECONDS)
app = QApplication([sys.argv[0], *qt_args])
app.setApplicationName(APP_NAME)
app.setQuitOnLastWindowClosed(False)
# Стандартные кнопки и диалоги Qt на языке системы
translator = QTranslator(app)
translations = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if translator.load(QLocale.system(), "qtbase", "_", translations):
app.installTranslator(translator)
instance = SingleInstance(app)
if not instance.acquire():
log.info("Приложение уже запущено — показываю его окно")
return 0
try:
config = load_config(args.config.resolve() if args.config else None)
except ConfigError as exc:
QMessageBox.critical(None, APP_NAME, str(exc))
return 1
log.info("Конфиг: %s", config.path)
if not QSystemTrayIcon.isSystemTrayAvailable():
QMessageBox.critical(None, APP_NAME, "Системный трей недоступен")
return 1
settings = Settings(config, app)
models_dir = data_dir() / "models"
speaker = Speaker(
SileroTTS(config.tts, models_dir), AudioPlayer(), enabled=config.tts.enabled
)
if config.tts.enabled:
speaker.warm_up()
voice = None
wake_word = None
if config.voice.enabled:
voice = VoiceInput(
SpeechRecorder(config.voice),
WhisperSTT(config.stt, models_dir),
sound_cues=config.voice.sound_cues,
)
voice.warm_up()
# Создаётся всегда, чтобы слово активации можно было включить из трея;
# модель Vosk загружается только при включении
wake_word = WakeWordListener(
VoskWakeWord(config.wake_word, models_dir), enabled=config.wake_word.enabled
)
memory = MemoryStore(data_dir() / "memory.sqlite3")
app.aboutToQuit.connect(memory.close)
commands = CommandCatalog(config.path.parent / COMMANDS_FILE_NAME, app)
commands.ensure_file()
assistant = Assistant(
config.llm,
speaker,
voice,
wake_word,
memory=memory,
memory_auto_save=config.memory.auto_save,
commands=commands,
)
app.setWindowIcon(state_icon(assistant.state))
window = ChatWindow(assistant)
instance.activated.connect(window.show_and_raise)
for error in commands.errors:
assistant.error_occurred.emit(error)
dialog: SettingsDialog | None = None
def forget_dialog() -> None:
nonlocal dialog
dialog = None
def open_settings() -> None:
nonlocal dialog
if dialog is None:
dialog = SettingsDialog(settings, memory, commands, window)
# Окно удаляется при закрытии, чтобы не копить подписки на каталог команд
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.destroyed.connect(forget_dialog)
dialog.show()
dialog.raise_()
dialog.activateWindow()
def apply_settings(new_config: AppConfig) -> None:
assistant.update_llm_config(new_config.llm)
assistant.set_speech_enabled(new_config.tts.enabled)
assistant.set_wake_word_enabled(new_config.wake_word.enabled)
assistant.set_memory_auto_save(new_config.memory.auto_save)
settings.changed.connect(apply_settings)
window.settings_requested.connect(open_settings)
hotkey_spec = config.voice.hotkey if voice is not None else ""
if hotkey_spec:
try:
hotkey = GlobalHotkey(hotkey_spec, app)
except HotkeyError as exc:
assistant.error_occurred.emit(str(exc))
hotkey_spec = ""
else:
hotkey.activated.connect(assistant.toggle_listening)
hotkey.failed.connect(assistant.error_occurred)
app.aboutToQuit.connect(hotkey.stop)
hotkey.start()
tray = TrayIcon(assistant, window, settings, open_settings, hotkey_spec)
tray.show()
if config.ui.start_minimized:
tray.showMessage(APP_NAME, "Работаю в трее")
else:
window.show_and_raise()
return app.exec()
def _ensure_standard_streams() -> None:
"""В сборке без консоли stdout/stderr равны None, а библиотеки (tqdm, argparse) пишут в них."""
if sys.stdout is None:
sys.stdout = open(os.devnull, "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w", encoding="utf-8")
def _setup_logging() -> None:
log_dir = data_dir() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
handlers: list[logging.Handler] = [
RotatingFileHandler(
log_dir / f"{APP_NAME}.log",
maxBytes=_LOG_FILE_BYTES,
backupCount=_LOG_FILE_COUNT,
encoding="utf-8",
)
]
if not getattr(sys, "frozen", False):
handlers.append(logging.StreamHandler())
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=handlers,
)
logging.getLogger("httpx").setLevel(logging.WARNING)
def log_uncaught(exc_type, exc_value, exc_traceback) -> None: # type: ignore[no-untyped-def]
log.critical("Необработанное исключение", exc_info=(exc_type, exc_value, exc_traceback))
sys.excepthook = log_uncaught
threading.excepthook = lambda hook_args: log_uncaught(
hook_args.exc_type, hook_args.exc_value, hook_args.exc_traceback
)
def _set_windows_app_id() -> None:
"""Своя группа на панели задач и корректное имя в уведомлениях Windows."""
if sys.platform == "win32":
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_NAME)
View File
+29
View File
@@ -0,0 +1,29 @@
"""Короткие звуковые сигналы начала и конца записи."""
from __future__ import annotations
import numpy as np
import sounddevice as sd
_SAMPLE_RATE = 24000
_DURATION_SECONDS = 0.09
_FADE_SECONDS = 0.01
_VOLUME = 0.25
START_FREQUENCY = 880.0
STOP_FREQUENCY = 587.0
def play_tone(frequency: float) -> None:
"""Блокирует до конца сигнала, чтобы он не попал в запись."""
samples = int(_SAMPLE_RATE * _DURATION_SECONDS)
t = np.arange(samples) / _SAMPLE_RATE
tone = np.sin(2 * np.pi * frequency * t) * _VOLUME
fade = int(_SAMPLE_RATE * _FADE_SECONDS)
envelope = np.ones(samples)
envelope[:fade] = np.linspace(0, 1, fade)
envelope[-fade:] = np.linspace(1, 0, fade)
sd.play((tone * envelope).astype(np.float32), _SAMPLE_RATE)
sd.wait()
+53
View File
@@ -0,0 +1,53 @@
"""Воспроизведение звука с возможностью быстро прервать."""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
import sounddevice as sd
# Как часто проверяем, не пора ли остановиться
_BLOCK_SECONDS = 0.05
class AudioPlayer:
"""Не потокобезопасен: все методы вызываются из одного потока воспроизведения."""
def __init__(self) -> None:
self._stream: sd.OutputStream | None = None
def play(
self, audio: np.ndarray, sample_rate: int, should_continue: Callable[[], bool]
) -> None:
"""Блокирует до конца фрагмента; если should_continue() вернул False — обрывает звук."""
stream = self._open(sample_rate)
samples = np.asarray(audio, dtype=np.float32).reshape(-1, 1)
block = max(1, int(sample_rate * _BLOCK_SECONDS))
for start in range(0, len(samples), block):
if not should_continue():
self.abort()
return
stream.write(samples[start : start + block])
def finish(self) -> None:
"""Дожидается, пока доиграет буфер, и освобождает устройство."""
if self._stream is not None:
stream, self._stream = self._stream, None
stream.stop()
stream.close()
def abort(self) -> None:
"""Немедленно глушит звук и освобождает устройство."""
if self._stream is not None:
stream, self._stream = self._stream, None
stream.abort()
stream.close()
def _open(self, sample_rate: int) -> sd.OutputStream:
if self._stream is not None and self._stream.samplerate != sample_rate:
self.finish()
if self._stream is None:
self._stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32")
self._stream.start()
return self._stream
+113
View File
@@ -0,0 +1,113 @@
"""Запись фразы с микрофона: детектор речи сам определяет, когда человек замолчал."""
from __future__ import annotations
import threading
from collections.abc import Callable
from enum import Enum
from typing import Any
import numpy as np
import sounddevice as sd
from agr_assistent.config import VoiceConfig
SAMPLE_RATE = 16000
# Silero VAD принимает окна ровно по 512 отсчётов при 16 кГц
_VAD_WINDOW = 512
class UtteranceStatus(Enum):
CONTINUE = "continue"
COMPLETE = "complete"
NO_SPEECH = "no_speech"
class UtteranceDetector:
"""По вероятностям речи от VAD решает, закончилась ли фраза."""
def __init__(
self,
*,
threshold: float,
start_timeout_seconds: float,
silence_seconds: float,
max_seconds: float,
sample_rate: int = SAMPLE_RATE,
) -> None:
self._threshold = threshold
self._start_timeout = int(start_timeout_seconds * sample_rate)
self._silence_limit = int(silence_seconds * sample_rate)
self._max = int(max_seconds * sample_rate)
self._total = 0
self._silence = 0
self._speech_detected = False
@property
def speech_detected(self) -> bool:
return self._speech_detected
def update(self, speech_probability: float, samples: int) -> UtteranceStatus:
self._total += samples
if speech_probability >= self._threshold:
self._speech_detected = True
self._silence = 0
elif self._speech_detected:
self._silence += samples
if self._speech_detected and self._silence >= self._silence_limit:
return UtteranceStatus.COMPLETE
if self._total >= self._max:
return UtteranceStatus.COMPLETE if self._speech_detected else UtteranceStatus.NO_SPEECH
if not self._speech_detected and self._total >= self._start_timeout:
return UtteranceStatus.NO_SPEECH
return UtteranceStatus.CONTINUE
class SpeechRecorder:
def __init__(self, config: VoiceConfig) -> None:
self._config = config
self._vad: Any = None
self._load_lock = threading.Lock()
# Отменённая запись может ещё доигрывать последние миллисекунды, когда начинается новая
self._record_lock = threading.Lock()
def load(self) -> None:
with self._load_lock:
if self._vad is None:
from silero_vad import load_silero_vad
self._vad = load_silero_vad()
def record(self, should_stop: Callable[[], bool]) -> np.ndarray | None:
"""Записывает одну фразу; None — речи не было или запись прервали до её начала."""
self.load()
import torch
config = self._config
detector = UtteranceDetector(
threshold=config.vad_threshold,
start_timeout_seconds=config.start_timeout_seconds,
silence_seconds=config.silence_seconds,
max_seconds=config.max_seconds,
)
chunks: list[np.ndarray] = []
with self._record_lock, sd.InputStream(
samplerate=SAMPLE_RATE, channels=1, dtype="float32", blocksize=_VAD_WINDOW
) as stream:
self._vad.reset_states()
while not should_stop():
block, _overflowed = stream.read(_VAD_WINDOW)
window = np.ascontiguousarray(block[:, 0])
chunks.append(window)
with torch.inference_mode():
probability = self._vad(torch.from_numpy(window), SAMPLE_RATE).item()
status = detector.update(probability, len(window))
if status is UtteranceStatus.NO_SPEECH:
return None
if status is UtteranceStatus.COMPLETE:
break
if not detector.speech_detected:
return None
return np.concatenate(chunks)
+171
View File
@@ -0,0 +1,171 @@
"""Распознавание слова активации маленькой моделью Vosk."""
from __future__ import annotations
import json
import logging
import re
import shutil
import tempfile
import threading
import urllib.request
import zipfile
from pathlib import Path
from typing import Any
from agr_assistent.config import WakeWordConfig
log = logging.getLogger(__name__)
SAMPLE_RATE = 16000
# Зеркало на HuggingFace заметно быстрее официального сайта
_MODEL_URLS = (
"https://huggingface.co/rhasspy/vosk-models/resolve/main/{language}/{model}.zip",
"https://alphacephei.com/vosk/models/{model}.zip",
)
_MODEL_LANGUAGE = re.compile(r"vosk-model-(?:small-)?([a-z]{2})")
# Сколько частичных результатов подряд должны содержать фразу.
# Отсекает гипотезы, которые мелькают на долю секунды и тут же исправляются
REQUIRED_PARTIAL_STREAK = 2
class WakeWordError(Exception):
"""Ошибка подготовки слова активации с понятным пользователю текстом."""
def normalize_phrase(phrase: str) -> tuple[str, ...]:
return tuple(phrase.lower().replace(",", " ").split())
def contains_phrase(words: list[str], phrase: tuple[str, ...]) -> bool:
size = len(phrase)
return any(tuple(words[i : i + size]) == phrase for i in range(len(words) - size + 1))
class WakeWordMatcher:
"""Ищет ключевую фразу в потоке гипотез распознавателя."""
def __init__(
self, phrases: list[tuple[str, ...]], required_streak: int = REQUIRED_PARTIAL_STREAK
) -> None:
self._phrases = phrases
self._required_streak = required_streak
self._streak = 0
def update_partial(self, text: str) -> bool:
if self._matches(text):
self._streak += 1
else:
self._streak = 0
return self._streak >= self._required_streak
def update_final(self, text: str) -> bool:
self._streak = 0
return self._matches(text)
def reset(self) -> None:
self._streak = 0
def _matches(self, text: str) -> bool:
words = text.split()
return any(contains_phrase(words, phrase) for phrase in self._phrases)
class WakeWordSession:
"""Один непрерывный отрезок прослушивания микрофона."""
def __init__(self, model: Any, phrases: list[tuple[str, ...]]) -> None:
import vosk
self._recognizer = vosk.KaldiRecognizer(model, SAMPLE_RATE)
self._matcher = WakeWordMatcher(phrases)
def accept(self, pcm16: bytes) -> bool:
"""Принимает кусок звука (int16, 16 кГц); True — прозвучала ключевая фраза."""
if self._recognizer.AcceptWaveform(pcm16):
text = json.loads(self._recognizer.Result())["text"]
detected = self._matcher.update_final(text)
else:
text = json.loads(self._recognizer.PartialResult())["partial"]
detected = self._matcher.update_partial(text)
if detected:
log.info("Слово активации: «%s»", text)
self._recognizer.Reset()
self._matcher.reset()
return detected
class VoskWakeWord:
def __init__(self, config: WakeWordConfig, models_dir: Path) -> None:
self._config = config
self._models_dir = models_dir / "vosk"
self._phrases = [normalize_phrase(phrase) for phrase in config.phrases]
self._model: Any = None
self._lock = threading.Lock()
@property
def phrases(self) -> list[str]:
return [" ".join(phrase) for phrase in self._phrases]
def load(self) -> None:
with self._lock:
if self._model is not None:
return
model_path = self._models_dir / self._config.model
if not model_path.is_dir():
self._download(model_path)
import vosk
vosk.SetLogLevel(-1)
try:
model = vosk.Model(str(model_path))
except Exception as exc:
raise WakeWordError(f"Не удалось загрузить модель Vosk {model_path}: {exc}") from exc
missing = sorted(
{
word
for phrase in self._phrases
for word in phrase
if model.vosk_model_find_word(word) < 0
}
)
if missing:
raise WakeWordError(
f"Слов {', '.join(missing)} нет в словаре модели {self._config.model}"
"выберите другую фразу в wake_word.phrases"
)
self._model = model
log.info("Модель Vosk %s загружена, фразы: %s", self._config.model, self.phrases)
def create_session(self) -> WakeWordSession:
self.load()
return WakeWordSession(self._model, self._phrases)
def _download(self, model_path: Path) -> None:
match = _MODEL_LANGUAGE.match(self._config.model)
language = match.group(1) if match else ""
self._models_dir.mkdir(parents=True, exist_ok=True)
errors = []
for template in _MODEL_URLS:
url = template.format(language=language, model=self._config.model)
log.info("Скачиваю модель Vosk: %s", url)
try:
with tempfile.TemporaryDirectory(dir=self._models_dir) as temp_dir:
archive = Path(temp_dir) / "model.zip"
urllib.request.urlretrieve(url, archive)
with zipfile.ZipFile(archive) as zip_file:
zip_file.extractall(temp_dir)
extracted = Path(temp_dir) / self._config.model
if not extracted.is_dir():
raise WakeWordError(f"в архиве нет папки {self._config.model}")
shutil.move(str(extracted), model_path)
return
except (OSError, zipfile.BadZipFile, WakeWordError) as exc:
log.warning("Не удалось скачать %s: %s", url, exc)
errors.append(f"{url}: {exc}")
raise WakeWordError("Не удалось скачать модель Vosk:\n" + "\n".join(errors))
+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
+301
View File
@@ -0,0 +1,301 @@
"""Загрузка настроек из config.yaml."""
from __future__ import annotations
import logging
import os
import re
import sys
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import Any
import yaml
from agr_assistent import APP_NAME
log = logging.getLogger(__name__)
CONFIG_ENV_VAR = "AGR_ASSISTENT_CONFIG"
CONFIG_FILE_NAME = "config.yaml"
_ENV_REFERENCE = re.compile(r"\$\{(\w+)\}")
class ConfigError(Exception):
"""Конфиг отсутствует или содержит ошибки."""
@dataclass(frozen=True)
class ProviderConfig:
name: str
base_url: str
api_key: str
model: str
@dataclass
class LLMConfig:
provider: str
providers: dict[str, ProviderConfig]
system_prompt: str
temperature: float
follow_up_seconds: float
timeout_seconds: float
@property
def active_provider(self) -> ProviderConfig:
return self.providers[self.provider]
@dataclass
class TTSConfig:
enabled: bool
model: str
speaker: str
sample_rate: int
device: str
@dataclass
class STTConfig:
model: str
device: str
compute_type: str
language: str | None
beam_size: int
@dataclass
class VoiceConfig:
enabled: bool
hotkey: str
sound_cues: bool
start_timeout_seconds: float
silence_seconds: float
max_seconds: float
vad_threshold: float
@dataclass
class WakeWordConfig:
enabled: bool
phrases: list[str]
model: str
@dataclass
class MemoryConfig:
auto_save: bool
@dataclass
class UIConfig:
start_minimized: bool
@dataclass
class AppConfig:
llm: LLMConfig
tts: TTSConfig
stt: STTConfig
voice: VoiceConfig
wake_word: WakeWordConfig
memory: MemoryConfig
ui: UIConfig
path: Path
def data_dir() -> Path:
"""Папка для скачиваемых моделей и прочих данных приложения."""
base = os.environ.get("LOCALAPPDATA") or Path.home() / ".local" / "share"
return Path(base) / APP_NAME
def resolve_config_path() -> Path:
"""Путь из переменной окружения, иначе config.yaml рядом с exe или в текущей папке."""
if env_path := os.environ.get(CONFIG_ENV_VAR):
return Path(env_path).expanduser()
base = Path(sys.executable).parent if getattr(sys, "frozen", False) else Path.cwd()
return base / CONFIG_FILE_NAME
def default_config_text() -> str:
return (
resources.files("agr_assistent")
.joinpath("default_config.yaml")
.read_text(encoding="utf-8")
)
def load_config(path: Path | None = None) -> AppConfig:
path = path or resolve_config_path()
return parse_config(load_config_data(path), path)
def load_config_data(path: Path) -> dict[str, Any]:
"""Настройки как есть (без подстановки ${VAR}), недостающие ключи — из умолчаний."""
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(default_config_text(), encoding="utf-8")
log.info("Создан конфиг по умолчанию: %s", path)
try:
user_data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
raise ConfigError(f"Не удалось разобрать {path}:\n{exc}") from exc
if not isinstance(user_data, dict):
raise ConfigError(f"{path}: ожидался словарь настроек верхнего уровня")
defaults = yaml.safe_load(default_config_text())
return _deep_merge(defaults, user_data)
def get_value(data: dict[str, Any], key: str) -> Any:
"""get_value(data, "llm.provider")"""
node: Any = data
for part in key.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
def save_config_updates(path: Path, updates: dict[str, Any]) -> AppConfig:
"""Записывает изменения вида {"llm.provider": "openrouter"}, сохраняя комментарии.
Итоговый конфиг проверяется до записи: ошибочные значения не попадут в файл.
"""
merged = load_config_data(path)
for key, value in updates.items():
_set_value(merged, key, value)
config = parse_config(merged, path)
if not updates:
return config
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
round_trip = YAML()
round_trip.preserve_quotes = True
round_trip.width = 4096
round_trip.indent(mapping=2, sequence=4, offset=2)
document = round_trip.load(path.read_text(encoding="utf-8")) or CommentedMap()
for key, value in updates.items():
_set_value(document, key, value, CommentedMap)
temp_path = path.with_suffix(".tmp")
with temp_path.open("w", encoding="utf-8") as file:
round_trip.dump(document, file)
temp_path.replace(path)
log.info("Настройки сохранены: %s", ", ".join(updates))
return config
def _set_value(
data: dict[str, Any], key: str, value: Any, mapping_type: type[dict] = dict
) -> None:
*parents, last = key.split(".")
node = data
for part in parents:
if not isinstance(node.get(part), dict):
node[part] = mapping_type()
node = node[part]
node[last] = value
def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
try:
llm_data = data["llm"]
providers = {
str(name): ProviderConfig(
name=str(name),
base_url=str(item["base_url"]).rstrip("/"),
api_key=expand_env(str(item.get("api_key") or "")),
model=str(item["model"]),
)
for name, item in (llm_data.get("providers") or {}).items()
}
llm = LLMConfig(
provider=str(llm_data["provider"]),
providers=providers,
system_prompt=str(llm_data.get("system_prompt") or ""),
temperature=float(llm_data["temperature"]),
follow_up_seconds=float(llm_data["follow_up_seconds"]),
timeout_seconds=float(llm_data["timeout_seconds"]),
)
tts_data = data["tts"]
tts = TTSConfig(
enabled=bool(tts_data["enabled"]),
model=str(tts_data["model"]),
speaker=str(tts_data["speaker"]),
sample_rate=int(tts_data["sample_rate"]),
device=str(tts_data["device"]),
)
stt_data = data["stt"]
stt = STTConfig(
model=str(stt_data["model"]),
device=str(stt_data["device"]),
compute_type=str(stt_data["compute_type"]),
language=str(stt_data.get("language") or "") or None,
beam_size=int(stt_data["beam_size"]),
)
voice_data = data["voice"]
voice = VoiceConfig(
enabled=bool(voice_data["enabled"]),
hotkey=str(voice_data.get("hotkey") or ""),
sound_cues=bool(voice_data["sound_cues"]),
start_timeout_seconds=float(voice_data["start_timeout_seconds"]),
silence_seconds=float(voice_data["silence_seconds"]),
max_seconds=float(voice_data["max_seconds"]),
vad_threshold=float(voice_data["vad_threshold"]),
)
wake_data = data["wake_word"]
phrases = wake_data["phrases"]
if isinstance(phrases, str):
phrases = [phrases]
wake_word = WakeWordConfig(
enabled=bool(wake_data["enabled"]),
phrases=[str(phrase) for phrase in phrases if str(phrase).strip()],
model=str(wake_data["model"]),
)
memory = MemoryConfig(auto_save=bool(data["memory"]["auto_save"]))
ui = UIConfig(start_minimized=bool(data["ui"]["start_minimized"]))
except KeyError as exc:
raise ConfigError(f"{path}: отсутствует ключ {exc}") from exc
except (TypeError, ValueError, AttributeError) as exc:
raise ConfigError(f"{path}: некорректное значение — {exc}") from exc
if llm.provider not in providers:
known = ", ".join(providers) or "нет ни одного"
raise ConfigError(
f"{path}: провайдер '{llm.provider}' не описан в llm.providers (есть: {known})"
)
if not wake_word.phrases:
raise ConfigError(f"{path}: wake_word.phrases не может быть пустым")
return AppConfig(
llm=llm,
tts=tts,
stt=stt,
voice=voice,
wake_word=wake_word,
memory=memory,
ui=ui,
path=path,
)
def expand_env(value: str) -> str:
"""Подставляет ${VAR} из окружения; незаданная переменная даёт пустую строку."""
return _ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), ""), value)
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
result = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
View File
+589
View File
@@ -0,0 +1,589 @@
"""Оркестратор запросов: фоновая генерация ответа, озвучка, голосовой ввод, состояния."""
from __future__ import annotations
import json
import logging
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any
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.core.context import FollowUpContext, build_messages
from agr_assistent.core.memory import MemoryStore, memory_prompt, memory_tools
from agr_assistent.core.speech import Speaker
from agr_assistent.core.voice import VoiceInput
from agr_assistent.core.wake import WakeWordListener
from agr_assistent.llm.client import (
LLMClient,
LLMError,
TextDelta,
ToolCall,
ToolCalls,
ToolsNotSupportedError,
)
from agr_assistent.llm.tools import Tool, ToolRegistry, ToolResult
log = logging.getLogger(__name__)
# Сколько раз подряд модель может вызвать инструменты в одном ответе
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):
IDLE = "idle"
LISTENING = "listening"
RECOGNIZING = "recognizing"
THINKING = "thinking"
SPEAKING = "speaking"
class Assistant(QObject):
"""Живёт в главном потоке; запросы к модели выполняются в фоновых потоках."""
state_changed = Signal(object) # AssistantState
provider_changed = Signal(str)
speech_enabled_changed = Signal(bool)
wake_word_enabled_changed = Signal(bool)
request_added = Signal(str, bool) # текст запроса; True — уточнение к предыдущему
reply_started = Signal()
reply_chunk = Signal(str)
reply_finished = Signal(str) # полный (возможно, прерванный) текст ответа
error_occurred = Signal(str)
tool_executed = Signal(str, bool) # текст для журнала; успешно ли
journal_cleared = Signal()
# Мост из фонового потока в главный; int — номер генерации
_worker_chunk = Signal(int, str)
_worker_tool = Signal(int, str, bool)
_worker_confirm = Signal(int, str, str) # имя инструмента, аргументы JSON
_worker_notice = Signal(int, str)
_worker_failed = Signal(int, str)
_worker_done = Signal(int)
def __init__(
self,
config: LLMConfig,
speaker: Speaker,
voice: VoiceInput | None,
wake_word: WakeWordListener | None = None,
*,
memory: MemoryStore | None = None,
memory_auto_save: bool = True,
commands: CommandCatalog | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._config = config
self._speaker = speaker
self._voice = voice
self._wake_word = wake_word
self._memory = memory
self._memory_auto_save = memory_auto_save
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._client: LLMClient | None = None
self._context = FollowUpContext(config.follow_up_seconds)
self._state = AssistantState.IDLE
self._generating = False
# Увеличивается при каждом запросе и отмене: ответы устаревших потоков игнорируются
self._generation = 0
self._request = ""
self._reply_parts: list[str] = []
self._worker_chunk.connect(self._on_worker_chunk)
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_failed.connect(self._on_worker_failed)
self._worker_done.connect(self._on_worker_done)
speaker.playback_started.connect(self._update_state)
speaker.finished.connect(self._update_state)
speaker.error_occurred.connect(self.error_occurred)
if voice is not None:
voice.listening_started.connect(self._update_state)
voice.recognizing_started.connect(self._update_state)
voice.finished.connect(self._update_state)
voice.recognized.connect(self._on_voice_recognized)
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:
wake_word.detected.connect(self._on_wake_word)
wake_word.enabled_changed.connect(self.wake_word_enabled_changed)
wake_word.error_occurred.connect(self.error_occurred)
self._sync_wake_word()
@property
def state(self) -> AssistantState:
return self._state
@property
def is_busy(self) -> bool:
"""Генерирует ответ или говорит."""
return self._state is not AssistantState.IDLE
@property
def is_generating(self) -> bool:
return self._generating
@property
def provider_name(self) -> str:
return self._config.provider
@property
def provider_names(self) -> list[str]:
return list(self._config.providers)
@property
def model_name(self) -> str:
return self._config.active_provider.model
@property
def speech_enabled(self) -> bool:
return self._speaker.enabled
@property
def voice_available(self) -> bool:
return self._voice is not None
@property
def wake_word_available(self) -> bool:
return self._voice is not None and self._wake_word is not None
@property
def wake_word_enabled(self) -> bool:
return self._wake_word is not None and self._wake_word.enabled
def set_wake_word_enabled(self, enabled: bool) -> None:
if self._wake_word is not None:
self._wake_word.set_enabled(enabled)
def toggle_listening(self) -> None:
"""Горячая клавиша: начать запись, а во время записи — закончить её досрочно.
Если ассистент в этот момент думает или говорит, он замолкает и слушает.
"""
if self._voice is None or self._voice.is_recognizing:
return
if self._voice.is_listening:
self._voice.stop()
return
self.cancel()
self._voice.start()
def set_provider(self, name: str) -> None:
if name == self._config.provider:
return
if name not in self._config.providers:
raise ValueError(f"Неизвестный провайдер: {name}")
self.cancel()
self._config.provider = name
self._client = None
log.info("Провайдер LLM: %s (%s)", name, self.model_name)
self.provider_changed.emit(name)
def update_llm_config(self, config: LLMConfig) -> None:
"""Применяет новые настройки модели; текущий ответ прерывается, только если они изменились."""
changed = config != self._config
self._config = config
self._context.window_seconds = config.follow_up_seconds
if changed:
self.cancel()
self._client = None
log.info("Настройки LLM обновлены: %s (%s)", config.provider, self.model_name)
self.provider_changed.emit(config.provider)
def set_memory_auto_save(self, enabled: bool) -> None:
self._memory_auto_save = enabled
def set_speech_enabled(self, enabled: bool) -> None:
if enabled == self._speaker.enabled:
return
self._speaker.set_enabled(enabled)
self.speech_enabled_changed.emit(enabled)
def send(self, text: str, *, by_voice: bool = False) -> None:
"""Новый запрос прерывает озвучку предыдущего ответа.
Порядок: ответ «да/нет» на ожидающее подтверждение, точная фраза команды, модель.
"""
text = text.strip()
if not text or self._generating:
return
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()
self.request_added.emit(text, bool(context))
try:
client = self._get_client()
except LLMError as exc:
self.error_occurred.emit(str(exc))
return
self._generation += 1
self._generating = True
self._request = text
self._reply_parts = []
sections = []
if self._memory is not None:
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(
self._config.system_prompt, context, text, datetime.now(), sections
)
model_key = (self._config.active_provider.base_url, self._config.active_provider.model)
use_tools = len(self._tools) > 0 and model_key not in self._models_without_tools
self._speaker.begin()
self._update_state()
self.reply_started.emit()
threading.Thread(
target=self._run_reply,
args=(self._generation, client, messages, self._tools, use_tools, model_key),
name=f"llm-reply-{self._generation}",
daemon=True,
).start()
def cancel(self) -> None:
"""Прерывает запись голоса, генерацию и озвучку."""
self._listen_after_reply = False
if self._voice is not None:
self._voice.cancel()
if self._generating:
self._generation += 1
self._finish_reply()
self._speaker.stop()
def clear_journal(self) -> None:
"""Очищает журнал и забывает контекст уточнений."""
self.cancel()
self._context.clear()
self._pending = None
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:
if self._client is None:
self._client = LLMClient(
self._config.active_provider,
temperature=self._config.temperature,
timeout_seconds=self._config.timeout_seconds,
)
return self._client
def _run_reply(
self,
generation: int,
client: LLMClient,
messages: list[dict[str, Any]],
registry: ToolRegistry,
use_tools: bool,
model_key: tuple[str, str],
) -> None:
"""Выполняется в фоновом потоке: ответ модели и вызовы инструментов по кругу."""
try:
for _round in range(MAX_TOOL_ROUNDS + 1):
# На последнем круге инструменты не предлагаем — модель обязана ответить текстом
tools = registry.schemas() if use_tools and _round < MAX_TOOL_ROUNDS else None
try:
calls, text = self._stream_round(generation, client, messages, tools)
except ToolsNotSupportedError as exc:
self._models_without_tools.add(model_key)
self._worker_notice.emit(generation, str(exc))
use_tools = False
calls, text = self._stream_round(generation, client, messages, None)
if calls is None:
return # запрос отменён
if not calls:
break
messages.append(_assistant_tool_message(text, calls))
for call in calls:
result = self._execute_tool_call(generation, registry, call)
log.info("Инструмент %s(%s): %s", call.name, call.arguments, result.content)
if generation != self._generation:
return
self._worker_tool.emit(generation, result.display, result.ok)
messages.append(
{"role": "tool", "tool_call_id": call.id, "content": result.content}
)
except LLMError as exc:
self._worker_failed.emit(generation, str(exc))
except Exception as exc:
log.exception("Сбой при генерации ответа")
self._worker_failed.emit(generation, f"Непредвиденная ошибка: {exc}")
else:
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(
self,
generation: int,
client: LLMClient,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
) -> tuple[list[ToolCall] | None, str]:
"""Один ответ модели. Возвращает (вызовы инструментов или None при отмене, текст)."""
calls: list[ToolCall] = []
parts: list[str] = []
for event in client.stream_chat(messages, tools):
if generation != self._generation:
return None, ""
if isinstance(event, TextDelta):
parts.append(event.text)
self._worker_chunk.emit(generation, event.text)
elif isinstance(event, ToolCalls):
calls = event.calls
return calls, "".join(parts)
@Slot()
def _on_wake_word(self) -> None:
if self._voice is not None and not self._voice.is_active:
self.cancel()
self._voice.start()
else:
self._sync_wake_word()
@Slot(str)
def _on_voice_recognized(self, text: str) -> None:
if text.strip():
self.send(text, by_voice=True)
else:
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)
def _on_worker_chunk(self, generation: int, piece: str) -> None:
if generation == self._generation:
self._reply_parts.append(piece)
self._speaker.feed(piece)
self.reply_chunk.emit(piece)
@Slot(int, str, bool)
def _on_worker_tool(self, generation: int, display: str, ok: bool) -> None:
if generation == self._generation:
self.tool_executed.emit(display, ok)
@Slot(int, str)
def _on_worker_notice(self, generation: int, message: str) -> None:
if generation == self._generation:
self.error_occurred.emit(message)
@Slot(int, str)
def _on_worker_failed(self, generation: int, message: str) -> None:
if generation == self._generation:
# Уже полученную часть ответа всё равно договариваем
self._speaker.end()
self._finish_reply()
self.error_occurred.emit(message)
@Slot(int)
def _on_worker_done(self, generation: int) -> None:
if generation == self._generation:
self._speaker.end()
self._finish_reply()
def _finish_reply(self) -> None:
reply = "".join(self._reply_parts)
self._reply_parts = []
self._generating = False
# Даже прерванный ответ пригодится для уточнения «нет, я имел в виду…»
self._context.remember(self._request, reply)
self._update_state()
self.reply_finished.emit(reply)
@Slot()
def _update_state(self) -> None:
if self._voice is not None and self._voice.is_listening:
state = AssistantState.LISTENING
elif self._voice is not None and self._voice.is_recognizing:
state = AssistantState.RECOGNIZING
elif self._speaker.is_playing:
state = AssistantState.SPEAKING
elif self._generating:
state = AssistantState.THINKING
elif self._speaker.is_active:
state = AssistantState.SPEAKING
else:
state = AssistantState.IDLE
if state is not self._state:
self._state = 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()
def _sync_wake_word(self) -> None:
"""Слово активации слушаем только в простое: не делим микрофон и не слышим себя."""
if self._wake_word is None:
return
if self._state is AssistantState.IDLE:
self._wake_word.resume()
else:
self._wake_word.pause()
def _assistant_tool_message(text: str, calls: list[ToolCall]) -> dict[str, Any]:
return {
"role": "assistant",
"content": text or None,
"tool_calls": [
{
"id": call.id,
"type": "function",
"function": {"name": call.name, "arguments": call.arguments or "{}"},
}
for call in calls
],
}
def _confirmation_question(summary: str) -> str:
return f"Выполнить: {summary}? Скажите «да» или «нет»."
+87
View File
@@ -0,0 +1,87 @@
"""Сборка запроса к модели: каждый запрос отдельный, но короткие уточнения видят предыдущий обмен."""
from __future__ import annotations
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
Message = dict[str, str]
# Сколько последних обменов цепочки уточнений отдаём модели
MAX_FOLLOW_UP_EXCHANGES = 3
_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
_MONTHS = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
] # fmt: skip
@dataclass(frozen=True)
class Exchange:
request: str
reply: str
finished_at: float
class FollowUpContext:
"""Цепочка обменов, идущих друг за другом с паузами меньше окна уточнений.
После паузы длиннее окна следующий запрос снова начинается с чистого листа.
"""
def __init__(self, window_seconds: float, clock: Callable[[], float] = time.monotonic) -> None:
self.window_seconds = window_seconds
self._clock = clock
self._exchanges: list[Exchange] = []
def recent(self) -> list[Message]:
if not self._is_alive():
self._exchanges.clear()
return []
messages: list[Message] = []
for exchange in self._exchanges:
messages.append({"role": "user", "content": exchange.request})
messages.append({"role": "assistant", "content": exchange.reply})
return messages
def remember(self, request: str, reply: str) -> None:
if self.window_seconds <= 0 or not reply.strip():
return
if not self._is_alive():
self._exchanges.clear()
self._exchanges.append(Exchange(request, reply, self._clock()))
del self._exchanges[:-MAX_FOLLOW_UP_EXCHANGES]
def clear(self) -> None:
self._exchanges.clear()
def _is_alive(self) -> bool:
return (
self.window_seconds > 0
and bool(self._exchanges)
and self._clock() - self._exchanges[-1].finished_at <= self.window_seconds
)
def build_messages(
system_prompt: str,
context: list[Message],
request: str,
now: datetime,
extra_sections: Iterable[str] = (),
) -> list[dict[str, Any]]:
"""extra_sections — дополнительные блоки системного промпта (память, команды)."""
parts = (system_prompt.strip(), f"Сейчас {format_datetime(now)}.", *extra_sections)
system = "\n\n".join(part.strip() for part in parts if part.strip())
return [{"role": "system", "content": system}, *context, {"role": "user", "content": request}]
def format_datetime(moment: datetime) -> str:
"""«четверг, 17 сентября 2026 года, 04:15» — без зависимости от локали системы."""
weekday = _WEEKDAYS[moment.weekday()]
month = _MONTHS[moment.month - 1]
return f"{weekday}, {moment.day} {month} {moment.year} года, {moment:%H:%M}"
+220
View File
@@ -0,0 +1,220 @@
"""Долговременная память: факты о пользователе в SQLite и инструменты для модели."""
from __future__ import annotations
import sqlite3
import threading
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from agr_assistent.llm.tools import Tool, ToolError, ToolResult
SOURCE_USER = "user"
SOURCE_AUTO = "auto"
# Больше фактов в системный промпт не кладём, чтобы не раздувать каждый запрос
MAX_PROMPT_FACTS = 200
_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class Fact:
id: int
text: str
source: str
created_at: datetime
updated_at: datetime
@property
def is_auto(self) -> bool:
return self.source == SOURCE_AUTO
class MemoryStore:
"""Потокобезопасное хранилище: модель пишет из фонового потока, настройки — из главного."""
def __init__(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._connection = sqlite3.connect(path, check_same_thread=False)
self._connection.row_factory = sqlite3.Row
with self._lock, self._connection:
if self._connection.execute("PRAGMA user_version").fetchone()[0] < _SCHEMA_VERSION:
self._connection.execute(
"""
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
self._connection.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
def close(self) -> None:
with self._lock:
self._connection.close()
def facts(self) -> list[Fact]:
with self._lock:
rows = self._connection.execute("SELECT * FROM facts ORDER BY id").fetchall()
return [_fact(row) for row in rows]
def add(self, text: str, source: str) -> Fact:
"""Точный дубль (без учёта регистра) не создаётся — возвращается существующий факт."""
text = _clean(text)
with self._lock, self._connection:
for row in self._connection.execute("SELECT * FROM facts"):
if row["text"].casefold() == text.casefold():
return _fact(row)
now = _now()
cursor = self._connection.execute(
"INSERT INTO facts (text, source, created_at, updated_at) VALUES (?, ?, ?, ?)",
(text, source, now, now),
)
row = self._connection.execute(
"SELECT * FROM facts WHERE id = ?", (cursor.lastrowid,)
).fetchone()
return _fact(row)
def update(self, fact_id: int, text: str) -> Fact | None:
text = _clean(text)
with self._lock, self._connection:
updated = self._connection.execute(
"UPDATE facts SET text = ?, updated_at = ? WHERE id = ?", (text, _now(), fact_id)
).rowcount
row = self._connection.execute("SELECT * FROM facts WHERE id = ?", (fact_id,)).fetchone()
return _fact(row) if updated else None
def delete(self, fact_id: int) -> Fact | None:
with self._lock, self._connection:
row = self._connection.execute("SELECT * FROM facts WHERE id = ?", (fact_id,)).fetchone()
if row is None:
return None
self._connection.execute("DELETE FROM facts WHERE id = ?", (fact_id,))
return _fact(row)
def memory_prompt(facts: list[Fact], auto_save: bool) -> str:
lines = ["Долговременная память о пользователе (номер: факт):"]
if facts:
shown = facts[-MAX_PROMPT_FACTS:]
lines.extend(f"[{fact.id}] {fact.text}" for fact in shown)
else:
lines.append("пока пусто")
lines.append("")
lines.append("Работа с памятью:")
lines.append(
"- Если пользователь просит что-то запомнить, вызови remember с requested_by_user=true "
"и коротко подтверди."
)
if auto_save:
lines.append(
"- Если пользователь сообщает о себе устойчивый факт, который пригодится в будущем "
"(имя, близкие, работа, техника, привычки, предпочтения в ответах), сохрани его через "
"remember с requested_by_user=false, не спрашивая разрешения и не сообщая об этом. "
"Не сохраняй разовые просьбы, временное состояние и содержимое текущего вопроса."
)
lines.append(
"- Если новый факт уточняет или противоречит сохранённому, используй update_memory "
"с номером старого факта, а не remember."
)
lines.append("- Если пользователь просит забыть факт, вызови forget с его номером.")
lines.append(
"- Формулируй факты кратко, в третьем лице, например: «Машина пользователя — Škoda Octavia»."
)
return "\n".join(lines)
def memory_tools(store: MemoryStore) -> list[Tool]:
def remember(arguments: dict[str, Any]) -> ToolResult:
text = _required_text(arguments)
source = SOURCE_USER if arguments.get("requested_by_user", True) else SOURCE_AUTO
fact = store.add(text, source)
prefix = "Запомнил" if source == SOURCE_USER else "Запомнил сам"
return ToolResult(True, f"Сохранено под номером {fact.id}", f"{prefix}: {fact.text}")
def update_memory(arguments: dict[str, Any]) -> ToolResult:
fact = store.update(arguments["id"], _required_text(arguments))
if fact is None:
raise ToolError(f"Факта с номером {arguments['id']} нет в памяти")
return ToolResult(True, f"Факт {fact.id} обновлён", f"Обновил в памяти: {fact.text}")
def forget(arguments: dict[str, Any]) -> ToolResult:
fact = store.delete(arguments["id"])
if fact is None:
raise ToolError(f"Факта с номером {arguments['id']} нет в памяти")
return ToolResult(True, f"Факт {fact.id} удалён", f"Забыл: {fact.text}")
return [
Tool(
name="remember",
description="Сохранить факт о пользователе в долговременную память.",
parameters={
"type": "object",
"properties": {
"text": {"type": "string", "description": "Факт одной короткой фразой"},
"requested_by_user": {
"type": "boolean",
"description": "true — пользователь сам попросил запомнить",
},
},
"required": ["text"],
},
handler=remember,
),
Tool(
name="update_memory",
description="Исправить или уточнить сохранённый факт.",
parameters={
"type": "object",
"properties": {
"id": {"type": "integer", "description": "Номер факта"},
"text": {"type": "string", "description": "Новая формулировка факта"},
},
"required": ["id", "text"],
},
handler=update_memory,
),
Tool(
name="forget",
description="Удалить факт из памяти.",
parameters={
"type": "object",
"properties": {"id": {"type": "integer", "description": "Номер факта"}},
"required": ["id"],
},
handler=forget,
),
]
def _required_text(arguments: dict[str, Any]) -> str:
text = _clean(arguments.get("text", ""))
if not text:
raise ToolError("Текст факта пуст")
return text
def _clean(text: str) -> str:
return " ".join(str(text).split())
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _fact(row: sqlite3.Row) -> Fact:
return Fact(
id=row["id"],
text=row["text"],
source=row["source"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
+45
View File
@@ -0,0 +1,45 @@
"""Текущие настройки приложения и их сохранение."""
from __future__ import annotations
from dataclasses import replace
from typing import Any
from PySide6.QtCore import QObject, Signal
from agr_assistent.config import AppConfig, load_config_data, save_config_updates
def needs_restart(old: AppConfig, new: AppConfig) -> bool:
"""На лету применяются настройки LLM, интерфейса и включение озвучки и слова активации;
смена моделей, голоса, горячей клавиши и параметров записи требует перезапуска."""
return (
replace(old.tts, enabled=False) != replace(new.tts, enabled=False)
or old.stt != new.stt
or old.voice != new.voice
or replace(old.wake_word, enabled=False) != replace(new.wake_word, enabled=False)
)
class Settings(QObject):
changed = Signal(object) # AppConfig
def __init__(self, config: AppConfig, parent: QObject | None = None) -> None:
super().__init__(parent)
self._config = config
@property
def config(self) -> AppConfig:
return self._config
def raw(self) -> dict[str, Any]:
"""Значения как в файле: ${VAR} не подставлены, чтобы не записать секрет вместо ссылки."""
return load_config_data(self._config.path)
def save(self, updates: dict[str, Any]) -> AppConfig:
"""Бросает ConfigError, если значения некорректны; файл при этом не меняется."""
if not updates:
return self._config
self._config = save_config_updates(self._config.path, updates)
self.changed.emit(self._config)
return self._config
+207
View File
@@ -0,0 +1,207 @@
"""Потоковая озвучка ответа: фразы синтезируются и проигрываются по мере генерации текста."""
from __future__ import annotations
import logging
import queue
import threading
from collections.abc import Callable
from typing import Protocol
import numpy as np
from PySide6.QtCore import QObject, Signal, Slot
from agr_assistent.tts.base import TTSEngine
from agr_assistent.tts.text import SpeechTextStream
log = logging.getLogger(__name__)
_END = object() # конец реплики
_LOAD = object() # предзагрузка модели
_NO_GENERATION = -1 # ошибка, не привязанная к реплике
_IDLE_POLL_SECONDS = 0.25
class Player(Protocol):
def play(
self, audio: np.ndarray, sample_rate: int, should_continue: Callable[[], bool]
) -> None: ...
def finish(self) -> None: ...
def abort(self) -> None: ...
class Speaker(QObject):
"""Живёт в главном потоке; синтез и воспроизведение — в двух фоновых потоках.
Синтез идёт параллельно с воспроизведением, поэтому следующая фраза обычно
готова к моменту, когда доиграла предыдущая.
"""
playback_started = Signal()
finished = Signal() # реплика доиграна или остановлена
error_occurred = Signal(str)
# Мост из фоновых потоков в главный; int — номер реплики
_worker_started = Signal(int)
_worker_done = Signal(int)
_worker_failed = Signal(int, str)
def __init__(
self,
engine: TTSEngine,
player: Player,
*,
enabled: bool,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._engine = engine
self._player = player
self._enabled = enabled
# Увеличивается при каждой новой реплике и остановке: устаревшие фразы отбрасываются
self._generation = 0
self._active = False
self._playing = False
self._text: SpeechTextStream | None = None
self._synthesis_queue: queue.Queue[tuple[int, object]] = queue.Queue()
self._audio_queue: queue.Queue[tuple[int, object]] = queue.Queue()
self._worker_started.connect(self._on_worker_started)
self._worker_done.connect(self._on_worker_done)
self._worker_failed.connect(self._on_worker_failed)
threading.Thread(target=self._synthesis_loop, name="tts-synthesis", daemon=True).start()
threading.Thread(target=self._playback_loop, name="tts-playback", daemon=True).start()
@property
def enabled(self) -> bool:
return self._enabled
@property
def is_active(self) -> bool:
"""Реплика начата и ещё не доиграна."""
return self._active
@property
def is_playing(self) -> bool:
return self._playing
def set_enabled(self, enabled: bool) -> None:
self._enabled = enabled
if enabled:
self.warm_up()
else:
self.stop()
def warm_up(self) -> None:
"""Загружает модель заранее, чтобы первый ответ не ждал её."""
self._synthesis_queue.put((_NO_GENERATION, _LOAD))
def begin(self) -> None:
self.stop()
if not self._enabled:
return
self._generation += 1
self._active = True
self._text = SpeechTextStream()
def feed(self, chunk: str) -> None:
if self._text is not None:
for sentence in self._text.feed(chunk):
self._synthesis_queue.put((self._generation, sentence))
def end(self) -> None:
if self._text is not None:
for sentence in self._text.flush():
self._synthesis_queue.put((self._generation, sentence))
self._synthesis_queue.put((self._generation, _END))
self._text = None
def stop(self) -> None:
if not self._active:
return
self._generation += 1
self._finish()
def _finish(self) -> None:
self._text = None
self._active = False
self._playing = False
self.finished.emit()
def _synthesis_loop(self) -> None:
"""Фоновый поток: текст -> звук."""
while True:
generation, item = self._synthesis_queue.get()
if item is _LOAD:
try:
self._engine.load()
except Exception as exc:
log.exception("Не удалось загрузить модель синтеза речи")
self._worker_failed.emit(_NO_GENERATION, str(exc))
continue
if generation != self._generation:
continue
if item is _END:
self._audio_queue.put((generation, _END))
continue
try:
audio = self._engine.synthesize(str(item))
except Exception as exc:
log.exception("Сбой синтеза речи")
self._worker_failed.emit(generation, f"Ошибка синтеза речи: {exc}")
continue
self._audio_queue.put((generation, audio))
def _playback_loop(self) -> None:
"""Фоновый поток: воспроизведение звука по порядку."""
started_generation: int | None = None
while True:
try:
generation, item = self._audio_queue.get(timeout=_IDLE_POLL_SECONDS)
except queue.Empty:
# Реплику остановили между фразами — освобождаем аудиоустройство
if started_generation is not None and started_generation != self._generation:
self._player.abort()
started_generation = None
continue
if generation != self._generation:
continue
try:
if item is _END:
self._player.finish()
started_generation = None
self._worker_done.emit(generation)
continue
if started_generation != generation:
started_generation = generation
self._worker_started.emit(generation)
self._player.play(
item, # type: ignore[arg-type]
self._engine.sample_rate,
lambda: generation == self._generation,
)
except Exception as exc:
log.exception("Сбой воспроизведения")
self._player.abort()
started_generation = None
self._worker_failed.emit(generation, f"Ошибка воспроизведения звука: {exc}")
@Slot(int)
def _on_worker_started(self, generation: int) -> None:
if generation == self._generation and self._active:
self._playing = True
self.playback_started.emit()
@Slot(int)
def _on_worker_done(self, generation: int) -> None:
if generation == self._generation and self._active:
self._finish()
@Slot(int, str)
def _on_worker_failed(self, generation: int, message: str) -> None:
if generation == self._generation:
self.stop()
elif generation != _NO_GENERATION:
return
self.error_occurred.emit(message)
+171
View File
@@ -0,0 +1,171 @@
"""Голосовой ввод: запись фразы и её распознавание в фоновом потоке."""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from enum import Enum
from typing import Protocol
import numpy as np
from PySide6.QtCore import QObject, Signal, Slot
from agr_assistent.audio import cues
log = logging.getLogger(__name__)
_NO_GENERATION = -1 # ошибка, не привязанная к сеансу записи
class Recorder(Protocol):
def load(self) -> None: ...
def record(self, should_stop: Callable[[], bool]) -> np.ndarray | None: ...
class Recognizer(Protocol):
def load(self) -> None: ...
def transcribe(self, audio: np.ndarray) -> str: ...
class _Phase(Enum):
IDLE = "idle"
LISTENING = "listening"
RECOGNIZING = "recognizing"
class VoiceInput(QObject):
listening_started = Signal()
recognizing_started = Signal()
recognized = Signal(str) # может быть пустой строкой, если слов не разобрать
finished = Signal() # сеанс завершён: распознан, прерван или речи не было
error_occurred = Signal(str)
# Мост из фонового потока в главный; int — номер сеанса
_worker_recognizing = Signal(int)
_worker_recognized = Signal(int, str)
_worker_done = Signal(int)
_worker_failed = Signal(int, str)
def __init__(
self,
recorder: Recorder,
recognizer: Recognizer,
*,
sound_cues: bool,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._recorder = recorder
self._recognizer = recognizer
self._sound_cues = sound_cues
self._phase = _Phase.IDLE
# Увеличивается при каждом сеансе и отмене: результаты устаревших потоков отбрасываются
self._generation = 0
self._stop_requested = False
self._worker_recognizing.connect(self._on_worker_recognizing)
self._worker_recognized.connect(self._on_worker_recognized)
self._worker_done.connect(self._on_worker_done)
self._worker_failed.connect(self._on_worker_failed)
@property
def is_listening(self) -> bool:
return self._phase is _Phase.LISTENING
@property
def is_recognizing(self) -> bool:
return self._phase is _Phase.RECOGNIZING
@property
def is_active(self) -> bool:
return self._phase is not _Phase.IDLE
def warm_up(self) -> None:
"""Загружает детектор речи и модель распознавания заранее."""
threading.Thread(target=self._load_models, name="stt-warm-up", daemon=True).start()
def start(self) -> None:
if self.is_active:
return
self._generation += 1
self._stop_requested = False
self._phase = _Phase.LISTENING
self.listening_started.emit()
threading.Thread(
target=self._run,
args=(self._generation,),
name=f"voice-input-{self._generation}",
daemon=True,
).start()
def stop(self) -> None:
"""Заканчивает запись досрочно и распознаёт то, что успели сказать."""
if self.is_listening:
self._stop_requested = True
def cancel(self) -> None:
if self.is_active:
self._generation += 1
self._finish()
def _load_models(self) -> None:
try:
self._recorder.load()
self._recognizer.load()
except Exception as exc:
log.exception("Не удалось загрузить модели голосового ввода")
self._worker_failed.emit(_NO_GENERATION, f"Голосовой ввод недоступен: {exc}")
def _run(self, generation: int) -> None:
"""Фоновый поток: сигнал, запись, распознавание."""
try:
if self._sound_cues:
cues.play_tone(cues.START_FREQUENCY)
audio = self._recorder.record(
lambda: self._stop_requested or generation != self._generation
)
if generation != self._generation:
return
if self._sound_cues:
cues.play_tone(cues.STOP_FREQUENCY)
if audio is None:
log.info("Речь не обнаружена")
self._worker_done.emit(generation)
return
self._worker_recognizing.emit(generation)
text = self._recognizer.transcribe(audio)
log.info("Распознано: %s", text)
self._worker_recognized.emit(generation, text)
except Exception as exc:
log.exception("Сбой голосового ввода")
self._worker_failed.emit(generation, f"Ошибка голосового ввода: {exc}")
@Slot(int)
def _on_worker_recognizing(self, generation: int) -> None:
if generation == self._generation and self.is_listening:
self._phase = _Phase.RECOGNIZING
self.recognizing_started.emit()
@Slot(int, str)
def _on_worker_recognized(self, generation: int, text: str) -> None:
if generation == self._generation and self.is_active:
self._finish()
self.recognized.emit(text)
@Slot(int)
def _on_worker_done(self, generation: int) -> None:
if generation == self._generation and self.is_active:
self._finish()
@Slot(int, str)
def _on_worker_failed(self, generation: int, message: str) -> None:
if generation == self._generation:
self.cancel()
elif generation != _NO_GENERATION:
return
self.error_occurred.emit(message)
def _finish(self) -> None:
self._phase = _Phase.IDLE
self.finished.emit()
+134
View File
@@ -0,0 +1,134 @@
"""Фоновое прослушивание микрофона в ожидании слова активации."""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from typing import Any, Protocol
import sounddevice as sd
from PySide6.QtCore import QObject, Signal, Slot
from agr_assistent.audio.wakeword import SAMPLE_RATE
log = logging.getLogger(__name__)
# 100 мс: достаточно быстро реагирует и на слово, и на выключение
_BLOCK_FRAMES = SAMPLE_RATE // 10
class Session(Protocol):
def accept(self, pcm16: bytes) -> bool: ...
class Detector(Protocol):
def load(self) -> None: ...
def create_session(self) -> Session: ...
def open_microphone() -> Any:
return sd.RawInputStream(
samplerate=SAMPLE_RATE, blocksize=_BLOCK_FRAMES, channels=1, dtype="int16"
)
class WakeWordListener(QObject):
"""Слушает, пока включён и не поставлен на паузу.
Паузу ставит ассистент на время записи команды, ответа и озвучки: так микрофон
не занят дважды и ассистент не срабатывает на собственный голос.
"""
detected = Signal()
enabled_changed = Signal(bool)
error_occurred = Signal(str)
# Мост из фонового потока в главный
_worker_detected = Signal()
_worker_failed = Signal(str)
def __init__(
self,
detector: Detector,
*,
enabled: bool,
open_stream: Callable[[], Any] = open_microphone,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._detector = detector
self._open_stream = open_stream
self._enabled = enabled
self._paused = False
# Установлен, пока поток должен слушать микрофон
self._listening = threading.Event()
self._thread: threading.Thread | None = None
self._worker_detected.connect(self._on_worker_detected)
self._worker_failed.connect(self._on_worker_failed)
self._refresh()
@property
def enabled(self) -> bool:
return self._enabled
def set_enabled(self, enabled: bool) -> None:
if enabled != self._enabled:
self._enabled = enabled
self._refresh()
self.enabled_changed.emit(enabled)
def pause(self) -> None:
if not self._paused:
self._paused = True
self._refresh()
def resume(self) -> None:
if self._paused:
self._paused = False
self._refresh()
def _refresh(self) -> None:
if self._enabled and not self._paused:
if self._thread is None:
self._thread = threading.Thread(target=self._run, name="wake-word", daemon=True)
self._thread.start()
self._listening.set()
else:
self._listening.clear()
def _run(self) -> None:
"""Фоновый поток: слушает микрофон, пока установлен _listening."""
while True:
self._listening.wait()
detected = False
try:
session = self._detector.create_session()
with self._open_stream() as stream:
while self._listening.is_set():
data, _overflowed = stream.read(_BLOCK_FRAMES)
if session.accept(bytes(data)):
detected = True
break
except Exception as exc:
log.exception("Сбой прослушивания слова активации")
self._listening.clear()
self._worker_failed.emit(f"Слово активации отключено: {exc}")
continue
if detected:
# Микрофон уже закрыт — запись команды его не делит
self._listening.clear()
self._worker_detected.emit()
@Slot()
def _on_worker_detected(self) -> None:
if self._enabled:
self._paused = True
self._refresh()
self.detected.emit()
@Slot(str)
def _on_worker_failed(self, message: str) -> None:
self.set_enabled(False)
self.error_occurred.emit(message)
+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
+92
View File
@@ -0,0 +1,92 @@
# Настройки agr-assistent.
# Этот файл создаётся автоматически при первом запуске; недостающие ключи
# берутся из значений по умолчанию.
llm:
# Активный провайдер — один из ключей в providers
provider: ollama
# Любой OpenAI-совместимый сервер: Ollama, LM Studio, llama.cpp, OpenRouter...
providers:
ollama:
base_url: http://localhost:11434/v1
api_key: ollama
model: qwen2.5:7b
lmstudio:
base_url: http://localhost:1234/v1
api_key: lm-studio
model: local-model
openrouter:
base_url: https://openrouter.ai/api/v1
# Ключ можно вписать напрямую или взять из переменной окружения
api_key: ${OPENROUTER_API_KEY}
model: openai/gpt-4o-mini
system_prompt: >-
Ты — полезный голосовой ассистент. Отвечай кратко и по делу,
на языке пользователя.
temperature: 0.7
# Каждый запрос отдельный, но в течение этого времени после ответа можно уточнять
# («а завтра?») — модель увидит предыдущие вопросы. 0 — без уточнений
follow_up_seconds: 120
timeout_seconds: 120
tts:
# Озвучивать ответы ассистента (переключается и в меню трея)
enabled: true
# Модель Silero, скачивается при первом использовании:
# https://github.com/snakers4/silero-models#text-to-speech
model: v5_5_ru
# Голос: aidar, baya, kseniya, eugene, xenia
speaker: xenia
# 8000, 24000 или 48000
sample_rate: 48000
device: cpu
stt:
# Модель faster-whisper: tiny, base, small, medium, large-v3, large-v3-turbo.
# Скачивается при первом использовании (large-v3-turbo — ~1.6 ГБ)
model: large-v3-turbo
# auto (видеокарта NVIDIA, если доступна), cuda или cpu
device: auto
# auto, float16, int8_float16 (GPU), int8 (CPU)
compute_type: auto
# Язык речи; пустая строка — определять автоматически
language: ru
beam_size: 5
voice:
# Голосовой ввод: загрузка моделей при старте и горячая клавиша
enabled: true
# Глобальная горячая клавиша: ctrl, alt, shift, win + клавиша (буква, цифра, f1f24, space...).
# Нажатие начинает запись, повторное — заканчивает досрочно
hotkey: win+alt+space
# Короткие звуковые сигналы в начале и в конце записи
sound_cues: true
# Сколько ждать начала речи
start_timeout_seconds: 6
# Пауза, после которой фраза считается законченной
silence_seconds: 1.0
max_seconds: 30
# Порог детектора речи 0..1: выше — меньше ложных срабатываний на шум
vad_threshold: 0.5
wake_word:
# Постоянно слушать микрофон и начинать запись после ключевой фразы
# (переключается и в меню трея; работает только при voice.enabled)
enabled: false
# Ключевые фразы; все слова должны быть в словаре модели Vosk
phrases:
- ассистент
# Модель Vosk, скачивается при первом включении (~45 МБ)
model: vosk-model-small-ru-0.22
memory:
# Модель сама сохраняет устойчивые факты о вас (имя, близкие, техника, предпочтения).
# Явные просьбы «запомни…» и «забудь…» работают всегда. Факты хранятся локально,
# просмотреть и отредактировать их можно в настройках на вкладке «Память»
auto_save: true
ui:
# Запускаться сразу в трее, не показывая окно чата
start_minimized: false
+139
View File
@@ -0,0 +1,139 @@
"""Глобальная горячая клавиша (Windows, RegisterHotKey)."""
from __future__ import annotations
import ctypes
import logging
import re
import sys
import threading
from PySide6.QtCore import QObject, Signal
log = logging.getLogger(__name__)
_MOD_ALT = 0x0001
_MOD_CONTROL = 0x0002
_MOD_SHIFT = 0x0004
_MOD_WIN = 0x0008
_MOD_NOREPEAT = 0x4000
_WM_HOTKEY = 0x0312
_WM_QUIT = 0x0012
_HOTKEY_ID = 1
_MODIFIERS = {
"ctrl": _MOD_CONTROL,
"control": _MOD_CONTROL,
"alt": _MOD_ALT,
"shift": _MOD_SHIFT,
"win": _MOD_WIN,
}
_NAMED_KEYS = {
"space": 0x20,
"enter": 0x0D,
"tab": 0x09,
"esc": 0x1B,
"escape": 0x1B,
"backspace": 0x08,
"insert": 0x2D,
"delete": 0x2E,
"home": 0x24,
"end": 0x23,
"pageup": 0x21,
"pagedown": 0x22,
"pause": 0x13,
"left": 0x25,
"up": 0x26,
"right": 0x27,
"down": 0x28,
"`": 0xC0,
}
_FUNCTION_KEY = re.compile(r"f([1-9]|1\d|2[0-4])")
class HotkeyError(Exception):
pass
def parse_hotkey(spec: str) -> tuple[int, int]:
"""«ctrl+alt+space» -> (модификаторы, виртуальный код клавиши)."""
parts = [part.strip().lower() for part in spec.split("+")]
if not spec.strip() or not all(parts):
raise HotkeyError(f"Некорректная горячая клавиша: '{spec}'")
*modifier_names, key = parts
modifiers = 0
for name in modifier_names:
if name not in _MODIFIERS:
raise HotkeyError(f"Неизвестный модификатор '{name}' в горячей клавише '{spec}'")
modifiers |= _MODIFIERS[name]
virtual_key = key_code(key)
if virtual_key is None:
raise HotkeyError(f"Неизвестная клавиша '{key}' в горячей клавише '{spec}'")
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):
"""Слушает клавишу в отдельном потоке с собственной очередью сообщений Windows."""
activated = Signal()
failed = Signal(str)
def __init__(self, spec: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._spec = spec
self._modifiers, self._virtual_key = parse_hotkey(spec)
self._thread_id: int | None = None
self._ready = threading.Event()
def start(self) -> None:
if sys.platform != "win32":
self.failed.emit("Глобальные горячие клавиши пока поддерживаются только в Windows")
return
threading.Thread(target=self._run, name="global-hotkey", daemon=True).start()
def stop(self) -> None:
if self._ready.wait(timeout=1) and self._thread_id is not None:
ctypes.windll.user32.PostThreadMessageW(self._thread_id, _WM_QUIT, 0, 0)
def _run(self) -> None:
from ctypes import wintypes
user32 = ctypes.windll.user32
self._thread_id = ctypes.windll.kernel32.GetCurrentThreadId()
registered = user32.RegisterHotKey(
None, _HOTKEY_ID, self._modifiers | _MOD_NOREPEAT, self._virtual_key
)
self._ready.set()
if not registered:
message = (
f"Не удалось зарегистрировать горячую клавишу {self._spec}: "
"возможно, она уже занята другой программой"
)
log.warning(message)
self.failed.emit(message)
return
log.info("Горячая клавиша: %s", self._spec)
message = wintypes.MSG()
try:
while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0:
if message.message == _WM_HOTKEY:
self.activated.emit()
finally:
user32.UnregisterHotKey(None, _HOTKEY_ID)
View File
+128
View File
@@ -0,0 +1,128 @@
"""Клиент для любого OpenAI-совместимого API (Ollama, LM Studio, OpenRouter...)."""
from __future__ import annotations
import re
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
import openai
from openai import OpenAI
from agr_assistent import APP_NAME
from agr_assistent.config import ProviderConfig
# Ollama: «… does not support tools»; OpenRouter: «No endpoints found that support tool use»
_TOOLS_UNSUPPORTED = re.compile(r"support(s)?\s+tool", re.IGNORECASE)
class LLMError(Exception):
"""Ошибка обращения к модели с понятным пользователю текстом."""
class ToolsNotSupportedError(LLMError):
"""Модель не умеет вызывать инструменты."""
@dataclass(frozen=True)
class TextDelta:
text: str
@dataclass(frozen=True)
class ToolCall:
id: str
name: str
arguments: str # JSON-строка, как её прислала модель
@dataclass(frozen=True)
class ToolCalls:
"""Все вызовы инструментов ответа; приходят после окончания стрима."""
calls: list[ToolCall]
StreamEvent = TextDelta | ToolCalls
class LLMClient:
def __init__(
self, provider: ProviderConfig, *, temperature: float, timeout_seconds: float
) -> None:
if not provider.api_key:
raise LLMError(
f"Не задан API-ключ для провайдера '{provider.name}': "
f"укажите llm.providers.{provider.name}.api_key в config.yaml"
)
self._provider = provider
self._temperature = temperature
headers = {"X-Title": APP_NAME} if "openrouter.ai" in provider.base_url else None
self._client = OpenAI(
base_url=provider.base_url,
api_key=provider.api_key,
timeout=timeout_seconds,
max_retries=1,
default_headers=headers,
)
def stream_chat(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
) -> Iterator[StreamEvent]:
"""Отдаёт текст по мере генерации, а в конце — запрошенные вызовы инструментов."""
provider = self._provider
extra: dict[str, Any] = {"tools": tools} if tools else {}
try:
stream = self._client.chat.completions.create(
model=provider.model,
messages=messages, # type: ignore[arg-type]
temperature=self._temperature,
stream=True,
**extra,
)
# Вызов инструмента приходит кусками: имя и id один раз, аргументы — частями
pending: dict[int, dict[str, str]] = {}
with stream:
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
yield TextDelta(delta.content)
for call in delta.tool_calls or []:
entry = pending.setdefault(call.index, {"id": "", "name": "", "arguments": ""})
if call.id:
entry["id"] = call.id
if call.function and call.function.name and not entry["name"]:
entry["name"] = call.function.name
if call.function and call.function.arguments:
entry["arguments"] += call.function.arguments
if pending:
yield ToolCalls(
[
ToolCall(entry["id"] or f"call_{index}", entry["name"], entry["arguments"])
for index, entry in sorted(pending.items())
]
)
except openai.APITimeoutError as exc:
raise LLMError(f"Превышено время ожидания ответа от {provider.base_url}") from exc
except openai.APIConnectionError as exc:
raise LLMError(
f"Не удалось подключиться к {provider.base_url} — сервер запущен?"
) from exc
except openai.APIStatusError as exc:
raise self._status_error(exc, bool(tools)) from exc
def _status_error(self, exc: openai.APIStatusError, with_tools: bool) -> LLMError:
provider = self._provider
if with_tools and _TOOLS_UNSUPPORTED.search(str(exc.message)):
return ToolsNotSupportedError(
f"Модель '{provider.model}' не поддерживает инструменты: "
"память и команды недоступны, пока не выбрана другая модель"
)
if isinstance(exc, openai.AuthenticationError):
return LLMError(f"Неверный API-ключ для провайдера '{provider.name}'")
if isinstance(exc, openai.NotFoundError):
return LLMError(f"Модель '{provider.model}' или адрес {provider.base_url} не найдены")
return LLMError(f"Ошибка API ({exc.status_code}): {exc.message}")
+174
View File
@@ -0,0 +1,174 @@
"""Инструменты, которые модель может вызывать: описание, проверка аргументов, выполнение."""
from __future__ import annotations
import json
import logging
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Any
log = logging.getLogger(__name__)
class ToolError(Exception):
"""Ожидаемая ошибка инструмента: текст уходит модели и в журнал."""
@dataclass(frozen=True)
class ToolResult:
ok: bool
content: str # что увидит модель
display: str # что увидит пользователь в журнале
@dataclass(frozen=True)
class Tool:
name: str
description: str
parameters: dict[str, Any] # JSON Schema объекта аргументов
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]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
class ToolRegistry:
def __init__(self, tools: Iterable[Tool] = ()) -> None:
self._tools: dict[str, Tool] = {}
for tool in tools:
self.register(tool)
def __len__(self) -> int:
return len(self._tools)
def register(self, tool: Tool) -> None:
self._tools[tool.name] = tool
def schemas(self) -> list[dict[str, Any]]:
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:
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)
if tool is None:
return ToolResult(False, f"Инструмента {name} не существует", f"Неизвестный инструмент {name}")
try:
raw_arguments = json.loads(arguments_json or "{}")
except json.JSONDecodeError as exc:
return ToolResult(False, f"Аргументы не являются JSON: {exc}", f"{name}: некорректные аргументы")
if not isinstance(raw_arguments, dict):
return ToolResult(False, "Аргументы должны быть JSON-объектом", f"{name}: некорректные аргументы")
arguments, errors = validate_arguments(tool.parameters, raw_arguments)
if errors:
message = "; ".join(errors)
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:
return tool.handler(arguments)
except ToolError as exc:
return ToolResult(False, str(exc), str(exc))
except Exception as exc:
log.exception("Сбой инструмента %s", name)
return ToolResult(False, f"Ошибка выполнения: {exc}", f"{name}: ошибка — {exc}")
def validate_arguments(
schema: dict[str, Any], arguments: dict[str, Any]
) -> tuple[dict[str, Any], list[str]]:
"""Проверяет аргументы по упрощённой JSON Schema.
Небольшие локальные модели часто присылают числа и логические значения строками,
поэтому такие значения приводятся к нужному типу.
"""
properties: dict[str, Any] = schema.get("properties", {})
errors = [
f"не указан параметр {name}" for name in schema.get("required", []) if name not in arguments
]
result: dict[str, Any] = {}
for name, value in arguments.items():
spec = properties.get(name)
if spec is None:
continue # лишние параметры молча игнорируем
converted, error = _convert(value, spec.get("type"))
if error:
errors.append(f"{name}: {error}")
continue
if "enum" in spec and converted not in spec["enum"]:
allowed = ", ".join(str(option) for option in spec["enum"])
errors.append(f"{name}: недопустимое значение {converted!r} (допустимы: {allowed})")
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
return result, errors
def _convert(value: Any, expected: str | None) -> tuple[Any, str | None]:
if expected == "string":
if isinstance(value, (dict, list)):
return None, "ожидалась строка"
return str(value), None
if expected == "integer":
if isinstance(value, bool):
return None, "ожидалось целое число"
if isinstance(value, int):
return value, None
if isinstance(value, float) and value.is_integer():
return int(value), None
if isinstance(value, str):
try:
return int(value.strip()), None
except ValueError:
pass
return None, "ожидалось целое число"
if expected == "number":
if isinstance(value, bool):
return None, "ожидалось число"
if isinstance(value, (int, float)):
return value, None
if isinstance(value, str):
try:
return float(value.strip().replace(",", ".")), None
except ValueError:
pass
return None, "ожидалось число"
if expected == "boolean":
if isinstance(value, bool):
return value, None
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
return value.strip().lower() == "true", None
return None, "ожидалось true или false"
return value, None
+48
View File
@@ -0,0 +1,48 @@
"""Не даёт запустить второй экземпляр: вместо этого показывает окно уже запущенного."""
from __future__ import annotations
import getpass
import logging
from PySide6.QtCore import QObject, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from agr_assistent import APP_NAME
log = logging.getLogger(__name__)
_CONNECT_TIMEOUT_MS = 500
class SingleInstance(QObject):
activated = Signal() # кто-то попытался запустить приложение повторно
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._name = f"{APP_NAME}-{getpass.getuser()}"
self._server: QLocalServer | None = None
def acquire(self) -> bool:
"""True — мы единственный экземпляр; False — уже запущенный разбужен."""
socket = QLocalSocket()
socket.connectToServer(self._name)
if socket.waitForConnected(_CONNECT_TIMEOUT_MS):
socket.write(b"show")
socket.waitForBytesWritten(_CONNECT_TIMEOUT_MS)
socket.disconnectFromServer()
return False
# Сокет мог остаться после аварийного завершения
QLocalServer.removeServer(self._name)
self._server = QLocalServer(self)
self._server.newConnection.connect(self._on_new_connection)
if not self._server.listen(self._name):
log.warning("Не удалось занять имя экземпляра: %s", self._server.errorString())
return True
def _on_new_connection(self) -> None:
assert self._server is not None
while connection := self._server.nextPendingConnection():
connection.disconnected.connect(connection.deleteLater)
self.activated.emit()
View File
+113
View File
@@ -0,0 +1,113 @@
"""Распознавание речи через faster-whisper."""
from __future__ import annotations
import logging
import os
import sys
import threading
from pathlib import Path
from typing import Any
import numpy as np
from agr_assistent.config import STTConfig
log = logging.getLogger(__name__)
SAMPLE_RATE = 16000
_WARM_UP_SECONDS = 1
class STTError(Exception):
"""Ошибка распознавания речи с понятным пользователю текстом."""
class WhisperSTT:
def __init__(self, config: STTConfig, models_dir: Path) -> None:
self._config = config
self._download_root = models_dir / "whisper"
self._model: Any = None
self._lock = threading.Lock()
def load(self) -> None:
with self._lock:
if self._model is not None:
return
log.info(
"Загружаю модель распознавания %s (при первом запуске она скачивается)",
self._config.model,
)
device = self._resolve_device()
try:
model = self._create_model(device)
except Exception as exc:
if self._config.device != "auto" or device == "cpu":
raise STTError(f"Не удалось загрузить модель Whisper: {exc}") from exc
log.warning("Видеокарта недоступна (%s), распознаю речь на CPU", exc)
device = "cpu"
model = self._create_model(device)
self._model = model
log.info("Модель Whisper %s загружена (%s)", self._config.model, device)
def transcribe(self, audio: np.ndarray) -> str:
"""audio — моно float32 с частотой 16 кГц."""
self.load()
segments, _info = self._model.transcribe(
audio,
language=self._config.language,
beam_size=self._config.beam_size,
vad_filter=True,
condition_on_previous_text=False,
)
return " ".join(segment.text.strip() for segment in segments).strip()
def _resolve_device(self) -> str:
if self._config.device == "cpu":
return "cpu"
_add_cuda_dll_directories()
if self._config.device == "cuda":
return "cuda"
import ctranslate2
return "cuda" if ctranslate2.get_cuda_device_count() > 0 else "cpu"
def _create_model(self, device: str) -> Any:
from faster_whisper import WhisperModel
compute_type = self._config.compute_type
if compute_type == "auto":
compute_type = "int8_float16" if device == "cuda" else "int8"
model = WhisperModel(
self._config.model,
device=device,
compute_type=compute_type,
download_root=str(self._download_root),
)
# Прогон на тишине: библиотеки CUDA подгружаются лениво, ошибки всплывут здесь,
# а первое настоящее распознавание не будет ждать инициализации
silence = np.zeros(SAMPLE_RATE * _WARM_UP_SECONDS, dtype=np.float32)
segments, _info = model.transcribe(silence, language=self._config.language, beam_size=1)
list(segments)
return model
def _add_cuda_dll_directories() -> None:
"""cuBLAS и cuDNN из pip-пакетов nvidia-* не лежат в PATH — добавляем их сами."""
if sys.platform != "win32":
return
package_dirs: list[str] = []
try:
import nvidia
package_dirs.extend(nvidia.__path__)
except ImportError:
pass
# В сборке PyInstaller библиотеки лежат рядом с программой
if bundle_dir := getattr(sys, "_MEIPASS", None):
package_dirs.append(os.path.join(bundle_dir, "nvidia"))
for package_dir in package_dirs:
for bin_dir in Path(package_dir).glob("*/bin"):
if str(bin_dir) not in os.environ["PATH"]:
os.add_dll_directory(str(bin_dir))
os.environ["PATH"] = f"{bin_dir}{os.pathsep}{os.environ['PATH']}"
+76
View File
@@ -0,0 +1,76 @@
"""Интеграция с Windows: автозапуск, перезапуск, ожидание завершения процесса."""
from __future__ import annotations
import ctypes
import os
import subprocess
import sys
from pathlib import Path
from agr_assistent import APP_NAME
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
_SYNCHRONIZE = 0x00100000
_DETACHED_PROCESS = 0x00000008
def launch_command(config_path: Path) -> list[str]:
"""Команда запуска приложения без консольного окна."""
if getattr(sys, "frozen", False):
command = [sys.executable]
else:
pythonw = Path(sys.executable).with_name("pythonw.exe")
interpreter = pythonw if pythonw.exists() else Path(sys.executable)
command = [str(interpreter), "-m", "agr_assistent"]
return [*command, "--config", str(config_path)]
def autostart_supported() -> bool:
return sys.platform == "win32"
def is_autostart_enabled() -> bool:
if not autostart_supported():
return False
import winreg
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as key:
winreg.QueryValueEx(key, APP_NAME)
return True
except FileNotFoundError:
return False
def set_autostart(enabled: bool, config_path: Path) -> None:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
if enabled:
command = subprocess.list2cmdline(launch_command(config_path))
winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, command)
else:
try:
winreg.DeleteValue(key, APP_NAME)
except FileNotFoundError:
pass
def restart(config_path: Path) -> None:
"""Запускает новый экземпляр, который дождётся завершения текущего."""
command = [*launch_command(config_path), "--wait-pid", str(os.getpid())]
flags = _DETACHED_PROCESS if sys.platform == "win32" else 0
subprocess.Popen(command, creationflags=flags, close_fds=True)
def wait_for_process_exit(pid: int, timeout_seconds: float) -> None:
if sys.platform != "win32":
return
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(_SYNCHRONIZE, False, pid)
if handle:
try:
kernel32.WaitForSingleObject(handle, int(timeout_seconds * 1000))
finally:
kernel32.CloseHandle(handle)
View File
+22
View File
@@ -0,0 +1,22 @@
"""Общий интерфейс движков синтеза речи."""
from __future__ import annotations
from typing import Protocol
import numpy as np
class TTSError(Exception):
"""Ошибка синтеза речи с понятным пользователю текстом."""
class TTSEngine(Protocol):
@property
def sample_rate(self) -> int: ...
def load(self) -> None:
"""Подготавливает модель; повторные вызовы ничего не делают."""
def synthesize(self, text: str) -> np.ndarray:
"""Возвращает моно-сигнал float32 с частотой sample_rate."""
+93
View File
@@ -0,0 +1,93 @@
"""Синтез речи моделями Silero (https://github.com/snakers4/silero-models)."""
from __future__ import annotations
import logging
import os
import threading
import urllib.request
import warnings
from pathlib import Path
from typing import Any
import numpy as np
from agr_assistent.config import TTSConfig
from agr_assistent.tts.base import TTSError
log = logging.getLogger(__name__)
_MODEL_URL = "https://models.silero.ai/models/tts/{language}/{model}.pt"
_MAX_TORCH_THREADS = 4
_WARM_UP_TEXT = "Готова к работе."
class SileroTTS:
def __init__(self, config: TTSConfig, models_dir: Path) -> None:
self._config = config
self._model_path = models_dir / "silero" / f"{config.model}.pt"
self._model: Any = None
self._lock = threading.Lock()
@property
def sample_rate(self) -> int:
return self._config.sample_rate
def load(self) -> None:
with self._lock:
if self._model is not None:
return
if not self._model_path.exists():
self._download()
# torch импортируется долго, поэтому только при первой загрузке модели
import torch
torch.set_num_threads(min(_MAX_TORCH_THREADS, os.cpu_count() or 1))
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
importer = torch.package.PackageImporter(str(self._model_path))
model = importer.load_pickle("tts_models", "model")
model.to(torch.device(self._config.device))
except Exception as exc:
raise TTSError(f"Не удалось загрузить модель {self._model_path}: {exc}") from exc
if self._config.speaker not in model.speakers:
raise TTSError(
f"Голос '{self._config.speaker}' отсутствует в модели {self._config.model} "
f"(есть: {', '.join(model.speakers)})"
)
# Первый синтез заметно медленнее последующих — прогреваем заранее
self._apply_tts(model, _WARM_UP_TEXT)
self._model = model
log.info("Модель Silero %s загружена", self._config.model)
def synthesize(self, text: str) -> np.ndarray:
self.load()
return self._apply_tts(self._model, text)
def _apply_tts(self, model: Any, text: str) -> np.ndarray:
import torch
with torch.inference_mode(), warnings.catch_warnings():
warnings.simplefilter("ignore")
audio = model.apply_tts(
text=text,
speaker=self._config.speaker,
sample_rate=self._config.sample_rate,
)
return audio.cpu().numpy().astype(np.float32, copy=False)
def _download(self) -> None:
language = self._config.model.rsplit("_", 1)[-1]
url = _MODEL_URL.format(language=language, model=self._config.model)
partial_path = self._model_path.with_suffix(".part")
log.info("Скачиваю модель Silero: %s", url)
try:
self._model_path.parent.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve(url, partial_path)
partial_path.replace(self._model_path)
except OSError as exc:
partial_path.unlink(missing_ok=True)
raise TTSError(f"Не удалось скачать модель Silero {url}: {exc}") from exc
+94
View File
@@ -0,0 +1,94 @@
"""Подготовка ответа модели к озвучке: нарезка потока на фразы и очистка текста."""
from __future__ import annotations
import re
from num2words import num2words
# Длиннее ~1000 символов Silero не синтезирует; режем с запасом
MAX_SEGMENT_CHARS = 400
# Конец предложения (но не «3.5» и не «1. пункт») или перевод строки
_SEGMENT_BOUNDARY = re.compile(r"(?<=\D[.!?…])\s+|\n")
_CODE_FENCE = "```"
_MARKDOWN_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
_URL = re.compile(r"https?://\S+?(?=[.,!?;:)]*(?:\s|$))")
_LIST_MARKER = re.compile(r"^\s*(?:[-*+•]|\d+[.)])\s+")
_NUMBER = re.compile(r"\d+(?:[.,]\d+)?")
# Всё, кроме кириллицы и знаков, влияющих на интонацию
_UNSPEAKABLE = re.compile(r"[^а-яё!,.:;?…–\s-]", re.IGNORECASE)
_CYRILLIC = re.compile(r"[а-яё]", re.IGNORECASE)
_SPACES = re.compile(r"\s+")
_SPACE_BEFORE_PUNCTUATION = re.compile(r"\s+(?=[!,.:;?…])")
class SpeechTextStream:
"""Принимает текст кусками по мере генерации и отдаёт готовые к озвучке фразы."""
def __init__(self) -> None:
self._buffer = ""
self._in_code_block = False
def feed(self, chunk: str) -> list[str]:
self._buffer += chunk
segments = []
while match := _SEGMENT_BOUNDARY.search(self._buffer):
segments.append(self._buffer[: match.start()])
self._buffer = self._buffer[match.end() :]
return self._speakable(segments)
def flush(self) -> list[str]:
rest, self._buffer = self._buffer, ""
return self._speakable([rest])
def _speakable(self, segments: list[str]) -> list[str]:
result = []
for segment in segments:
# Блоки кода не озвучиваем
if segment.lstrip().startswith(_CODE_FENCE):
self._in_code_block = not self._in_code_block
continue
if self._in_code_block:
continue
if text := normalize_for_speech(segment):
result.extend(split_long_text(text))
return result
def normalize_for_speech(text: str) -> str:
"""Убирает разметку, переводит числа в слова; пустая строка — озвучивать нечего."""
text = _MARKDOWN_LINK.sub(r"\1", text)
text = _URL.sub(" ссылка ", text)
text = _LIST_MARKER.sub("", text)
# «+» у Silero — маркер ударения
text = text.replace("+", " плюс ").replace("", "")
text = _NUMBER.sub(_number_to_words, text)
text = _UNSPEAKABLE.sub(" ", text)
text = _SPACES.sub(" ", text)
text = _SPACE_BEFORE_PUNCTUATION.sub("", text).strip()
return text if _CYRILLIC.search(text) else ""
def split_long_text(text: str, limit: int = MAX_SEGMENT_CHARS) -> list[str]:
parts = []
while len(text) > limit:
cut = text.rfind(", ", 0, limit)
cut = cut + 1 if cut > 0 else text.rfind(" ", 0, limit)
if cut <= 0:
cut = limit
parts.append(text[:cut].strip())
text = text[cut:].strip()
if text:
parts.append(text)
return parts
def _number_to_words(match: re.Match[str]) -> str:
raw = match.group().replace(",", ".")
try:
value: int | float = float(raw) if "." in raw else int(raw)
return f" {num2words(value, lang='ru')} "
except (ValueError, OverflowError, NotImplementedError):
return " "
View File
+269
View File
@@ -0,0 +1,269 @@
"""Окно ассистента: журнал запросов и ответов и поле ввода."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import (
QCloseEvent,
QColor,
QFont,
QKeyEvent,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPlainTextEdit,
QPushButton,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from agr_assistent import APP_NAME
from agr_assistent.core.assistant import Assistant, AssistantState
from agr_assistent.ui.icons import state_icon
_ROLE_STYLES = {
"user": ("Вы", "#1e88e5"),
"assistant": ("Ассистент", "#43a047"),
"error": ("Ошибка", "#e53935"),
"tool": ("Действие", "#8e24aa"),
"tool_failed": ("Действие не выполнено", "#e53935"),
}
_VOICE_BUTTON_TEXTS = {
AssistantState.LISTENING: "Готово",
AssistantState.RECOGNIZING: "Распознаю…",
}
# Во время стриминга перерисовываем журнал не чаще, чем раз в N мс
_RENDER_INTERVAL_MS = 50
# Журнал перерисовывается целиком, поэтому храним только последние записи
_MAX_ENTRIES = 100
_META_COLOR = "#9e9e9e"
@dataclass
class _Entry:
role: str
text: str
follow_up: bool = False
time: datetime = field(default_factory=datetime.now)
class _MessageInput(QPlainTextEdit):
"""Enter отправляет сообщение, Shift+Enter переносит строку."""
submitted = Signal()
def keyPressEvent(self, event: QKeyEvent) -> None:
is_enter = event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
if is_enter and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
self.submitted.emit()
return
super().keyPressEvent(event)
class ChatWindow(QWidget):
settings_requested = Signal()
def __init__(self, assistant: Assistant) -> None:
super().__init__()
self._assistant = assistant
self._entries: list[_Entry] = []
self.setWindowTitle(APP_NAME)
self.setWindowIcon(state_icon(assistant.state))
self.resize(560, 680)
self._provider_label = QLabel()
clear_button = QPushButton("Очистить журнал")
clear_button.clicked.connect(assistant.clear_journal)
self._transcript = QTextBrowser()
self._transcript.setOpenExternalLinks(True)
self._input = _MessageInput()
self._input.setPlaceholderText(
"Запрос или команда… (Enter — отправить, Shift+Enter — новая строка)"
)
self._input.setMaximumHeight(90)
self._input.submitted.connect(self._submit)
self._send_button = QPushButton()
self._send_button.setMinimumWidth(100)
self._send_button.clicked.connect(self._on_send_clicked)
self._voice_button = QPushButton()
self._voice_button.setMinimumWidth(100)
self._voice_button.clicked.connect(assistant.toggle_listening)
self._voice_button.setVisible(assistant.voice_available)
settings_button = QPushButton("Настройки")
settings_button.clicked.connect(self.settings_requested)
header = QHBoxLayout()
header.addWidget(self._provider_label, 1)
header.addWidget(clear_button)
header.addWidget(settings_button)
buttons = QVBoxLayout()
buttons.addWidget(self._send_button)
buttons.addWidget(self._voice_button)
buttons.addStretch()
input_row = QHBoxLayout()
input_row.addWidget(self._input, 1)
input_row.addLayout(buttons)
layout = QVBoxLayout(self)
layout.addLayout(header)
layout.addWidget(self._transcript, 1)
layout.addLayout(input_row)
self._render_timer = QTimer(self)
self._render_timer.setSingleShot(True)
self._render_timer.setInterval(_RENDER_INTERVAL_MS)
self._render_timer.timeout.connect(self._render)
assistant.state_changed.connect(self._on_state_changed)
assistant.provider_changed.connect(self._update_provider_label)
assistant.request_added.connect(
lambda text, follow_up: self._append(_Entry("user", text, follow_up))
)
assistant.reply_started.connect(lambda: self._append(_Entry("assistant", "")))
assistant.reply_chunk.connect(self._on_reply_chunk)
assistant.reply_finished.connect(self._on_reply_finished)
assistant.error_occurred.connect(lambda message: self._append(_Entry("error", message)))
assistant.tool_executed.connect(self._on_tool_executed)
assistant.journal_cleared.connect(self._on_journal_cleared)
self._update_provider_label()
self._on_state_changed(assistant.state)
def show_and_raise(self) -> None:
self.showNormal()
self.raise_()
self.activateWindow()
self._input.setFocus()
def toggle_visibility(self) -> None:
if self.isVisible() and not self.isMinimized():
self.hide()
else:
self.show_and_raise()
def closeEvent(self, event: QCloseEvent) -> None:
# Закрытие окна сворачивает приложение в трей; выход — через меню трея
event.ignore()
self.hide()
def _submit(self) -> None:
text = self._input.toPlainText().strip()
if not text or self._assistant.is_generating:
return
self._input.clear()
self._assistant.send(text)
def _on_send_clicked(self) -> None:
if self._assistant.is_busy:
self._assistant.cancel()
else:
self._submit()
def _on_state_changed(self, state: object) -> None:
current = self._assistant.state
self._send_button.setText("Стоп" if self._assistant.is_busy else "Отправить")
self._voice_button.setText(_VOICE_BUTTON_TEXTS.get(current, "Говорить"))
self._voice_button.setEnabled(current is not AssistantState.RECOGNIZING)
self.setWindowIcon(state_icon(current))
def _update_provider_label(self) -> None:
self._provider_label.setText(
f"Модель: <b>{self._assistant.provider_name}</b> · {self._assistant.model_name}"
)
def _on_reply_chunk(self, piece: str) -> None:
# После действия текст ответа продолжается новой записью под ним
if not self._entries or self._entries[-1].role != "assistant":
self._entries.append(_Entry("assistant", ""))
self._entries[-1].text += piece
if not self._render_timer.isActive():
self._render_timer.start()
def _on_tool_executed(self, display: str, ok: bool) -> None:
self._drop_empty_reply()
self._append(_Entry("tool" if ok else "tool_failed", display))
def _on_reply_finished(self, text: str) -> None:
self._drop_empty_reply()
self._render_now()
def _drop_empty_reply(self) -> None:
last = self._entries[-1] if self._entries else None
if last and last.role == "assistant" and not last.text.strip():
self._entries.pop()
def _on_journal_cleared(self) -> None:
self._entries.clear()
self._render_now()
def _append(self, entry: _Entry) -> None:
self._entries.append(entry)
del self._entries[:-_MAX_ENTRIES]
self._render_now()
def _render_now(self) -> None:
self._render_timer.stop()
self._render()
def _render(self) -> None:
scrollbar = self._transcript.verticalScrollBar()
previous_position = scrollbar.value()
stick_to_bottom = previous_position >= scrollbar.maximum() - 4
document = self._transcript.document()
document.clear()
cursor = QTextCursor(document)
for index, entry in enumerate(self._entries):
title, color = _ROLE_STYLES[entry.role]
title_block = QTextBlockFormat()
title_block.setTopMargin(14 if index else 0)
if index:
cursor.insertBlock(title_block, QTextCharFormat())
else:
cursor.setBlockFormat(title_block)
title_format = QTextCharFormat()
title_format.setFontWeight(QFont.Weight.Bold)
title_format.setForeground(QColor(color))
cursor.insertText(title, title_format)
meta = f" {entry.time:%H:%M}"
if entry.follow_up:
meta += " · уточнение"
meta_format = QTextCharFormat()
meta_format.setForeground(QColor(_META_COLOR))
cursor.insertText(meta, meta_format)
cursor.insertBlock(QTextBlockFormat(), QTextCharFormat())
if entry.role == "assistant":
cursor.insertMarkdown(entry.text or "")
else:
cursor.insertText(entry.text)
# Диапазон прокрутки обновляется после раскладки документа
QTimer.singleShot(
0,
lambda: scrollbar.setValue(
scrollbar.maximum() if stick_to_bottom else previous_position
),
)
+55
View File
@@ -0,0 +1,55 @@
"""Иконки состояний, рисуются программно — без файлов ресурсов."""
from __future__ import annotations
from functools import cache
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
from agr_assistent.core.assistant import AssistantState
STATE_TITLES = {
AssistantState.IDLE: "ожидание",
AssistantState.LISTENING: "слушаю…",
AssistantState.RECOGNIZING: "распознаю…",
AssistantState.THINKING: "думаю…",
AssistantState.SPEAKING: "говорю…",
}
_STATE_COLORS = {
AssistantState.IDLE: "#5c6bc0",
AssistantState.LISTENING: "#e53935",
AssistantState.RECOGNIZING: "#8e24aa",
AssistantState.THINKING: "#fb8c00",
AssistantState.SPEAKING: "#43a047",
}
_ICON_SIZE = 64
@cache
def state_icon(state: AssistantState) -> QIcon:
return QIcon(state_pixmap(state, _ICON_SIZE))
def state_pixmap(state: AssistantState, size: int) -> QPixmap:
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
margin = max(1, size // 32)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(_STATE_COLORS[state]))
painter.drawEllipse(margin, margin, size - 2 * margin, size - 2 * margin)
font = painter.font()
font.setPixelSize(round(size * 0.6))
font.setBold(True)
painter.setFont(font)
painter.setPen(QColor("white"))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "A")
painter.end()
return pixmap
+517
View File
@@ -0,0 +1,517 @@
"""Окно настроек."""
from __future__ import annotations
import html
import logging
import threading
from pathlib import Path
from typing import Any
from PySide6.QtCore import QObject, Qt, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSpinBox,
QTabWidget,
QTextBrowser,
QToolButton,
QVBoxLayout,
QWidget,
)
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.core.memory import SOURCE_USER, MemoryStore
from agr_assistent.core.settings import Settings, needs_restart
log = logging.getLogger(__name__)
_AUTO_FACT_COLOR = "#757575"
_ERROR_COLOR = "#e53935"
_TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"]
_STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"]
_STT_DEVICES = ["auto", "cuda", "cpu"]
class _ModelListLoader(QObject):
"""Загружает список моделей провайдера в фоне."""
loaded = Signal(list)
failed = Signal(str)
def load(self, base_url: str, api_key: str) -> None:
threading.Thread(
target=self._run, args=(base_url, api_key), name="llm-models", daemon=True
).start()
def _run(self, base_url: str, api_key: str) -> None:
try:
from openai import OpenAI
client = OpenAI(base_url=base_url, api_key=api_key or "none", timeout=15, max_retries=0)
self.loaded.emit(sorted(model.id for model in client.models.list()))
except Exception as exc:
self.failed.emit(str(exc))
class SettingsDialog(QDialog):
def __init__(
self,
settings: Settings,
memory: MemoryStore | None = None,
commands: CommandCatalog | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._settings = settings
self._memory = memory
self._commands = commands
self._raw = settings.raw()
self._provider_edits: dict[str, dict[str, str]] = {
name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")}
for name, values in (get_value(self._raw, "llm.providers") or {}).items()
}
self._current_provider = ""
self.setWindowTitle(f"{APP_NAME} — настройки")
self.setMinimumWidth(560)
tabs = QTabWidget()
tabs.addTab(self._build_llm_tab(), "Модель")
tabs.addTab(self._build_speech_tab(), "Озвучка")
tabs.addTab(self._build_voice_tab(), "Голосовой ввод")
tabs.addTab(self._build_memory_tab(), "Память")
tabs.addTab(self._build_commands_tab(), "Команды")
tabs.addTab(self._build_general_tab(), "Общие")
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(tabs)
layout.addWidget(buttons)
# --- вкладки
def _build_llm_tab(self) -> QWidget:
self._provider = QComboBox()
self._provider.addItems(list(self._provider_edits))
self._base_url = QLineEdit()
self._api_key = QLineEdit()
self._api_key.setEchoMode(QLineEdit.EchoMode.Password)
self._api_key.setPlaceholderText("ключ или ${ПЕРЕМЕННАЯ_ОКРУЖЕНИЯ}")
self._model = QComboBox()
self._model.setEditable(True)
self._model.setSizePolicy(self._base_url.sizePolicy())
refresh_models = QToolButton()
refresh_models.setText("")
refresh_models.setToolTip("Загрузить список моделей с сервера")
refresh_models.clicked.connect(self._load_models)
model_row = QHBoxLayout()
model_row.addWidget(self._model, 1)
model_row.addWidget(refresh_models)
self._models_status = QLabel()
self._models_status.setWordWrap(True)
self._model_loader = _ModelListLoader(self)
self._model_loader.loaded.connect(self._on_models_loaded)
self._model_loader.failed.connect(
lambda message: self._models_status.setText(f"Не удалось загрузить: {message}")
)
self._system_prompt = QPlainTextEdit(str(get_value(self._raw, "llm.system_prompt") or ""))
self._system_prompt.setMaximumHeight(90)
self._temperature = QDoubleSpinBox()
self._temperature.setRange(0, 2)
self._temperature.setSingleStep(0.1)
self._temperature.setValue(float(get_value(self._raw, "llm.temperature")))
self._follow_up = QSpinBox()
self._follow_up.setRange(0, 3600)
self._follow_up.setSuffix(" с")
self._follow_up.setSpecialValueText("без уточнений")
self._follow_up.setToolTip(
"Сколько времени после ответа можно задать уточнение («а завтра?»), "
"которое увидит предыдущие вопросы"
)
self._follow_up.setValue(int(float(get_value(self._raw, "llm.follow_up_seconds"))))
self._provider.currentTextChanged.connect(self._switch_provider)
self._provider.setCurrentText(str(get_value(self._raw, "llm.provider")))
self._switch_provider(self._provider.currentText())
form = QFormLayout()
form.addRow("Провайдер", self._provider)
form.addRow("Адрес API", self._base_url)
form.addRow("API-ключ", self._api_key)
form.addRow("Модель", model_row)
form.addRow("", self._models_status)
form.addRow("Системный промпт", self._system_prompt)
form.addRow("Температура", self._temperature)
form.addRow("Окно уточнений", self._follow_up)
return _page(form)
def _build_speech_tab(self) -> QWidget:
self._tts_enabled = QCheckBox("Озвучивать ответы")
self._tts_enabled.setChecked(bool(get_value(self._raw, "tts.enabled")))
self._tts_speaker = _combo(_TTS_SPEAKERS, str(get_value(self._raw, "tts.speaker")))
form = QFormLayout()
form.addRow(self._tts_enabled)
form.addRow("Голос", self._tts_speaker)
return _page(form)
def _build_voice_tab(self) -> QWidget:
self._voice_enabled = QCheckBox("Голосовой ввод")
self._voice_enabled.setChecked(bool(get_value(self._raw, "voice.enabled")))
self._hotkey = QLineEdit(str(get_value(self._raw, "voice.hotkey") or ""))
self._hotkey.setPlaceholderText("например, win+alt+space; пусто — без клавиши")
self._sound_cues = QCheckBox("Звуковые сигналы начала и конца записи")
self._sound_cues.setChecked(bool(get_value(self._raw, "voice.sound_cues")))
self._silence = QDoubleSpinBox()
self._silence.setRange(0.3, 5)
self._silence.setSingleStep(0.1)
self._silence.setSuffix(" с")
self._silence.setValue(float(get_value(self._raw, "voice.silence_seconds")))
self._vad_threshold = QDoubleSpinBox()
self._vad_threshold.setRange(0.1, 0.95)
self._vad_threshold.setSingleStep(0.05)
self._vad_threshold.setValue(float(get_value(self._raw, "voice.vad_threshold")))
self._stt_model = _combo(_STT_MODELS, str(get_value(self._raw, "stt.model")))
self._stt_device = _combo(_STT_DEVICES, str(get_value(self._raw, "stt.device")))
self._stt_language = QLineEdit(str(get_value(self._raw, "stt.language") or ""))
self._stt_language.setPlaceholderText("ru, en…; пусто — определять автоматически")
self._wake_enabled = QCheckBox("Слушать слово активации")
self._wake_enabled.setChecked(bool(get_value(self._raw, "wake_word.enabled")))
phrases = get_value(self._raw, "wake_word.phrases") or []
if isinstance(phrases, str):
phrases = [phrases]
self._wake_phrases = QLineEdit(", ".join(str(phrase) for phrase in phrases))
self._wake_phrases.setPlaceholderText("через запятую, например: ассистент, эй компьютер")
form = QFormLayout()
form.addRow(self._voice_enabled)
form.addRow("Горячая клавиша", self._hotkey)
form.addRow(self._sound_cues)
form.addRow("Пауза в конце фразы", self._silence)
form.addRow("Порог детектора речи", self._vad_threshold)
form.addRow(_section("Распознавание"))
form.addRow("Модель Whisper", self._stt_model)
form.addRow("Устройство", self._stt_device)
form.addRow("Язык", self._stt_language)
form.addRow(_section("Слово активации"))
form.addRow(self._wake_enabled)
form.addRow("Фразы", self._wake_phrases)
return _page(form)
def _build_memory_tab(self) -> QWidget:
self._memory_auto_save = QCheckBox("Запоминать факты о вас автоматически")
self._memory_auto_save.setToolTip(
"Модель сама сохраняет устойчивые факты: имя, близких, технику, предпочтения. "
"Просьбы «запомни…» и «забудь…» работают всегда"
)
self._memory_auto_save.setChecked(bool(get_value(self._raw, "memory.auto_save")))
self._facts = QListWidget()
self._facts.setWordWrap(True)
self._facts.itemChanged.connect(self._on_fact_edited)
add_button = QPushButton("Добавить…")
add_button.clicked.connect(self._add_fact)
delete_button = QPushButton("Удалить")
delete_button.clicked.connect(self._delete_facts)
self._facts.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
buttons = QHBoxLayout()
buttons.addWidget(add_button)
buttons.addWidget(delete_button)
buttons.addStretch()
hint = QLabel(
"Двойной щелчок — изменить факт. Изменения списка сохраняются сразу. "
"Серым показаны факты, которые модель сохранила сама."
)
hint.setWordWrap(True)
page = QWidget()
layout = QVBoxLayout(page)
layout.addWidget(self._memory_auto_save)
layout.addWidget(self._facts, 1)
layout.addLayout(buttons)
layout.addWidget(hint)
page.setEnabled(self._memory is not None)
self._reload_facts()
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:
self._start_minimized = QCheckBox("Запускаться свёрнутым в трей")
self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized")))
self._autostart = QCheckBox("Запускать вместе с Windows")
self._autostart.setVisible(system.autostart_supported())
self._autostart.setChecked(system.is_autostart_enabled())
config_path = self._settings.config.path
open_config = QPushButton("Открыть config.yaml")
open_config.clicked.connect(lambda: _open_path(config_path))
open_data = QPushButton("Открыть папку с моделями и логами")
open_data.clicked.connect(lambda: _open_path(data_dir()))
note = QLabel(
"Редко используемые параметры (провайдеры, таймауты, модели Silero и Vosk) "
f"настраиваются в файле:<br><code>{config_path}</code>"
)
note.setWordWrap(True)
note.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
form = QFormLayout()
form.addRow(self._start_minimized)
form.addRow(self._autostart)
form.addRow(note)
form.addRow(open_config)
form.addRow(open_data)
return _page(form)
# --- провайдеры и модели
def _switch_provider(self, name: str) -> None:
if self._current_provider in self._provider_edits:
self._provider_edits[self._current_provider] = self._current_provider_fields()
self._current_provider = name
fields = self._provider_edits.get(name, {})
self._base_url.setText(fields.get("base_url", ""))
self._api_key.setText(fields.get("api_key", ""))
self._model.clear()
self._model.setEditText(fields.get("model", ""))
self._models_status.clear()
def _current_provider_fields(self) -> dict[str, str]:
return {
"base_url": self._base_url.text().strip(),
"api_key": self._api_key.text().strip(),
"model": self._model.currentText().strip(),
}
def _load_models(self) -> None:
self._models_status.setText("Загружаю список моделей…")
self._model_loader.load(
self._base_url.text().strip(), expand_env(self._api_key.text().strip())
)
def _on_models_loaded(self, models: list[str]) -> None:
current = self._model.currentText()
self._model.clear()
self._model.addItems(models)
self._model.setEditText(current)
self._models_status.setText(f"Доступно моделей: {len(models)}")
# --- память
def _reload_facts(self) -> None:
if self._memory is None:
return
self._facts.blockSignals(True)
self._facts.clear()
for fact in self._memory.facts():
item = QListWidgetItem(fact.text)
item.setData(Qt.ItemDataRole.UserRole, fact.id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
if fact.is_auto:
item.setForeground(QColor(_AUTO_FACT_COLOR))
source = "сохранён автоматически" if fact.is_auto else "по вашей просьбе"
item.setToolTip(f"{source}, изменён {fact.updated_at:%d.%m.%Y %H:%M}")
self._facts.addItem(item)
self._facts.blockSignals(False)
def _on_fact_edited(self, item: QListWidgetItem) -> None:
assert self._memory is not None
if text := item.text().strip():
self._memory.update(item.data(Qt.ItemDataRole.UserRole), text)
self._reload_facts()
def _add_fact(self) -> None:
assert self._memory is not None
text, ok = QInputDialog.getText(self, "Новый факт", "Что запомнить:")
if ok and text.strip():
self._memory.add(text, SOURCE_USER)
self._reload_facts()
def _delete_facts(self) -> None:
assert self._memory is not None
for item in self._facts.selectedItems():
self._memory.delete(item.data(Qt.ItemDataRole.UserRole))
self._reload_facts()
# --- сохранение
def _collect(self) -> dict[str, Any]:
self._provider_edits[self._current_provider] = self._current_provider_fields()
phrases = [phrase.strip() for phrase in self._wake_phrases.text().split(",") if phrase.strip()]
values: dict[str, Any] = {
"llm.provider": self._provider.currentText(),
"llm.system_prompt": self._system_prompt.toPlainText().strip(),
"llm.temperature": round(self._temperature.value(), 2),
"llm.follow_up_seconds": self._follow_up.value(),
"tts.enabled": self._tts_enabled.isChecked(),
"tts.speaker": self._tts_speaker.currentText(),
"voice.enabled": self._voice_enabled.isChecked(),
"voice.hotkey": self._hotkey.text().strip().lower(),
"voice.sound_cues": self._sound_cues.isChecked(),
"voice.silence_seconds": round(self._silence.value(), 2),
"voice.vad_threshold": round(self._vad_threshold.value(), 2),
"stt.model": self._stt_model.currentText().strip(),
"stt.device": self._stt_device.currentText(),
"stt.language": self._stt_language.text().strip(),
"wake_word.enabled": self._wake_enabled.isChecked(),
"wake_word.phrases": phrases,
"memory.auto_save": self._memory_auto_save.isChecked(),
"ui.start_minimized": self._start_minimized.isChecked(),
}
for name, fields in self._provider_edits.items():
for field, value in fields.items():
values[f"llm.providers.{name}.{field}"] = value
# Записываем только изменённое, чтобы не раздувать пользовательский конфиг
return {
key: value for key, value in values.items() if _differs(get_value(self._raw, key), value)
}
def _save(self) -> None:
old_config = self._settings.config
try:
new_config = self._settings.save(self._collect())
except ConfigError as exc:
QMessageBox.warning(self, APP_NAME, f"Настройки не сохранены:\n{exc}")
return
if self._autostart.isVisible() and self._autostart.isChecked() != system.is_autostart_enabled():
try:
system.set_autostart(self._autostart.isChecked(), new_config.path)
except OSError as exc:
QMessageBox.warning(self, APP_NAME, f"Не удалось изменить автозапуск: {exc}")
self.accept()
if needs_restart(old_config, new_config):
answer = QMessageBox.question(
self.parentWidget(),
APP_NAME,
"Часть изменений вступит в силу после перезапуска. Перезапустить сейчас?",
)
if answer == QMessageBox.StandardButton.Yes:
system.restart(new_config.path)
from PySide6.QtWidgets import QApplication
QApplication.quit()
def _differs(old: Any, new: Any) -> bool:
if old is None:
return new not in ("", [], None)
if isinstance(old, (int, float)) and not isinstance(old, bool) and isinstance(new, (int, float)):
return abs(float(old) - float(new)) > 1e-9
if isinstance(new, str):
return str(old) != new
return old != new
def _combo(items: list[str], current: str) -> QComboBox:
combo = QComboBox()
combo.setEditable(True)
combo.addItems(items)
combo.setCurrentText(current)
return combo
def _section(title: str) -> QLabel:
label = QLabel(f"<b>{title}</b>")
label.setContentsMargins(0, 8, 0, 0)
return label
def _page(form: QFormLayout) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.addLayout(form)
layout.addStretch()
return page
def _open_path(path: Path) -> None:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
+131
View File
@@ -0,0 +1,131 @@
"""Значок в системном трее и его меню."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from agr_assistent import APP_NAME
from agr_assistent.config import ConfigError
from agr_assistent.core.assistant import Assistant, AssistantState
from agr_assistent.core.settings import Settings
from agr_assistent.ui.chat_window import ChatWindow
from agr_assistent.ui.icons import STATE_TITLES, state_icon
_NOTIFICATION_PREVIEW_CHARS = 200
_NOTIFICATION_TIMEOUT_MS = 6000
class TrayIcon(QSystemTrayIcon):
def __init__(
self,
assistant: Assistant,
window: ChatWindow,
settings: Settings,
open_settings: Callable[[], None],
hotkey: str = "",
) -> None:
super().__init__(state_icon(assistant.state))
self._assistant = assistant
self._window = window
self._settings = settings
menu = QMenu()
menu.addAction("Открыть чат", window.show_and_raise)
if assistant.voice_available:
# Текст после табуляции QMenu выравнивает по правому краю, как подсказку клавиши
label = f"Говорить\t{hotkey.title()}" if hotkey else "Говорить"
menu.addAction(label, assistant.toggle_listening)
if assistant.wake_word_available:
phrases = ", ".join(f"«{phrase}»" for phrase in settings.config.wake_word.phrases)
wake_action = QAction(f"Слушать слово активации: {phrases}", self, checkable=True)
wake_action.setChecked(assistant.wake_word_enabled)
# triggered — только действие пользователя: автоотключение после ошибки не сохраняем
wake_action.triggered.connect(self._on_wake_word_triggered)
assistant.wake_word_enabled_changed.connect(wake_action.setChecked)
menu.addAction(wake_action)
menu.addSeparator()
provider_menu = menu.addMenu("Провайдер LLM")
provider_group = QActionGroup(self)
self._provider_actions: dict[str, QAction] = {}
for name in assistant.provider_names:
action = QAction(name, self, checkable=True)
action.setChecked(name == assistant.provider_name)
action.triggered.connect(lambda _checked=False, n=name: self._on_provider_triggered(n))
provider_group.addAction(action)
provider_menu.addAction(action)
self._provider_actions[name] = action
self._speech_action = QAction("Озвучивать ответы", self, checkable=True)
self._speech_action.setChecked(assistant.speech_enabled)
self._speech_action.triggered.connect(self._on_speech_triggered)
menu.addAction(self._speech_action)
menu.addAction("Настройки…", open_settings)
menu.addSeparator()
menu.addAction("Выход", QApplication.quit)
# Меню нужно держать в атрибуте, иначе его соберёт сборщик мусора
self._menu = menu
self.setContextMenu(menu)
self.activated.connect(self._on_activated)
self.messageClicked.connect(window.show_and_raise)
assistant.state_changed.connect(self._on_state_changed)
assistant.provider_changed.connect(self._on_provider_changed)
assistant.speech_enabled_changed.connect(self._speech_action.setChecked)
assistant.reply_finished.connect(self._on_reply_finished)
assistant.error_occurred.connect(self._on_error)
self._on_state_changed(assistant.state)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
if reason in (
QSystemTrayIcon.ActivationReason.Trigger,
QSystemTrayIcon.ActivationReason.DoubleClick,
):
self._window.toggle_visibility()
def _on_provider_triggered(self, name: str) -> None:
self._assistant.set_provider(name)
self._persist({"llm.provider": name})
def _on_speech_triggered(self, enabled: bool) -> None:
self._assistant.set_speech_enabled(enabled)
self._persist({"tts.enabled": enabled})
def _on_wake_word_triggered(self, enabled: bool) -> None:
self._assistant.set_wake_word_enabled(enabled)
self._persist({"wake_word.enabled": enabled})
def _persist(self, updates: dict[str, Any]) -> None:
try:
self._settings.save(updates)
except (ConfigError, OSError) as exc:
self._assistant.error_occurred.emit(f"Не удалось сохранить настройки: {exc}")
def _on_state_changed(self, state: AssistantState) -> None:
self.setIcon(state_icon(state))
self.setToolTip(f"{APP_NAME}{STATE_TITLES[state]}")
def _on_provider_changed(self, name: str) -> None:
if action := self._provider_actions.get(name):
action.setChecked(True)
def _on_reply_finished(self, text: str) -> None:
if text.strip() and not self._window.isVisible():
preview = text.strip()
if len(preview) > _NOTIFICATION_PREVIEW_CHARS:
preview = preview[:_NOTIFICATION_PREVIEW_CHARS].rstrip() + ""
self.showMessage(
APP_NAME, preview, QSystemTrayIcon.MessageIcon.Information, _NOTIFICATION_TIMEOUT_MS
)
def _on_error(self, message: str) -> None:
if not self._window.isVisible():
self.showMessage(
APP_NAME, message, QSystemTrayIcon.MessageIcon.Warning, _NOTIFICATION_TIMEOUT_MS
)
View File
+24
View File
@@ -0,0 +1,24 @@
import os
import pytest
# Окна в тестах не показываем
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from collections.abc import Iterator # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from tests.fake_llm import FakeLLMServer # noqa: E402
@pytest.fixture(scope="session")
def qapp() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def fake_llm() -> Iterator[FakeLLMServer]:
server = FakeLLMServer()
yield server
server.close()
+90
View File
@@ -0,0 +1,90 @@
"""Фейковый OpenAI-совместимый сервер: отдаёт заранее заданные ответы стримом."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
@dataclass
class Reply:
text: str = ""
# (имя, аргументы JSON); аргументы отдаются несколькими кусками, как у настоящих серверов
tool_calls: list[tuple[str, str]] = field(default_factory=list)
status: int = 200
error: str = ""
class FakeLLMServer:
def __init__(self) -> None:
self.replies: list[Reply] = []
self.requests: list[dict[str, Any]] = []
server = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
server.requests.append(body)
reply = server.replies.pop(0) if server.replies else Reply(text="")
if reply.status != 200:
payload = json.dumps({"error": {"message": reply.error}}).encode()
self.send_response(reply.status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for delta in _deltas(reply):
chunk = {
"id": "fake",
"object": "chat.completion.chunk",
"created": 0,
"model": body["model"],
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self._server.serve_forever, daemon=True).start()
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self._server.server_port}/v1"
def close(self) -> None:
self._server.shutdown()
self._server.server_close()
def _deltas(reply: Reply) -> list[dict[str, Any]]:
deltas: list[dict[str, Any]] = []
for start in range(0, len(reply.text), 5):
deltas.append({"content": reply.text[start : start + 5]})
for index, (name, arguments) in enumerate(reply.tool_calls):
middle = len(arguments) // 2
deltas.append(
{
"tool_calls": [
{
"index": index,
"id": f"call_{index}",
"type": "function",
"function": {"name": name, "arguments": arguments[:middle]},
}
]
}
)
deltas.append(
{"tool_calls": [{"index": index, "function": {"arguments": arguments[middle:]}}]}
)
return deltas
+76
View File
@@ -0,0 +1,76 @@
from datetime import datetime
from agr_assistent.core.context import (
MAX_FOLLOW_UP_EXCHANGES,
FollowUpContext,
build_messages,
format_datetime,
)
class FakeClock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def test_build_messages_adds_date_context_and_request() -> None:
context = [{"role": "user", "content": "погода?"}, {"role": "assistant", "content": "солнце"}]
messages = build_messages(" будь краток ", context, "а завтра?", datetime(2026, 9, 17, 4, 5))
assert messages == [
{"role": "system", "content": "будь краток\n\nСейчас четверг, 17 сентября 2026 года, 04:05."},
*context,
{"role": "user", "content": "а завтра?"},
]
def test_format_datetime() -> None:
assert format_datetime(datetime(2026, 3, 1, 23, 59)) == "воскресенье, 1 марта 2026 года, 23:59"
def test_follow_up_is_available_only_within_window() -> None:
clock = FakeClock()
context = FollowUpContext(window_seconds=120, clock=clock)
context.remember("погода?", "солнце")
clock.now += 119
assert [m["content"] for m in context.recent()] == ["погода?", "солнце"]
clock.now += 2
assert context.recent() == []
def test_follow_up_chain_extends_window_and_keeps_last_exchanges() -> None:
clock = FakeClock()
context = FollowUpContext(window_seconds=60, clock=clock)
for index in range(MAX_FOLLOW_UP_EXCHANGES + 2):
context.remember(f"вопрос {index}", f"ответ {index}")
clock.now += 50 # каждый следующий вопрос успевает в окно предыдущего
requests = [m["content"] for m in context.recent() if m["role"] == "user"]
assert requests == [f"вопрос {i}" for i in range(2, MAX_FOLLOW_UP_EXCHANGES + 2)]
def test_chain_restarts_after_pause() -> None:
clock = FakeClock()
context = FollowUpContext(window_seconds=60, clock=clock)
context.remember("старый", "ответ")
clock.now += 61
context.remember("новый", "ответ")
assert [m["content"] for m in context.recent()] == ["новый", "ответ"]
def test_zero_window_disables_follow_ups_and_empty_replies_are_ignored() -> None:
clock = FakeClock()
assert FollowUpContext(window_seconds=0, clock=clock).recent() == []
context = FollowUpContext(window_seconds=60, clock=clock)
context.remember("вопрос", " ")
assert context.recent() == []
+344
View File
@@ -0,0 +1,344 @@
"""Полный цикл запроса через Assistant: модель, инструменты памяти, откат без инструментов."""
import time
from collections.abc import Callable
from pathlib import Path
import numpy as np
import pytest
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
from agr_assistent.core.speech import Speaker
from tests.fake_llm import FakeLLMServer, Reply
class _SilentEngine:
sample_rate = 24000
def load(self) -> None:
pass
def synthesize(self, text: str) -> np.ndarray:
return np.zeros(1, dtype=np.float32)
class _SilentPlayer:
def play(self, *args: object) -> None:
pass
def finish(self) -> None:
pass
def abort(self) -> None:
pass
def _wait_until(condition: Callable[[], bool], timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "условие не выполнилось вовремя"
QCoreApplication.processEvents()
time.sleep(0.005)
@pytest.fixture
def memory(tmp_path: Path) -> MemoryStore:
store = MemoryStore(tmp_path / "memory.sqlite3")
yield store
store.close()
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")},
system_prompt="Будь краток.",
temperature=0.5,
follow_up_seconds=0,
timeout_seconds=10,
)
speaker = Speaker(_SilentEngine(), _SilentPlayer(), enabled=False)
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(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore
) -> None:
memory.add("Живёт в Казани", "user")
fake_llm.replies += [
Reply(tool_calls=[("remember", '{"text": "Машина — Škoda Octavia", "requested_by_user": true}')]),
Reply(text="Запомнил."),
]
assistant = _assistant(fake_llm, memory)
tools: list[tuple[str, bool]] = []
finished: list[str] = []
assistant.tool_executed.connect(lambda display, ok: tools.append((display, ok)))
assistant.reply_finished.connect(finished.append)
assistant.send("Запомни, что у меня Škoda Octavia")
_wait_until(lambda: bool(finished))
assert [fact.text for fact in memory.facts()] == ["Живёт в Казани", "Машина — Škoda Octavia"]
assert tools == [("Запомнил: Машина — Škoda Octavia", True)]
assert finished == ["Запомнил."]
first, second = fake_llm.requests
system = first["messages"][0]["content"]
assert "Живёт в Казани" in system and "requested_by_user=false" in system
assert {tool["function"]["name"] for tool in first["tools"]} == {
"remember",
"update_memory",
"forget",
}
# Второй запрос несёт вызов инструмента и его результат
assert second["messages"][-2]["tool_calls"][0]["function"]["name"] == "remember"
assert second["messages"][-1] == {
"role": "tool",
"tool_call_id": "call_0",
"content": "Сохранено под номером 2",
}
def test_model_without_tools_falls_back_once(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore
) -> None:
fake_llm.replies += [
Reply(status=400, error="model does not support tools"),
Reply(text="Привет!"),
Reply(text="Снова привет!"),
]
assistant = _assistant(fake_llm, memory)
errors: list[str] = []
finished: list[str] = []
assistant.error_occurred.connect(errors.append)
assistant.reply_finished.connect(finished.append)
assistant.send("Привет")
_wait_until(lambda: len(finished) == 1)
assistant.send("Ещё раз привет")
_wait_until(lambda: len(finished) == 2)
assert finished == ["Привет!", "Снова привет!"]
assert len(errors) == 1 and "не поддерживает инструменты" in errors[0]
assert ["tools" in request for request in fake_llm.requests] == [True, False, False]
def test_auto_save_can_be_disabled(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore
) -> None:
fake_llm.replies.append(Reply(text="Ок"))
assistant = _assistant(fake_llm, memory, auto_save=False)
finished: list[str] = []
assistant.reply_finished.connect(finished.append)
assistant.send("Меня зовут Лео")
_wait_until(lambda: bool(finished))
assert "requested_by_user=false" not in fake_llm.requests[0]["messages"][0]["content"]
# --- команды
_COMMANDS_YAML = """
commands:
- name: open_downloads
description: Открыть папку «Загрузки»
phrases: [открой загрузки]
reply: Открываю.
action: {type: open, target: "C:/Downloads"}
- name: web_search
description: Найти в интернете
parameters:
query: {type: string}
action: {type: open, target: "https://example.com/?q={query}"}
- name: shutdown_computer
description: Выключить компьютер
phrases: [выключи компьютер]
confirm: true
action: {type: open, target: "shutdown://now"}
"""
class _FakeVoice(QObject):
listening_started = Signal()
recognizing_started = Signal()
finished = Signal()
recognized = Signal(str)
error_occurred = Signal(str)
def __init__(self) -> None:
super().__init__()
self.started = 0
self.is_listening = False
self.is_recognizing = False
self.is_active = False
def start(self) -> None:
self.started += 1
def stop(self) -> None:
pass
def cancel(self) -> None:
pass
@pytest.fixture
def opened(monkeypatch: pytest.MonkeyPatch) -> list[str]:
targets: list[str] = []
monkeypatch.setattr(actions, "open_target", targets.append)
return targets
def _commands_assistant(
server: FakeLLMServer, memory: MemoryStore, tmp_path: Path, voice: _FakeVoice | None = None
) -> tuple[Assistant, CommandCatalog]:
path = tmp_path / "commands.yaml"
path.write_text(_COMMANDS_YAML, encoding="utf-8")
catalog = CommandCatalog(path)
return _assistant(server, memory, commands=catalog, voice=voice), catalog
def _collect(assistant: Assistant) -> dict[str, list]:
events: dict[str, list] = {"tools": [], "replies": [], "errors": []}
assistant.tool_executed.connect(lambda display, ok: events["tools"].append((display, ok)))
assistant.reply_finished.connect(events["replies"].append)
assistant.error_occurred.connect(events["errors"].append)
return events
def test_exact_phrase_runs_without_model(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Открой загрузки!")
_wait_until(lambda: bool(events["replies"]))
assert opened == ["C:/Downloads"]
assert events["replies"] == ["Открываю."]
assert events["tools"] == [("Открыл: Открыть папку «Загрузки»", True)]
assert fake_llm.requests == []
def test_model_calls_command_tool(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
fake_llm.replies += [
Reply(tool_calls=[("web_search", '{"query": "погода в Казани"}')]),
Reply(text="Открыл поиск."),
]
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Поищи погоду в Казани")
_wait_until(lambda: bool(events["replies"]))
assert opened == ["https://example.com/?q=%D0%BF%D0%BE%D0%B3%D0%BE%D0%B4%D0%B0%20%D0%B2%20%D0%9A%D0%B0%D0%B7%D0%B0%D0%BD%D0%B8"]
tool_names = {tool["function"]["name"] for tool in fake_llm.requests[0]["tools"]}
assert {"web_search", "open_downloads", "remember"} <= tool_names
assert "команды для управления компьютером" in fake_llm.requests[0]["messages"][0]["content"]
assert events["replies"] == ["Открыл поиск."]
def test_model_command_with_confirmation_waits_for_yes(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
fake_llm.replies += [
Reply(tool_calls=[("shutdown_computer", "{}")]),
Reply(text="Выключить компьютер?"),
]
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Выключи комп, пожалуйста")
_wait_until(lambda: len(events["replies"]) == 1)
assert opened == []
assert events["tools"] == [("Ждёт подтверждения: Выключить компьютер", True)]
assert "НЕ выполнена" in fake_llm.requests[1]["messages"][-1]["content"]
assistant.send("Да")
_wait_until(lambda: len(events["replies"]) == 2)
assert opened == ["shutdown://now"]
assert events["replies"][1] == "Готово."
assert len(fake_llm.requests) == 2 # «да» обработано без модели
def test_phrase_confirmation_can_be_declined_or_dropped(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
fake_llm.replies.append(Reply(text="Не понял."))
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
assistant.send("Выключи компьютер")
_wait_until(lambda: len(events["replies"]) == 1)
assert events["replies"][0] == "Выполнить: Выключить компьютер? Скажите «да» или «нет»."
assistant.send("нет")
_wait_until(lambda: len(events["replies"]) == 2)
assert events["replies"][1] == "Хорошо, не выполняю."
# После отказа «да» уже ничего не подтверждает и уходит модели
assistant.send("да")
_wait_until(lambda: len(events["replies"]) == 3)
assert opened == []
assert len(fake_llm.requests) == 1
def test_voice_confirmation_starts_listening(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
voice = _FakeVoice()
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path, voice)
events = _collect(assistant)
assistant.send("выключи компьютер", by_voice=True)
_wait_until(lambda: voice.started == 1)
assistant.send("открой загрузки", by_voice=True)
_wait_until(lambda: len(events["replies"]) == 2)
for _ in range(20):
QCoreApplication.processEvents()
time.sleep(0.005)
assert voice.started == 1 # без вопроса микрофон сам не включается
def test_commands_file_changes_are_picked_up(
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
) -> None:
assistant, catalog = _commands_assistant(fake_llm, memory, tmp_path)
events = _collect(assistant)
catalog.path.write_text(
_COMMANDS_YAML.replace("[открой загрузки]", "[покажи загрузки]")
+ " - name: broken\n description: x\n action: {type: nope}\n",
encoding="utf-8",
)
catalog.reload()
assistant.send("покажи загрузки")
_wait_until(lambda: bool(events["replies"]))
assert opened == ["C:/Downloads"]
assert any("broken" in error for error in events["errors"])
+218
View File
@@ -0,0 +1,218 @@
import json
import sys
import threading
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
from agr_assistent.commands import actions
from agr_assistent.commands.model import parse_command
def _command(action: dict[str, Any], parameters: dict[str, Any] | None = None): # type: ignore[no-untyped-def]
return parse_command(
{"name": "test", "description": "Тест", "parameters": parameters, "action": action}
)
def test_substitution_encodes_urls_and_keeps_json_types() -> None:
values = {"query": "кофе & чай", "level": 30, "on": True}
assert actions.substitute("q={query}", values, url_encode=True) == (
"q=%D0%BA%D0%BE%D1%84%D0%B5%20%26%20%D1%87%D0%B0%D0%B9"
)
assert actions.substitute("{missing}-{level}", values) == "-30"
assert actions.substitute_json(
{"level": "{level}", "text": "уровень {level}", "flags": ["{on}"]}, values
) == {"level": 30, "text": "уровень 30", "flags": [True]}
def test_parameter_values_cannot_reach_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_SECRET_TOKEN", "секрет")
values = {"value": "${TEST_SECRET_TOKEN}"}
assert actions.substitute_json("Bearer ${TEST_SECRET_TOKEN} {value}", values) == (
"Bearer секрет ${TEST_SECRET_TOKEN}"
)
def test_run_waits_and_returns_output() -> None:
command = _command(
{
"type": "run",
"program": sys.executable,
"args": ["-c", "import sys; print('привет', sys.argv[1])", "{name}"],
"wait": True,
},
{"name": {"type": "string"}},
)
outcome = actions.execute(command, {"name": "мир"})
assert outcome.ok
assert "привет мир" in outcome.content
def test_run_reports_exit_code_and_timeout() -> None:
failing = _command(
{"type": "run", "program": sys.executable, "args": ["-c", "raise SystemExit(3)"], "wait": True}
)
slow = _command(
{
"type": "run",
"program": sys.executable,
"args": ["-c", "import time; time.sleep(5)"],
"wait": True,
"timeout_seconds": 0.5,
}
)
failed = actions.execute(failing, {})
assert not failed.ok and "Код завершения 3" in failed.content
timed_out = actions.execute(slow, {})
assert not timed_out.ok and "не завершилась" in timed_out.content
assert not actions.execute(_command({"type": "run", "program": "no-such-program-xyz"}), {}).ok
@pytest.mark.skipif(sys.platform != "win32", reason="bat-файлы есть только в Windows")
def test_batch_file_rejects_cmd_metacharacters(tmp_path: Path) -> None:
marker = tmp_path / "ran.txt"
script = tmp_path / "echo.bat"
script.write_text(f'@echo %1 > "{marker}"\n', encoding="utf-8")
command = _command(
{"type": "run", "program": str(script), "args": ["{text}"], "wait": True},
{"text": {"type": "string"}},
)
refused = actions.execute(command, {"text": "hi & calc"})
assert not refused.ok and "недопустимые символы" in refused.content
assert not marker.exists()
assert actions.execute(command, {"text": "hello"}).ok
assert marker.exists()
@pytest.mark.skipif(sys.platform != "win32", reason="cmd и powershell есть только в Windows")
def test_shell_interpreters_reject_injection_but_run_plain_values() -> None:
cmd = _command(
{"type": "run", "program": "cmd", "args": ["/c", "echo", "{text}"], "wait": True},
{"text": {"type": "string"}},
)
powershell = _command(
{
"type": "run",
"program": "powershell",
"args": ["-NoProfile", "-Command", "Write-Output '{text}'"],
"wait": True,
},
{"text": {"type": "string"}},
)
assert not actions.execute(cmd, {"text": "hi & calc"}).ok
assert not actions.execute(powershell, {"text": "x'; Remove-Item C:\\temp"}).ok
echoed = actions.execute(cmd, {"text": "Привет"})
assert echoed.ok and "Привет" in echoed.content # вывод cmd в OEM-кодировке декодируется
written = actions.execute(powershell, {"text": "Мир"})
assert written.ok and "Мир" in written.content
def test_open_uses_encoded_url(monkeypatch: pytest.MonkeyPatch) -> None:
opened: list[str] = []
monkeypatch.setattr(actions, "open_target", opened.append)
command = _command(
{"type": "open", "target": "https://example.com/search?q={query}"},
{"query": {"type": "string"}},
)
assert actions.execute(command, {"query": "a b"}).ok
assert opened == ["https://example.com/search?q=a%20b"]
@pytest.fixture
def http_server() -> Iterator[tuple[str, list[dict[str, Any]]]]:
received: list[dict[str, Any]] = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def _respond(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
received.append(
{
"method": self.command,
"path": self.path,
"auth": self.headers.get("Authorization"),
"body": json.loads(self.rfile.read(length)) if length else None,
}
)
status = 500 if self.path.startswith("/fail") else 200
payload = json.dumps({"state": "ok"}).encode()
self.send_response(status)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
do_GET = do_POST = _respond
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", received
server.shutdown()
server.server_close()
def test_http_sends_json_and_secret_headers(
http_server: tuple[str, list[dict[str, Any]]], monkeypatch: pytest.MonkeyPatch
) -> None:
base_url, received = http_server
monkeypatch.setenv("TEST_HA_TOKEN", "token123")
command = _command(
{
"type": "http",
"method": "POST",
"url": base_url + "/light/turn_{state}",
"headers": {"Authorization": "Bearer ${TEST_HA_TOKEN}"},
"json": {"entity_id": "light.room", "brightness": "{level}"},
},
{"state": {"type": "string", "enum": ["on", "off"]}, "level": {"type": "integer"}},
)
outcome = actions.execute(command, {"state": "on", "level": 80})
assert outcome.ok and "HTTP 200" in outcome.content
assert received == [
{
"method": "POST",
"path": "/light/turn_on",
"auth": "Bearer token123",
"body": {"entity_id": "light.room", "brightness": 80},
}
]
assert "token123" not in outcome.content + outcome.display
def test_http_error_status_is_failure(http_server: tuple[str, list[dict[str, Any]]]) -> None:
base_url, _received = http_server
outcome = actions.execute(_command({"type": "http", "url": base_url + "/fail"}), {})
assert not outcome.ok and "HTTP 500" in outcome.content
assert not actions.execute(_command({"type": "http", "url": "http://127.0.0.1:9/"}), {}).ok
@pytest.mark.skipif(sys.platform != "win32", reason="клавиши эмулируются только в Windows")
def test_keys_repeat(monkeypatch: pytest.MonkeyPatch) -> None:
pressed: list[list[int]] = []
monkeypatch.setattr(actions, "send_keys", pressed.append)
command = _command(
{"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
{"steps": {"type": "integer", "default": 3}},
)
assert actions.execute(command, {}).ok
assert pressed == [[0xAF]] * 3
+118
View File
@@ -0,0 +1,118 @@
from pathlib import Path
import pytest
from agr_assistent.commands.catalog import default_commands_text
from agr_assistent.commands.model import CommandError, load_commands, parse_command, parse_key_combo
def _command(**overrides: object) -> dict:
item = {"name": "test", "description": "Тест", "action": {"type": "open", "target": "https://x.io"}}
item.update(overrides)
return item
def test_default_commands_file_is_valid(tmp_path: Path) -> None:
path = tmp_path / "commands.yaml"
path.write_text(default_commands_text(), encoding="utf-8")
commands, errors = load_commands(path)
assert errors == []
assert {command.name for command in commands} >= {"media_play_pause", "volume_up", "web_search"}
def test_commented_examples_are_valid_too(tmp_path: Path) -> None:
"""Примеры в конце файла закомментированы — раскомментированные, они тоже должны загружаться."""
lines = default_commands_text().splitlines()
start = next(i for i, line in enumerate(lines) if "--- Примеры" in line)
uncommented = lines[: start + 1] + [
line.replace(" # ", " ", 1) if line.startswith(" # ") else line
for line in lines[start + 1 :]
]
path = tmp_path / "commands.yaml"
path.write_text("\n".join(uncommented), encoding="utf-8")
commands, errors = load_commands(path)
assert errors == []
assert {"shutdown_computer", "disk_space", "room_light"} <= {c.name for c in commands}
def test_parameters_schema_defaults_and_summary() -> None:
command = parse_command(
_command(
parameters={
"level": {"type": "integer", "minimum": 0, "maximum": 100},
"steps": {"type": "integer", "default": 5},
"note": "Просто описание",
},
action={"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
)
)
assert command.schema() == {
"type": "object",
"properties": {
"level": {"type": "integer", "minimum": 0, "maximum": 100},
"steps": {"type": "integer"},
"note": {"type": "string", "description": "Просто описание"},
},
"required": ["level", "note"],
}
assert command.with_defaults({"level": 30}) == {"steps": 5, "level": 30}
assert command.summary({"level": 30}) == "Тест (steps: 5, level: 30)"
@pytest.mark.parametrize(
("overrides", "message"),
[
({"name": "плохое имя"}, "латиница"),
({"name": "remember"}, "занято"),
({"description": ""}, "описание"),
({"action": {"type": "shell", "command": "rm"}}, "run, open, http или keys"),
({"action": {"type": "run"}}, "program"),
({"action": {"type": "http", "url": "ftp://x"}}, "http://"),
({"action": {"type": "keys", "keys": "ctrl+bogus"}}, "неизвестная клавиша"),
({"action": {"type": "open", "target": "https://x.io/{query}"}}, "неизвестные параметры"),
(
{"parameters": {"level": {"type": "integer"}}, "phrases": ["громкость"]},
"не хватает обязательных",
),
],
)
def test_invalid_commands_are_rejected(overrides: dict, message: str) -> None:
with pytest.raises(CommandError, match=message):
parse_command(_command(**overrides))
def test_one_broken_command_does_not_break_others(tmp_path: Path) -> None:
path = tmp_path / "commands.yaml"
path.write_text(
"""
commands:
- name: good
description: Хорошая
action: {type: open, target: "https://x.io"}
- name: bad
description: Плохая
action: {type: nope}
- name: good
description: Дубль
action: {type: open, target: "https://y.io"}
""",
encoding="utf-8",
)
commands, errors = load_commands(path)
assert [command.name for command in commands] == ["good"]
assert len(errors) == 2
assert "bad" in errors[0] and "уже используется" in errors[1]
def test_key_combos() -> None:
assert parse_key_combo("ctrl+shift+esc") == [0x11, 0x10, 0x1B]
assert parse_key_combo("volume_mute") == [0xAD]
with pytest.raises(CommandError):
parse_key_combo("ctrl+")
+62
View File
@@ -0,0 +1,62 @@
from pathlib import Path
import pytest
from agr_assistent.config import ConfigError, load_config
def _write(path: Path, text: str) -> Path:
path.write_text(text, encoding="utf-8")
return path
def test_creates_default_config_when_missing(tmp_path: Path) -> None:
path = tmp_path / "config.yaml"
config = load_config(path)
assert path.exists()
assert config.llm.provider == "ollama"
assert config.llm.active_provider.base_url == "http://localhost:11434/v1"
def test_user_values_override_defaults(tmp_path: Path) -> None:
path = _write(
tmp_path / "config.yaml",
"llm:\n provider: openrouter\n providers:\n openrouter:\n model: my/model\n",
)
config = load_config(path)
assert config.llm.active_provider.model == "my/model"
assert config.llm.active_provider.base_url == "https://openrouter.ai/api/v1"
assert config.llm.temperature == 0.7
def test_api_key_expands_environment_variable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = _write(
tmp_path / "config.yaml",
"llm:\n providers:\n openrouter:\n api_key: ${TEST_AGR_KEY}\n",
)
monkeypatch.setenv("TEST_AGR_KEY", "secret")
assert load_config(path).llm.providers["openrouter"].api_key == "secret"
monkeypatch.delenv("TEST_AGR_KEY")
assert load_config(path).llm.providers["openrouter"].api_key == ""
def test_unknown_provider_is_rejected(tmp_path: Path) -> None:
path = _write(tmp_path / "config.yaml", "llm:\n provider: nope\n")
with pytest.raises(ConfigError, match="nope"):
load_config(path)
def test_invalid_yaml_is_rejected(tmp_path: Path) -> None:
path = _write(tmp_path / "config.yaml", "llm: [unclosed\n")
with pytest.raises(ConfigError):
load_config(path)
+22
View File
@@ -0,0 +1,22 @@
import pytest
from agr_assistent.hotkey import HotkeyError, parse_hotkey
@pytest.mark.parametrize(
("spec", "expected"),
[
("ctrl+alt+space", (0x0002 | 0x0001, 0x20)),
("Win + Shift + A", (0x0008 | 0x0004, ord("A"))),
("f12", (0, 0x7B)),
("ctrl+7", (0x0002, ord("7"))),
],
)
def test_parse_hotkey(spec: str, expected: tuple[int, int]) -> None:
assert parse_hotkey(spec) == expected
@pytest.mark.parametrize("spec", ["", "ctrl+", "hyper+a", "ctrl+f25", "ctrl+ж"])
def test_parse_hotkey_rejects_invalid(spec: str) -> None:
with pytest.raises(HotkeyError):
parse_hotkey(spec)
+67
View File
@@ -0,0 +1,67 @@
import pytest
from agr_assistent.config import ProviderConfig
from agr_assistent.llm.client import (
LLMClient,
LLMError,
TextDelta,
ToolCall,
ToolCalls,
ToolsNotSupportedError,
)
from tests.fake_llm import FakeLLMServer, Reply
_TOOLS = [{"type": "function", "function": {"name": "remember", "parameters": {"type": "object"}}}]
def _client(server: FakeLLMServer) -> LLMClient:
return LLMClient(
ProviderConfig("fake", server.base_url, "key", "fake-model"),
temperature=0.5,
timeout_seconds=10,
)
def test_text_and_tool_calls_are_assembled(fake_llm: FakeLLMServer) -> None:
fake_llm.replies.append(
Reply(text="Сейчас запомню.", tool_calls=[("remember", '{"text": "Любит кофе"}')])
)
events = list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}], _TOOLS))
text = "".join(event.text for event in events if isinstance(event, TextDelta))
assert text == "Сейчас запомню."
assert events[-1] == ToolCalls([ToolCall("call_0", "remember", '{"text": "Любит кофе"}')])
assert fake_llm.requests[0]["tools"] == _TOOLS
def test_tools_are_not_sent_when_absent(fake_llm: FakeLLMServer) -> None:
fake_llm.replies.append(Reply(text="ок"))
list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}]))
assert "tools" not in fake_llm.requests[0]
@pytest.mark.parametrize(
"message",
[
"registry.ollama.ai/library/gemma:2b does not support tools",
"No endpoints found that support tool use",
],
)
def test_tools_not_supported_is_recognized(fake_llm: FakeLLMServer, message: str) -> None:
fake_llm.replies.append(Reply(status=400, error=message))
with pytest.raises(ToolsNotSupportedError):
list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}], _TOOLS))
def test_other_api_errors_stay_generic(fake_llm: FakeLLMServer) -> None:
fake_llm.replies.append(Reply(status=400, error="context too long"))
with pytest.raises(LLMError) as error:
list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}], _TOOLS))
assert not isinstance(error.value, ToolsNotSupportedError)
assert "context too long" in str(error.value)
+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
+73
View File
@@ -0,0 +1,73 @@
from pathlib import Path
import pytest
from agr_assistent.core.memory import (
SOURCE_AUTO,
SOURCE_USER,
MemoryStore,
memory_prompt,
memory_tools,
)
from agr_assistent.llm.tools import ToolRegistry
@pytest.fixture
def store(tmp_path: Path) -> MemoryStore:
memory = MemoryStore(tmp_path / "memory.sqlite3")
yield memory
memory.close()
def test_facts_survive_reopening(tmp_path: Path) -> None:
path = tmp_path / "memory.sqlite3"
first = MemoryStore(path)
fact = first.add(" Машина пользователя — Škoda Octavia ", SOURCE_USER)
first.close()
second = MemoryStore(path)
assert [(f.id, f.text, f.source) for f in second.facts()] == [
(fact.id, "Машина пользователя — Škoda Octavia", SOURCE_USER)
]
second.close()
def test_duplicates_update_and_delete(store: MemoryStore) -> None:
fact = store.add("Зовут Лео", SOURCE_AUTO)
assert store.add("зовут лео", SOURCE_USER).id == fact.id
assert store.update(fact.id, "Зовут Леонид").text == "Зовут Леонид"
assert store.update(999, "нет такого") is None
assert store.delete(fact.id).text == "Зовут Леонид"
assert store.delete(fact.id) is None
assert store.facts() == []
def test_memory_tools(store: MemoryStore) -> None:
tools = ToolRegistry(memory_tools(store))
saved = tools.execute("remember", '{"text": "Любит кофе", "requested_by_user": false}')
assert saved.ok and saved.display == "Запомнил сам: Любит кофе"
fact_id = store.facts()[0].id
assert store.facts()[0].is_auto
assert tools.execute("update_memory", f'{{"id": {fact_id}, "text": "Любит чай"}}').ok
assert store.facts()[0].text == "Любит чай"
missing = tools.execute("forget", '{"id": 12345}')
assert not missing.ok and "12345" in missing.content
assert tools.execute("forget", f'{{"id": "{fact_id}"}}').ok
assert store.facts() == []
assert not tools.execute("remember", '{"text": " "}').ok
def test_prompt_lists_facts_and_respects_auto_save(store: MemoryStore) -> None:
fact = store.add("Живёт в Казани", SOURCE_USER)
with_auto = memory_prompt(store.facts(), auto_save=True)
without_auto = memory_prompt(store.facts(), auto_save=False)
assert f"[{fact.id}] Живёт в Казани" in with_auto
assert "requested_by_user=false" in with_auto
assert "requested_by_user=false" not in without_auto
assert "пока пусто" in memory_prompt([], auto_save=True)
+43
View File
@@ -0,0 +1,43 @@
from agr_assistent.audio.recorder import UtteranceDetector, UtteranceStatus
# Частота 100 отсчётов в секунду, окно — 10 отсчётов (0.1 с): так проще считать
_WINDOW = 10
def _detector(**overrides: float) -> UtteranceDetector:
settings = dict(threshold=0.5, start_timeout_seconds=1, silence_seconds=0.3, max_seconds=5)
settings.update(overrides)
return UtteranceDetector(sample_rate=100, **settings)
def _feed(detector: UtteranceDetector, probabilities: list[float]) -> list[UtteranceStatus]:
return [detector.update(p, _WINDOW) for p in probabilities]
def test_phrase_completes_after_silence() -> None:
statuses = _feed(_detector(), [0.1, 0.9, 0.8, 0.2, 0.1, 0.1])
assert statuses[:-1] == [UtteranceStatus.CONTINUE] * 5
assert statuses[-1] is UtteranceStatus.COMPLETE
def test_short_pause_does_not_end_phrase() -> None:
statuses = _feed(_detector(), [0.9, 0.1, 0.1, 0.9, 0.1, 0.1])
assert UtteranceStatus.COMPLETE not in statuses
def test_no_speech_before_timeout() -> None:
statuses = _feed(_detector(), [0.1] * 10)
assert statuses[-1] is UtteranceStatus.NO_SPEECH
assert statuses[:-1] == [UtteranceStatus.CONTINUE] * 9
def test_max_duration_completes_ongoing_speech() -> None:
detector = _detector(max_seconds=0.5)
statuses = _feed(detector, [0.9] * 5)
assert statuses[-1] is UtteranceStatus.COMPLETE
assert detector.speech_detected
+85
View File
@@ -0,0 +1,85 @@
from dataclasses import replace
from pathlib import Path
import pytest
from PySide6.QtWidgets import QApplication
from agr_assistent.config import ConfigError, load_config, save_config_updates
from agr_assistent.core.settings import Settings, needs_restart
from agr_assistent.ui.settings_dialog import SettingsDialog
_USER_CONFIG = """\
# мой конфиг
llm:
provider: ollama # локально
providers:
openrouter:
api_key: ${TEST_SETTINGS_KEY}
"""
@pytest.fixture
def config_path(tmp_path: Path) -> Path:
path = tmp_path / "config.yaml"
path.write_text(_USER_CONFIG, encoding="utf-8")
return path
def test_save_keeps_comments_and_env_references(config_path: Path) -> None:
config = save_config_updates(
config_path, {"llm.provider": "openrouter", "wake_word.phrases": ["эй", "компьютер"]}
)
text = config_path.read_text(encoding="utf-8")
assert "# мой конфиг" in text
assert "provider: openrouter # локально" in text
assert "api_key: ${TEST_SETTINGS_KEY}" in text
assert config.llm.provider == "openrouter"
assert load_config(config_path).wake_word.phrases == ["эй", "компьютер"]
def test_invalid_update_does_not_touch_file(config_path: Path) -> None:
with pytest.raises(ConfigError):
save_config_updates(config_path, {"llm.provider": "nope"})
assert config_path.read_text(encoding="utf-8") == _USER_CONFIG
def test_restart_is_needed_only_for_heavy_settings(config_path: Path) -> None:
config = load_config(config_path)
live = replace(config, tts=replace(config.tts, enabled=False))
live.llm = replace(config.llm, temperature=0.1)
assert not needs_restart(config, live)
assert needs_restart(config, replace(config, voice=replace(config.voice, hotkey="f9")))
assert needs_restart(config, replace(config, tts=replace(config.tts, speaker="aidar")))
def test_dialog_saves_only_changed_values(
qapp: QApplication, config_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("TEST_SETTINGS_KEY", "secret")
settings = Settings(load_config(config_path))
changes: list[object] = []
settings.changed.connect(changes.append)
dialog = SettingsDialog(settings)
# Переключаемся на openrouter: ключ показан ссылкой на переменную, а не секретом
dialog._provider.setCurrentText("openrouter")
assert dialog._api_key.text() == "${TEST_SETTINGS_KEY}"
dialog._model.setEditText("anthropic/claude-sonnet-5")
dialog._tts_enabled.setChecked(False)
assert dialog._collect() == {
"llm.provider": "openrouter",
"llm.providers.openrouter.model": "anthropic/claude-sonnet-5",
"tts.enabled": False,
}
dialog._save()
text = config_path.read_text(encoding="utf-8")
assert "secret" not in text
assert settings.config.llm.active_provider.model == "anthropic/claude-sonnet-5"
assert settings.config.llm.active_provider.api_key == "secret"
assert len(changes) == 1
+120
View File
@@ -0,0 +1,120 @@
import threading
import time
from collections.abc import Callable
import numpy as np
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.speech import Speaker
class FakeEngine:
sample_rate = 1000
def __init__(self, fail_on: str | None = None) -> None:
self.fail_on = fail_on
def load(self) -> None:
pass
def synthesize(self, text: str) -> np.ndarray:
if text == self.fail_on:
raise RuntimeError("boom")
return np.zeros(10, dtype=np.float32)
class FakePlayer:
def __init__(self, play_seconds: float = 0.0) -> None:
self.play_seconds = play_seconds
self.played = 0
self.finished = 0
self.aborted = 0
self.lock = threading.Lock()
def play(self, audio: np.ndarray, sample_rate: int, should_continue: Callable[[], bool]) -> None:
deadline = time.monotonic() + self.play_seconds
while time.monotonic() < deadline:
if not should_continue():
self.abort()
return
time.sleep(0.005)
with self.lock:
self.played += 1
def finish(self) -> None:
self.finished += 1
def abort(self) -> None:
self.aborted += 1
def _wait_until(condition: Callable[[], bool], timeout: float = 3.0) -> None:
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "условие не выполнилось вовремя"
QCoreApplication.processEvents()
time.sleep(0.005)
def test_reply_is_spoken_sentence_by_sentence(qapp: QCoreApplication) -> None:
player = FakePlayer()
speaker = Speaker(FakeEngine(), player, enabled=True)
events: list[str] = []
speaker.playback_started.connect(lambda: events.append("started"))
speaker.finished.connect(lambda: events.append("finished"))
speaker.begin()
speaker.feed("Раз. Два! ")
speaker.feed("Три")
speaker.end()
_wait_until(lambda: "finished" in events)
assert events == ["started", "finished"]
assert player.played == 3
assert player.finished == 1
assert not speaker.is_active
def test_stop_interrupts_playback(qapp: QCoreApplication) -> None:
player = FakePlayer(play_seconds=0.5)
speaker = Speaker(FakeEngine(), player, enabled=True)
finished: list[bool] = []
speaker.finished.connect(lambda: finished.append(True))
speaker.begin()
speaker.feed("Первая фраза. Вторая фраза. ")
_wait_until(lambda: speaker.is_playing)
speaker.stop()
assert finished == [True]
_wait_until(lambda: player.aborted >= 1)
time.sleep(0.1)
assert player.played == 0
def test_disabled_speaker_ignores_replies(qapp: QCoreApplication) -> None:
player = FakePlayer()
speaker = Speaker(FakeEngine(), player, enabled=False)
speaker.begin()
speaker.feed("Привет. ")
speaker.end()
assert not speaker.is_active
time.sleep(0.1)
assert player.played == 0
def test_synthesis_error_stops_reply_and_reports(qapp: QCoreApplication) -> None:
speaker = Speaker(FakeEngine(fail_on="Плохо."), FakePlayer(), enabled=True)
errors: list[str] = []
speaker.error_occurred.connect(errors.append)
speaker.begin()
speaker.feed("Плохо. Хорошо. Ещё. ")
speaker.end()
_wait_until(lambda: bool(errors))
assert len(errors) == 1
assert "boom" in errors[0]
assert not speaker.is_active
+50
View File
@@ -0,0 +1,50 @@
from agr_assistent.tts.text import SpeechTextStream, normalize_for_speech, split_long_text
def _feed_all(chunks: list[str]) -> list[str]:
stream = SpeechTextStream()
result = []
for chunk in chunks:
result.extend(stream.feed(chunk))
return result + stream.flush()
def test_sentences_are_emitted_as_soon_as_complete() -> None:
stream = SpeechTextStream()
assert stream.feed("Привет! Как ") == ["Привет!"]
assert stream.feed("дела? Хоро") == ["Как дела?"]
assert stream.flush() == ["Хоро"]
def test_decimal_numbers_and_list_markers_do_not_split() -> None:
assert _feed_all(["Будет 3.", "5 градуса.\n", "1. Первый пункт"]) == [
"Будет три целых пять десятых градуса.",
"Первый пункт",
]
def test_code_blocks_are_skipped() -> None:
chunks = ["Пример:\n``", "`python\nprint('Привет')\n", "```\nГотово."]
assert _feed_all(chunks) == ["Пример:", "Готово."]
def test_normalize_removes_markdown_links_and_latin() -> None:
text = "**Важно**: смотри [документацию](https://x.io) и https://y.io, Python тоже 😀"
assert normalize_for_speech(text) == "Важно: смотри документацию и ссылка, тоже"
def test_text_without_cyrillic_is_not_spoken() -> None:
assert normalize_for_speech("Hello, world! 👋") == ""
assert _feed_all(["Hello world.\n"]) == []
def test_long_text_is_split_on_commas_and_spaces() -> None:
text = ", ".join(["слово"] * 100)
parts = split_long_text(text, limit=50)
assert all(len(part) <= 50 for part in parts)
assert " ".join(parts).replace(" ,", ",") == text
+64
View File
@@ -0,0 +1,64 @@
from typing import Any
from agr_assistent.llm.tools import Tool, ToolError, ToolRegistry, ToolResult, validate_arguments
_SCHEMA = {
"type": "object",
"properties": {
"level": {"type": "integer"},
"ratio": {"type": "number"},
"loud": {"type": "boolean"},
"mode": {"type": "string", "enum": ["on", "off"]},
},
"required": ["level"],
}
def test_arguments_are_converted_from_strings() -> None:
arguments, errors = validate_arguments(
_SCHEMA, {"level": "30", "ratio": "0,5", "loud": "true", "mode": "on", "extra": 1}
)
assert errors == []
assert arguments == {"level": 30, "ratio": 0.5, "loud": True, "mode": "on"}
def test_invalid_arguments_are_reported() -> None:
_arguments, errors = validate_arguments(_SCHEMA, {"ratio": "много", "loud": 1, "mode": "auto"})
assert "не указан параметр level" in errors
assert any(error.startswith("ratio:") for error in errors)
assert any(error.startswith("loud:") for error in errors)
assert any("недопустимое значение 'auto'" in error for error in errors)
def _registry() -> ToolRegistry:
def handler(arguments: dict[str, Any]) -> ToolResult:
if arguments["level"] > 100:
raise ToolError("слишком громко")
if arguments["level"] < 0:
raise RuntimeError("сломалось")
return ToolResult(True, f"ok {arguments['level']}", "готово")
return ToolRegistry([Tool("volume", "Громкость", _SCHEMA, handler)])
def test_registry_executes_tool_and_exposes_schema() -> None:
registry = _registry()
assert registry.schemas()[0]["function"]["name"] == "volume"
assert registry.execute("volume", '{"level": 30}') == ToolResult(True, "ok 30", "готово")
def test_registry_turns_problems_into_failed_results() -> None:
registry = _registry()
assert not registry.execute("missing", "{}").ok
assert not registry.execute("volume", "{not json").ok
assert not registry.execute("volume", "[1]").ok
assert not registry.execute("volume", "{}").ok
assert registry.execute("volume", '{"level": 101}') == ToolResult(
False, "слишком громко", "слишком громко"
)
failed = registry.execute("volume", '{"level": -1}')
assert not failed.ok and "сломалось" in failed.content
+121
View File
@@ -0,0 +1,121 @@
import threading
import time
from collections.abc import Callable
import numpy as np
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.voice import VoiceInput
class FakeRecorder:
def __init__(self, *, wait_for_stop: bool = False, speech: bool = True) -> None:
self.wait_for_stop = wait_for_stop
self.speech = speech
self.recording = threading.Event()
def load(self) -> None:
pass
def record(self, should_stop: Callable[[], bool]) -> np.ndarray | None:
self.recording.set()
while self.wait_for_stop and not should_stop():
time.sleep(0.005)
return np.zeros(160, dtype=np.float32) if self.speech else None
class FakeRecognizer:
def __init__(self, text: str = "привет", error: Exception | None = None) -> None:
self.text = text
self.error = error
def load(self) -> None:
pass
def transcribe(self, audio: np.ndarray) -> str:
if self.error:
raise self.error
return self.text
def _wait_until(condition: Callable[[], bool], timeout: float = 3.0) -> None:
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "условие не выполнилось вовремя"
QCoreApplication.processEvents()
time.sleep(0.005)
def _voice(recorder: FakeRecorder, recognizer: FakeRecognizer) -> VoiceInput:
return VoiceInput(recorder, recognizer, sound_cues=False)
def test_recognized_text_is_emitted(qapp: QCoreApplication) -> None:
voice = _voice(FakeRecorder(), FakeRecognizer("какая погода"))
events: list[str] = []
voice.recognizing_started.connect(lambda: events.append("recognizing"))
voice.recognized.connect(lambda text: events.append(text))
voice.start()
assert voice.is_listening
_wait_until(lambda: "какая погода" in events)
assert events == ["recognizing", "какая погода"]
assert not voice.is_active
def test_stop_finishes_recording_early(qapp: QCoreApplication) -> None:
recorder = FakeRecorder(wait_for_stop=True)
voice = _voice(recorder, FakeRecognizer())
recognized: list[str] = []
voice.recognized.connect(recognized.append)
voice.start()
assert recorder.recording.wait(1)
voice.stop()
_wait_until(lambda: recognized == ["привет"])
def test_cancel_discards_result(qapp: QCoreApplication) -> None:
recorder = FakeRecorder(wait_for_stop=True)
voice = _voice(recorder, FakeRecognizer())
recognized: list[str] = []
finished: list[bool] = []
voice.recognized.connect(recognized.append)
voice.finished.connect(lambda: finished.append(True))
voice.start()
assert recorder.recording.wait(1)
voice.cancel()
assert finished == [True]
time.sleep(0.1)
QCoreApplication.processEvents()
assert recognized == []
assert finished == [True]
def test_no_speech_finishes_silently(qapp: QCoreApplication) -> None:
voice = _voice(FakeRecorder(speech=False), FakeRecognizer())
events: list[str] = []
voice.recognized.connect(lambda text: events.append("recognized"))
voice.error_occurred.connect(lambda message: events.append("error"))
voice.finished.connect(lambda: events.append("finished"))
voice.start()
_wait_until(lambda: "finished" in events)
assert events == ["finished"]
def test_recognition_error_is_reported(qapp: QCoreApplication) -> None:
voice = _voice(FakeRecorder(), FakeRecognizer(error=RuntimeError("нет CUDA")))
errors: list[str] = []
voice.error_occurred.connect(errors.append)
voice.start()
_wait_until(lambda: bool(errors))
assert "нет CUDA" in errors[0]
assert not voice.is_active
+123
View File
@@ -0,0 +1,123 @@
import threading
import time
from collections.abc import Callable
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.wake import WakeWordListener
class FakeStream:
def __init__(self, owner: "FakeMicrophone") -> None:
self.owner = owner
def __enter__(self) -> "FakeStream":
with self.owner.lock:
self.owner.open_streams += 1
self.owner.opened += 1
return self
def __exit__(self, *args: object) -> None:
with self.owner.lock:
self.owner.open_streams -= 1
def read(self, frames: int) -> tuple[bytes, bool]:
time.sleep(0.005)
return b"\0\0" * frames, False
class FakeMicrophone:
def __init__(self) -> None:
self.lock = threading.Lock()
self.open_streams = 0
self.opened = 0
def __call__(self) -> FakeStream:
return FakeStream(self)
class FakeSession:
def __init__(self, detect_after: int | None) -> None:
self.detect_after = detect_after
self.blocks = 0
def accept(self, pcm16: bytes) -> bool:
self.blocks += 1
return self.detect_after is not None and self.blocks >= self.detect_after
class FakeDetector:
def __init__(self, detect_after: int | None = 3, error: Exception | None = None) -> None:
self.detect_after = detect_after
self.error = error
self.sessions = 0
def load(self) -> None:
pass
def create_session(self) -> FakeSession:
if self.error:
raise self.error
self.sessions += 1
return FakeSession(self.detect_after)
def _wait_until(condition: Callable[[], bool], timeout: float = 3.0) -> None:
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "условие не выполнилось вовремя"
QCoreApplication.processEvents()
time.sleep(0.005)
def test_detection_pauses_listening_until_resumed(qapp: QCoreApplication) -> None:
detector, microphone = FakeDetector(detect_after=3), FakeMicrophone()
listener = WakeWordListener(detector, enabled=True, open_stream=microphone)
detections: list[bool] = []
listener.detected.connect(lambda: detections.append(True))
_wait_until(lambda: detections == [True])
# После срабатывания микрофон освобождён и новая сессия не начинается
time.sleep(0.1)
assert microphone.open_streams == 0
assert detector.sessions == 1
listener.resume()
_wait_until(lambda: len(detections) == 2)
assert detector.sessions == 2
def test_disabled_listener_does_not_open_microphone(qapp: QCoreApplication) -> None:
microphone = FakeMicrophone()
listener = WakeWordListener(FakeDetector(), enabled=False, open_stream=microphone)
time.sleep(0.1)
assert microphone.opened == 0
listener.set_enabled(True)
_wait_until(lambda: microphone.opened == 1)
def test_pause_releases_microphone(qapp: QCoreApplication) -> None:
microphone = FakeMicrophone()
listener = WakeWordListener(FakeDetector(detect_after=None), enabled=True, open_stream=microphone)
_wait_until(lambda: microphone.open_streams == 1)
listener.pause()
_wait_until(lambda: microphone.open_streams == 0)
def test_failure_disables_listener_and_reports(qapp: QCoreApplication) -> None:
listener = WakeWordListener(
FakeDetector(error=RuntimeError("нет модели")), enabled=True, open_stream=FakeMicrophone()
)
errors: list[str] = []
enabled_changes: list[bool] = []
listener.error_occurred.connect(errors.append)
listener.enabled_changed.connect(enabled_changes.append)
_wait_until(lambda: bool(errors))
assert "нет модели" in errors[0]
assert enabled_changes == [False]
assert not listener.enabled
+33
View File
@@ -0,0 +1,33 @@
from agr_assistent.audio.wakeword import (
WakeWordMatcher,
contains_phrase,
normalize_phrase,
)
def test_normalize_phrase() -> None:
assert normalize_phrase(" Эй, Ассистент ") == ("эй", "ассистент")
def test_contains_phrase_matches_whole_words_in_order() -> None:
words = "слушай эй ассистент включи".split()
assert contains_phrase(words, ("эй", "ассистент"))
assert not contains_phrase(words, ("ассистент", "эй"))
assert not contains_phrase("ассистентка пришла".split(), ("ассистент",))
def test_partial_hypothesis_must_hold_for_several_updates() -> None:
matcher = WakeWordMatcher([("ассистент",)], required_streak=2)
assert not matcher.update_partial("ассистент")
assert not matcher.update_partial("ассистентка") # гипотеза исправилась — счёт сброшен
assert not matcher.update_partial("ассистентка ассистент")
assert matcher.update_partial("ассистентка ассистент какая")
def test_final_result_matches_immediately() -> None:
matcher = WakeWordMatcher([("эй", "ассистент")], required_streak=3)
assert matcher.update_final("ну эй ассистент")
assert not matcher.update_final("просто ассистент")
Generated
+2119
View File
File diff suppressed because it is too large Load Diff