Clean main branch

This commit is contained in:
2026-07-14 09:46:58 +00:00
parent 074379aa22
commit cf1d526304
5 changed files with 0 additions and 1265 deletions
-3
View File
@@ -1,3 +0,0 @@
# sd_dispatcher
Service Desk Dispatcher — automatic ticket routing
-9
View File
@@ -1,9 +0,0 @@
# /opt/GigaAM/.env
PW_ID_FTP=FTP
PW_ID_TC=bot_tcs
PW_ID_SD=sdesktoken
PW_ID_AD=AD
PW_ID_SQL=boss_base
Binary file not shown.
-303
View File
@@ -1,303 +0,0 @@
import os
import sys
from dotenv import load_dotenv
# Автоматическое определение корневой директории проекта
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Загружаем приватные переменные окружения из вашего .env
load_dotenv(os.path.join(BASE_DIR, 'config', '.env'))
# Карта соответствия: Переменная диспетчера -> Ключ из вашего .env
# Объявлена в самом начале, чтобы скрипт знал имена карточек до обращения к API
CREDENTIALS_MAP = {
"FTP_PASS": os.getenv("PW_ID_FTP", "").strip(' "\'\r\n'), # Мягкий поиск по ключу
"API_KEY": os.getenv("PW_ID_SD", "").strip(' "\'\r\n'), # Ищет карточку по значению из .env
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'), # Ищет карточку по значению из .env
}
# Единственный токен, который может быть зашит в локальном .env, а не в Пассворке
EMBEDDING_API_TOKEN = os.getenv("EMBEDDING_API_TOKEN", "").strip(' "\'\r\n')
# =====================================================================
# ИНТЕГРАЦИЯ С PASSWORK (Динамическая загрузка паролей)
# =====================================================================
PASSWORK_DIR = "/opt/passwork"
if PASSWORK_DIR not in sys.path:
sys.path.insert(0, PASSWORK_DIR)
try:
from passwork import get_passwork_secrets
print("LOG: 🔐 Обращение к модулю Passwork и выгрузка сейфа для GigaAM...")
# Формируем и пробрасываем список обязательных карточек для автозащиты от коллизий
required_to_wait = [name for name in CREDENTIALS_MAP.values() if name]
passwork_pool = get_passwork_secrets(required_cards=required_to_wait)
except Exception as e:
print(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Не удалось подключить модуль Passwork: {e}")
sys.exit(1)
print("LOG: 🔐 Распределение секретов и логинов по модулям диспетчера...")
for var_name, card_name in CREDENTIALS_MAP.items():
if not card_name:
print(f"⚠️ Внимание: Название карточки для {var_name} не задано в .env. Используется локальное значение.")
continue
clean_card_name = card_name.strip(' "\'\r\n')
card_data = passwork_pool.get(clean_card_name)
if card_data is None:
print(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Карточка с именем '{clean_card_name}' не найдена в сейфе Passwork!")
sys.exit(1)
# Динамически заполняем пароль/токен в глобальную область видимости config.py
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
# Динамически вытаскиваем сопутствующие логины из карточек
if var_name == "FTP_PASS":
globals()["FTP_USER"] = card_data.get("login", "").strip(' "\'\r\n')
elif var_name == "AD_PASSWORD":
globals()["AD_USER"] = card_data.get("login", "").strip(' "\'\r\n')
print(f"{var_name} и логин успешно синхронизированы из карточки '{clean_card_name}'")
# Интеграция с АТС (Выгрузка записей звонков по FTP)
FTP_HOST = "10.0.0.7"
FTP_DIR = "/zabbix"
FTP_ARCHIVE = "/zabbix/archive"
# =====================================================================
# 1. СИСАДМИНСКАЯ ИНФРАСТРУКТУРА, ФЛАСК И ЛОГИРОВАНИЕ
# =====================================================================
# Порт, на котором Flask-вебхук слушает входящие JSON-пакеты от ManageEngine
WEBHOOK_PORT = 5000
# Путь к файлу сквозного отладочного лога диспетчера
WEBHOOK_LOG_FILE = os.path.join(BASE_DIR, 'dispatcher_log.txt')
# Включение профилирования (замеры времени выполнения этапов обработки заявки)
ENABLE_PROFILING = True
# Ограничение длины текста исторических документов при выводе в файл лога
LOG_EXAMPLE_MAX_CHARS = 350
# Ограничение длины строки текста заявки при выводе в интерактивную CLI-консоль
CLI_LOG_MAX_CHARS = 80
# =====================================================================
# 2. КОНВЕЙЕР РАСПОЗНАВАНИЯ РЕЧИ И ПЕРВИЧНОЙ ОБРАБОТКИ (ASR PIPELINE)
# =====================================================================
# Флаг включения суммаризации текста звонка (сейчас отключен для экономии CPU)
ENABLE_TEXT_SUMMARIZATION = False
# Рабочие директории для хранения входящих аудиозаписей и результатов транскрибации
AUDIO_DIR = os.path.join(BASE_DIR, 'audio')
JSON_DIR = os.path.join(BASE_DIR, 'json')
# Длина одного чанка (куска аудио) при нарезке для модели распознавания (в секундах)
CHUNK_SECONDS = 25
# Пути к словарям терминов и золотых ИТ-токенов для исправления ошибок транскрипции
DICT_PATH = "/opt/dict/dict.json"
GOLDEN_DICT_PATH = "/opt/dict/GOLDEN_TERMS.json"
# Переключатели модулей постобработки и исправления текста после ASR
ENABLE_REGEX_CORRECTION = True # Исправление по регулярным выражениям
ENABLE_FUZZY_CORRECTION = True # Нечёткое исправление по Левенштейну/словарям
# =====================================================================
# 3. ИНТЕГРАЦИЯ С MANAGEENGINE SERVICE DESK API
# =====================================================================
# Корпоративный URL и API-эндпоинт для работы с заявками по протоколу v3
SERVER_URL = "https://servicedesk.sibcem.ru:8080"
ENDPOINT = f"{SERVER_URL}/api/v3/requests"
# Системный email-адрес учетной записи ИИ-сортировщика в Service Desk
DEFAULT_REQUESTER = "ai@sibcem.ru"
# =====================================================================
# 4. ИНТЕГРАЦИЯ С ACTIVE DIRECTORY (LDAP)
# =====================================================================
# Контроллер домена и базовая точка поиска пользователей (Base DN)
AD_SERVER = "ldap://172.16.20.20"
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
# Сетевые тайм-ауты (в секундах) для защиты фоновых потоков от зависания на LDAP-запросах
AD_CONNECT_TIMEOUT = 5 # Время на установку tcp-соединения
AD_RECEIVE_TIMEOUT = 5 # Время ожидания ответа на поисковый запрос
# =====================================================================
# 5. ПОЧТОВЫЕ УВЕДОМЛЕНИЯ И АЛЕРТЫ (SMTP)
# =====================================================================
# Корпоративный почтовый релей и порт (используется стандартный 25 без SSL)
SMTP_SERVER = "srvkem-mail.sibcem.ru"
SMTP_PORT = 25
# Отправитель по умолчанию для ИИ-отчетов
DEFAULT_EMAIL_FROM = "ai@sibcem.ru"
# Списки рассылки и исключений для отправки логов принятия решений
ALERTS_DISABLED_USERS = ["ds.krivochenko@sibcem.ru"]
ALERTS_ALL_TICKETS = ["ds.krivochenko@sibcem.ru"]
# =====================================================================
# 6. ВЕКТОРНЫЙ ПОИСК И ЭМБЕДДИНГИ (QDRANT API)
# =====================================================================
# Адрес, порт и целевая коллекция локального сервера Qdrant
QDRANT_HOST = "localhost"
QDRANT_PORT = 6333
QDRANT_COLLECTION = "tickets"
# Осредненный лимит выборки документов из Qdrant под дальнейший реранкинг (оптимально для CPU)
QDRANT_SEARCH_LIMIT = 20
# Локальная служба генерации векторов (Embedding API)
EMBEDDING_API_URL = "http://127.0.0.1:8010/embed"
EMBEDDING_MODEL_NAME = "multilingual-e5-base"
# =====================================================================
# 7. ПЕРЕРАНЖИРОВАНИЕ ПРЕЦЕДЕНТОВ (CROSS-ENCODER RERANKER)
# =====================================================================
# Адрес выделенной службы реранкера (работает на порту 8081)
RERANKER_URL = "http://127.0.0.1:8081/rerank"
RERANKER_MODEL_NAME = "bge-reranker-v2-m3-Q8_0.gguf"
# Предохранитель от OOM: максимальное количество символов исторического билета, скармливаемое реранкеру
RERANKER_MAX_DOC_CHARS = 1200
# Жесткий тайм-аут сокета (в секундах) на ожидание пачки документов от реранкера
RERANKER_TIMEOUT = 60
# Коэффициент смешивания скоров (Score Blending):
# 1.0 — абсолютная власть реранкера, 0.0 — чистый Qdrant (база векторов)
RERANKER_WEIGHT = 0.7
# =====================================================================
# 8. ЛОКАЛЬНЫЙ ИНТЕЛЛЕКТ И СЕРВЕРЫ LLM / STT
# =====================================================================
# Переключатель вызова большой языковой модели
ENABLE_OLLAMA = True
# ГЛОБАЛЬНЫЙ ДИСПЕТЧЕР И АНАЛИЗАТОР: Классификация тикетов (Тяжелый квант Q8)
OLLAMA_URL = "http://127.0.0.1:8080/v1/chat/completions"
OLLAMA_MODEL = "gemma-4-12b-it-Q8_0"
# РАСПОЗНАВАНИЕ ЗВОНКОВ: Локальный роутер моделей llama.service (Порт 8080)
OLLAMA_SUBJECT_URL = "http://127.0.0.1:8080/v1/chat/completions"
OLLAMA_SUBJECT_MODEL = "gemma-4-E4B-it-Q4_K_M"
# МИКРОСЕРВИС ТРАНСКРИБАЦИИ: Точка подключения к демону GigaAM (Порт 5001)
STT_SERVICE_URL = "http://127.0.0.1:5001/transcribe"
# Размер контекстного окна для слота (динамическое выделение памяти из пула 16384)
OLLAMA_NUM_CTX = 12288
# Параметры инференса текстовой модели
OLLAMA_TEMPERATURE = 0.0 # Строгая детерминированность вывода
OLLAMA_MAX_TOKENS = 65 # Ограничение длины ответа (модели нужно выдать только имя группы)
OLLAMA_TIMEOUT = 1200 # Предельный тайм-аут генерации токенов (в секундах) под CPU-нагрузкой
# =====================================================================
# 9. МАТЕМАТИЧЕСКИЕ ФИЛЬТРЫ, ТРИГГЕРЫ МУСОРА И ПОРОГОВЫЕ ОГРАНИЧЕНИЯ
# =====================================================================
# Осредненный порог относительной уверенности Qdrant. Ниже него — принудительный вызов Qwen 2.5
CONFIDENCE_THRESHOLD = 0.85
# КРИТИЧЕСКИЙ ПРЕДОХРАНИТЕЛЬ: если абсолютный скор лучшего совпадения реранкера ниже этого веса,
# сквозной байпас ИИ запрещается, и билет отправляется на перепроверку в Ollama
FORCE_OLLAMA_SCORE_THRESHOLD = 0.75
# Сколько голосов (совпадений) максимум может забрать одна ИТ-группа в векторном пуле
QDRANT_MAX_VOTES_PER_GROUP = 10
# Абсолютный скор, выше которого заявка считается стопроцентным историческим дублем
EXACT_MATCH_SCORE_THRESHOLD = 0.9
# Фильтры детектирования аномального распределения (маркеры мусорных/непонятных заявок)
GARBAGE_CONFIDENCE_THRESHOLD = 0.01 # Предельно низкая уверенность
GARBAGE_UNIQUE_GROUPS_LIMIT = 4 # Слишком много разных групп в топ-выборке
GARBAGE_VARIANCE_THRESHOLD = 0.02 # Слишком плотный шаг весов (нет явного лидера)
GARBAGE_MIN_GROUPS_FOR_VARIANCE = 2 # Минимальное число конкурентов для оценки дисперсии
GARBAGE_BYPASS_DOMINANCE = 0.90 # Порог доминации, снимающий метку мусора
# Фильтры условий обхода вызова Ollama (Bypass)
OLLAMA_BYPASS_DOMINANCE = 0.95 # Если одна группа заняла 80% топа выборки
OLLAMA_BYPASS_MIN_CONFIDENCE = 0.10 # Минимальная базовая уверенность для байпаса
OLLAMA_ACCEPTANCE_THRESHOLD = 0.65 # Минимальная уверенность логпробов Ollama для принятия ответа
DOMINANCE_TOP_K = 10 # Глубина среза топ-результатов для расчёта коэффициента доминации
LOG_TOP_GROUPS_LIMIT = 5 # Количество позиций истории, выводимых в логи структуры decision_log
# =====================================================================
# 10. СПРАВОЧНИКИ МАРШРУТИЗАЦИИ, КАТЕГОРИИ И БИЗНЕС-ЛОГИКА АВТОМАТИЗАЦИИ
# =====================================================================
# White-list валидных ИТ-групп в системе Service Desk Plus
VALID_GROUPS = [
"CRM", "АХО", "Техподдержка", "1С", "Axapta (Microsoft Dynamics AX)",
"АСУТП", "Босс-Кадровик", "ОПС, видеонаблюдение, СКУД, СМТ",
"Программисты КТЦ", "Системное администрирование", "Телеком и сообщения",
"Битрикс", "СЭД Directum", "Администрирование систем BI", "СБИС", "Служба информационной безопасности", "НСИ"
]
# Группы, для которых открыто прямое автоматическое исполнение Put-запросов назначения через API
ALLOWED_ASSIGNMENT_GROUPS = [
"Техподдержка КЦ", "Техподдержка ХК", "Техподдержка Тимлюй",
"Техподдержка Ангарск", "Техподдержка Искитим", "Техподдержка Топки",
"Техподдержка КТЦ", "Техподдержка Волна", "Техподдержка Москва","Телеком и сообщения"
]
# Матрица ответственных менеджеров групп для отправки алертов о сбоях/назначениях билетов
GROUP_MANAGERS = {
"Техподдержка": ["e.voevodin@sibcem.ru"]
}
# Региональный справочник привязки городов к конкретным Кустовым Центрам (КЦ) и ХК
CITY_SUPPORT = {
"Ангарск": "Техподдержка Ангарск",
"Слюдянка": "Техподдержка Ангарск",
"Искитим": "Техподдержка Искитим",
"Новосибирск": "Техподдержка КТЦ",
"Топки": "Техподдержка Топки",
"Кемерово": "Техподдержка ХК",
"Москва": "Техподдержка Москва",
"Тимлюй": "Техподдержка ХК",
"Каменск": "Техподдержка ХК",
"Улан-Удэ": "Техподдержка ХК",
"Татарский Ключ": "Техподдержка ХК",
"Спас-Клепики": "Техподдержка ХК",
"Красноярск": "Техподдержка КЦ"
}
# Настройки матрицы автоматического создания уровней согласования руководителями
ENABLE_AUTO_APPROVAL = True
APPROVAL_CITIES = ["Новосибирск"]
APPROVAL_GROUPS = ["1С", "1с", "Программисты КТЦ"]
APPROVAL_CATEGORIES = {
"ИТ": ["ПО КТЦ", "Проблемы с Договорной Работой"],
"1С": ["*"],
"1с": ["*"]
}
# План «Б» маршрутизации: жесткое определение локации по телефонному префиксу внутренней АТС
PREFIX_ROUTING = {
"700": {"city": "Красноярск", "company": "ООО Комбинат Волна"},
"702": {"city": "Кемерово"},
"703": {"city": "Новосибирск"},
"704": {"city": "Красноярск"},
"705": {"city": "Каменск"},
"701": {"city": "Ангарск"},
"706": {"city": "Ангарск"},
"707": {"city": "Ангарск"},
"709": {"city": "Москва"},
"724": {"city": "Искитим"},
"71": {"city": "Топки"}
}
-950
View File
@@ -1,950 +0,0 @@
# /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('&nbsp;', ' ').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)