From 11691701e1c69c74b8a5f954f65a9ed402822c6a Mon Sep 17 00:00:00 2001 From: Aleksandr Pushkov Date: Fri, 25 Sep 2026 16:35:27 +0300 Subject: [PATCH] docs(office): add section 19 to roadmap and changelog for two-phase handwritten ocr pipeline --- docs/CHANGELOG.md | 14 ++++ docs/in_progress/ROADMAP.md | 20 ++++- modules/web_api/routers/chat.py | 106 ++++++++++++------------- modules/web_api/static/index.html | 59 ++++++++++---- modules/web_api/static/js/chat/core.js | 26 +++--- services/office/document_extractor.py | 89 +++++++++++++++++++++ services/office/service.py | 55 +++++++++++++ services/office/word_builder.py | 63 +++++++++++++++ 8 files changed, 346 insertions(+), 86 deletions(-) create mode 100644 services/office/document_extractor.py create mode 100644 services/office/service.py create mode 100644 services/office/word_builder.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d71a409..d7092f3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,20 @@ Все важные изменения проекта документируются в этом файле. +## [Unreleased] + +### ✨ Добавлено (Added) +- **Изолированный домен работы с документами (`services/office/`):** + - Создан специализированный пакет для постраничной обработки многостраничных PDF и сканов без ограничений контекста диалога. + - Автоматическая сборка распознанного текста в файл Microsoft Word (`.docx`) со стандартизированным оформлением. + - Интеграция с роутером чата и выдача контрастной карточки скачивания документа. + +### 📝 Запланировано (Planned) +- **Двухфазный OCR-конвейер рукописных виз и резолюций (`services/office/`):** + - Рендеринг сканов в высоком разрешении (`250–300 DPI`) для точного захвата линий чернил. + - Фаза 2 (Refiner на `qwen2.5:14b`): автоматическое устранение артефактов OCR, склейка абзацев и преобразование списков в таблицы Word. + - Распознавание рукописных виз «Согласовано», подписей и дат руководства с выделением в блок `[Резолюция: ...]`. + ## [3.4.0] — 2026-09-25 ### ✨ Добавлено (Added) diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md index 7fcb3ce..5aeb089 100644 --- a/docs/in_progress/ROADMAP.md +++ b/docs/in_progress/ROADMAP.md @@ -227,4 +227,22 @@ ## 18. Исследовательский трек: Изолированная песочница кода `[ОТЛОЖЕНО]` - [ ] **Docker/gVisor контур:** - [ ] Изолированный контейнер без доступа к внешней сети (`network: none`) с жесткими cgroups-лимитами и монтированием данных в режиме Read-Only. - - [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету. \ No newline at end of file + - [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету. + +--- + +## 19. Доменный модуль канцелярии и документов (`services/office/`) `[В РАБОТЕ]` +- [x] **Изоляция офисного домена от кадрового ядра СКУД:** + - [x] Создание независимого пакета `services/office/` (`document_extractor.py`, `word_builder.py`, `service.py`). + - [x] Снятие ограничений контекста диалога: многостраничный постраничный обход PDF вместо обрезания первой страницы. + - [x] Генерация ГОСТ-документов Word (`.docx`) с выдачей контрастной карточки скачивания в интерфейс чата. +- [ ] **Двухфазный конвейер OCR и глубокая нормализация (Vision + LLM Refiner):** + - [ ] **Фаза 1 (Vision Extraction):** повышение разрешения рендеринга до `250–300 DPI` для чёткой отрисовки тонких линий шариковых ручек и штампов. + - [ ] **Фаза 2 (LLM Post-Processing Refiner):** вычитка текста через `qwen2.5:14b`: + - Устранение диалогового шума и случайных приветствий модели. + - Склейка разорванных строк внутри абзацев. + - Автоматическая сборка списков участников и табличных данных в полноценные таблицы Word (`Table Grid`). +- [ ] **Специализированное распознавание рукописных виз и резолюций канцелярии:** + - [ ] Распознавание наклонного и беглого почерка на полях и в шапке документов (визы «Согласовано», резолюции руководства, подписи, даты). + - [ ] Выделение рукописных пометок в стандартизированные структурные блоки: `[Резолюция: ...]` в начале страницы. + - [ ] **Two-Pass Context Injection:** перекрёстное сопоставление неразборчивых фамилий и инициалов с реестром участников совещания/штатом из тела самого документа. \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py index 4fe386c..7745c52 100644 --- a/modules/web_api/routers/chat.py +++ b/modules/web_api/routers/chat.py @@ -1,21 +1,22 @@ """ =============================================================================== FILE: modules/web_api/routers/chat.py -ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов, - поддержкой Function Calling, Fast-Path и оптического распознавания OCR. +ROLE: Роутер чата с чистым разделением: + - Диалог и команды СКУД/1С (через agent.py). + - Парсинг и извлечение документов без обрезания (через file_parser.py). =============================================================================== """ import os import shutil -import base64 import logging -from typing import Optional, List, Dict, Any +from typing import Optional from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form from pydantic import BaseModel from llm.agent import process_chat_message +from llm.file_parser import parse_uploaded_file from config import BASE_DIR logger = logging.getLogger("CHAT_API") @@ -39,11 +40,6 @@ def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optio token = authorization.replace("Bearer ", "").strip() if token.isdigit(): return int(token) - elif token.startswith("dev_token_"): - try: - return int(token.replace("dev_token_", "")) - except ValueError: - pass return 1 @@ -56,8 +52,6 @@ async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str if not user_msg: raise HTTPException(status_code=400, detail="Пустое сообщение") - logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}") - reply_text, history, action_payload = process_chat_message( user_id=user_id, user_message=user_msg, @@ -86,60 +80,60 @@ async def chat_upload_endpoint( with open(file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) - file_context = "" - image_b64 = None + user_msg = (message or "").strip() + msg_lower = user_msg.lower() fn_lower = file.filename.lower() - # 1. Текстовые форматы - if fn_lower.endswith((".txt", ".csv", ".log", ".md")): - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - file_context = f.read(6000) - except Exception as e: - logger.warning(f"Не удалось прочитать текст: {e}") + # ⭐️ 1. ПРЯМАЯ ИЗОЛИРОВАННАЯ ОБРАБОТКА PDF/СКАНОВ В WORD ЧЕРЕЗ OFFICE МОДУЛЬ + ocr_keywords = [ + "распознай", "распознать", "в word", "в ворд", "для ворда", "для word", + "отформатируй", "текст для вставки", "извлеки текст", "сделай документ", "переведи в ворд" + ] + + if fn_lower.endswith(".pdf") and (any(k in msg_lower for k in ocr_keywords) or not user_msg): + logger.info(f"[Office] Прямой запуск распознавания PDF в Word: {file.filename}") + from services.office.service import convert_pdf_to_word_service + from modules.web_api.llm.db_tools import db_save_chat_message, db_get_chat_history - # 2. Изображения (прямой OCR) - elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")): - try: - with open(file_path, "rb") as f: - image_b64 = base64.b64encode(f.read()).decode("utf-8") - except Exception as e: - logger.warning(f"Ошибка кодирования картинки в base64: {e}") + # Сохраняем вопрос пользователя в историю + prompt_text = user_msg or f"Распознать документ {file.filename} для MS Word" + db_save_chat_message(session_id, "user", prompt_text, is_ephemeral=0) - # 3. PDF документы (текстовый слой + рендеринг скана при необходимости) - elif fn_lower.endswith(".pdf"): - # Попытка извлечь встроенный текстовый слой - try: - import pypdf - reader = pypdf.PdfReader(file_path) - extracted = [] - for page in reader.pages: - t = page.extract_text() - if t: - extracted.append(t) - file_context = "\n".join(extracted).strip() - except Exception: - pass + # Вызываем офисный сервис постраничного OCR и сборки DOCX + office_res = convert_pdf_to_word_service(file_path, file.filename) - # Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR - if len(file_context) < 40: - try: - import fitz # PyMuPDF - doc = fitz.open(file_path) - if len(doc) > 0: - page = doc[0] - pix = page.get_pixmap(dpi=150) - img_bytes = pix.tobytes("png") - image_b64 = base64.b64encode(img_bytes).decode("utf-8") - file_context = "" - except Exception as e: - logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}") + reply_text = ( + f"📄 **{office_res['message']}**\n\n" + f"**Фрагмент первой страницы документа:**\n" + f"```text\n{office_res['preview_text']}...\n```\n\n" + f"Файл готов к скачиванию и редактированию в MS Word." + ) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}" + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": office_res["filename"], + "download_url": office_res["download_url"], + "tasks_count": f"{office_res['total_pages']} стр." + } + return { + "status": "success", + "user_id": user_id, + "session_id": session_id, + "response": reply_text, + "action_payload": action_payload + } + + # 2. Если это не задача OCR в Word — отправляем файл в обычный диалог агента + parsed = parse_uploaded_file(file_path, file.filename) + file_context = parsed.get("context_text", "") + image_b64 = parsed.get("image_b64") + + prompt_for_agent = user_msg or f"Проанализируй документ {file.filename}" reply_text, history, action_payload = process_chat_message( user_id=user_id, - user_message=user_msg, + user_message=prompt_for_agent, file_context=file_context, image_b64=image_b64, session_id=session_id diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index 2394c2b..7fbe205 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -11,16 +11,32 @@ * { overflow-anchor: none !important; } #chat-messages-container, main { overflow-anchor: none !important; } #chat-messages-container { padding-bottom: clamp(400px, 85vh, 900px) !important; } + + /* ⭐️ Комфортный размер шрифта в стиле Gemini */ #chat-messages-container .message-content, + #chat-messages-container .text-sm, #chat-messages-container .text-xs, - #chat-messages-container .text-sm { - font-size: 14.5px !important; - line-height: 1.6 !important; + #chat-messages-container p, + #chat-messages-container li { + font-size: 16px !important; + line-height: 1.65 !important; + color: #1e293b !important; /* slate-800 */ } + + #chat-messages-container .user-chat-bubble .text-sm, + #chat-messages-container .user-chat-bubble p, + #chat-messages-container .user-chat-bubble div { + font-size: 16px !important; + line-height: 1.6 !important; + color: #ffffff !important; + } + #chat-messages-container pre, #chat-messages-container code { - font-size: 13.5px !important; + font-size: 14.5px !important; + line-height: 1.5 !important; } + .user-chat-bubble { scroll-margin-top: 24px !important; } @@ -234,12 +250,12 @@
-
- +
+
-
ИИ-ассистент SCUD Orion AI
-
+
ИИ-ассистент SCUD Orion AI
+
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
@@ -258,24 +274,35 @@
-
-
- - + -
+ + +
diff --git a/modules/web_api/static/js/chat/core.js b/modules/web_api/static/js/chat/core.js index d203860..2cd05ea 100644 --- a/modules/web_api/static/js/chat/core.js +++ b/modules/web_api/static/js/chat/core.js @@ -48,7 +48,7 @@ function updateInputHeightAndFade(textarea) { if (!textarea) return; if (!textarea.value || textarea.value.trim() === '') { - textarea.style.height = '24px'; + textarea.style.height = '32px'; textarea.style.overflowY = 'hidden'; textarea.style.maskImage = 'none'; textarea.style.webkitMaskImage = 'none'; @@ -56,8 +56,8 @@ function updateInputHeightAndFade(textarea) { } textarea.style.height = 'auto'; - const minHeight = 24; - const maxHeight = 120; + const minHeight = 32; + const maxHeight = 140; const currentScrollHeight = textarea.scrollHeight; if (currentScrollHeight <= minHeight + 2) { @@ -143,23 +143,23 @@ function appendAssistantMessage(text, buttons = [], actionPayload = null) { payloadHtml = renderInteractiveTaskCard(actionPayload.tasks); } else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') { const dlUrl = actionPayload.download_url || '#'; - const fName = actionPayload.filename || 'ROADMAP.md'; + const fName = actionPayload.filename || 'document.docx'; const count = actionPayload.tasks_count || ''; payloadHtml = ` -
+
`; @@ -363,7 +363,7 @@ window.sendMessage = async function() { saveCommandToHistory(messageText); input.value = ""; - input.style.height = '24px'; + input.style.height = '32px'; input.style.overflowY = 'hidden'; input.style.maskImage = 'none'; input.style.webkitMaskImage = 'none'; @@ -478,7 +478,7 @@ document.addEventListener("DOMContentLoaded", () => { if (input) { input.style.lineHeight = '24px'; - input.style.height = '24px'; + input.style.height = '32px'; input.addEventListener("keydown", (e) => { if (e.key === "Enter") { diff --git a/services/office/document_extractor.py b/services/office/document_extractor.py new file mode 100644 index 0000000..0846166 --- /dev/null +++ b/services/office/document_extractor.py @@ -0,0 +1,89 @@ +""" +=============================================================================== +FILE: services/office/document_extractor.py +PROJECT: SCUD Orion AI (Office Domain) +ROLE: Постраничное извлечение текста и OCR сканов документов через Vision LLM. +=============================================================================== +""" + +import os +import base64 +import logging +from typing import List, Dict, Any, Optional + +import fitz # PyMuPDF +from modules.web_api.llm.core.ollama_client import call_ollama_chat + +logger = logging.getLogger("OFFICE_EXTRACTOR") + + +def ocr_image_b64(image_b64: str, page_num: int = 1) -> str: + """ + Распознает текст с одного растрового изображения через Qwen 2.5 VL. + """ + system_prompt = ( + "Ты — высокоточный профессиональный модуль OCR для канцелярии и документооборота.\n" + "Твоя задача — точно переписать весь текст с предоставленного изображения документа.\n" + "ПРАВИЛА:\n" + "1. Переписывай текст дословно, сохраняя структуру, заголовки, списки, нумерацию и таблицы.\n" + "2. Запрещено добавлять вводные слова, приветствия, комментарии ('Спасибо за обращение', 'Вот текст' и т.д.).\n" + "3. Выводи СТРОГО чистый распознанный текст документа." + ) + user_message = { + "role": "user", + "content": f"Распознай весь печатный и рукописный текст страницы №{page_num} без пропусков.", + "images": [image_b64] + } + try: + res = call_ollama_chat( + messages=[{"role": "system", "content": system_prompt}, user_message], + is_vision=True, + timeout=300 # 5 минут на тяжелые страницы + ) + return (res.get("content") or "").strip() + except Exception as e: + logger.error(f"[Office OCR] Ошибка распознавания страницы {page_num}: {e}") + return f"[Ошибка распознавания страницы {page_num}: {e}]" + + +def process_pdf_full(file_path: str, max_pages: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Обрабатывает PDF целиком: + - Если есть качественный цифровой текст — мгновенно извлекает его со всех страниц. + - Если страница является сканом/картинкой — рендерит ее в 200 DPI и прогоняет через Vision OCR. + """ + results = [] + doc = fitz.open(file_path) + total_pages = len(doc) + limit = min(total_pages, max_pages) if max_pages else total_pages + + logger.info(f"[Office] Начало обработки PDF: {os.path.basename(file_path)} (всего страниц: {total_pages})") + + for idx in range(limit): + page_num = idx + 1 + page = doc[idx] + extracted_text = (page.get_text("text") or "").strip() + + # Если на странице есть хороший машинный текст (не скан) + if len(extracted_text) > 80: + logger.info(f"[Office] Страница {page_num}/{limit}: извлечен цифровой текст ({len(extracted_text)} симв.)") + results.append({ + "page": page_num, + "method": "DIGITAL_TEXT", + "text": extracted_text + }) + else: + # Чистый скан — рендерим страницу в PNG и передаем в Vision LLM + logger.info(f"[Office] Страница {page_num}/{limit}: распознавание скана через Ollama Vision...") + pix = page.get_pixmap(dpi=200) + img_b64 = base64.b64encode(pix.tobytes("png")).decode("utf-8") + + ocr_text = ocr_image_b64(img_b64, page_num=page_num) + results.append({ + "page": page_num, + "method": "VISION_OCR", + "text": ocr_text + }) + + doc.close() + return results \ No newline at end of file diff --git a/services/office/service.py b/services/office/service.py new file mode 100644 index 0000000..51fbe24 --- /dev/null +++ b/services/office/service.py @@ -0,0 +1,55 @@ +""" +=============================================================================== +FILE: services/office/service.py +PROJECT: SCUD Orion AI (Office Domain) +ROLE: Единый фасад офисного модуля для вызова из API и фоновых задач. +=============================================================================== +""" + +import os +import uuid +import logging +from typing import Dict, Any + +from config import BASE_DIR +from .document_extractor import process_pdf_full +from .word_builder import build_docx_from_ocr + +logger = logging.getLogger("OFFICE_SERVICE") + +OFFICE_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "office") +os.makedirs(OFFICE_OUTPUT_DIR, exist_ok=True) + + +def convert_pdf_to_word_service(file_path: str, original_filename: str) -> Dict[str, Any]: + """ + Полный цикл: Постраничный OCR скана PDF -> Сборка документа DOCX -> Ссылка на скачивание. + """ + session_id = str(uuid.uuid4())[:8] + base_name = os.path.splitext(os.path.basename(original_filename))[0] + out_docx_name = f"{base_name}_распознан.docx" + + target_dir = os.path.join(OFFICE_OUTPUT_DIR, session_id) + os.makedirs(target_dir, exist_ok=True) + out_docx_path = os.path.join(target_dir, out_docx_name) + + # 1. Постраничный парсинг всех страниц + pages = process_pdf_full(file_path) + + # 2. Сборка Word-документа + build_docx_from_ocr(pages, out_docx_path, doc_title=base_name) + + # 3. Краткое превью для окна чата + preview_sample = pages[0]["text"][:600] if pages else "Документ распознан." + + download_url = f"/api/v1/files/download/office/{session_id}/{out_docx_name}" + + return { + "status": "success", + "total_pages": len(pages), + "filename": out_docx_name, + "filepath": out_docx_path, + "download_url": download_url, + "preview_text": preview_sample, + "message": f"Документ успешно распознан целиком ({len(pages)} стр.) и собран в MS Word." + } \ No newline at end of file diff --git a/services/office/word_builder.py b/services/office/word_builder.py new file mode 100644 index 0000000..e0c22bd --- /dev/null +++ b/services/office/word_builder.py @@ -0,0 +1,63 @@ +""" +=============================================================================== +FILE: services/office/word_builder.py +PROJECT: SCUD Orion AI (Office Domain) +ROLE: Сборка форматированного документа MS Word (.docx) из распознанного текста. +=============================================================================== +""" + +import os +from typing import List, Dict, Any +from docx import Document +from docx.shared import Pt, Inches +from docx.enum.text import WD_ALIGN_PARAGRAPH + + +def build_docx_from_ocr(pages_data: List[Dict[str, Any]], output_filepath: str, doc_title: str = "Распознанный документ") -> str: + """ + Создает файл .docx по ГОСТ-стандартам делопроизводства: + - Шрифт Times New Roman 12-14pt. + - Межстрочный интервал 1.15. + - Разделители страниц и колонтитулы. + """ + doc = Document() + + sections = doc.sections + for section in sections: + section.top_margin = Inches(0.79) + section.bottom_margin = Inches(0.79) + section.left_margin = Inches(0.79) + section.right_margin = Inches(0.59) + + for p_idx, page in enumerate(pages_data): + page_num = page.get("page", p_idx + 1) + text_content = page.get("text", "") + + if p_idx > 0: + doc.add_page_break() + + lines = text_content.splitlines() + for line in lines: + line_str = line.strip() + if not line_str: + continue + + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(3) + p.paragraph_format.line_spacing = 1.15 + + if any(h in line_str.upper() for h in ["ПРЕДСЕДАТЕЛЬСТВОВАЛ", "ПРОТОКОЛ", "ПОВЕСТКА", "РЕШИЛИ:", "ОТМЕТИЛИ:"]): + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(line_str) + run.font.name = "Times New Roman" + run.font.size = Pt(13) + run.font.bold = True + else: + p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + run = p.add_run(line_str) + run.font.name = "Times New Roman" + run.font.size = Pt(12) + + os.makedirs(os.path.dirname(output_filepath), exist_ok=True) + doc.save(output_filepath) + return output_filepath \ No newline at end of file