From 4ad93cfdd5fcb2b3058ab8f168c987738595f215 Mon Sep 17 00:00:00 2001 From: dddennnisss Date: Tue, 14 Jul 2026 17:01:24 +0700 Subject: [PATCH] revert cf1d526304dbced937a9461e43c5d009a5c485b7 revert Clean main branch --- README.md | 3 + config/.env | 9 + config/__pycache__/config.cpython-311.pyc | Bin 0 -> 8877 bytes config/config.py | 303 +++++++ sd_dispatcher.py | 950 ++++++++++++++++++++++ 5 files changed, 1265 insertions(+) create mode 100644 README.md create mode 100644 config/.env create mode 100644 config/__pycache__/config.cpython-311.pyc create mode 100644 config/config.py create mode 100644 sd_dispatcher.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3f25d1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# sd_dispatcher + +Service Desk Dispatcher — automatic ticket routing \ No newline at end of file diff --git a/config/.env b/config/.env new file mode 100644 index 0000000..a180109 --- /dev/null +++ b/config/.env @@ -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 + diff --git a/config/__pycache__/config.cpython-311.pyc b/config/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e85e361825b7572573d0a1409bfaec0db050fc93 GIT binary patch literal 8877 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 literal 0 HcmV?d00001 diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..ca8bec4 --- /dev/null +++ b/config/config.py @@ -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(os.path.join(BASE_DIR, '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 = os.path.join(BASE_DIR, '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 = os.path.join(BASE_DIR, 'audio') +JSON_DIR = os.path.join(BASE_DIR, '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": "Топки"} +} diff --git a/sd_dispatcher.py b/sd_dispatcher.py new file mode 100644 index 0000000..acaefff --- /dev/null +++ b/sd_dispatcher.py @@ -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(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +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", "
") + formatted_decision_log = decision_log.replace("\n", "
") + + html_body = f""" + + +

От кого: {requester_name}

+

Тема: {subject}

+

Ссылка: {ticket_link}

+

Текст заявки:
{formatted_ticket_text}

+
+

ВЫБРАННАЯ ГРУППА: {final_category}

+
+

КАК ПРИНИМАЛОСЬ РЕШЕНИЕ (Лог ИИ):

+

+ {formatted_decision_log} +

+ + + """ + + 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)