Compare commits
9
Commits
26a01aa210
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f4210480 | ||
|
|
04029f3750 | ||
|
|
84d8aaa848 | ||
|
|
542389c0f9 | ||
|
|
20a666e0d4 | ||
|
|
a719980d74 | ||
|
|
513d010c19 | ||
|
|
fec52dd1c2 | ||
|
|
7933613234 |
+10
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Локальный конфиг может содержать API-ключи
|
||||||
|
config.yaml
|
||||||
|
commands.yaml
|
||||||
@@ -1,2 +1,145 @@
|
|||||||
# agr-assistent
|
# 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,10 @@
|
|||||||
|
"""Точка входа для PyInstaller."""
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from agr_assistent.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
sys.exit(main())
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""agr-assistent — минимальный AI-ассистент в системном трее."""
|
||||||
|
|
||||||
|
APP_NAME = "agr-assistent"
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from agr_assistent.app import main
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}? Скажите «да» или «нет»."
|
||||||
@@ -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}"
|
||||||
@@ -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"]),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 + клавиша (буква, цифра, f1–f24, 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
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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']}"
|
||||||
@@ -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)
|
||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -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 " "
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Окно ассистента: журнал запросов и ответов и поле ввода."""
|
||||||
|
|
||||||
|
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
|
||||||
|
# Насколько можно не дотянуть до низа, чтобы журнал всё равно следовал за новыми записями
|
||||||
|
_BOTTOM_TOLERANCE_PX = 4
|
||||||
|
|
||||||
|
_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._follow_bottom = True
|
||||||
|
self._scroll_position = 0
|
||||||
|
self._rendering = False
|
||||||
|
self._adjusting_scroll = False
|
||||||
|
transcript_scrollbar = self._transcript.verticalScrollBar()
|
||||||
|
transcript_scrollbar.valueChanged.connect(self._on_user_scrolled)
|
||||||
|
transcript_scrollbar.rangeChanged.connect(self._restore_scroll)
|
||||||
|
|
||||||
|
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(self._on_request_added)
|
||||||
|
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_request_added(self, text: str, follow_up: bool) -> None:
|
||||||
|
# Новый запрос всегда показываем, даже если журнал был прокручен вверх
|
||||||
|
self._follow_bottom = True
|
||||||
|
self._append(_Entry("user", text, follow_up))
|
||||||
|
|
||||||
|
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()
|
||||||
|
# Журнал перестраивается целиком, и clear() сбрасывает прокрутку в начало
|
||||||
|
self._rendering = True
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._rendering = False
|
||||||
|
self._restore_scroll(scrollbar.minimum(), scrollbar.maximum())
|
||||||
|
|
||||||
|
def _on_user_scrolled(self, value: int) -> None:
|
||||||
|
# Перерисовка и наша же подстройка тоже двигают полосу — это не действие пользователя
|
||||||
|
if self._rendering or self._adjusting_scroll:
|
||||||
|
return
|
||||||
|
scrollbar = self._transcript.verticalScrollBar()
|
||||||
|
self._follow_bottom = value >= scrollbar.maximum() - _BOTTOM_TOLERANCE_PX
|
||||||
|
self._scroll_position = value
|
||||||
|
|
||||||
|
def _restore_scroll(self, _minimum: int, maximum: int) -> None:
|
||||||
|
"""Диапазон прокрутки меняется после раскладки документа, иногда позже перерисовки."""
|
||||||
|
if self._rendering:
|
||||||
|
return
|
||||||
|
self._adjusting_scroll = True
|
||||||
|
try:
|
||||||
|
scrollbar = self._transcript.verticalScrollBar()
|
||||||
|
scrollbar.setValue(maximum if self._follow_bottom else self._scroll_position)
|
||||||
|
finally:
|
||||||
|
self._adjusting_scroll = False
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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() == []
|
||||||
@@ -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"])
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QCoreApplication
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from agr_assistent.core.memory import MemoryStore
|
||||||
|
from agr_assistent.ui.chat_window import ChatWindow
|
||||||
|
from tests.fake_llm import FakeLLMServer
|
||||||
|
from tests.test_assistant_flow import _assistant
|
||||||
|
|
||||||
|
|
||||||
|
def _pump(seconds: float = 0.1) -> None:
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
QCoreApplication.processEvents()
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window(qapp: QApplication, fake_llm: FakeLLMServer, tmp_path) -> ChatWindow: # type: ignore[no-untyped-def]
|
||||||
|
memory = MemoryStore(tmp_path / "memory.sqlite3")
|
||||||
|
chat = ChatWindow(_assistant(fake_llm, memory))
|
||||||
|
chat.resize(500, 300)
|
||||||
|
chat.show()
|
||||||
|
yield chat
|
||||||
|
chat.hide()
|
||||||
|
memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _scrollbar(window: ChatWindow): # type: ignore[no-untyped-def]
|
||||||
|
return window._transcript.verticalScrollBar()
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_reply(window: ChatWindow, request: str, chunks: int) -> None:
|
||||||
|
assistant = window._assistant
|
||||||
|
assistant.request_added.emit(request, False)
|
||||||
|
assistant.reply_started.emit()
|
||||||
|
for index in range(chunks):
|
||||||
|
assistant.reply_chunk.emit(f"Строка ответа номер {index}.\n\n")
|
||||||
|
_pump(0.01)
|
||||||
|
assistant.reply_finished.emit("")
|
||||||
|
_pump(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_journal_follows_new_content(window: ChatWindow) -> None:
|
||||||
|
for request in range(3):
|
||||||
|
_stream_reply(window, f"Вопрос {request}", chunks=15)
|
||||||
|
|
||||||
|
scrollbar = _scrollbar(window)
|
||||||
|
assert scrollbar.maximum() > 0
|
||||||
|
assert scrollbar.value() == scrollbar.maximum()
|
||||||
|
|
||||||
|
|
||||||
|
def test_journal_keeps_position_when_user_scrolled_up(window: ChatWindow) -> None:
|
||||||
|
_stream_reply(window, "Длинный вопрос", chunks=30)
|
||||||
|
scrollbar = _scrollbar(window)
|
||||||
|
scrollbar.setValue(10)
|
||||||
|
_pump()
|
||||||
|
|
||||||
|
assistant = window._assistant
|
||||||
|
assistant.reply_started.emit()
|
||||||
|
for index in range(10):
|
||||||
|
assistant.reply_chunk.emit(f"Ещё строка {index}.\n\n")
|
||||||
|
_pump(0.01)
|
||||||
|
_pump(0.2)
|
||||||
|
assert scrollbar.value() == 10
|
||||||
|
|
||||||
|
# Новый запрос пользователя снова прокручивает вниз
|
||||||
|
assistant.request_added.emit("Новый вопрос", False)
|
||||||
|
_pump(0.2)
|
||||||
|
assert scrollbar.value() == scrollbar.maximum()
|
||||||
@@ -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
|
||||||
@@ -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+")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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("просто ассистент")
|
||||||
Reference in New Issue
Block a user