feat(web): стабилизация UI, Gemini-скроллинг, роутеры контекста/снапшотов и актуализация роадмапа

This commit is contained in:
2026-09-07 17:29:21 +03:00
parent 1bb95cd8e1
commit d391a08224
33 changed files with 7768 additions and 1832 deletions
+102 -45
View File
@@ -1,22 +1,29 @@
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа.
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
===============================================================================
"""
from fastapi import APIRouter, Header, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
import os
import shutil
import base64
import logging
from typing import Optional, List, Dict, Any
from services.text_reporter import ask_ollama
from services.knowledge_base import load_knowledge_base
from core.database import get_connection
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from llm.agent import process_chat_message
from config import BASE_DIR
logger = logging.getLogger("CHAT_API")
router = APIRouter(prefix="/api/v1", tags=["Chat"])
UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
class ChatMessageRequest(BaseModel):
message: str
@@ -25,17 +32,11 @@ class ChatMessageRequest(BaseModel):
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
"""
Извлекает ID пользователя из Bearer-токена.
Если токен не передан или сессия новая — использует user_id=1 по умолчанию,
не блокируя работу ошибкой 403 Forbidden.
"""
if explicit_user_id and explicit_user_id > 0:
return explicit_user_id
if authorization and authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "").strip()
# Если используется простой токен вида 'user_1' или JWT
if token.isdigit():
return int(token)
elif token.startswith("dev_token_"):
@@ -43,8 +44,6 @@ def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optio
return int(token.replace("dev_token_", ""))
except ValueError:
pass
# Дефолтный пользователь (гостевой / основной аккаунт)
return 1
@@ -59,39 +58,97 @@ async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
# Загружаем контекст базы знаний
kb = load_knowledge_base()
rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])])
system_prompt = (
"Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n"
"Отвечай четко, профессионально и на русском языке.\n"
f"Актуальные правила системы:\n{rules_text}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
session_id=session_id
)
try:
reply_text = ask_ollama(user_msg, system_prompt=system_prompt)
# Сохранение истории в SQLite при необходимости
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
@router.post("/chat/upload")
async def chat_upload_endpoint(
file: UploadFile = File(...),
message: Optional[str] = Form(""),
session_id: Optional[str] = Form("web_session_main"),
authorization: Optional[str] = Header(None)
):
user_id = resolve_user_id(authorization, 1)
file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
file_context = ""
image_b64 = None
fn_lower = file.filename.lower()
# 1. Текстовые форматы
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
try:
with get_connection() as conn:
conn.execute(
"INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)",
(session_id, "user", user_msg, session_id, "assistant", reply_text)
)
conn.commit()
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}")
# 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}")
# 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
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text
}
except Exception as e:
logger.error(f"Ошибка вызова нейросети: {e}")
return {
"status": "error",
"response": f"⚠️ Ошибка обработки запроса: {str(e)}"
}
# Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для 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}")
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
file_context=file_context,
image_b64=image_b64,
session_id=session_id
)
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}