Этап 7: долговременная память

- Факты о пользователе в SQLite, в системном промпте с номерами
- Инструменты remember / update_memory / forget, автоматическое запоминание отключается
- Цикл вызова инструментов со стримингом (до 5 кругов), проверка и приведение аргументов
- Откат без инструментов для моделей, которые их не поддерживают, с одним предупреждением
- Действия в журнале, вкладка «Память» в настройках
- Фейковый OpenAI-совместимый сервер для тестов, тесты полного цикла через Assistant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 04:27:16 +03:00
co-authored by Claude Opus 5
parent 542389c0f9
commit 84d8aaa848
18 changed files with 1168 additions and 37 deletions
+13
View File
@@ -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`), дождитесь короткого сигнала и говорите —
+13 -2
View File
@@ -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)
+15 -1
View File
@@ -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,
) )
+118 -10
View File
@@ -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
],
}
+11 -6
View File
@@ -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}]
+220
View File
@@ -0,0 +1,220 @@
"""Долговременная память: факты о пользователе в SQLite и инструменты для модели."""
from __future__ import annotations
import sqlite3
import threading
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from agr_assistent.llm.tools import Tool, ToolError, ToolResult
SOURCE_USER = "user"
SOURCE_AUTO = "auto"
# Больше фактов в системный промпт не кладём, чтобы не раздувать каждый запрос
MAX_PROMPT_FACTS = 200
_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class Fact:
id: int
text: str
source: str
created_at: datetime
updated_at: datetime
@property
def is_auto(self) -> bool:
return self.source == SOURCE_AUTO
class MemoryStore:
"""Потокобезопасное хранилище: модель пишет из фонового потока, настройки — из главного."""
def __init__(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._connection = sqlite3.connect(path, check_same_thread=False)
self._connection.row_factory = sqlite3.Row
with self._lock, self._connection:
if self._connection.execute("PRAGMA user_version").fetchone()[0] < _SCHEMA_VERSION:
self._connection.execute(
"""
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
self._connection.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
def close(self) -> None:
with self._lock:
self._connection.close()
def facts(self) -> list[Fact]:
with self._lock:
rows = self._connection.execute("SELECT * FROM facts ORDER BY id").fetchall()
return [_fact(row) for row in rows]
def add(self, text: str, source: str) -> Fact:
"""Точный дубль (без учёта регистра) не создаётся — возвращается существующий факт."""
text = _clean(text)
with self._lock, self._connection:
for row in self._connection.execute("SELECT * FROM facts"):
if row["text"].casefold() == text.casefold():
return _fact(row)
now = _now()
cursor = self._connection.execute(
"INSERT INTO facts (text, source, created_at, updated_at) VALUES (?, ?, ?, ?)",
(text, source, now, now),
)
row = self._connection.execute(
"SELECT * FROM facts WHERE id = ?", (cursor.lastrowid,)
).fetchone()
return _fact(row)
def update(self, fact_id: int, text: str) -> Fact | None:
text = _clean(text)
with self._lock, self._connection:
updated = self._connection.execute(
"UPDATE facts SET text = ?, updated_at = ? WHERE id = ?", (text, _now(), fact_id)
).rowcount
row = self._connection.execute("SELECT * FROM facts WHERE id = ?", (fact_id,)).fetchone()
return _fact(row) if updated else None
def delete(self, fact_id: int) -> Fact | None:
with self._lock, self._connection:
row = self._connection.execute("SELECT * FROM facts WHERE id = ?", (fact_id,)).fetchone()
if row is None:
return None
self._connection.execute("DELETE FROM facts WHERE id = ?", (fact_id,))
return _fact(row)
def memory_prompt(facts: list[Fact], auto_save: bool) -> str:
lines = ["Долговременная память о пользователе (номер: факт):"]
if facts:
shown = facts[-MAX_PROMPT_FACTS:]
lines.extend(f"[{fact.id}] {fact.text}" for fact in shown)
else:
lines.append("пока пусто")
lines.append("")
lines.append("Работа с памятью:")
lines.append(
"- Если пользователь просит что-то запомнить, вызови remember с requested_by_user=true "
"и коротко подтверди."
)
if auto_save:
lines.append(
"- Если пользователь сообщает о себе устойчивый факт, который пригодится в будущем "
"(имя, близкие, работа, техника, привычки, предпочтения в ответах), сохрани его через "
"remember с requested_by_user=false, не спрашивая разрешения и не сообщая об этом. "
"Не сохраняй разовые просьбы, временное состояние и содержимое текущего вопроса."
)
lines.append(
"- Если новый факт уточняет или противоречит сохранённому, используй update_memory "
"с номером старого факта, а не remember."
)
lines.append("- Если пользователь просит забыть факт, вызови forget с его номером.")
lines.append(
"- Формулируй факты кратко, в третьем лице, например: «Машина пользователя — Škoda Octavia»."
)
return "\n".join(lines)
def memory_tools(store: MemoryStore) -> list[Tool]:
def remember(arguments: dict[str, Any]) -> ToolResult:
text = _required_text(arguments)
source = SOURCE_USER if arguments.get("requested_by_user", True) else SOURCE_AUTO
fact = store.add(text, source)
prefix = "Запомнил" if source == SOURCE_USER else "Запомнил сам"
return ToolResult(True, f"Сохранено под номером {fact.id}", f"{prefix}: {fact.text}")
def update_memory(arguments: dict[str, Any]) -> ToolResult:
fact = store.update(arguments["id"], _required_text(arguments))
if fact is None:
raise ToolError(f"Факта с номером {arguments['id']} нет в памяти")
return ToolResult(True, f"Факт {fact.id} обновлён", f"Обновил в памяти: {fact.text}")
def forget(arguments: dict[str, Any]) -> ToolResult:
fact = store.delete(arguments["id"])
if fact is None:
raise ToolError(f"Факта с номером {arguments['id']} нет в памяти")
return ToolResult(True, f"Факт {fact.id} удалён", f"Забыл: {fact.text}")
return [
Tool(
name="remember",
description="Сохранить факт о пользователе в долговременную память.",
parameters={
"type": "object",
"properties": {
"text": {"type": "string", "description": "Факт одной короткой фразой"},
"requested_by_user": {
"type": "boolean",
"description": "true — пользователь сам попросил запомнить",
},
},
"required": ["text"],
},
handler=remember,
),
Tool(
name="update_memory",
description="Исправить или уточнить сохранённый факт.",
parameters={
"type": "object",
"properties": {
"id": {"type": "integer", "description": "Номер факта"},
"text": {"type": "string", "description": "Новая формулировка факта"},
},
"required": ["id", "text"],
},
handler=update_memory,
),
Tool(
name="forget",
description="Удалить факт из памяти.",
parameters={
"type": "object",
"properties": {"id": {"type": "integer", "description": "Номер факта"}},
"required": ["id"],
},
handler=forget,
),
]
def _required_text(arguments: dict[str, Any]) -> str:
text = _clean(arguments.get("text", ""))
if not text:
raise ToolError("Текст факта пуст")
return text
def _clean(text: str) -> str:
return " ".join(str(text).split())
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _fact(row: sqlite3.Row) -> Fact:
return Fact(
id=row["id"],
text=row["text"],
source=row["source"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
+6
View File
@@ -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
+74 -11
View File
@@ -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}")
+146
View File
@@ -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
+17 -5
View File
@@ -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()
+89 -2
View File
@@ -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():
View File
+11
View File
@@ -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()
+90
View File
@@ -0,0 +1,90 @@
"""Фейковый OpenAI-совместимый сервер: отдаёт заранее заданные ответы стримом."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
@dataclass
class Reply:
text: str = ""
# (имя, аргументы JSON); аргументы отдаются несколькими кусками, как у настоящих серверов
tool_calls: list[tuple[str, str]] = field(default_factory=list)
status: int = 200
error: str = ""
class FakeLLMServer:
def __init__(self) -> None:
self.replies: list[Reply] = []
self.requests: list[dict[str, Any]] = []
server = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
server.requests.append(body)
reply = server.replies.pop(0) if server.replies else Reply(text="")
if reply.status != 200:
payload = json.dumps({"error": {"message": reply.error}}).encode()
self.send_response(reply.status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for delta in _deltas(reply):
chunk = {
"id": "fake",
"object": "chat.completion.chunk",
"created": 0,
"model": body["model"],
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self._server.serve_forever, daemon=True).start()
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self._server.server_port}/v1"
def close(self) -> None:
self._server.shutdown()
self._server.server_close()
def _deltas(reply: Reply) -> list[dict[str, Any]]:
deltas: list[dict[str, Any]] = []
for start in range(0, len(reply.text), 5):
deltas.append({"content": reply.text[start : start + 5]})
for index, (name, arguments) in enumerate(reply.tool_calls):
middle = len(arguments) // 2
deltas.append(
{
"tool_calls": [
{
"index": index,
"id": f"call_{index}",
"type": "function",
"function": {"name": name, "arguments": arguments[:middle]},
}
]
}
)
deltas.append(
{"tool_calls": [{"index": index, "function": {"arguments": arguments[middle:]}}]}
)
return deltas
+141
View File
@@ -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"]
+67
View File
@@ -0,0 +1,67 @@
import pytest
from agr_assistent.config import ProviderConfig
from agr_assistent.llm.client import (
LLMClient,
LLMError,
TextDelta,
ToolCall,
ToolCalls,
ToolsNotSupportedError,
)
from tests.fake_llm import FakeLLMServer, Reply
_TOOLS = [{"type": "function", "function": {"name": "remember", "parameters": {"type": "object"}}}]
def _client(server: FakeLLMServer) -> LLMClient:
return LLMClient(
ProviderConfig("fake", server.base_url, "key", "fake-model"),
temperature=0.5,
timeout_seconds=10,
)
def test_text_and_tool_calls_are_assembled(fake_llm: FakeLLMServer) -> None:
fake_llm.replies.append(
Reply(text="Сейчас запомню.", tool_calls=[("remember", '{"text": "Любит кофе"}')])
)
events = list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}], _TOOLS))
text = "".join(event.text for event in events if isinstance(event, TextDelta))
assert text == "Сейчас запомню."
assert events[-1] == ToolCalls([ToolCall("call_0", "remember", '{"text": "Любит кофе"}')])
assert fake_llm.requests[0]["tools"] == _TOOLS
def test_tools_are_not_sent_when_absent(fake_llm: FakeLLMServer) -> None:
fake_llm.replies.append(Reply(text="ок"))
list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}]))
assert "tools" not in fake_llm.requests[0]
@pytest.mark.parametrize(
"message",
[
"registry.ollama.ai/library/gemma:2b does not support tools",
"No endpoints found that support tool use",
],
)
def test_tools_not_supported_is_recognized(fake_llm: FakeLLMServer, message: str) -> None:
fake_llm.replies.append(Reply(status=400, error=message))
with pytest.raises(ToolsNotSupportedError):
list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}], _TOOLS))
def test_other_api_errors_stay_generic(fake_llm: FakeLLMServer) -> None:
fake_llm.replies.append(Reply(status=400, error="context too long"))
with pytest.raises(LLMError) as error:
list(_client(fake_llm).stream_chat([{"role": "user", "content": "привет"}], _TOOLS))
assert not isinstance(error.value, ToolsNotSupportedError)
assert "context too long" in str(error.value)
+73
View File
@@ -0,0 +1,73 @@
from pathlib import Path
import pytest
from agr_assistent.core.memory import (
SOURCE_AUTO,
SOURCE_USER,
MemoryStore,
memory_prompt,
memory_tools,
)
from agr_assistent.llm.tools import ToolRegistry
@pytest.fixture
def store(tmp_path: Path) -> MemoryStore:
memory = MemoryStore(tmp_path / "memory.sqlite3")
yield memory
memory.close()
def test_facts_survive_reopening(tmp_path: Path) -> None:
path = tmp_path / "memory.sqlite3"
first = MemoryStore(path)
fact = first.add(" Машина пользователя — Škoda Octavia ", SOURCE_USER)
first.close()
second = MemoryStore(path)
assert [(f.id, f.text, f.source) for f in second.facts()] == [
(fact.id, "Машина пользователя — Škoda Octavia", SOURCE_USER)
]
second.close()
def test_duplicates_update_and_delete(store: MemoryStore) -> None:
fact = store.add("Зовут Лео", SOURCE_AUTO)
assert store.add("зовут лео", SOURCE_USER).id == fact.id
assert store.update(fact.id, "Зовут Леонид").text == "Зовут Леонид"
assert store.update(999, "нет такого") is None
assert store.delete(fact.id).text == "Зовут Леонид"
assert store.delete(fact.id) is None
assert store.facts() == []
def test_memory_tools(store: MemoryStore) -> None:
tools = ToolRegistry(memory_tools(store))
saved = tools.execute("remember", '{"text": "Любит кофе", "requested_by_user": false}')
assert saved.ok and saved.display == "Запомнил сам: Любит кофе"
fact_id = store.facts()[0].id
assert store.facts()[0].is_auto
assert tools.execute("update_memory", f'{{"id": {fact_id}, "text": "Любит чай"}}').ok
assert store.facts()[0].text == "Любит чай"
missing = tools.execute("forget", '{"id": 12345}')
assert not missing.ok and "12345" in missing.content
assert tools.execute("forget", f'{{"id": "{fact_id}"}}').ok
assert store.facts() == []
assert not tools.execute("remember", '{"text": " "}').ok
def test_prompt_lists_facts_and_respects_auto_save(store: MemoryStore) -> None:
fact = store.add("Живёт в Казани", SOURCE_USER)
with_auto = memory_prompt(store.facts(), auto_save=True)
without_auto = memory_prompt(store.facts(), auto_save=False)
assert f"[{fact.id}] Живёт в Казани" in with_auto
assert "requested_by_user=false" in with_auto
assert "requested_by_user=false" not in without_auto
assert "пока пусто" in memory_prompt([], auto_save=True)
+64
View File
@@ -0,0 +1,64 @@
from typing import Any
from agr_assistent.llm.tools import Tool, ToolError, ToolRegistry, ToolResult, validate_arguments
_SCHEMA = {
"type": "object",
"properties": {
"level": {"type": "integer"},
"ratio": {"type": "number"},
"loud": {"type": "boolean"},
"mode": {"type": "string", "enum": ["on", "off"]},
},
"required": ["level"],
}
def test_arguments_are_converted_from_strings() -> None:
arguments, errors = validate_arguments(
_SCHEMA, {"level": "30", "ratio": "0,5", "loud": "true", "mode": "on", "extra": 1}
)
assert errors == []
assert arguments == {"level": 30, "ratio": 0.5, "loud": True, "mode": "on"}
def test_invalid_arguments_are_reported() -> None:
_arguments, errors = validate_arguments(_SCHEMA, {"ratio": "много", "loud": 1, "mode": "auto"})
assert "не указан параметр level" in errors
assert any(error.startswith("ratio:") for error in errors)
assert any(error.startswith("loud:") for error in errors)
assert any("недопустимое значение 'auto'" in error for error in errors)
def _registry() -> ToolRegistry:
def handler(arguments: dict[str, Any]) -> ToolResult:
if arguments["level"] > 100:
raise ToolError("слишком громко")
if arguments["level"] < 0:
raise RuntimeError("сломалось")
return ToolResult(True, f"ok {arguments['level']}", "готово")
return ToolRegistry([Tool("volume", "Громкость", _SCHEMA, handler)])
def test_registry_executes_tool_and_exposes_schema() -> None:
registry = _registry()
assert registry.schemas()[0]["function"]["name"] == "volume"
assert registry.execute("volume", '{"level": 30}') == ToolResult(True, "ok 30", "готово")
def test_registry_turns_problems_into_failed_results() -> None:
registry = _registry()
assert not registry.execute("missing", "{}").ok
assert not registry.execute("volume", "{not json").ok
assert not registry.execute("volume", "[1]").ok
assert not registry.execute("volume", "{}").ok
assert registry.execute("volume", '{"level": 101}') == ToolResult(
False, "слишком громко", "слишком громко"
)
failed = registry.execute("volume", '{"level": -1}')
assert not failed.ok and "сломалось" in failed.content