docs(office): add section 19 to roadmap and changelog for two-phase handwritten ocr pipeline
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -227,4 +227,22 @@
|
||||
## 18. Исследовательский трек: Изолированная песочница кода `[ОТЛОЖЕНО]`
|
||||
- [ ] **Docker/gVisor контур:**
|
||||
- [ ] Изолированный контейнер без доступа к внешней сети (`network: none`) с жесткими cgroups-лимитами и монтированием данных в режиме Read-Only.
|
||||
- [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету.
|
||||
- [ ] Генерация и выполнение 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:** перекрёстное сопоставление неразборчивых фамилий и инициалов с реестром участников совещания/штатом из тела самого документа.
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -234,12 +250,12 @@
|
||||
<!-- ЛЕНТА ЧАТА -->
|
||||
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
|
||||
<div class="flex gap-3 max-w-4xl mx-auto w-full">
|
||||
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||||
<i class="fa-solid fa-robot text-xs"></i>
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||||
<i class="fa-solid fa-robot text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
|
||||
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
|
||||
<div class="text-xs text-slate-700 leading-relaxed">
|
||||
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
|
||||
<div class="text-sm text-slate-700 leading-relaxed">
|
||||
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,24 +274,35 @@
|
||||
</div>
|
||||
|
||||
<!-- СТРОКА ВВОДА -->
|
||||
<div class="px-4 py-2 bg-white border-t border-slate-200">
|
||||
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
|
||||
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0" title="Прикрепить файл">
|
||||
<i class="fa-solid fa-paperclip text-xs"></i>
|
||||
<div class="px-4 py-3 bg-white border-t border-slate-200">
|
||||
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-end gap-3 bg-slate-50 border border-slate-300 rounded-2xl px-4 py-2.5 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-indigo-100 shadow-xs">
|
||||
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1.5 transition shrink-0 mb-0.5" title="Прикрепить файл">
|
||||
<i class="fa-solid fa-paperclip text-base"></i>
|
||||
</button>
|
||||
<input type="file" id="file-upload-input" class="hidden" />
|
||||
|
||||
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
|
||||
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
|
||||
<textarea id="user-input" rows="1" placeholder="Задайте вопрос, отправьте команду (Enter — отправить, Shift+Enter — перенос)..."
|
||||
class="flex-1 bg-transparent border-0 focus:outline-none text-[15.5px] text-slate-800 placeholder:text-slate-400 resize-none py-1 leading-6 min-h-[32px] max-h-36" style="height: 32px;"></textarea>
|
||||
|
||||
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
|
||||
<i class="fa-solid fa-paper-plane text-[10px]"></i>
|
||||
<button type="button" onclick="window.sendMessage()" class="w-8 h-8 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white flex items-center justify-center shrink-0 shadow-sm transition mb-0.5">
|
||||
<i class="fa-solid fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ПОЛНОЭКРАННЫЙ ОВЕРЛЕЙ DRAG & DROP -->
|
||||
<div id="global-drag-overlay" class="fixed inset-0 bg-indigo-950/70 backdrop-blur-xs z-50 flex items-center justify-center p-8 hidden pointer-events-none transition-all">
|
||||
<div class="border-3 border-dashed border-indigo-300 rounded-3xl w-full h-full flex flex-col items-center justify-center text-white gap-4 bg-indigo-900/40">
|
||||
<div class="w-20 h-20 rounded-2xl bg-white/10 flex items-center justify-center shadow-lg backdrop-blur-md border border-white/20">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-4xl text-indigo-200"></i>
|
||||
</div>
|
||||
<div class="text-xl font-bold tracking-wide">Перетащите файл в окно для загрузки</div>
|
||||
<div class="text-sm text-indigo-200">Поддерживаются PDF (документы и сканы), изображения, Excel, CSV, TXT</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
|
||||
<div id="modals-container"></div>
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
|
||||
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-200 rounded-xl flex items-center justify-between gap-3 shadow-xs">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
|
||||
<i class="fa-solid fa-file-lines text-base"></i>
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center shrink-0 shadow-sm">
|
||||
<i class="fa-solid fa-file-word text-lg"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
|
||||
<div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
|
||||
<div class="text-xs text-slate-500">${count ? count + ' · ' : ''}Документ MS Word (.docx)</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
|
||||
<i class="fa-solid fa-download text-xs"></i>
|
||||
<span>Скачать файл</span>
|
||||
class="px-4 py-2.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-bold rounded-xl text-xs shadow-sm transition flex items-center gap-2 shrink-0">
|
||||
<i class="fa-solid fa-arrow-down-to-line text-sm text-white"></i>
|
||||
<span class="text-white tracking-wide">Скачать файл</span>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
@@ -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") {
|
||||
|
||||
@@ -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