docs(office): add section 19 to roadmap and changelog for two-phase handwritten ocr pipeline
This commit is contained in:
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user