Универсальный watchdog-мониторинг Service Engine Server (SES): - Liveness-проверки (API, лицензия, роботы) - Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик - Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос) - Поддержка Python 3.7+, Zabbix 5.0
434 lines
18 KiB
Python
434 lines
18 KiB
Python
"""
|
||
Анализ архива диалогов 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,
|
||
)
|