diff --git a/README.md b/README.md index 10e5963..adae95d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ Минимальный AI-ассистент, живущий в системном трее. - LLM: локальные модели (Ollama, LM Studio, llama.cpp) или OpenRouter — через единый OpenAI-совместимый API -- Текстовый чат со стримингом ответа +- Каждый запрос отдельный — без бесконечной истории диалога; короткие уточнения («а завтра?») + в течение пары минут после ответа видят предыдущие вопросы +- Журнал запросов и ответов со стримингом - Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются - Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD) - Слово активации («ассистент») через Vosk — без нажатия клавиш diff --git a/src/agr_assistent/config.py b/src/agr_assistent/config.py index e14c817..9eae781 100644 --- a/src/agr_assistent/config.py +++ b/src/agr_assistent/config.py @@ -41,7 +41,7 @@ class LLMConfig: providers: dict[str, ProviderConfig] system_prompt: str temperature: float - max_history_messages: int + follow_up_seconds: float timeout_seconds: float @property @@ -216,7 +216,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig: providers=providers, system_prompt=str(llm_data.get("system_prompt") or ""), temperature=float(llm_data["temperature"]), - max_history_messages=int(llm_data["max_history_messages"]), + follow_up_seconds=float(llm_data["follow_up_seconds"]), timeout_seconds=float(llm_data["timeout_seconds"]), ) tts_data = data["tts"] diff --git a/src/agr_assistent/core/assistant.py b/src/agr_assistent/core/assistant.py index 78c0417..6f9c368 100644 --- a/src/agr_assistent/core/assistant.py +++ b/src/agr_assistent/core/assistant.py @@ -1,14 +1,16 @@ -"""Оркестратор диалога: история сообщений, фоновая генерация ответа, озвучка, состояния.""" +"""Оркестратор запросов: фоновая генерация ответа, озвучка, голосовой ввод, состояния.""" from __future__ import annotations import logging import threading +from datetime import datetime from enum import Enum from PySide6.QtCore import QObject, Signal, Slot from agr_assistent.config import LLMConfig +from agr_assistent.core.context import FollowUpContext, Message, build_messages from agr_assistent.core.speech import Speaker from agr_assistent.core.voice import VoiceInput from agr_assistent.core.wake import WakeWordListener @@ -16,8 +18,6 @@ from agr_assistent.llm.client import LLMClient, LLMError log = logging.getLogger(__name__) -Message = dict[str, str] - class AssistantState(Enum): IDLE = "idle" @@ -27,19 +27,6 @@ class AssistantState(Enum): SPEAKING = "speaking" -def build_messages( - system_prompt: str, history: list[Message], max_history_messages: int -) -> list[Message]: - """Системный промпт + хвост истории (0 и меньше — вся история).""" - messages: list[Message] = [] - if system_prompt.strip(): - messages.append({"role": "system", "content": system_prompt.strip()}) - if max_history_messages > 0: - history = history[-max_history_messages:] - messages.extend(history) - return messages - - class Assistant(QObject): """Живёт в главном потоке; запросы к модели выполняются в фоновых потоках.""" @@ -47,12 +34,12 @@ class Assistant(QObject): provider_changed = Signal(str) speech_enabled_changed = Signal(bool) wake_word_enabled_changed = Signal(bool) - user_message_added = Signal(str) + request_added = Signal(str, bool) # текст запроса; True — уточнение к предыдущему reply_started = Signal() reply_chunk = Signal(str) reply_finished = Signal(str) # полный (возможно, прерванный) текст ответа error_occurred = Signal(str) - history_cleared = Signal() + journal_cleared = Signal() # Мост из фонового потока в главный; int — номер генерации _worker_chunk = Signal(int, str) @@ -73,11 +60,12 @@ class Assistant(QObject): self._voice = voice self._wake_word = wake_word self._client: LLMClient | None = None - self._history: list[Message] = [] + self._context = FollowUpContext(config.follow_up_seconds) self._state = AssistantState.IDLE self._generating = False # Увеличивается при каждом запросе и отмене: ответы устаревших потоков игнорируются self._generation = 0 + self._request = "" self._reply_parts: list[str] = [] self._worker_chunk.connect(self._on_worker_chunk) @@ -171,6 +159,7 @@ class Assistant(QObject): """Применяет новые настройки модели; текущий ответ прерывается, только если они изменились.""" changed = config != self._config self._config = config + self._context.window_seconds = config.follow_up_seconds if changed: self.cancel() self._client = None @@ -184,13 +173,13 @@ class Assistant(QObject): self.speech_enabled_changed.emit(enabled) def send(self, text: str) -> None: - """Новое сообщение прерывает озвучку предыдущего ответа.""" + """Новый запрос прерывает озвучку предыдущего ответа.""" text = text.strip() if not text or self._generating: return self._speaker.stop() - self._history.append({"role": "user", "content": text}) - self.user_message_added.emit(text) + context = self._context.recent() + self.request_added.emit(text, bool(context)) try: client = self._get_client() @@ -200,10 +189,9 @@ class Assistant(QObject): self._generation += 1 self._generating = True + self._request = text self._reply_parts = [] - messages = build_messages( - self._config.system_prompt, self._history, self._config.max_history_messages - ) + messages = build_messages(self._config.system_prompt, context, text, datetime.now()) self._speaker.begin() self._update_state() self.reply_started.emit() @@ -223,10 +211,11 @@ class Assistant(QObject): self._finish_reply() self._speaker.stop() - def clear_history(self) -> None: + def clear_journal(self) -> None: + """Очищает журнал и забывает контекст уточнений.""" self.cancel() - self._history.clear() - self.history_cleared.emit() + self._context.clear() + self.journal_cleared.emit() def _get_client(self) -> LLMClient: if self._client is None: @@ -292,8 +281,8 @@ class Assistant(QObject): reply = "".join(self._reply_parts) self._reply_parts = [] self._generating = False - if reply.strip(): - self._history.append({"role": "assistant", "content": reply}) + # Даже прерванный ответ пригодится для уточнения «нет, я имел в виду…» + self._context.remember(self._request, reply) self._update_state() self.reply_finished.emit(reply) diff --git a/src/agr_assistent/core/context.py b/src/agr_assistent/core/context.py new file mode 100644 index 0000000..dd36c23 --- /dev/null +++ b/src/agr_assistent/core/context.py @@ -0,0 +1,82 @@ +"""Сборка запроса к модели: каждый запрос отдельный, но короткие уточнения видят предыдущий обмен.""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +Message = dict[str, str] + +# Сколько последних обменов цепочки уточнений отдаём модели +MAX_FOLLOW_UP_EXCHANGES = 3 + +_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] +_MONTHS = [ + "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря", +] # fmt: skip + + +@dataclass(frozen=True) +class Exchange: + request: str + reply: str + finished_at: float + + +class FollowUpContext: + """Цепочка обменов, идущих друг за другом с паузами меньше окна уточнений. + + После паузы длиннее окна следующий запрос снова начинается с чистого листа. + """ + + def __init__(self, window_seconds: float, clock: Callable[[], float] = time.monotonic) -> None: + self.window_seconds = window_seconds + self._clock = clock + self._exchanges: list[Exchange] = [] + + def recent(self) -> list[Message]: + if not self._is_alive(): + self._exchanges.clear() + return [] + messages: list[Message] = [] + for exchange in self._exchanges: + messages.append({"role": "user", "content": exchange.request}) + messages.append({"role": "assistant", "content": exchange.reply}) + return messages + + def remember(self, request: str, reply: str) -> None: + if self.window_seconds <= 0 or not reply.strip(): + return + if not self._is_alive(): + self._exchanges.clear() + self._exchanges.append(Exchange(request, reply, self._clock())) + del self._exchanges[:-MAX_FOLLOW_UP_EXCHANGES] + + def clear(self) -> None: + self._exchanges.clear() + + def _is_alive(self) -> bool: + return ( + self.window_seconds > 0 + and bool(self._exchanges) + and self._clock() - self._exchanges[-1].finished_at <= self.window_seconds + ) + + +def build_messages( + system_prompt: str, context: list[Message], request: str, now: datetime +) -> list[Message]: + system = "\n\n".join( + part for part in (system_prompt.strip(), f"Сейчас {format_datetime(now)}.") if part + ) + return [{"role": "system", "content": system}, *context, {"role": "user", "content": request}] + + +def format_datetime(moment: datetime) -> str: + """«четверг, 17 сентября 2026 года, 04:15» — без зависимости от локали системы.""" + weekday = _WEEKDAYS[moment.weekday()] + month = _MONTHS[moment.month - 1] + return f"{weekday}, {moment.day} {month} {moment.year} года, {moment:%H:%M}" diff --git a/src/agr_assistent/default_config.yaml b/src/agr_assistent/default_config.yaml index 6ba46b5..0b3e8b6 100644 --- a/src/agr_assistent/default_config.yaml +++ b/src/agr_assistent/default_config.yaml @@ -26,8 +26,9 @@ llm: Ты — полезный голосовой ассистент. Отвечай кратко и по делу, на языке пользователя. temperature: 0.7 - # Сколько последних сообщений диалога отправлять модели (0 — все) - max_history_messages: 20 + # Каждый запрос отдельный, но в течение этого времени после ответа можно уточнять + # («а завтра?») — модель увидит предыдущие вопросы. 0 — без уточнений + follow_up_seconds: 120 timeout_seconds: 120 tts: diff --git a/src/agr_assistent/ui/chat_window.py b/src/agr_assistent/ui/chat_window.py index 51514ae..4e14b8d 100644 --- a/src/agr_assistent/ui/chat_window.py +++ b/src/agr_assistent/ui/chat_window.py @@ -1,8 +1,9 @@ -"""Окно чата: история диалога и поле ввода.""" +"""Окно ассистента: журнал запросов и ответов и поле ввода.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from datetime import datetime from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import ( @@ -39,14 +40,20 @@ _VOICE_BUTTON_TEXTS = { AssistantState.RECOGNIZING: "Распознаю…", } -# Во время стриминга перерисовываем историю не чаще, чем раз в N мс +# Во время стриминга перерисовываем журнал не чаще, чем раз в N мс _RENDER_INTERVAL_MS = 50 +# Журнал перерисовывается целиком, поэтому храним только последние записи +_MAX_ENTRIES = 100 + +_META_COLOR = "#9e9e9e" @dataclass class _Entry: role: str text: str + follow_up: bool = False + time: datetime = field(default_factory=datetime.now) class _MessageInput(QPlainTextEdit): @@ -75,15 +82,15 @@ class ChatWindow(QWidget): self.resize(560, 680) self._provider_label = QLabel() - new_chat_button = QPushButton("Новый диалог") - new_chat_button.clicked.connect(assistant.clear_history) + clear_button = QPushButton("Очистить журнал") + clear_button.clicked.connect(assistant.clear_journal) self._transcript = QTextBrowser() self._transcript.setOpenExternalLinks(True) self._input = _MessageInput() self._input.setPlaceholderText( - "Напишите сообщение… (Enter — отправить, Shift+Enter — новая строка)" + "Запрос или команда… (Enter — отправить, Shift+Enter — новая строка)" ) self._input.setMaximumHeight(90) self._input.submitted.connect(self._submit) @@ -102,7 +109,7 @@ class ChatWindow(QWidget): header = QHBoxLayout() header.addWidget(self._provider_label, 1) - header.addWidget(new_chat_button) + header.addWidget(clear_button) header.addWidget(settings_button) buttons = QVBoxLayout() @@ -126,12 +133,14 @@ class ChatWindow(QWidget): assistant.state_changed.connect(self._on_state_changed) assistant.provider_changed.connect(self._update_provider_label) - assistant.user_message_added.connect(lambda text: self._append("user", text)) - assistant.reply_started.connect(lambda: self._append("assistant", "")) + assistant.request_added.connect( + lambda text, follow_up: self._append(_Entry("user", text, follow_up)) + ) + assistant.reply_started.connect(lambda: self._append(_Entry("assistant", ""))) assistant.reply_chunk.connect(self._on_reply_chunk) assistant.reply_finished.connect(self._on_reply_finished) - assistant.error_occurred.connect(lambda message: self._append("error", message)) - assistant.history_cleared.connect(self._on_history_cleared) + assistant.error_occurred.connect(lambda message: self._append(_Entry("error", message))) + assistant.journal_cleared.connect(self._on_journal_cleared) self._update_provider_label() self._on_state_changed(assistant.state) @@ -190,12 +199,13 @@ class ChatWindow(QWidget): self._entries.pop() self._render_now() - def _on_history_cleared(self) -> None: + def _on_journal_cleared(self) -> None: self._entries.clear() self._render_now() - def _append(self, role: str, text: str) -> None: - self._entries.append(_Entry(role, text)) + def _append(self, entry: _Entry) -> None: + self._entries.append(entry) + del self._entries[:-_MAX_ENTRIES] self._render_now() def _render_now(self) -> None: @@ -225,6 +235,13 @@ class ChatWindow(QWidget): title_format.setForeground(QColor(color)) cursor.insertText(title, title_format) + meta = f" {entry.time:%H:%M}" + if entry.follow_up: + meta += " · уточнение" + meta_format = QTextCharFormat() + meta_format.setForeground(QColor(_META_COLOR)) + cursor.insertText(meta, meta_format) + cursor.insertBlock(QTextBlockFormat(), QTextCharFormat()) if entry.role == "assistant": cursor.insertMarkdown(entry.text or "…") diff --git a/src/agr_assistent/ui/settings_dialog.py b/src/agr_assistent/ui/settings_dialog.py index d0f8172..a183136 100644 --- a/src/agr_assistent/ui/settings_dialog.py +++ b/src/agr_assistent/ui/settings_dialog.py @@ -126,10 +126,15 @@ class SettingsDialog(QDialog): self._temperature.setRange(0, 2) self._temperature.setSingleStep(0.1) self._temperature.setValue(float(get_value(self._raw, "llm.temperature"))) - self._max_history = QSpinBox() - self._max_history.setRange(0, 1000) - self._max_history.setSpecialValueText("вся история") - self._max_history.setValue(int(get_value(self._raw, "llm.max_history_messages"))) + self._follow_up = QSpinBox() + self._follow_up.setRange(0, 3600) + self._follow_up.setSuffix(" с") + self._follow_up.setSpecialValueText("без уточнений") + self._follow_up.setToolTip( + "Сколько времени после ответа можно задать уточнение («а завтра?»), " + "которое увидит предыдущие вопросы" + ) + self._follow_up.setValue(int(float(get_value(self._raw, "llm.follow_up_seconds")))) self._provider.currentTextChanged.connect(self._switch_provider) self._provider.setCurrentText(str(get_value(self._raw, "llm.provider"))) @@ -143,7 +148,7 @@ class SettingsDialog(QDialog): form.addRow("", self._models_status) form.addRow("Системный промпт", self._system_prompt) form.addRow("Температура", self._temperature) - form.addRow("Сообщений истории", self._max_history) + form.addRow("Окно уточнений", self._follow_up) return _page(form) def _build_speech_tab(self) -> QWidget: @@ -272,7 +277,7 @@ class SettingsDialog(QDialog): "llm.provider": self._provider.currentText(), "llm.system_prompt": self._system_prompt.toPlainText().strip(), "llm.temperature": round(self._temperature.value(), 2), - "llm.max_history_messages": self._max_history.value(), + "llm.follow_up_seconds": self._follow_up.value(), "tts.enabled": self._tts_enabled.isChecked(), "tts.speaker": self._tts_speaker.currentText(), "voice.enabled": self._voice_enabled.isChecked(), diff --git a/src/agr_assistent/ui/tray.py b/src/agr_assistent/ui/tray.py index a63562b..50053a1 100644 --- a/src/agr_assistent/ui/tray.py +++ b/src/agr_assistent/ui/tray.py @@ -65,7 +65,6 @@ class TrayIcon(QSystemTrayIcon): self._speech_action.triggered.connect(self._on_speech_triggered) menu.addAction(self._speech_action) - menu.addAction("Новый диалог", assistant.clear_history) menu.addAction("Настройки…", open_settings) menu.addSeparator() menu.addAction("Выход", QApplication.quit) diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 7085438..24da56b 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -1,19 +1,76 @@ -from agr_assistent.core.assistant import build_messages +from datetime import datetime + +from agr_assistent.core.context import ( + MAX_FOLLOW_UP_EXCHANGES, + FollowUpContext, + build_messages, + format_datetime, +) -def _history(count: int) -> list[dict[str, str]]: - return [{"role": "user", "content": str(i)} for i in range(count)] +class FakeClock: + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now -def test_build_messages_adds_system_prompt_and_trims_history() -> None: - messages = build_messages(" be nice ", _history(5), max_history_messages=2) +def test_build_messages_adds_date_context_and_request() -> None: + context = [{"role": "user", "content": "погода?"}, {"role": "assistant", "content": "солнце"}] + + messages = build_messages(" будь краток ", context, "а завтра?", datetime(2026, 9, 17, 4, 5)) assert messages == [ - {"role": "system", "content": "be nice"}, - {"role": "user", "content": "3"}, - {"role": "user", "content": "4"}, + {"role": "system", "content": "будь краток\n\nСейчас четверг, 17 сентября 2026 года, 04:05."}, + *context, + {"role": "user", "content": "а завтра?"}, ] -def test_build_messages_without_limit_or_prompt() -> None: - assert build_messages("", _history(3), max_history_messages=0) == _history(3) +def test_format_datetime() -> None: + assert format_datetime(datetime(2026, 3, 1, 23, 59)) == "воскресенье, 1 марта 2026 года, 23:59" + + +def test_follow_up_is_available_only_within_window() -> None: + clock = FakeClock() + context = FollowUpContext(window_seconds=120, clock=clock) + + context.remember("погода?", "солнце") + clock.now += 119 + assert [m["content"] for m in context.recent()] == ["погода?", "солнце"] + + clock.now += 2 + assert context.recent() == [] + + +def test_follow_up_chain_extends_window_and_keeps_last_exchanges() -> None: + clock = FakeClock() + context = FollowUpContext(window_seconds=60, clock=clock) + + for index in range(MAX_FOLLOW_UP_EXCHANGES + 2): + context.remember(f"вопрос {index}", f"ответ {index}") + clock.now += 50 # каждый следующий вопрос успевает в окно предыдущего + + requests = [m["content"] for m in context.recent() if m["role"] == "user"] + assert requests == [f"вопрос {i}" for i in range(2, MAX_FOLLOW_UP_EXCHANGES + 2)] + + +def test_chain_restarts_after_pause() -> None: + clock = FakeClock() + context = FollowUpContext(window_seconds=60, clock=clock) + + context.remember("старый", "ответ") + clock.now += 61 + context.remember("новый", "ответ") + + assert [m["content"] for m in context.recent()] == ["новый", "ответ"] + + +def test_zero_window_disables_follow_ups_and_empty_replies_are_ignored() -> None: + clock = FakeClock() + assert FollowUpContext(window_seconds=0, clock=clock).recent() == [] + + context = FollowUpContext(window_seconds=60, clock=clock) + context.remember("вопрос", " ") + assert context.recent() == []