""" Анализ архива диалогов 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, )