Initial commit: sd_dispatcher code
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# /opt/GigaAM/.env
|
||||||
|
|
||||||
|
|
||||||
|
PW_ID_FTP=FTP
|
||||||
|
PW_ID_TC=bot_tcs
|
||||||
|
PW_ID_SD=sdesktoken
|
||||||
|
PW_ID_AD=AD
|
||||||
|
PW_ID_SQL=boss_base
|
||||||
|
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Автоматическое определение корневой директории проекта
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# Загружаем приватные переменные окружения из вашего .env
|
||||||
|
load_dotenv('/opt/GigaAM/config/.env')
|
||||||
|
|
||||||
|
# Карта соответствия: Переменная диспетчера -> Ключ из вашего .env
|
||||||
|
# Объявлена в самом начале, чтобы скрипт знал имена карточек до обращения к API
|
||||||
|
CREDENTIALS_MAP = {
|
||||||
|
"FTP_PASS": os.getenv("PW_ID_FTP", "").strip(' "\'\r\n'), # Мягкий поиск по ключу
|
||||||
|
"API_KEY": os.getenv("PW_ID_SD", "").strip(' "\'\r\n'), # Ищет карточку по значению из .env
|
||||||
|
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'), # Ищет карточку по значению из .env
|
||||||
|
}
|
||||||
|
|
||||||
|
# Единственный токен, который может быть зашит в локальном .env, а не в Пассворке
|
||||||
|
EMBEDDING_API_TOKEN = os.getenv("EMBEDDING_API_TOKEN", "").strip(' "\'\r\n')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# ИНТЕГРАЦИЯ С PASSWORK (Динамическая загрузка паролей)
|
||||||
|
# =====================================================================
|
||||||
|
PASSWORK_DIR = "/opt/passwork"
|
||||||
|
if PASSWORK_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, PASSWORK_DIR)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from passwork import get_passwork_secrets
|
||||||
|
print("LOG: 🔐 Обращение к модулю Passwork и выгрузка сейфа для GigaAM...")
|
||||||
|
|
||||||
|
# Формируем и пробрасываем список обязательных карточек для автозащиты от коллизий
|
||||||
|
required_to_wait = [name for name in CREDENTIALS_MAP.values() if name]
|
||||||
|
passwork_pool = get_passwork_secrets(required_cards=required_to_wait)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Не удалось подключить модуль Passwork: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("LOG: 🔐 Распределение секретов и логинов по модулям диспетчера...")
|
||||||
|
for var_name, card_name in CREDENTIALS_MAP.items():
|
||||||
|
if not card_name:
|
||||||
|
print(f"⚠️ Внимание: Название карточки для {var_name} не задано в .env. Используется локальное значение.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
clean_card_name = card_name.strip(' "\'\r\n')
|
||||||
|
card_data = passwork_pool.get(clean_card_name)
|
||||||
|
|
||||||
|
if card_data is None:
|
||||||
|
print(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Карточка с именем '{clean_card_name}' не найдена в сейфе Passwork!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Динамически заполняем пароль/токен в глобальную область видимости config.py
|
||||||
|
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
|
||||||
|
|
||||||
|
# Динамически вытаскиваем сопутствующие логины из карточек
|
||||||
|
if var_name == "FTP_PASS":
|
||||||
|
globals()["FTP_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||||
|
elif var_name == "AD_PASSWORD":
|
||||||
|
globals()["AD_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||||
|
|
||||||
|
print(f" ✓ {var_name} и логин успешно синхронизированы из карточки '{clean_card_name}'")
|
||||||
|
|
||||||
|
|
||||||
|
# Интеграция с АТС (Выгрузка записей звонков по FTP)
|
||||||
|
FTP_HOST = "10.0.0.7"
|
||||||
|
FTP_DIR = "/zabbix"
|
||||||
|
FTP_ARCHIVE = "/zabbix/archive"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 1. СИСАДМИНСКАЯ ИНФРАСТРУКТУРА, ФЛАСК И ЛОГИРОВАНИЕ
|
||||||
|
# =====================================================================
|
||||||
|
# Порт, на котором Flask-вебхук слушает входящие JSON-пакеты от ManageEngine
|
||||||
|
WEBHOOK_PORT = 5000
|
||||||
|
|
||||||
|
# Путь к файлу сквозного отладочного лога диспетчера
|
||||||
|
WEBHOOK_LOG_FILE = "/opt/GigaAM/dispatcher_log.txt"
|
||||||
|
|
||||||
|
# Включение профилирования (замеры времени выполнения этапов обработки заявки)
|
||||||
|
ENABLE_PROFILING = True
|
||||||
|
|
||||||
|
# Ограничение длины текста исторических документов при выводе в файл лога
|
||||||
|
LOG_EXAMPLE_MAX_CHARS = 350
|
||||||
|
|
||||||
|
# Ограничение длины строки текста заявки при выводе в интерактивную CLI-консоль
|
||||||
|
CLI_LOG_MAX_CHARS = 80
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 2. КОНВЕЙЕР РАСПОЗНАВАНИЯ РЕЧИ И ПЕРВИЧНОЙ ОБРАБОТКИ (ASR PIPELINE)
|
||||||
|
# =====================================================================
|
||||||
|
# Флаг включения суммаризации текста звонка (сейчас отключен для экономии CPU)
|
||||||
|
ENABLE_TEXT_SUMMARIZATION = False
|
||||||
|
|
||||||
|
# Рабочие директории для хранения входящих аудиозаписей и результатов транскрибации
|
||||||
|
AUDIO_DIR = "/opt/GigaAM/audio"
|
||||||
|
JSON_DIR = "/opt/GigaAM/json"
|
||||||
|
|
||||||
|
# Длина одного чанка (куска аудио) при нарезке для модели распознавания (в секундах)
|
||||||
|
CHUNK_SECONDS = 25
|
||||||
|
|
||||||
|
# Пути к словарям терминов и золотых ИТ-токенов для исправления ошибок транскрипции
|
||||||
|
DICT_PATH = "/opt/dict/dict.json"
|
||||||
|
GOLDEN_DICT_PATH = "/opt/dict/GOLDEN_TERMS.json"
|
||||||
|
|
||||||
|
# Переключатели модулей постобработки и исправления текста после ASR
|
||||||
|
ENABLE_REGEX_CORRECTION = True # Исправление по регулярным выражениям
|
||||||
|
ENABLE_FUZZY_CORRECTION = True # Нечёткое исправление по Левенштейну/словарям
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 3. ИНТЕГРАЦИЯ С MANAGEENGINE SERVICE DESK API
|
||||||
|
# =====================================================================
|
||||||
|
# Корпоративный URL и API-эндпоинт для работы с заявками по протоколу v3
|
||||||
|
SERVER_URL = "https://servicedesk.sibcem.ru:8080"
|
||||||
|
ENDPOINT = f"{SERVER_URL}/api/v3/requests"
|
||||||
|
|
||||||
|
# Системный email-адрес учетной записи ИИ-сортировщика в Service Desk
|
||||||
|
DEFAULT_REQUESTER = "ai@sibcem.ru"
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 4. ИНТЕГРАЦИЯ С ACTIVE DIRECTORY (LDAP)
|
||||||
|
# =====================================================================
|
||||||
|
# Контроллер домена и базовая точка поиска пользователей (Base DN)
|
||||||
|
AD_SERVER = "ldap://172.16.20.20"
|
||||||
|
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
|
||||||
|
|
||||||
|
# Сетевые тайм-ауты (в секундах) для защиты фоновых потоков от зависания на LDAP-запросах
|
||||||
|
AD_CONNECT_TIMEOUT = 5 # Время на установку tcp-соединения
|
||||||
|
AD_RECEIVE_TIMEOUT = 5 # Время ожидания ответа на поисковый запрос
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 5. ПОЧТОВЫЕ УВЕДОМЛЕНИЯ И АЛЕРТЫ (SMTP)
|
||||||
|
# =====================================================================
|
||||||
|
# Корпоративный почтовый релей и порт (используется стандартный 25 без SSL)
|
||||||
|
SMTP_SERVER = "srvkem-mail.sibcem.ru"
|
||||||
|
SMTP_PORT = 25
|
||||||
|
|
||||||
|
# Отправитель по умолчанию для ИИ-отчетов
|
||||||
|
DEFAULT_EMAIL_FROM = "ai@sibcem.ru"
|
||||||
|
|
||||||
|
# Списки рассылки и исключений для отправки логов принятия решений
|
||||||
|
ALERTS_DISABLED_USERS = ["ds.krivochenko@sibcem.ru"]
|
||||||
|
ALERTS_ALL_TICKETS = ["ds.krivochenko@sibcem.ru"]
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 6. ВЕКТОРНЫЙ ПОИСК И ЭМБЕДДИНГИ (QDRANT API)
|
||||||
|
# =====================================================================
|
||||||
|
# Адрес, порт и целевая коллекция локального сервера Qdrant
|
||||||
|
QDRANT_HOST = "localhost"
|
||||||
|
QDRANT_PORT = 6333
|
||||||
|
QDRANT_COLLECTION = "tickets"
|
||||||
|
|
||||||
|
# Осредненный лимит выборки документов из Qdrant под дальнейший реранкинг (оптимально для CPU)
|
||||||
|
QDRANT_SEARCH_LIMIT = 20
|
||||||
|
|
||||||
|
# Локальная служба генерации векторов (Embedding API)
|
||||||
|
EMBEDDING_API_URL = "http://127.0.0.1:8010/embed"
|
||||||
|
EMBEDDING_MODEL_NAME = "multilingual-e5-base"
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 7. ПЕРЕРАНЖИРОВАНИЕ ПРЕЦЕДЕНТОВ (CROSS-ENCODER RERANKER)
|
||||||
|
# =====================================================================
|
||||||
|
# Адрес выделенной службы реранкера (работает на порту 8081)
|
||||||
|
RERANKER_URL = "http://127.0.0.1:8081/rerank"
|
||||||
|
RERANKER_MODEL_NAME = "bge-reranker-v2-m3-Q8_0.gguf"
|
||||||
|
|
||||||
|
# Предохранитель от OOM: максимальное количество символов исторического билета, скармливаемое реранкеру
|
||||||
|
RERANKER_MAX_DOC_CHARS = 1200
|
||||||
|
|
||||||
|
# Жесткий тайм-аут сокета (в секундах) на ожидание пачки документов от реранкера
|
||||||
|
RERANKER_TIMEOUT = 60
|
||||||
|
|
||||||
|
# Коэффициент смешивания скоров (Score Blending):
|
||||||
|
# 1.0 — абсолютная власть реранкера, 0.0 — чистый Qdrant (база векторов)
|
||||||
|
RERANKER_WEIGHT = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 8. ЛОКАЛЬНЫЙ ИНТЕЛЛЕКТ И СЕРВЕРЫ LLM / STT
|
||||||
|
# =====================================================================
|
||||||
|
# Переключатель вызова большой языковой модели
|
||||||
|
ENABLE_OLLAMA = True
|
||||||
|
|
||||||
|
# ГЛОБАЛЬНЫЙ ДИСПЕТЧЕР И АНАЛИЗАТОР: Классификация тикетов (Тяжелый квант Q8)
|
||||||
|
OLLAMA_URL = "http://127.0.0.1:8080/v1/chat/completions"
|
||||||
|
OLLAMA_MODEL = "gemma-4-12b-it-Q8_0"
|
||||||
|
|
||||||
|
# РАСПОЗНАВАНИЕ ЗВОНКОВ: Локальный роутер моделей llama.service (Порт 8080)
|
||||||
|
OLLAMA_SUBJECT_URL = "http://127.0.0.1:8080/v1/chat/completions"
|
||||||
|
OLLAMA_SUBJECT_MODEL = "gemma-4-E4B-it-Q4_K_M"
|
||||||
|
|
||||||
|
# МИКРОСЕРВИС ТРАНСКРИБАЦИИ: Точка подключения к демону GigaAM (Порт 5001)
|
||||||
|
STT_SERVICE_URL = "http://127.0.0.1:5001/transcribe"
|
||||||
|
|
||||||
|
# Размер контекстного окна для слота (динамическое выделение памяти из пула 16384)
|
||||||
|
OLLAMA_NUM_CTX = 12288
|
||||||
|
|
||||||
|
# Параметры инференса текстовой модели
|
||||||
|
OLLAMA_TEMPERATURE = 0.0 # Строгая детерминированность вывода
|
||||||
|
OLLAMA_MAX_TOKENS = 65 # Ограничение длины ответа (модели нужно выдать только имя группы)
|
||||||
|
OLLAMA_TIMEOUT = 1200 # Предельный тайм-аут генерации токенов (в секундах) под CPU-нагрузкой
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 9. МАТЕМАТИЧЕСКИЕ ФИЛЬТРЫ, ТРИГГЕРЫ МУСОРА И ПОРОГОВЫЕ ОГРАНИЧЕНИЯ
|
||||||
|
# =====================================================================
|
||||||
|
# Осредненный порог относительной уверенности Qdrant. Ниже него — принудительный вызов Qwen 2.5
|
||||||
|
CONFIDENCE_THRESHOLD = 0.85
|
||||||
|
|
||||||
|
# КРИТИЧЕСКИЙ ПРЕДОХРАНИТЕЛЬ: если абсолютный скор лучшего совпадения реранкера ниже этого веса,
|
||||||
|
# сквозной байпас ИИ запрещается, и билет отправляется на перепроверку в Ollama
|
||||||
|
FORCE_OLLAMA_SCORE_THRESHOLD = 0.75
|
||||||
|
|
||||||
|
# Сколько голосов (совпадений) максимум может забрать одна ИТ-группа в векторном пуле
|
||||||
|
QDRANT_MAX_VOTES_PER_GROUP = 10
|
||||||
|
|
||||||
|
# Абсолютный скор, выше которого заявка считается стопроцентным историческим дублем
|
||||||
|
EXACT_MATCH_SCORE_THRESHOLD = 0.9
|
||||||
|
|
||||||
|
# Фильтры детектирования аномального распределения (маркеры мусорных/непонятных заявок)
|
||||||
|
GARBAGE_CONFIDENCE_THRESHOLD = 0.01 # Предельно низкая уверенность
|
||||||
|
GARBAGE_UNIQUE_GROUPS_LIMIT = 4 # Слишком много разных групп в топ-выборке
|
||||||
|
GARBAGE_VARIANCE_THRESHOLD = 0.02 # Слишком плотный шаг весов (нет явного лидера)
|
||||||
|
GARBAGE_MIN_GROUPS_FOR_VARIANCE = 2 # Минимальное число конкурентов для оценки дисперсии
|
||||||
|
GARBAGE_BYPASS_DOMINANCE = 0.90 # Порог доминации, снимающий метку мусора
|
||||||
|
|
||||||
|
# Фильтры условий обхода вызова Ollama (Bypass)
|
||||||
|
OLLAMA_BYPASS_DOMINANCE = 0.95 # Если одна группа заняла 80% топа выборки
|
||||||
|
OLLAMA_BYPASS_MIN_CONFIDENCE = 0.10 # Минимальная базовая уверенность для байпаса
|
||||||
|
OLLAMA_ACCEPTANCE_THRESHOLD = 0.65 # Минимальная уверенность логпробов Ollama для принятия ответа
|
||||||
|
DOMINANCE_TOP_K = 10 # Глубина среза топ-результатов для расчёта коэффициента доминации
|
||||||
|
LOG_TOP_GROUPS_LIMIT = 5 # Количество позиций истории, выводимых в логи структуры decision_log
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 10. СПРАВОЧНИКИ МАРШРУТИЗАЦИИ, КАТЕГОРИИ И БИЗНЕС-ЛОГИКА АВТОМАТИЗАЦИИ
|
||||||
|
# =====================================================================
|
||||||
|
# White-list валидных ИТ-групп в системе Service Desk Plus
|
||||||
|
VALID_GROUPS = [
|
||||||
|
"CRM", "АХО", "Техподдержка", "1С", "Axapta (Microsoft Dynamics AX)",
|
||||||
|
"АСУТП", "Босс-Кадровик", "ОПС, видеонаблюдение, СКУД, СМТ",
|
||||||
|
"Программисты КТЦ", "Системное администрирование", "Телеком и сообщения",
|
||||||
|
"Битрикс", "СЭД Directum", "Администрирование систем BI", "СБИС", "Служба информационной безопасности", "НСИ"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Группы, для которых открыто прямое автоматическое исполнение Put-запросов назначения через API
|
||||||
|
ALLOWED_ASSIGNMENT_GROUPS = [
|
||||||
|
"Техподдержка КЦ", "Техподдержка ХК", "Техподдержка Тимлюй",
|
||||||
|
"Техподдержка Ангарск", "Техподдержка Искитим", "Техподдержка Топки",
|
||||||
|
"Техподдержка КТЦ", "Техподдержка Волна", "Техподдержка Москва","Телеком и сообщения"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Матрица ответственных менеджеров групп для отправки алертов о сбоях/назначениях билетов
|
||||||
|
GROUP_MANAGERS = {
|
||||||
|
"Техподдержка": ["e.voevodin@sibcem.ru"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Региональный справочник привязки городов к конкретным Кустовым Центрам (КЦ) и ХК
|
||||||
|
CITY_SUPPORT = {
|
||||||
|
"Ангарск": "Техподдержка Ангарск",
|
||||||
|
"Слюдянка": "Техподдержка Ангарск",
|
||||||
|
"Искитим": "Техподдержка Искитим",
|
||||||
|
"Новосибирск": "Техподдержка КТЦ",
|
||||||
|
"Топки": "Техподдержка Топки",
|
||||||
|
"Кемерово": "Техподдержка ХК",
|
||||||
|
"Москва": "Техподдержка Москва",
|
||||||
|
"Тимлюй": "Техподдержка ХК",
|
||||||
|
"Каменск": "Техподдержка ХК",
|
||||||
|
"Улан-Удэ": "Техподдержка ХК",
|
||||||
|
"Татарский Ключ": "Техподдержка ХК",
|
||||||
|
"Спас-Клепики": "Техподдержка ХК",
|
||||||
|
"Красноярск": "Техподдержка КЦ"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройки матрицы автоматического создания уровней согласования руководителями
|
||||||
|
ENABLE_AUTO_APPROVAL = True
|
||||||
|
APPROVAL_CITIES = ["Новосибирск"]
|
||||||
|
APPROVAL_GROUPS = ["1С", "1с", "Программисты КТЦ"]
|
||||||
|
APPROVAL_CATEGORIES = {
|
||||||
|
"ИТ": ["ПО КТЦ", "Проблемы с Договорной Работой"],
|
||||||
|
"1С": ["*"],
|
||||||
|
"1с": ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# План «Б» маршрутизации: жесткое определение локации по телефонному префиксу внутренней АТС
|
||||||
|
PREFIX_ROUTING = {
|
||||||
|
"700": {"city": "Красноярск", "company": "ООО Комбинат Волна"},
|
||||||
|
"702": {"city": "Кемерово"},
|
||||||
|
"703": {"city": "Новосибирск"},
|
||||||
|
"704": {"city": "Красноярск"},
|
||||||
|
"705": {"city": "Каменск"},
|
||||||
|
"701": {"city": "Ангарск"},
|
||||||
|
"706": {"city": "Ангарск"},
|
||||||
|
"707": {"city": "Ангарск"},
|
||||||
|
"709": {"city": "Москва"},
|
||||||
|
"724": {"city": "Искитим"},
|
||||||
|
"71": {"city": "Топки"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
# /opt/GigaAM/webhook.py
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import urllib3
|
||||||
|
import math
|
||||||
|
import warnings
|
||||||
|
import datetime
|
||||||
|
import html
|
||||||
|
import threading
|
||||||
|
import smtplib
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from collections import defaultdict, Counter
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
|
# Отключаем предупреждения сокетов для безопасных внутренних соединений
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
|
||||||
|
# --- ИМПОРТИРУЕМ ВСЕ НАСТРОЙКИ ИЗ ЕДИНОГО КОНФИГА ---
|
||||||
|
sys.path.append('/opt/GigaAM')
|
||||||
|
from config.config import *
|
||||||
|
|
||||||
|
# --- ИНИЦИАЛИЗАЦИЯ FLASK ---
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Потоковый мьютекс для предотвращения конкурентной записи разных билетов в один файл лога
|
||||||
|
log_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Глобальные заголовки для авторизации в ManageEngine Service Desk API
|
||||||
|
api_headers = {
|
||||||
|
"authtoken": API_KEY,
|
||||||
|
"Accept": "application/vnd.manageengine.sdp.v3+json"
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_headers = {
|
||||||
|
"authtoken": API_KEY,
|
||||||
|
"Accept": "application/v3+json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ИИ И ПОЧТЫ
|
||||||
|
# ------------------------------------------------
|
||||||
|
def log_to_file(message, ticket_id=None):
|
||||||
|
"""Потокобезопасная запись лога с автоматическим тегированием и временной меткой"""
|
||||||
|
try:
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
prefix = f" [Заявка #{ticket_id}]" if ticket_id else ""
|
||||||
|
|
||||||
|
lines = str(message).strip().split('\n')
|
||||||
|
formatted_lines = [f"{timestamp}{prefix} {line}" for line in lines]
|
||||||
|
|
||||||
|
with log_lock:
|
||||||
|
with open(WEBHOOK_LOG_FILE, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(formatted_lines) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_email_report(ticket_id, requester_name, subject, ticket_text, final_category, decision_log):
|
||||||
|
"""Формирование и отправка HTML-отчета на почту ответственным лицам"""
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = DEFAULT_EMAIL_FROM
|
||||||
|
|
||||||
|
recipients = set(ALERTS_ALL_TICKETS)
|
||||||
|
|
||||||
|
if final_category in GROUP_MANAGERS:
|
||||||
|
recipients.update(GROUP_MANAGERS[final_category])
|
||||||
|
elif final_category.startswith("Техподдержка") and "Техподдержка" in GROUP_MANAGERS:
|
||||||
|
recipients.update(GROUP_MANAGERS["Техподдержка"])
|
||||||
|
|
||||||
|
if not recipients: return
|
||||||
|
msg['To'] = ", ".join(recipients)
|
||||||
|
|
||||||
|
if final_category in ["Не является заявкой", "Не определено", "Не назначено"]:
|
||||||
|
msg['Subject'] = f"🤖 ИИ-Отчет: Заявка #{ticket_id} не распределена (Сброс)"
|
||||||
|
group_color = "#d9363e"
|
||||||
|
else:
|
||||||
|
msg['Subject'] = f"🤖 ИИ-Отчет: Заявка #{ticket_id} -> {final_category}"
|
||||||
|
group_color = "#0056b3"
|
||||||
|
|
||||||
|
ticket_link = f"{SERVER_URL}/WorkOrder.do?woMode=viewWO&woID={ticket_id}"
|
||||||
|
formatted_ticket_text = ticket_text.replace("\n", "<br>")
|
||||||
|
formatted_decision_log = decision_log.replace("\n", "<br>")
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; font-size: 14px; color: #333; line-height: 1.5;">
|
||||||
|
<p><b>От кого:</b> {requester_name}</p>
|
||||||
|
<p><b>Тема:</b> {subject}</p>
|
||||||
|
<p><b>Ссылка:</b> <a href="{ticket_link}">{ticket_link}</a></p>
|
||||||
|
<p><b>Текст заявки:</b><br>{formatted_ticket_text}</p>
|
||||||
|
<hr style="border: 1px solid #ccc; margin: 20px 0;">
|
||||||
|
<p><b style="font-size: 16px;">ВЫБРАННАЯ ГРУППА: <span style="color: {group_color};">{final_category}</span></b></p>
|
||||||
|
<hr style="border: 1px solid #ccc; margin: 20px 0;">
|
||||||
|
<p><b>КАК ПРИНИМАЛОСЬ РЕШЕНИЕ (Лог ИИ):</b></p>
|
||||||
|
<p style="font-family: Consolas, 'Courier New', monospace; font-size: 13px; background-color: #f4f4f4; padding: 15px; border-radius: 5px; color: #333;">
|
||||||
|
{formatted_decision_log}
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
|
||||||
|
server.send_message(msg)
|
||||||
|
server.quit()
|
||||||
|
log_to_file(f"📧 Простой HTML-отчет по заявке {ticket_id} успешно отправлен ({len(recipients)} получателей).", ticket_id)
|
||||||
|
except Exception as e:
|
||||||
|
log_to_file(f"❌ Ошибка отправки почты для заявки {ticket_id}: {e}", ticket_id)
|
||||||
|
|
||||||
|
def assign_group_to_ticket(ticket_id, group_name):
|
||||||
|
"""Физическое переназначение группы внутри ManageEngine через API v3"""
|
||||||
|
url = f"{ENDPOINT}/{ticket_id}"
|
||||||
|
payload = {"request": {"group": {"name": group_name}}}
|
||||||
|
data = {"input_data": json.dumps(payload)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.put(url, headers=api_headers, data=data, verify=False)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
resp_json = response.json()
|
||||||
|
status_code = resp_json.get("response_status", {}).get("status_code")
|
||||||
|
|
||||||
|
if status_code == 2000:
|
||||||
|
log_to_file(f"🔧 [API] Заявка {ticket_id} физически назначена на '{group_name}' в SD!", ticket_id)
|
||||||
|
else:
|
||||||
|
log_to_file(f"❌ [API] Ошибка ManageEngine при назначении: {resp_json}", ticket_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_to_file(f"❌ [API] Ошибка сети при назначении группы: {e}", ticket_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# ФУНКЦИИ AD И МАРШРУТИЗАЦИИ
|
||||||
|
# ------------------------------------------------
|
||||||
|
def find_user_in_ad(requester, caller_phone=None, ticket_id=None):
|
||||||
|
"""Поиск параметров учетной записи (Город, Компания, OU) внутри Active Directory"""
|
||||||
|
ldap_filter = ""
|
||||||
|
search_by = ""
|
||||||
|
|
||||||
|
if caller_phone:
|
||||||
|
ldap_filter = f"(|(telephoneNumber=*{caller_phone}*)(mobile=*{caller_phone}*)(homePhone=*{caller_phone}*)(pager=*{caller_phone}*)(facsimileTelephoneNumber=*{caller_phone}*)(ipPhone=*{caller_phone}*))"
|
||||||
|
search_by = f"номеру телефона ({caller_phone})"
|
||||||
|
else:
|
||||||
|
if not requester: return None
|
||||||
|
email = requester.get("email_id")
|
||||||
|
login = requester.get("login_name")
|
||||||
|
name = requester.get("name")
|
||||||
|
|
||||||
|
if login:
|
||||||
|
clean_login = login.split("\\")[-1]
|
||||||
|
ldap_filter = f"(sAMAccountName={clean_login})"
|
||||||
|
search_by = f"Логину ({clean_login})"
|
||||||
|
elif email:
|
||||||
|
ldap_filter = f"(mail={email})"
|
||||||
|
search_by = f"Почте ({email})"
|
||||||
|
elif name:
|
||||||
|
ldap_filter = f"(displayName=*{name}*)"
|
||||||
|
search_by = f"ФИО (*{name}*)"
|
||||||
|
else: return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = Server(AD_SERVER, get_info=ALL, connect_timeout=AD_CONNECT_TIMEOUT)
|
||||||
|
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True, receive_timeout=AD_RECEIVE_TIMEOUT)
|
||||||
|
conn.search(search_base=AD_BASE, search_filter=ldap_filter, attributes=["l", "company", "manager"])
|
||||||
|
|
||||||
|
if not conn.entries:
|
||||||
|
log_to_file(f" [AD] ❌ Объект ({search_by}) не найден in AD!", ticket_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = conn.entries[0]
|
||||||
|
city = user.l.value if 'l' in user and user.l.value else None
|
||||||
|
company = user.company.value if 'company' in user and user.company.value else None
|
||||||
|
ad_path = user.entry_dn
|
||||||
|
|
||||||
|
if not city and ad_path:
|
||||||
|
match = re.search(r'OU=([^,]+),OU=-Пользователи', ad_path, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
city = match.group(1).strip()
|
||||||
|
if city.lower() == "красноярск" and "OU=ООО Комбинат Волна" in ad_path:
|
||||||
|
company = "ООО Комбинат Волна"
|
||||||
|
|
||||||
|
manager_dn = user.manager.value if 'manager' in user else None
|
||||||
|
manager_email = None
|
||||||
|
if manager_dn:
|
||||||
|
conn.search(search_base=manager_dn, search_filter="(objectClass=user)", attributes=["mail"], search_scope='BASE')
|
||||||
|
if conn.entries and 'mail' in conn.entries[0]:
|
||||||
|
manager_email = conn.entries[0].mail.value
|
||||||
|
|
||||||
|
return {"city": city, "company": company, "ad_path": ad_path, "manager_email": manager_email, "source": f"AD ({search_by})"}
|
||||||
|
except Exception as e:
|
||||||
|
log_to_file(f"❌ Ошибка LDAP или таймаут сети AD: {e}", ticket_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_support_group(qdrant_group, city, company, ad_path):
|
||||||
|
"""Региональная привязка базовой группы 'Техподдержка' к конкретным Кустовым Центрам"""
|
||||||
|
if not qdrant_group.startswith("Техподдержка"):
|
||||||
|
return qdrant_group, f"Группа '{qdrant_group}' не требует региональной маршрутизации"
|
||||||
|
|
||||||
|
if not city:
|
||||||
|
return qdrant_group, "Город не определен, оставлена базовая группа техподдержки"
|
||||||
|
|
||||||
|
city_clean = city.strip()
|
||||||
|
if city_clean == "Красноярск":
|
||||||
|
is_volna_by_attr = company and "Комбинат Волна" in company
|
||||||
|
is_volna_by_ou = ad_path and "OU=ООО Комбинат Волна" in ad_path
|
||||||
|
|
||||||
|
if is_volna_by_attr:
|
||||||
|
return "Техподдержка Волна", "Красноярск: определено по атрибуту 'Организация' (Комбинат Волна)"
|
||||||
|
if is_volna_by_ou:
|
||||||
|
return "Техподдержка Волна", "Красноярск: определено по нахождению in OU 'ООО Комбинат Волна'"
|
||||||
|
|
||||||
|
return "Техподдержка КЦ", "Красноярск: атрибуты 'Волны' не найдены, назначен Кустовой центр"
|
||||||
|
|
||||||
|
target_group = CITY_SUPPORT.get(city_clean)
|
||||||
|
if target_group:
|
||||||
|
return target_group, f"Маршрутизация по справочнику городов: {city_clean} -> {target_group}"
|
||||||
|
|
||||||
|
return qdrant_group, f"Город '{city_clean}' отсутствует в CITY_SUPPORT, оставлена базовая группа"
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# ФУНКЦИИ РАСШАРИВАНИЯ И СОГЛАСОВАНИЯ
|
||||||
|
# ------------------------------------------------
|
||||||
|
def find_user_id_and_role(target_email):
|
||||||
|
target_name = target_email.split('@')[0]
|
||||||
|
endpoints = [("technicians", "technicians"), ("requester", "users"), ("users", "users")]
|
||||||
|
search_variants = [
|
||||||
|
{"field": "email_id", "condition": "contains", "value": target_email},
|
||||||
|
{"field": "login_name", "condition": "contains", "value": target_name},
|
||||||
|
{"field": "name", "condition": "contains", "value": target_name}
|
||||||
|
]
|
||||||
|
|
||||||
|
for endpoint, role_key in endpoints:
|
||||||
|
search_url = f"{SERVER_URL}/api/v3/{endpoint}"
|
||||||
|
for criteria in search_variants:
|
||||||
|
if not criteria["value"]: continue
|
||||||
|
payload = {"list_info": {"search_criteria": [criteria]}}
|
||||||
|
try:
|
||||||
|
resp = requests.get(search_url, headers=api_headers, params={"input_data": json.dumps(payload)}, verify=False)
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
items = data.get(endpoint, []) or data.get("users", []) or data.get("technician", []) or data.get("requesters", [])
|
||||||
|
if items:
|
||||||
|
for item in items:
|
||||||
|
name = item.get("name", "").lower()
|
||||||
|
email = item.get("email_id", "").lower() if item.get("email_id") else ""
|
||||||
|
login = item.get("login_name", "").lower() if item.get("login_name") else ""
|
||||||
|
if target_email.lower() in email or target_name.lower() in login or target_name.lower() in name:
|
||||||
|
return str(item.get("id")), item.get("name"), role_key
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
def share_ticket_for_approval(ticket_id, manager_email):
|
||||||
|
step_log = ""
|
||||||
|
user_id, found_name, role_key = find_user_id_and_role(manager_email)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return False, f" - ⚠️ Ошибка: Руководитель ({manager_email}) не найден в базе Service Desk для выдачи прав."
|
||||||
|
|
||||||
|
role_label = "Специалисту" if role_key == "technicians" else "Автору"
|
||||||
|
step_log += f" - Найден {role_label} {found_name} (ID: {user_id}). Открываем доступ...\n"
|
||||||
|
|
||||||
|
share_url = f"{SERVER_URL}/api/v3/requests/{ticket_id}/share"
|
||||||
|
payload = {"share": {role_key: [{"id": user_id}]}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
share_resp = requests.put(share_url, headers=ui_headers, data={"input_data": json.dumps(payload)}, verify=False)
|
||||||
|
share_result = share_resp.json()
|
||||||
|
if isinstance(share_result, dict):
|
||||||
|
status_obj = share_result.get("response_status", {})
|
||||||
|
if status_obj.get("status") == "success" or str(status_obj.get("status_code")) == "2000":
|
||||||
|
step_log += f" - 🔓 Успешно: Доступ к заявке открыт."
|
||||||
|
return True, step_log
|
||||||
|
else:
|
||||||
|
msg = status_obj.get('messages', [{}])[0].get('message', 'Неизвестная ошибка API')
|
||||||
|
return False, step_log + f" - ❌ Ошибка выдачи прав: {msg}"
|
||||||
|
else:
|
||||||
|
return False, step_log + " - ❌ Ответ API не JSON."
|
||||||
|
except Exception as e:
|
||||||
|
return False, step_log + f" - ❌ Сбой сети при выдаче прав: {e}"
|
||||||
|
|
||||||
|
def add_email_to_notify(ticket_id, new_email):
|
||||||
|
url = f"{ENDPOINT}/{ticket_id}"
|
||||||
|
try:
|
||||||
|
get_response = requests.get(url, headers=api_headers, verify=False)
|
||||||
|
if get_response.status_code == 200:
|
||||||
|
current_data = get_response.json()
|
||||||
|
existing_emails = current_data.get("request", {}).get("email_ids_to_notify", [])
|
||||||
|
if not existing_emails: existing_emails = []
|
||||||
|
if new_email in existing_emails:
|
||||||
|
return True, f" - ℹ️ Руководитель ({new_email}) уже числится в наблюдателях."
|
||||||
|
|
||||||
|
existing_emails.append(new_email)
|
||||||
|
payload = {"request": {"email_ids_to_notify": existing_emails}}
|
||||||
|
put_response = requests.put(url, headers=api_headers, data={"input_data": json.dumps(payload)}, verify=False)
|
||||||
|
put_resp_json = put_response.json()
|
||||||
|
if put_response.status_code in [200, 201] and put_resp_json.get("response_status", {}).get("status_code") == 2000:
|
||||||
|
return True, f" - ✅ Успешно: Руководитель ({new_email}) добавлен в копию заявки."
|
||||||
|
else:
|
||||||
|
msg = put_resp_json.get('response_status', {}).get('messages', [{}])[0].get('message', 'Неизвестная ошибка API')
|
||||||
|
return False, f" - ❌ Ошибка добавления в копию: {msg}"
|
||||||
|
return False, f" - ❌ Ошибка получения заявки для редактирования копии."
|
||||||
|
except Exception as e:
|
||||||
|
return False, f" - ❌ Сбой сети при добавлении в копию: {e}"
|
||||||
|
|
||||||
|
def trigger_manager_approval(ticket_id, manager_email):
|
||||||
|
step_log = ""
|
||||||
|
url_level = f"{ENDPOINT}/{ticket_id}/approval_levels"
|
||||||
|
payload_level = {"approval_level": {"approvals": [{"approver": {"email_id": manager_email}}]}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp1 = requests.post(url_level, headers=api_headers, data={"input_data": json.dumps(payload_level)}, verify=False)
|
||||||
|
data1 = resp1.json()
|
||||||
|
if resp1.status_code not in [200, 201] or data1.get("response_status", {}).get("status_code") != 2000:
|
||||||
|
err_msg = f"❌ [API] Ошибка создания уровня согласования: {resp1.text}"
|
||||||
|
log_to_file(err_msg, ticket_id)
|
||||||
|
return False, err_msg
|
||||||
|
|
||||||
|
level_id = data1.get("approval_level", {}).get("id")
|
||||||
|
step_log += f" - Создан уровень согласования (ID: {level_id})\n"
|
||||||
|
|
||||||
|
url_send = f"{ENDPOINT}/{ticket_id}/approval_levels/{level_id}/approvals/_send_notification"
|
||||||
|
resp2 = requests.put(url_send, headers=api_headers, data={"input_data": json.dumps({"approval": {}})}, verify=False)
|
||||||
|
if resp2.status_code in [200, 201] and resp2.json().get("response_status", {}).get("status_code") == 2000:
|
||||||
|
success_msg = f"🎉 [API] Запрос на согласование успешно отправлен руководителю: {manager_email}"
|
||||||
|
log_to_file(success_msg, ticket_id)
|
||||||
|
step_log += f" - {success_msg}"
|
||||||
|
return True, step_log
|
||||||
|
else:
|
||||||
|
warn_msg = f"⚠️ [API] Ошибка при отправке письма: {resp2.text}"
|
||||||
|
log_to_file(warn_msg, ticket_id)
|
||||||
|
return False, step_log + f" - {warn_msg}"
|
||||||
|
except Exception as e:
|
||||||
|
crit_msg = f"❌ [API] Сбой сети при работе с API согласований: {e}"
|
||||||
|
log_to_file(crit_msg, ticket_id)
|
||||||
|
return False, crit_msg
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# ЛОГИКА КЛАССИФИКАЦИИ (QDRANT + РЕРАНКЕР + LLM)
|
||||||
|
# ------------------------------------------------
|
||||||
|
def calculate_confidence(weighted_scores):
|
||||||
|
"""Синхронизировано с анализатором: маржинальная разница между 1 и 2 местом"""
|
||||||
|
if len(weighted_scores) < 2: return 1.0
|
||||||
|
sorted_scores = sorted(weighted_scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
return sorted_scores[0][1] - sorted_scores[1][1]
|
||||||
|
|
||||||
|
def classify_with_ollama(full_text, ticket_id=None):
|
||||||
|
"""Глубокий семантический анализ через локальную LLM с защитой GBNF и 17 группами холдинга"""
|
||||||
|
groups_text = "\n".join(f"- {g}" for g in VALID_GROUPS)
|
||||||
|
|
||||||
|
system_prompt = fr"""
|
||||||
|
Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — классифицировать текстовую заявку и определить её строго в одну целевую ИТ-группу.
|
||||||
|
Заявка получена из системы распознавания речи (STT), поэтому учитывай специфику: сленг, опечатки, звуковые искажения ("собака" вместо @, "один эс", "акцапта", "ворд").
|
||||||
|
|
||||||
|
ДОПУСТИМЫЕ КАТЕГОРИИ ДЛЯ ВЫБОРА:
|
||||||
|
{groups_text}
|
||||||
|
- Не назначено
|
||||||
|
|
||||||
|
ИНСТРУКЦИИ ПО ОФОРМЛЕНИЮ ОТВЕТА (СТРОЖАЙШИЙ ЗАПРЕТ НА ЛИШНИЙ ТЕКСТ):
|
||||||
|
1. Выведи ТОЛЬКО точное название группы из справочника выше. Без кавычек, точек и пояснений.
|
||||||
|
2. Категорически запрещено выводить в качестве ответа названия заголовков правил разрешения конфликтов!
|
||||||
|
3. Если текст является спамом, шуткой или бессмысленным набором слов, выводи: Не назначено.
|
||||||
|
|
||||||
|
МАТРИЦА РАЗРЕШЕНИЯ КОНФЛИКТОВ (ПРИОРИТЕТЫ МАРШРУТИЗАЦИИ):
|
||||||
|
|
||||||
|
1. СБИС — АБСОЛЮТНЫЙ НАИВЫСШИЙ ПРИОРИТЕТ при работе в чистом интерфейсе платформы (sbis.ru / Saby):
|
||||||
|
- Выпуск, продление, перевыпуск или настройка любых ЭП / УКЭП / КЭП для сотрудников (включая поломки Рутокенов).
|
||||||
|
- Электронная отчетность внутри веб-интерфейсов СБИС/Saby в госорганы (СФР, больничные СФР, ФНС, Росстат, файлы МЧД).
|
||||||
|
- Восстановление паролей, карточки пользователей, кассы, ЭДО (link.sbis.ru) и Честный Знаки внутри СБИС.
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 1: Если выгрузка или запуск обработки СБИС происходит внутри баз 1С — это группа 1С.
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 2: Если заявка требует добавить, внести или прописать данные сотрудника (ФИО, ИНН, Тип счета доверителя, регистрационный номер, даты) в программы Аксапты (12ах, 4ах, базы ЗСЦ, КВ, СЦС) для обеспечения возможности последующего подписания или обработки документов СБИС через Аксапту — это НЕ СБИС, это СТРОГО группа СПРАВОЧНИКИ И НСИ!
|
||||||
|
|
||||||
|
2. СИСТЕМНОЕ АДМИНИСТРИРОВАНИЕ — ГЛОБАЛЬНЫЙ ИНФРАСТРУКТУРНЫЙ УРОВЕНЬ ДОМЕНА И СЕРВЕРОВ:
|
||||||
|
Глобальное управление серверами, сетевыми правами, бэкапами и общесистемными доменными сервисами. Жестко перехватывает из ВСЕХ прикладных групп следующие заявки:
|
||||||
|
- РАССЫЛКА РАСЧЕТНЫХ ЛИСТКОВ (РЛ): Запросы на автоматический запуск или ручную отправку расчетных листков (РЛ СБ, РЛ КВ), находящихся на серверах и сетевых путях (включая пути вида \\srvkem-appboss\bk\temp\OAGORBACHUK\RL\). НЕ Босс-Кадровик!
|
||||||
|
- АДМИНИСТРИРОВАНИЕ ПРОГРАММЫ АССИСТЕНТ: Любые заявки на "добавить имя ПК (top-gp-10, top-sir-, top-kpp- и др.) в Ассистент", управление административными доменными группами Ассистента (группы СОРЗИМ Топки, АСУТП Топки). ЖЕСТКОЕ ПРАВИЛО: Даже если это имя ПК добавляется для целей обслуживания и работы видеонаблюдения! Это уровень админов, а не ОПС.
|
||||||
|
- ВОССТАНОВЛЕНИЕ ФАЙЛОВ И ПАПОК ИЗ БЭКАПОВ: Задачи на "восстановление утерянных / пропавших / исчезнувших файлов или папок" из общих сетевых ресурсов и папок общего доступа (сетевая папка ОТК, П6-П7, Общие ресурсы, Result ДХ, Лаборатория ДХ, формы фми). НЕ Техподдержка!
|
||||||
|
- СЕТЕВЫЕ ПРАВА И ДОСТУП К ПАПКАМ: Инциденты вида "нет доступа к папке", "не могу войти в сетечную папку ресурса", "пропал доступ к папке с файлами" на общих сетевых дисках домена \\sibcem.ru. Предоставление доменных разрешений на работу с макросами в Excel/Word MS Office. Предоставление доменных прав доступа и добавление учетных записей Active Directory в закрытые Группы/Блоки Продаж домена. НЕ Техподдержка! НЕ CRM!
|
||||||
|
- СЕРВЕРНЫЕ СЛУЖБЫ ТРУКОНФ И ТЕРМИНАЛЫ: Настройка групп, параллельных вызовов, проблемы истории звонков или удаление номерных псевдонимов в Труконф (TrueConf). Низкоуровневые проблемы подключения к терминальным серверам ("не работает терминальный ЭТРАН", "нет подключения к терминальному ЭТРАНУ"). Установка плагинов, расширений Яндекс.Браузера и любого ПО на удаленных терминальных серверах (плагин BS на АксаптаТерминал для BSPortal).
|
||||||
|
- ПОЧТОВЫЙ ТРАНСПОРТ EXCHANGE: Глобальные почтовые transport-сбои Exchange/Outlook, исправление некорректного времени передачи и сдвигов часовых поясов писем в Outlook при пересылке сообщений (когда время приема от ОАО РЖД 23:32 меняется на 11:32, не уходит почта на КЗХ). Управление ящиками Exchange: добавление/удаление сотрудников из списков рассылок, настройка серверных автоответов (OOF), права доступа к чужим ящикам/календарям домена @sibcem.ru.
|
||||||
|
- ДОМЕН AD И СУБД: Развертывание и настройка СУБД (PostgreSQL, MS SQL) на любых серверах и бэкап-машинах домена (сервер nsk-backup-01). Очистка дискового пространства и удаление файлов серверных логов на хостах СЭД srvkem-drx-02 (папки RX4_8Data\logs\remote) и настройка скриптов их автоматической ротации.
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ: Локальные пользовательские инциденты внутри рабочих мест и сессий (неверное время в VDI, сброс/восстановление паролей учеток, парольные петли почты Outlook), установка Windows и ввод ПК в домен силами локального ИТ-специалиста относятся СТРОГО к Техподдержке! Любые кросс-сетевые доступы, настройка межсетевой маршрутизации (в сеть СОРЗИМ), прописывание маршрутов между подсетями и открытие доступов к IP-адресам (Энергосфера, VGL Патруль) относятся СТРОГО к Службе информационной безопасности!
|
||||||
|
|
||||||
|
3. AXAPTA (MICROSOFT DYNAMICS AX) — ПРОГРАММНЫЙ И ОПЕРАЦИОННЫЙ УРОВЕНЬ ERP-СИСТЕМЫ AX:
|
||||||
|
Любые инциденты, баги, ошибки кода, настройки логики процессов, маршрутов согласования, закрытия склада, сторно проводок, интеграции с АС-ЭТРАН (в части XML-структур и реквизитов накладных) или прав на действия внутри окон в базах АХ4, АХ12, АХ12_ЗСЦ. Сбои системных счетчиков нумерации партий в ЖЛ, отсутствие номеров вагонов из-за ошибок синхронизации филиалов.
|
||||||
|
- ИС КЛЮЧЕНИЕ: Если пользователь жалуется, что Аксапта работает или отправляет документы слишком медленно (по 10 минут) по причине того, что сам компьютер зависает, тормозит и долго открывает любые локальные файлы — это относится СТРОГО к группе Техподдержка!
|
||||||
|
|
||||||
|
4. СПРАВОЧНИКИ И НСИ — РУТИННЫЙ ВВОД ДАННЫХ, КАРТОЧЕК И РАССЫЛОК ДЛЯ ВСЕХ СИСТЕМ (AXAPTA, 1С, DIRECTUM):
|
||||||
|
Первичное заведение, наполнение, текстовое редактирование и корректировка нормативно-справочной информации (Master Data), справочников, МЧД, доверенностей и списков оповещений во ВСЕХ базах холдинга. Жестко перехватывает у Аксапты, 1С, Директума, СБИС и Службы ИБ следующие заявки:
|
||||||
|
- ВНЕСЕНИЕ МЧД И ДАННЫХ СОТРУДНИКОВ ДЛЯ СБИС: Любые запросы "внести МЧД", "внесение МЧД в АХ12 / ах4", "добавить сотрудника (Степанцову, Климчук, Мельникова и др.) в базу ЗСЦ / КВ / СЦС / 12ах / 4ах для обработки и подписания документов в СБИС", а также кастомные таблицы параметров сотрудников для ЭДО (поля: ФИО, ИНН, Тип счета доверителя (Поставщик или Клиент), Счет поставщика из базы в Ax, Единый регистрационный номер, Начальная/Конечная дата).
|
||||||
|
- КАРТОЧКИ ТС И ПРОМ. ОБОРУДОВАНИЯ (СПРАВОЧНИК УАТ И AX): Запросы на "внести ТС в аксапту блока УАТ для возможности создания ПЛ", первичное заведение новых и тестовых ТС (самосвалы SITRAK C7H, экскаваторы SANY SY980H, буровые станки SWDE, автомобили Газ Соболь, Ford Transit), если они содержат нумерованные или текстовые списки реквизитов (инв №, гаражный номер, гос. номер, модель, VIN, год выпуска, категория, цвет, вид ПЛ, журнал проводок, топливо, счет списания, филиал, подразделение, аналитики ан.3, ан.5, ан.7, ан.8, рабочие центры). Также: "Завести Рабочий Центр для цементной мельницы" (ОМЦ_5_Д, ОМЦ_5_Н, аналитика 6) для введения оборудования в работу.
|
||||||
|
- СПИСКИ РАССЫЛОК, УВЕДОМЛЕНИЙ И ПРИКЛАДНЫЕ ГРУППЫ: Отредактировать списки получателей уведомлений или рассылок отчетов в Личном Кабинете или прикладных базах ("добавить/исключить эл. адреса для рассылки оповещений 1cmail в 1С КТЦ по контрагенту", "поставить получателем Мирошникову / Ленкову, остальных убрать"). Дозаполнение или наполнение прикладных групп пользователей внутри систем документооборота (наполнение группы "Планы и отчеты ХК" в Directum RX пользователями из вложения). Активация тестовых сред ("активировать тестовую версию Ах12").
|
||||||
|
- ИС КЛЮЧЕНИЕ: Прописывание подъездных путей Юр.лиц с привязкой к станциям (Китой-Комбинатская, Слюнявка 2), справочники Грузоперевозчиков и Дорог назначения в системе МИКС, доработка шаблонов резолюций КТЦ относятся СТРОГО к группе Программисты КТЦ!
|
||||||
|
|
||||||
|
5. СЛУЖБА ИНФОРМАЦИОННОЙ БЕЗОПАСНОСТИ — СЕТЕВОЙ ПЕРИМЕТР, КРОСС-СЕТЕВАЯ МАРШРУТИЗАЦИЯ, VLAN И ЗАЩИТА:
|
||||||
|
Обеспечение безопасности сетевого периметр холдинга, Firewall Authentication, блокировки и сетевая связность подсетей. Жестко забирает из ОПС, Телекома и Сисиадминов следующие заявки:
|
||||||
|
- ДОСТУП И СЕТЕВАЯ МАРШРУТИЗАЦИЯ: Предоставление или открытие доступа компьютерам (ANG294, ANG557 и др.) в закрытые технологические сегменты и сторонние подсети (сеть СОРЗИМ, подсети 172.16.34.0, 172.16.35.0, сетевое хранилище 172.16.34.27). Прописывание, восстановление и корректировка сетевых маршрутов между серверами и рабочими местами при сбоях связности ("перестал идти маршрут от сервера top-szm-cctv01 до ПК", открытие доступов к IP 192.168.53.204 для Энергосферы, КНС, систем VGL Патруль).
|
||||||
|
- НАСТРОЙКА И ИДЕНТИФИКАЦИЯ КОММУТАТОРОВ (VLAN И ПОРТЫ): Настройка конкретных портов коммутаторов под определенные VLAN (настроить порты на 12Vlan у коммутатора в Лаборатории или ГДУ). Поиск, определение и выяснение наименований, IP-адресов и MAC-адресов коммутаторов для систем мониторинга Zabbix / Заббикс по номерам устройств (№128, №119, №37, №155).
|
||||||
|
- СЕТЕВАЯ ДИАГНОСТИКА И ПОТЕРЯ ПАКЕТОВ: Все инциденты каналов связи ("большие потери пакетов", "нет стабильного пинга") до серверов СКУД 172.16.61.102 со стороны Искитимцемента, видеосерверов ТЗП 10.1.8.29 или цифровых камер 10.30.22.*.
|
||||||
|
- ВНЕШНЯЯ ПОЧТА И БЛОКИРОВКИ: Блокировка, недоставка внешней почты (НЕ @sibcem.ru, домены @yandex.ru, @mrs24.ru, @law.ru), спам-фильтры, спам-карантин шлюза KSMG. Открытие/закрытие физического доступа к внешним сайтам, порталам, биржам, мессенджерам (YouTube, Telegram, ВК). Согласование AnyDesk/TeamViewer удаленного доступа для внешних подрядчиков (Айко, ЦИТ).
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ: Нумерованные или текстовые списки параметров ТС (инв. №, гос. номер, VIN, модель, топливо), заявки на создание "Рабочих Центров" цементных мельниц или списки кадровых данных сотрудников (ФИО, ИНН, регистрационные номера) для Аксапты к ИБ НЕ ОТНОСЯТСЯ ни при каких условиях — это СТРОГО группа СПРАВОЧНИКИ И НСИ!
|
||||||
|
|
||||||
|
6. БУХГАЛТЕРСКИЙ УЧЕТ И ПЛАТФОРМА 1С (1С) — ФИНАНСОВЫЕ, УЧЕТНЫЕ ОПЕРАЦИИ И ИНТЕГРАЦИЯ СБИС/ЭДО ВНУТРИ 1С:
|
||||||
|
Техническое сопровождение, исправление ошибок, программирование, настройка прав и доработка любых баз, конфигураций и модулей платформы 1С. Проводки, журналы, доп. обработки СБИС в 1С, зависания блоков ЭДО в 1С КТЦ/СБ/ГК, настройка ПО "Трактир", кассовые модули ККМ, интеграция с проектом IBM.
|
||||||
|
|
||||||
|
7. БОСС-КАДРОВИК — ОФИЦИАЛЬНЫЙ КАДРОВЫЙ УЧЕТ, ОРГСТРУКТУРА И ЛИЦЕНЗИИ СИСТЕМЫ:
|
||||||
|
Любые инциденты, ошибки, корректировки записей, отпусков, штатного расписания и официальных замещений внутри кадровой базы данных. Сбои дат официальных замещений сотрудников в цехах/складах, смена руководителей подразделений ХК (Леготин, Шарыкин), лицензии БК.
|
||||||
|
|
||||||
|
8. БИТРИКС — КОРПОРАТИВНЫЙ ВЕБ-ПОРТАЛ БИТРИКС24 И ВЕБ-ПРОЦЕССЫ:
|
||||||
|
Исключительно работа внутри корпоративного веб-портала Битрикс24, управление задачами портала, смарт-процессами, доступами к карточкам, кнопками веб-интерфейса ("Делегирование"), кадровыми веб-анкеты кандидатов и интеграциями с системой "Василёк".
|
||||||
|
|
||||||
|
9. СЭД DIRECTUM — СИСТЕМА ЭЛЕКТРОННОГО ДОКУМЕНТООБОРОТА (DIRECTUM / RX):
|
||||||
|
Любые инциденты, ошибки, доработки интерфейса, карточек, прав, журналов регистрации и логики бизнес-процессов внутри систем Directum / Directum RX. Модуль "Планы и отчеты", нумераторы приказов, маршруты согласования, закрытие командировок.
|
||||||
|
|
||||||
|
10. ТЕХПОДДЕРЖКА — ЛОКАЛЬНОЕ ОБСЛУЖИВАНИЕ РАБОЧИХ МЕСТ, КЛИЕНТСКОГО ПО, КРИПТОГРАФИИ И ПЕРИФЕРИИ:
|
||||||
|
Обслуживание физических и виртуальных рабочих мест пользователей, локальной оргтехники, источников бесперебойного питания (ИБП, бесперебойников, включая инциденты вида "пищит", "издает постоянный сигнал", "гудит", "не держит заряд"), базового офисного софта, надстроек и средств СКЗИ. Настройка Outlook под сертификаты подписей, Saby Плагин, КриптоПро CSP, VipNet, ошибки ведомственных сайтов (ЕИАС, ЧЗ). Замена картриджей принтеров, переустановка Windows, тормоза и аппаратные зависания физических ПК. Ярлыки, Wi-Fi, скачивание файлов по внешним ссылкам.
|
||||||
|
- ВАЖНОЕ ПРАВИЛО 1: Заявки на "дооснастить", "оснастить", "доукомплектовать", "выделить" или "установить на рабочее место" любое дополнительное ИТ-оборудование (второй монитор, новый монитор взамен отсутствующего, системный блок, клавиатуру, мышь, колонки, веб-камеру) относятся СТРОГО к Техподдержке!
|
||||||
|
- ВАЖНОЕ ПРАВИЛО 2: Любые локальные проблемы со связью на конкретном рабочем месте вида "пропал интернет", "нет сети на компьютере", "не работает интернет и стационарный телефон", "компьютер пишет, что сетевой кабель не подключен" относятся СТРОГО к Техподдержке (это уровень локальной проверки патч-кордов, сетевых карт и розеток инженером на месте)!
|
||||||
|
|
||||||
|
11. АХО — ХОЗЯЙСТВЕННЫЕ РАБОТЫ И БЫТОВОЕ ОБСЛУЖИВАНИЕ (НЕ ИТ-ЗАДАЧИ):
|
||||||
|
Мебель, снабжение питьевой водой, хозяйственные и строительные работы, ремонт помещений, мелкая силовая электрика зданий (замена силовой розетки здания электриком 220V), сантехника, доставка бумаги А4.
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 1: Поломки, гул, постоянные сигналы или писк источников бесперебойного питания (ИБП / бесперебойников) — это НЕ мелкая электрика АХО, это ИТ-оборудование рабочего места, такие заявки относятся СТРОГО к группе ТЕХПОДДЕРЖКА!
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 2: Если в заявке звучат слова "оснастить рабочее место", "дооснащение", "доукомплектовать", но объектом выступает компьютерная техника или периферия (мониторы, системные блоки, принтеры, клавиатуры) — это НЕ хозяйственное обустройство АХО, это СТРОГО группа ТЕХПОДДЕРЖКА!
|
||||||
|
|
||||||
|
12. АСУТП — ПРОМЫШЛЕННАЯ АВТОМАТИЗАЦИЯ ПРОИЗВОДСТВА:
|
||||||
|
Промышленные контроллеры, датчики, шкафы автоматизации цехов, контроллеры промышленных печей и холодильников.
|
||||||
|
|
||||||
|
13. АДМИНИСТРИРОВАНИЕ СИСТЕМ BI — АНАЛИТИЧЕСКАЯ ОТЧЕТНОСТЬ, КУБЫ ДАННЫХ И ПЛАНЫ (IBM / BI):
|
||||||
|
Любые задачи по администрированию, поддержке, настройке кубов, межкубовых процессов и аналитических отчетов внутри платформ IBM (IBM Planning Analytics / TM1 / IBM PA) и BI-систем холдинга (настроечные таблицы БДР, коды записи IBM вроде ISK-000022, процессы межкубовой загрузки Факт_УО, Факт_Носитель_ВидРаботУслуг, ошибки загрузки планов).
|
||||||
|
|
||||||
|
14. CRM — УПРАВЛЕНИЕ ВЗАИМООТНОШЕНИЯМИ С КЛИЕНТАМИ:
|
||||||
|
Техническая поддержка, настройка прав и ведение модуля Аксапты "СРМ_____Деловые Отношения" (обязательные поля "Адрес", "Район Сбыта", блок маркетинг-кампаний по ТЗ).
|
||||||
|
|
||||||
|
15. ТЕЛЕКОМ И СООБЩЕНИЯ — СЕТЕВАЯ КАБЕЛЬНАЯ, МАГИСТРАЛЬНАЯ И ТЕЛЕФОННАЯ ИНФРАСТРУКТУРА:
|
||||||
|
ВОЛС, оптика, витая пара, байпасные сегменты кольцевых ВОЛС цехов. Монтаж и прошивка Eltex, D-Link, Cisco. Стационарные телефоны (т.X-XX), CallerID / АОН, треск в трубке. Физический монтаж и организация новых комбо-мест (прокладка кабельных линий под телефон + ПК с нуля).
|
||||||
|
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ: Рядовые инциденты на уже существующих рабочих местах вида "пропал интернет/сеть на компьютере", "не работает локальная сеть и телефон на столе", "нет связи на ПК" к магистральному Телекому НЕ ОТНОСЯТСЯ — это задачи локальной диагностики рабочих мест, направлять СТРОГО в ТЕХПОДДЕРЖКУ!
|
||||||
|
|
||||||
|
16. ОПС, ВИДЕОНАБЛЮДЕНИЕ, СКУД, СМТ: Пожарная/охранная сигнализация (приборы С2000М, ДИП, ШС), Omnicomm, ДУТ, датчики LLS, шлагбаумы, турникеты, ремонт самих камер.
|
||||||
|
|
||||||
|
17. ПРОГРАММИСТЫ КТЦ — ЖЕЛЕЗНОДОРОЖНАЯ ЛОГИСТИКА И ПО КТЦ:
|
||||||
|
Специализированное ПО КТЦ (Экспедитор, VICtory, Планирование, Договорная работа, Дислокация, АБД ПВ, БиКФОР, МИКС). Ремонтные справки КТЦ, учет суток полувагонов, простои ПС КТЦ, Журнал заявок СНГ КТЦ.
|
||||||
|
|
||||||
|
Выведи точное название группы из справочника:"""
|
||||||
|
|
||||||
|
# Вычищаем скрытые управляющие символы
|
||||||
|
clean_content = "".join(ch for ch in full_text if ch.isprintable() or ch in "\n\r\t")
|
||||||
|
|
||||||
|
user_prompt = f"""=== ТЕКСТ ЗАЯВКИ ДЛЯ АНАЛИЗА ===
|
||||||
|
{clean_content}
|
||||||
|
=== КОНЕЦ ТЕКСТА ЗАЯВКИ ===
|
||||||
|
Выведи точное название группы на русском языке:""".strip()
|
||||||
|
|
||||||
|
# Сборка GBNF-грамматики для ограничения выбора Gemma на бэкенде
|
||||||
|
grammar_items = [f'"{g}"' for g in VALID_GROUPS] + ['"Не назначено"']
|
||||||
|
gbnf_grammar = f'root ::= [ \t\n\r]* ({" | ".join(grammar_items)}) [ \t\n\r]*'
|
||||||
|
|
||||||
|
try:
|
||||||
|
chat_url = OLLAMA_URL.replace("/api/generate", "/v1/chat/completions")
|
||||||
|
payload = {
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt}
|
||||||
|
],
|
||||||
|
"temperature": 0.0,
|
||||||
|
"max_tokens": OLLAMA_MAX_TOKENS,
|
||||||
|
"stream": False,
|
||||||
|
"grammar": gbnf_grammar,
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"enable_thinking": False # КРИТИЧЕСКИ: отключаем рассуждения, чтобы не ломать GBNF
|
||||||
|
},
|
||||||
|
"logprobs": True,
|
||||||
|
"top_logprobs": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(chat_url, json=payload, timeout=OLLAMA_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
try:
|
||||||
|
err_msg = response.json().get("error", {}).get("message", response.text)
|
||||||
|
except:
|
||||||
|
err_msg = response.text
|
||||||
|
return "Не назначено", 0.0, f"Ошибка Ollama ({response.status_code}): {err_msg}"
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if "choices" not in data or not data["choices"]:
|
||||||
|
return "Не назначено", 0.0, "Пустой ответ"
|
||||||
|
|
||||||
|
raw_answer = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Очистка и обработка ответа
|
||||||
|
clean_answer = re.sub(r"```[a-zA-Z]*\n?", "", raw_answer).strip()
|
||||||
|
lines = [line.strip(" .' Blunt'\"\n\r`*") for line in clean_answer.split("\n") if line.strip()]
|
||||||
|
|
||||||
|
category = lines[-1] if lines else "Не назначено"
|
||||||
|
|
||||||
|
confidence = 0.99
|
||||||
|
try:
|
||||||
|
if "logprobs" in data["choices"][0] and data["choices"][0]["logprobs"] is not None:
|
||||||
|
content_list = data["choices"][0]["logprobs"]["content"]
|
||||||
|
valid_logprobs = [t["logprob"] for t in content_list if t.get("token") and t["token"].strip()]
|
||||||
|
if not valid_logprobs: valid_logprobs = [t["logprob"] for t in content_list]
|
||||||
|
if valid_logprobs:
|
||||||
|
avg_logprob = sum(valid_logprobs) / len(valid_logprobs)
|
||||||
|
confidence = math.exp(avg_logprob)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if category not in VALID_GROUPS and category != "Не назначено":
|
||||||
|
matched = next((g for g in VALID_GROUPS if g.lower() in category.lower()), "Не назначено")
|
||||||
|
category = matched
|
||||||
|
|
||||||
|
return category, float(confidence), raw_answer.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return "Не назначено", 0.0, str(e)
|
||||||
|
|
||||||
|
def classify_ticket(text, ticket_id=None):
|
||||||
|
"""Двухэтапная классификация заявки (Эмбеддинги + Реранкер) с возвратом метрик доминации"""
|
||||||
|
global qdrant
|
||||||
|
log_to_file(" ➤ [Qdrant] Запрос эмбеддингов для текста...", ticket_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"model": EMBEDDING_MODEL_NAME,
|
||||||
|
"input_type": "query",
|
||||||
|
"texts": [text]
|
||||||
|
}
|
||||||
|
headers_api = {"Content-Type": "application/json"}
|
||||||
|
if EMBEDDING_API_TOKEN:
|
||||||
|
headers_api["X-Embedding-Token"] = EMBEDDING_API_TOKEN
|
||||||
|
|
||||||
|
api_resp = requests.post(EMBEDDING_API_URL, json=payload, headers=headers_api, verify=False)
|
||||||
|
api_resp.raise_for_status()
|
||||||
|
vector = api_resp.json()["embeddings"][0]
|
||||||
|
except Exception as e:
|
||||||
|
log_to_file(f" ❌ [Qdrant] Ошибка получения вектора от Embedding API: {e}", ticket_id)
|
||||||
|
return "Не определено", 0, [], True, [], {"pure_qdrant_choice": "Не определено", "q_variance": 0, "q_unique_groups": 0, "q_max_score": 0, "q_dominance": 0}
|
||||||
|
|
||||||
|
log_to_file(f" ➤ [Qdrant] Выборка топ-{QDRANT_SEARCH_LIMIT} из векторной БД...", ticket_id)
|
||||||
|
result = qdrant.search(collection_name=QDRANT_COLLECTION, query_vector=vector, limit=QDRANT_SEARCH_LIMIT)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return "Не определено", 0, [], True, [], {"pure_qdrant_choice": "Не определено", "q_variance": 0, "q_unique_groups": 0, "q_max_score": 0, "q_dominance": 0}
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# ЭТАП 1: РАСЧЕТ ВЫБОРА ЧИСТОГО QDRANT (ДО СЛУЖБЫ РЕРАНКИНГА)
|
||||||
|
# =================================================================
|
||||||
|
pure_scores = defaultdict(float)
|
||||||
|
pure_counts = defaultdict(int)
|
||||||
|
for p in result:
|
||||||
|
grp = p.payload.get("group", "Unknown")
|
||||||
|
group_clean = "Техподдержка" if grp.startswith("Техподдержка") else grp
|
||||||
|
|
||||||
|
if pure_counts[group_clean] < QDRANT_MAX_VOTES_PER_GROUP:
|
||||||
|
pure_scores[group_clean] += p.score
|
||||||
|
pure_counts[group_clean] += 1
|
||||||
|
|
||||||
|
pure_weighted = {g: (pure_scores[g] / pure_counts[g]) * math.sqrt(pure_counts[g]) for g in pure_scores}
|
||||||
|
pure_qdrant_predicted = max(pure_weighted, key=pure_weighted.get) if pure_weighted else "Не определено"
|
||||||
|
|
||||||
|
qdrant_scores = [p.score for p in result]
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# ЭТАП 2: СЛУЖБА ПЕРЕРАНЖИРОВАНИЯ CROSS-ENCODER + БЛЕНДИНГ СКОРОВ
|
||||||
|
# =================================================================
|
||||||
|
documents = [p.payload.get("text", "Текст отсутствует").strip()[:RERANKER_MAX_DOC_CHARS] for p in result]
|
||||||
|
|
||||||
|
rerank_payload = {
|
||||||
|
"model": RERANKER_MODEL_NAME,
|
||||||
|
"query": text,
|
||||||
|
"documents": documents,
|
||||||
|
"top_n": len(documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
log_to_file(f" ➤ [Reranker] Старт пересчета весов (Таймаут: {RERANKER_TIMEOUT}с, URL: {RERANKER_URL})...", ticket_id)
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
rerank_resp = requests.post(RERANKER_URL, json=rerank_payload, timeout=RERANKER_TIMEOUT)
|
||||||
|
rerank_resp.raise_for_status()
|
||||||
|
rerank_data = rerank_resp.json()
|
||||||
|
|
||||||
|
if "results" in rerank_data:
|
||||||
|
for item in rerank_data["results"]:
|
||||||
|
idx = item["index"]
|
||||||
|
relevance_score = item["relevance_score"]
|
||||||
|
|
||||||
|
r_score = 1.0 / (1.0 + math.exp(-relevance_score))
|
||||||
|
q_score = qdrant_scores[idx]
|
||||||
|
|
||||||
|
result[idx].score = (RERANKER_WEIGHT * r_score) + ((1.0 - RERANKER_WEIGHT) * q_score)
|
||||||
|
|
||||||
|
elapsed = round(time.time() - start_time, 2)
|
||||||
|
log_to_file(f" ➤ [Reranker] Пересчет успешно завершен за {elapsed} сек.", ticket_id)
|
||||||
|
except Exception as e:
|
||||||
|
log_to_file(f" ⚠️ [Reranker] Сбой службы реранкера: {e}. Работаем на чистых весах Qdrant без блендинга.", ticket_id)
|
||||||
|
|
||||||
|
result = sorted(result, key=lambda x: x.score, reverse=True)
|
||||||
|
|
||||||
|
scores, counts, top_groups = defaultdict(float), defaultdict(int), []
|
||||||
|
for p in result:
|
||||||
|
group = p.payload.get("group", "Unknown")
|
||||||
|
txt = p.payload.get("text", "Текст отсутствует").strip()
|
||||||
|
q_group = "Техподдержка" if group.startswith("Техподдержка") else group
|
||||||
|
top_groups.append((q_group, p.score, txt))
|
||||||
|
|
||||||
|
if counts[q_group] < QDRANT_MAX_VOTES_PER_GROUP:
|
||||||
|
scores[q_group] += p.score
|
||||||
|
counts[q_group] += 1
|
||||||
|
|
||||||
|
weighted = {g: (scores[g] / counts[g]) * math.sqrt(counts[g]) for g in scores}
|
||||||
|
if not weighted:
|
||||||
|
return "Не определено", 0, [], True, [], {"pure_qdrant_choice": pure_qdrant_predicted, "q_variance": 0, "q_unique_groups": 0, "q_max_score": 0, "q_dominance": 0}
|
||||||
|
|
||||||
|
predicted = max(weighted, key=weighted.get)
|
||||||
|
confidence = calculate_confidence(weighted)
|
||||||
|
|
||||||
|
top_groups_sorted = sorted(top_groups, key=lambda x: x[1], reverse=True)[:LOG_TOP_GROUPS_LIMIT]
|
||||||
|
|
||||||
|
examples_for_log = []
|
||||||
|
for grp, grp_score, txt in top_groups_sorted:
|
||||||
|
clean_txt = txt.replace('\n', ' ')
|
||||||
|
if len(clean_txt) > LOG_EXAMPLE_MAX_CHARS:
|
||||||
|
clean_txt = clean_txt[:(LOG_EXAMPLE_MAX_CHARS - 3)] + "..."
|
||||||
|
examples_for_log.append(f" - [{round(grp_score, 4)}] {grp} ({clean_txt})")
|
||||||
|
|
||||||
|
scores_list = [p.score for p in result]
|
||||||
|
variance = max(scores_list) - min(scores_list) if scores_list else 0
|
||||||
|
max_absolute_score = max(scores_list) if scores_list else 0
|
||||||
|
|
||||||
|
groups = [p.payload.get("group", "Unknown") for p in result]
|
||||||
|
clean_groups = ["Техподдержка" if g.startswith("Техподдержка") else g for g in groups]
|
||||||
|
unique_groups = set(clean_groups)
|
||||||
|
|
||||||
|
top_k_groups = clean_groups[:DOMINANCE_TOP_K]
|
||||||
|
counter = Counter(top_k_groups)
|
||||||
|
dominance = 0.0
|
||||||
|
if counter:
|
||||||
|
top_group, top_count = counter.most_common(1)[0]
|
||||||
|
dominance = top_count / len(top_k_groups)
|
||||||
|
|
||||||
|
is_garbage = False
|
||||||
|
if confidence < GARBAGE_CONFIDENCE_THRESHOLD: is_garbage = True
|
||||||
|
elif len(unique_groups) > GARBAGE_UNIQUE_GROUPS_LIMIT: is_garbage = True
|
||||||
|
elif variance < GARBAGE_VARIANCE_THRESHOLD and len(unique_groups) > GARBAGE_MIN_GROUPS_FOR_VARIANCE: is_garbage = True
|
||||||
|
|
||||||
|
if max_absolute_score > EXACT_MATCH_SCORE_THRESHOLD: is_garbage = False
|
||||||
|
elif dominance >= GARBAGE_BYPASS_DOMINANCE: is_garbage = False
|
||||||
|
|
||||||
|
debug_info = {
|
||||||
|
"pure_qdrant_choice": pure_qdrant_predicted,
|
||||||
|
"q_variance": round(variance, 4),
|
||||||
|
"q_unique_groups": len(unique_groups),
|
||||||
|
"q_max_score": round(max_absolute_score, 4),
|
||||||
|
"q_dominance": round(dominance, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return predicted, confidence, top_groups_sorted, is_garbage, examples_for_log, debug_info
|
||||||
|
|
||||||
|
def get_single_ticket(ticket_id):
|
||||||
|
try:
|
||||||
|
url = f"{ENDPOINT}/{ticket_id}"
|
||||||
|
response = requests.get(url, headers=api_headers, verify=False)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
request_data = data.get("request")
|
||||||
|
if not request_data: return None
|
||||||
|
return request_data
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# ГЛАВНЫЙ ПОТОК ОБРАБОТКИ ЗАЯВКИ
|
||||||
|
# ------------------------------------------------
|
||||||
|
def process_ticket_logic(req_id):
|
||||||
|
decision_log_lines = []
|
||||||
|
|
||||||
|
def t_log(msg):
|
||||||
|
decision_log_lines.append(msg)
|
||||||
|
log_to_file(msg, req_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
log_to_file(f"\n\n{'='*60}\nЗАПУСК АНАЛИЗА ЗАЯВКИ ПО WEBHOOK: {run_time}\n{'='*60}", req_id)
|
||||||
|
|
||||||
|
req = get_single_ticket(req_id)
|
||||||
|
if not req:
|
||||||
|
log_to_file(f"❌ Заявка {req_id} не найдена в SD.", req_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_subject = req.get("subject", "Без темы").strip()
|
||||||
|
raw_description = req.get("description") or ""
|
||||||
|
if isinstance(raw_description, dict): raw_description = raw_description.get("content", "")
|
||||||
|
|
||||||
|
caller_phone = None
|
||||||
|
if raw_description:
|
||||||
|
temp_text = re.sub(r'<.*?>', ' ', str(raw_description))
|
||||||
|
phone_match = re.search(r'Номер звонящего:\s*(\d+)', temp_text, re.IGNORECASE)
|
||||||
|
if phone_match: caller_phone = phone_match.group(1)
|
||||||
|
|
||||||
|
clean_description = ""
|
||||||
|
if raw_description:
|
||||||
|
clean_html = re.compile('<.*?>')
|
||||||
|
no_html_text = re.sub(clean_html, ' ', str(raw_description))
|
||||||
|
clean_description = html.unescape(no_html_text).replace(' ', ' ').strip()
|
||||||
|
clean_description = re.sub(r'\s+', ' ', clean_description)
|
||||||
|
clean_description = clean_description.replace("Эта заявка создана автоматически ИИ на основе аудиозвонка. Оригинальная запись прикреплена к заявке.", "")
|
||||||
|
clean_description = re.sub(r'Заявка создана от контакта:.*', '', clean_description, flags=re.IGNORECASE)
|
||||||
|
clean_description = re.sub(r'Номер звонящего:.*', '', clean_description, flags=re.IGNORECASE)
|
||||||
|
clean_description = re.sub(r'ВНИМАНИЕ: Контакт не был найден в AD\.', '', clean_description, flags=re.IGNORECASE)
|
||||||
|
clean_description = clean_description.strip()
|
||||||
|
|
||||||
|
subject = html.unescape(raw_subject)
|
||||||
|
# Добавлен жесткий лимит среза текста [:1000] для полной синхронизации с анализатором
|
||||||
|
text_to_classify = f"ТЕМА: {subject}\nОПИСАНИЕ: {clean_description[:1000]}" if clean_description else f"ТЕМА: {subject}"
|
||||||
|
|
||||||
|
requester = req.get("requester", {})
|
||||||
|
if requester.get("id") and not requester.get("email_id"):
|
||||||
|
for ep in ["users", "requesters", "technicians"]:
|
||||||
|
try:
|
||||||
|
u_url = f"{SERVER_URL}/api/v3/{ep}/{requester.get('id')}"
|
||||||
|
u_resp = requests.get(u_url, headers=api_headers, verify=False)
|
||||||
|
if u_resp.status_code == 200:
|
||||||
|
key = ep[:-1] if ep != "technicians" else "technician"
|
||||||
|
u_data = u_resp.json().get(key, {})
|
||||||
|
if u_data and u_data.get("email_id"):
|
||||||
|
requester.update(u_data)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
requester_name = requester.get("name", "Неизвестный пользователь")
|
||||||
|
udf_fields = req.get("udf_fields") or {}
|
||||||
|
ticket_city = udf_fields.get("udf_pick_301")
|
||||||
|
|
||||||
|
group_obj = req.get("group")
|
||||||
|
current_group = group_obj.get("name", "") if isinstance(group_obj, dict) else ""
|
||||||
|
tech_obj = req.get("technician")
|
||||||
|
current_tech = tech_obj.get("name", "") if isinstance(tech_obj, dict) else ""
|
||||||
|
|
||||||
|
t_log(f"\nНОВАЯ ЗАЯВКА ID: {req_id}")
|
||||||
|
t_log(f"ИСХОДНЫЙ ТЕКСТ:\n{text_to_classify}\n{'-'*40}")
|
||||||
|
|
||||||
|
ai_was_used = False
|
||||||
|
|
||||||
|
# --- ШАГ 1 ---
|
||||||
|
t_log("=== ШАГ 1. МАРШРУТИЗАЦИЯ И AD ===")
|
||||||
|
user_info = find_user_in_ad(requester, caller_phone, req_id)
|
||||||
|
|
||||||
|
ad_city = user_info["city"] if user_info else None
|
||||||
|
company = user_info["company"] if user_info else None
|
||||||
|
ad_path = user_info["ad_path"] if user_info else None
|
||||||
|
manager_email = user_info["manager_email"] if user_info else None
|
||||||
|
info_source = user_info["source"] if user_info else "Не определен"
|
||||||
|
|
||||||
|
prefix_used = False
|
||||||
|
if caller_phone and not user_info:
|
||||||
|
for prefix in sorted(PREFIX_ROUTING.keys(), key=len, reverse=True):
|
||||||
|
if caller_phone.startswith(prefix):
|
||||||
|
ad_city = PREFIX_ROUTING[prefix].get("city")
|
||||||
|
company = PREFIX_ROUTING[prefix].get("company")
|
||||||
|
info_source = f"Таблица префиксов (совпадение '{prefix}')"
|
||||||
|
prefix_used = True
|
||||||
|
break
|
||||||
|
|
||||||
|
city = ticket_city if ticket_city else ad_city
|
||||||
|
|
||||||
|
t_log(f"Источник данных: {info_source}")
|
||||||
|
t_log(f"Итоговый город: {city or 'Неизвестен'} " + ("(Взят из UDF заявки)" if ticket_city and ticket_city != ad_city else ""))
|
||||||
|
t_log(f"Организация: {company or 'Не определена'}")
|
||||||
|
if ad_path: t_log(f"OU в AD: {ad_path}")
|
||||||
|
if manager_email: t_log(f"Руководитель: {manager_email}")
|
||||||
|
|
||||||
|
# --- ШАГ 2 ---
|
||||||
|
if current_group or current_tech:
|
||||||
|
t_log("\n=== ШАГ 2. БАЙПАС ИИ (ШАБЛОН ПРИВИЛО СД) ===")
|
||||||
|
if current_group: t_log(f"ℹ️ Группа уже назначена: '{current_group}'.")
|
||||||
|
if current_tech: t_log(f"ℹ️ Специалист уже назначен: '{current_tech}'.")
|
||||||
|
t_log("Нейросети отключены. Заявка не требует переназначения.")
|
||||||
|
final_category = current_group
|
||||||
|
else:
|
||||||
|
ai_was_used = True
|
||||||
|
|
||||||
|
t_log("\n=== ШАГ 2. ВЕКТОРНАЯ БАЗА (Qdrant + Выделенный BGE-Reranker) ===")
|
||||||
|
q_cat, q_conf, top_groups, is_garbage, examples_for_log, q_debug = classify_ticket(text_to_classify, req_id)
|
||||||
|
|
||||||
|
t_log("Топ-5 похожих заявок из истории (после пересчета весов реранкером):")
|
||||||
|
for line in examples_for_log:
|
||||||
|
t_log(line)
|
||||||
|
|
||||||
|
t_log(f"\nПредварительный выбор Qdrant: {q_cat}")
|
||||||
|
t_log(f"Уверенность (Confidence): {round(q_conf, 4)}")
|
||||||
|
t_log(f"Метка мусора/сложной заявки: {'ДА' if is_garbage else 'НЕТ'}\n")
|
||||||
|
|
||||||
|
ai_category = q_cat
|
||||||
|
max_absolute_score = q_debug.get("q_max_score", 0.0)
|
||||||
|
|
||||||
|
# КРИТИЧЕСКИЙ ПРЕДОХРАНИТЕЛЬ: Если лучший скор ниже порога из конфига, запрещаем сквозной байпас
|
||||||
|
force_ollama_by_score = max_absolute_score < FORCE_OLLAMA_SCORE_THRESHOLD
|
||||||
|
|
||||||
|
t_log("=== ШАГ 3. УМНАЯ ВЕРИФИКАЦИЯ (LLM Gemma) ===")
|
||||||
|
|
||||||
|
if ENABLE_OLLAMA and (is_garbage or q_conf < CONFIDENCE_THRESHOLD or force_ollama_by_score):
|
||||||
|
if q_debug.get("q_dominance", 0.0) >= OLLAMA_BYPASS_DOMINANCE and q_conf >= OLLAMA_BYPASS_MIN_CONFIDENCE and not force_ollama_by_score:
|
||||||
|
t_log(f"Пропущено. Доминирующая группа векторного поиска признана достоверной.\n")
|
||||||
|
else:
|
||||||
|
if force_ollama_by_score and not (is_garbage or q_conf < CONFIDENCE_THRESHOLD):
|
||||||
|
t_log(f"Причина вызова: Абсолютный скор совпадения слишком мал ({round(max_absolute_score, 4)} < {FORCE_OLLAMA_SCORE_THRESHOLD}).")
|
||||||
|
elif is_garbage:
|
||||||
|
t_log("Причина вызова: Qdrant пометил заявку как возможный мусор/аномалию.")
|
||||||
|
else:
|
||||||
|
t_log(f"Причина вызова: Низкая уверенность Qdrant ({round(q_conf, 4)} < {CONFIDENCE_THRESHOLD}).")
|
||||||
|
|
||||||
|
t_log(f" ➤ Запуск генеративной проверки (URL: {OLLAMA_URL})...")
|
||||||
|
ai_cat, ai_conf, ai_raw = classify_with_ollama(text_to_classify, req_id)
|
||||||
|
t_log(f"🤖 Ответ ИИ: {ai_cat} (Сырой текст: '{ai_raw}', Уверенность: {ai_conf:.4f})")
|
||||||
|
|
||||||
|
if "Ошибка Ollama" not in ai_raw:
|
||||||
|
def norm_cmp(g): return "техподдержка" if g.lower().startswith("техподдержка") else g.lower()
|
||||||
|
|
||||||
|
if ai_conf >= OLLAMA_ACCEPTANCE_THRESHOLD:
|
||||||
|
ai_category = ai_cat
|
||||||
|
t_log(f" ✔ Коридор 1: Безусловное доверие ИИ (Скор >= {OLLAMA_ACCEPTANCE_THRESHOLD}). Назначено: {ai_category}\n")
|
||||||
|
elif norm_cmp(ai_cat) == norm_cmp(q_cat):
|
||||||
|
ai_category = ai_cat
|
||||||
|
t_log(f" ✔ Коридор 2: Абсолютный консенсус систем (Вектор и ИИ совпали). Назначено: {ai_category}\n")
|
||||||
|
elif ai_conf >= 0.30 and q_conf < 0.50:
|
||||||
|
ai_category = ai_cat
|
||||||
|
t_log(f" ✔ Коридор 3: Умный перехват. Вектор дезориентирован (Скор Qdrant < 0.50). Доверяем ИИ: {ai_category}\n")
|
||||||
|
elif ai_conf >= 0.50 and q_conf < 0.85:
|
||||||
|
ai_category = ai_cat
|
||||||
|
t_log(f" ✔ Коридор 4: Перехват ложной уверенности вектора (Бухгалтерский камуфляж). Доверяем ИИ: {ai_category}\n")
|
||||||
|
else:
|
||||||
|
ai_category = "Не назначено"
|
||||||
|
t_log(" ⚠ Коридор 5: Архитектурный конфликт систем! Сбрасываем заявку в 'Не назначено' для диспетчера.\n")
|
||||||
|
else:
|
||||||
|
ai_category = "Не назначено"
|
||||||
|
t_log(" ⚠ Критический сбой: Ollama недоступна. Сброс в 'Не назначено'.\n")
|
||||||
|
else:
|
||||||
|
t_log("Пропущено. Уверенность Qdrant достаточно высока.\n")
|
||||||
|
|
||||||
|
# --- ШАГ 4 ---
|
||||||
|
t_log("=== ШАГ 4. РЕГИОНАЛЬНЫЙ ФИЛЬТР И НАЗНАЧЕНИЕ ===")
|
||||||
|
final_category, routing_reason = resolve_support_group(ai_category, city, company, ad_path)
|
||||||
|
|
||||||
|
t_log(f"Выбор ИИ-комплекса: {ai_category}")
|
||||||
|
t_log(f"Логика регионального назначения: {routing_reason}")
|
||||||
|
|
||||||
|
if final_category not in ["Не является заявкой", "Не определено", "Не назначено"]:
|
||||||
|
if final_category in ALLOWED_ASSIGNMENT_GROUPS:
|
||||||
|
t_log(f"✔ Группа '{final_category}' разрешена для автоназначения. Выполняем...")
|
||||||
|
assign_group_to_ticket(req_id, final_category)
|
||||||
|
else:
|
||||||
|
t_log(f"⚠ ВНИМАНИЕ: Группа '{final_category}' валидна, но не включена in ALLOWED_ASSIGNMENT_GROUPS. Автоназначение запрещено.")
|
||||||
|
final_category = "Не назначено"
|
||||||
|
else:
|
||||||
|
t_log(f"🗑️ Мусорная или конфликтная заявка {req_id}. Оставлена без группы.")
|
||||||
|
|
||||||
|
t_log(f"\n>>> ФИНАЛЬНАЯ КАТЕГОРИЯ: {final_category} <<<")
|
||||||
|
|
||||||
|
# --- ШАГ 5 ---
|
||||||
|
if ENABLE_AUTO_APPROVAL and final_category not in ["Не является заявкой", "Не определено", "Не назначено"]:
|
||||||
|
t_log("\n=== ШАГ 5. АВТОСОГЛАСОВАНИЕ ===")
|
||||||
|
cat_obj = req.get("category")
|
||||||
|
subcat_obj = req.get("subcategory")
|
||||||
|
cat_name = cat_obj.get("name", "") if isinstance(cat_obj, dict) else ""
|
||||||
|
subcat_name = subcat_obj.get("name", "") if isinstance(subcat_obj, dict) else ""
|
||||||
|
|
||||||
|
is_city_ok = False
|
||||||
|
if city:
|
||||||
|
for c in APPROVAL_CITIES:
|
||||||
|
if c.lower() in city.lower():
|
||||||
|
is_city_ok = True
|
||||||
|
break
|
||||||
|
|
||||||
|
is_group_ok = final_category in APPROVAL_GROUPS
|
||||||
|
is_cat_ok = False
|
||||||
|
if cat_name in APPROVAL_CATEGORIES:
|
||||||
|
allowed_subcats = APPROVAL_CATEGORIES[cat_name]
|
||||||
|
if "*" in allowed_subcats or subcat_name in allowed_subcats:
|
||||||
|
is_cat_ok = True
|
||||||
|
|
||||||
|
if is_city_ok and (is_group_ok or is_cat_ok):
|
||||||
|
if manager_email:
|
||||||
|
t_log(f"✔ Условия совпали. Запуск процесса согласования для {manager_email}...")
|
||||||
|
ai_was_used = True
|
||||||
|
|
||||||
|
share_success, share_log = share_ticket_for_approval(req_id, manager_email)
|
||||||
|
t_log(share_log)
|
||||||
|
|
||||||
|
cc_success, cc_log = add_email_to_notify(req_id, manager_email)
|
||||||
|
t_log(cc_log)
|
||||||
|
|
||||||
|
success, step_detail = trigger_manager_approval(req_id, manager_email)
|
||||||
|
t_log(step_detail)
|
||||||
|
else:
|
||||||
|
t_log("⚠️ Согласование невозможно: у пользователя не указан руководитель в AD.")
|
||||||
|
else:
|
||||||
|
if ai_was_used:
|
||||||
|
t_log("\n=== ШАГ 5. АВТОСОГЛАСОВАНИЕ ===")
|
||||||
|
t_log("⏭ Пропуск: Условия автосогласования не выполнены.")
|
||||||
|
|
||||||
|
# Отправляем письмо с накопленным логом
|
||||||
|
if ai_was_used:
|
||||||
|
full_log_str = "\n".join(decision_log_lines)
|
||||||
|
send_email_report(req_id, requester_name, subject, text_to_classify, final_category, full_log_str)
|
||||||
|
|
||||||
|
print(f"🎉 Обработка заявки {req_id} завершена!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
err_trace = traceback.format_exc()
|
||||||
|
crash_msg = f"\n🔥 КРИТИЧЕСКАЯ ОШИБКА В ПОТОКЕ (Заявка {req_id}):\n{err_trace}"
|
||||||
|
log_to_file(crash_msg, req_id)
|
||||||
|
print(crash_msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# МАРШРУТЫ FLASK
|
||||||
|
# ------------------------------------------------
|
||||||
|
@app.route('/webhook', methods=['POST'])
|
||||||
|
def webhook_handler():
|
||||||
|
data = request.get_json()
|
||||||
|
if not data: return jsonify({"status": "error", "message": "Invalid JSON"}), 400
|
||||||
|
|
||||||
|
ticket_id = data.get("ticket_id")
|
||||||
|
if ticket_id:
|
||||||
|
threading.Thread(target=process_ticket_logic, args=(ticket_id,)).start()
|
||||||
|
return jsonify({"status": "received"}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({"status": "error", "message": "No ticket_id provided"}), 400
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# ЗАПУСК СЕРВЕРА
|
||||||
|
# ------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
global qdrant
|
||||||
|
qdrant = QdrantClient(QDRANT_HOST, port=QDRANT_PORT)
|
||||||
|
print(f"🚀 ИИ-сервис запущен и готов к приему вебхуков на порту {WEBHOOK_PORT}!")
|
||||||
|
app.run(host='0.0.0.0', port=WEBHOOK_PORT)
|
||||||
Reference in New Issue
Block a user