Этап 6: отдельные запросы вместо диалога
- Каждый запрос уходит в модель отдельно: системный промпт, текущие дата и время, запрос - Окно уточнений: цепочка обменов с паузами меньше follow_up_seconds (по умолчанию 120 с), не больше трёх последних - Окно чата стало журналом с временем и пометкой уточнений, «Очистить журнал» вместо «Новый диалог» - Настройка окна уточнений в окне настроек - Тесты контекста уточнений и сборки запроса Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
20a666e0d4
commit
542389c0f9
@@ -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 — без нажатия клавиш
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 "…")
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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() == []
|
||||||
|
|||||||
Reference in New Issue
Block a user