Этап 7: долговременная память
- Факты о пользователе в SQLite, в системном промпте с номерами - Инструменты remember / update_memory / forget, автоматическое запоминание отключается - Цикл вызова инструментов со стримингом (до 5 кругов), проверка и приведение аргументов - Откат без инструментов для моделей, которые их не поддерживают, с одним предупреждением - Действия в журнале, вкладка «Память» в настройках - Фейковый OpenAI-совместимый сервер для тестов, тесты полного цикла через Assistant Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
542389c0f9
commit
84d8aaa848
@@ -6,6 +6,7 @@
|
|||||||
- Каждый запрос отдельный — без бесконечной истории диалога; короткие уточнения («а завтра?»)
|
- Каждый запрос отдельный — без бесконечной истории диалога; короткие уточнения («а завтра?»)
|
||||||
в течение пары минут после ответа видят предыдущие вопросы
|
в течение пары минут после ответа видят предыдущие вопросы
|
||||||
- Журнал запросов и ответов со стримингом
|
- Журнал запросов и ответов со стримингом
|
||||||
|
- Долговременная память: «запомни…», «забудь…», а устойчивые факты о вас модель сохраняет сама
|
||||||
- Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются
|
- Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются
|
||||||
- Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD)
|
- Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD)
|
||||||
- Слово активации («ассистент») через Vosk — без нажатия клавиш
|
- Слово активации («ассистент») через Vosk — без нажатия клавиш
|
||||||
@@ -37,6 +38,18 @@ llm:
|
|||||||
Голос и модель задаются в секции `tts` конфига; озвучку можно выключить в меню значка.
|
Голос и модель задаются в секции `tts` конфига; озвучку можно выключить в меню значка.
|
||||||
Silero читает только кириллицу: числа переводятся в слова, латиница пропускается.
|
Silero читает только кириллицу: числа переводятся в слова, латиница пропускается.
|
||||||
|
|
||||||
|
### Память
|
||||||
|
|
||||||
|
Скажите «Запомни, что у меня Škoda Octavia» или «Забудь про машину». Если включено
|
||||||
|
автоматическое запоминание (`memory.auto_save`), модель сама сохраняет устойчивые факты:
|
||||||
|
имя, близких, технику, предпочтения. Факты хранятся локально в
|
||||||
|
`%LOCALAPPDATA%\agr-assistent\memory.sqlite3` и добавляются к каждому запросу; посмотреть,
|
||||||
|
исправить и удалить их можно в настройках на вкладке «Память».
|
||||||
|
|
||||||
|
Память работает через вызов инструментов, поэтому модель должна их поддерживать
|
||||||
|
(например, `qwen2.5:7b` в Ollama или большинство моделей OpenRouter). С другими моделями
|
||||||
|
ассистент просто отвечает без памяти и один раз предупреждает об этом.
|
||||||
|
|
||||||
### Голосовой ввод
|
### Голосовой ввод
|
||||||
|
|
||||||
Нажмите `Win+Alt+Space` (настраивается в `voice.hotkey`), дождитесь короткого сигнала и говорите —
|
Нажмите `Win+Alt+Space` (настраивается в `voice.hotkey`), дождитесь короткого сигнала и говорите —
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from agr_assistent.audio.recorder import SpeechRecorder
|
|||||||
from agr_assistent.audio.wakeword import VoskWakeWord
|
from agr_assistent.audio.wakeword import VoskWakeWord
|
||||||
from agr_assistent.config import AppConfig, ConfigError, data_dir, load_config
|
from agr_assistent.config import AppConfig, ConfigError, data_dir, load_config
|
||||||
from agr_assistent.core.assistant import Assistant
|
from agr_assistent.core.assistant import Assistant
|
||||||
|
from agr_assistent.core.memory import MemoryStore
|
||||||
from agr_assistent.core.settings import Settings
|
from agr_assistent.core.settings import Settings
|
||||||
from agr_assistent.core.speech import Speaker
|
from agr_assistent.core.speech import Speaker
|
||||||
from agr_assistent.core.voice import VoiceInput
|
from agr_assistent.core.voice import VoiceInput
|
||||||
@@ -103,7 +104,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
VoskWakeWord(config.wake_word, models_dir), enabled=config.wake_word.enabled
|
VoskWakeWord(config.wake_word, models_dir), enabled=config.wake_word.enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
assistant = Assistant(config.llm, speaker, voice, wake_word)
|
memory = MemoryStore(data_dir() / "memory.sqlite3")
|
||||||
|
app.aboutToQuit.connect(memory.close)
|
||||||
|
assistant = Assistant(
|
||||||
|
config.llm,
|
||||||
|
speaker,
|
||||||
|
voice,
|
||||||
|
wake_word,
|
||||||
|
memory=memory,
|
||||||
|
memory_auto_save=config.memory.auto_save,
|
||||||
|
)
|
||||||
app.setWindowIcon(state_icon(assistant.state))
|
app.setWindowIcon(state_icon(assistant.state))
|
||||||
window = ChatWindow(assistant)
|
window = ChatWindow(assistant)
|
||||||
instance.activated.connect(window.show_and_raise)
|
instance.activated.connect(window.show_and_raise)
|
||||||
@@ -113,7 +123,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
def open_settings() -> None:
|
def open_settings() -> None:
|
||||||
nonlocal dialog
|
nonlocal dialog
|
||||||
if dialog is None or not dialog.isVisible():
|
if dialog is None or not dialog.isVisible():
|
||||||
dialog = SettingsDialog(settings, window)
|
dialog = SettingsDialog(settings, memory, window)
|
||||||
dialog.show()
|
dialog.show()
|
||||||
dialog.raise_()
|
dialog.raise_()
|
||||||
dialog.activateWindow()
|
dialog.activateWindow()
|
||||||
@@ -122,6 +132,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
assistant.update_llm_config(new_config.llm)
|
assistant.update_llm_config(new_config.llm)
|
||||||
assistant.set_speech_enabled(new_config.tts.enabled)
|
assistant.set_speech_enabled(new_config.tts.enabled)
|
||||||
assistant.set_wake_word_enabled(new_config.wake_word.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)
|
settings.changed.connect(apply_settings)
|
||||||
window.settings_requested.connect(open_settings)
|
window.settings_requested.connect(open_settings)
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ class WakeWordConfig:
|
|||||||
model: str
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MemoryConfig:
|
||||||
|
auto_save: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UIConfig:
|
class UIConfig:
|
||||||
start_minimized: bool
|
start_minimized: bool
|
||||||
@@ -97,6 +102,7 @@ class AppConfig:
|
|||||||
stt: STTConfig
|
stt: STTConfig
|
||||||
voice: VoiceConfig
|
voice: VoiceConfig
|
||||||
wake_word: WakeWordConfig
|
wake_word: WakeWordConfig
|
||||||
|
memory: MemoryConfig
|
||||||
ui: UIConfig
|
ui: UIConfig
|
||||||
path: Path
|
path: Path
|
||||||
|
|
||||||
@@ -254,6 +260,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
|
|||||||
phrases=[str(phrase) for phrase in phrases if str(phrase).strip()],
|
phrases=[str(phrase) for phrase in phrases if str(phrase).strip()],
|
||||||
model=str(wake_data["model"]),
|
model=str(wake_data["model"]),
|
||||||
)
|
)
|
||||||
|
memory = MemoryConfig(auto_save=bool(data["memory"]["auto_save"]))
|
||||||
ui = UIConfig(start_minimized=bool(data["ui"]["start_minimized"]))
|
ui = UIConfig(start_minimized=bool(data["ui"]["start_minimized"]))
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise ConfigError(f"{path}: отсутствует ключ {exc}") from exc
|
raise ConfigError(f"{path}: отсутствует ключ {exc}") from exc
|
||||||
@@ -268,7 +275,14 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
|
|||||||
if not wake_word.phrases:
|
if not wake_word.phrases:
|
||||||
raise ConfigError(f"{path}: wake_word.phrases не может быть пустым")
|
raise ConfigError(f"{path}: wake_word.phrases не может быть пустым")
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
llm=llm, tts=tts, stt=stt, voice=voice, wake_word=wake_word, ui=ui, path=path
|
llm=llm,
|
||||||
|
tts=tts,
|
||||||
|
stt=stt,
|
||||||
|
voice=voice,
|
||||||
|
wake_word=wake_word,
|
||||||
|
memory=memory,
|
||||||
|
ui=ui,
|
||||||
|
path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,31 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal, Slot
|
from PySide6.QtCore import QObject, Signal, Slot
|
||||||
|
|
||||||
from agr_assistent.config import LLMConfig
|
from agr_assistent.config import LLMConfig
|
||||||
from agr_assistent.core.context import FollowUpContext, Message, build_messages
|
from agr_assistent.core.context import FollowUpContext, build_messages
|
||||||
|
from agr_assistent.core.memory import MemoryStore, memory_prompt, memory_tools
|
||||||
from agr_assistent.core.speech import Speaker
|
from agr_assistent.core.speech import Speaker
|
||||||
from agr_assistent.core.voice import VoiceInput
|
from agr_assistent.core.voice import VoiceInput
|
||||||
from agr_assistent.core.wake import WakeWordListener
|
from agr_assistent.core.wake import WakeWordListener
|
||||||
from agr_assistent.llm.client import LLMClient, LLMError
|
from agr_assistent.llm.client import (
|
||||||
|
LLMClient,
|
||||||
|
LLMError,
|
||||||
|
TextDelta,
|
||||||
|
ToolCall,
|
||||||
|
ToolCalls,
|
||||||
|
ToolsNotSupportedError,
|
||||||
|
)
|
||||||
|
from agr_assistent.llm.tools import ToolRegistry
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Сколько раз подряд модель может вызвать инструменты в одном ответе
|
||||||
|
MAX_TOOL_ROUNDS = 5
|
||||||
|
|
||||||
|
|
||||||
class AssistantState(Enum):
|
class AssistantState(Enum):
|
||||||
IDLE = "idle"
|
IDLE = "idle"
|
||||||
@@ -39,10 +52,13 @@ class Assistant(QObject):
|
|||||||
reply_chunk = Signal(str)
|
reply_chunk = Signal(str)
|
||||||
reply_finished = Signal(str) # полный (возможно, прерванный) текст ответа
|
reply_finished = Signal(str) # полный (возможно, прерванный) текст ответа
|
||||||
error_occurred = Signal(str)
|
error_occurred = Signal(str)
|
||||||
|
tool_executed = Signal(str, bool) # текст для журнала; успешно ли
|
||||||
journal_cleared = Signal()
|
journal_cleared = Signal()
|
||||||
|
|
||||||
# Мост из фонового потока в главный; int — номер генерации
|
# Мост из фонового потока в главный; int — номер генерации
|
||||||
_worker_chunk = Signal(int, str)
|
_worker_chunk = Signal(int, str)
|
||||||
|
_worker_tool = Signal(int, str, bool)
|
||||||
|
_worker_notice = Signal(int, str)
|
||||||
_worker_failed = Signal(int, str)
|
_worker_failed = Signal(int, str)
|
||||||
_worker_done = Signal(int)
|
_worker_done = Signal(int)
|
||||||
|
|
||||||
@@ -52,6 +68,9 @@ class Assistant(QObject):
|
|||||||
speaker: Speaker,
|
speaker: Speaker,
|
||||||
voice: VoiceInput | None,
|
voice: VoiceInput | None,
|
||||||
wake_word: WakeWordListener | None = None,
|
wake_word: WakeWordListener | None = None,
|
||||||
|
*,
|
||||||
|
memory: MemoryStore | None = None,
|
||||||
|
memory_auto_save: bool = True,
|
||||||
parent: QObject | None = None,
|
parent: QObject | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -59,6 +78,11 @@ class Assistant(QObject):
|
|||||||
self._speaker = speaker
|
self._speaker = speaker
|
||||||
self._voice = voice
|
self._voice = voice
|
||||||
self._wake_word = wake_word
|
self._wake_word = wake_word
|
||||||
|
self._memory = memory
|
||||||
|
self._memory_auto_save = memory_auto_save
|
||||||
|
self._tools = ToolRegistry(memory_tools(memory) if memory is not None else ())
|
||||||
|
# Модели, которые отказались работать с инструментами: больше не предлагаем им инструменты
|
||||||
|
self._models_without_tools: set[tuple[str, str]] = set()
|
||||||
self._client: LLMClient | None = None
|
self._client: LLMClient | None = None
|
||||||
self._context = FollowUpContext(config.follow_up_seconds)
|
self._context = FollowUpContext(config.follow_up_seconds)
|
||||||
self._state = AssistantState.IDLE
|
self._state = AssistantState.IDLE
|
||||||
@@ -69,6 +93,8 @@ class Assistant(QObject):
|
|||||||
self._reply_parts: list[str] = []
|
self._reply_parts: list[str] = []
|
||||||
|
|
||||||
self._worker_chunk.connect(self._on_worker_chunk)
|
self._worker_chunk.connect(self._on_worker_chunk)
|
||||||
|
self._worker_tool.connect(self._on_worker_tool)
|
||||||
|
self._worker_notice.connect(self._on_worker_notice)
|
||||||
self._worker_failed.connect(self._on_worker_failed)
|
self._worker_failed.connect(self._on_worker_failed)
|
||||||
self._worker_done.connect(self._on_worker_done)
|
self._worker_done.connect(self._on_worker_done)
|
||||||
speaker.playback_started.connect(self._update_state)
|
speaker.playback_started.connect(self._update_state)
|
||||||
@@ -166,6 +192,9 @@ class Assistant(QObject):
|
|||||||
log.info("Настройки LLM обновлены: %s (%s)", config.provider, self.model_name)
|
log.info("Настройки LLM обновлены: %s (%s)", config.provider, self.model_name)
|
||||||
self.provider_changed.emit(config.provider)
|
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:
|
def set_speech_enabled(self, enabled: bool) -> None:
|
||||||
if enabled == self._speaker.enabled:
|
if enabled == self._speaker.enabled:
|
||||||
return
|
return
|
||||||
@@ -191,13 +220,20 @@ class Assistant(QObject):
|
|||||||
self._generating = True
|
self._generating = True
|
||||||
self._request = text
|
self._request = text
|
||||||
self._reply_parts = []
|
self._reply_parts = []
|
||||||
messages = build_messages(self._config.system_prompt, context, text, datetime.now())
|
sections = []
|
||||||
|
if self._memory is not None:
|
||||||
|
sections.append(memory_prompt(self._memory.facts(), self._memory_auto_save))
|
||||||
|
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._speaker.begin()
|
||||||
self._update_state()
|
self._update_state()
|
||||||
self.reply_started.emit()
|
self.reply_started.emit()
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._run_reply,
|
target=self._run_reply,
|
||||||
args=(self._generation, client, messages),
|
args=(self._generation, client, messages, use_tools, model_key),
|
||||||
name=f"llm-reply-{self._generation}",
|
name=f"llm-reply-{self._generation}",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
@@ -226,13 +262,40 @@ class Assistant(QObject):
|
|||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def _run_reply(self, generation: int, client: LLMClient, messages: list[Message]) -> None:
|
def _run_reply(
|
||||||
"""Выполняется в фоновом потоке."""
|
self,
|
||||||
|
generation: int,
|
||||||
|
client: LLMClient,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
use_tools: bool,
|
||||||
|
model_key: tuple[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Выполняется в фоновом потоке: ответ модели и вызовы инструментов по кругу."""
|
||||||
try:
|
try:
|
||||||
for piece in client.stream_chat(messages):
|
for _round in range(MAX_TOOL_ROUNDS + 1):
|
||||||
if generation != self._generation:
|
# На последнем круге инструменты не предлагаем — модель обязана ответить текстом
|
||||||
return
|
tools = self._tools.schemas() if use_tools and _round < MAX_TOOL_ROUNDS else None
|
||||||
self._worker_chunk.emit(generation, piece)
|
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._tools.execute(call.name, call.arguments)
|
||||||
|
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:
|
except LLMError as exc:
|
||||||
self._worker_failed.emit(generation, str(exc))
|
self._worker_failed.emit(generation, str(exc))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -241,6 +304,26 @@ class Assistant(QObject):
|
|||||||
else:
|
else:
|
||||||
self._worker_done.emit(generation)
|
self._worker_done.emit(generation)
|
||||||
|
|
||||||
|
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()
|
@Slot()
|
||||||
def _on_wake_word(self) -> None:
|
def _on_wake_word(self) -> None:
|
||||||
if self._voice is not None and not self._voice.is_active:
|
if self._voice is not None and not self._voice.is_active:
|
||||||
@@ -263,6 +346,16 @@ class Assistant(QObject):
|
|||||||
self._speaker.feed(piece)
|
self._speaker.feed(piece)
|
||||||
self.reply_chunk.emit(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)
|
@Slot(int, str)
|
||||||
def _on_worker_failed(self, generation: int, message: str) -> None:
|
def _on_worker_failed(self, generation: int, message: str) -> None:
|
||||||
if generation == self._generation:
|
if generation == self._generation:
|
||||||
@@ -313,3 +406,18 @@ class Assistant(QObject):
|
|||||||
self._wake_word.resume()
|
self._wake_word.resume()
|
||||||
else:
|
else:
|
||||||
self._wake_word.pause()
|
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
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
Message = dict[str, str]
|
Message = dict[str, str]
|
||||||
|
|
||||||
@@ -67,11 +68,15 @@ class FollowUpContext:
|
|||||||
|
|
||||||
|
|
||||||
def build_messages(
|
def build_messages(
|
||||||
system_prompt: str, context: list[Message], request: str, now: datetime
|
system_prompt: str,
|
||||||
) -> list[Message]:
|
context: list[Message],
|
||||||
system = "\n\n".join(
|
request: str,
|
||||||
part for part in (system_prompt.strip(), f"Сейчас {format_datetime(now)}.") if part
|
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}]
|
return [{"role": "system", "content": system}, *context, {"role": "user", "content": request}]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
)
|
||||||
@@ -81,6 +81,12 @@ wake_word:
|
|||||||
# Модель Vosk, скачивается при первом включении (~45 МБ)
|
# Модель Vosk, скачивается при первом включении (~45 МБ)
|
||||||
model: vosk-model-small-ru-0.22
|
model: vosk-model-small-ru-0.22
|
||||||
|
|
||||||
|
memory:
|
||||||
|
# Модель сама сохраняет устойчивые факты о вас (имя, близкие, техника, предпочтения).
|
||||||
|
# Явные просьбы «запомни…» и «забудь…» работают всегда. Факты хранятся локально,
|
||||||
|
# просмотреть и отредактировать их можно в настройках на вкладке «Память»
|
||||||
|
auto_save: true
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
# Запускаться сразу в трее, не показывая окно чата
|
# Запускаться сразу в трее, не показывая окно чата
|
||||||
start_minimized: false
|
start_minimized: false
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
@@ -10,11 +13,40 @@ from openai import OpenAI
|
|||||||
from agr_assistent import APP_NAME
|
from agr_assistent import APP_NAME
|
||||||
from agr_assistent.config import ProviderConfig
|
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 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:
|
class LLMClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, provider: ProviderConfig, *, temperature: float, timeout_seconds: float
|
self, provider: ProviderConfig, *, temperature: float, timeout_seconds: float
|
||||||
@@ -35,31 +67,62 @@ class LLMClient:
|
|||||||
default_headers=headers,
|
default_headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
def stream_chat(self, messages: list[dict[str, str]]) -> Iterator[str]:
|
def stream_chat(
|
||||||
"""Отдаёт текст ответа по мере генерации."""
|
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
|
||||||
|
) -> Iterator[StreamEvent]:
|
||||||
|
"""Отдаёт текст по мере генерации, а в конце — запрошенные вызовы инструментов."""
|
||||||
provider = self._provider
|
provider = self._provider
|
||||||
|
extra: dict[str, Any] = {"tools": tools} if tools else {}
|
||||||
try:
|
try:
|
||||||
stream = self._client.chat.completions.create(
|
stream = self._client.chat.completions.create(
|
||||||
model=provider.model,
|
model=provider.model,
|
||||||
messages=messages, # type: ignore[arg-type]
|
messages=messages, # type: ignore[arg-type]
|
||||||
temperature=self._temperature,
|
temperature=self._temperature,
|
||||||
stream=True,
|
stream=True,
|
||||||
|
**extra,
|
||||||
)
|
)
|
||||||
|
# Вызов инструмента приходит кусками: имя и id один раз, аргументы — частями
|
||||||
|
pending: dict[int, dict[str, str]] = {}
|
||||||
with stream:
|
with stream:
|
||||||
for chunk in stream:
|
for chunk in stream:
|
||||||
if chunk.choices and (text := chunk.choices[0].delta.content):
|
if not chunk.choices:
|
||||||
yield text
|
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:
|
except openai.APITimeoutError as exc:
|
||||||
raise LLMError(f"Превышено время ожидания ответа от {provider.base_url}") from exc
|
raise LLMError(f"Превышено время ожидания ответа от {provider.base_url}") from exc
|
||||||
except openai.APIConnectionError as exc:
|
except openai.APIConnectionError as exc:
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"Не удалось подключиться к {provider.base_url} — сервер запущен?"
|
f"Не удалось подключиться к {provider.base_url} — сервер запущен?"
|
||||||
) from exc
|
) from exc
|
||||||
except openai.AuthenticationError as exc:
|
|
||||||
raise LLMError(f"Неверный API-ключ для провайдера '{provider.name}'") from exc
|
|
||||||
except openai.NotFoundError as exc:
|
|
||||||
raise LLMError(
|
|
||||||
f"Модель '{provider.model}' или адрес {provider.base_url} не найдены"
|
|
||||||
) from exc
|
|
||||||
except openai.APIStatusError as exc:
|
except openai.APIStatusError as exc:
|
||||||
raise LLMError(f"Ошибка API ({exc.status_code}): {exc.message}") from 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,146 @@
|
|||||||
|
"""Инструменты, которые модель может вызывать: описание, проверка аргументов, выполнение."""
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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 execute(self, name: str, arguments_json: str) -> 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}")
|
||||||
|
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
|
||||||
|
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
|
||||||
@@ -33,6 +33,8 @@ _ROLE_STYLES = {
|
|||||||
"user": ("Вы", "#1e88e5"),
|
"user": ("Вы", "#1e88e5"),
|
||||||
"assistant": ("Ассистент", "#43a047"),
|
"assistant": ("Ассистент", "#43a047"),
|
||||||
"error": ("Ошибка", "#e53935"),
|
"error": ("Ошибка", "#e53935"),
|
||||||
|
"tool": ("Действие", "#8e24aa"),
|
||||||
|
"tool_failed": ("Действие не выполнено", "#e53935"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_VOICE_BUTTON_TEXTS = {
|
_VOICE_BUTTON_TEXTS = {
|
||||||
@@ -140,6 +142,7 @@ class ChatWindow(QWidget):
|
|||||||
assistant.reply_chunk.connect(self._on_reply_chunk)
|
assistant.reply_chunk.connect(self._on_reply_chunk)
|
||||||
assistant.reply_finished.connect(self._on_reply_finished)
|
assistant.reply_finished.connect(self._on_reply_finished)
|
||||||
assistant.error_occurred.connect(lambda message: self._append(_Entry("error", message)))
|
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)
|
assistant.journal_cleared.connect(self._on_journal_cleared)
|
||||||
|
|
||||||
self._update_provider_label()
|
self._update_provider_label()
|
||||||
@@ -188,16 +191,25 @@ class ChatWindow(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _on_reply_chunk(self, piece: str) -> None:
|
def _on_reply_chunk(self, piece: str) -> None:
|
||||||
if self._entries and self._entries[-1].role == "assistant":
|
# После действия текст ответа продолжается новой записью под ним
|
||||||
self._entries[-1].text += piece
|
if not self._entries or self._entries[-1].role != "assistant":
|
||||||
if not self._render_timer.isActive():
|
self._entries.append(_Entry("assistant", ""))
|
||||||
self._render_timer.start()
|
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:
|
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
|
last = self._entries[-1] if self._entries else None
|
||||||
if last and last.role == "assistant" and not last.text.strip():
|
if last and last.role == "assistant" and not last.text.strip():
|
||||||
self._entries.pop()
|
self._entries.pop()
|
||||||
self._render_now()
|
|
||||||
|
|
||||||
def _on_journal_cleared(self) -> None:
|
def _on_journal_cleared(self) -> None:
|
||||||
self._entries.clear()
|
self._entries.clear()
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Qt, QUrl, Signal
|
from PySide6.QtCore import QObject, Qt, QUrl, Signal
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QColor, QDesktopServices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -17,8 +18,11 @@ from PySide6.QtWidgets import (
|
|||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
@@ -31,10 +35,13 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from agr_assistent import APP_NAME, system
|
from agr_assistent import APP_NAME, system
|
||||||
from agr_assistent.config import ConfigError, data_dir, expand_env, get_value
|
from agr_assistent.config import ConfigError, data_dir, expand_env, get_value
|
||||||
|
from agr_assistent.core.memory import SOURCE_USER, MemoryStore
|
||||||
from agr_assistent.core.settings import Settings, needs_restart
|
from agr_assistent.core.settings import Settings, needs_restart
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_AUTO_FACT_COLOR = "#757575"
|
||||||
|
|
||||||
_TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"]
|
_TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"]
|
||||||
_STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"]
|
_STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"]
|
||||||
_STT_DEVICES = ["auto", "cuda", "cpu"]
|
_STT_DEVICES = ["auto", "cuda", "cpu"]
|
||||||
@@ -62,9 +69,12 @@ class _ModelListLoader(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
class SettingsDialog(QDialog):
|
||||||
def __init__(self, settings: Settings, parent: QWidget | None = None) -> None:
|
def __init__(
|
||||||
|
self, settings: Settings, memory: MemoryStore | None = None, parent: QWidget | None = None
|
||||||
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
|
self._memory = memory
|
||||||
self._raw = settings.raw()
|
self._raw = settings.raw()
|
||||||
self._provider_edits: dict[str, dict[str, str]] = {
|
self._provider_edits: dict[str, dict[str, str]] = {
|
||||||
name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")}
|
name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")}
|
||||||
@@ -79,6 +89,7 @@ class SettingsDialog(QDialog):
|
|||||||
tabs.addTab(self._build_llm_tab(), "Модель")
|
tabs.addTab(self._build_llm_tab(), "Модель")
|
||||||
tabs.addTab(self._build_speech_tab(), "Озвучка")
|
tabs.addTab(self._build_speech_tab(), "Озвучка")
|
||||||
tabs.addTab(self._build_voice_tab(), "Голосовой ввод")
|
tabs.addTab(self._build_voice_tab(), "Голосовой ввод")
|
||||||
|
tabs.addTab(self._build_memory_tab(), "Память")
|
||||||
tabs.addTab(self._build_general_tab(), "Общие")
|
tabs.addTab(self._build_general_tab(), "Общие")
|
||||||
|
|
||||||
buttons = QDialogButtonBox(
|
buttons = QDialogButtonBox(
|
||||||
@@ -206,6 +217,44 @@ class SettingsDialog(QDialog):
|
|||||||
form.addRow("Фразы", self._wake_phrases)
|
form.addRow("Фразы", self._wake_phrases)
|
||||||
return _page(form)
|
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_general_tab(self) -> QWidget:
|
def _build_general_tab(self) -> QWidget:
|
||||||
self._start_minimized = QCheckBox("Запускаться свёрнутым в трей")
|
self._start_minimized = QCheckBox("Запускаться свёрнутым в трей")
|
||||||
self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized")))
|
self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized")))
|
||||||
@@ -268,6 +317,43 @@ class SettingsDialog(QDialog):
|
|||||||
self._model.setEditText(current)
|
self._model.setEditText(current)
|
||||||
self._models_status.setText(f"Доступно моделей: {len(models)}")
|
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]:
|
def _collect(self) -> dict[str, Any]:
|
||||||
@@ -290,6 +376,7 @@ class SettingsDialog(QDialog):
|
|||||||
"stt.language": self._stt_language.text().strip(),
|
"stt.language": self._stt_language.text().strip(),
|
||||||
"wake_word.enabled": self._wake_enabled.isChecked(),
|
"wake_word.enabled": self._wake_enabled.isChecked(),
|
||||||
"wake_word.phrases": phrases,
|
"wake_word.phrases": phrases,
|
||||||
|
"memory.auto_save": self._memory_auto_save.isChecked(),
|
||||||
"ui.start_minimized": self._start_minimized.isChecked(),
|
"ui.start_minimized": self._start_minimized.isChecked(),
|
||||||
}
|
}
|
||||||
for name, fields in self._provider_edits.items():
|
for name, fields in self._provider_edits.items():
|
||||||
|
|||||||
@@ -5,9 +5,20 @@ import pytest
|
|||||||
# Окна в тестах не показываем
|
# Окна в тестах не показываем
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from collections.abc import Iterator # noqa: E402
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
from tests.fake_llm import FakeLLMServer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def qapp() -> QApplication:
|
def qapp() -> QApplication:
|
||||||
return QApplication.instance() or 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,141 @@
|
|||||||
|
"""Полный цикл запроса через Assistant: модель, инструменты памяти, откат без инструментов."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QCoreApplication
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
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) -> 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, None, memory=memory, memory_auto_save=auto_save)
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -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,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,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
|
||||||
Reference in New Issue
Block a user