docs(office): add section 19 to roadmap and changelog for two-phase handwritten ocr pipeline

This commit is contained in:
2026-09-25 16:35:27 +03:00
parent 90656944c5
commit 11691701e1
8 changed files with 346 additions and 86 deletions
+50 -56
View File
@@ -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
+43 -16
View File
@@ -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>
+13 -13
View File
@@ -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") {