# /opt/GigaAM/webhook.py
import requests
import json
import urllib3
import os
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", "department"]) 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 department = user.department.value if "department" in user and user.department.value else None return {"city": city, "company": company, "department": department, "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 "Не определен" user_department = user_info["department"] if user_info else None 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 # Проверка: отдел пользователя в списке исключений is_excluded = False if user_department and APPROVAL_EXCLUDED_DEPARTMENTS: for dept in APPROVAL_EXCLUDED_DEPARTMENTS: if dept.lower() in user_department.lower() or user_department.lower() in dept.lower(): is_excluded = True t_log(f" ⚠️ Отдел '{user_department}' в списке исключений — автосогласование отменено") break if is_excluded: t_log(" ⏭ Пропуск: пользователь в исключённом отделе.") else: 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)