Этап 6: отдельные запросы вместо диалога

- Каждый запрос уходит в модель отдельно: системный промпт, текущие дата и время, запрос
- Окно уточнений: цепочка обменов с паузами меньше follow_up_seconds (по умолчанию 120 с),
  не больше трёх последних
- Окно чата стало журналом с временем и пометкой уточнений, «Очистить журнал» вместо «Новый диалог»
- Настройка окна уточнений в окне настроек
- Тесты контекста уточнений и сборки запроса

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 04:19:33 +03:00
co-authored by Claude Opus 5
parent 20a666e0d4
commit 542389c0f9
9 changed files with 218 additions and 66 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
Минимальный AI-ассистент, живущий в системном трее. Минимальный AI-ассистент, живущий в системном трее.
- LLM: локальные модели (Ollama, LM Studio, llama.cpp) или OpenRouter — через единый OpenAI-совместимый API - LLM: локальные модели (Ollama, LM Studio, llama.cpp) или OpenRouter — через единый OpenAI-совместимый API
- Текстовый чат со стримингом ответа - Каждый запрос отдельный — без бесконечной истории диалога; короткие уточнения («а завтра?»)
в течение пары минут после ответа видят предыдущие вопросы
- Журнал запросов и ответов со стримингом
- Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются - Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются
- Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD) - Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD)
- Слово активации («ассистент») через Vosk — без нажатия клавиш - Слово активации («ассистент») через Vosk — без нажатия клавиш
+2 -2
View File
@@ -41,7 +41,7 @@ class LLMConfig:
providers: dict[str, ProviderConfig] providers: dict[str, ProviderConfig]
system_prompt: str system_prompt: str
temperature: float temperature: float
max_history_messages: int follow_up_seconds: float
timeout_seconds: float timeout_seconds: float
@property @property
@@ -216,7 +216,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
providers=providers, providers=providers,
system_prompt=str(llm_data.get("system_prompt") or ""), system_prompt=str(llm_data.get("system_prompt") or ""),
temperature=float(llm_data["temperature"]), 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"]), timeout_seconds=float(llm_data["timeout_seconds"]),
) )
tts_data = data["tts"] tts_data = data["tts"]
+19 -30
View File
@@ -1,14 +1,16 @@
"""Оркестратор диалога: история сообщений, фоновая генерация ответа, озвучка, состояния.""" """Оркестратор запросов: фоновая генерация ответа, озвучка, голосовой ввод, состояния."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading import threading
from datetime import datetime
from enum import Enum from enum import Enum
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.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
@@ -16,8 +18,6 @@ from agr_assistent.llm.client import LLMClient, LLMError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
Message = dict[str, str]
class AssistantState(Enum): class AssistantState(Enum):
IDLE = "idle" IDLE = "idle"
@@ -27,19 +27,6 @@ class AssistantState(Enum):
SPEAKING = "speaking" 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): class Assistant(QObject):
"""Живёт в главном потоке; запросы к модели выполняются в фоновых потоках.""" """Живёт в главном потоке; запросы к модели выполняются в фоновых потоках."""
@@ -47,12 +34,12 @@ class Assistant(QObject):
provider_changed = Signal(str) provider_changed = Signal(str)
speech_enabled_changed = Signal(bool) speech_enabled_changed = Signal(bool)
wake_word_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_started = Signal()
reply_chunk = Signal(str) reply_chunk = Signal(str)
reply_finished = Signal(str) # полный (возможно, прерванный) текст ответа reply_finished = Signal(str) # полный (возможно, прерванный) текст ответа
error_occurred = Signal(str) error_occurred = Signal(str)
history_cleared = Signal() journal_cleared = Signal()
# Мост из фонового потока в главный; int — номер генерации # Мост из фонового потока в главный; int — номер генерации
_worker_chunk = Signal(int, str) _worker_chunk = Signal(int, str)
@@ -73,11 +60,12 @@ class Assistant(QObject):
self._voice = voice self._voice = voice
self._wake_word = wake_word self._wake_word = wake_word
self._client: LLMClient | None = None self._client: LLMClient | None = None
self._history: list[Message] = [] self._context = FollowUpContext(config.follow_up_seconds)
self._state = AssistantState.IDLE self._state = AssistantState.IDLE
self._generating = False self._generating = False
# Увеличивается при каждом запросе и отмене: ответы устаревших потоков игнорируются # Увеличивается при каждом запросе и отмене: ответы устаревших потоков игнорируются
self._generation = 0 self._generation = 0
self._request = ""
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)
@@ -171,6 +159,7 @@ class Assistant(QObject):
"""Применяет новые настройки модели; текущий ответ прерывается, только если они изменились.""" """Применяет новые настройки модели; текущий ответ прерывается, только если они изменились."""
changed = config != self._config changed = config != self._config
self._config = config self._config = config
self._context.window_seconds = config.follow_up_seconds
if changed: if changed:
self.cancel() self.cancel()
self._client = None self._client = None
@@ -184,13 +173,13 @@ class Assistant(QObject):
self.speech_enabled_changed.emit(enabled) self.speech_enabled_changed.emit(enabled)
def send(self, text: str) -> None: def send(self, text: str) -> None:
"""Новое сообщение прерывает озвучку предыдущего ответа.""" """Новый запрос прерывает озвучку предыдущего ответа."""
text = text.strip() text = text.strip()
if not text or self._generating: if not text or self._generating:
return return
self._speaker.stop() self._speaker.stop()
self._history.append({"role": "user", "content": text}) context = self._context.recent()
self.user_message_added.emit(text) self.request_added.emit(text, bool(context))
try: try:
client = self._get_client() client = self._get_client()
@@ -200,10 +189,9 @@ class Assistant(QObject):
self._generation += 1 self._generation += 1
self._generating = True self._generating = True
self._request = text
self._reply_parts = [] self._reply_parts = []
messages = build_messages( messages = build_messages(self._config.system_prompt, context, text, datetime.now())
self._config.system_prompt, self._history, self._config.max_history_messages
)
self._speaker.begin() self._speaker.begin()
self._update_state() self._update_state()
self.reply_started.emit() self.reply_started.emit()
@@ -223,10 +211,11 @@ class Assistant(QObject):
self._finish_reply() self._finish_reply()
self._speaker.stop() self._speaker.stop()
def clear_history(self) -> None: def clear_journal(self) -> None:
"""Очищает журнал и забывает контекст уточнений."""
self.cancel() self.cancel()
self._history.clear() self._context.clear()
self.history_cleared.emit() self.journal_cleared.emit()
def _get_client(self) -> LLMClient: def _get_client(self) -> LLMClient:
if self._client is None: if self._client is None:
@@ -292,8 +281,8 @@ class Assistant(QObject):
reply = "".join(self._reply_parts) reply = "".join(self._reply_parts)
self._reply_parts = [] self._reply_parts = []
self._generating = False self._generating = False
if reply.strip(): # Даже прерванный ответ пригодится для уточнения «нет, я имел в виду…»
self._history.append({"role": "assistant", "content": reply}) self._context.remember(self._request, reply)
self._update_state() self._update_state()
self.reply_finished.emit(reply) self.reply_finished.emit(reply)
+82
View File
@@ -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}"
+3 -2
View File
@@ -26,8 +26,9 @@ llm:
Ты — полезный голосовой ассистент. Отвечай кратко и по делу, Ты — полезный голосовой ассистент. Отвечай кратко и по делу,
на языке пользователя. на языке пользователя.
temperature: 0.7 temperature: 0.7
# Сколько последних сообщений диалога отправлять модели (0 — все) # Каждый запрос отдельный, но в течение этого времени после ответа можно уточнять
max_history_messages: 20 # («а завтра?») — модель увидит предыдущие вопросы. 0 — без уточнений
follow_up_seconds: 120
timeout_seconds: 120 timeout_seconds: 120
tts: tts:
+31 -14
View File
@@ -1,8 +1,9 @@
"""Окно чата: история диалога и поле ввода.""" """Окно ассистента: журнал запросов и ответов и поле ввода."""
from __future__ import annotations 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.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import ( from PySide6.QtGui import (
@@ -39,14 +40,20 @@ _VOICE_BUTTON_TEXTS = {
AssistantState.RECOGNIZING: "Распознаю…", AssistantState.RECOGNIZING: "Распознаю…",
} }
# Во время стриминга перерисовываем историю не чаще, чем раз в N мс # Во время стриминга перерисовываем журнал не чаще, чем раз в N мс
_RENDER_INTERVAL_MS = 50 _RENDER_INTERVAL_MS = 50
# Журнал перерисовывается целиком, поэтому храним только последние записи
_MAX_ENTRIES = 100
_META_COLOR = "#9e9e9e"
@dataclass @dataclass
class _Entry: class _Entry:
role: str role: str
text: str text: str
follow_up: bool = False
time: datetime = field(default_factory=datetime.now)
class _MessageInput(QPlainTextEdit): class _MessageInput(QPlainTextEdit):
@@ -75,15 +82,15 @@ class ChatWindow(QWidget):
self.resize(560, 680) self.resize(560, 680)
self._provider_label = QLabel() self._provider_label = QLabel()
new_chat_button = QPushButton("Новый диалог") clear_button = QPushButton("Очистить журнал")
new_chat_button.clicked.connect(assistant.clear_history) clear_button.clicked.connect(assistant.clear_journal)
self._transcript = QTextBrowser() self._transcript = QTextBrowser()
self._transcript.setOpenExternalLinks(True) self._transcript.setOpenExternalLinks(True)
self._input = _MessageInput() self._input = _MessageInput()
self._input.setPlaceholderText( self._input.setPlaceholderText(
"Напишите сообщение… (Enter — отправить, Shift+Enter — новая строка)" "Запрос или команда… (Enter — отправить, Shift+Enter — новая строка)"
) )
self._input.setMaximumHeight(90) self._input.setMaximumHeight(90)
self._input.submitted.connect(self._submit) self._input.submitted.connect(self._submit)
@@ -102,7 +109,7 @@ class ChatWindow(QWidget):
header = QHBoxLayout() header = QHBoxLayout()
header.addWidget(self._provider_label, 1) header.addWidget(self._provider_label, 1)
header.addWidget(new_chat_button) header.addWidget(clear_button)
header.addWidget(settings_button) header.addWidget(settings_button)
buttons = QVBoxLayout() buttons = QVBoxLayout()
@@ -126,12 +133,14 @@ class ChatWindow(QWidget):
assistant.state_changed.connect(self._on_state_changed) assistant.state_changed.connect(self._on_state_changed)
assistant.provider_changed.connect(self._update_provider_label) assistant.provider_changed.connect(self._update_provider_label)
assistant.user_message_added.connect(lambda text: self._append("user", text)) assistant.request_added.connect(
assistant.reply_started.connect(lambda: self._append("assistant", "")) 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_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("error", message)) assistant.error_occurred.connect(lambda message: self._append(_Entry("error", message)))
assistant.history_cleared.connect(self._on_history_cleared) assistant.journal_cleared.connect(self._on_journal_cleared)
self._update_provider_label() self._update_provider_label()
self._on_state_changed(assistant.state) self._on_state_changed(assistant.state)
@@ -190,12 +199,13 @@ class ChatWindow(QWidget):
self._entries.pop() self._entries.pop()
self._render_now() self._render_now()
def _on_history_cleared(self) -> None: def _on_journal_cleared(self) -> None:
self._entries.clear() self._entries.clear()
self._render_now() self._render_now()
def _append(self, role: str, text: str) -> None: def _append(self, entry: _Entry) -> None:
self._entries.append(_Entry(role, text)) self._entries.append(entry)
del self._entries[:-_MAX_ENTRIES]
self._render_now() self._render_now()
def _render_now(self) -> None: def _render_now(self) -> None:
@@ -225,6 +235,13 @@ class ChatWindow(QWidget):
title_format.setForeground(QColor(color)) title_format.setForeground(QColor(color))
cursor.insertText(title, title_format) 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()) cursor.insertBlock(QTextBlockFormat(), QTextCharFormat())
if entry.role == "assistant": if entry.role == "assistant":
cursor.insertMarkdown(entry.text or "") cursor.insertMarkdown(entry.text or "")
+11 -6
View File
@@ -126,10 +126,15 @@ class SettingsDialog(QDialog):
self._temperature.setRange(0, 2) self._temperature.setRange(0, 2)
self._temperature.setSingleStep(0.1) self._temperature.setSingleStep(0.1)
self._temperature.setValue(float(get_value(self._raw, "llm.temperature"))) self._temperature.setValue(float(get_value(self._raw, "llm.temperature")))
self._max_history = QSpinBox() self._follow_up = QSpinBox()
self._max_history.setRange(0, 1000) self._follow_up.setRange(0, 3600)
self._max_history.setSpecialValueText("вся история") self._follow_up.setSuffix(" с")
self._max_history.setValue(int(get_value(self._raw, "llm.max_history_messages"))) 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.currentTextChanged.connect(self._switch_provider)
self._provider.setCurrentText(str(get_value(self._raw, "llm.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._models_status)
form.addRow("Системный промпт", self._system_prompt) form.addRow("Системный промпт", self._system_prompt)
form.addRow("Температура", self._temperature) form.addRow("Температура", self._temperature)
form.addRow("Сообщений истории", self._max_history) form.addRow("Окно уточнений", self._follow_up)
return _page(form) return _page(form)
def _build_speech_tab(self) -> QWidget: def _build_speech_tab(self) -> QWidget:
@@ -272,7 +277,7 @@ class SettingsDialog(QDialog):
"llm.provider": self._provider.currentText(), "llm.provider": self._provider.currentText(),
"llm.system_prompt": self._system_prompt.toPlainText().strip(), "llm.system_prompt": self._system_prompt.toPlainText().strip(),
"llm.temperature": round(self._temperature.value(), 2), "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.enabled": self._tts_enabled.isChecked(),
"tts.speaker": self._tts_speaker.currentText(), "tts.speaker": self._tts_speaker.currentText(),
"voice.enabled": self._voice_enabled.isChecked(), "voice.enabled": self._voice_enabled.isChecked(),
-1
View File
@@ -65,7 +65,6 @@ class TrayIcon(QSystemTrayIcon):
self._speech_action.triggered.connect(self._on_speech_triggered) self._speech_action.triggered.connect(self._on_speech_triggered)
menu.addAction(self._speech_action) menu.addAction(self._speech_action)
menu.addAction("Новый диалог", assistant.clear_history)
menu.addAction("Настройки…", open_settings) menu.addAction("Настройки…", open_settings)
menu.addSeparator() menu.addSeparator()
menu.addAction("Выход", QApplication.quit) menu.addAction("Выход", QApplication.quit)
+67 -10
View File
@@ -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]]: class FakeClock:
return [{"role": "user", "content": str(i)} for i in range(count)] 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: def test_build_messages_adds_date_context_and_request() -> None:
messages = build_messages(" be nice ", _history(5), max_history_messages=2) context = [{"role": "user", "content": "погода?"}, {"role": "assistant", "content": "солнце"}]
messages = build_messages(" будь краток ", context, "а завтра?", datetime(2026, 9, 17, 4, 5))
assert messages == [ assert messages == [
{"role": "system", "content": "be nice"}, {"role": "system", "content": "будь краток\n\nСейчас четверг, 17 сентября 2026 года, 04:05."},
{"role": "user", "content": "3"}, *context,
{"role": "user", "content": "4"}, {"role": "user", "content": "а завтра?"},
] ]
def test_build_messages_without_limit_or_prompt() -> None: def test_format_datetime() -> None:
assert build_messages("", _history(3), max_history_messages=0) == _history(3) 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() == []