diff --git a/src/ses_monitor/__init__.py b/src/ses_monitor/__init__.py index 8b16ff3..8daa914 100644 --- a/src/ses_monitor/__init__.py +++ b/src/ses_monitor/__init__.py @@ -7,7 +7,6 @@ Основные компоненты: - checker: быстрые liveness-проверки (доступность API, лицензия) - scenario_runner: выполнение сценариев диалогов из YAML-файлов - - archive_auditor: анализ архива диалогов на ошибки - zabbix: отправка метрик в Zabbix через zabbix_sender """ diff --git a/src/ses_monitor/archive_auditor.py b/src/ses_monitor/archive_auditor.py deleted file mode 100644 index 08a7cf6..0000000 --- a/src/ses_monitor/archive_auditor.py +++ /dev/null @@ -1,433 +0,0 @@ -""" -Анализ архива диалогов SES на наличие ошибок и качество работы моделей. - -Проверяет записи в /ses/archive/list за последние N минут: - - Количество диалогов с флагом unknown (не распознано). - - Количество диалогов с событием "not found" или "no data". - - Общее количество диалогов (чтобы заметить резкое падение). - - SEE: точность распознавания намерений/сущностей (confidence, detections). - - SMC: точность классификации (confidence, classifications). - - TTS: наличие голосовых ответов. - - QAS: наполненность вопросно-ответной системы. - - SPR: распознавание речи (Speech Recognition) — наличие текстовых расшифровок. - -Метрики агрегируются для отправки в Zabbix. -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple - -from ses_monitor.config import ModelsMonitoringConfig -from ses_monitor.session_client import SESClient, ArchiveEntry - -log = logging.getLogger("ses_monitor.archive") - -# Формат даты в archive.list (пример: "2026-06-19 10:30:00") -_ARCHIVE_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" - - -@dataclass -class ModelMetric: - """Агрегированная метрика по одной модели (SEE или SMC).""" - - name: str = "" # Имя модели (например, "fio", "phone2", "project_classifier") - total: int = 0 # Всего детекций/классификаций - low_confidence: int = 0 # Количество срабатываний ниже порога - sum_confidence: float = 0.0 # Сумма confidence для расчёта среднего - - @property - def avg_confidence(self) -> float: - """Средняя уверенность модели за период.""" - if self.total == 0: - return 0.0 - return self.sum_confidence / self.total - - -@dataclass -class ArchiveAuditResult: - """Результат анализа архива за период.""" - - period_start: str = "" - period_end: str = "" - - # === Базовые метрики === - - total_dialogs: int = 0 - unknown_count: int = 0 - not_found_count: int = 0 - no_data_count: int = 0 - closed_count: int = 0 - answered_count: int = 0 - - # === SEE (распознавание намерений/сущностей) === - - see_total: int = 0 # Всего детекций SEE - see_low_confidence: int = 0 # Детекции ниже порога - see_avg_confidence: float = 0.0 # Средняя уверенность по всем SEE - # Детализация по конкретным SEE-моделям - see_by_model: Dict[str, ModelMetric] = field(default_factory=dict) - - # === SMC (классификация) === - - smc_total: int = 0 # Всего классификаций SMC - smc_low_confidence: int = 0 # Классификации ниже порога - smc_avg_confidence: float = 0.0 # Средняя уверенность по всем SMC - # Детализация по конкретным SMC-моделям - smc_by_model: Dict[str, ModelMetric] = field(default_factory=dict) - - # === TTS (синтез речи) === - - tts_voice_present: int = 0 # Ответы с голосом - tts_voice_missing: int = 0 # Ответы без голоса - - # === QAS (вопросно-ответная система) === - - qas_total: int = 0 # Диалоги с полем qas - qas_empty: int = 0 # Пустые qas - qas_filled: int = 0 # Заполненные qas - - # === SPR (распознавание речи: аудио→текст) === - - spr_total: int = 0 # Диалоги с полем reports - spr_filled: int = 0 # Заполненные reports - spr_empty: int = 0 # Пустые reports - - # === Время шага диалога === - # Парсится из raw["request"]["datetime"] / raw["reply"]["datetime"] - - avg_step_time_ms: float = 0.0 # Среднее время обработки шага (мс) - max_step_time_ms: float = 0.0 # Максимальное время (мс) - # Внутренние аккумуляторы для расчёта среднего - _total_step_time_ms: float = 0.0 - _step_time_count: int = 0 - - # === Ошибки === - - fetch_error: str = "" - - def to_zabbix_metrics(self, host: str) -> List[Tuple[str, str]]: - """Форматирует метрики для Zabbix. - - Returns: - Список кортежей (ключ_метрики, значение). - """ - metrics: List[Tuple[str, str]] = [ - # Базовые - ("ses.archive.total_5m", str(self.total_dialogs)), - ("ses.archive.unknown_5m", str(self.unknown_count)), - ("ses.archive.not_found_5m", str(self.not_found_count)), - ("ses.archive.no_data_5m", str(self.no_data_count)), - ("ses.archive.closed_5m", str(self.closed_count)), - ("ses.archive.answered_5m", str(self.answered_count)), - # SEE - ("ses.models.see.total", str(self.see_total)), - ("ses.models.see.low_confidence", str(self.see_low_confidence)), - ("ses.models.see.avg_confidence", str(round(self.see_avg_confidence, 3))), - # SMC - ("ses.models.smc.total", str(self.smc_total)), - ("ses.models.smc.low_confidence", str(self.smc_low_confidence)), - ("ses.models.smc.avg_confidence", str(round(self.smc_avg_confidence, 3))), - # TTS - ("ses.models.tts.voice_present", str(self.tts_voice_present)), - ("ses.models.tts.voice_missing", str(self.tts_voice_missing)), - # QAS - ("ses.models.qas.total", str(self.qas_total)), - ("ses.models.qas.empty", str(self.qas_empty)), - # SPR - ("ses.models.spr.total", str(self.spr_total)), - ("ses.models.spr.filled", str(self.spr_filled)), - # Время шага - ("ses.archive.avg_step_time_5m", str(round(self.avg_step_time_ms))), - ("ses.archive.max_step_time_5m", str(round(self.max_step_time_ms))), - ] - return metrics - - -class ArchiveAuditor: - """Анализирует архив диалогов SES. - - Запускается с заданным интервалом (например, каждые 10 минут) и проверяет - записи за последний период (например, 5 минут). - - Отслеживает: - - Базовые ошибки (unknown, not_found, no_data). - - Качество SEE (entity/intent recognition): confidence, detections. - - Качество SMC (classification): confidence, classifications. - - Доступность TTS (text-to-speech): наличие голосовых ответов. - - Наполненность QAS (question-answer): пустые qas. - - Наполненность SPR (распознавание речи): непустые reports. - """ - - def __init__( - self, - lookback_minutes: int = 5, - models_config: Optional[ModelsMonitoringConfig] = None, - step_time_enabled: bool = True, - ): - self._lookback = lookback_minutes - self._models_config = models_config or ModelsMonitoringConfig() - self._step_time_enabled = step_time_enabled - - async def audit(self, client: SESClient) -> ArchiveAuditResult: - """Выполняет анализ архива за последние N минут. - - Args: - client: HTTP-клиент для SES API. - - Returns: - Результат анализа со всеми метриками. - """ - now = datetime.now(timezone.utc) - period_start = now - timedelta(minutes=self._lookback) - - start_str = period_start.strftime("%Y-%m-%d %H:%M") - end_str = now.strftime("%Y-%m-%d %H:%M") - - result = ArchiveAuditResult( - period_start=start_str, - period_end=end_str, - ) - - try: - entries = await client.get_archive( - start_date=start_str, - end_date=end_str, - ) - - result.total_dialogs = len(entries) - - for entry in entries: - # Базовые проверки - self._audit_base(result, entry) - - # Мониторинг моделей - if self._models_config.enabled: - self._audit_models(result, entry) - - # Время шага диалога - if self._step_time_enabled: - self._calc_step_time(result, entry) - - # Расчёт средних значений - if result.see_total > 0: - total_conf = sum(m.sum_confidence for m in result.see_by_model.values()) - result.see_avg_confidence = total_conf / result.see_total - - if result.smc_total > 0: - total_conf = sum(m.sum_confidence for m in result.smc_by_model.values()) - result.smc_avg_confidence = total_conf / result.smc_total - - if result._step_time_count > 0: - result.avg_step_time_ms = result._total_step_time_ms / result._step_time_count - - log.info( - "Archive audit: %d диалогов за %d мин " - "(unknown=%d, nf=%d, nd=%d, closed=%d, answered=%d) | " - "SEE: %d det (low=%d, avg=%.2f) | " - "SMC: %d cls (low=%d, avg=%.2f) | " - "TTS: voice=%d no=%d | QAS: empty=%d/%d | SPR: filled=%d/%d | " - "step_time: avg=%dms max=%dms", - result.total_dialogs, self._lookback, - result.unknown_count, result.not_found_count, - result.no_data_count, result.closed_count, - result.answered_count, - result.see_total, result.see_low_confidence, - result.see_avg_confidence, - result.smc_total, result.smc_low_confidence, - result.smc_avg_confidence, - result.tts_voice_present, result.tts_voice_missing, - result.qas_empty, result.qas_total, - result.spr_filled, result.spr_total, - round(result.avg_step_time_ms), round(result.max_step_time_ms), - ) - - except Exception as e: - result.fetch_error = str(e) - log.error("Archive audit: ошибка получения данных: %s", e) - - return result - - # ------------------------------------------------------------------ - # Базовые проверки (без изменений) - # ------------------------------------------------------------------ - - def _audit_base(self, result: ArchiveAuditResult, entry: ArchiveEntry) -> None: - """Базовые проверки: unknown, not_found, no_data.""" - if entry.unknown: - result.unknown_count += 1 - - for event in entry.events: - if event == "not found": - result.not_found_count += 1 - elif event == "no data": - result.no_data_count += 1 - - if entry.closed and entry.closed is not False: - result.closed_count += 1 - - if entry.answered: - result.answered_count += 1 - - # ------------------------------------------------------------------ - # Мониторинг внутренних моделей SES - # ------------------------------------------------------------------ - - def _audit_models(self, result: ArchiveAuditResult, entry: ArchiveEntry) -> None: - """Анализирует модели SEE, SMC, TTS, QAS, SPR в одной записи архива.""" - raw = entry.raw - - # --- SEE и SMC: разбор request.data --- - request_data = raw.get("request", {}).get("data", []) - if isinstance(request_data, list): - for item in request_data: - item_type = item.get("type", "") - - if item_type == "see": - result.see_total += 1 - self._track_see(item, result) - - elif item_type == "smc": - result.smc_total += 1 - self._track_smc(item, result) - - # --- TTS: проверка голосовых ответов --- - if self._models_config.tts_enabled: - answers = raw.get("reply", {}).get("answers", []) - if isinstance(answers, list): - for ans in answers: - voice = ans.get("voice", "") - if voice: - result.tts_voice_present += 1 - else: - result.tts_voice_missing += 1 - - # --- QAS: проверка наполненности --- - if self._models_config.qas_enabled: - qas = raw.get("current", {}).get("qas", "") - result.qas_total += 1 - if not qas or qas == "": - result.qas_empty += 1 - else: - result.qas_filled += 1 - - # --- SPR: проверка reports --- - if self._models_config.spr_enabled: - reports = raw.get("reports", {}) - result.spr_total += 1 - if isinstance(reports, dict) and len(reports) > 0: - result.spr_filled += 1 - else: - result.spr_empty += 1 - - def _track_see(self, item: Dict[str, Any], result: ArchiveAuditResult) -> None: - """Учитывает одну SEE-детекцию в агрегированных метриках. - - SEE (Semantic Entity Extraction) — извлечение сущностей и намерений. - Каждая детекция имеет: model (имя модели), reply.confidence (уверенность). - """ - model_name = item.get("model", "unknown") - confidence = self._extract_confidence(item) - - # Глобальная агрегация - if confidence is not None and confidence < self._models_config.see_confidence_threshold: - result.see_low_confidence += 1 - - # Поимённая агрегация - metric = result.see_by_model.get(model_name) - if metric is None: - metric = ModelMetric(name=model_name) - result.see_by_model[model_name] = metric - - metric.total += 1 - if confidence is not None: - metric.sum_confidence += confidence - if confidence < self._models_config.see_confidence_threshold: - metric.low_confidence += 1 - - def _track_smc(self, item: Dict[str, Any], result: ArchiveAuditResult) -> None: - """Учитывает одну SMC-классификацию в агрегированных метриках. - - SMC (Semantic Meaning Classification) — классификация запроса. - Каждая классификация имеет: model (имя классификатора), reply.confidence. - """ - model_name = item.get("model", "unknown") - confidence = self._extract_confidence(item) - - if confidence is not None and confidence < self._models_config.smc_confidence_threshold: - result.smc_low_confidence += 1 - - metric = result.smc_by_model.get(model_name) - if metric is None: - metric = ModelMetric(name=model_name) - result.smc_by_model[model_name] = metric - - metric.total += 1 - if confidence is not None: - metric.sum_confidence += confidence - if confidence < self._models_config.smc_confidence_threshold: - metric.low_confidence += 1 - - @staticmethod - def _extract_confidence(item: Dict[str, Any]) -> Optional[float]: - """Извлекает confidence из reply-блока элемента запроса. - - Возможные структуры: - reply.confidence (число) - reply.position / reply.length (для NER, confidence обычно есть) - """ - reply = item.get("reply", {}) - if isinstance(reply, dict): - conf = reply.get("confidence") - if conf is not None: - try: - return float(conf) - except (TypeError, ValueError): - return None - return None - - def _calc_step_time(self, result: ArchiveAuditResult, entry: ArchiveEntry) -> None: - """Парсит request.datetime / reply.datetime из записи архива и считает разницу. - - Обновляет: - - result._total_step_time_ms — суммарное время - - result._step_time_count — количество учтённых записей - - result.max_step_time_ms — максимальное время - """ - raw = entry.raw - req_dt_str = raw.get("request", {}).get("datetime") - rep_dt_str = raw.get("reply", {}).get("datetime") - - if not req_dt_str or not rep_dt_str: - return - - try: - req_dt = datetime.strptime(str(req_dt_str), _ARCHIVE_DATETIME_FORMAT) - rep_dt = datetime.strptime(str(rep_dt_str), _ARCHIVE_DATETIME_FORMAT) - except (ValueError, TypeError): - return - - # Пропускаем если время ответа меньше времени запроса (рассинхрон часов) - if rep_dt < req_dt: - return - - ms = (rep_dt - req_dt).total_seconds() * 1000 - - result._total_step_time_ms += ms - result._step_time_count += 1 - if ms > result.max_step_time_ms: - result.max_step_time_ms = ms - - # Лог предупреждения для диалогов дольше 5 секунд - if ms > 5000: - robot_label = entry.robot[-24:] if len(entry.robot) > 24 else entry.robot - log.warning( - "Медленный диалог: session=%s time=%dms request=\"%s\" robot=%s", - entry.session, - round(ms), - entry.request_message[:80], - robot_label, - ) diff --git a/tests/test_scenario_runner.py b/tests/test_scenario_runner.py index fcc548f..e4f0de9 100644 --- a/tests/test_scenario_runner.py +++ b/tests/test_scenario_runner.py @@ -20,8 +20,6 @@ import pytest import yaml from ses_monitor.config import ( - AppConfig, - ModelsMonitoringConfig, ScenarioConfig, ScenarioStep, load_scenarios, @@ -562,419 +560,6 @@ class TestAggregateScenarioResults: assert agg["max_time"] == 11 # round(10.6) = 11 -# ============================================================================= -# Тесты: archive_auditor.py -# ============================================================================= - -class TestArchiveAuditor: - """Тесты анализа архива диалогов.""" - - @pytest.mark.asyncio - async def test_audit_normal_dialogs(self): - """Проверяет анализ нормальных диалогов без ошибок.""" - from ses_monitor.archive_auditor import ArchiveAuditor - - auditor = ArchiveAuditor(lookback_minutes=5) - mock_client = AsyncMock() - # Возвращаем пустой список — нет данных - mock_client.get_archive.return_value = [] - - result = await auditor.audit(mock_client) - assert result.total_dialogs == 0 - assert result.unknown_count == 0 - assert result.not_found_count == 0 - - @pytest.mark.asyncio - async def test_audit_with_errors(self): - """Проверяет анализ диалогов с ошибками.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - auditor = ArchiveAuditor(lookback_minutes=5) - - # Создаём тестовые записи архива - entries = [ - ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=True, closed=None, - endpoint=None, answered=False, conversion=False, - models=[], events=["not found"], - request_message="тест", last_reply="ответ", - raw={}, - ), - ArchiveEntry( - id="2", session="s2", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed="2026-01-01", - endpoint=None, answered=True, conversion=True, - models=[], events=["no data"], - request_message="тест2", last_reply="ответ2", - raw={}, - ), - ArchiveEntry( - id="3", session="s3", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], - request_message="тест3", last_reply="ответ3", - raw={}, - ), - ] - - mock_client = AsyncMock() - mock_client.get_archive.return_value = entries - - result = await auditor.audit(mock_client) - assert result.total_dialogs == 3 - assert result.unknown_count == 1 - assert result.not_found_count == 1 - assert result.no_data_count == 1 - assert result.closed_count == 1 - assert result.answered_count == 2 - - @pytest.mark.asyncio - async def test_audit_see_model_confidence(self): - """Проверяет мониторинг SEE: уверенность распознавания.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - config = ModelsMonitoringConfig( - enabled=True, - see_confidence_threshold=0.5, - ) - auditor = ArchiveAuditor(lookback_minutes=5, models_config=config) - - # Запись с высокой уверенностью SEE - entry_high = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=["see:fio"], - events=[], request_message="тест", last_reply="ответ", - raw={ - "request": { - "data": [ - { - "type": "see", - "model": "fio", - "reply": {"confidence": 0.95}, - } - ] - } - }, - ) - - # Запись с низкой уверенностью SEE - entry_low = ArchiveEntry( - id="2", session="s2", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=["see:fio"], - events=[], request_message="тест2", last_reply="ответ2", - raw={ - "request": { - "data": [ - { - "type": "see", - "model": "fio", - "reply": {"confidence": 0.3}, - } - ] - } - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry_high, entry_low] - - result = await auditor.audit(mock_client) - assert result.see_total == 2 - assert result.see_low_confidence == 1 # Только вторая запись - assert result.see_avg_confidence == pytest.approx(0.625, abs=0.01) - - @pytest.mark.asyncio - async def test_audit_smc_model_confidence(self): - """Проверяет мониторинг SMC: уверенность классификации.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - config = ModelsMonitoringConfig( - enabled=True, - smc_confidence_threshold=0.7, - ) - auditor = ArchiveAuditor(lookback_minutes=5, models_config=config) - - entry = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=["smc:test_classifier"], - events=[], request_message="тест", last_reply="ответ", - raw={ - "request": { - "data": [ - { - "type": "smc", - "model": "test_classifier", - "reply": {"confidence": 0.65, "class": "test"}, - } - ] - } - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry] - - result = await auditor.audit(mock_client) - assert result.smc_total == 1 - assert result.smc_low_confidence == 1 # 0.65 < 0.7 - assert result.smc_avg_confidence == 0.65 - - @pytest.mark.asyncio - async def test_audit_tts_voice_detection(self): - """Проверяет мониторинг TTS: наличие голосовых ответов.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - config = ModelsMonitoringConfig(enabled=True, tts_enabled=True) - auditor = ArchiveAuditor(lookback_minutes=5, models_config=config) - - entry = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест", last_reply="ответ", - raw={ - "reply": { - "answers": [ - {"messages": ["здравствуйте"], "voice": "Коля"}, - {"messages": ["до свидания"], "voice": ""}, - ] - } - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry] - - result = await auditor.audit(mock_client) - assert result.tts_voice_present == 1 - assert result.tts_voice_missing == 1 - - @pytest.mark.asyncio - async def test_audit_qas_emptiness(self): - """Проверяет мониторинг QAS: пустые ответы.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - config = ModelsMonitoringConfig(enabled=True, qas_enabled=True) - auditor = ArchiveAuditor(lookback_minutes=5, models_config=config) - - entry_empty = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест", last_reply="ответ", - raw={"current": {"qas": ""}}, - ) - entry_filled = ArchiveEntry( - id="2", session="s2", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест2", last_reply="ответ2", - raw={"current": {"qas": "какой-то ответ QAS"}}, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry_empty, entry_filled] - - result = await auditor.audit(mock_client) - assert result.qas_total == 2 - assert result.qas_empty == 1 - assert result.qas_filled == 1 - - @pytest.mark.asyncio - async def test_audit_spr_reports(self): - """Проверяет мониторинг SPR: наполненность reports.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - config = ModelsMonitoringConfig(enabled=True, spr_enabled=True) - auditor = ArchiveAuditor(lookback_minutes=5, models_config=config) - - entry_filled = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест", last_reply="ответ", - raw={"reports": {"recognition": "текст расшифровки"}}, - ) - entry_empty = ArchiveEntry( - id="2", session="s2", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест2", last_reply="ответ2", - raw={"reports": {}}, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry_filled, entry_empty] - - result = await auditor.audit(mock_client) - assert result.spr_total == 2 - assert result.spr_filled == 1 - assert result.spr_empty == 1 - - @pytest.mark.asyncio - async def test_audit_models_disabled(self): - """Проверяет что при отключённом model_monitoring метрики нулевые.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - config = ModelsMonitoringConfig(enabled=False) - auditor = ArchiveAuditor(lookback_minutes=5, models_config=config) - - entry = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=["see:fio"], - events=[], request_message="тест", last_reply="ответ", - raw={ - "request": { - "data": [ - {"type": "see", "model": "fio", "reply": {"confidence": 0.3}} - ] - }, - "reply": {"answers": [{"voice": "Коля"}]}, - "current": {"qas": "ответ"}, - "reports": {"rec": "текст"}, - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry] - - result = await auditor.audit(mock_client) - # Все model-метрики должны быть нулевыми - assert result.see_total == 0 - assert result.smc_total == 0 - assert result.tts_voice_present == 0 - assert result.qas_total == 0 - assert result.spr_total == 0 - - @pytest.mark.asyncio - async def test_audit_step_time_normal(self): - """Проверяет расчёт времени шага диалога из архива.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - auditor = ArchiveAuditor(lookback_minutes=5, step_time_enabled=True) - - entry1 = ArchiveEntry( - id="1", session="s-fast", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="быстрый запрос", - last_reply="быстрый ответ", - raw={ - "request": {"datetime": "2026-06-19 10:00:00"}, - "reply": {"datetime": "2026-06-19 10:00:01"}, - }, - ) - entry2 = ArchiveEntry( - id="2", session="s-slow", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="медленный запрос", - last_reply="медленный ответ", - raw={ - "request": {"datetime": "2026-06-19 10:00:00"}, - "reply": {"datetime": "2026-06-19 10:00:03"}, - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry1, entry2] - - result = await auditor.audit(mock_client) - assert result._step_time_count == 2 - assert result.max_step_time_ms == 3000.0 # 3 секунды - assert result.avg_step_time_ms == 2000.0 # (1000 + 3000) / 2 - - @pytest.mark.asyncio - async def test_audit_step_time_missing_dates(self): - """Проверяет что записи без datetime не ломают расчёт.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - auditor = ArchiveAuditor(lookback_minutes=5, step_time_enabled=True) - - entry_no_dates = ArchiveEntry( - id="1", session="s-nodate", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест", last_reply="ответ", - raw={}, - ) - entry_partial = ArchiveEntry( - id="2", session="s-partial", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест2", last_reply="ответ2", - raw={ - "request": {"datetime": "2026-06-19 10:00:00"}, - # Нет reply.datetime - }, - ) - entry_bad_format = ArchiveEntry( - id="3", session="s-bad", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест3", last_reply="ответ3", - raw={ - "request": {"datetime": "неправильный формат"}, - "reply": {"datetime": "2026-06-19 10:00:01"}, - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry_no_dates, entry_partial, entry_bad_format] - - result = await auditor.audit(mock_client) - # Ни одна запись не дала времени — всё пропущено - assert result._step_time_count == 0 - assert result.max_step_time_ms == 0.0 - assert result.avg_step_time_ms == 0.0 - - @pytest.mark.asyncio - async def test_audit_step_time_disabled(self): - """Проверяет что при отключённом step_time метрики нулевые.""" - from ses_monitor.archive_auditor import ArchiveAuditor - from ses_monitor.session_client import ArchiveEntry - - auditor = ArchiveAuditor(lookback_minutes=5, step_time_enabled=False) - - entry = ArchiveEntry( - id="1", session="s1", robot="test-bot", hostname="test", - channel="telegram", unknown=False, closed=None, - endpoint=None, answered=True, conversion=True, - models=[], events=[], request_message="тест", last_reply="ответ", - raw={ - "request": {"datetime": "2026-06-19 10:00:00"}, - "reply": {"datetime": "2026-06-19 10:00:05"}, - }, - ) - - mock_client = AsyncMock() - mock_client.get_archive.return_value = [entry] - - result = await auditor.audit(mock_client) - assert result._step_time_count == 0 - assert result.max_step_time_ms == 0.0 - assert result.avg_step_time_ms == 0.0 - - @pytest.mark.asyncio class TestUserParameterWriter: """Тесты для UserParameterWriter."""