ses-monitor/ses_monitor/archive_auditor.py
Maksim Totmin 7394b14b52 Initial commit: ses-monitor watchdog for SES
Универсальный watchdog-мониторинг Service Engine Server (SES):
- Liveness-проверки (API, лицензия, роботы)
- Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик
- Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос)
- Поддержка Python 3.7+, Zabbix 5.0
2026-06-19 20:26:26 +07:00

434 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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