Этап 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-ассистент, живущий в системном трее.
- LLM: локальные модели (Ollama, LM Studio, llama.cpp) или OpenRouter — через единый OpenAI-совместимый API
- Текстовый чат со стримингом ответа
- Каждый запрос отдельный — без бесконечной истории диалога; короткие уточнения («а завтра?»)
в течение пары минут после ответа видят предыдущие вопросы
- Журнал запросов и ответов со стримингом
- Озвучка ответов голосом Silero: фразы проговариваются по мере генерации, блоки кода пропускаются
- Голосовой ввод по глобальной горячей клавише: faster-whisper на видеокарте, конец фразы по паузе (Silero VAD)
- Слово активации («ассистент») через Vosk — без нажатия клавиш
+2 -2
View File
@@ -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"]
+19 -30
View File
@@ -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)
+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
# Сколько последних сообщений диалога отправлять модели (0 — все)
max_history_messages: 20
# Каждый запрос отдельный, но в течение этого времени после ответа можно уточнять
# («а завтра?») — модель увидит предыдущие вопросы. 0 — без уточнений
follow_up_seconds: 120
timeout_seconds: 120
tts:
+31 -14
View File
@@ -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 "")
+11 -6
View File
@@ -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(),
-1
View File
@@ -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)
+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]]:
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() == []