Files
scud_context_api/api_code_snapshot.md
T

134 KiB
Raw Blame History

📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api

File: ./init_db.py

import os
import sqlite3

DB_NAME = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"

def init_db():
    os.makedirs(os.path.dirname(DB_NAME), exist_ok=True)
    conn = sqlite3.connect(DB_NAME)
    cursor = conn.cursor()

    cursor.execute("""
    CREATE TABLE IF NOT EXISTS tasks (
        task_id TEXT PRIMARY KEY,
        module TEXT,
        title TEXT,
        status TEXT DEFAULT 'BACKLOG',
        priority TEXT DEFAULT 'HIGH',
        due_date TEXT,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        completed_at TIMESTAMP
    );
    """)

    cursor.execute("""
    CREATE TABLE IF NOT EXISTS chat_sessions (
        session_id TEXT PRIMARY KEY,
        history_json TEXT NOT NULL,
        updated_at TEXT DEFAULT CURRENT_TIMESTAMP
    );
    """)

    cursor.execute("""
    CREATE TABLE IF NOT EXISTS system_prompts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT UNIQUE NOT NULL,
        prompt_text TEXT NOT NULL,
        is_active INTEGER DEFAULT 1,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );
    """)

    # Добавление новой таблицы для сессионных состояний
    cursor.execute("""
    CREATE TABLE IF NOT EXISTS session_states (
        session_id TEXT PRIMARY KEY,
        state_type TEXT NOT NULL,
        pending_data TEXT NOT NULL,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    """)

    cursor.execute("""
    CREATE TABLE IF NOT EXISTS chat_messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT NOT NULL,
    role TEXT NOT NULL,       -- 'user', 'assistant', 'tool'
    content TEXT NOT NULL,    -- текст сообщения
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
        """)

    conn.commit()
    conn.close()
    print(f"[✓] Единая база данных SQLite ({DB_NAME}) успешно инициализирована!")

if __name__ == "__main__":
    init_db()

File: ./main.py

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")

File: ./update_index.sh

cd /home/puh/scud_context_api

# 1. Создаем необходимые директории для CSS и JS
mkdir -p static/css static/js

# 2. Выносим CSS в static/css/styles.css
cat << 'EOF' > static/css/styles.css
/* Плавное (градиентное) исчезновение текста сверху при скролле */
.fade-scroll-top {
    mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
}

/* Скрытие стандартного скроллбара */
.no-scrollbar::-webkit-scrollbar {
    display: none;
}

.no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
}
EOF

# 3. Выносим JavaScript в static/js/app.js
cat << 'EOF' > static/js/app.js
const API_TOKEN = "scud_secret_token_2026";
const SESSION_ID = "web_session_main";
const STORAGE_KEY = 'scud_chat_input_history';

let currentFilter = 'ALL';
let allTasks = [];

// === ИСТОРИЯ КОМАНД (LOCALSTORAGE + СТРЕЛКИ ВВЕРХ/ВНИЗ) ===
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
let historyIndex = -1;

document.addEventListener('DOMContentLoaded', () => {
    const userInputEl = document.getElementById('user-input');

    if (userInputEl) {
        // Динамическое расширение высоты поля до 4 строк (~96px)
        userInputEl.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = Math.min(this.scrollHeight, 96) + 'px';
        });

        userInputEl.addEventListener('keydown', function(e) {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                document.getElementById('chat-form').requestSubmit();
            }
            else if (e.key === 'ArrowUp') {
                if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
                    e.preventDefault();
                    if (historyIndex === -1) {
                        this.dataset.draft = this.value;
                    }
                    historyIndex++;
                    this.value = inputHistory[inputHistory.length - 1 - historyIndex];
                    this.dispatchEvent(new Event('input'));
                    setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
                }
            }
            else if (e.key === 'ArrowDown') {
                if (historyIndex !== -1) {
                    e.preventDefault();
                    if (historyIndex > 0) {
                        historyIndex--;
                        this.value = inputHistory[inputHistory.length - 1 - historyIndex];
                    } else {
                        historyIndex = -1;
                        this.value = this.dataset.draft || '';
                    }
                    this.dispatchEvent(new Event('input'));
                    setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
                }
            }
        });
    }

    loadTasks();
});

function toggleDrawer() {
    const drawer = document.getElementById('task-drawer');
    const backdrop = document.getElementById('drawer-backdrop');
    const isHidden = drawer.classList.contains('translate-x-full');
    if (isHidden) {
        drawer.classList.remove('translate-x-full');
        backdrop.classList.remove('hidden');
    } else {
        drawer.classList.add('translate-x-full');
        backdrop.classList.add('hidden');
    }
}

function setFilter(status) {
    currentFilter = status;
    ['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
        const btn = document.getElementById(`filter-${f}`);
        if (f === status) {
            btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold";
        } else {
            btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700";
        }
    });
    renderTasks();
}

async function loadTasks() {
    try {
        const res = await fetch('/api/v1/tasks', {
            headers: { 'Authorization': `Bearer ${API_TOKEN}` }
        });
        allTasks = await res.json();
        document.getElementById('task-count-badge').innerText = allTasks.length;
        renderTasks();
    } catch (err) {
        document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`;
    }
}

function renderTasks() {
    const container = document.getElementById('tasks-container');
    const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);

    if (filtered.length === 0) {
        container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
        return;
    }

    container.innerHTML = filtered.map(t => {
        let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
        let cardBg = 'bg-white';
        if (t.status === 'COMPLETED') {
            statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
            cardBg = 'bg-emerald-50/20';
        } else if (t.status === 'IN_PROGRESS') {
            statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
            cardBg = 'bg-amber-50/20 border-amber-200';
        }

        let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
        if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';

        let dueDateHtml = '';
        if (t.due_date) {
            dueDateHtml = `
                <div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
                    <i class="fa-solid fa-clock text-amber-600"></i>
                    <span>Срок: ${t.due_date}</span>
                </div>
            `;
        }

        return `
            <div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
                <div class="flex justify-between items-center mb-1.5">
                    <div class="flex items-center gap-1.5">
                        <span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id}</span>
                        <span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span>
                    </div>
                    <span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span>
                </div>

                <h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3>

                <div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
                    <i class="fa-solid fa-folder-closed text-slate-300"></i>
                    <span>${t.module}</span>
                </div>

                ${dueDateHtml}
            </div>
        `;
    }).join('');
}

async function sendMessage(e) {
    e.preventDefault();
    const input = document.getElementById('user-input');
    const chatWindow = document.getElementById('chat-window');
    const sendBtn = document.getElementById('send-btn');
    const text = input.value.trim();

    if (!text) return;

    if (inputHistory[inputHistory.length - 1] !== text) {
        inputHistory.push(text);
        if (inputHistory.length > 50) inputHistory.shift();
        localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
    }
    historyIndex = -1;

    chatWindow.innerHTML += `
        <div class="flex justify-end">
            <div class="bg-indigo-600 text-white rounded-xl px-4 py-2.5 max-w-2xl text-sm shadow-sm">
                ${text}
            </div>
        </div>
    `;
    input.value = '';
    input.style.height = 'auto';
    chatWindow.scrollTop = chatWindow.scrollHeight;

    sendBtn.disabled = true;
    sendBtn.classList.add('opacity-50');

    try {
        const res = await fetch('/api/v1/chat', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${API_TOKEN}`
            },
            body: JSON.stringify({ session_id: SESSION_ID, message: text })
        });

        const data = await res.json();

        chatWindow.innerHTML += `
            <div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
                <p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1"><i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент</p>
                <p class="text-slate-700 text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p>
            </div>
        `;
        chatWindow.scrollTop = chatWindow.scrollHeight;
        loadTasks();

    } catch (err) {
        chatWindow.innerHTML += `
            <div class="bg-red-50 border border-red-200 rounded-xl p-4 max-w-2xl text-red-700 text-sm">
                Ошибка связи с сервером API.
            </div>
        `;
    } finally {
        sendBtn.disabled = false;
        sendBtn.classList.remove('opacity-50');
    }
}
EOF

# 4. Обновляем чистый static/index.html
cat << 'EOF' > static/index.html
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SCUD Orion AI — Context Manager</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="bg-slate-50 text-slate-800 h-screen flex flex-col font-sans">

    <!-- Хедер -->
    <header class="bg-white border-b border-slate-200 px-6 py-3 flex justify-between items-center shadow-sm">
        <div class="flex items-center space-x-3">
            <div class="bg-indigo-600 text-white p-2 rounded-lg">
                <i class="fa-solid fa-brain text-lg"></i>
            </div>
            <div>
                <h1 class="text-base font-bold leading-none text-slate-900">SCUD Orion AI</h1>
                <span class="text-xs text-slate-500">Context & Task Manager</span>
            </div>
        </div>

        <div class="flex items-center space-x-4">
            <span class="flex items-center text-xs text-emerald-600 font-medium bg-emerald-50 px-2.5 py-1 rounded-full border border-emerald-200">
                <span class="h-2 w-2 rounded-full bg-emerald-500 mr-1.5"></span> API Online
            </span>
            <button onclick="toggleDrawer()" class="bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-3.5 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-2 transition border border-indigo-200">
                <i class="fa-solid fa-list-check text-indigo-600"></i>
                <span>Реестр задач</span>
                <span id="task-count-badge" class="bg-indigo-600 text-white text-[10px] px-1.5 py-0.2 rounded-full">0</span>
            </button>
        </div>
    </header>

    <!-- Основная зона чата -->
    <main class="flex-1 flex flex-col max-w-4xl w-full mx-auto bg-white my-4 rounded-xl border border-slate-200 shadow-sm overflow-hidden">
        <div id="chat-window" class="flex-1 p-6 overflow-y-auto space-y-4 bg-slate-50/50">
            <div class="bg-white border border-slate-200 rounded-xl p-4 max-w-2xl shadow-sm">
                <p class="text-xs font-bold text-indigo-600 uppercase tracking-wider mb-1">
                    <i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
                </p>
                <p class="text-slate-700 text-sm leading-relaxed">
                    Привет! Я подключен к вашей базе задач и системным промптам. Вы можете писать команды прямо в чат или управлять задачами через панель справа.
                </p>
            </div>
        </div>

        <!-- Поле ввода -->
        <div class="p-4 bg-white border-t border-slate-200">
            <form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2">
                <div class="flex-1 bg-slate-50 border border-slate-300 rounded-lg p-1 focus-within:border-indigo-600 focus-within:bg-white transition">
                    <textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
                        placeholder="Команда или вопрос (Shift+Enter — новая строка, ↑/↓ — история)..." 
                        class="w-full bg-transparent text-slate-800 px-3 py-1.5 text-sm focus:outline-none resize-none overflow-y-auto max-h-[96px] leading-relaxed fade-scroll-top no-scrollbar"></textarea>
                </div>
                <button type="submit" id="send-btn" class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-5 h-[42px] rounded-lg text-sm transition flex items-center gap-2 shrink-0">
                    <span>Отправить</span>
                    <i class="fa-solid fa-paper-plane text-xs"></i>
                </button>
            </form>
        </div>
    </main>

    <!-- Выезжающая панель (Drawer) -->
    <div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/30 backdrop-blur-sm hidden transition-opacity z-40"></div>
    
    <aside id="task-drawer" class="fixed right-0 top-0 h-full w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
        <div class="p-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
            <h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
                <i class="fa-solid fa-list-check text-indigo-600"></i> Реестр задач
            </h2>
            <div class="flex items-center gap-2">
                <button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
                    <i class="fa-solid fa-rotate-right"></i>
                </button>
                <button onclick="toggleDrawer()" class="text-slate-400 hover:text-slate-700 transition p-1">
                    <i class="fa-solid fa-xmark text-lg"></i>
                </button>
            </div>
        </div>

        <!-- Фильтры задач -->
        <div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-medium text-slate-500 gap-1">
            <button onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold">Все</button>
            <button onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">В работе</button>
            <button onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">Бэклог</button>
            <button onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700">Завершено</button>
        </div>

        <div id="tasks-container" class="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/50">
            <div class="text-center text-slate-400 py-8 text-xs">Загрузка задач...</div>
        </div>
    </aside>

    <script src="/static/js/app.js"></script>
</body>
</html>
EOF

# 5. Фиксируем изменения фронтенда в Git
git add static/
git commit -m "refactor: декомпозиция фронтенда index.html (вынос static/css/styles.css и static/js/app.js)"
git push origin feature/llm-refactoring

File: ./scripts/clear_history.py

#!/usr/bin/env python3
"""
Скрипт полной очистки истории диалогов и сессионных состояний SQLite.
"""
import os
import sqlite3

DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"

def clear_chat_history():
    if not os.path.exists(DB_PATH):
        print(f"❌ База данных не найдена по адресу: {DB_PATH}")
        return

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()
    
    cursor.execute("DELETE FROM chat_messages;")
    cursor.execute("DELETE FROM session_states;")
    cursor.execute("DELETE FROM chat_sessions;")
    
    conn.commit()
    conn.close()
    print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!")

if __name__ == "__main__":
    clear_chat_history()

File: ./scripts/diagnostics/inspect_db.py

import os
import sqlite3

# Автопоиск файла базы данных в проекте
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'

print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)

if not os.path.exists(db_path):
    print(f"❌ Файл базы данных {db_path} не найден!")
    exit(1)

conn = sqlite3.connect(db_path)
cursor = conn.cursor()

# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]

print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
    cursor.execute(f"PRAGMA table_info({t_name})")
    cols = [c[1] for c in cursor.fetchall()]
    
    cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
    count = cursor.fetchone()[0]
    
    print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")

# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
    print("\n" + "=" * 80)
    print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
    print("=" * 80)
    cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
    rules = cursor.fetchall()
    if not rules:
        print("Таблица ai_knowledge_base пуста.")
    else:
        for r_id, r_text, r_author in rules:
            print(f" {r_id}. [{r_author}] {r_text}\n")

conn.close()
print("=" * 80)

File: ./scripts/diagnostics/inspect_files.py

import os

print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
print("=" * 80)

total_files = 0
total_size = 0

for root, dirs, files in os.walk('.'):
    # Исключаем служебные каталоги
    dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
    
    for f in files:
        p = os.path.join(root, f)
        size = os.path.getsize(p)
        total_files += 1
        total_size += size
        print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))

print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)

File: ./scripts/diagnostics/make_code_snapshot.py

import os

OUTPUT_SNAPSHOT = "api_code_snapshot.md"

# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}

print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")

with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
    out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
    
    for root, dirs, files in os.walk('.'):
        dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
        
        for file in sorted(files):
            ext = os.path.splitext(file)[1].lower()
            if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
                filepath = os.path.join(root, file)
                out.write(f"## File: `{filepath}`\n")
                out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
                try:
                    with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
                        out.write(f.read())
                except Exception as e:
                    out.write(f"// Ошибка чтения файла: {e}\n")
                out.write("\n```\n\n")

print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")

File: ./scripts/diagnostics/show_tree.py

import os

EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}

def print_tree(startpath):
    print("=" * 60)
    print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА")
    print("=" * 60)
    for root, dirs, files in os.walk(startpath):
        dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
        level = root.replace(startpath, '').count(os.sep)
        indent = ' ' * 4 * (level)
        print(f'{indent}📁 {os.path.basename(root)}/')
        subindent = ' ' * 4 * (level + 1)
        for f in sorted(files):
            if not f.endswith('.pyc'):
                print(f'{subindent}📄 {f}')
    print("=" * 60)

if __name__ == "__main__":
    print_tree('.')

File: ./static/index.html

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
    <title>SCUD Orion AI — Context Manager</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">

    <!-- Окно авторизации -->
    <div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
        <div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
            <div class="flex items-center space-x-3 mb-6">
                <div class="bg-indigo-600 text-white p-3 rounded-xl">
                    <i class="fa-solid fa-user-shield text-xl"></i>
                </div>
                <div>
                    <h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
                    <p class="text-xs text-slate-500">Авторизация в системе</p>
                </div>
            </div>

            <div class="space-y-4">
                <div>
                    <label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
                    <input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
                        class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
                </div>

                <div>
                    <label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
                    <input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
                        class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
                </div>

                <div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div>

                <button type="button" onclick="handleLogin()" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
                    <i class="fa-solid fa-right-to-bracket"></i>
                    <span>Войти в систему</span>
                </button>
            </div>

            <div class="relative my-4">
                <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
                <div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
            </div>

            <button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
                <i class="fa-solid fa-user-ninja"></i>
                <span>Войти как гость (Локальный ИИ)</span>
            </button>
        </div>
    </div>

    <!-- Модальное окно смены пароля -->
    <div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
        <div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
            <div class="flex justify-between items-center mb-4">
                <h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
                    <i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
                </h3>
                <button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
                    <i class="fa-solid fa-xmark text-lg"></i>
                </button>
            </div>

            <div class="space-y-3">
                <div>
                    <label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
                    <input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
                </div>
                <div>
                    <label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
                    <input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
                </div>
                <div>
                    <label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
                    <input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
                </div>

                <div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
                <div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>

                <button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
                    Сохранить новый пароль
                </button>
            </div>
        </div>
    </div>

    <!-- Модальное окно управления пользователями -->
    <div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
        <div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
            <div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
                <h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
                    <i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
                </h3>
                <button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
                    <i class="fa-solid fa-xmark text-lg"></i>
                </button>
            </div>

            <div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
                <p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
                <div class="grid grid-cols-2 gap-2">
                    <input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
                    <input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
                </div>
                <input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
                <div class="flex items-center justify-between pt-1">
                    <label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
                        <input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
                        <span>Права администратора</span>
                    </label>
                    <button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
                        + Добавить
                    </button>
                </div>
                <div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
            </div>

            <div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list">
                <div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>
            </div>
        </div>
    </div>

    <!-- Хедер -->
    <header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
        <div class="flex items-center space-x-2.5">
            <div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
                <i class="fa-solid fa-brain text-lg"></i>
            </div>
            <div>
                <h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
                <span class="text-[11px] text-slate-500">Task & Context API</span>
            </div>
        </div>

        <div class="flex items-center space-x-1.5">
            <span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
                Гость
            </span>

            <span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
                puh
            </span>

            <button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
                <i class="fa-solid fa-users-gear text-base"></i>
            </button>

            <button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
                <i class="fa-solid fa-key text-base"></i>
            </button>

            <button id="tasks-drawer-btn" type="button" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
                <i class="fa-solid fa-list-check"></i>
                <span>Задачи</span>
                <span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
            </button>

            <button type="button" onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
                <i class="fa-solid fa-right-from-bracket text-base"></i>
            </button>
        </div>
    </header>

    <!-- Главный контейнер -->
    <div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
        <div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
            <div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
            <div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
                <i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
                <p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
                <p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
            </div>
        </div>
            <div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
                <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
                    <i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
                </p>
                <p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
                    Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
                </p>
            </div>
        </div>

        <!-- Превью прикрепленного файла -->
        <div id="file-preview-container" class="hidden px-4 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between text-xs text-slate-700">
            <div class="flex items-center gap-2 truncate">
                <i class="fa-solid fa-paperclip text-indigo-600"></i>
                <span id="file-name-display" class="font-medium truncate">file.pdf</span>
                <span id="file-size-display" class="text-slate-400 text-[10px]">(0 KB)</span>
            </div>
            <button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-red-500 p-1 transition">
                <i class="fa-solid fa-xmark text-sm"></i>
            </button>
        </div>

        <div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
            <div class="flex items-center gap-2">
                <!-- Скрытый инпут и кнопка прикрепления файла -->
                <input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
                <button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
                    <i class="fa-solid fa-paperclip text-lg"></i>
                </button>

                <div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
                    <textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
                        placeholder="Команда, вопрос или перетащите файл сюда..." 
                        class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
                </div>
                <button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
                    <span>Отправить</span>
                    <i class="fa-solid fa-paper-plane text-xs"></i>
                </button>
            </div>
        </div>
    </div>

    <!-- Выезжающая панель (Drawer) -->
    <div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
    
    <aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
        <div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
            <h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
                <i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
            </h2>
            <div class="flex items-center gap-3">
                <button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
                    <i class="fa-solid fa-rotate-right text-sm"></i>
                </button>
                <button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
                    <i class="fa-solid fa-xmark text-lg"></i>
                </button>
            </div>
        </div>

        <div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
            <button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
            <button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
            <button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
            <button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
        </div>

        <div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
            <div class="text-center text-slate-400 py-8 text-xs">Загрузка ваших задач...</div>
        </div>
    </aside>

    <script src="/static/js/auth.js"></script>
    <script src="/static/js/tasks.js"></script>
    <script src="/static/js/chat.js"></script>
    <script src="/static/js/app.js"></script>
</body>
</html>

File: ./static/js/app.js

const AUTH_TOKEN_KEY = "scud_api_auth_token";
const SESSION_ID = "web_session_main";
const STORAGE_KEY = "scud_chat_input_history";

let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";

let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let historyIndex = -1;

document.addEventListener("DOMContentLoaded", () => {
    const userInputEl = document.getElementById("user-input");

    if (userInputEl) {
        userInputEl.addEventListener("input", function() {
            this.style.height = "24px";
            const newHeight = Math.min(this.scrollHeight, 120);
            this.style.height = newHeight + "px";
        });
    }

    if (IS_GUEST) {
        hideAuthModal();
        updateUIState();
    } else if (API_TOKEN) {
        hideAuthModal();
        updateUIState();
        if (typeof loadTasks === "function") {
            loadTasks();
        }
    } else {
        showAuthModal();
    }
});

File: ./static/js/auth.js

function showAuthModal() {
    const el = document.getElementById("auth-modal");
    if (el) el.classList.remove("hidden");
}

function hideAuthModal() {
    const el = document.getElementById("auth-modal");
    if (el) el.classList.add("hidden");
}

async function handleLogin(e) {
    if (e && e.preventDefault) e.preventDefault();
    
    const usernameInput = document.getElementById("auth-username-input");
    const passwordInput = document.getElementById("auth-password-input");
    const errorEl = document.getElementById("auth-error");

    if (!usernameInput || !passwordInput) return;

    const username = usernameInput.value.trim();
    const password = passwordInput.value;

    if (!username || !password) return;

    if (errorEl) errorEl.classList.add("hidden");

    try {
        const res = await fetch("/api/v1/auth/login", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ username, password })
        });

        const data = await res.json();

        if (res.status === 200) {
            // Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
            API_TOKEN = data.token;
            CURRENT_USERNAME = data.username;
            IS_ADMIN = data.is_admin;
            IS_GUEST = false;

            localStorage.setItem("scud_api_auth_token", data.token);
            localStorage.setItem("scud_username", data.username);
            localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
            localStorage.removeItem("scud_is_guest");

            hideAuthModal();
            updateUIState();
            
            if (typeof loadTasks === 'function') {
                loadTasks();
            }
        } else {
            if (errorEl) {
                errorEl.innerText = data.detail || "Ошибка авторизации";
                errorEl.classList.remove("hidden");
            }
        }
    } catch (err) {
        console.error("[Auth Error]", err);
        if (errorEl) {
            errorEl.innerText = "Ошибка соединения с сервером";
            errorEl.classList.remove("hidden");
        }
    }
}

function enableGuestMode() {
    IS_GUEST = true;
    API_TOKEN = "";
    CURRENT_USERNAME = "Гость";
    IS_ADMIN = false;
    localStorage.setItem("scud_is_guest", "true");
    hideAuthModal();
    updateUIState();
}

function logout() {
    localStorage.removeItem("scud_api_auth_token");
    localStorage.removeItem("scud_username");
    localStorage.removeItem("scud_is_admin");
    localStorage.removeItem("scud_is_guest");
    API_TOKEN = "";
    CURRENT_USERNAME = "";
    IS_ADMIN = false;
    IS_GUEST = false;
    showAuthModal();
}

function updateUIState() {
    const tasksBtn = document.getElementById("tasks-drawer-btn");
    const adminBtn = document.getElementById("admin-users-btn");
    const changePwdBtn = document.getElementById("change-pwd-btn");
    const guestBadge = document.getElementById("guest-badge");
    const usernameBadge = document.getElementById("username-badge");

    if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
        if (tasksBtn) tasksBtn.classList.add("hidden");
        if (adminBtn) adminBtn.classList.add("hidden");
        if (changePwdBtn) changePwdBtn.classList.add("hidden");
        if (guestBadge) guestBadge.classList.remove("hidden");
        if (usernameBadge) usernameBadge.classList.add("hidden");
    } else {
        if (tasksBtn) tasksBtn.classList.remove("hidden");
        if (changePwdBtn) changePwdBtn.classList.remove("hidden");
        if (guestBadge) guestBadge.classList.add("hidden");

        if (usernameBadge) {
            usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
            usernameBadge.classList.remove("hidden");
        }

        if (adminBtn) {
            const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
            if (isAdminUser) {
                adminBtn.classList.remove("hidden");
            } else {
                adminBtn.classList.add("hidden");
            }
        }
    }
}

function openChangePasswordModal() {
    const el = document.getElementById("change-pwd-modal");
    if (el) el.classList.remove("hidden");
}

function closeChangePasswordModal() {
    const el = document.getElementById("change-pwd-modal");
    if (el) el.classList.add("hidden");
    
    const err = document.getElementById("pwd-error");
    const succ = document.getElementById("pwd-success");
    if (err) err.classList.add("hidden");
    if (succ) succ.classList.add("hidden");
    
    document.getElementById("old-pwd-input").value = "";
    document.getElementById("new-pwd-input").value = "";
    const confirmInput = document.getElementById("confirm-pwd-input");
    if (confirmInput) confirmInput.value = "";
}

async function handleChangePassword(e) {
    if (e && e.preventDefault) e.preventDefault();
    const old_password = document.getElementById("old-pwd-input").value;
    const new_password = document.getElementById("new-pwd-input").value;
    const confirmInput = document.getElementById("confirm-pwd-input");
    const confirm_password = confirmInput ? confirmInput.value : new_password;
    const errorEl = document.getElementById("pwd-error");
    const successEl = document.getElementById("pwd-success");

    if (errorEl) errorEl.classList.add("hidden");
    if (successEl) successEl.classList.add("hidden");

    if (new_password !== confirm_password) {
        if (errorEl) {
            errorEl.innerText = "Новые пароли не совпадают";
            errorEl.classList.remove("hidden");
        }
        return;
    }

    try {
        const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
        const res = await fetch("/api/v1/auth/change-password", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Authorization": "Bearer " + token
            },
            body: JSON.stringify({ old_password, new_password })
        });

        const data = await res.json();

        if (res.status === 200) {
            if (successEl) {
                successEl.innerText = "Пароль успешно изменен!";
                successEl.classList.remove("hidden");
            }
            setTimeout(closeChangePasswordModal, 1500);
        } else {
            if (errorEl) {
                errorEl.innerText = data.detail || "Ошибка при смене пароля";
                errorEl.classList.remove("hidden");
            }
        }
    } catch (err) {
        if (errorEl) {
            errorEl.innerText = "Ошибка соединения с сервером";
            errorEl.classList.remove("hidden");
        }
    }
}

function openAdminModal() {
    const el = document.getElementById("admin-modal");
    if (el) el.classList.remove("hidden");
    loadUsersList();
}

function closeAdminModal() {
    const el = document.getElementById("admin-modal");
    if (el) el.classList.add("hidden");
}

async function loadUsersList() {
    const listEl = document.getElementById("admin-users-list");
    if (!listEl) return;
    listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';

    try {
        const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
        const res = await fetch("/api/v1/admin/users", {
            headers: { "Authorization": "Bearer " + token }
        });
        const users = await res.json();

        if (res.status === 200) {
            listEl.innerHTML = users.map(u => {
                const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
                const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
                const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
                const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
                
                return `
                    <div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
                        <div>
                            <div class="flex items-center">
                                <span class="font-bold text-slate-800">${u.username}</span>
                                ${adminTag}
                            </div>
                            ${fullNameHtml}
                            <div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
                        </div>
                        ${deleteBtn}
                    </div>
                `;
            }).join("");
        } else {
            listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
        }
    } catch (err) {
        listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
    }
}

async function handleCreateUser(e) {
    if (e && e.preventDefault) e.preventDefault();
    const username = document.getElementById("new-user-name").value.trim();
    const password = document.getElementById("new-user-pwd").value;
    const fullNameInput = document.getElementById("new-user-fullname");
    const full_name = fullNameInput ? fullNameInput.value.trim() : "";
    const adminCheckbox = document.getElementById("new-user-is-admin");
    const is_admin = adminCheckbox ? adminCheckbox.checked : false;
    const msgEl = document.getElementById("admin-msg");

    if (msgEl) msgEl.classList.add("hidden");

    try {
        const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
        const res = await fetch("/api/v1/admin/users", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Authorization": "Bearer " + token
            },
            body: JSON.stringify({ username, password, full_name, is_admin })
        });

        const data = await res.json();

        if (res.status === 200) {
            document.getElementById("new-user-name").value = "";
            document.getElementById("new-user-pwd").value = "";
            if (fullNameInput) fullNameInput.value = "";
            if (adminCheckbox) adminCheckbox.checked = false;
            loadUsersList();
        } else {
            if (msgEl) {
                msgEl.innerText = data.detail || "Ошибка";
                msgEl.classList.remove("hidden");
            }
        }
    } catch (err) {
        if (msgEl) {
            msgEl.innerText = "Ошибка связи с сервером";
            msgEl.classList.remove("hidden");
        }
    }
}

async function deleteUser(userId, username) {
    if (!confirm("Удалить пользователя " + username + "?")) return;

    try {
        const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
        await fetch("/api/v1/admin/users/" + userId, {
            method: "DELETE",
            headers: { "Authorization": "Bearer " + token }
        });
        loadUsersList();
    } catch (err) {
        alert("Ошибка при удалении");
    }
}

File: ./static/js/chat.js

// Вспомогательная функция для автоматического изменения высоты текстового поля
function updateInputHeight(el) {
    if (!el) return;
    el.style.height = "24px";
    const newHeight = Math.min(el.scrollHeight, 120);
    el.style.height = newHeight + "px";
}

let selectedFile = null;

function handleFileSelect(e) {
    const file = e.target.files[0];
    if (!file) return;

    if (file.size > 15 * 1024 * 1024) {
        alert("Файл слишком большой. Максимальный размер: 15 МБ");
        e.target.value = "";
        return;
    }

    selectedFile = file;
    const fileNameEl = document.getElementById("file-name-display");
    const fileSizeEl = document.getElementById("file-size-display");
    const previewContainer = document.getElementById("file-preview-container");

    if (fileNameEl) fileNameEl.innerText = file.name;
    if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
    if (previewContainer) previewContainer.classList.remove("hidden");
}

function clearAttachedFile() {
    selectedFile = null;
    const fileInput = document.getElementById("file-input");
    const previewContainer = document.getElementById("file-preview-container");
    if (fileInput) fileInput.value = "";
    if (previewContainer) previewContainer.classList.add("hidden");
}

// Функция симуляции отправки системной команды по кнопке
function sendQuickAction(actionText) {
    const input = document.getElementById("user-input");
    if (input) {
        input.value = actionText;
        sendMessage();
    }
}

async function sendMessage(e) {
    if (e && e.preventDefault) e.preventDefault();

    const input = document.getElementById("user-input");
    const chatWindow = document.getElementById("chat-window");
    const sendBtn = document.getElementById("send-btn");
    
    if (!input || !chatWindow) return;
    const text = input.value.trim();

    if (!text && !selectedFile) return;

    // Удаляем старые активные плашки кнопок, чтобы не висели предыдущие подтверждения
    document.querySelectorAll('.confirm-action-bar').forEach(el => el.remove());

    let userDisplayHtml = escapeHtml(text);
    if (selectedFile) {
        userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
            <i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
        </div>` + userDisplayHtml;
    }

    const userMsgHtml = `
        <div class="flex justify-end mb-3">
            <div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
                ${userDisplayHtml}
            </div>
        </div>
    `;
    chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
    
    input.value = "";
    updateInputHeight(input);
    chatWindow.scrollTop = chatWindow.scrollHeight;

    if (sendBtn) {
        sendBtn.disabled = true;
        sendBtn.classList.add("opacity-50");
    }

    const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
    const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");

    const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
    
    const formData = new FormData();
    formData.append("session_id", "web_session_main");
    formData.append("message", text || "Проанализируй прикрепленный файл");
    
    if (selectedFile instanceof File) {
        formData.append("file", selectedFile, selectedFile.name);
    }

    const headers = {};
    if (!isGuest && token) {
        headers["Authorization"] = "Bearer " + token;
    }

    try {
        const res = await fetch(endpoint, {
            method: "POST",
            headers: headers,
            body: formData
        });

        if (res.status === 401 && !isGuest) {
            if (typeof logout === 'function') logout();
            return;
        }

        const data = await res.json();
        const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
        const replyText = data.reply || "Пустой ответ от нейросети";

        // Кнопки генерируем СТРОГО если бэкенд вернул флаг data.needs_confirmation === true
        let buttonsHtml = "";
        if (data.needs_confirmation === true) {
            buttonsHtml = `
                <div class="confirm-action-bar mt-3 pt-2.5 border-t border-slate-200 flex items-center justify-end gap-2">
                    <button type="button" onclick="sendQuickAction('отмена')" 
                        class="bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 font-semibold px-3 py-1.5 rounded-xl text-xs transition border border-slate-300 flex items-center gap-1.5 shadow-sm">
                        <i class="fa-solid fa-xmark text-red-500"></i>
                        <span>Отменить</span>
                    </button>
                    <button type="button" onclick="sendQuickAction('подтверждаю')" 
                        class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-semibold px-3.5 py-1.5 rounded-xl text-xs transition flex items-center gap-1.5 shadow-md">
                        <i class="fa-solid fa-check"></i>
                        <span>Подтвердить</span>
                    </button>
                </div>
            `;
        }

        const botMsgHtml = `
            <div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
                <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
                    <i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
                </p>
                <p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
                ${buttonsHtml}
            </div>
        `;
        chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
        chatWindow.scrollTop = chatWindow.scrollHeight;

        clearAttachedFile();

        if (!isGuest && typeof loadTasks === 'function') {
            loadTasks();
        }

    } catch (err) {
        console.error("[Chat Error]", err);
        const errorHtml = `
            <div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
                Ошибка связи с сервером.
            </div>
        `;
        chatWindow.insertAdjacentHTML("beforeend", errorHtml);
        chatWindow.scrollTop = chatWindow.scrollHeight;
    } finally {
        if (sendBtn) {
            sendBtn.disabled = false;
            sendBtn.classList.remove("opacity-50");
        }
    }
}

function escapeHtml(text) {
    if (!text) return "";
    return text
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

document.addEventListener("DOMContentLoaded", () => {
    const input = document.getElementById("user-input");
    const dropZone = document.getElementById("chat-window")?.parentElement;
    const dropOverlay = document.getElementById("drop-overlay");

    if (input) {
        let historyIndex = -1;
        let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");

        input.addEventListener("keydown", (e) => {
            if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                const text = input.value.trim();
                if (text) {
                    if (localHistory.length === 0 || localHistory[0] !== text) {
                        localHistory.unshift(text);
                        if (localHistory.length > 50) localHistory.pop();
                        localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
                    }
                    historyIndex = -1;
                }
                sendMessage(e);
                updateInputHeight(input);
                return;
            }

            if (e.key === "ArrowUp") {
                const textBeforeCursor = input.value.substring(0, input.selectionStart);
                const isFirstLine = !textBeforeCursor.includes("\n");

                if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
                    if (historyIndex < localHistory.length - 1) {
                        e.preventDefault();
                        if (historyIndex === -1) {
                            input.dataset.draft = input.value;
                        }
                        historyIndex++;
                        input.value = localHistory[historyIndex];
                        updateInputHeight(input);
                        input.setSelectionRange(input.value.length, input.value.length);
                    }
                }
            }

            if (e.key === "ArrowDown") {
                const textAfterCursor = input.value.substring(input.selectionEnd);
                const isLastLine = !textAfterCursor.includes("\n");

                if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
                    e.preventDefault();
                    if (historyIndex > 0) {
                        historyIndex--;
                        input.value = localHistory[historyIndex];
                    } else {
                        historyIndex = -1;
                        input.value = input.dataset.draft || "";
                    }
                    updateInputHeight(input);
                    input.setSelectionRange(input.value.length, input.value.length);
                }
            }
        });
    }

    if (dropZone && dropOverlay) {
        ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
            dropZone.addEventListener(eventName, (e) => {
                e.preventDefault();
                e.stopPropagation();
            }, false);
        });

        ["dragenter", "dragover"].forEach(eventName => {
            dropZone.addEventListener(eventName, () => {
                dropOverlay.classList.remove("hidden");
                dropOverlay.classList.add("flex");
            }, false);
        });

        ["dragleave", "drop"].forEach(eventName => {
            dropZone.addEventListener(eventName, (e) => {
                if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
                    dropOverlay.classList.add("hidden");
                    dropOverlay.classList.remove("flex");
                }
            }, false);
        });

        dropZone.addEventListener("drop", (e) => {
            const dt = e.dataTransfer;
            const files = dt.files;

            if (files && files.length > 0) {
                const file = files[0];
                handleFileSelect({ target: { files: [file] } });
                
                const fileInput = document.getElementById("file-input");
                if (fileInput) {
                    const dataTransfer = new DataTransfer();
                    dataTransfer.items.add(file);
                    fileInput.files = dataTransfer.files;
                }
            }
        }, false);
    }
});

File: ./static/js/tasks.js

let currentFilter = 'ALL';
let allTasks = [];

function toggleDrawer() {
    if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
    const drawer = document.getElementById("task-drawer");
    const backdrop = document.getElementById("drawer-backdrop");
    if (!drawer) return;
    
    const isHidden = drawer.classList.contains("translate-x-full");
    if (isHidden) {
        drawer.classList.remove("translate-x-full");
        if (backdrop) backdrop.classList.remove("hidden");
        loadTasks();
    } else {
        drawer.classList.add("translate-x-full");
        if (backdrop) backdrop.classList.add("hidden");
    }
}

function setFilter(status) {
    currentFilter = status;
    ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
        const btn = document.getElementById(`filter-${f}`);
        if (btn) {
            btn.className = (f === status)
                ? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
                : "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
        }
    });
    renderTasks();
}

async function loadTasks() {
    const badge = document.getElementById("task-count-badge");
    const container = document.getElementById("tasks-container");

    const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
    const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");

    if (isGuest || !token) {
        if (badge) badge.innerText = "0";
        return;
    }

    try {
        const res = await fetch("/api/v1/tasks", {
            headers: { 
                "Authorization": "Bearer " + token,
                "Content-Type": "application/json"
            }
        });

        if (res.status === 401) {
            if (typeof logout === 'function') logout();
            return;
        }

        if (!res.ok) {
            throw new Error(`HTTP error! status: ${res.status}`);
        }

        const data = await res.json();

        // Гибкое определение структуры данных (массив или объект с ключом tasks)
        if (Array.isArray(data)) {
            allTasks = data;
        } else if (data && Array.isArray(data.tasks)) {
            allTasks = data.tasks;
        } else if (data && typeof data === 'object') {
            allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
        } else {
            allTasks = [];
        }

        if (badge) {
            badge.innerText = allTasks.length.toString();
        }

        renderTasks();

    } catch (err) {
        console.error("[Tasks Error]", err);
        if (badge) badge.innerText = "0";
        if (container) {
            container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
        }
    }
}

function renderTasks() {
    const container = document.getElementById("tasks-container");
    if (!container) return;
    
    if (!Array.isArray(allTasks)) {
        allTasks = [];
    }

    const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);

    if (filtered.length === 0) {
        container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
        return;
    }

    container.innerHTML = filtered.map(t => {
        let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
        let cardBg = "bg-white";
        
        if (t.status === "COMPLETED") {
            statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
            cardBg = "bg-emerald-50/20";
        } else if (t.status === "IN_PROGRESS") {
            statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
            cardBg = "bg-amber-50/20 border-amber-200";
        }

        let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
        if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";

        let dueDateHtml = t.due_date ? `
            <div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
                <i class="fa-solid fa-clock text-amber-600"></i>
                <span>Срок: ${t.due_date}</span>
            </div>` : "";

        return `
            <div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
                <div class="flex justify-between items-center mb-1.5">
                    <div class="flex items-center gap-1.5">
                        <span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
                        <span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
                    </div>
                    <span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span>
                </div>
                <h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
                <div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
                    <i class="fa-solid fa-folder-closed text-slate-300"></i>
                    <span>${t.module || 'General'}</span>
                </div>
                ${dueDateHtml}
            </div>
        `;
    }).join("");
}

File: ./static/css/styles.css

/* Плавное исчезновение текста сверху при скролле */
.fade-scroll-top {
    mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
}

/* Скрытие стандартного скроллбара */
.no-scrollbar::-webkit-scrollbar {
    display: none;
}
.no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
}

/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */
body {
    min-height: 100vh;
    min-height: -webkit-fill-available;
}
/* Плавное исчезновение текста сверху при скролле */
.fade-scroll-top {
    mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
}

/* Скрытие стандартного скроллбара */
.no-scrollbar::-webkit-scrollbar {
    display: none;
}
.no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
}

/* Оптимизация под мобильный viewport */
body {
    min-height: 100vh;
    min-height: -webkit-fill-available;
}

/* Анимация появления плашки с кнопками подтверждения */
.confirm-action-bar {
    animation: slideUp 0.25s ease-out forwards;
}

@keyframes slideUp {
    from {
        opacity: 0;
        transform: translateY(10px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

File: ./llm/__init__.py


File: ./llm/agent.py

"""
===============================================================================
MODULE: llm/agent.py
ROLE: Главный оркестратор агентской логики.
===============================================================================
"""
import re
import json
import urllib.request
import urllib.error
import logging
from typing import List, Dict, Any, Tuple, Optional

from .db_tools import (
    db_get_active_system_prompt,
    db_add_system_prompt,    
    db_confirm_prompt_preview,
    db_get_tasks,
    db_update_task_status,
    db_delete_task,
    db_add_task,
    db_get_rules,
    db_set_session_state,
    db_get_session_state,
    db_get_snapshots,
    db_delete_snapshots,
    db_clear_session_state,
    db_get_current_server_time,
    db_save_chat_message,
    db_get_chat_history,
    db_get_stats,
    db_get_anomalies,
    db_get_session_states,
    db_get_reference
)

from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("SCUD_AGENT")

OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"

def process_chat_message(
    user_id: int, 
    user_message: str, 
    file_context: str = "",
    image_b64: Optional[str] = None,
    chat_history: List[Dict[str, Any]] = None, 
    session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]]]:
    logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
    
    full_user_content = user_message
    if file_context:
        full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"

    db_history = db_get_chat_history(session_id, limit=20)
    db_save_chat_message(session_id, "user", full_user_content)

    dynamic_prompt_text = db_get_active_system_prompt()
    calendar_context = get_dynamic_calendar_context()
    
    session_state = db_get_session_state(session_id)
    preview_status_note = ""
    if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
        preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]."

    user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"

    system_prompt_content = (
        f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
        f"Вы общаетесь с пользователем: {user_info}.\n"
        f"Все запрашиваемые задачи через инструмент db_get_tasks автоматически принадлежат ИМЕННО этому пользователю. "
        f"Тебе НЕ НУЖНО уточнять, чьи это задачи или просить дополнительные идентификаторы. При запросах 'покажи мои задачи', 'список задач', 'мои дела' — СРАЗУ вызывай db_get_tasks.\n\n"
        f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
        f"{calendar_context}\n\n"
        f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n"
        f"При любых вопросах про дни недели ('прошлая среда', 'вторник', 'дата в прошлый понедельник') бери ГОТОВУЮ точную дату из справочника выше. Тебе ЗАПРЕЩЕНО вычислять даты самостоятельно!\n\n"
        f"[ПРАВИЛО ИНТЕЛЛЕКТУАЛЬНОГО УПРАВЛЕНИЯ]\n"
        f"Ты работаешь через функции (Tools). Если пользователь хочет изменить, добавить или удалить пункт системного промпта, ты НЕ ИМЕЕШЬ ПРАВА отвечать текстом. Ты ОБЯЗАН вызвать инструмент db_preview_prompt_merge, передав в аргументе prompt_text полный измененный вариант промпта. Игнорируй любые опечатки пользователя, понимай их смысл и вызывай функции.\n\n"
        f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}"
    )

    user_msg_object = {"role": "user", "content": full_user_content}

    llm_options = {
        "num_predict": 8192,
        "num_ctx": 8192,
        "temperature": 0.1,
        "repeat_penalty": 1.1,
        "presence_penalty": 0.5,
        "top_p": 0.9
    }

    if image_b64:
        user_msg_object["images"] = [image_b64]
        messages = [
            {
                "role": "system", 
                "content": (
                    "Ты — строгий модуль OCR для документов. Твоя задача — дословно переписать весь печатный и рукописный текст с изображения.\n"
                    "ПРАВИЛА:\n"
                    "1. Переписывай рукописный текст СТРОГО буква в букву так, как он написан от руки. Не додумывай слова от себя!\n"
                    "2. Отдельно выдели блок с рукописными записями, подписями и датами.\n"
                    "3. Не добавляй лишних слов, которых нет в графической части."
                )
            },
            user_msg_object
        ]
        payload = {
            "model": VISION_MODEL,
            "messages": messages,
            "stream": False,
            "options": llm_options
        }
    else:
        clean_db_history = []
        for msg in db_history:
            msg_copy = dict(msg)
            msg_copy.pop("images", None)
            clean_db_history.append(msg_copy)

        system_prompt = {"role": "system", "content": system_prompt_content}
        messages = [system_prompt] + clean_db_history + [user_msg_object]
        payload = {
            "model": TEXT_MODEL,
            "messages": messages,
            "tools": TOOLS_SCHEMA,
            "tool_choice": "auto",  # Явное указание Ollama задействовать функции
            "stream": False,
            "options": llm_options
        }

    try:
        req = urllib.request.Request(
            OLLAMA_URL,
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req) as response:
            res_data = json.loads(response.read().decode("utf-8"))
            msg = res_data.get("message", {})
            tool_calls = msg.get("tool_calls", [])
            raw_text_content = msg.get("content", "")

            # Активируем внешний инжектор
            tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls)
            logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}")

            if tool_calls:
                messages.append(msg)
                
                for tool in tool_calls:
                    fn_name = tool["function"]["name"]
                    fn_args = tool["function"].get("arguments", {})
                    logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
                    tool_result_content = ""

                    if fn_name == "db_get_snapshots":
                        date_arg = fn_args.get("date_str")
                        snapshots_res = db_get_snapshots(session_id=session_id, date_str=date_arg, original_user_message=user_message)
                        tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)

                    elif fn_name == "db_get_current_server_time":
                        time_res = db_get_current_server_time()
                        tool_result_content = json.dumps(time_res, ensure_ascii=False)

                    elif fn_name == "db_get_tasks":
                        tasks = db_get_tasks(user_id)
                        tool_result_content = json.dumps(tasks, ensure_ascii=False)

                    elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
                        prompt_content = db_get_active_system_prompt()
                        tool_result_content = json.dumps({"system_prompt": prompt_content}, ensure_ascii=False)

                    elif fn_name == "db_get_stats":
                        tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)

                    elif fn_name == "db_get_anomalies":
                        limit_arg = fn_args.get("limit", 100)
                        date_arg = fn_args.get("date_str")
                        tool_result_content = json.dumps(db_get_anomalies(limit=limit_arg, date_str=date_arg), ensure_ascii=False)

                    elif fn_name == "db_get_session_states":
                        tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)

                    elif fn_name == "db_delete_snapshots":
                        snap_id = fn_args.get("snapshot_id")
                        day_arg = fn_args.get("day_str")
                        tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=snap_id, day_str=day_arg), ensure_ascii=False)    

                    elif fn_name == "db_get_reference":
                        cat_arg = fn_args.get("category")
                        tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False)

                    elif fn_name == "db_confirm_prompt_preview":
                        # 1. Применяем изменения в БД и чистим состояние
                        res = db_confirm_prompt_preview(session_id)
                        
                        # 2. Формируем четкий текстовый ответ
                        if res.get("status") == "success":
                            confirm_reply = "Изменения системного промпта успешно подтверждены и применены."
                        else:
                            confirm_reply = res.get("message", "Ошибка при подтверждении превью.")
                            
                        # 3. Сохраняем ответ и СРАЗУ возвращаем его, НЕ запрашивая Ollama повторно!
                        db_save_chat_message(session_id, "assistant", confirm_reply)
                        return clean_raw_tool_tags(confirm_reply), db_get_chat_history(session_id)

                    elif fn_name == "db_cancel_prompt_preview":
                        db_clear_session_state(session_id)
                        cancel_reply = "Превью изменений системного промпта отменено."
                        db_save_chat_message(session_id, "assistant", cancel_reply)
                        return clean_raw_tool_tags(cancel_reply), db_get_chat_history(session_id)

                    elif fn_name == "db_cancel_prompt_preview":
                        db_clear_session_state(session_id)
                        tool_result_content = json.dumps({"status": "success", "message": "Превью отменено."}, ensure_ascii=False)

                    elif fn_name == "db_add_system_prompt":
                        try:
                            prompt_text = fn_args.get("prompt_text") if isinstance(fn_args, dict) else str(fn_args)
                            name = fn_args.get("name", "main_agent") if isinstance(fn_args, dict) else "main_agent"
                            res = db_add_system_prompt(name=name, prompt_text=prompt_text)
                            db_clear_session_state(session_id)
                            tool_result_content = json.dumps(res, ensure_ascii=False)
                        except Exception as e:
                            tool_result_content = json.dumps({"status": "error", "error": str(e)}, ensure_ascii=False)

                    elif fn_name == "db_get_rules":
                        tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)

                    elif fn_name == "db_add_task":
                        res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
                        tool_result_content = json.dumps(res, ensure_ascii=False)

                    elif fn_name == "db_update_task_status":
                        res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
                        tool_result_content = json.dumps(res, ensure_ascii=False)

                    elif fn_name == "db_delete_task":
                        res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
                        tool_result_content = json.dumps(res, ensure_ascii=False)

                    messages.append({
                        "role": "tool",
                        "content": tool_result_content
                    })

                second_payload = {
                    "model": TEXT_MODEL,
                    "messages": messages,
                    "stream": False,
                    "options": llm_options
                }
                sec_req = urllib.request.Request(
                    OLLAMA_URL,
                    data=json.dumps(second_payload).encode("utf-8"),
                    headers={"Content-Type": "application/json"}
                )
                with urllib.request.urlopen(sec_req) as sec_response:
                    sec_res_data = json.loads(sec_response.read().decode("utf-8"))
                    raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
                    final_content = clean_raw_tool_tags(clean_output(raw_content))
                    db_save_chat_message(session_id, "assistant", final_content)
                    return final_content, db_get_chat_history(session_id)

            raw_str = msg.get("content", "").strip().replace("**", "")
            content_str = clean_raw_tool_tags(clean_output(raw_str))
            final_reply = content_str or "Запрос обработан."
            db_save_chat_message(session_id, "assistant", final_reply)
            return final_reply, db_get_chat_history(session_id)

    except Exception as ex:
        logger.exception(f"Непредвиденная ошибка: {ex}")
        error_reply = f"Внутренняя ошибка сервера: {ex}"
        return error_reply, db_get_chat_history(session_id)

File: ./llm/db_tools.py

"""
Прослойка совместимости: реэкспортирует функции из llm/db/
"""
from .db.connection import DB_PATH, get_db_connection
from .db.db_chat import db_save_chat_message, db_get_chat_history
from .db.db_tasks import (
    normalize_task_id,
    db_get_tasks,
    db_add_task,
    db_update_task_status,
    db_delete_task
)
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
from .db.db_prompts import (
    db_get_active_system_prompt,
    db_add_system_prompt,
    db_get_rules,
    db_set_session_state,
    db_get_session_state,
    db_clear_session_state,
    db_get_session_states,
    db_get_stats,
    db_get_anomalies,
    db_confirm_prompt_preview,
    db_get_reference
)
from datetime import datetime

def db_get_current_server_time():
    now = datetime.now()
    days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
    return {
        "current_date": now.strftime("%d.%m.%Y"),
        "current_time": now.strftime("%H:%M:%S"),
        "day_of_week": days_ru[now.weekday()],
        "iso_date": now.strftime("%Y-%m-%d")
    }

File: ./llm/file_parser.py

import base64
import os
import subprocess
import logging
import pandas as pd

logger = logging.getLogger("FILE_PARSER")

def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
    ext = os.path.splitext(filename)[1].lower()
    temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
    
    with open(temp_filepath, "wb") as f:
        f.write(file_bytes)

    try:
        # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
        if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
            b64_str = base64.b64encode(file_bytes).decode('utf-8')
            return {
                "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
                "image_b64": b64_str
            }

        # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
        elif ext == '.pdf':
            cmd = ['pdftotext', temp_filepath, '-']
            res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
            pdf_text = res.stdout.strip()

            img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
            subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
            
            page_png = f"{img_prefix}-1.png"
            b64_str = None
            if os.path.exists(page_png):
                with open(page_png, "rb") as pf:
                    b64_str = base64.b64encode(pf.read()).decode('utf-8')
                os.remove(page_png)

            context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
            if pdf_text:
                context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"

            return {
                "text": context_text,
                "image_b64": b64_str
            }

        # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
        elif ext in ['.xlsx', '.xls', '.csv']:
            if ext == '.csv':
                df = pd.read_csv(temp_filepath)
            else:
                df = pd.read_excel(temp_filepath)
                
            total_rows = len(df)
            df_preview = df.head(100)
            table_str = df_preview.to_string(index=False)
            note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
            return {
                "text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
                "image_b64": None
            }

        # 4. ТЕКСТОВЫЕ ФАЙЛЫ
        elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
            with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
                return {
                    "text": tf.read().strip(),
                    "image_b64": None
                }

        else:
            return {
                "text": f"[ОШИБКА: Формат {ext} не поддерживается]",
                "image_b64": None
            }

    except Exception as e:
        logger.error(f"Ошибка при анализе файла {filename}: {e}")
        return {
            "text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
            "image_b64": None
        }
    finally:
        if os.path.exists(temp_filepath):
            os.remove(temp_filepath)

File: ./llm/schemas.py

TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "db_get_tasks",
            "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
            "parameters": {
                "type": "object",
                "properties": {
                    "status": {
                        "type": "string",
                        "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_rules",
            "description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
            "parameters": {"type": "object", "properties": {}}
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_update_task_status",
            "description": "Изменить статус и/или срок выполнения задачи в реестре.",
            "parameters": {
                "type": "object",
                "properties": {
                    "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
                    "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
                    "due_date": {"type": "string", "description": "Срок выполнения задачи"}
                },
                "required": ["task_id"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_delete_task",
            "description": "УДАЛИТЬ ЗАДАЧУ ИЗ РЕЕСТРА ЗАДАЧ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при командах 'удали задачу TASK-XX', 'стереть задачу'. ЗАПРЕЩЕНО использовать эту функцию для работы с системным промптом!",
            "parameters": {
                "type": "object",
                "properties": {
                    "task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
                },
                "required": ["task_id"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_snapshots",
            "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
            "parameters": {
                "type": "object",
                "properties": {
                    "date_str": {
                        "type": "string",
                        "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_delete_snapshots",
            "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
            "parameters": {
                "type": "object",
                "properties": {
                    "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
                    "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_current_server_time",
            "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
            "parameters": {
                "type": "object",
                "properties": {},
                "required": []
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_add_task",
            "description": "ДОБАВИТЬ НОВУЮ ЗАДАЧУ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, когда пользователь просит 'добавь новую задачу', 'создай задачу', 'поставь задачу'. ЗАПРЕЩЕНО писать о добавлении задачи текстом без вызова этой функции!",
            "parameters": {
                "type": "object",
                "properties": {
                    "title": {"type": "string", "description": "Краткое описание задачи"},
                    "priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
                    "module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
                    "due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
                },
                "required": ["title"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_stats",
            "description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
            "parameters": {"type": "object", "properties": {}}
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_anomalies",
            "description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
            "parameters": {
                "type": "object",
                "properties": {
                    "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
                    "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_session_states",
            "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
            "parameters": {"type": "object", "properties": {}}
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_reference",
            "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
            "parameters": {
                "type": "object",
                "properties": {
                    "category": {
                        "type": "string",
                        "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_preview_prompt_merge",
            "description": "СОЗДАТЬ ПРЕВЬЮ ИЗМЕНЕНИЙ СИСТЕМНОГО ПРОМПТА. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при запросах на добавление, изменение или удаление пунктов системного промпта.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt_text": {
                        "type": "string",
                        "description": "Текст нового пункта для добавления ИЛИ инструкция по удалению/изменению (например: '3.4. Тестовый промпт' или 'удалить пункт 3.4')."
                    }
                },
                "required": ["prompt_text"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_system_prompt",
            "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!",
            "parameters": {"type": "object", "properties": {}}
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_confirm_prompt_preview",
            "description": "ПОДТВЕРДИТЬ И СОХРАНИТЬ ПРЕВЬЮ ПРОМПТА В БАЗУ. Вызывай этот инструмент СТРОГО тогда, когда пользователь пишет 'подтверждаю', 'да', 'сохраняй', 'применяй' или кликает кнопку Подтвердить.",
            "parameters": {"type": "object", "properties": {}}
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_cancel_prompt_preview",
            "description": "ОТМЕНИТЬ ПРЕВЬЮ ПРОМПТА. Вызывай, когда пользователь пишет 'отмена', 'отменить' или отказывается от изменений.",
            "parameters": {"type": "object", "properties": {}}
        }
    }
]

File: ./llm/db/connection.py

"""
Модуль подключения к SQLite.
"""
import sqlite3

DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"

def get_db_connection() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH, timeout=30.0)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode = WAL;")
    conn.execute("PRAGMA synchronous = NORMAL;")
    return conn

File: ./llm/db/db_chat.py

"""
Модуль хранения и загрузки истории сообщений чата.
"""
from typing import List, Dict, Any
from .connection import get_db_connection

def db_save_chat_message(session_id: str, role: str, content: str):
    if not content:
        return
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        INSERT INTO chat_messages (session_id, role, content, created_at)
        VALUES (?, ?, ?, CURRENT_TIMESTAMP)
    """, (session_id, role, content))
    conn.commit()
    conn.close()

def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        SELECT role, content FROM chat_messages 
        WHERE session_id = ? 
        ORDER BY id DESC LIMIT ?
    """, (session_id, limit))
    rows = cursor.fetchall()
    conn.close()
    return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]

File: ./llm/db/db_prompts.py

"""
Модуль системных промптов, сессионных состояний и превью.
"""
import logging
from typing import List, Dict, Any, Optional
from .connection import get_db_connection

logger = logging.getLogger("DB_PROMPTS")

def db_get_active_system_prompt() -> str:
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
    row = cursor.fetchone()
    conn.close()
    return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."

def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
    try:
        with get_db_connection() as conn:
            cursor = conn.cursor()
            cursor.execute("BEGIN IMMEDIATE;")
            
            cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
            existing = cursor.fetchone()
            
            if existing:
                cursor.execute(
                    "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
                    (prompt_text, name)
                )
            else:
                cursor.execute(
                    "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
                    (name, prompt_text)
                )
                
            conn.commit()
            
        logger.info("Системный промпт успешно сохранен и применен в базе данных.")
        return {"status": "success", "message": "Системный промпт успешно обновлен"}
    except Exception as e:
        logger.error(f"Ошибка при сохранении промпта в БД: {e}")
        return {"status": "error", "error": str(e)}

def db_get_rules() -> List[Dict[str, Any]]:
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
    rows = cursor.fetchall()
    conn.close()
    return [dict(r) for r in rows]

def db_set_session_state(session_id: str, state_type: str, data: str):
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
        VALUES (?, ?, ?, CURRENT_TIMESTAMP)
        ON CONFLICT(session_id) DO UPDATE SET
            state_type = excluded.state_type,
            pending_data = excluded.pending_data,
            updated_at = CURRENT_TIMESTAMP
    """, (session_id, state_type, data))
    conn.commit()
    conn.close()

def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,))
    row = cursor.fetchone()
    conn.close()
    return dict(row) if row else None

def db_clear_session_state(session_id: str):
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
    conn.commit()
    conn.close()

def db_get_session_states() -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
    rows = cursor.fetchall()
    conn.close()
    return {"status": "success", "active_sessions": [dict(r) for r in rows]}

def db_get_stats() -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks']
    stats = {}
    for t in tables:
        try:
            cursor.execute(f"SELECT COUNT(*) FROM {t}")
            stats[t] = cursor.fetchone()[0]
        except Exception:
            stats[t] = 0
    conn.close()
    return {"status": "success", "tables_stats": stats}

def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
    params = []
    if date_str:
        query += " WHERE anomaly_date = ?"
        params.append(date_str)
    query += " ORDER BY id DESC LIMIT ?"
    params.append(limit)
    cursor.execute(query, params)
    rows = cursor.fetchall()
    conn.close()
    return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]}

def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    query = "SELECT category, title, example_prompt, description FROM system_reference"
    params = []
    if category:
        query += " WHERE category = ?"
        params.append(category)
    query += " ORDER BY id ASC"
    cursor.execute(query, params)
    rows = cursor.fetchall()
    conn.close()
    return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}

def db_confirm_prompt_preview(session_id: str) -> Dict[str, Any]:
    session_state = db_get_session_state(session_id)
    if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
        pending_text = session_state.get("pending_data", "")
        res = db_add_system_prompt("main_agent", pending_text)
        db_clear_session_state(session_id) # Очищаем состояние
        return res
    return {"status": "error", "message": "Нет активного превью для подтверждения."}

File: ./llm/db/db_snapshots.py

"""
Модуль выгрузки и очистки срезов/снапшотов СКУД.
"""
import json
from typing import Dict, Any, Optional
from .connection import get_db_connection
from .db_prompts import db_set_session_state

def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]:
    return date_str

def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
    date_str = smart_parse_date(date_str, original_user_message)
    
    conn = get_db_connection()
    cursor = conn.cursor()
    
    query = """
        SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count 
        FROM scud_logs 
    """
    params = []
    
    if date_str:
        iso_date = date_str
        if "." in date_str:
            parts = date_str.split(".")
            if len(parts) == 3:
                iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
        
        query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
        params.extend([date_str, iso_date, f"{iso_date}%"])
        
    query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
    
    cursor.execute(query, params)
    rows = cursor.fetchall()
    snapshots = [dict(r) for r in rows]
    
    result_data = {
        "query_date": date_str or "все",
        "snapshots_count": len(snapshots),
        "snapshots": snapshots
    }
    
    db_set_session_state(
        session_id=session_id, 
        state_type="SNAPSHOTS_VIEW", 
        data=json.dumps(result_data, ensure_ascii=False)
    )
    
    conn.close()
    return result_data

def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
    if not snapshot_id and not day_str:
        return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
    
    conn = get_db_connection()
    cursor = conn.cursor()
    
    if snapshot_id:
        cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
        deleted = cursor.rowcount
    else:
        cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
        deleted = cursor.rowcount
        
    conn.commit()
    conn.close()
    return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}

File: ./llm/db/db_tasks.py

"""
Модуль управления бэклогом задач (Task Tracker).
"""
import re
from typing import List, Dict, Any, Optional
from .connection import get_db_connection

def normalize_task_id(task_id_input: str) -> str:
    if not task_id_input:
        return ""
    clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
    if clean_id.isdigit():
        num = int(clean_id)
        return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
    return f"TASK-{clean_id}"

def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        SELECT id, task_id, module, title, priority, status, due_date, created_at 
        FROM tasks 
        WHERE user_id = ?
        ORDER BY id DESC
    """, (user_id,))
    rows = cursor.fetchall()
    conn.close()
    return [dict(r) for r in rows]

def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    
    # Считываем все существующие TASK-XX и находим реальный максимум
    cursor.execute("SELECT task_id FROM tasks")
    rows = cursor.fetchall()
    
    max_num = 0
    for r in rows:
        t_id = r["task_id"] or ""
        match = re.search(r'\d+', t_id)
        if match:
            num = int(match.group(0))
            if num > max_num:
                max_num = num
                
    new_task_id = f"TASK-{(max_num + 1):02d}"

    cursor.execute("""
        INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
        VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
    """, (new_task_id, module, title, priority.upper(), due_date, user_id))
    
    conn.commit()
    conn.close()
    return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}

def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    
    formatted_id = normalize_task_id(task_id)
    
    if due_date:
        cursor.execute("""
            UPDATE tasks 
            SET status = ?, due_date = ? 
            WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
        """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
    else:
        cursor.execute("""
            UPDATE tasks 
            SET status = ? 
            WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
        """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))

    if cursor.rowcount == 0:
        conn.close()
        return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}

    conn.commit()
    conn.close()
    return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}

def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
    conn = get_db_connection()
    cursor = conn.cursor()
    
    formatted_id = normalize_task_id(task_id)
    
    cursor.execute("""
        DELETE FROM tasks 
        WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
    """, (formatted_id, f"%{task_id.strip()}", user_id))
    
    if cursor.rowcount == 0:
        conn.close()
        return {"error": f"Задача {task_id} не найдена"}

    conn.commit()
    conn.close()
    return {"status": "success", "message": f"Задача {formatted_id} удалена"}

File: ./llm/core/calendar_utils.py

"""
Вспомогательные утилиты динамического календаря и парсинга дат.
"""
import re
from datetime import datetime, timedelta

DAYS_RU = [
    "понедельник", "вторник", "среда", "четверг", 
    "пятница", "суббота", "воскресенье"
]

def parse_relative_date_ru(text: str) -> str:
    now = datetime.now()
    text_lower = text.lower() if text else ""
    
    match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
    if match:
        return match.group(1)

    if "вчера" in text_lower:
        return (now - timedelta(days=1)).strftime("%d.%m.%Y")
    elif "позавчера" in text_lower:
        return (now - timedelta(days=2)).strftime("%d.%m.%Y")
    elif "сегодня" in text_lower:
        return now.strftime("%d.%m.%Y")
        
    return (now - timedelta(days=1)).strftime("%d.%m.%Y")

def get_dynamic_calendar_context() -> str:
    now = datetime.now()
    current_wd = now.weekday()
    
    lines = [
        f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).",
        "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):",
        f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})",
        f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})",
        f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})",
        "\nПрошедшие дни недели:"
    ]
    
    for days_back in range(1, 8):
        dt = now - timedelta(days=days_back)
        day_name = DAYS_RU[dt.weekday()]
        
        if days_back == 7:
            label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}"
            lines.append(f"• {label} (ровно неделю назад): {dt.strftime('%d.%m.%Y')}")
        else:
            label = f"Ближайший прошедший {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Ближайшая прошедшая {day_name}"
            lines.append(f"• {label} / {day_name}: {dt.strftime('%d.%m.%Y')}")
            
    return "\n".join(lines)

File: ./llm/core/tool_injector.py

"""
Модуль чистки сырых тегов Ollama и перехвата Tool-вызовов (Tool Injector).
"""
import re
import json
import logging
from typing import List, Dict, Any

logger = logging.getLogger("TOOL_INJECTOR")

def clean_raw_tool_tags(text: str) -> str:
    if not text:
        return ""
    text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
    text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
    text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
    text = re.sub(r'</tool_call>', '', text)
    return text.strip()

def clean_output(text: str) -> str:
    if not text:
        return text
    artifacts = ["почемучто", "почто", "почему что"]
    lower_text = text.lower()
    for art in artifacts:
        if lower_text.startswith(art):
            text = text[len(art):].lstrip(",.!?:; -")
    return text.strip()

def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    if tool_calls:
        return tool_calls

    # Единственная задача Python — поймать JSON, если модель написала его текстом
    if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
        try:
            match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
            if match:
                fn_name = match.group(1)
                fn_args = json.loads(match.group(2))
                return [{"function": {"name": fn_name, "arguments": fn_args}}]
        except Exception:
            pass

    return tool_calls