From e7dbe6fc25bc910cd2d74e299ed7bf2b22deaa69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=9A=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Mon, 20 Jul 2026 15:04:45 +0700 Subject: [PATCH] Rebase: merge all SD commits into service_desk branch --- __pycache__/main.cpython-311.pyc | Bin 0 -> 18337 bytes search_bot/document_parser.py | 835 ++++++++++++++++++ search_bot/optimize_documents.py | 298 +++++++ .../__pycache__/handlers.cpython-311.pyc | Bin 0 -> 37649 bytes utils/__pycache__/texts.cpython-311.pyc | Bin 0 -> 35912 bytes 5 files changed, 1133 insertions(+) create mode 100644 __pycache__/main.cpython-311.pyc create mode 100644 search_bot/document_parser.py create mode 100644 search_bot/optimize_documents.py create mode 100644 service_desk/__pycache__/handlers.cpython-311.pyc create mode 100644 utils/__pycache__/texts.cpython-311.pyc diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fa6c9247178050c41885aa1c9fc56f933d76c46 GIT binary patch literal 18337 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 literal 0 HcmV?d00001 diff --git a/search_bot/document_parser.py b/search_bot/document_parser.py new file mode 100644 index 0000000..9cb7887 --- /dev/null +++ b/search_bot/document_parser.py @@ -0,0 +1,835 @@ +#!/usr/bin/env python3 +""" +Unified Document Parser — объединённый парсер DOCX и PDF. + +Поддерживает два режима: + 1. API mode (по умолчанию) — Unstructured API server (smart_indexer / parse_pdf) + 2. Local mode — локальная библиотека unstructured (docx_pipeline) + +Возвращает единый список чанков с метаданными, очищенный от шума +(шапки, футеры, листы согласования, подписи, страницы). +""" + +import os +import sys +import re +import json +import hashlib +import uuid +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Optional, Any + + +# ============================================================ +# CONFIG +# ============================================================ + +DEFAULT_API_URL = "http://192.168.1.103:8005/general/v0/general" +DEFAULT_LOCAL_MODE = False + + +def get_config() -> dict: + """Собирает конфиг из переменных окружения или дефолтов.""" + return { + "api_url": os.environ.get("UNSTRUCTURED_API_URL", DEFAULT_API_URL), + "local_mode": os.environ.get("LOCAL_MODE", "0").lower() in ("1", "true", "yes"), + "languages": os.environ.get("LANGUAGES", "rus").split(","), + "strategy": os.environ.get("STRATEGY", "hi_res"), + "pdf_infer_table_structure": os.environ.get("PDF_INFER_TABLE", "true").lower() == "true", + "min_text_length": int(os.environ.get("MIN_TEXT_LENGTH", "15")), + "min_chunk_length": int(os.environ.get("MIN_CHUNK_LENGTH", "40")), + "chunk_max_chars": int(os.environ.get("CHUNK_MAX_CHARS", "1800")), + "chunk_combine_under": int(os.environ.get("CHUNK_COMBINE_UNDER", "300")), + "chunk_new_after": int(os.environ.get("CHUNK_NEW_AFTER", "6000")), + "batch_size": int(os.environ.get("BATCH_SIZE", "32")), + } + + +# ============================================================ +# CLEAN / FILTER HELPERS +# ============================================================ + +def clean_text(text: str) -> str: + """Убирает множественные пробелы/переносы.""" + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text.strip() + + + + +# ============================================================ +# SECTION DETECTION & ID GENERATION +# ============================================================ + +# Regex for section titles like "1. Нормативные ссылки", "2. Термины и определения" +_SECTION_RE = re.compile(r"^\s*(\d+)[\.\s]\s*(.+)$") + +# Keywords that indicate section titles from Unstructured API +SECTION_KEYWORDS = [ + "Нормативные ссылки", + "Термины", + "Термины и определения", + "Области применения", + "Общие положения", + "Порядок организации", + "Обязанности", + "Контроль", + "Ответственность", + "Заключительные положения", + "Приложения", +] + +def _detect_section_number(text: str) -> tuple: + """Extract section number and title from text. + + Returns (section_number, section_title) or (None, None). + """ + if not text: + return None, None + + # Try numbered section pattern: "3. Нормативные ссылки" + m = _SECTION_RE.match(text.strip()) + if m: + num = int(m.group(1)) + title = m.group(2).strip() + return num, title + + # Try unnumbered section pattern (fallback) + for kw in SECTION_KEYWORDS: + if kw in text: + # Extract just the keyword part as title + idx = text.index(kw) + title = text[idx:].strip() + return None, title + + return None, None + + +def _generate_section_id(section_number: int, section_title: str) -> str: + """Generate a stable section_id based on section number and title.""" + if section_number is not None: + return f"section_{section_number}" + # Fallback: hash the title + h = hashlib.md5(section_title.encode("utf-8")).hexdigest()[:8] + return f"section_{h}" + + +def is_page_number(text: str) -> bool: + return bool(re.match(r"^Стр\.\s*\d+\s*из\s*\d+$", text)) + + +def is_header_noise(text: str) -> bool: + patterns = [ + "АО «ХК «Сибцем»", + "Тип документа:", + "Ведущее подразделение:", + "Дата утверждения:", + "Редакция 1", + "Оглавление", + ] + return any(p in text for p in patterns) + + +def is_header_chunk(text: str) -> bool: + """DOCX-специфичные фильтры шапки/обложки.""" + text_lower = text.lower() + if any(kw in text_lower for kw in [ + "утверждаю", "президент", "генеральный директор", + "согласован", "в.в. шаповалов", + ]): + return True + if any(kw in text_lower for kw in ["кемерово", "2008г.", "рег-20-1", "регистр"]): + if len(text) < 300: + return True + if "лист соглас" in text_lower: + return True + if "содержание" in text_lower and len(text) < 500: + return True + if any(kw in text_lower for kw in [ + "разработчик:", "дата разработки:", "стр. из:", + ]): + return True + if any(kw in text_lower for kw in [ + "список лиц", "разработавших", "финансовый директор", + "главный инженер", + ]): + return True + return False + + +def is_footer_chunk(text: str) -> bool: + """DOCX-специфичные фильтры футера.""" + text_lower = text.lower() + if any(kw in text_lower for kw in [ + "документ:", "стр. из", "дата разработки:", + "разработчик:", "унифицированная", + ]): + return True + return False + + +def is_junk(item: dict, text: str, cfg: dict) -> bool: + """Общий фильтр мусора для обоих режимов.""" + if not text: + return True + if len(text) < cfg["min_text_length"]: + return True + if item.get("type") in ("Header", "Footer", "Image"): + return True + if is_page_number(text): + return True + if is_header_noise(text): + return True + # DOCX-специфичные фильтры + if is_header_chunk(text): + return True + if is_footer_chunk(text): + return True + return False + + +# ============================================================ +# HTML TABLE → TEXT +# ============================================================ + +def _ocr_table_fixes(text: str) -> str: + """Применяет патч-исправления для OCR-ошибок в таблицах.""" + if not text: + return text + + # Порядок важен — длинные патчи первыми, более специфичные до общих + fixes = [ + # OCR-исправления заголовков (самые важные) — длинные сначала + ("рредакции ы", "редакции"), + ("рредакции :", "редакции :"), + ("рредакции:", "редакции:"), + ("рредакции", "редакции"), + ("едакции ы", "редакции"), + ("едакции :", "редакции :"), + ("едакции:", "редакции:"), + ("едакции", "редакции"), + ("едаты ", "даты "), + ("едаты:", "даты:"), + ("едаты", "даты"), + # OCR "р" перед словами (массовое) + ("р внесенных :", "внесенных :"), + ("р внесенных:", "внесенных:"), + ("р внесенных", "внесенных"), + ("р системное", "системное"), + ("р системного", "системного"), + ("р системном", "системном"), + ("р Департамента", "Департамента"), + ("р технологий", "технологий"), + ("р администрирование", "администрирование"), + ("р администрирования", "администрирования"), + ("р информационных", "информационных"), + ("р Группы", "Группы"), + ("р технолог", "технолог"), + ("р технологическим", "технологическим"), + ("р персонализированный", "персонализированный"), + # OCR "р" перед другими словами + ("р ", " "), + # OCR "Ошесв" / "Обесв" / "Отесв" + ("Ошесвииа", "Общества"), + ("Отесват", "Общества"), + ("Обесват", "Общества"), + ("Ошесват", "Общества"), + ("Ошесв", "Общества"), + # Дублирование слов (администрирование администрирования) + (" администрирования администрирования", " администрирования"), + ("администрирования администрирования", " администрирования"), + (" администрирование администрирование", " администрирование"), + ("администрирование администрирование", " администрирование"), + # Убираем вертикальные разделители + ("| Создано", "Создано"), + ("| ", " "), + ("|", ""), + ("Список лиц:", "Список лиц"), + ("Список лиц,", "Список лиц"), + # Бауэр + ("Бауэ ", "Бауэр "), + ("Бауэ", "Бауэр"), + ("Бауэ С", "Бауэр С"), + # Общие OCR-исправления + ("пк", "ПК"), + ("ПК пк", "ПК"), + ("РроюСаг", "PhotoCar"), + ("р р", " "), + ] + for bad, good in fixes: + text = text.replace(bad, good) + + # Убираем "р" как отдельный OCR-мусор — ТОЛЬКО в конце слов (не в середине) + # НЕ используем \br\s — он ломает русские слова (Номер→Номе) + text = re.sub(r'\sр\b', '', text) + + # Убираем двойные/тройные двоеточия + text = re.sub(r':\s*:\s*', ':', text) + text = re.sub(r':$', '', text) + + # Убираем "№:" из заголовков + text = re.sub(r'№:\s*', '', text) + + # Убираем двойные буквы (Бауэрр → Бауэр и т.д.) + text = re.sub(r'(.)\1\1+', r'\1\1', text) + + # Убираем тройные пробелы + text = re.sub(r' +', ' ', text) + + # Чистим лишние пробелы + text = re.sub(r'\s+', ' ', text) + return text.strip() + + +def _is_ocr_table(html: str) -> bool: + """Проверяет, является ли HTML таблицы сильно повреждённым OCR.""" + if not html: + return False + # Если HTML содержит много OCR-мусора + ocr_markers = ["едации", "едаты", "Ошесв", "Обесв", "рредакции", "р систем", "р Департ"] + ocr_count = sum(1 for m in ocr_markers if m in html) + return ocr_count >= 2 + + +def _extract_table_text_fallback(table_text: str) -> str: + """Извлекает структуру таблицы из уже обработанного text поля API.""" + if not table_text: + return "" + lines = [] + current_record = None + for line in table_text.split('\n'): + line = clean_text(line) + if not line: + continue + # Проверяем "Запись N:" + m = re.match(r'^Запись\s+(\d+):\s*(.*)', line) + if m: + current_record = m.group(1) + rest = m.group(2) + if rest: + lines.append(f"Запись {current_record}: {clean_text(rest)}") + continue + lines.append(line) + + return "\n".join(lines) + + +def html_table_to_text(html: str, table_text: str = "") -> str: + """Конвертирует HTML таблицу в читаемый текст. + + Сначала чистит HTML от OCR-ошибок, затем парсит. + Если OCR слишком сильный — fallback из table_text. + """ + if not html and not table_text: + return "" + + # Определяем, стоит ли использовать HTML-парсинг + # Если в HTML слишком много OCR-мусора — используем fallback + html_clean = _ocr_table_fixes(html) + + # Проверяем качество HTML после очистки + ocr_markers = ["едации", "едаты", "Ошесв", "Обесв", "рредакции", "р систем", "р Департ"] + ocr_count = sum(1 for m in ocr_markers if m in html_clean) + + # Если после очистки всё ещё есть OCR-ошибки — используем fallback + if ocr_count >= 2: + return _extract_table_text_fallback(table_text) if table_text else "" + + # Парсим HTML + from html.parser import HTMLParser + + class TableParser(HTMLParser): + def __init__(self): + super().__init__() + self.rows = [] + self.current_row = [] + self.in_table = False + + def handle_starttag(self, tag, attrs): + if tag == "table": + self.in_table = True + elif tag == "tr" and self.in_table: + self.current_row = [] + elif tag in ("td", "th") and self.in_table: + attrs_dict = dict(attrs) + rowspan = int(attrs_dict.get("rowspan", 1)) + colspan = int(attrs_dict.get("colspan", 1)) + self.current_row.append({ + "text": "", + "rowspan": rowspan, + "colspan": colspan, + }) + + def handle_data(self, data): + if self.current_row: + self.current_row[-1]["text"] += data + + def handle_endtag(self, tag): + if tag == "tr" and self.in_table and self.current_row: + self.rows.append(self.current_row) + self.current_row = [] + + parser = TableParser() + parser.feed(html_clean) + + rows = parser.rows + if not rows: + return _extract_table_text_fallback(table_text) if table_text else "" + + # Убираем пустые строки + rows = [r for r in rows if any(cell["text"].strip() for cell in r)] + + if not rows: + return _extract_table_text_fallback(table_text) if table_text else "" + + # Определяем ширину таблицы с учётом rowspan + max_cols = max(len(row) for row in rows) + table_width = max(max_cols, 1) + + # Строим виртуальную сетку с rowspan + grid = [[None] * table_width for _ in range(len(rows))] + for row_idx, row in enumerate(rows): + col_idx = 0 + for cell in row: + # Пропускаем занятые ячейки (rowspan) + while col_idx < table_width and grid[row_idx][col_idx] is not None: + col_idx += 1 + if col_idx >= table_width: + break + grid[row_idx][col_idx] = { + "text": cell["text"], + "rowspan": cell["rowspan"], + "colspan": cell["colspan"], + } + # Занимаем ячейки для colspan + for c in range(1, cell["colspan"]): + next_col = col_idx + c + if next_col < table_width: + grid[row_idx][next_col] = {"text": "", "rowspan": 1, "colspan": 1} + + # Определяем, является ли первая строка заголовком + def is_header_row(row_strings): + """row_strings = list of str. Headers are short, no numbers.""" + texts = [s.strip() for s in row_strings if s.strip()] + if not texts: + return False + short = all(len(t) < 50 for t in texts) + has_number = any(re.match(r"^\d+\.?\s*$", t) for t in texts) + return short and not has_number + + # Парсим ячейки в плоские строки + def parse_row_cells(row_cells): + result = [] + for cell in row_cells: + if cell is None: + result.append("") + else: + result.append(_ocr_table_fixes(cell["text"].strip())) + return result + + parsed_rows = [] + for row in grid: + parsed_rows.append(parse_row_cells(row)) + + # Фильтруем пустые строки + parsed_rows = [r for r in parsed_rows if any(c.strip() for c in r)] + + if not parsed_rows: + return _extract_table_text_fallback(table_text) if table_text else "" + + # Определяем заголовок + header_idx = 0 + if is_header_row(parsed_rows[0]): + header_idx = 0 + else: + for i, row in enumerate(parsed_rows): + if is_header_row(row): + header_idx = i + break + + headers = parsed_rows[header_idx] + + # Формируем вывод + lines = [] + for row_idx, row in enumerate(parsed_rows[header_idx + 1:], header_idx + 1): + lines.append(f"Запись {row_idx}:") + for col_idx, cell in enumerate(row): + if not cell: + continue + header = headers[col_idx] if col_idx < len(headers) else f"Колонка {col_idx+1}" + header = _ocr_table_fixes(header) + lines.append(f"{header}: {cell}") + lines.append("") + + result = "\n".join(lines).strip() + return result if result else _extract_table_text_fallback(table_text) if table_text else "" + + +# ============================================================ +# MERGE FRAGMENTS +# ============================================================ + +def merge_fragments(elements: list) -> list: + """Объединяет короткие чанки в буфер, таблицы — отдельно. + + Улучшена логика слияния: + - Учитывает номер страницы (объединяет только с соседних) + - Учитывает section_title (не объединяет через разделы) + - Более высокий порог для разделения (300 вместо 80) + """ + merged = [] + buffer = [] + buffer_page = None + buffer_section = None + + def flush_buffer(): + nonlocal buffer + if not buffer: + return + text = clean_text(" ".join(buffer)) + if text: + merged.append({ + "type": "MergedText", + "text": text, + "page_number": buffer_page, + "section_title": buffer_section, + }) + buffer = [] + + for el in elements: + text = clean_text(el.get("text", "")) + if not text: + continue + el_type = el.get("type") + page = el.get("metadata", {}).get("page_number", 0) + section = el.get("section_title", "") + + if el_type == "Table": + flush_buffer() + merged.append({ + "type": "Table", + "text": text, + "html": el.get("metadata", {}).get("text_as_html", ""), + "page_number": page, + "section_title": section, + }) + continue + + if el_type in ("NarrativeText", "ListItem", "UncategorizedText"): + # Check if we should flush the buffer (different section or page jump) + if buffer_section and section and buffer_section != section: + flush_buffer() + buffer = [] + buffer_section = section + buffer_page = page + + if len(text) < 300: + buffer.append(text) + buffer_page = page + buffer_section = section + else: + flush_buffer() + merged.append({ + "type": "Text", + "text": text, + "page_number": page, + "section_title": section, + }) + else: + flush_buffer() + + flush_buffer() + return merged + + +# ============================================================ +# API MODE — parse via Unstructured API +# ============================================================ + +def parse_document_api(file_path: str, cfg: dict) -> list: + """Парсинг через Unstructured API (работает и с PDF, и с DOCX).""" + import requests + + filename = os.path.basename(file_path) + print(f"\n📄 API Парсинг: {filename}") + + payload_data = { + "strategy": cfg["strategy"], + "languages": cfg["languages"], + "pdf_infer_table_structure": str(cfg["pdf_infer_table_structure"]), + } + + ext = os.path.splitext(filename)[1].lower() + mime_type = "application/pdf" if ext == ".pdf" else ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + + with open(file_path, "rb") as f: + files = {"files": (filename, f, mime_type)} + timeout = cfg.get("timeout", 900) + response = requests.post( + cfg["api_url"], + files=files, + data=payload_data, + timeout=timeout, + ) + response.raise_for_status() + return response.json() + + +# ============================================================ +# LOCAL MODE — parse DOCX via python unstructured +# ============================================================ + +def parse_document_local(file_path: str, cfg: dict) -> list: + """Локальный парсинг DOCX через unstructured.partition.docx.""" + from unstructured.partition.docx import partition_docx + from unstructured.chunking.title import chunk_by_title + + print(f"\n📄 Local DOCX Парсинг: {os.path.basename(file_path)}") + + elements = partition_docx( + file_path, + strategy=cfg["strategy"], + infer_table_structure=True, + ) + print(f" Partitioned: {len(elements)} elements") + + chunks = chunk_by_title( + elements, + max_characters=cfg["chunk_max_chars"], + combine_text_under_n_chars=cfg["chunk_combine_under"], + new_after_n_chars=cfg["chunk_new_after"], + ) + print(f" Chunked: {len(chunks)} chunks") + + # Конвертируем в единый формат с API + result = [] + for c in chunks: + item = { + "type": c.category, + "text": c.text, + "metadata": { + "page_number": getattr(c.metadata, "page_number", 0) if hasattr(c, "metadata") and c.metadata else 0, + "text_as_html": getattr(c.metadata, "text_as_html", "") if hasattr(c, "metadata") and c.metadata else "", + }, + } + # section_title из metadata + if hasattr(c, "metadata") and c.metadata: + if hasattr(c.metadata, "page_label") and c.metadata.page_label: + item["metadata"]["page_number"] = c.metadata.page_label + result.append(item) + + return result + + +# ============================================================ +# CLEAN + MERGE (единый пайплайн) +# ============================================================ + +def clean_and_merge(data: list, cfg: dict) -> list: + """Очистка + фильтрация + слияние — общий для обоих режимов.""" + cleaned = [] + current_title = "Общая информация" + current_section_num = None + + for item in data: + raw_text = item.get("text", "") + text = clean_text(raw_text) + if is_junk(item, text, cfg): + continue + + # Try to detect section titles from content (not just Title type) + section_num, section_title = _detect_section_number(text) + if section_num is not None or section_title != text: + if section_num is not None: + current_section_num = section_num + current_title = section_title if section_title else text + # Don't add the title itself as content + continue + + item["section_title"] = current_title + item["section_number"] = current_section_num + cleaned.append(item) + + merged = merge_fragments(cleaned) + return merged + + +# ============================================================ +# BUILD CHUNKS + CONTENT +# ============================================================ + +def build_chunks(merged: list, filename: str, cfg: dict) -> list: + """Формирует финальные чанки с content для индексации. + + Добавлен section_id, улучшена фильтрация мусорных чанков. + """ + chunks = [] + for idx, chunk in enumerate(merged): + chunk_type = chunk["type"] + section_title = chunk["section_title"] + page_number = chunk["page_number"] + section_num = chunk.get("section_number", None) + + # Generate section_id + section_id = _generate_section_id(section_num, section_title) + + if chunk_type == "Table": + table_text = html_table_to_text(chunk.get("html", ""), chunk.get("text", "")) + content = f"Раздел: {section_title}\nТип: Таблица\n\n{table_text}" + else: + content = f"Раздел: {section_title}\n\n{chunk['text']}" + + content = clean_text(content) + if len(content) < cfg["min_chunk_length"]: + continue + + # Filter out garbage chunks (too short content after header) + if chunk_type == "Table" and len(table_text) < 50 if "table_text" in locals() else len(content) < 100: + continue + + # Filter out chunks that are just headers with no real content + if chunk["type"] in ("MergedText", "Text") and content.count("\n") <= 1 and len(content) < 100: + continue + + chunk_hash = hashlib.sha256( + (filename + str(idx) + content).encode("utf-8") + ).hexdigest() + + chunks.append({ + "id": str(uuid.uuid5(uuid.NAMESPACE_DNS, chunk_hash)), + "content": content, + "metadata": { + "filename": filename, + "chunk_index": idx, + "section_title": section_title, + "section_id": section_id, + "page_number": page_number, + "content_type": chunk_type, + "chunk_hash": chunk_hash, + }, + }) + + return chunks + + + + + +# ============================================================ +# MAIN ENTRY POINT +# ============================================================ + +def parse_document(file_path: str, cfg: Optional[dict] = None) -> dict: + """ + Универсальный парсер документов (PDF + DOCX). + + Возвращает: + { + "filename": str, + "chunks": list[dict], # каждый: {id, content, metadata} + "stats": { + "total_elements": int, + "total_chunks_before_filter": int, + "total_chunks_after_filter": int, + "total_final_chunks": int, + } + } + """ + if cfg is None: + cfg = get_config() + + filename = os.path.basename(file_path) + + # --- PARSE --- + if cfg["local_mode"]: + raw_data = parse_document_local(file_path, cfg) + else: + raw_data = parse_document_api(file_path, cfg) + + total_elements = len(raw_data) + + # --- CLEAN + MERGE --- + merged = clean_and_merge(raw_data, cfg) + total_after_filter = len(merged) + + # --- BUILD CHUNKS --- + chunks = build_chunks(merged, filename, cfg) + total_final = len(chunks) + + stats = { + "total_elements": total_elements, + "total_chunks_before_filter": total_elements, + "total_chunks_after_filter": total_after_filter, + "total_final_chunks": total_final, + } + + return { + "filename": filename, + "chunks": chunks, + "stats": stats, + } + + +# ============================================================ +# CLI +# ============================================================ + +def main(): + """CLI: python document_parser.py [--api|--local] [--json]""" + import argparse + + parser = argparse.ArgumentParser(description="Unified DOCX/PDF Document Parser") + parser.add_argument("file", help="Path to PDF or DOCX file") + parser.add_argument("--api", action="store_true", default=None, + help="Use API mode (default: auto-detect from env)") + parser.add_argument("--local", action="store_true", default=None, + help="Use local unstructured library") + parser.add_argument("--json", action="store_true", default=False, + help="Output parsed JSON to stdout") + parser.add_argument("--output", "-o", default=None, + help="Output JSON file path") + args = parser.parse_args() + + file_path = os.path.abspath(args.file) + if not os.path.exists(file_path): + print(f"❌ File not found: {file_path}") + sys.exit(1) + + # Override mode from CLI + if args.local: + cfg = get_config() + cfg["local_mode"] = True + elif args.api: + cfg = get_config() + cfg["local_mode"] = False + else: + cfg = get_config() + + result = parse_document(file_path, cfg) + + print(f"\n✅ {result['filename']}") + print(f" Elements: {result['stats']['total_elements']}") + print(f" After filter: {result['stats']['total_chunks_after_filter']}") + print(f" Final chunks: {result['stats']['total_final_chunks']}") + + # Output + output_json = json.dumps(result, ensure_ascii=False, indent=2) + + if args.json or args.output: + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(output_json) + print(f"\n📄 Saved to: {args.output}") + else: + print(output_json) + + return result + + +if __name__ == "__main__": + main() diff --git a/search_bot/optimize_documents.py b/search_bot/optimize_documents.py new file mode 100644 index 0000000..9ec28eb --- /dev/null +++ b/search_bot/optimize_documents.py @@ -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 '' in table and '' in table: + return table + rows = re.findall(r'(.*?)', table, re.DOTALL) + if not rows: + return table + + header = '' + rows[0] + '' + body = '' + for row in rows[1:]: + body += '' + row + '' + body += '' + + return '' + header + body + '
' + + return re.sub(r'.*?
', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL) + + +def merge_terms_tables(content): + """Объединить разбитые таблицы терминов.""" + # Поиск таблиц терминов + terms_pattern = r'(.*?Термины.*?
)' + matches = re.findall(terms_pattern, content, re.DOTALL) + + if len(matches) > 1: + # Объединяем все таблицы терминов в одну + merged = '' + for table in matches: + rows = re.findall(r'(.*?)', table, re.DOTALL) + for row in rows[1:]: # Пропускаем заголовок + merged += row + merged += '
ТерминОпределение
' + 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\n«\d+» \w+ \d{4} г\.)', + r'(УТВЕРЖДАУ\nПрезидент\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'''
+УТВЕРЖДАУ
+Президент
+О.В. Шарыкин
+{old_text.split("«")[1].split("г.")[0]} г. +
''' + 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'', 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) diff --git a/service_desk/__pycache__/handlers.cpython-311.pyc b/service_desk/__pycache__/handlers.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce0ff08f926cbee883a482d48e53beb742ed6e8d GIT binary patch literal 37649 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 literal 0 HcmV?d00001