From 83e7963391a26535302deebbe633cd5febd1f6ef 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:06:45 +0700 Subject: [PATCH] Clean: remove __pycache__ and search_bot files from service_desk branch --- __pycache__/main.cpython-311.pyc | Bin 18337 -> 0 bytes search_bot/document_parser.py | 835 ------------------ search_bot/optimize_documents.py | 298 ------- .../__pycache__/handlers.cpython-311.pyc | Bin 37649 -> 0 bytes 4 files changed, 1133 deletions(-) delete mode 100644 __pycache__/main.cpython-311.pyc delete mode 100644 search_bot/document_parser.py delete mode 100644 search_bot/optimize_documents.py delete mode 100644 service_desk/__pycache__/handlers.cpython-311.pyc diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc deleted file mode 100644 index 0fa6c9247178050c41885aa1c9fc56f933d76c46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18337 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 diff --git a/search_bot/document_parser.py b/search_bot/document_parser.py deleted file mode 100644 index 9cb7887..0000000 --- a/search_bot/document_parser.py +++ /dev/null @@ -1,835 +0,0 @@ -#!/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 deleted file mode 100644 index 9ec28eb..0000000 --- a/search_bot/optimize_documents.py +++ /dev/null @@ -1,298 +0,0 @@ -#!/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 deleted file mode 100644 index ce0ff08f926cbee883a482d48e53beb742ed6e8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37649 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 b5$6l&oFSw`_hkDs1E