32 Commits

Author SHA1 Message Date
Dennis Krivochenko 119f77b1e5 feat: add document optimization script for batch processing 2026-07-18 23:29:24 +07:00
Dennis Krivochenko 9fb276f6d7 feat: add document optimization script 2026-07-18 12:17:07 +07:00
dddennnisss b0ce571b3d Обновить parse_pol177.py 2026-07-16 17:08:41 +07:00
Hermes f3ecc8cf06 Update parse_pol177.py: use Unstructured API for tables, remove pypdf 2026-07-16 17:04:59 +07:00
dddennnisss b7e5fffee0 Fix: use yolox model (available on 103) + hi_res + GPU for maximum quality 2026-07-16 16:16:05 +07:00
dddennnisss aa0923f4e1 Fix: use yolox model (available on 103) + hi_res + GPU for maximum quality 2026-07-16 16:15:05 +07:00
dddennnisss f8085d9ec2 Fix: use hi_res strategy + chipper model + GPU for maximum quality 2026-07-16 16:00:35 +07:00
dddennnisss 35d5f48c5e Add clean_chunk_pol177.py - Clean and chunk parsed PDF data 2026-07-16 15:06:35 +07:00
dddennnisss 9f36374351 Add parse_pol177.py - PDF parser with Unstructured API 2026-07-16 15:06:34 +07:00
dddennnisss bae21d08ef Add parse_pol177.py - PDF parser with Unstructured API + pypdf 2026-07-16 10:24:19 +07:00
Денис Кривоченко 5325b43c95 fix: add service_category from template to SD API request 2026-07-15 22:49:26 +07:00
Денис Кривоченко 50e9aa12e4 fix: use template instead of request_template for SD API 2026-07-15 22:40:55 +07:00
Денис Кривоченко 4df52d48f5 fix: use params instead of content for SD API requests 2026-07-15 22:28:15 +07:00
dddennnisss e2c0a2572d fix: добавить Content-Type: application/json и исправить input_data 2026-07-15 22:21:52 +07:00
dddennnisss d9f5ec16e1 fix: double-encode input_data для API Service Desk 2026-07-15 17:01:08 +07:00
dddennnisss eb35fcec82 fix: content= вместо data= для raw JSON body 2026-07-15 16:51:28 +07:00
dddennnisss d4dde34d90 fix: raw JSON body для API Service Desk 2026-07-15 16:40:58 +07:00
dddennnisss f09b693218 fix: добавить логирование ответа API для отладки 2026-07-15 16:16:14 +07:00
dddennnisss 88fca32156 fix: исправить API call (json вместо data) и добавить _sd_ticket_create_error 2026-07-15 16:08:56 +07:00
dddennnisss c4827ce559 feat: встроить шаблоны в SD_MAIN_MENU_TEXT, после выбора показать "Опишите проблему текстом" 2026-07-15 15:54:00 +07:00
dddennnisss 1a4f21cc20 fix: добавить sd_workflow_logger и относительный путь к config 2026-07-15 14:48:15 +07:00
dddennnisss 066f057003 feat: шаблоны показываются в main.py при нажатии 3, sd_module_handler только обрабатывает выбор 2026-07-15 14:37:14 +07:00
dddennnisss 067c8846b0 fix: добавить msg.handled=True чтобы sd_module_handler не перезаписал 2026-07-15 14:19:20 +07:00
dddennnisss 666435be3e feat: показать шаблоны вместо SD_MAIN_MENU_TEXT при нажатии 3 2026-07-15 13:58:24 +07:00
dddennnisss 914c964e08 fix: импортировать и инициализировать sd_sessions в main.py 2026-07-15 13:06:36 +07:00
dddennnisss 8267466ee6 fix: восстановить выбор шаблона после SD_MAIN_MENU_TEXT 2026-07-15 13:03:59 +07:00
dddennnisss b0439bd725 feat: встроить шаблоны в SD_MAIN_MENU_TEXT при нажатии 3 2026-07-15 13:02:38 +07:00
dddennnisss 39a2700555 fix: убрать SD_MAIN_MENU_TEXT из главного меню, показывать шаблоны 2026-07-15 12:52:30 +07:00
dddennnisss 691c112ace fix: инициализация шаблонов до need_text 2026-07-15 12:42:31 +07:00
dddennnisss f7aed57df4 fix: обновить тексты выбора шаблона 2026-07-15 12:38:30 +07:00
dddennnisss e07b546f08 feat: пагинация шаблонов Service Desk по 10 на страницу 2026-07-15 12:33:56 +07:00
dddennnisss efda2c8ae9 feat: добавить выбор шаблона Service Desk перед созданием заявки 2026-07-15 12:24:23 +07:00
8 changed files with 1246 additions and 42 deletions
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
Clean and chunk parsed PDF data from unstructured-api.
Filters noise, preserves structure, chunks to 1500-1800 chars.
"""
import json
import re
def load_parsed_data(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def clean_text(text):
if not text:
return ""
text = re.sub(r'\s+', ' ', text.strip())
return text.strip()
def is_noise(text):
"""Check if text is noise."""
text = clean_text(text)
if not text:
return True
# Company name header/footer
if text == "АО «ХК «Сибцем»":
return True
# Page numbers: "Стр. 2 из 10"
if re.match(r'^Стр\.\s+\d+\s+из\s+\d+$', text):
return True
# Standalone numbers: "4."
if re.match(r'^\d+\.$', text):
return True
# Table of contents
lines = text.split('\n')
if len(lines) > 3:
page_pattern = re.compile(r'\d+\s*$')
page_lines = sum(1 for l in lines if page_pattern.search(l.strip()))
if page_lines > len(lines) * 0.5:
return True
# Specific noise
noise_patterns = [
r'^Оглавление$',
r'^Содержание$',
r'^Редакция \d+$',
r'^Тип документа:',
r'^Наименование процесса:',
r'^Ведущее подразделение:',
r'^Дата утверждения:',
]
for pattern in noise_patterns:
if re.search(pattern, text):
return True
return False
def is_section_title(text):
"""Check if text is a section title like '4. Термины, определения и сокращения'."""
text = clean_text(text)
return bool(re.match(r'^\d+\.\s+\w', text))
def is_section_subtitle(text):
"""Check if text is a section subtitle like '6.7. Электронные...'."""
text = clean_text(text)
return bool(re.match(r'^\d+\.\d+\.\s+\w', text))
def is_section_header(text):
"""Check if text starts with a section/subsection number."""
text = clean_text(text)
return bool(re.match(r'^\d+(?:\.\d+)*[\.\s]', text))
def merge_elements(elements):
"""
Merge short fragments with context.
Strategy: merge consecutive non-title elements into text blocks.
Keep titles and tables as separate blocks.
"""
if not elements:
return []
# Filter noise first
filtered = [el for el in elements if not is_noise(el.get('text', ''))]
# Separate into blocks: text blocks, title blocks, table blocks
blocks = []
current_text_block = []
for el in filtered:
text = el.get('text', '')
el_type = el.get('type', '')
# Skip Image (OCR noise from cover page)
if el_type == 'Image':
continue
# If this is a title/section header, flush text block and add title
if el_type in ('Title',) and is_section_header(text):
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
current_text_block = []
blocks.append({
'text': text,
'type': 'Title',
'metadata': el.get('metadata', {})
})
continue
# If this is a table, flush text block and add table
if el_type == 'Table':
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
current_text_block = []
blocks.append({
'text': text,
'type': 'Table',
'metadata': el.get('metadata', {})
})
continue
# Otherwise, accumulate into text block
current_text_block.append(el)
# Flush remaining text block
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
# Now merge short text blocks with adjacent content
# Specifically: merge short blocks that are continuations of previous sections
result = []
for i, block in enumerate(blocks):
text = block['text']
el_type = block['type']
clean = clean_text(text)
# If this is a short text block, try to merge with previous
if len(clean) < 60 and el_type == 'NarrativeText' and result:
# Check if it looks like a continuation (section subtitle or fragment)
if is_section_subtitle(clean) or not clean[0].isdigit():
# Merge with previous block
result[-1]['text'] = f"{result[-1]['text']} {clean}"
continue
result.append(block)
return result
def chunk_text(text, max_chunk_size=1800):
"""Split text into chunks of ~1500-1800 characters."""
if len(text) <= max_chunk_size:
return [text]
# Try to split by paragraphs first
paragraphs = re.split(r'\n+', text)
if len(paragraphs) > 1:
chunks = []
current = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(current) + len(para) + 1 <= max_chunk_size:
current = f"{current}\n{para}"
else:
if current:
chunks.append(current)
current = para
if current:
chunks.append(current)
return chunks
# Split by sentences
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) + 1 <= max_chunk_size:
current = f"{current} {sentence}"
else:
if current:
chunks.append(current)
current = sentence
if current:
chunks.append(current)
return chunks
def process_elements(elements, max_chunk_size=1800):
"""Process elements into chunks."""
# Merge blocks
merged = merge_elements(elements)
# Create chunks
chunks = []
chunk_index = 0
for el in merged:
text = el.get('text', '')
el_type = el.get('type', 'Unknown')
page = el.get('metadata', {}).get('page_number', '?')
# Skip very short chunks
if len(text.strip()) < 10:
continue
# Split long texts
parts = chunk_text(text, max_chunk_size)
for part in parts:
chunk_index += 1
chunks.append({
'index': chunk_index,
'type': el_type,
'page': page,
'text': part,
'size': len(part)
})
return chunks, merged
def main():
parsed_path = '/tmp/pol177_parsed.json'
output_path = '/tmp/pol177_clean_chunk.json'
print("Loading parsed data...")
elements = load_parsed_data(parsed_path)
print(f"Loaded {len(elements)} elements")
print("Processing...")
chunks, merged = process_elements(elements)
print(f"Generated {len(chunks)} chunks")
# Stats
if chunks:
sizes = [c['size'] for c in chunks]
print(f"Chunk sizes: min={min(sizes)}, max={max(sizes)}, avg={sum(sizes)/len(sizes):.0f}")
# Type distribution
types = {}
for c in chunks:
types[c['type']] = types.get(c['type'], 0) + 1
print(f"Chunk types: {types}")
# Show all chunks
print("\n=== All chunks ===")
for c in chunks:
print(f"[{c['index']}] {c['type']} (page {c['page']}, {c['size']} chars)")
print(f" {c['text'][:120]}")
print()
if __name__ == '__main__':
main()
+71 -2
View File
@@ -1,6 +1,7 @@
import sys import sys
import asyncio import asyncio
import logging import logging
from logging.handlers import RotatingFileHandler
import os import os
import psutil import psutil
import time import time
@@ -17,6 +18,13 @@ logging.basicConfig(
) )
logging.getLogger('trueconf.client.chatbot').propagate = False logging.getLogger('trueconf.client.chatbot').propagate = False
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
sd_workflow_logger = logging.getLogger('sd_workflow')
sd_workflow_logger.setLevel(logging.INFO)
if not sd_workflow_logger.handlers:
handler = RotatingFileHandler(os.path.join(os.path.dirname(__file__), 'logs', 'sd_workflow.log'), maxBytes=10*1024*1024, backupCount=5)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
sd_workflow_logger.addHandler(handler)
# Добавляем путь к проекту в sys.path # Добавляем путь к проекту в sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -71,7 +79,7 @@ log_ram_usage("Старт скрипта (базовый вес)")
from photo_bot.handlers import router as photo_router, PHOTO_MAIN_MENU_TEXT from photo_bot.handlers import router as photo_router, PHOTO_MAIN_MENU_TEXT
log_ram_usage("После импорта photo_bot") log_ram_usage("После импорта photo_bot")
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT, sd_sessions
log_ram_usage("После импорта service_desk") log_ram_usage("После импорта service_desk")
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
@@ -112,7 +120,68 @@ async def main_menu(msg: Message):
USER_MENU_SHOWN[user_id] = True USER_MENU_SHOWN[user_id] = True
set_state(user_id, "SD_MODE") set_state(user_id, "SD_MODE")
log_menu_stats(user_id, "Service Desk", "Вход") log_menu_stats(user_id, "Service Desk", "Вход")
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html") # Помечаем сообщение как обработанное, чтобы sd_module_handler не перезаписал
msg.handled = True
# Инициализируем сессию для service_desk
if user_id not in sd_sessions:
sd_sessions[user_id] = {"step": "choose_template", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}, "template_menu_text": ""}
# Загружаем шаблоны и встраиваем их в текст
try:
import json, asyncio, httpx, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Импортируем SD_URL и SD_TOKEN из config
import importlib.util
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.abspath(__file__)), "config", "config.py"))
custom_config = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_config)
SD_URL = custom_config.SD_URL
SD_TOKEN = custom_config.SD_TOKEN
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
params = {"input_data": json.dumps(input_data)}
url = f"{SD_URL}/api/v3/request_templates"
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(url, headers=headers, params=params, timeout=30.0)
if resp.status_code == 200:
data = resp.json()
all_templates = data.get("request_templates", [])
pre_filtered = [
t for t in all_templates
if t.get("status") == "ACTIVE"
and not t.get("inactive", False)
and str(t.get("id")) != "2"
]
async def check_template(t, cl):
try:
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
if t_resp.status_code == 200:
detailed = t_resp.json().get("request_template", {})
return t if detailed.get("show_to_requester") is True else None
except Exception:
pass
return None
tasks = [check_template(t, client) for t in pre_filtered]
results = await asyncio.gather(*tasks)
templates = [t for t in results if t is not None]
template_lines = ""
for idx, t in enumerate(templates):
num = idx + 1
digit = f"{num}" if num <= 9 else "0⃣" if num == 10 else str(num)
template_lines += f"{digit} - {t['name']}\n"
# Сохраняем шаблоны в sd_sessions для sd_module_handler
sd_sessions[user_id]["templates"] = templates
sd_sessions[user_id]["template_names"] = {str(i+1): str(t["id"]) for i, t in enumerate(templates)}
sd_sessions[user_id]["template_menu_text"] = template_lines.strip()
template_text = f"🎫 Режим создания заявки в Service Desk\n\nВыберите, пожалуйста, шаблон подходящий под вашу проблему\n\n{template_lines}"
await msg.answer(template_text, parse_mode="html")
msg.handled = True
else:
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
msg.handled = True
except Exception as e:
sd_workflow_logger.error(f"Ошибка шаблонов: {e}")
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
msg.handled = True
return return
elif action == "PHOTO": elif action == "PHOTO":
USER_MENU_SHOWN[user_id] = True USER_MENU_SHOWN[user_id] = True
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Парсинг регламента через Unstructured API.
Делает один проход (strategy=hi_res) и разделяет элементы на текст и таблицы.
"""
import json
import re
import requests
from pathlib import Path
# === НАСТРОЙКИ ===
UNSTRUCTURED_API = "http://192.168.1.103:8005/general/v0/general"
PDF_PATH = "/opt/documents_xk/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.pdf"
OUTPUT_PATH = "/opt/okf-regulations/concepts/pol123_parsed.json"
def parse_document(pdf_path: str) -> dict:
"""Отправляет PDF в API (hi_res) и возвращает рассортированные данные."""
print(f" Отправка файла {Path(pdf_path).name} в Unstructured API (hi_res)...")
with open(pdf_path, "rb") as f:
files = {"files": (Path(pdf_path).name, f, "application/pdf")}
data = {
"strategy": "hi_res",
"coordinates": "true"
}
try:
response = requests.post(UNSTRUCTURED_API, files=files, data=data, timeout=300)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f" ❌ Ошибка Unstructured API: {e}")
return {"text": [], "tables": []}
elements = response.json()
if isinstance(elements, dict):
elements = elements.get("elements", [])
text_elements = []
real_tables = []
print(" Сортировка и фильтрация элементов...")
for el in elements:
el_type = el.get("type")
if el_type == "Table":
meta = el.get("metadata", {})
html = meta.get("text_as_html", "")
# Строгий фильтр на наличие HTML-таблицы
if html and "<table" in html.lower():
row_count = len(re.findall(r'<tr[^>]*>', html, re.IGNORECASE)) if html else None
col_count = len(re.findall(r'<td[^>]*>|<th[^>]*>', html[:500], re.IGNORECASE)) if html else None
real_tables.append({
"page": meta.get("page_number"),
"text": el.get("text", ""),
"html": html,
"row_count": row_count,
"col_count": col_count,
})
else:
# Если таблица оказалась фейковой, отправляем её текст в общий котел
text_elements.append(el)
else:
# Всё остальное (Title, NarrativeText, ListItem и т.д.)
text_elements.append(el)
return {"text": text_elements, "tables": real_tables}
def main():
print("=" * 60)
print(f"📄 Парсинг документа: {Path(PDF_PATH).name}")
print("=" * 60)
parsed_data = parse_document(PDF_PATH)
print(f"\n[Итоги]")
print(f" ✅ Получено текстовых элементов: {len(parsed_data['text'])}")
print(f" ✅ Найдено валидных таблиц: {len(parsed_data['tables'])}")
result = {
"document": "ПОЛ-123",
"title": "Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2",
"text": parsed_data["text"],
"tables": parsed_data["tables"],
"metadata": {
"unstructured_api": UNSTRUCTURED_API,
"strategy": "hi_res",
"timestamp": "2026-07-06",
},
}
Path(OUTPUT_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(f"\n✅ Сохранено в {OUTPUT_PATH}")
print("=" * 60)
if __name__ == "__main__":
main()
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
Универсальный скрипт для улучшения MD-файлов документов АО «ХК «Сибцем».
Применяет паттерны:
1. Удаляет футеры страниц
2. Форматирует блок "УТВЕРЖДАЮ"
3. Выравнивает иерархию заголовков
4. Объединяет таблицы терминов
5. Добавляет разделители между разделами
6. Приводит таблицы к единому виду
Использование:
python3 optimize_document.py <путь_к_оригиналу_на_сервере>
Пример:
python3 optimize_document.py "/opt/documents_xk/АОХКСибцем/07 Служба.../08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.md"
"""
import subprocess
import re
import sys
import time
import requests
import urllib.parse
# Конфигурация
SSH_KEY = "/home/hermes/.ssh/192.168.1.106"
SSH_USER = "administrator@192.168.1.106"
SERVER_BASE = "/opt/documents_xk/АОХКСибцем"
FILEBROWSER_URL = "https://smb.dddennnisss.ru"
FILEBROWSER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjoyMzAyNzM1NzA3LCJpYXQiOjE3ODQzMzU3MDcsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOmZhbHNlLCJkZWxldGUiOnRydWUsImNyZWF0ZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9fQ.QBKlh3NQJeh4aeMiKLkhg0tF8hxk-Oh2KLsXLSbuvm8"
SOURCE_NAME = "SSD-Storage"
def run_ssh(command):
"""Выполнить команду на сервере через SSH."""
result = subprocess.run(
["ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no",
SSH_USER, command],
capture_output=True, text=True, timeout=30
)
return result
def scp_download(remote_path, local_path):
"""Скачать файл с сервера."""
result = subprocess.run(
["scp", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no",
f"{SSH_USER}:{remote_path}", local_path],
capture_output=True, text=True, timeout=60
)
return result.returncode == 0
def fix_headings(content):
"""Выровнять иерархию заголовков: все на уровень ##."""
content = re.sub(r'^(#+)\s+(.+)$', r'## \2', content, flags=re.MULTILINE)
return content
def add_dividers(content):
"""Добавить разделители между основными разделами."""
# Разделитель перед Оглавлением
content = re.sub(
r'(\n)(## )(Оглавление)',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед разделами 1-9
content = re.sub(
r'(\n)(## )(\s*(?:1\.|2\.|3\.|4\.|5\.|6\.|7\.|8\.|9\.))',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед Приложениями
content = re.sub(
r'(\n)(## )(Приложение)',
r'\1\n---\n\1\2\3',
content
)
# Разделитель перед Листом согласований
content = re.sub(
r'(\n)(## )(Лист согласований)',
r'\1\n---\n\1\2\3',
content
)
return content
def fix_tables(content):
"""Привести все таблицы к единому виду с thead/tbody."""
def fix_table(table):
if '<thead>' in table and '<tbody>' in table:
return table
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
if not rows:
return table
header = '<thead><tr>' + rows[0] + '</tr></thead><tbody>'
body = ''
for row in rows[1:]:
body += '<tr>' + row + '</tr>'
body += '</tbody>'
return '<table>' + header + body + '</table>'
return re.sub(r'<table>.*?</table>', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL)
def remove_footers(content):
"""Удалить футеры страниц."""
# Удалить "АО «ХК «Сибцем»\nТип документа: ...\nНаименование документа: ..."
content = re.sub(
r'АО «ХК «Сибцем»\nТип документа:.*?\nНаименование документа:.*?\n\n',
'',
content,
flags=re.DOTALL
)
# Удалить "Стр. X из Y"
content = re.sub(r'Стр\. \d+ из \d+\n\n', '', content)
# Удалить "Дата утверждения: DD.MM.YYYY"
content = re.sub(r'Дата утверждения: \d{2}\.\d{2}\.\d{4}\n\n', '', content)
# Удалить "Ведущее подразделение: ..."
content = re.sub(r'Ведущее подразделение:.*?\n\n', '', content)
return content
def fix_approval_block(content):
"""Форматировать блок УТВЕРЖДАЮ."""
# Паттерн 1: "УТВЕРЖДАЮ\nПрезидент\n\nО.В. Шарыкин\n\n«XX» месяц год г."
approval_patterns = [
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
]
for pattern in approval_patterns:
match = re.search(pattern, content)
if match:
old_text = match.group(0)
new_text = f'''<div align="right">
<strong>УТВЕРЖДАУ</strong><br>
Президент<br>
О.В. Шарыкин<br>
{old_text.split("«")[1].split("г.")[0]} г.
</div>'''
content = content.replace(old_text, new_text)
break
return content
def merge_terms_tables(content):
"""Объединить разбитые таблицы терминов."""
# Поиск таблиц терминов
terms_pattern = r'(<table>.*?Термины.*?</table>)'
matches = re.findall(terms_pattern, content, re.DOTALL)
if len(matches) > 1:
# Объединяем все таблицы терминов в одну
merged = '<table><thead><tr><th>Термин</th><th>Определение</th></tr></thead><tbody>'
for table in matches:
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
for row in rows[1:]: # Пропускаем заголовок
merged += row
merged += '</tbody></table>'
content = content.replace(matches[0], merged)
return content
def optimize_document(md_path):
"""Основная функция оптимизации документа."""
start_time = time.time()
print(f"📄 Обработка документа: {md_path}")
print("=" * 60)
# Шаг 1: Скачать MD с сервера
print("\n📥 Шаг 1: Скачивание MD...")
result = run_ssh(f"cat '{md_path}'")
if result.returncode != 0:
print(f"✗ Ошибка при скачивании: {result.stderr}")
return
md_content = result.stdout
print(f"✓ MD скачан: {len(md_content)} символов")
# Шаг 2: Применить паттерны
print("\n🔧 Шаг 2: Применение паттернов...")
# 2.1 Удалить футеры
content = remove_footers(md_content)
print(" ✓ Футеры удалены")
# 2.2 Выровнять иерархию заголовков
content = fix_headings(content)
print(" ✓ Иерархия заголовков выровнена")
# 2.3 Добавить разделители
content = add_dividers(content)
print(" ✓ Разделители добавлены")
# 2.4 Привести таблицы к единому виду
content = fix_tables(content)
print(" ✓ Таблицы приведены к единому виду")
# 2.5 Объединить таблицы терминов
content = merge_terms_tables(content)
print(" ✓ Таблицы терминов объединены")
# 2.6 Форматировать блок УТВЕРЖДАЮ
content = fix_approval_block(content)
print(" ✓ Блок УТВЕРЖДАЮ отформатирован")
# Сохранить улучшенную версию
improved_path = "/tmp/improved_document.md"
with open(improved_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"\n📊 Результаты:")
print(f" Исходный размер: {len(md_content)} символов")
print(f" Улучшенный размер: {len(content)} символов")
# Показать структуру
sections = re.findall(r'^(##)\s+(.+)$', content, re.MULTILINE)
print(f" Разделов: {len(sections)}")
tables = re.findall(r'<table>', content)
print(f" Таблиц: {len(tables)}")
# Показать первые 300 символов
print(f"\n📝 Начало файла:")
print(content[:300].replace('\n', '\\n'))
# Загрузить в filebrowser
print("\n📤 Шаг 3: Загрузка в filebrowser...")
upload_to_filebrowser(content, md_path)
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Готово! Время выполнения: {elapsed:.1f} сек")
print(f"{'=' * 60}")
def upload_to_filebrowser(content, md_path):
"""Загрузить файл в filebrowser."""
# Извлечь имя файла из пути
filename = md_path.split('/')[-1]
# Добавить "_improved" перед расширением
if filename.endswith('.md'):
improved_filename = filename[:-3] + '_improved.md'
else:
improved_filename = filename + '_improved'
session = requests.Session()
session.cookies.set("filebrowser_quantum_jwt", FILEBROWSER_TOKEN, domain="smb.dddennnisss.ru")
url = f"{FILEBROWSER_URL}/api/resources"
params = {"path": improved_filename, "source": SOURCE_NAME}
r = session.put(url, params=params, data=content)
if r.status_code == 200:
print(f"✓ Файл загружен: {improved_filename}")
# Проверить
r = session.get(f"{FILEBROWSER_URL}/api/resources", params={"path": "/", "source": SOURCE_NAME})
if r.status_code == 200:
data = r.json()
items = data.get("files", []) + data.get("folders", [])
for item in items:
if improved_filename in item["name"]:
print(f"✓ Размер: {item['size']} байт")
break
else:
print(f"✗ Ошибка загрузки: {r.status_code} - {r.text[:200]}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование:")
print(" python3 optimize_document.py <путь_к_оригиналу_на_сервере>")
print("\nПример:")
print(" python3 optimize_document.py \"/opt/documents_xk/АОХКСибцем/07 Служба.../08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.md\"")
sys.exit(1)
md_path = sys.argv[1]
optimize_document(md_path)
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
Универсальный скрипт для улучшения MD-файлов документов АО «ХК «Сибцем».
Выполняется на сервере 192.168.1.106.
Применяет паттерны:
1. Удаляет футеры страниц
2. Форматирует блок "УТВЕРЖДАЮ"
3. Выравнивает иерархию заголовков
4. Объединяет таблицы терминов
5. Добавляет разделители между разделами
6. Приводит таблицы к единому виду
Использование:
python3 /opt/trueconf_bot/search_bot/optimize_documents.py <путь_к_каталогу>
Пример:
python3 /opt/trueconf_bot/search_bot/optimize_documents.py "/opt/documents_xk/ready_md/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/"
"""
import os
import re
import sys
import time
import requests
import base64
# Конфигурация
FILEBROWSER_URL = "https://smb.dddennnisss.ru"
FILEBROWSER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjoyMzAyNzM1NzA3LCJpYXQiOjE3ODQzMzU3MDcsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOmZhbHNlLCJkZWxldGUiOnRydWUsImNyZWF0ZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9fQ.QBKlh3NQJeh4aeMiKLkhg0tF8hxk-Oh2KLsXLSbuvm8"
SOURCE_NAME = "SSD-Storage"
def remove_footers(content):
"""Удалить футеры страниц."""
# Удалить "АО «ХК «Сибцем»\nТип документа: ...\nНаименование документа: ..."
content = re.sub(
r'АО «ХК «Сибцем»\nТип документа:.*?\nНаименование документа:.*?\n\n',
'',
content,
flags=re.DOTALL
)
# Удалить "Стр. X из Y"
content = re.sub(r'Стр\. \d+ из \d+\n\n', '', content)
# Удалить "Дата утверждения: DD.MM.YYYY"
content = re.sub(r'Дата утверждения: \d{2}\.\d{2}\.\d{4}\n\n', '', content)
# Удалить "Ведущее подразделение: ..."
content = re.sub(r'Ведущее подразделение:.*?\n\n', '', content)
return content
def fix_headings(content):
"""Выровнять иерархию заголовков: все на уровень ##."""
content = re.sub(r'^(#+)\s+(.+)$', r'## \2', content, flags=re.MULTILINE)
return content
def add_dividers(content):
"""Добавить разделители между основными разделами."""
# Разделитель перед Оглавлением
content = re.sub(
r'(\n)(## )(Оглавление)',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед разделами 1-9
content = re.sub(
r'(\n)(## )(\s*(?:1\.|2\.|3\.|4\.|5\.|6\.|7\.|8\.|9\.))',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед Приложениями
content = re.sub(
r'(\n)(## )(Приложение)',
r'\1\n---\n\1\2\3',
content
)
# Разделитель перед Листом согласований
content = re.sub(
r'(\n)(## )(Лист согласований)',
r'\1\n---\n\1\2\3',
content
)
return content
def fix_tables(content):
"""Привести все таблицы к единому виду с thead/tbody."""
def fix_table(table):
if '<thead>' in table and '<tbody>' in table:
return table
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
if not rows:
return table
header = '<thead><tr>' + rows[0] + '</tr></thead><tbody>'
body = ''
for row in rows[1:]:
body += '<tr>' + row + '</tr>'
body += '</tbody>'
return '<table>' + header + body + '</table>'
return re.sub(r'<table>.*?</table>', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL)
def merge_terms_tables(content):
"""Объединить разбитые таблицы терминов."""
# Поиск таблиц терминов
terms_pattern = r'(<table>.*?Термины.*?</table>)'
matches = re.findall(terms_pattern, content, re.DOTALL)
if len(matches) > 1:
# Объединяем все таблицы терминов в одну
merged = '<table><thead><tr><th>Термин</th><th>Определение</th></tr></thead><tbody>'
for table in matches:
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
for row in rows[1:]: # Пропускаем заголовок
merged += row
merged += '</tbody></table>'
content = content.replace(matches[0], merged)
return content
def fix_approval_block(content):
"""Форматировать блок УТВЕРЖДАЮ."""
# Паттерн 1: "УТВЕРЖДАЮ\nПрезидент\n\nО.В. Шарыкин\n\n«XX» месяц год г."
approval_patterns = [
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
]
for pattern in approval_patterns:
match = re.search(pattern, content)
if match:
old_text = match.group(0)
new_text = f'''<div align="right">
<strong>УТВЕРЖДАУ</strong><br>
Президент<br>
О.В. Шарыкин<br>
{old_text.split("«")[1].split("г.")[0]} г.
</div>'''
content = content.replace(old_text, new_text)
break
return content
def upload_to_filebrowser(content, filename):
"""Загрузить файл в filebrowser."""
# Добавить "_improved" перед расширением
if filename.endswith('.md'):
improved_filename = filename[:-3] + '_improved.md'
else:
improved_filename = filename + '_improved'
session = requests.Session()
session.cookies.set("filebrowser_quantum_jwt", FILEBROWSER_TOKEN, domain="smb.dddennnisss.ru")
url = f"{FILEBROWSER_URL}/api/resources"
params = {"path": improved_filename, "source": SOURCE_NAME}
r = session.put(url, params=params, data=content)
if r.status_code == 200:
print(f"✓ Файл загружен: {improved_filename}")
# Проверить
r = session.get(f"{FILEBROWSER_URL}/api/resources", params={"path": "/", "source": SOURCE_NAME})
if r.status_code == 200:
data = r.json()
items = data.get("files", []) + data.get("folders", [])
for item in items:
if improved_filename in item["name"]:
print(f"✓ Размер: {item['size']} байт")
break
else:
print(f"✗ Ошибка загрузки: {r.status_code} - {r.text[:200]}")
return improved_filename
def optimize_document(md_path):
"""Основная функция оптимизации документа."""
start_time = time.time()
print(f"\n📄 Обработка документа: {md_path.split('/')[-1]}")
print("=" * 60)
# Шаг 1: Читать MD файл
print("\n📥 Шаг 1: Чтение MD...")
try:
with open(md_path, 'r', encoding='utf-8') as f:
md_content = f.read()
except Exception as e:
print(f"✗ Ошибка при чтении: {e}")
return
print(f"✓ MD прочитан: {len(md_content)} символов")
# Шаг 2: Применить паттерны
print("\n🔧 Шаг 2: Применение паттернов...")
# 2.1 Удалить футеры
content = remove_footers(md_content)
print(" ✓ Футеры удалены")
# 2.2 Выровнять иерархию заголовков
content = fix_headings(content)
print(" ✓ Иерархия заголовков выровнена")
# 2.3 Добавить разделители
content = add_dividers(content)
print(" ✓ Разделители добавлены")
# 2.4 Привести таблицы к единому виду
content = fix_tables(content)
print(" ✓ Таблицы приведены к единому виду")
# 2.5 Объединить таблицы терминов
content = merge_terms_tables(content)
print(" ✓ Таблицы терминов объединены")
# 2.6 Форматировать блок УТВЕРЖДАЮ
content = fix_approval_block(content)
print(" ✓ Блок УТВЕРЖДАУ отформатирован")
# Сохранить улучшенную версию
improved_path = md_path.replace('.md', '_improved.md')
with open(improved_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"\n📊 Результаты:")
print(f" Исходный размер: {len(md_content)} символов")
print(f" Улучшенный размер: {len(content)} символов")
# Показать структуру
sections = re.findall(r'^(##)\s+(.+)$', content, re.MULTILINE)
print(f" Разделов: {len(sections)}")
tables = re.findall(r'<table>', content)
print(f" Таблиц: {len(tables)}")
# Показать первые 300 символов
print(f"\n📝 Начало файла:")
print(content[:300].replace('\n', '\\n'))
# Загрузить в filebrowser
print("\n📤 Шаг 3: Загрузка в filebrowser...")
filename = md_path.split('/')[-1]
upload_to_filebrowser(content, filename)
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Готово! Время выполнения: {elapsed:.1f} сек")
print(f"{'=' * 60}")
def process_directory(directory):
"""Обработать все MD файлы в каталоге."""
start_time = time.time()
print(f"📂 Обработка каталога: {directory}")
print("=" * 60)
# Найти все MD файлы
md_files = []
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith('.md'):
md_files.append(os.path.join(root, file))
print(f"Найдено {len(md_files)} MD файлов")
# Обработать каждый файл
for md_path in md_files:
try:
optimize_document(md_path)
except Exception as e:
print(f"✗ Ошибка при обработке {md_path}: {e}")
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Обработка каталога завершена! Общее время: {elapsed:.1f} сек")
print(f"{'=' * 60}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование:")
print(" python3 optimize_documents.py <путь_к_каталогу>")
print("\nПример:")
print(' python3 optimize_documents.py "/opt/documents_xk/ready_md/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/"')
sys.exit(1)
directory = sys.argv[1]
process_directory(directory)
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Комбинированный парсинг ПОЛ-177:
- Текст: Unstructured API (192.168.1.103:8005)
- Таблицы: pypdf (локально)
Сохраняет результат в /opt/okf-regulations/concepts/pol177_parsed.json
"""
import json
import subprocess
from pathlib import Path
# === НАСТРОЙКИ ===
UNSTRUCTURED_API = "http://192.168.1.103:8005/general/v0/general"
PDF_PATH = "/opt/documents_xk/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/10 Положение Хранение электронных документов от 03.04.2024 № ПОЛ-177.pdf"
OUTPUT_PATH = "/opt/okf-regulations/concepts/pol177_parsed.json"
def fetch_unstructured(pdf_path: str) -> list[dict]:
"""Получаем элементы текста из Unstructured API (103)."""
cmd = [
"curl", "-X", "POST", UNSTRUCTURED_API,
"-F", f"files=@{pdf_path}",
"-F", "strategy=fast",
"-F", "output_format=json",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Unstructured API error: {result.stderr}")
return []
return json.loads(result.stdout)
def extract_tables_pypdf(pdf_path: str) -> list[dict]:
"""Извлекаем таблицы через pypdf (локально на 106)."""
try:
from pypdf import PdfReader
except ImportError:
import subprocess
subprocess.run(["pip3", "install", "pypdf", "--user"], check=True)
from pypdf import PdfReader
reader = PdfReader(pdf_path)
tables = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text and ("таблица" in text.lower() or "стр" in text.lower()):
tables.append({"page": i + 1, "text": text})
return tables
def main():
print("=" * 60)
print("📄 Парсинг ПОЛ-177")
print("=" * 60)
# 1. Текст через Unstructured API
print("\n[1/2] Запрос текста через Unstructured API (103)...")
elements = fetch_unstructured(PDF_PATH)
print(f" ✅ Получено {len(elements)} элементов")
# 2. Таблицы через pypdf
print("\n[2/2] Извлечение таблиц через pypdf (106)...")
tables = extract_tables_pypdf(PDF_PATH)
print(f" ✅ Найдено {len(tables)} страниц с текстом")
# 3. Сохраняем комбинированный результат
result = {
"document": "ПОЛ-177",
"title": "Положение Хранение электронных документов от 03.04.2024 № ПОЛ-177",
"unstructured": elements,
"tables": tables,
"metadata": {
"unstructured_api": UNSTRUCTURED_API,
"strategy": "fast",
"timestamp": "2026-07-06",
},
}
Path(OUTPUT_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(f"\n✅ Сохранено в {OUTPUT_PATH}")
print("=" * 60)
if __name__ == "__main__":
main()
+133 -40
View File
@@ -23,7 +23,7 @@ if not sd_workflow_logger.handlers:
handler.setFormatter(formatter) handler.setFormatter(formatter)
sd_workflow_logger.addHandler(handler) sd_workflow_logger.addHandler(handler)
# ---------------------------------- # ----------------------------------
# Абсолютный импорт конфигурации # Абсолютный импорт конфигурации
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py")) spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
custom_config = importlib.util.module_from_spec(spec) custom_config = importlib.util.module_from_spec(spec)
@@ -44,14 +44,14 @@ from utils.texts import (
sd_uploading_file, sd_uploading_file,
sd_file_attached, sd_file_attached,
sd_file_upload_error, sd_file_upload_error,
) )
from utils.texts import UNKNOWN_MAIN_CMD_TEXT from utils.texts import UNKNOWN_MAIN_CMD_TEXT
# 🔌 Импортируем централизованную функцию сбора статистики из main # 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats from utils.stats_logger import log_menu_stats
# Таймаут ожидания вложений после текста (секунды) # Таймаут ожидания вложений после текста (секунды)
ATTACHMENT_WAIT_TIMEOUT = 2.0 ATTACHMENT_WAIT_TIMEOUT = 2.0
SD_DISPATCHER_SYSTEM_PROMPT = ( SD_DISPATCHER_SYSTEM_PROMPT = (
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n" "Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
"ПРАВИЛА:\n" "ПРАВИЛА:\n"
@@ -59,7 +59,7 @@ SD_DISPATCHER_SYSTEM_PROMPT = (
"2. Отвечать на английском языке ЗАПРЕЩЕНО.\n" "2. Отвечать на английском языке ЗАПРЕЩЕНО.\n"
"3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n" "3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n"
"4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n" "4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n"
"5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.") "5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.")
# Глушим системные предупреждения # Глушим системные предупреждения
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -67,6 +67,51 @@ logger = logging.getLogger(__name__)
router = Router() router = Router()
sd_sessions = {} sd_sessions = {}
async def get_sd_templates():
url = f"{SD_URL}/api/v3/request_templates"
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
params = {"input_data": json.dumps(input_data)}
try:
async with httpx.AsyncClient(verify=False) as client:
response = await client.get(url, headers=headers, params=params, timeout=30.0)
if response.status_code != 200:
sd_workflow_logger.error(f"Ошибка API шаблонов: HTTP {response.status_code} - {response.text}")
return []
data = response.json()
all_templates = data.get("request_templates", [])
pre_filtered = [
t for t in all_templates
if t.get("status") == "ACTIVE"
and not t.get("inactive", False)
and str(t.get("id")) != "2"
]
sd_workflow_logger.info(f"Шаблонов в системе: {len(all_templates)} | Активных: {len(pre_filtered)}")
async def check_template(t):
try:
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
if t_resp.status_code == 200:
detailed = t_resp.json().get("request_template", {})
if detailed.get("show_to_requester") is True:
sc = detailed.get("service_category", {})
t["service_category"] = sc
t["request"] = detailed.get("request", {})
return t if detailed.get("show_to_requester") is True else None
except Exception as e:
sd_workflow_logger.warning(f"Ошибка деталей шаблона ID {t['id']}: {e}")
return None
tasks = [check_template(t) for t in pre_filtered]
results = await asyncio.gather(*tasks)
active_templates = [t for t in results if t is not None]
sd_workflow_logger.info(f"Шаблоны с show_to_requester=True: {len(active_templates)}")
for temp in active_templates:
sd_workflow_logger.info(f" ID: {temp['id']} | Name: {temp['name']}")
return active_templates
except Exception as e:
sd_workflow_logger.error(f"Ошибка загрузки шаблонов: {e}")
return []
# ================================================ # ================================================
# БИЗНЕС-ЛОГИКА # БИЗНЕС-ЛОГИКА
# ================================================ # ================================================
@@ -97,14 +142,14 @@ async def generate_smart_subject(text: str) -> str:
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t))) def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР" if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}" user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
messages = [ messages = [
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT}, {"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
{"role": "user", "content": user_msg} {"role": "user", "content": user_msg}
] ]
try: try:
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0} payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}") sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
@@ -128,18 +173,36 @@ async def generate_smart_subject(text: str) -> str:
sd_workflow_logger.error(f"📝 [Subject Gen Error] Exception: {e}\n{traceback.format_exc()}") sd_workflow_logger.error(f"📝 [Subject Gen Error] Exception: {e}\n{traceback.format_exc()}")
return "Заявка из КЛЕВЕР" return "Заявка из КЛЕВЕР"
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str): async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str, template_id: str | None = None, templates: list | None = None):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"} headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
endpoint = f"{SD_URL}/api/v3/requests" endpoint = f"{SD_URL}/api/v3/requests"
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>" html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
async def _send_req(email, current_city): async def _send_req(email, current_city, template_id, templates):
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}} req_payload = {"request": {"subject": subject, "description": html_desc, "requester": {"name": email}}}
async with httpx.AsyncClient(verify=False) as client: if template_id:
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0) req_payload["request"]["template"] = {"id": template_id}
resp = await _send_req(requester_email, city) # Найти service_category по шаблону
data = resp.json() if resp.status_code in [200, 201] else {} for t in templates:
if data.get("response_status", {}).get("status_code") != 2000: if str(t.get("id")) == str(template_id):
resp = await _send_req(requester_email, "Кемерово") sc = t.get("service_category", {})
if sc:
req_payload["request"]["service_category"] = sc
break
if "udf_pick_301" in req_payload["request"]:
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
elif "udf_fields" in req_payload["request"] and current_city:
req_payload["request"]["udf_fields"]["udf_pick_301"] = current_city
else:
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
async with httpx.AsyncClient(verify=False) as client:
resp = await client.post(endpoint, headers=headers, params={"input_data": json.dumps(req_payload)}, timeout=15.0)
if resp.status_code not in [200, 201]:
sd_workflow_logger.error(f"API Error: status={resp.status_code} body={resp.text}")
return resp
resp = await _send_req(requester_email, city, template_id, templates)
data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") != 2000:
resp = await _send_req(requester_email, "Кемерово", template_id, templates)
data = resp.json() if resp.status_code in [200, 201] else {} data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") == 2000: if data.get("response_status", {}).get("status_code") == 2000:
return data.get("request", {}).get("id") return data.get("request", {}).get("id")
@@ -178,7 +241,7 @@ async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
return False return False
finally: finally:
if downloaded_path and os.path.exists(downloaded_path): if downloaded_path and os.path.exists(downloaded_path):
os.remove(downloaded_path) os.remove(downloaded_path)
def _extract_text_from_content(content): def _extract_text_from_content(content):
"""Извлечение текста из msg.content.""" """Извлечение текста из msg.content."""
@@ -220,6 +283,8 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login): async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений.""" """Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
session["step"] = "creating_ticket" session["step"] = "creating_ticket"
template_id = session.get("template_id")
sd_workflow_logger.info(f"[Create] Template ID: {template_id}")
try: try:
ad_user = await asyncio.to_thread(get_ad_user_sync, login) ad_user = await asyncio.to_thread(get_ad_user_sync, login)
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}") sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
@@ -228,7 +293,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
subject = await generate_smart_subject(msg_text) subject = await generate_smart_subject(msg_text)
sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}") sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}")
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city) ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city, template_id, session.get("templates", []))
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}") sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
if ticket_id: if ticket_id:
@@ -273,10 +338,10 @@ async def _wait_for_attachments_and_create(user_id, session):
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT) await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
except asyncio.CancelledError: except asyncio.CancelledError:
return # Таймер сброшен, прерываем выполнение текущей таски return # Таймер сброшен, прерываем выполнение текущей таски
if session.get("step") != "waiting_for_attachments": if session.get("step") != "waiting_for_attachments":
return return
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}") sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
msg = session.get("msg") msg = session.get("msg")
login = session.get("login") login = session.get("login")
@@ -289,23 +354,23 @@ async def sd_module_handler(msg: Message):
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}") sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
if states.get_state(user_id) != "SD_MODE": if states.get_state(user_id) != "SD_MODE":
return return
msg_text = _extract_text_from_content(msg.content) msg_text = _extract_text_from_content(msg.content)
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment) inline_attachments = _extract_attachments_from_content(msg.content, is_attachment)
sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}") sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}")
msg.handled = True msg.handled = True
login = user_id.split("@")[0] if "@" in user_id else user_id login = user_id.split("@")[0] if "@" in user_id else user_id
cmd = msg_text.lower() cmd = msg_text.lower()
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ # 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
for raw_num, emoji_num in EMOJI_DIGITS.items(): for raw_num, emoji_num in EMOJI_DIGITS.items():
if cmd == emoji_num: if cmd == emoji_num:
cmd = raw_num cmd = raw_num
break break
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) --- # --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
if cmd in ["0", "9", "/start", "меню"]: if cmd in ["0", "9", "/start", "меню"]:
log_menu_stats(user_id, "Service Desk", "Выход в главное меню") log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
@@ -313,36 +378,64 @@ async def sd_module_handler(msg: Message):
sd_sessions.pop(user_id, None) sd_sessions.pop(user_id, None)
await msg.answer(MENU_TEXT, parse_mode="html") await msg.answer(MENU_TEXT, parse_mode="html")
return return
if user_id not in sd_sessions: if user_id not in sd_sessions:
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []} sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}}
session = sd_sessions[user_id] session = sd_sessions[user_id]
# --- ШАГ 0: ВЫБОР ШАБЛОНА ---
if session["step"] == "choose_template":
if cmd in ["skip", "пропустить", "без шаблона"]:
sd_workflow_logger.info("[Template] User skipped template selection")
session["step"] = "need_text"
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
if cmd in session.get("template_names", {}):
template_id = session["template_names"][cmd]
template_name = next((t["name"] for t in session.get("templates", []) if str(t["id"]) == str(template_id)), "Неизвестно")
sd_workflow_logger.info(f"[Template] User selected: {template_id} ({template_name})")
session["template_id"] = template_id
session["step"] = "need_text"
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
else:
await msg.answer(
f"Неверный номер. Выберите из списка:\n\n"
f"{session.get('template_menu_text', '')}",
parse_mode="html"
)
return
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА --- # --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
if session["step"] == "need_text": if session["step"] == "need_text":
# Пользователь только что вошёл в SD_MODE — показываем меню
if len(msg_text.strip()) == 0 and not inline_attachments:
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
if not msg_text.strip() and not inline_attachments: if not msg_text.strip() and not inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html") await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
return return
# Если только вложение без текста — сохраняем и ждем текст # Если только вложение без текста — сохраняем и ждем текст
if not msg_text.strip() and inline_attachments: if not msg_text.strip() and inline_attachments:
session["files_queue"].extend(inline_attachments) session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text") sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html") await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
return return
# Если пришел текст (с вложениями или без) # Если пришел текст (с вложениями или без)
if inline_attachments: if inline_attachments:
session["files_queue"].extend(inline_attachments) session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue") sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
# Настраиваем параметры сессии для ожидания # Настраиваем параметры сессии для ожидания
session["step"] = "waiting_for_attachments" session["step"] = "waiting_for_attachments"
session["msg_text"] = msg_text session["msg_text"] = msg_text
session["msg"] = msg session["msg"] = msg
session["user_id"] = user_id session["user_id"] = user_id
session["login"] = login session["login"] = login
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота # Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
await msg.answer(SD_CREATING, parse_mode="html") await msg.answer(SD_CREATING, parse_mode="html")
@@ -352,7 +445,7 @@ async def sd_module_handler(msg: Message):
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session)) session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...") sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
return return
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) --- # --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
if session["step"] == "waiting_for_attachments": if session["step"] == "waiting_for_attachments":
if inline_attachments: if inline_attachments:
@@ -361,14 +454,14 @@ async def sd_module_handler(msg: Message):
if msg_text.strip(): if msg_text.strip():
session["msg_text"] = session["msg_text"] + " " + msg_text.strip() session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
sd_workflow_logger.info(f"📝 [SD] Additional text appended") sd_workflow_logger.info(f"📝 [SD] Additional text appended")
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед) # Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
if "timer_task" in session and not session["timer_task"].done(): if "timer_task" in session and not session["timer_task"].done():
session["timer_task"].cancel() session["timer_task"].cancel()
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session)) session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s") sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
return return
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") --- # --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
if session["step"] == "creating_ticket": if session["step"] == "creating_ticket":
if inline_attachments: if inline_attachments:
@@ -377,7 +470,7 @@ async def sd_module_handler(msg: Message):
session["post_create_queue"].extend(inline_attachments) session["post_create_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.") sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
return return
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ --- # --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
if session["step"] == "ticket_created": if session["step"] == "ticket_created":
if inline_attachments: if inline_attachments:
@@ -393,4 +486,4 @@ async def sd_module_handler(msg: Message):
await msg.answer(sd_file_upload_error(), parse_mode="html") await msg.answer(sd_file_upload_error(), parse_mode="html")
elif msg_text.strip(): elif msg_text.strip():
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return return
+5
View File
@@ -170,6 +170,11 @@ SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS) SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
SD_CREATING = _sd_creating() SD_CREATING = _sd_creating()
def _sd_ticket_create_error(emojis: dict) -> str:
return (
f"❌ <b>Не удалось создать заявку.</b>\n\n" f"Пожалуйста, повторите попытку позже.\n\n" f"{emojis['0']}В главное меню"
)
def sd_ticket_create_error() -> str: def sd_ticket_create_error() -> str:
return _sd_ticket_create_error(EMOJI_DIGITS) return _sd_ticket_create_error(EMOJI_DIGITS)