4ad93cfdd5
revert Clean main branch
951 lines
72 KiB
Python
951 lines
72 KiB
Python
# /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", "<br>")
|
||
formatted_decision_log = decision_log.replace("\n", "<br>")
|
||
|
||
html_body = f"""
|
||
<html>
|
||
<body style="font-family: Arial, sans-serif; font-size: 14px; color: #333; line-height: 1.5;">
|
||
<p><b>От кого:</b> {requester_name}</p>
|
||
<p><b>Тема:</b> {subject}</p>
|
||
<p><b>Ссылка:</b> <a href="{ticket_link}">{ticket_link}</a></p>
|
||
<p><b>Текст заявки:</b><br>{formatted_ticket_text}</p>
|
||
<hr style="border: 1px solid #ccc; margin: 20px 0;">
|
||
<p><b style="font-size: 16px;">ВЫБРАННАЯ ГРУППА: <span style="color: {group_color};">{final_category}</span></b></p>
|
||
<hr style="border: 1px solid #ccc; margin: 20px 0;">
|
||
<p><b>КАК ПРИНИМАЛОСЬ РЕШЕНИЕ (Лог ИИ):</b></p>
|
||
<p style="font-family: Consolas, 'Courier New', monospace; font-size: 13px; background-color: #f4f4f4; padding: 15px; border-radius: 5px; color: #333;">
|
||
{formatted_decision_log}
|
||
</p>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
|
||
|
||
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
|
||
server.send_message(msg)
|
||
server.quit()
|
||
log_to_file(f"📧 Простой HTML-отчет по заявке {ticket_id} успешно отправлен ({len(recipients)} получателей).", ticket_id)
|
||
except Exception as e:
|
||
log_to_file(f"❌ Ошибка отправки почты для заявки {ticket_id}: {e}", ticket_id)
|
||
|
||
def assign_group_to_ticket(ticket_id, group_name):
|
||
"""Физическое переназначение группы внутри ManageEngine через API v3"""
|
||
url = f"{ENDPOINT}/{ticket_id}"
|
||
payload = {"request": {"group": {"name": group_name}}}
|
||
data = {"input_data": json.dumps(payload)}
|
||
|
||
try:
|
||
response = requests.put(url, headers=api_headers, data=data, verify=False)
|
||
response.raise_for_status()
|
||
|
||
resp_json = response.json()
|
||
status_code = resp_json.get("response_status", {}).get("status_code")
|
||
|
||
if status_code == 2000:
|
||
log_to_file(f"🔧 [API] Заявка {ticket_id} физически назначена на '{group_name}' в SD!", ticket_id)
|
||
else:
|
||
log_to_file(f"❌ [API] Ошибка ManageEngine при назначении: {resp_json}", ticket_id)
|
||
|
||
except Exception as e:
|
||
log_to_file(f"❌ [API] Ошибка сети при назначении группы: {e}", ticket_id)
|
||
|
||
# ------------------------------------------------
|
||
# ФУНКЦИИ AD И МАРШРУТИЗАЦИИ
|
||
# ------------------------------------------------
|
||
def find_user_in_ad(requester, caller_phone=None, ticket_id=None):
|
||
"""Поиск параметров учетной записи (Город, Компания, OU) внутри Active Directory"""
|
||
ldap_filter = ""
|
||
search_by = ""
|
||
|
||
if caller_phone:
|
||
ldap_filter = f"(|(telephoneNumber=*{caller_phone}*)(mobile=*{caller_phone}*)(homePhone=*{caller_phone}*)(pager=*{caller_phone}*)(facsimileTelephoneNumber=*{caller_phone}*)(ipPhone=*{caller_phone}*))"
|
||
search_by = f"номеру телефона ({caller_phone})"
|
||
else:
|
||
if not requester: return None
|
||
email = requester.get("email_id")
|
||
login = requester.get("login_name")
|
||
name = requester.get("name")
|
||
|
||
if login:
|
||
clean_login = login.split("\\")[-1]
|
||
ldap_filter = f"(sAMAccountName={clean_login})"
|
||
search_by = f"Логину ({clean_login})"
|
||
elif email:
|
||
ldap_filter = f"(mail={email})"
|
||
search_by = f"Почте ({email})"
|
||
elif name:
|
||
ldap_filter = f"(displayName=*{name}*)"
|
||
search_by = f"ФИО (*{name}*)"
|
||
else: return None
|
||
|
||
try:
|
||
server = Server(AD_SERVER, get_info=ALL, connect_timeout=AD_CONNECT_TIMEOUT)
|
||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True, receive_timeout=AD_RECEIVE_TIMEOUT)
|
||
conn.search(search_base=AD_BASE, search_filter=ldap_filter, attributes=["l", "company", "manager"])
|
||
|
||
if not conn.entries:
|
||
log_to_file(f" [AD] ❌ Объект ({search_by}) не найден in AD!", ticket_id)
|
||
return None
|
||
|
||
user = conn.entries[0]
|
||
city = user.l.value if 'l' in user and user.l.value else None
|
||
company = user.company.value if 'company' in user and user.company.value else None
|
||
ad_path = user.entry_dn
|
||
|
||
if not city and ad_path:
|
||
match = re.search(r'OU=([^,]+),OU=-Пользователи', ad_path, re.IGNORECASE)
|
||
if match:
|
||
city = match.group(1).strip()
|
||
if city.lower() == "красноярск" and "OU=ООО Комбинат Волна" in ad_path:
|
||
company = "ООО Комбинат Волна"
|
||
|
||
manager_dn = user.manager.value if 'manager' in user else None
|
||
manager_email = None
|
||
if manager_dn:
|
||
conn.search(search_base=manager_dn, search_filter="(objectClass=user)", attributes=["mail"], search_scope='BASE')
|
||
if conn.entries and 'mail' in conn.entries[0]:
|
||
manager_email = conn.entries[0].mail.value
|
||
|
||
return {"city": city, "company": company, "ad_path": ad_path, "manager_email": manager_email, "source": f"AD ({search_by})"}
|
||
except Exception as e:
|
||
log_to_file(f"❌ Ошибка LDAP или таймаут сети AD: {e}", ticket_id)
|
||
return None
|
||
|
||
def resolve_support_group(qdrant_group, city, company, ad_path):
|
||
"""Региональная привязка базовой группы 'Техподдержка' к конкретным Кустовым Центрам"""
|
||
if not qdrant_group.startswith("Техподдержка"):
|
||
return qdrant_group, f"Группа '{qdrant_group}' не требует региональной маршрутизации"
|
||
|
||
if not city:
|
||
return qdrant_group, "Город не определен, оставлена базовая группа техподдержки"
|
||
|
||
city_clean = city.strip()
|
||
if city_clean == "Красноярск":
|
||
is_volna_by_attr = company and "Комбинат Волна" in company
|
||
is_volna_by_ou = ad_path and "OU=ООО Комбинат Волна" in ad_path
|
||
|
||
if is_volna_by_attr:
|
||
return "Техподдержка Волна", "Красноярск: определено по атрибуту 'Организация' (Комбинат Волна)"
|
||
if is_volna_by_ou:
|
||
return "Техподдержка Волна", "Красноярск: определено по нахождению in OU 'ООО Комбинат Волна'"
|
||
|
||
return "Техподдержка КЦ", "Красноярск: атрибуты 'Волны' не найдены, назначен Кустовой центр"
|
||
|
||
target_group = CITY_SUPPORT.get(city_clean)
|
||
if target_group:
|
||
return target_group, f"Маршрутизация по справочнику городов: {city_clean} -> {target_group}"
|
||
|
||
return qdrant_group, f"Город '{city_clean}' отсутствует в CITY_SUPPORT, оставлена базовая группа"
|
||
|
||
# ------------------------------------------------
|
||
# ФУНКЦИИ РАСШАРИВАНИЯ И СОГЛАСОВАНИЯ
|
||
# ------------------------------------------------
|
||
def find_user_id_and_role(target_email):
|
||
target_name = target_email.split('@')[0]
|
||
endpoints = [("technicians", "technicians"), ("requester", "users"), ("users", "users")]
|
||
search_variants = [
|
||
{"field": "email_id", "condition": "contains", "value": target_email},
|
||
{"field": "login_name", "condition": "contains", "value": target_name},
|
||
{"field": "name", "condition": "contains", "value": target_name}
|
||
]
|
||
|
||
for endpoint, role_key in endpoints:
|
||
search_url = f"{SERVER_URL}/api/v3/{endpoint}"
|
||
for criteria in search_variants:
|
||
if not criteria["value"]: continue
|
||
payload = {"list_info": {"search_criteria": [criteria]}}
|
||
try:
|
||
resp = requests.get(search_url, headers=api_headers, params={"input_data": json.dumps(payload)}, verify=False)
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
items = data.get(endpoint, []) or data.get("users", []) or data.get("technician", []) or data.get("requesters", [])
|
||
if items:
|
||
for item in items:
|
||
name = item.get("name", "").lower()
|
||
email = item.get("email_id", "").lower() if item.get("email_id") else ""
|
||
login = item.get("login_name", "").lower() if item.get("login_name") else ""
|
||
if target_email.lower() in email or target_name.lower() in login or target_name.lower() in name:
|
||
return str(item.get("id")), item.get("name"), role_key
|
||
except Exception:
|
||
pass
|
||
return None, None, None
|
||
|
||
def share_ticket_for_approval(ticket_id, manager_email):
|
||
step_log = ""
|
||
user_id, found_name, role_key = find_user_id_and_role(manager_email)
|
||
|
||
if not user_id:
|
||
return False, f" - ⚠️ Ошибка: Руководитель ({manager_email}) не найден в базе Service Desk для выдачи прав."
|
||
|
||
role_label = "Специалисту" if role_key == "technicians" else "Автору"
|
||
step_log += f" - Найден {role_label} {found_name} (ID: {user_id}). Открываем доступ...\n"
|
||
|
||
share_url = f"{SERVER_URL}/api/v3/requests/{ticket_id}/share"
|
||
payload = {"share": {role_key: [{"id": user_id}]}}
|
||
|
||
try:
|
||
share_resp = requests.put(share_url, headers=ui_headers, data={"input_data": json.dumps(payload)}, verify=False)
|
||
share_result = share_resp.json()
|
||
if isinstance(share_result, dict):
|
||
status_obj = share_result.get("response_status", {})
|
||
if status_obj.get("status") == "success" or str(status_obj.get("status_code")) == "2000":
|
||
step_log += f" - 🔓 Успешно: Доступ к заявке открыт."
|
||
return True, step_log
|
||
else:
|
||
msg = status_obj.get('messages', [{}])[0].get('message', 'Неизвестная ошибка API')
|
||
return False, step_log + f" - ❌ Ошибка выдачи прав: {msg}"
|
||
else:
|
||
return False, step_log + " - ❌ Ответ API не JSON."
|
||
except Exception as e:
|
||
return False, step_log + f" - ❌ Сбой сети при выдаче прав: {e}"
|
||
|
||
def add_email_to_notify(ticket_id, new_email):
|
||
url = f"{ENDPOINT}/{ticket_id}"
|
||
try:
|
||
get_response = requests.get(url, headers=api_headers, verify=False)
|
||
if get_response.status_code == 200:
|
||
current_data = get_response.json()
|
||
existing_emails = current_data.get("request", {}).get("email_ids_to_notify", [])
|
||
if not existing_emails: existing_emails = []
|
||
if new_email in existing_emails:
|
||
return True, f" - ℹ️ Руководитель ({new_email}) уже числится в наблюдателях."
|
||
|
||
existing_emails.append(new_email)
|
||
payload = {"request": {"email_ids_to_notify": existing_emails}}
|
||
put_response = requests.put(url, headers=api_headers, data={"input_data": json.dumps(payload)}, verify=False)
|
||
put_resp_json = put_response.json()
|
||
if put_response.status_code in [200, 201] and put_resp_json.get("response_status", {}).get("status_code") == 2000:
|
||
return True, f" - ✅ Успешно: Руководитель ({new_email}) добавлен в копию заявки."
|
||
else:
|
||
msg = put_resp_json.get('response_status', {}).get('messages', [{}])[0].get('message', 'Неизвестная ошибка API')
|
||
return False, f" - ❌ Ошибка добавления в копию: {msg}"
|
||
return False, f" - ❌ Ошибка получения заявки для редактирования копии."
|
||
except Exception as e:
|
||
return False, f" - ❌ Сбой сети при добавлении в копию: {e}"
|
||
|
||
def trigger_manager_approval(ticket_id, manager_email):
|
||
step_log = ""
|
||
url_level = f"{ENDPOINT}/{ticket_id}/approval_levels"
|
||
payload_level = {"approval_level": {"approvals": [{"approver": {"email_id": manager_email}}]}}
|
||
|
||
try:
|
||
resp1 = requests.post(url_level, headers=api_headers, data={"input_data": json.dumps(payload_level)}, verify=False)
|
||
data1 = resp1.json()
|
||
if resp1.status_code not in [200, 201] or data1.get("response_status", {}).get("status_code") != 2000:
|
||
err_msg = f"❌ [API] Ошибка создания уровня согласования: {resp1.text}"
|
||
log_to_file(err_msg, ticket_id)
|
||
return False, err_msg
|
||
|
||
level_id = data1.get("approval_level", {}).get("id")
|
||
step_log += f" - Создан уровень согласования (ID: {level_id})\n"
|
||
|
||
url_send = f"{ENDPOINT}/{ticket_id}/approval_levels/{level_id}/approvals/_send_notification"
|
||
resp2 = requests.put(url_send, headers=api_headers, data={"input_data": json.dumps({"approval": {}})}, verify=False)
|
||
if resp2.status_code in [200, 201] and resp2.json().get("response_status", {}).get("status_code") == 2000:
|
||
success_msg = f"🎉 [API] Запрос на согласование успешно отправлен руководителю: {manager_email}"
|
||
log_to_file(success_msg, ticket_id)
|
||
step_log += f" - {success_msg}"
|
||
return True, step_log
|
||
else:
|
||
warn_msg = f"⚠️ [API] Ошибка при отправке письма: {resp2.text}"
|
||
log_to_file(warn_msg, ticket_id)
|
||
return False, step_log + f" - {warn_msg}"
|
||
except Exception as e:
|
||
crit_msg = f"❌ [API] Сбой сети при работе с API согласований: {e}"
|
||
log_to_file(crit_msg, ticket_id)
|
||
return False, crit_msg
|
||
|
||
# ------------------------------------------------
|
||
# ЛОГИКА КЛАССИФИКАЦИИ (QDRANT + РЕРАНКЕР + LLM)
|
||
# ------------------------------------------------
|
||
def calculate_confidence(weighted_scores):
|
||
"""Синхронизировано с анализатором: маржинальная разница между 1 и 2 местом"""
|
||
if len(weighted_scores) < 2: return 1.0
|
||
sorted_scores = sorted(weighted_scores.items(), key=lambda x: x[1], reverse=True)
|
||
return sorted_scores[0][1] - sorted_scores[1][1]
|
||
|
||
def classify_with_ollama(full_text, ticket_id=None):
|
||
"""Глубокий семантический анализ через локальную LLM с защитой GBNF и 17 группами холдинга"""
|
||
groups_text = "\n".join(f"- {g}" for g in VALID_GROUPS)
|
||
|
||
system_prompt = fr"""
|
||
Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — классифицировать текстовую заявку и определить её строго в одну целевую ИТ-группу.
|
||
Заявка получена из системы распознавания речи (STT), поэтому учитывай специфику: сленг, опечатки, звуковые искажения ("собака" вместо @, "один эс", "акцапта", "ворд").
|
||
|
||
ДОПУСТИМЫЕ КАТЕГОРИИ ДЛЯ ВЫБОРА:
|
||
{groups_text}
|
||
- Не назначено
|
||
|
||
ИНСТРУКЦИИ ПО ОФОРМЛЕНИЮ ОТВЕТА (СТРОЖАЙШИЙ ЗАПРЕТ НА ЛИШНИЙ ТЕКСТ):
|
||
1. Выведи ТОЛЬКО точное название группы из справочника выше. Без кавычек, точек и пояснений.
|
||
2. Категорически запрещено выводить в качестве ответа названия заголовков правил разрешения конфликтов!
|
||
3. Если текст является спамом, шуткой или бессмысленным набором слов, выводи: Не назначено.
|
||
|
||
МАТРИЦА РАЗРЕШЕНИЯ КОНФЛИКТОВ (ПРИОРИТЕТЫ МАРШРУТИЗАЦИИ):
|
||
|
||
1. СБИС — АБСОЛЮТНЫЙ НАИВЫСШИЙ ПРИОРИТЕТ при работе в чистом интерфейсе платформы (sbis.ru / Saby):
|
||
- Выпуск, продление, перевыпуск или настройка любых ЭП / УКЭП / КЭП для сотрудников (включая поломки Рутокенов).
|
||
- Электронная отчетность внутри веб-интерфейсов СБИС/Saby в госорганы (СФР, больничные СФР, ФНС, Росстат, файлы МЧД).
|
||
- Восстановление паролей, карточки пользователей, кассы, ЭДО (link.sbis.ru) и Честный Знаки внутри СБИС.
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 1: Если выгрузка или запуск обработки СБИС происходит внутри баз 1С — это группа 1С.
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 2: Если заявка требует добавить, внести или прописать данные сотрудника (ФИО, ИНН, Тип счета доверителя, регистрационный номер, даты) в программы Аксапты (12ах, 4ах, базы ЗСЦ, КВ, СЦС) для обеспечения возможности последующего подписания или обработки документов СБИС через Аксапту — это НЕ СБИС, это СТРОГО группа СПРАВОЧНИКИ И НСИ!
|
||
|
||
2. СИСТЕМНОЕ АДМИНИСТРИРОВАНИЕ — ГЛОБАЛЬНЫЙ ИНФРАСТРУКТУРНЫЙ УРОВЕНЬ ДОМЕНА И СЕРВЕРОВ:
|
||
Глобальное управление серверами, сетевыми правами, бэкапами и общесистемными доменными сервисами. Жестко перехватывает из ВСЕХ прикладных групп следующие заявки:
|
||
- РАССЫЛКА РАСЧЕТНЫХ ЛИСТКОВ (РЛ): Запросы на автоматический запуск или ручную отправку расчетных листков (РЛ СБ, РЛ КВ), находящихся на серверах и сетевых путях (включая пути вида \\srvkem-appboss\bk\temp\OAGORBACHUK\RL\). НЕ Босс-Кадровик!
|
||
- АДМИНИСТРИРОВАНИЕ ПРОГРАММЫ АССИСТЕНТ: Любые заявки на "добавить имя ПК (top-gp-10, top-sir-, top-kpp- и др.) в Ассистент", управление административными доменными группами Ассистента (группы СОРЗИМ Топки, АСУТП Топки). ЖЕСТКОЕ ПРАВИЛО: Даже если это имя ПК добавляется для целей обслуживания и работы видеонаблюдения! Это уровень админов, а не ОПС.
|
||
- ВОССТАНОВЛЕНИЕ ФАЙЛОВ И ПАПОК ИЗ БЭКАПОВ: Задачи на "восстановление утерянных / пропавших / исчезнувших файлов или папок" из общих сетевых ресурсов и папок общего доступа (сетевая папка ОТК, П6-П7, Общие ресурсы, Result ДХ, Лаборатория ДХ, формы фми). НЕ Техподдержка!
|
||
- СЕТЕВЫЕ ПРАВА И ДОСТУП К ПАПКАМ: Инциденты вида "нет доступа к папке", "не могу войти в сетечную папку ресурса", "пропал доступ к папке с файлами" на общих сетевых дисках домена \\sibcem.ru. Предоставление доменных разрешений на работу с макросами в Excel/Word MS Office. Предоставление доменных прав доступа и добавление учетных записей Active Directory в закрытые Группы/Блоки Продаж домена. НЕ Техподдержка! НЕ CRM!
|
||
- СЕРВЕРНЫЕ СЛУЖБЫ ТРУКОНФ И ТЕРМИНАЛЫ: Настройка групп, параллельных вызовов, проблемы истории звонков или удаление номерных псевдонимов в Труконф (TrueConf). Низкоуровневые проблемы подключения к терминальным серверам ("не работает терминальный ЭТРАН", "нет подключения к терминальному ЭТРАНУ"). Установка плагинов, расширений Яндекс.Браузера и любого ПО на удаленных терминальных серверах (плагин BS на АксаптаТерминал для BSPortal).
|
||
- ПОЧТОВЫЙ ТРАНСПОРТ EXCHANGE: Глобальные почтовые transport-сбои Exchange/Outlook, исправление некорректного времени передачи и сдвигов часовых поясов писем в Outlook при пересылке сообщений (когда время приема от ОАО РЖД 23:32 меняется на 11:32, не уходит почта на КЗХ). Управление ящиками Exchange: добавление/удаление сотрудников из списков рассылок, настройка серверных автоответов (OOF), права доступа к чужим ящикам/календарям домена @sibcem.ru.
|
||
- ДОМЕН AD И СУБД: Развертывание и настройка СУБД (PostgreSQL, MS SQL) на любых серверах и бэкап-машинах домена (сервер nsk-backup-01). Очистка дискового пространства и удаление файлов серверных логов на хостах СЭД srvkem-drx-02 (папки RX4_8Data\logs\remote) и настройка скриптов их автоматической ротации.
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ: Локальные пользовательские инциденты внутри рабочих мест и сессий (неверное время в VDI, сброс/восстановление паролей учеток, парольные петли почты Outlook), установка Windows и ввод ПК в домен силами локального ИТ-специалиста относятся СТРОГО к Техподдержке! Любые кросс-сетевые доступы, настройка межсетевой маршрутизации (в сеть СОРЗИМ), прописывание маршрутов между подсетями и открытие доступов к IP-адресам (Энергосфера, VGL Патруль) относятся СТРОГО к Службе информационной безопасности!
|
||
|
||
3. AXAPTA (MICROSOFT DYNAMICS AX) — ПРОГРАММНЫЙ И ОПЕРАЦИОННЫЙ УРОВЕНЬ ERP-СИСТЕМЫ AX:
|
||
Любые инциденты, баги, ошибки кода, настройки логики процессов, маршрутов согласования, закрытия склада, сторно проводок, интеграции с АС-ЭТРАН (в части XML-структур и реквизитов накладных) или прав на действия внутри окон в базах АХ4, АХ12, АХ12_ЗСЦ. Сбои системных счетчиков нумерации партий в ЖЛ, отсутствие номеров вагонов из-за ошибок синхронизации филиалов.
|
||
- ИС КЛЮЧЕНИЕ: Если пользователь жалуется, что Аксапта работает или отправляет документы слишком медленно (по 10 минут) по причине того, что сам компьютер зависает, тормозит и долго открывает любые локальные файлы — это относится СТРОГО к группе Техподдержка!
|
||
|
||
4. СПРАВОЧНИКИ И НСИ — РУТИННЫЙ ВВОД ДАННЫХ, КАРТОЧЕК И РАССЫЛОК ДЛЯ ВСЕХ СИСТЕМ (AXAPTA, 1С, DIRECTUM):
|
||
Первичное заведение, наполнение, текстовое редактирование и корректировка нормативно-справочной информации (Master Data), справочников, МЧД, доверенностей и списков оповещений во ВСЕХ базах холдинга. Жестко перехватывает у Аксапты, 1С, Директума, СБИС и Службы ИБ следующие заявки:
|
||
- ВНЕСЕНИЕ МЧД И ДАННЫХ СОТРУДНИКОВ ДЛЯ СБИС: Любые запросы "внести МЧД", "внесение МЧД в АХ12 / ах4", "добавить сотрудника (Степанцову, Климчук, Мельникова и др.) в базу ЗСЦ / КВ / СЦС / 12ах / 4ах для обработки и подписания документов в СБИС", а также кастомные таблицы параметров сотрудников для ЭДО (поля: ФИО, ИНН, Тип счета доверителя (Поставщик или Клиент), Счет поставщика из базы в Ax, Единый регистрационный номер, Начальная/Конечная дата).
|
||
- КАРТОЧКИ ТС И ПРОМ. ОБОРУДОВАНИЯ (СПРАВОЧНИК УАТ И AX): Запросы на "внести ТС в аксапту блока УАТ для возможности создания ПЛ", первичное заведение новых и тестовых ТС (самосвалы SITRAK C7H, экскаваторы SANY SY980H, буровые станки SWDE, автомобили Газ Соболь, Ford Transit), если они содержат нумерованные или текстовые списки реквизитов (инв №, гаражный номер, гос. номер, модель, VIN, год выпуска, категория, цвет, вид ПЛ, журнал проводок, топливо, счет списания, филиал, подразделение, аналитики ан.3, ан.5, ан.7, ан.8, рабочие центры). Также: "Завести Рабочий Центр для цементной мельницы" (ОМЦ_5_Д, ОМЦ_5_Н, аналитика 6) для введения оборудования в работу.
|
||
- СПИСКИ РАССЫЛОК, УВЕДОМЛЕНИЙ И ПРИКЛАДНЫЕ ГРУППЫ: Отредактировать списки получателей уведомлений или рассылок отчетов в Личном Кабинете или прикладных базах ("добавить/исключить эл. адреса для рассылки оповещений 1cmail в 1С КТЦ по контрагенту", "поставить получателем Мирошникову / Ленкову, остальных убрать"). Дозаполнение или наполнение прикладных групп пользователей внутри систем документооборота (наполнение группы "Планы и отчеты ХК" в Directum RX пользователями из вложения). Активация тестовых сред ("активировать тестовую версию Ах12").
|
||
- ИС КЛЮЧЕНИЕ: Прописывание подъездных путей Юр.лиц с привязкой к станциям (Китой-Комбинатская, Слюнявка 2), справочники Грузоперевозчиков и Дорог назначения в системе МИКС, доработка шаблонов резолюций КТЦ относятся СТРОГО к группе Программисты КТЦ!
|
||
|
||
5. СЛУЖБА ИНФОРМАЦИОННОЙ БЕЗОПАСНОСТИ — СЕТЕВОЙ ПЕРИМЕТР, КРОСС-СЕТЕВАЯ МАРШРУТИЗАЦИЯ, VLAN И ЗАЩИТА:
|
||
Обеспечение безопасности сетевого периметр холдинга, Firewall Authentication, блокировки и сетевая связность подсетей. Жестко забирает из ОПС, Телекома и Сисиадминов следующие заявки:
|
||
- ДОСТУП И СЕТЕВАЯ МАРШРУТИЗАЦИЯ: Предоставление или открытие доступа компьютерам (ANG294, ANG557 и др.) в закрытые технологические сегменты и сторонние подсети (сеть СОРЗИМ, подсети 172.16.34.0, 172.16.35.0, сетевое хранилище 172.16.34.27). Прописывание, восстановление и корректировка сетевых маршрутов между серверами и рабочими местами при сбоях связности ("перестал идти маршрут от сервера top-szm-cctv01 до ПК", открытие доступов к IP 192.168.53.204 для Энергосферы, КНС, систем VGL Патруль).
|
||
- НАСТРОЙКА И ИДЕНТИФИКАЦИЯ КОММУТАТОРОВ (VLAN И ПОРТЫ): Настройка конкретных портов коммутаторов под определенные VLAN (настроить порты на 12Vlan у коммутатора в Лаборатории или ГДУ). Поиск, определение и выяснение наименований, IP-адресов и MAC-адресов коммутаторов для систем мониторинга Zabbix / Заббикс по номерам устройств (№128, №119, №37, №155).
|
||
- СЕТЕВАЯ ДИАГНОСТИКА И ПОТЕРЯ ПАКЕТОВ: Все инциденты каналов связи ("большие потери пакетов", "нет стабильного пинга") до серверов СКУД 172.16.61.102 со стороны Искитимцемента, видеосерверов ТЗП 10.1.8.29 или цифровых камер 10.30.22.*.
|
||
- ВНЕШНЯЯ ПОЧТА И БЛОКИРОВКИ: Блокировка, недоставка внешней почты (НЕ @sibcem.ru, домены @yandex.ru, @mrs24.ru, @law.ru), спам-фильтры, спам-карантин шлюза KSMG. Открытие/закрытие физического доступа к внешним сайтам, порталам, биржам, мессенджерам (YouTube, Telegram, ВК). Согласование AnyDesk/TeamViewer удаленного доступа для внешних подрядчиков (Айко, ЦИТ).
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ: Нумерованные или текстовые списки параметров ТС (инв. №, гос. номер, VIN, модель, топливо), заявки на создание "Рабочих Центров" цементных мельниц или списки кадровых данных сотрудников (ФИО, ИНН, регистрационные номера) для Аксапты к ИБ НЕ ОТНОСЯТСЯ ни при каких условиях — это СТРОГО группа СПРАВОЧНИКИ И НСИ!
|
||
|
||
6. БУХГАЛТЕРСКИЙ УЧЕТ И ПЛАТФОРМА 1С (1С) — ФИНАНСОВЫЕ, УЧЕТНЫЕ ОПЕРАЦИИ И ИНТЕГРАЦИЯ СБИС/ЭДО ВНУТРИ 1С:
|
||
Техническое сопровождение, исправление ошибок, программирование, настройка прав и доработка любых баз, конфигураций и модулей платформы 1С. Проводки, журналы, доп. обработки СБИС в 1С, зависания блоков ЭДО в 1С КТЦ/СБ/ГК, настройка ПО "Трактир", кассовые модули ККМ, интеграция с проектом IBM.
|
||
|
||
7. БОСС-КАДРОВИК — ОФИЦИАЛЬНЫЙ КАДРОВЫЙ УЧЕТ, ОРГСТРУКТУРА И ЛИЦЕНЗИИ СИСТЕМЫ:
|
||
Любые инциденты, ошибки, корректировки записей, отпусков, штатного расписания и официальных замещений внутри кадровой базы данных. Сбои дат официальных замещений сотрудников в цехах/складах, смена руководителей подразделений ХК (Леготин, Шарыкин), лицензии БК.
|
||
|
||
8. БИТРИКС — КОРПОРАТИВНЫЙ ВЕБ-ПОРТАЛ БИТРИКС24 И ВЕБ-ПРОЦЕССЫ:
|
||
Исключительно работа внутри корпоративного веб-портала Битрикс24, управление задачами портала, смарт-процессами, доступами к карточкам, кнопками веб-интерфейса ("Делегирование"), кадровыми веб-анкеты кандидатов и интеграциями с системой "Василёк".
|
||
|
||
9. СЭД DIRECTUM — СИСТЕМА ЭЛЕКТРОННОГО ДОКУМЕНТООБОРОТА (DIRECTUM / RX):
|
||
Любые инциденты, ошибки, доработки интерфейса, карточек, прав, журналов регистрации и логики бизнес-процессов внутри систем Directum / Directum RX. Модуль "Планы и отчеты", нумераторы приказов, маршруты согласования, закрытие командировок.
|
||
|
||
10. ТЕХПОДДЕРЖКА — ЛОКАЛЬНОЕ ОБСЛУЖИВАНИЕ РАБОЧИХ МЕСТ, КЛИЕНТСКОГО ПО, КРИПТОГРАФИИ И ПЕРИФЕРИИ:
|
||
Обслуживание физических и виртуальных рабочих мест пользователей, локальной оргтехники, источников бесперебойного питания (ИБП, бесперебойников, включая инциденты вида "пищит", "издает постоянный сигнал", "гудит", "не держит заряд"), базового офисного софта, надстроек и средств СКЗИ. Настройка Outlook под сертификаты подписей, Saby Плагин, КриптоПро CSP, VipNet, ошибки ведомственных сайтов (ЕИАС, ЧЗ). Замена картриджей принтеров, переустановка Windows, тормоза и аппаратные зависания физических ПК. Ярлыки, Wi-Fi, скачивание файлов по внешним ссылкам.
|
||
- ВАЖНОЕ ПРАВИЛО 1: Заявки на "дооснастить", "оснастить", "доукомплектовать", "выделить" или "установить на рабочее место" любое дополнительное ИТ-оборудование (второй монитор, новый монитор взамен отсутствующего, системный блок, клавиатуру, мышь, колонки, веб-камеру) относятся СТРОГО к Техподдержке!
|
||
- ВАЖНОЕ ПРАВИЛО 2: Любые локальные проблемы со связью на конкретном рабочем месте вида "пропал интернет", "нет сети на компьютере", "не работает интернет и стационарный телефон", "компьютер пишет, что сетевой кабель не подключен" относятся СТРОГО к Техподдержке (это уровень локальной проверки патч-кордов, сетевых карт и розеток инженером на месте)!
|
||
|
||
11. АХО — ХОЗЯЙСТВЕННЫЕ РАБОТЫ И БЫТОВОЕ ОБСЛУЖИВАНИЕ (НЕ ИТ-ЗАДАЧИ):
|
||
Мебель, снабжение питьевой водой, хозяйственные и строительные работы, ремонт помещений, мелкая силовая электрика зданий (замена силовой розетки здания электриком 220V), сантехника, доставка бумаги А4.
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 1: Поломки, гул, постоянные сигналы или писк источников бесперебойного питания (ИБП / бесперебойников) — это НЕ мелкая электрика АХО, это ИТ-оборудование рабочего места, такие заявки относятся СТРОГО к группе ТЕХПОДДЕРЖКА!
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ 2: Если в заявке звучат слова "оснастить рабочее место", "дооснащение", "доукомплектовать", но объектом выступает компьютерная техника или периферия (мониторы, системные блоки, принтеры, клавиатуры) — это НЕ хозяйственное обустройство АХО, это СТРОГО группа ТЕХПОДДЕРЖКА!
|
||
|
||
12. АСУТП — ПРОМЫШЛЕННАЯ АВТОМАТИЗАЦИЯ ПРОИЗВОДСТВА:
|
||
Промышленные контроллеры, датчики, шкафы автоматизации цехов, контроллеры промышленных печей и холодильников.
|
||
|
||
13. АДМИНИСТРИРОВАНИЕ СИСТЕМ BI — АНАЛИТИЧЕСКАЯ ОТЧЕТНОСТЬ, КУБЫ ДАННЫХ И ПЛАНЫ (IBM / BI):
|
||
Любые задачи по администрированию, поддержке, настройке кубов, межкубовых процессов и аналитических отчетов внутри платформ IBM (IBM Planning Analytics / TM1 / IBM PA) и BI-систем холдинга (настроечные таблицы БДР, коды записи IBM вроде ISK-000022, процессы межкубовой загрузки Факт_УО, Факт_Носитель_ВидРаботУслуг, ошибки загрузки планов).
|
||
|
||
14. CRM — УПРАВЛЕНИЕ ВЗАИМООТНОШЕНИЯМИ С КЛИЕНТАМИ:
|
||
Техническая поддержка, настройка прав и ведение модуля Аксапты "СРМ_____Деловые Отношения" (обязательные поля "Адрес", "Район Сбыта", блок маркетинг-кампаний по ТЗ).
|
||
|
||
15. ТЕЛЕКОМ И СООБЩЕНИЯ — СЕТЕВАЯ КАБЕЛЬНАЯ, МАГИСТРАЛЬНАЯ И ТЕЛЕФОННАЯ ИНФРАСТРУКТУРА:
|
||
ВОЛС, оптика, витая пара, байпасные сегменты кольцевых ВОЛС цехов. Монтаж и прошивка Eltex, D-Link, Cisco. Стационарные телефоны (т.X-XX), CallerID / АОН, треск в трубке. Физический монтаж и организация новых комбо-мест (прокладка кабельных линий под телефон + ПК с нуля).
|
||
- СТРОЖАЙШЕЕ ИСКЛЮЧЕНИЕ: Рядовые инциденты на уже существующих рабочих местах вида "пропал интернет/сеть на компьютере", "не работает локальная сеть и телефон на столе", "нет связи на ПК" к магистральному Телекому НЕ ОТНОСЯТСЯ — это задачи локальной диагностики рабочих мест, направлять СТРОГО в ТЕХПОДДЕРЖКУ!
|
||
|
||
16. ОПС, ВИДЕОНАБЛЮДЕНИЕ, СКУД, СМТ: Пожарная/охранная сигнализация (приборы С2000М, ДИП, ШС), Omnicomm, ДУТ, датчики LLS, шлагбаумы, турникеты, ремонт самих камер.
|
||
|
||
17. ПРОГРАММИСТЫ КТЦ — ЖЕЛЕЗНОДОРОЖНАЯ ЛОГИСТИКА И ПО КТЦ:
|
||
Специализированное ПО КТЦ (Экспедитор, VICtory, Планирование, Договорная работа, Дислокация, АБД ПВ, БиКФОР, МИКС). Ремонтные справки КТЦ, учет суток полувагонов, простои ПС КТЦ, Журнал заявок СНГ КТЦ.
|
||
|
||
Выведи точное название группы из справочника:"""
|
||
|
||
# Вычищаем скрытые управляющие символы
|
||
clean_content = "".join(ch for ch in full_text if ch.isprintable() or ch in "\n\r\t")
|
||
|
||
user_prompt = f"""=== ТЕКСТ ЗАЯВКИ ДЛЯ АНАЛИЗА ===
|
||
{clean_content}
|
||
=== КОНЕЦ ТЕКСТА ЗАЯВКИ ===
|
||
Выведи точное название группы на русском языке:""".strip()
|
||
|
||
# Сборка GBNF-грамматики для ограничения выбора Gemma на бэкенде
|
||
grammar_items = [f'"{g}"' for g in VALID_GROUPS] + ['"Не назначено"']
|
||
gbnf_grammar = f'root ::= [ \t\n\r]* ({" | ".join(grammar_items)}) [ \t\n\r]*'
|
||
|
||
try:
|
||
chat_url = OLLAMA_URL.replace("/api/generate", "/v1/chat/completions")
|
||
payload = {
|
||
"model": OLLAMA_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt}
|
||
],
|
||
"temperature": 0.0,
|
||
"max_tokens": OLLAMA_MAX_TOKENS,
|
||
"stream": False,
|
||
"grammar": gbnf_grammar,
|
||
"chat_template_kwargs": {
|
||
"enable_thinking": False # КРИТИЧЕСКИ: отключаем рассуждения, чтобы не ломать GBNF
|
||
},
|
||
"logprobs": True,
|
||
"top_logprobs": 1
|
||
}
|
||
|
||
response = requests.post(chat_url, json=payload, timeout=OLLAMA_TIMEOUT)
|
||
|
||
if response.status_code != 200:
|
||
try:
|
||
err_msg = response.json().get("error", {}).get("message", response.text)
|
||
except:
|
||
err_msg = response.text
|
||
return "Не назначено", 0.0, f"Ошибка Ollama ({response.status_code}): {err_msg}"
|
||
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
if "choices" not in data or not data["choices"]:
|
||
return "Не назначено", 0.0, "Пустой ответ"
|
||
|
||
raw_answer = data["choices"][0]["message"]["content"]
|
||
|
||
# Очистка и обработка ответа
|
||
clean_answer = re.sub(r"```[a-zA-Z]*\n?", "", raw_answer).strip()
|
||
lines = [line.strip(" .' Blunt'\"\n\r`*") for line in clean_answer.split("\n") if line.strip()]
|
||
|
||
category = lines[-1] if lines else "Не назначено"
|
||
|
||
confidence = 0.99
|
||
try:
|
||
if "logprobs" in data["choices"][0] and data["choices"][0]["logprobs"] is not None:
|
||
content_list = data["choices"][0]["logprobs"]["content"]
|
||
valid_logprobs = [t["logprob"] for t in content_list if t.get("token") and t["token"].strip()]
|
||
if not valid_logprobs: valid_logprobs = [t["logprob"] for t in content_list]
|
||
if valid_logprobs:
|
||
avg_logprob = sum(valid_logprobs) / len(valid_logprobs)
|
||
confidence = math.exp(avg_logprob)
|
||
except Exception:
|
||
pass
|
||
|
||
if category not in VALID_GROUPS and category != "Не назначено":
|
||
matched = next((g for g in VALID_GROUPS if g.lower() in category.lower()), "Не назначено")
|
||
category = matched
|
||
|
||
return category, float(confidence), raw_answer.strip()
|
||
|
||
except Exception as e:
|
||
return "Не назначено", 0.0, str(e)
|
||
|
||
def classify_ticket(text, ticket_id=None):
|
||
"""Двухэтапная классификация заявки (Эмбеддинги + Реранкер) с возвратом метрик доминации"""
|
||
global qdrant
|
||
log_to_file(" ➤ [Qdrant] Запрос эмбеддингов для текста...", ticket_id)
|
||
|
||
try:
|
||
payload = {
|
||
"model": EMBEDDING_MODEL_NAME,
|
||
"input_type": "query",
|
||
"texts": [text]
|
||
}
|
||
headers_api = {"Content-Type": "application/json"}
|
||
if EMBEDDING_API_TOKEN:
|
||
headers_api["X-Embedding-Token"] = EMBEDDING_API_TOKEN
|
||
|
||
api_resp = requests.post(EMBEDDING_API_URL, json=payload, headers=headers_api, verify=False)
|
||
api_resp.raise_for_status()
|
||
vector = api_resp.json()["embeddings"][0]
|
||
except Exception as e:
|
||
log_to_file(f" ❌ [Qdrant] Ошибка получения вектора от Embedding API: {e}", ticket_id)
|
||
return "Не определено", 0, [], True, [], {"pure_qdrant_choice": "Не определено", "q_variance": 0, "q_unique_groups": 0, "q_max_score": 0, "q_dominance": 0}
|
||
|
||
log_to_file(f" ➤ [Qdrant] Выборка топ-{QDRANT_SEARCH_LIMIT} из векторной БД...", ticket_id)
|
||
result = qdrant.search(collection_name=QDRANT_COLLECTION, query_vector=vector, limit=QDRANT_SEARCH_LIMIT)
|
||
|
||
if not result:
|
||
return "Не определено", 0, [], True, [], {"pure_qdrant_choice": "Не определено", "q_variance": 0, "q_unique_groups": 0, "q_max_score": 0, "q_dominance": 0}
|
||
|
||
# =================================================================
|
||
# ЭТАП 1: РАСЧЕТ ВЫБОРА ЧИСТОГО QDRANT (ДО СЛУЖБЫ РЕРАНКИНГА)
|
||
# =================================================================
|
||
pure_scores = defaultdict(float)
|
||
pure_counts = defaultdict(int)
|
||
for p in result:
|
||
grp = p.payload.get("group", "Unknown")
|
||
group_clean = "Техподдержка" if grp.startswith("Техподдержка") else grp
|
||
|
||
if pure_counts[group_clean] < QDRANT_MAX_VOTES_PER_GROUP:
|
||
pure_scores[group_clean] += p.score
|
||
pure_counts[group_clean] += 1
|
||
|
||
pure_weighted = {g: (pure_scores[g] / pure_counts[g]) * math.sqrt(pure_counts[g]) for g in pure_scores}
|
||
pure_qdrant_predicted = max(pure_weighted, key=pure_weighted.get) if pure_weighted else "Не определено"
|
||
|
||
qdrant_scores = [p.score for p in result]
|
||
|
||
# =================================================================
|
||
# ЭТАП 2: СЛУЖБА ПЕРЕРАНЖИРОВАНИЯ CROSS-ENCODER + БЛЕНДИНГ СКОРОВ
|
||
# =================================================================
|
||
documents = [p.payload.get("text", "Текст отсутствует").strip()[:RERANKER_MAX_DOC_CHARS] for p in result]
|
||
|
||
rerank_payload = {
|
||
"model": RERANKER_MODEL_NAME,
|
||
"query": text,
|
||
"documents": documents,
|
||
"top_n": len(documents)
|
||
}
|
||
|
||
log_to_file(f" ➤ [Reranker] Старт пересчета весов (Таймаут: {RERANKER_TIMEOUT}с, URL: {RERANKER_URL})...", ticket_id)
|
||
start_time = time.time()
|
||
|
||
try:
|
||
rerank_resp = requests.post(RERANKER_URL, json=rerank_payload, timeout=RERANKER_TIMEOUT)
|
||
rerank_resp.raise_for_status()
|
||
rerank_data = rerank_resp.json()
|
||
|
||
if "results" in rerank_data:
|
||
for item in rerank_data["results"]:
|
||
idx = item["index"]
|
||
relevance_score = item["relevance_score"]
|
||
|
||
r_score = 1.0 / (1.0 + math.exp(-relevance_score))
|
||
q_score = qdrant_scores[idx]
|
||
|
||
result[idx].score = (RERANKER_WEIGHT * r_score) + ((1.0 - RERANKER_WEIGHT) * q_score)
|
||
|
||
elapsed = round(time.time() - start_time, 2)
|
||
log_to_file(f" ➤ [Reranker] Пересчет успешно завершен за {elapsed} сек.", ticket_id)
|
||
except Exception as e:
|
||
log_to_file(f" ⚠️ [Reranker] Сбой службы реранкера: {e}. Работаем на чистых весах Qdrant без блендинга.", ticket_id)
|
||
|
||
result = sorted(result, key=lambda x: x.score, reverse=True)
|
||
|
||
scores, counts, top_groups = defaultdict(float), defaultdict(int), []
|
||
for p in result:
|
||
group = p.payload.get("group", "Unknown")
|
||
txt = p.payload.get("text", "Текст отсутствует").strip()
|
||
q_group = "Техподдержка" if group.startswith("Техподдержка") else group
|
||
top_groups.append((q_group, p.score, txt))
|
||
|
||
if counts[q_group] < QDRANT_MAX_VOTES_PER_GROUP:
|
||
scores[q_group] += p.score
|
||
counts[q_group] += 1
|
||
|
||
weighted = {g: (scores[g] / counts[g]) * math.sqrt(counts[g]) for g in scores}
|
||
if not weighted:
|
||
return "Не определено", 0, [], True, [], {"pure_qdrant_choice": pure_qdrant_predicted, "q_variance": 0, "q_unique_groups": 0, "q_max_score": 0, "q_dominance": 0}
|
||
|
||
predicted = max(weighted, key=weighted.get)
|
||
confidence = calculate_confidence(weighted)
|
||
|
||
top_groups_sorted = sorted(top_groups, key=lambda x: x[1], reverse=True)[:LOG_TOP_GROUPS_LIMIT]
|
||
|
||
examples_for_log = []
|
||
for grp, grp_score, txt in top_groups_sorted:
|
||
clean_txt = txt.replace('\n', ' ')
|
||
if len(clean_txt) > LOG_EXAMPLE_MAX_CHARS:
|
||
clean_txt = clean_txt[:(LOG_EXAMPLE_MAX_CHARS - 3)] + "..."
|
||
examples_for_log.append(f" - [{round(grp_score, 4)}] {grp} ({clean_txt})")
|
||
|
||
scores_list = [p.score for p in result]
|
||
variance = max(scores_list) - min(scores_list) if scores_list else 0
|
||
max_absolute_score = max(scores_list) if scores_list else 0
|
||
|
||
groups = [p.payload.get("group", "Unknown") for p in result]
|
||
clean_groups = ["Техподдержка" if g.startswith("Техподдержка") else g for g in groups]
|
||
unique_groups = set(clean_groups)
|
||
|
||
top_k_groups = clean_groups[:DOMINANCE_TOP_K]
|
||
counter = Counter(top_k_groups)
|
||
dominance = 0.0
|
||
if counter:
|
||
top_group, top_count = counter.most_common(1)[0]
|
||
dominance = top_count / len(top_k_groups)
|
||
|
||
is_garbage = False
|
||
if confidence < GARBAGE_CONFIDENCE_THRESHOLD: is_garbage = True
|
||
elif len(unique_groups) > GARBAGE_UNIQUE_GROUPS_LIMIT: is_garbage = True
|
||
elif variance < GARBAGE_VARIANCE_THRESHOLD and len(unique_groups) > GARBAGE_MIN_GROUPS_FOR_VARIANCE: is_garbage = True
|
||
|
||
if max_absolute_score > EXACT_MATCH_SCORE_THRESHOLD: is_garbage = False
|
||
elif dominance >= GARBAGE_BYPASS_DOMINANCE: is_garbage = False
|
||
|
||
debug_info = {
|
||
"pure_qdrant_choice": pure_qdrant_predicted,
|
||
"q_variance": round(variance, 4),
|
||
"q_unique_groups": len(unique_groups),
|
||
"q_max_score": round(max_absolute_score, 4),
|
||
"q_dominance": round(dominance, 4)
|
||
}
|
||
|
||
return predicted, confidence, top_groups_sorted, is_garbage, examples_for_log, debug_info
|
||
|
||
def get_single_ticket(ticket_id):
|
||
try:
|
||
url = f"{ENDPOINT}/{ticket_id}"
|
||
response = requests.get(url, headers=api_headers, verify=False)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
request_data = data.get("request")
|
||
if not request_data: return None
|
||
return request_data
|
||
except requests.exceptions.RequestException:
|
||
return None
|
||
|
||
# ------------------------------------------------
|
||
# ГЛАВНЫЙ ПОТОК ОБРАБОТКИ ЗАЯВКИ
|
||
# ------------------------------------------------
|
||
def process_ticket_logic(req_id):
|
||
decision_log_lines = []
|
||
|
||
def t_log(msg):
|
||
decision_log_lines.append(msg)
|
||
log_to_file(msg, req_id)
|
||
|
||
try:
|
||
run_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
log_to_file(f"\n\n{'='*60}\nЗАПУСК АНАЛИЗА ЗАЯВКИ ПО WEBHOOK: {run_time}\n{'='*60}", req_id)
|
||
|
||
req = get_single_ticket(req_id)
|
||
if not req:
|
||
log_to_file(f"❌ Заявка {req_id} не найдена в SD.", req_id)
|
||
return
|
||
|
||
raw_subject = req.get("subject", "Без темы").strip()
|
||
raw_description = req.get("description") or ""
|
||
if isinstance(raw_description, dict): raw_description = raw_description.get("content", "")
|
||
|
||
caller_phone = None
|
||
if raw_description:
|
||
temp_text = re.sub(r'<.*?>', ' ', str(raw_description))
|
||
phone_match = re.search(r'Номер звонящего:\s*(\d+)', temp_text, re.IGNORECASE)
|
||
if phone_match: caller_phone = phone_match.group(1)
|
||
|
||
clean_description = ""
|
||
if raw_description:
|
||
clean_html = re.compile('<.*?>')
|
||
no_html_text = re.sub(clean_html, ' ', str(raw_description))
|
||
clean_description = html.unescape(no_html_text).replace(' ', ' ').strip()
|
||
clean_description = re.sub(r'\s+', ' ', clean_description)
|
||
clean_description = clean_description.replace("Эта заявка создана автоматически ИИ на основе аудиозвонка. Оригинальная запись прикреплена к заявке.", "")
|
||
clean_description = re.sub(r'Заявка создана от контакта:.*', '', clean_description, flags=re.IGNORECASE)
|
||
clean_description = re.sub(r'Номер звонящего:.*', '', clean_description, flags=re.IGNORECASE)
|
||
clean_description = re.sub(r'ВНИМАНИЕ: Контакт не был найден в AD\.', '', clean_description, flags=re.IGNORECASE)
|
||
clean_description = clean_description.strip()
|
||
|
||
subject = html.unescape(raw_subject)
|
||
# Добавлен жесткий лимит среза текста [:1000] для полной синхронизации с анализатором
|
||
text_to_classify = f"ТЕМА: {subject}\nОПИСАНИЕ: {clean_description[:1000]}" if clean_description else f"ТЕМА: {subject}"
|
||
|
||
requester = req.get("requester", {})
|
||
if requester.get("id") and not requester.get("email_id"):
|
||
for ep in ["users", "requesters", "technicians"]:
|
||
try:
|
||
u_url = f"{SERVER_URL}/api/v3/{ep}/{requester.get('id')}"
|
||
u_resp = requests.get(u_url, headers=api_headers, verify=False)
|
||
if u_resp.status_code == 200:
|
||
key = ep[:-1] if ep != "technicians" else "technician"
|
||
u_data = u_resp.json().get(key, {})
|
||
if u_data and u_data.get("email_id"):
|
||
requester.update(u_data)
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
requester_name = requester.get("name", "Неизвестный пользователь")
|
||
udf_fields = req.get("udf_fields") or {}
|
||
ticket_city = udf_fields.get("udf_pick_301")
|
||
|
||
group_obj = req.get("group")
|
||
current_group = group_obj.get("name", "") if isinstance(group_obj, dict) else ""
|
||
tech_obj = req.get("technician")
|
||
current_tech = tech_obj.get("name", "") if isinstance(tech_obj, dict) else ""
|
||
|
||
t_log(f"\nНОВАЯ ЗАЯВКА ID: {req_id}")
|
||
t_log(f"ИСХОДНЫЙ ТЕКСТ:\n{text_to_classify}\n{'-'*40}")
|
||
|
||
ai_was_used = False
|
||
|
||
# --- ШАГ 1 ---
|
||
t_log("=== ШАГ 1. МАРШРУТИЗАЦИЯ И AD ===")
|
||
user_info = find_user_in_ad(requester, caller_phone, req_id)
|
||
|
||
ad_city = user_info["city"] if user_info else None
|
||
company = user_info["company"] if user_info else None
|
||
ad_path = user_info["ad_path"] if user_info else None
|
||
manager_email = user_info["manager_email"] if user_info else None
|
||
info_source = user_info["source"] if user_info else "Не определен"
|
||
|
||
prefix_used = False
|
||
if caller_phone and not user_info:
|
||
for prefix in sorted(PREFIX_ROUTING.keys(), key=len, reverse=True):
|
||
if caller_phone.startswith(prefix):
|
||
ad_city = PREFIX_ROUTING[prefix].get("city")
|
||
company = PREFIX_ROUTING[prefix].get("company")
|
||
info_source = f"Таблица префиксов (совпадение '{prefix}')"
|
||
prefix_used = True
|
||
break
|
||
|
||
city = ticket_city if ticket_city else ad_city
|
||
|
||
t_log(f"Источник данных: {info_source}")
|
||
t_log(f"Итоговый город: {city or 'Неизвестен'} " + ("(Взят из UDF заявки)" if ticket_city and ticket_city != ad_city else ""))
|
||
t_log(f"Организация: {company or 'Не определена'}")
|
||
if ad_path: t_log(f"OU в AD: {ad_path}")
|
||
if manager_email: t_log(f"Руководитель: {manager_email}")
|
||
|
||
# --- ШАГ 2 ---
|
||
if current_group or current_tech:
|
||
t_log("\n=== ШАГ 2. БАЙПАС ИИ (ШАБЛОН ПРИВИЛО СД) ===")
|
||
if current_group: t_log(f"ℹ️ Группа уже назначена: '{current_group}'.")
|
||
if current_tech: t_log(f"ℹ️ Специалист уже назначен: '{current_tech}'.")
|
||
t_log("Нейросети отключены. Заявка не требует переназначения.")
|
||
final_category = current_group
|
||
else:
|
||
ai_was_used = True
|
||
|
||
t_log("\n=== ШАГ 2. ВЕКТОРНАЯ БАЗА (Qdrant + Выделенный BGE-Reranker) ===")
|
||
q_cat, q_conf, top_groups, is_garbage, examples_for_log, q_debug = classify_ticket(text_to_classify, req_id)
|
||
|
||
t_log("Топ-5 похожих заявок из истории (после пересчета весов реранкером):")
|
||
for line in examples_for_log:
|
||
t_log(line)
|
||
|
||
t_log(f"\nПредварительный выбор Qdrant: {q_cat}")
|
||
t_log(f"Уверенность (Confidence): {round(q_conf, 4)}")
|
||
t_log(f"Метка мусора/сложной заявки: {'ДА' if is_garbage else 'НЕТ'}\n")
|
||
|
||
ai_category = q_cat
|
||
max_absolute_score = q_debug.get("q_max_score", 0.0)
|
||
|
||
# КРИТИЧЕСКИЙ ПРЕДОХРАНИТЕЛЬ: Если лучший скор ниже порога из конфига, запрещаем сквозной байпас
|
||
force_ollama_by_score = max_absolute_score < FORCE_OLLAMA_SCORE_THRESHOLD
|
||
|
||
t_log("=== ШАГ 3. УМНАЯ ВЕРИФИКАЦИЯ (LLM Gemma) ===")
|
||
|
||
if ENABLE_OLLAMA and (is_garbage or q_conf < CONFIDENCE_THRESHOLD or force_ollama_by_score):
|
||
if q_debug.get("q_dominance", 0.0) >= OLLAMA_BYPASS_DOMINANCE and q_conf >= OLLAMA_BYPASS_MIN_CONFIDENCE and not force_ollama_by_score:
|
||
t_log(f"Пропущено. Доминирующая группа векторного поиска признана достоверной.\n")
|
||
else:
|
||
if force_ollama_by_score and not (is_garbage or q_conf < CONFIDENCE_THRESHOLD):
|
||
t_log(f"Причина вызова: Абсолютный скор совпадения слишком мал ({round(max_absolute_score, 4)} < {FORCE_OLLAMA_SCORE_THRESHOLD}).")
|
||
elif is_garbage:
|
||
t_log("Причина вызова: Qdrant пометил заявку как возможный мусор/аномалию.")
|
||
else:
|
||
t_log(f"Причина вызова: Низкая уверенность Qdrant ({round(q_conf, 4)} < {CONFIDENCE_THRESHOLD}).")
|
||
|
||
t_log(f" ➤ Запуск генеративной проверки (URL: {OLLAMA_URL})...")
|
||
ai_cat, ai_conf, ai_raw = classify_with_ollama(text_to_classify, req_id)
|
||
t_log(f"🤖 Ответ ИИ: {ai_cat} (Сырой текст: '{ai_raw}', Уверенность: {ai_conf:.4f})")
|
||
|
||
if "Ошибка Ollama" not in ai_raw:
|
||
def norm_cmp(g): return "техподдержка" if g.lower().startswith("техподдержка") else g.lower()
|
||
|
||
if ai_conf >= OLLAMA_ACCEPTANCE_THRESHOLD:
|
||
ai_category = ai_cat
|
||
t_log(f" ✔ Коридор 1: Безусловное доверие ИИ (Скор >= {OLLAMA_ACCEPTANCE_THRESHOLD}). Назначено: {ai_category}\n")
|
||
elif norm_cmp(ai_cat) == norm_cmp(q_cat):
|
||
ai_category = ai_cat
|
||
t_log(f" ✔ Коридор 2: Абсолютный консенсус систем (Вектор и ИИ совпали). Назначено: {ai_category}\n")
|
||
elif ai_conf >= 0.30 and q_conf < 0.50:
|
||
ai_category = ai_cat
|
||
t_log(f" ✔ Коридор 3: Умный перехват. Вектор дезориентирован (Скор Qdrant < 0.50). Доверяем ИИ: {ai_category}\n")
|
||
elif ai_conf >= 0.50 and q_conf < 0.85:
|
||
ai_category = ai_cat
|
||
t_log(f" ✔ Коридор 4: Перехват ложной уверенности вектора (Бухгалтерский камуфляж). Доверяем ИИ: {ai_category}\n")
|
||
else:
|
||
ai_category = "Не назначено"
|
||
t_log(" ⚠ Коридор 5: Архитектурный конфликт систем! Сбрасываем заявку в 'Не назначено' для диспетчера.\n")
|
||
else:
|
||
ai_category = "Не назначено"
|
||
t_log(" ⚠ Критический сбой: Ollama недоступна. Сброс в 'Не назначено'.\n")
|
||
else:
|
||
t_log("Пропущено. Уверенность Qdrant достаточно высока.\n")
|
||
|
||
# --- ШАГ 4 ---
|
||
t_log("=== ШАГ 4. РЕГИОНАЛЬНЫЙ ФИЛЬТР И НАЗНАЧЕНИЕ ===")
|
||
final_category, routing_reason = resolve_support_group(ai_category, city, company, ad_path)
|
||
|
||
t_log(f"Выбор ИИ-комплекса: {ai_category}")
|
||
t_log(f"Логика регионального назначения: {routing_reason}")
|
||
|
||
if final_category not in ["Не является заявкой", "Не определено", "Не назначено"]:
|
||
if final_category in ALLOWED_ASSIGNMENT_GROUPS:
|
||
t_log(f"✔ Группа '{final_category}' разрешена для автоназначения. Выполняем...")
|
||
assign_group_to_ticket(req_id, final_category)
|
||
else:
|
||
t_log(f"⚠ ВНИМАНИЕ: Группа '{final_category}' валидна, но не включена in ALLOWED_ASSIGNMENT_GROUPS. Автоназначение запрещено.")
|
||
final_category = "Не назначено"
|
||
else:
|
||
t_log(f"🗑️ Мусорная или конфликтная заявка {req_id}. Оставлена без группы.")
|
||
|
||
t_log(f"\n>>> ФИНАЛЬНАЯ КАТЕГОРИЯ: {final_category} <<<")
|
||
|
||
# --- ШАГ 5 ---
|
||
if ENABLE_AUTO_APPROVAL and final_category not in ["Не является заявкой", "Не определено", "Не назначено"]:
|
||
t_log("\n=== ШАГ 5. АВТОСОГЛАСОВАНИЕ ===")
|
||
cat_obj = req.get("category")
|
||
subcat_obj = req.get("subcategory")
|
||
cat_name = cat_obj.get("name", "") if isinstance(cat_obj, dict) else ""
|
||
subcat_name = subcat_obj.get("name", "") if isinstance(subcat_obj, dict) else ""
|
||
|
||
is_city_ok = False
|
||
if city:
|
||
for c in APPROVAL_CITIES:
|
||
if c.lower() in city.lower():
|
||
is_city_ok = True
|
||
break
|
||
|
||
is_group_ok = final_category in APPROVAL_GROUPS
|
||
is_cat_ok = False
|
||
if cat_name in APPROVAL_CATEGORIES:
|
||
allowed_subcats = APPROVAL_CATEGORIES[cat_name]
|
||
if "*" in allowed_subcats or subcat_name in allowed_subcats:
|
||
is_cat_ok = True
|
||
|
||
if is_city_ok and (is_group_ok or is_cat_ok):
|
||
if manager_email:
|
||
t_log(f"✔ Условия совпали. Запуск процесса согласования для {manager_email}...")
|
||
ai_was_used = True
|
||
|
||
share_success, share_log = share_ticket_for_approval(req_id, manager_email)
|
||
t_log(share_log)
|
||
|
||
cc_success, cc_log = add_email_to_notify(req_id, manager_email)
|
||
t_log(cc_log)
|
||
|
||
success, step_detail = trigger_manager_approval(req_id, manager_email)
|
||
t_log(step_detail)
|
||
else:
|
||
t_log("⚠️ Согласование невозможно: у пользователя не указан руководитель в AD.")
|
||
else:
|
||
if ai_was_used:
|
||
t_log("\n=== ШАГ 5. АВТОСОГЛАСОВАНИЕ ===")
|
||
t_log("⏭ Пропуск: Условия автосогласования не выполнены.")
|
||
|
||
# Отправляем письмо с накопленным логом
|
||
if ai_was_used:
|
||
full_log_str = "\n".join(decision_log_lines)
|
||
send_email_report(req_id, requester_name, subject, text_to_classify, final_category, full_log_str)
|
||
|
||
print(f"🎉 Обработка заявки {req_id} завершена!")
|
||
|
||
except Exception as e:
|
||
err_trace = traceback.format_exc()
|
||
crash_msg = f"\n🔥 КРИТИЧЕСКАЯ ОШИБКА В ПОТОКЕ (Заявка {req_id}):\n{err_trace}"
|
||
log_to_file(crash_msg, req_id)
|
||
print(crash_msg)
|
||
|
||
# ------------------------------------------------
|
||
# МАРШРУТЫ FLASK
|
||
# ------------------------------------------------
|
||
@app.route('/webhook', methods=['POST'])
|
||
def webhook_handler():
|
||
data = request.get_json()
|
||
if not data: return jsonify({"status": "error", "message": "Invalid JSON"}), 400
|
||
|
||
ticket_id = data.get("ticket_id")
|
||
if ticket_id:
|
||
threading.Thread(target=process_ticket_logic, args=(ticket_id,)).start()
|
||
return jsonify({"status": "received"}), 200
|
||
else:
|
||
return jsonify({"status": "error", "message": "No ticket_id provided"}), 400
|
||
|
||
# ------------------------------------------------
|
||
# ЗАПУСК СЕРВЕРА
|
||
# ------------------------------------------------
|
||
if __name__ == "__main__":
|
||
global qdrant
|
||
qdrant = QdrantClient(QDRANT_HOST, port=QDRANT_PORT)
|
||
print(f"🚀 ИИ-сервис запущен и готов к приему вебхуков на порту {WEBHOOK_PORT}!")
|
||
app.run(host='0.0.0.0', port=WEBHOOK_PORT)
|