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