Files
scud_context_api/main.py

294 lines
11 KiB
Python

import json
import sqlite3
import logging
import urllib.request
import os
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
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
from pydantic import BaseModel
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
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()]
)
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
MODEL_NAME = "qwen2.5:14b"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def get_db():
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"},
)
app = FastAPI(title="SCUD Orion AI Context API")
if os.path.exists("static"):
app.mount("/static", StaticFiles(directory="static"), name="static")
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@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)}
)
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
# === API МАРШРУТЫ ===
@app.get("/")
def read_root():
return FileResponse("static/index.html")
@app.get("/favicon.ico")
async def favicon():
file_path = os.path.join("static", "favicon.ico")
if os.path.exists(file_path):
return FileResponse(file_path)
raise HTTPException(status_code=404)
@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:
logging.warning(f"===> Ошибка: Пользователь {username} не найден")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
if 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": "Пользователь удален"}
@app.get("/api/v1/tasks")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
return db_get_tasks(user_id=user["id"])
# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ
from llm.db_tools import db_get_session_state
@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)
# 1. Сначала обрабатываем сообщение и вызовы инструментов
reply, history = 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
)
# 2. СТРОГО ПОСЛЕ обработки проверяем, осталось ли активное превью в базе
from llm.db_tools import db_get_session_state
state = db_get_session_state(session_id)
needs_confirm = bool(state and state.get("state_type") in ["PROMPT_PREVIEW", "TASK_DELETE_PREVIEW"])
return {"reply": reply, "history": history, "needs_confirmation": needs_confirm}
@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 = process_chat_message(
user_id=0,
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
state = db_get_session_state(session_id)
needs_confirm = bool(state and state.get("state_type") == "PROMPT_PREVIEW")
return {"reply": reply, "history": history, "needs_confirmation": needs_confirm}
# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ ===
@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/")
target = os.path.join("static", clean_path)
if os.path.isfile(target):
return FileResponse(target)
filename = os.path.basename(clean_path)
target_js = os.path.join("static/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/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")