""" =============================================================================== FILE: modules/web_api/main.py PROJECT: SCUD Orion AI (Unified Repository) MODULE: web_api (FastAPI REST Server & Context Management) ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. AI-CONTEXT-ANCHORS & INVARIANTS: - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов независимо от рабочей директории запуска (root или web_api). - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного рендеринга интерфейса и ассетов (css/js/favicon). - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и генерации ответов LLM (agent.process_chat_message). DEPENDENCIES: - modules/web_api/llm/agent.py (process_chat_message) - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) - modules/web_api/llm/file_parser.py (extract_text_from_file) =============================================================================== """ # --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] import os import sys import json import sqlite3 import logging import urllib.request from datetime import datetime, timedelta from typing import List, Dict, Any, Optional # Гарантируем корректный импорт подмодулей web_api независимо от точки запуска CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) import jwt from passlib.context import CryptContext from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import RequestValidationError from pydantic import BaseModel # Внутренние модули LLM и БД from llm.agent import process_chat_message from llm.db_tools import db_get_tasks, DB_PATH from llm.file_parser import extract_text_from_file # --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" ALGORITHM = "HS256" pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") security = HTTPBearer() STATIC_DIR = os.path.join(CURRENT_DIR, "static") app = FastAPI(title="SCUD Orion AI Context API", version="2.5") if os.path.exists(STATIC_DIR): app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") return JSONResponse( status_code=422, content={"detail": exc.errors(), "body": str(exc)} ) # --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] def get_db(): """Создает безопасное соединение с SQLite БД модуля.""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def create_access_token(user_id: int, username: str, is_admin: bool) -> str: payload = { "sub": str(user_id), "username": username, "is_admin": is_admin, "exp": datetime.utcnow() + timedelta(days=30) } return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: try: token = credentials.credentials payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) user_id = int(payload.get("sub")) username = payload.get("username") is_admin = bool(payload.get("is_admin", False)) return {"id": user_id, "username": username, "is_admin": is_admin} except Exception as e: logging.warning(f"Auth error: {e}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный или просроченный токен авторизации", headers={"WWW-Authenticate": "Bearer"}, ) # Pydantic-схемы валидации запросов class AuthRequest(BaseModel): username: str password: str class CreateUserRequest(BaseModel): username: str password: str full_name: Optional[str] = None is_admin: Optional[bool] = False class ChangePasswordRequest(BaseModel): old_password: str new_password: str # --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] @app.get("/") def read_root(): """Отдает главную страницу панели управления.""" index_path = os.path.join(STATIC_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) raise HTTPException(status_code=404, detail="Frontend index.html not found") @app.get("/favicon.ico") async def favicon(): file_path = os.path.join(STATIC_DIR, "favicon.ico") if os.path.exists(file_path): return FileResponse(file_path) raise HTTPException(status_code=404) # --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] @app.post("/api/v1/auth/login") def login(req: AuthRequest): username = req.username.strip().lower() logging.info(f"===> Попытка входа для пользователя: {username}") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) user = cursor.fetchone() conn.close() if not user or not pwd_context.verify(req.password, user["password_hash"]): logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") is_admin = bool(user["is_admin"]) or (user["username"] == "puh") token = create_access_token(user["id"], user["username"], is_admin) logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} @app.post("/api/v1/auth/change-password") def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not req.new_password or len(req.new_password) < 4: raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) user = cursor.fetchone() if not user or not pwd_context.verify(req.old_password, user["password_hash"]): conn.close() raise HTTPException(status_code=400, detail="Неверный старый пароль") new_hash = pwd_context.hash(req.new_password) cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) conn.commit() conn.close() logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") return {"status": "success", "message": "Пароль успешно изменен"} @app.get("/api/v1/admin/users") def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") users = [dict(r) for r in cursor.fetchall()] conn.close() return users @app.post("/api/v1/admin/users") def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") username = req.username.strip().lower() if not username or not req.password: raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) if cursor.fetchone(): conn.close() raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") pwd_hash = pwd_context.hash(req.password) full_name = req.full_name.strip() if req.full_name else None is_admin = 1 if req.is_admin else 0 cursor.execute( "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", (username, pwd_hash, full_name, is_admin) ) conn.commit() conn.close() logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") return {"status": "success", "message": f"Пользователь {username} создан"} @app.delete("/api/v1/admin/users/{user_id}") def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") if user_id == current_user["id"]: raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") conn = get_db() cursor = conn.cursor() cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.commit() conn.close() logging.info(f"Удален пользователь ID: {user_id}") return {"status": "success", "message": "Пользователь удален"} # --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] @app.get("/api/v1/tasks") def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): """Получить задачи текущего авторизованного пользователя.""" return db_get_tasks(user_id=user["id"]) # --- ЧАТ С АВТОРИЗАЦИЕЙ --- @app.post("/api/v1/chat") async def chat_endpoint( session_id: str = Form("web_session_main"), message: str = Form(""), file: Optional[UploadFile] = File(default=None), current_user: dict = Depends(get_current_user) ): parsed_file = {"text": "", "image_b64": None} if file and file.filename: file_bytes = await file.read() parsed_file = extract_text_from_file(file_bytes, file.filename) reply, history, action_type = process_chat_message( user_id=current_user["id"], user_message=message, file_context=parsed_file["text"], image_b64=parsed_file["image_b64"], session_id=session_id ) return {"reply": reply, "history": history, "action_type": action_type} # --- ГОСТЕВОЙ ЧАТ --- @app.post("/api/v1/chat/guest") async def guest_chat_endpoint( session_id: str = Form("web_session_main"), message: str = Form(""), file: Optional[UploadFile] = File(default=None) ): parsed_file = {"text": "", "image_b64": None} if file and file.filename: file_bytes = await file.read() parsed_file = extract_text_from_file(file_bytes, file.filename) reply, history, action_type = process_chat_message( user_id=0, user_message=message, file_context=parsed_file["text"], image_b64=parsed_file["image_b64"], session_id=session_id ) return {"reply": reply, "history": history, "action_type": action_type} # --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): return FileResponse(target) filename = os.path.basename(clean_path) target_js = os.path.join(STATIC_DIR, "js", filename) if filename.endswith(".js") and os.path.isfile(target_js): return FileResponse(target_js, media_type="application/javascript") target_css = os.path.join(STATIC_DIR, "css", filename) if filename.endswith(".css") and os.path.isfile(target_css): return FileResponse(target_css, media_type="text/css") raise HTTPException(status_code=404, detail="File not found")