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