Files
scud_ai/web_api_code_snapshot.md
T

241 KiB
Raw Blame History

🌐 WEB API & FRONTEND CODE SNAPSHOT

File: ./modules/web_api/main.py

"""
===============================================================================
FILE: modules/web_api/main.py
PROJECT: SCUD Orion AI (Unified Repository)
MODULE: web_api (Main Application Entry Point)
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
===============================================================================
"""

# ANCHOR[APP_INIT_IMPORTS]
import os
import sys
import logging

CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
    sys.path.insert(0, CURRENT_DIR)

ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))

for p in [ROOT_DIR, CURRENT_DIR]:
    if p not in sys.path:
        sys.path.insert(0, p)

from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from routers.manual_absences import router as manual_absences_router

from routers.auth import router as auth_router
from routers.admin import router as admin_router
from routers.tasks import router as tasks_router
from routers.chat import router as chat_router
from routers.files import router as files_router
from routers.exceptions import router as exceptions_router
from routers.snapshots import router as snapshots_router
from routers.remote_workers import router as remote_workers_router
from routers.context import router as context_router

# ANCHOR[APP_CONFIG]
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler()]
)

STATIC_DIR = os.path.join(CURRENT_DIR, "static")

app = FastAPI(title="SCUD Orion AI Context API", version="2.5")

if os.path.exists(STATIC_DIR):
    app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
    return JSONResponse(
        status_code=422,
        content={"detail": exc.errors(), "body": str(exc)}
    )

# ANCHOR[ROUTER_REGISTRATION]
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(tasks_router)
app.include_router(chat_router)
app.include_router(files_router)
app.include_router(exceptions_router)
app.include_router(snapshots_router)
app.include_router(remote_workers_router)
app.include_router(context_router)
app.include_router(manual_absences_router)

# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
def read_root():
    index_path = os.path.join(STATIC_DIR, "index.html")
    if os.path.exists(index_path):
        return FileResponse(index_path)
    raise HTTPException(status_code=404, detail="Frontend index.html not found")

@app.get("/favicon.ico")
async def favicon():
    file_path = os.path.join(STATIC_DIR, "favicon.ico")
    if os.path.exists(file_path):
        return FileResponse(file_path)
    raise HTTPException(status_code=404)

@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
    clean_path = file_path.lstrip("/")
    
    # Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
    forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
    if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
        raise HTTPException(status_code=403, detail="Access denied")

    target = os.path.join(STATIC_DIR, clean_path)
    
    if os.path.isfile(target):
        if clean_path.endswith(".js"):
            return FileResponse(target, media_type="application/javascript")
        elif clean_path.endswith(".css"):
            return FileResponse(target, media_type="text/css")
        return FileResponse(target)

    filename = os.path.basename(clean_path)
    for root, _, files in os.walk(STATIC_DIR):
        if filename in files:
            full_path = os.path.join(root, filename)
            media = "application/javascript" if filename.endswith(".js") else "text/css"
            return FileResponse(full_path, media_type=media)

    raise HTTPException(status_code=404, detail="File not found")

File: ./modules/web_api/routers/chat.py

"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
      поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
===============================================================================
"""

import os
import shutil
import base64
import logging
from typing import Optional, List, Dict, Any

from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
from pydantic import BaseModel

from llm.agent import process_chat_message
from config import BASE_DIR

logger = logging.getLogger("CHAT_API")
router = APIRouter(prefix="/api/v1", tags=["Chat"])

UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)


class ChatMessageRequest(BaseModel):
    message: str
    session_id: Optional[str] = "web_session_main"
    user_id: Optional[int] = 1


def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
    if explicit_user_id and explicit_user_id > 0:
        return explicit_user_id

    if authorization and authorization.startswith("Bearer "):
        token = authorization.replace("Bearer ", "").strip()
        if token.isdigit():
            return int(token)
        elif token.startswith("dev_token_"):
            try:
                return int(token.replace("dev_token_", ""))
            except ValueError:
                pass
    return 1


@router.post("/chat")
async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
    user_id = resolve_user_id(authorization, payload.user_id)
    session_id = payload.session_id or "web_session_main"
    user_msg = payload.message.strip()

    if not user_msg:
        raise HTTPException(status_code=400, detail="Пустое сообщение")

    logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")

    reply_text, history, action_payload = process_chat_message(
        user_id=user_id,
        user_message=user_msg,
        session_id=session_id
    )

    return {
        "status": "success",
        "user_id": user_id,
        "session_id": session_id,
        "response": reply_text,
        "action_payload": action_payload
    }


@router.post("/chat/upload")
async def chat_upload_endpoint(
    file: UploadFile = File(...),
    message: Optional[str] = Form(""),
    session_id: Optional[str] = Form("web_session_main"),
    authorization: Optional[str] = Header(None)
):
    user_id = resolve_user_id(authorization, 1)
    file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
    
    with open(file_path, "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)

    file_context = ""
    image_b64 = None
    fn_lower = file.filename.lower()

    # 1. Текстовые форматы
    if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
        try:
            with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
                file_context = f.read(6000)
        except Exception as e:
            logger.warning(f"Не удалось прочитать текст: {e}")

    # 2. Изображения (прямой OCR)
    elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
        try:
            with open(file_path, "rb") as f:
                image_b64 = base64.b64encode(f.read()).decode("utf-8")
        except Exception as e:
            logger.warning(f"Ошибка кодирования картинки в base64: {e}")

    # 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
    elif fn_lower.endswith(".pdf"):
        # Попытка извлечь встроенный текстовый слой
        try:
            import pypdf
            reader = pypdf.PdfReader(file_path)
            extracted = []
            for page in reader.pages:
                t = page.extract_text()
                if t:
                    extracted.append(t)
            file_context = "\n".join(extracted).strip()
        except Exception:
            pass

        # Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
        if len(file_context) < 40:
            try:
                import fitz  # PyMuPDF
                doc = fitz.open(file_path)
                if len(doc) > 0:
                    page = doc[0]
                    pix = page.get_pixmap(dpi=150)
                    img_bytes = pix.tobytes("png")
                    image_b64 = base64.b64encode(img_bytes).decode("utf-8")
                    file_context = ""
            except Exception as e:
                logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")

    user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"

    reply_text, history, action_payload = process_chat_message(
        user_id=user_id,
        user_message=user_msg,
        file_context=file_context,
        image_b64=image_b64,
        session_id=session_id
    )

    return {
        "status": "success",
        "user_id": user_id,
        "session_id": session_id,
        "response": reply_text,
        "action_payload": action_payload
    }

File: ./modules/web_api/routers/remote_workers.py

"""
===============================================================================
FILE: modules/web_api/routers/remote_workers.py
ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка).
===============================================================================
"""

import os
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any

from routers.auth import get_current_user
from config import BASE_DIR, DATA_DIR

router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"])
CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv")


def _load_workers() -> List[Dict[str, Any]]:
    """Читает CSV, гарантирует структуру колонок и удаляет просроченные записи."""
    if not os.path.exists(CSV_PATH):
        return []
    try:
        df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("")
        for col in ["fio", "department", "reason", "date_from", "date_to"]:
            if col not in df.columns:
                df[col] = ""

        today_date = datetime.now().date()
        valid_workers = []
        has_expired = False

        for row in df.to_dict(orient="records"):
            fio = str(row.get("fio", "")).strip()
            if not fio:
                continue

            d_to_str = str(row.get("date_to", "")).strip()
            if d_to_str and d_to_str.lower() not in ["nan", "none", ""]:
                try:
                    d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date()
                    # Если срок завершился вчера или ранее — запись удаляется из файла
                    if today_date > d_to:
                        has_expired = True
                        continue
                except ValueError:
                    pass

            valid_workers.append(row)

        # Синхронная перезапись файла при обнаружении истекших сроков
        if has_expired:
            _save_workers(valid_workers)

        return valid_workers
    except Exception:
        return []


def _save_workers(workers: List[Dict[str, Any]]):
    """Сохраняет актуальный список в CSV с сохранением колонок."""
    df = pd.DataFrame(workers)
    for col in ["fio", "department", "reason", "date_from", "date_to"]:
        if col not in df.columns:
            df[col] = ""
    os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
    df.to_csv(CSV_PATH, index=False, encoding="utf-8")


class RemoteWorkerItem(BaseModel):
    fio: str
    department: Optional[str] = "Все"
    reason: Optional[str] = "Удаленная работа"
    date_from: Optional[str] = ""
    date_to: Optional[str] = ""


class UpdateDatesRequest(BaseModel):
    fio: str
    date_from: Optional[str] = ""
    date_to: Optional[str] = ""


@router.get("")
def api_get_remote_workers(current_user = Depends(get_current_user)):
    return {"workers": _load_workers()}


@router.post("")
def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)):
    workers = _load_workers()
    fio_clean = item.fio.strip()
    if not fio_clean:
        raise HTTPException(status_code=400, detail="ФИО не может быть пустым")

    # Если начало не указано — берем сегодня
    today_str = datetime.now().strftime("%d.%m.%Y")
    date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str
    date_to = item.date_to.strip() if item.date_to else ""

    # Проверка на совпадение ФИО (обновление существующей записи)
    for w in workers:
        if str(w.get("fio", "")).strip().lower() == fio_clean.lower():
            w["department"] = item.department.strip() if item.department else (w.get("department") or "Все")
            w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа")
            w["date_from"] = date_from
            w["date_to"] = date_to
            _save_workers(workers)
            return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers}

    workers.append({
        "fio": fio_clean,
        "department": item.department.strip() if item.department else "Все",
        "reason": item.reason.strip() if item.reason else "Удаленная работа",
        "date_from": date_from,
        "date_to": date_to
    })
    _save_workers(workers)
    return {"status": "success", "workers": workers}


@router.put("")
def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)):
    """Редактирование срока удаленки (продление или сокращение)."""
    workers = _load_workers()
    target_fio = req.fio.strip().lower()
    found = False

    for w in workers:
        if str(w.get("fio", "")).strip().lower() == target_fio:
            w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "")
            w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "")
            found = True
            break

    if not found:
        raise HTTPException(status_code=404, detail="Сотрудник не найден в списке")

    _save_workers(workers)
    return {"status": "success", "message": "Сроки успешно изменены", "workers": workers}


@router.delete("")
def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)):
    workers = _load_workers()
    fio_clean = fio.strip().lower()
    initial_len = len(workers)
    workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean]

    if len(workers) == initial_len:
        raise HTTPException(status_code=404, detail="Сотрудник не найден")

    _save_workers(workers)
    return {"status": "success", "workers": workers}

File: ./modules/web_api/routers/manual_absences.py

"""
===============================================================================
FILE: modules/web_api/routers/manual_absences.py
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
===============================================================================
"""

from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List, Dict, Any

from services.manual_absences_repo import (
    search_staff_suggestions,
    get_static_reasons,
    add_manual_absence,
    delete_manual_absence,
    get_manual_absences_list
)

router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])


class AddAbsenceRequest(BaseModel):
    absence_type: str  # 'LOCAL_TRIP' или 'OTHER'
    fio: str
    reason: Optional[str] = ""
    department: Optional[str] = ""
    position: Optional[str] = ""
    date_start: Optional[str] = None
    date_end: Optional[str] = None
    comment: Optional[str] = ""


@router.get("/staff-autocomplete")
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
    return search_staff_suggestions(q)


@router.get("/reasons")
def api_get_reasons():
    return {"reasons": get_static_reasons()}


@router.get("/")
def api_list_manual_absences(type: Optional[str] = None):
    return {"items": get_manual_absences_list(type)}


@router.post("/")
def api_add_manual_absence(req: AddAbsenceRequest):
    reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
    res_id = add_manual_absence(
        absence_type=req.absence_type,
        fio=req.fio,
        reason=reason,
        department=req.department,
        position=req.position,
        date_start=req.date_start,
        date_end=req.date_end,
        comment=req.comment
    )
    if not res_id:
        raise HTTPException(status_code=400, detail="Не удалось добавить запись")
    return {"status": "success", "id": res_id}


@router.delete("/{item_id}")
def api_delete_manual_absence(item_id: int):
    if not delete_manual_absence(item_id):
        raise HTTPException(status_code=404, detail="Запись не найдена")
    return {"status": "success"}

File: ./modules/web_api/routers/exceptions.py

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, List
from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db

router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])


class ExceptionItem(BaseModel):
    category: str
    value: str
    comment: Optional[str] = ""


@router.get("/")
def api_get_exceptions():
    return get_all_exceptions_from_db()


@router.post("/")
def api_add_exception(item: ExceptionItem):
    if not add_exception_to_db(item.category, item.value, item.comment):
        raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
    return {"status": "success", "data": item}


@router.delete("/")
def api_delete_exception(category: str, value: str):
    if not remove_exception_from_db(category, value):
        raise HTTPException(status_code=404, detail="Исключение не найдено")
    return {"status": "success"}

File: ./modules/web_api/routers/snapshots.py

"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
===============================================================================
"""

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any

from routers.auth import get_current_user
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from services.scud_export import run_export

router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])


class CreateSnapshotRequest(BaseModel):
    date_str: Optional[str] = None


class DeleteSnapshotsRequest(BaseModel):
    snapshot_ids: List[str]


@router.get("")
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
    return get_snapshots_registry(date_str=date_str)


@router.post("/create")
def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
    """Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
    try:
        success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
        if not success:
            raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
        
        fresh_data = get_snapshots_registry(date_str=req.date_str)
        return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")


@router.delete("")
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
    safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
    if not safe_ids:
        raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
    
    res = delete_snapshots_safely(snapshot_ids=safe_ids)
    return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}

File: ./modules/web_api/routers/tasks.py

"""
===============================================================================
FILE: modules/web_api/routers/tasks.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: REST API эндпоинты реестра задач.
===============================================================================
"""

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, Any

from routers.auth import get_current_user
from services.tasks.service import get_tasks, add_task, update_task_details

router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])


class TaskCreateRequest(BaseModel):
    title: str
    priority: Optional[str] = "MEDIUM"
    module: Optional[str] = "general"
    due_date: Optional[str] = None
    status: Optional[str] = "BACKLOG"


class TaskUpdateRequest(BaseModel):
    title: Optional[str] = None
    priority: Optional[str] = None
    due_date: Optional[str] = None
    status: Optional[str] = None


def resolve_user_id(current_user: Dict[str, Any]) -> int:
    if not current_user:
        return 1
    return current_user.get("id") or current_user.get("user_id") or 1


@router.get("")
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
    user_id = resolve_user_id(current_user)
    return {"tasks": get_tasks(user_id=user_id, status=status)}


@router.post("")
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
    user_id = resolve_user_id(current_user)
    res = add_task(
        user_id=user_id,
        module=req.module,
        title=req.title,
        priority=req.priority,
        due_date=req.due_date,
        status=req.status
    )
    if "error" in res:
        raise HTTPException(status_code=400, detail=res["error"])
    return res


@router.patch("/{task_id}")
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
    user_id = resolve_user_id(current_user)
    res = update_task_details(
        user_id=user_id,
        task_id=task_id,
        title=req.title,
        priority=req.priority,
        status=req.status,
        due_date=req.due_date
    )
    if "error" in res:
        raise HTTPException(status_code=404, detail=res["error"])
    return res

File: ./modules/web_api/routers/auth.py

"""
===============================================================================
FILE: modules/web_api/routers/auth.py
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
===============================================================================
"""

import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional

import jwt
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel

from core.connection import get_connection

JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()

router = APIRouter(prefix="/api/v1/auth", tags=["auth"])


def get_db():
    return get_connection(row_factory=True)


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


class AuthRequest(BaseModel):
    username: str
    password: str


class ChangePasswordRequest(BaseModel):
    old_password: str
    new_password: str


@router.post("/login")
def login(req: AuthRequest):
    username = req.username.strip().lower()
    conn = get_db()
    cursor = conn.cursor()
    cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
    user = cursor.fetchone()
    conn.close()

    if not user or not pwd_context.verify(req.password, user["password_hash"]):
        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)
    
    # Возвращаем full_name (если не задано — отдаем username)
    full_name = user["full_name"] if user["full_name"] else user["username"]
    
    return {
        "status": "success", 
        "token": token, 
        "username": user["username"], 
        "full_name": full_name,
        "user_id": user["id"],
        "is_admin": is_admin
    }


@router.post("/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()

    return {"status": "success", "message": "Пароль успешно изменен"}

File: ./modules/web_api/routers/admin.py

"""
===============================================================================
FILE: modules/web_api/routers/admin.py
ROLE: Администрирование пользователей и прав доступа.
===============================================================================
"""

# ANCHOR[ADMIN_ROUTER_IMPORTS]
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

from .auth import get_current_user, get_db, pwd_context

router = APIRouter(prefix="/api/v1/admin", tags=["admin"])

# ANCHOR[ADMIN_SCHEMAS]
class CreateUserRequest(BaseModel):
    username: str
    password: str
    full_name: Optional[str] = None
    is_admin: Optional[bool] = False

# ANCHOR[ADMIN_ENDPOINTS]
@router.get("/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

@router.post("/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} создан"}

@router.delete("/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": "Пользователь удален"}

File: ./modules/web_api/routers/files.py

"""
===============================================================================
FILE: modules/web_api/routers/files.py
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
      через изолированные UUID-директории инструментов.
===============================================================================
"""

import os
import time
import shutil
import urllib.parse
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse

router = APIRouter(prefix="/api/v1/files", tags=["Files"])

BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)

SESSION_TTL_HOURS = 24  # Срок жизни временных сессионных выгрузок


def purge_old_tool_sessions(tool_dir_path: str):
    """Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
    if not os.path.exists(tool_dir_path):
        return
    now = time.time()
    cutoff = now - (SESSION_TTL_HOURS * 3600)
    try:
        for entry in os.listdir(tool_dir_path):
            subpath = os.path.join(tool_dir_path, entry)
            if os.path.isdir(subpath):
                if os.path.getmtime(subpath) < cutoff:
                    shutil.rmtree(subpath, ignore_errors=True)
    except Exception:
        pass


@router.get("/download/{tool_name}/{session_uuid}/{filename}")
async def download_file(tool_name: str, session_uuid: str, filename: str):
    """
    Безопасная отдача файла с каноническим именем из изолированной директории.
    """
    safe_tool = os.path.basename(tool_name)
    safe_uuid = os.path.basename(session_uuid)
    safe_filename = os.path.basename(filename)

    file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)

    if not os.path.exists(file_path) or not os.path.isfile(file_path):
        raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")

    # Определение MIME-типа
    media_type = "application/octet-stream"
    if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
        media_type = "text/markdown; charset=utf-8"
    elif safe_filename.endswith(".xlsx"):
        media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    elif safe_filename.endswith(".pdf"):
        media_type = "application/pdf"

    # Корректная кодировка для кириллических имен файлов
    encoded_filename = urllib.parse.quote(safe_filename)

    return FileResponse(
        path=file_path,
        media_type=media_type,
        headers={
            "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
        }
    )

File: ./modules/web_api/routers/context.py

"""
===============================================================================
FILE: modules/web_api/routers/context.py
ROLE: REST API мониторинга состояния сессии и очистки памяти диалога.
===============================================================================
"""

from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional

from routers.auth import get_current_user
from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state
from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history

router = APIRouter(prefix="/api/v1/context", tags=["Context"])


class SessionActionRequest(BaseModel):
    session_id: Optional[str] = "web_session_main"


@router.get("/state")
def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)):
    state = db_get_session_state(session_id) or {}
    history = db_get_chat_history(session_id, limit=50)
    
    ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1)
    total_messages = len(history)

    return {
        "session_id": session_id,
        "active_state": state.get("state_type", "IDLE"),
        "state_data": state.get("data_json", {}),
        "total_messages": total_messages,
        "ephemeral_messages": ephemeral_count
    }


@router.post("/purge-ephemeral")
def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)):
    purged = db_purge_ephemeral_messages(req.session_id)
    return {"status": "success", "purged_count": purged}


@router.post("/clear-all")
def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)):
    db_clear_session_state(req.session_id)
    db_clear_chat_history(req.session_id)
    return {"status": "success", "message": "Контекст сессии полностью очищен"}

File: ./modules/web_api/llm/agent.py

"""
===============================================================================
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
      передача активных срезов в контекст модели и терминальные вызовы.
===============================================================================
"""

import sys
import json
import logging
from typing import List, Dict, Any, Tuple, Optional

from .db.connection import get_db_connection
from .db_tools import (
    db_get_active_system_prompt,
    db_apply_prompt_node_action,
    db_get_tool_action,
    db_get_tasks,
    db_update_task_details,
    db_delete_task,
    db_add_task,
    db_tasks_edit,
    db_export_tasks_markdown,
    db_get_rules,
    db_set_session_state,
    db_get_session_state,
    db_clear_session_state,
    db_get_snapshots,
    db_delete_snapshots,
    db_get_current_server_time,
    db_save_chat_message,
    db_get_chat_history,
    db_purge_ephemeral_messages,
    db_get_stats,
    db_get_anomalies,
    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
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
from .core.context_manager import (
    save_tool_interaction,
    save_dialog_interaction,
    mark_last_user_message_ephemeral,
    close_tool_session_and_cleanup
)

logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
logger.propagate = False

if not logger.handlers:
    handler = logging.StreamHandler(sys.stdout)
    formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s")
    handler.setFormatter(formatter)
    logger.addHandler(handler)


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]], Optional[Dict[str, Any]]]:
    """
    Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
    """
    logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
    
    session_state = db_get_session_state(session_id)
    if not session_state:
        db_purge_ephemeral_messages(session_id)

    full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message

    # 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
    fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
    if fast_path_res:
        return fast_path_res

    # 2. Фиксация сообщения пользователя
    db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
    db_history = db_get_chat_history(session_id, limit=20)

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

    current_state_type = session_state.get("state_type") if session_state else None
    state_data = session_state.get("data_json") or {} if session_state else {}
    if not isinstance(state_data, dict):
        state_data = {}
    idle_turns = state_data.get("idle_turns", 0)

    active_state_context = ""
    if current_state_type == "PROMPT_PREVIEW":
        active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n"
    elif current_state_type == "SNAPSHOTS_VIEW":
        active_date = state_data.get("query_date", "выбранную дату")
        active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
    elif current_state_type == "SNAPSHOT_INSPECT":
        # ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
        msg_l = user_message.lower().strip()
        scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
        if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
            db_clear_session_state(session_id)
            current_state_type = None
            active_state_context = ""
        else:
            snap_id = state_data.get("snapshot_id", "")
            snap_date = state_data.get("log_date", "")
            records = state_data.get("records", [])
            
            lines = []
            for r in records:
                st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
                lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
            
            dump_str = "\n".join(lines)
            active_state_context = (
                f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
                f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
                f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
                f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
                f"Список сотрудников в активном срезе:\n{dump_str}\n"
            )

    # ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
    system_prompt_content = (
        f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
        f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
        f"СТРОГИЕ ПРАВИЛА:\n"
        f"1. К оператору всегда обращайся на Вы.\n"
        f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n"
        f"   - Снапшоты и срезы СКУД -> db_get_snapshots\n"
        f"   - Удаление срезов -> db_delete_snapshots\n"
        f"   - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n"
        f"   - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
        f"   - База знаний и регламенты -> db_get_rules\n"
        f"   - Аномалии СКУД/1С -> db_get_anomalies\n"
        f"   - Справка -> db_get_reference\n"
        f"3. ЗАДАЧИ:\n"
        f"   - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — "
        f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
        f"   - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
        f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
        f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n"
        f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
        f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
        f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
        f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
        f"- Пользователь: {user_info}\n"
        f"- {calendar_context}\n"
        f"{active_state_context}"
    )

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

    try:
        if image_b64:
            user_msg_object["images"] = [image_b64]
            messages = [
                {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
                user_msg_object
            ]
            msg = call_ollama_chat(messages, is_vision=True)
        else:
            clean_db_history = [dict(m) for m in db_history]
            for m in clean_db_history:
                m.pop("images", None)
            messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
            msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)

        raw_text_reply = msg.get("content", "")
        tool_calls = msg.get("tool_calls", [])

        if not tool_calls:
            tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)

        if tool_calls:
            logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
            
            tool = tool_calls[0]
            fn_name = tool["function"]["name"]
            fn_args = tool["function"].get("arguments", {})
            if isinstance(fn_args, str):
                try:
                    fn_args = json.loads(fn_args)
                except Exception:
                    fn_args = {}

            logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")

            # Ротация контекста
            close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
            session_state = None
            mark_last_user_message_ephemeral(session_id)

            # 1. Задачи (Просмотр)
            if fn_name == "db_get_tasks":
                raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
                reply_text = "Вот интерактивный список ваших текущих задач:"
                db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                return reply_text, db_get_chat_history(session_id), {
                    "type": "TASK_INTERACTIVE_CARD",
                    "tasks": raw_tasks
                }

            # 2. Единый диспетчер задач
            elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
                action = fn_args.get("action", "UPDATE").upper()
                if fn_name == "db_add_task": action = "ADD"
                elif fn_name == "db_delete_task": action = "DELETE"
                elif fn_name == "db_update_task_status": action = "UPDATE"

                if action == "DELETE":
                    task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
                    db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
                    reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
                    db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                    return reply_text, db_get_chat_history(session_id), {
                        "type": "TASK_DELETE_CONFIRM",
                        "buttons": [
                            {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
                            {"label": "Отмена", "value": "отмена", "style": "secondary"}
                        ]
                    }

                elif action == "EXPORT":
                    export_res = db_export_tasks_markdown(
                        user_id=user_id, 
                        filename=fn_args.get("filename") or "ROADMAP.md",
                        status_filter=fn_args.get("status")
                    )
                    reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
                    db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
                    
                    action_payload = None
                    if export_res.get("status") == "success":
                        action_payload = {
                            "type": "FILE_DOWNLOAD_CARD",
                            "filename": export_res.get("filename"),
                            "download_url": export_res.get("download_url"),
                            "tasks_count": export_res.get("tasks_count")
                        }
                    return reply_text, db_get_chat_history(session_id), action_payload

                else:
                    res = db_tasks_edit(
                        user_id=user_id,
                        action=action,
                        task_id=fn_args.get("task_id"),
                        title=fn_args.get("title"),
                        priority=fn_args.get("priority", "MEDIUM"),
                        status=fn_args.get("status"),
                        module=fn_args.get("module", "general"),
                        due_date=fn_args.get("due_date")
                    )
                    raw_tasks = db_get_tasks(user_id)
                    reply_text = res.get("message", "Операция над задачами выполнена.")
                    db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                    return reply_text, db_get_chat_history(session_id), {
                        "type": "TASK_INTERACTIVE_CARD",
                        "tasks": raw_tasks
                    }

            # 3. Системный промпт
            elif fn_name == "db_get_system_prompt":
                active_prompt = db_get_active_system_prompt()
                reply_text = (
                    "📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n"
                    f"```text\n{active_prompt}\n```\n\n"
                    "Вы можете добавить, отредактировать или удалить любой пункт."
                )
                db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
                db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                return reply_text, db_get_chat_history(session_id), {
                    "type": "PROMPT_VIEW",
                    "buttons": [
                        {"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
                        {"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
                    ]
                }

            # 4. База знаний и правила компании (терминальный возврат)
            elif fn_name == "db_get_rules":
                rules_data = db_get_rules()
                if isinstance(rules_data, dict) and "rules" in rules_data:
                    rules_list = rules_data["rules"]
                elif isinstance(rules_data, list):
                    rules_list = rules_data
                else:
                    rules_list = [str(rules_data)]

                formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
                reply_text = (
                    "📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n"
                    f"```text\n{formatted_rules}\n```"
                )
                db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                return reply_text, db_get_chat_history(session_id), {
                    "type": "RULES_VIEW",
                    "buttons": [
                        {"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"},
                        {"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
                    ]
                }

            # 5. Снапшоты СКУД
            elif fn_name == "db_get_snapshots":
                snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
                query_date = snapshots_res.get("query_date", "выбранную дату")
                reply_text = f"Реестр срезов СКУД за {query_date}:"
                db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                return reply_text, db_get_chat_history(session_id), {
                    "type": "SNAPSHOTS_CARD",
                    "data": snapshots_res
                }

            elif fn_name == "db_delete_snapshots":
                raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
                raw_ids = fn_args.get("snapshot_ids") or []
                is_confirmed = fn_args.get("confirmed", False)
                
                if raw_id and not raw_ids:
                    if isinstance(raw_id, str) and "," in raw_id:
                        raw_ids = [s.strip() for s in raw_id.split(",")]
                    else:
                        raw_ids = [raw_id]
                
                safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
                if not safe_ids:
                    reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
                    db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                    return reply_text, db_get_chat_history(session_id), None

                if not is_confirmed:
                    query_date = state_data.get("query_date", "")
                    db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
                        "snapshot_ids": safe_ids,
                        "query_date": query_date,
                        "idle_turns": 0
                    })
                    ids_str = ", ".join(safe_ids)
                    reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
                    db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                    return reply_text, db_get_chat_history(session_id), {
                        "type": "SNAPSHOT_DELETE_CONFIRM",
                        "buttons": [
                            {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
                            {"label": "Отмена", "value": "отмена", "style": "secondary"}
                        ]
                    }
                else:
                    db_delete_snapshots(snapshot_ids=safe_ids)
                    query_date = state_data.get("query_date", "")
                    updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)

                    reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
                    db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
                    return reply_text, db_get_chat_history(session_id), {
                        "type": "SNAPSHOTS_CARD",
                        "data": updated_snapshots_res
                    }

            # 6. Прочие сервисные инструменты
            elif fn_name == "db_get_current_server_time":
                tool_result_content = json.dumps(db_get_current_server_time(), 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":
                tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
            elif fn_name == "db_get_reference":
                tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
            else:
                tool_result_content = "{}"

            messages.append(msg)
            messages.append({"role": "tool", "content": tool_result_content})
            sec_msg = call_ollama_chat(messages, is_vision=False)
            raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
            final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."

            db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
            return final_content, db_get_chat_history(session_id), None

        # Свободный диалог (Topic Drift)
        raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
        final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."

        db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
        return final_reply, db_get_chat_history(session_id), None

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

File: ./modules/web_api/llm/schemas.py

"""
===============================================================================
FILE: modules/web_api/llm/schemas.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.

AI-CONTEXT-ANCHORS:
  - ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
  - ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
  - ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
===============================================================================
"""

# ANCHOR[SCHEMA_PROMPT_EDIT]
TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "db_prompt_node_edit",
            "description": (
                "Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
                "Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
                "При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
                        "description": "Тип действия"
                    },
                    "section_id": {
                        "type": "integer",
                        "description": "Номер раздела (например 1)"
                    },
                    "item_id": {
                        "type": "integer",
                        "description": "Номер пункта (например 8)"
                    },
                    "nodes_list": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
                    },
                    "content": {
                        "type": "string",
                        "description": "Текст пункта"
                    }
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_system_prompt",
            "description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
            "parameters": {
                "type": "object",
                "properties": {}
            }
        }
    },
    # ANCHOR[SCHEMA_TASKS_EDIT]
    {
        "type": "function",
        "function": {
            "name": "db_get_tasks",
            "description": (
                "Просмотр реестра задач и бэклога текущего пользователя.\n"
                "Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
                "Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "status": {
                        "type": "string",
                        "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
                        "description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_tasks_edit",
            "description": (
                "Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
                "СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
                "- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
                "- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
                "- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
                "- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
                        "description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
                    },
                    "task_id": {
                        "type": "string",
                        "description": "Номер задачи (например: '37')"
                    },
                    "title": {
                        "type": "string",
                        "description": "Описание или текст задачи"
                    },
                    "status": {
                        "type": "string",
                        "enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
                        "description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
                    },
                    "priority": {
                        "type": "string",
                        "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
                        "description": "Приоритет задачи"
                    },
                    "module": {
                        "type": "string",
                        "description": "Модуль проекта"
                    },
                    "due_date": {
                        "type": "string",
                        "description": "Срок в формате ГГГГ-ММ-ДД"
                    },
                    "filename": {
                        "type": "string",
                        "description": "Имя файла для экспорта"
                    }
                },
                "required": ["action"]
            }
        }
    },
    # ANCHOR[SCHEMA_SNAPSHOTS]
    {
        "type": "function",
        "function": {
            "name": "db_get_snapshots",
            "description": (
                "Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
                "ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
                "КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "date_str": {
                        "type": "string",
                        "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_delete_snapshots",
            "description": "Удаление снапшотов СКУД по идентификатору или дате.",
            "parameters": {
                "type": "object",
                "properties": {
                    "snapshot_id": {
                        "type": "string",
                        "description": "Идентификатор конкретного снапшота"
                    },
                    "day_str": {
                        "type": "string",
                        "description": "Дата всех снапшотов за день"
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_anomalies",
            "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
            "parameters": {
                "type": "object",
                "properties": {
                    "date_str": {
                        "type": "string",
                        "description": "Опциональная дата в формате ДД.ММ.ГГГГ"
                    },
                    "limit": {
                        "type": "integer",
                        "description": "Лимит записей"
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_rules",
            "description": "Просмотр базы знаний и правил кадрового арбитража компании.",
            "parameters": {
                "type": "object",
                "properties": {}
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_stats",
            "description": "Получение статистики количества записей в таблицах базы данных.",
            "parameters": {
                "type": "object",
                "properties": {}
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_current_server_time",
            "description": "Получение текущего точного времени сервера.",
            "parameters": {
                "type": "object",
                "properties": {}
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "db_get_reference",
            "description": "Справка о возможностях ассистента и примеры доступных команд.",
            "parameters": {
                "type": "object",
                "properties": {
                    "category": {
                        "type": "string",
                        "description": "Категория справки"
                    }
                }
            }
        }
    }
]

File: ./modules/web_api/llm/db_tools.py

"""
===============================================================================
FILE: modules/web_api/llm/db_tools.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).

AI-CONTEXT-ANCHORS:
  - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
===============================================================================
"""

# ANCHOR[FACADE_EXPORTS]
from typing import Optional, List, Dict, Any
from core.connection import DB_PATH, get_connection as get_db_connection

# Домен: Задачи
from services.tasks.service import (
    get_tasks as db_get_tasks,
    add_task as db_add_task,
    update_task_details as db_update_task_details,
    delete_task as db_delete_task,
    execute_task_action as db_tasks_edit
)
# Защитный алиас для обратной совместимости
db_update_task_status = db_update_task_details

from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id

# Домен: Системный промпт
from services.prompts.service import (
    get_active_system_prompt as db_get_active_system_prompt,
    apply_prompt_action as db_apply_prompt_node_action,
    save_full_prompt_draft,
    create_prompt_preview
)

# Домен: База знаний
from services.knowledge.service import (
    get_rules as db_get_rules,
    add_rule as db_add_rule
)

# Чат, сессии, статистика
from .db.db_chat import (
    db_save_chat_message,
    db_get_chat_history,
    db_purge_ephemeral_messages,
    db_clear_chat_history
)
from .db.db_prompts import (
    db_get_tool_action,
    db_set_session_state,
    db_get_session_state,
    db_clear_session_state,
    db_get_stats,
    db_get_anomalies,
    db_get_reference
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time


def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
    """Совместимая обертка для сохранения системного промпта."""
    if draft_text is not None:
        save_full_prompt_draft(draft_text, prompt_name=name_or_text)
    else:
        save_full_prompt_draft(name_or_text, prompt_name="main_agent")


def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
    """Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
    from services.snapshots.service import get_snapshots_registry
    from .core.calendar_utils import parse_relative_date_ru

    clean_date = (date_str or "").strip()
    if not clean_date and original_user_message:
        clean_date = parse_relative_date_ru(original_user_message)

    res = get_snapshots_registry(date_str=clean_date if clean_date else None)
    db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
    return res


def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
    """Совместимый фасад безопасного удаления снапшотов."""
    from services.snapshots.service import delete_snapshots_safely
    
    target_ids = []
    if snapshot_ids:
        target_ids.extend(snapshot_ids)
    if snapshot_id:
        if isinstance(snapshot_id, str) and "," in snapshot_id:
            target_ids.extend([s.strip() for s in snapshot_id.split(",")])
        else:
            target_ids.append(snapshot_id)

    return delete_snapshots_safely(snapshot_ids=target_ids)

File: ./modules/web_api/llm/core/fast_path.py

"""
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
===============================================================================
"""

import difflib
import logging
from typing import Dict, Any, Tuple, Optional

from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
from services.knowledge.service import get_rules, add_rule
from services.tasks.service import get_tasks, delete_task, execute_task_action
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely

from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup

logger = logging.getLogger("FAST_PATH")


def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str:
    base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()]
    draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()]

    matcher = difflib.SequenceMatcher(None, base_lines, draft_lines)
    diff_html_lines = []

    for tag, i1, i2, j1, j2 in matcher.get_opcodes():
        if tag == 'equal':
            for line in base_lines[i1:i2]:
                diff_html_lines.append(line)
        elif tag == 'delete':
            for line in base_lines[i1:i2]:
                diff_html_lines.append(
                    f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
                )
        elif tag == 'insert':
            for line in draft_lines[j1:j2]:
                diff_html_lines.append(
                    f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
                )
        elif tag == 'replace':
            for line in base_lines[i1:i2]:
                diff_html_lines.append(
                    f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
                )
            for line in draft_lines[j1:j2]:
                diff_html_lines.append(
                    f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
                )

    return "\n".join(diff_html_lines)


def handle_fast_path_intercept(
    session_id: str, 
    user_message: str, 
    full_user_content: str, 
    session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
    msg_raw = user_message.strip()
    msg_lower = msg_raw.lower()

    # -------------------------------------------------------------------------
    # 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
    # -------------------------------------------------------------------------
    if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
        filename = "ROADMAP.md"
        if " в " in msg_lower:
            parts = msg_raw.split(" в ", 1)[1].strip().split()
            if parts and ("." in parts[0] or parts[0].endswith("md")):
                filename = parts[0]

        res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
        reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
        db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)

        action_payload = {
            "type": "FILE_DOWNLOAD_CARD",
            "filename": res.get("filename", filename),
            "download_url": res.get("download_url", "#"),
            "tasks_count": res.get("tasks_count", 0)
        }
        return reply, db_get_chat_history(session_id), action_payload

    # -------------------------------------------------------------------------
    # 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
    # -------------------------------------------------------------------------
    if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
        target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
        from core.database import load_scud_from_db_by_snapshot
        
        df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
        if df_snap is None or df_snap.empty:
            reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
            db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
            return reply, db_get_chat_history(session_id), None

        total_cnt = len(df_snap)
        present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
        absent_cnt = total_cnt - present_cnt

        snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
        log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'

        rows_list = []
        for _, r in df_snap.iterrows():
            rows_list.append({
                "fio": r.get('Сотрудник', r.get('fio', '')),
                "department": r.get('Подразделение', r.get('department_scud', '—')),
                "time_in": r.get('Начало_дня', 'Нет входа'),
                "first_activity": r.get('Первая_активность', '—'),
                "time_out": r.get('Конец_дня', 'Нет выхода'),
                "in_building": r.get('Находился_в_здании', '00:00'),
                "is_present": bool(r.get('Пришел', False)),
                "anomaly": r.get('anomaly_flag', 'NONE')
            })

        # Фиксация среза в памяти сессии для последующих вопросов к LLM
        db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
            "snapshot_id": target_snap_id,
            "log_date": log_date,
            "snapshot_time": snap_time,
            "records": rows_list,
            "idle_turns": 0
        })

        reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
        db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)

        return reply, db_get_chat_history(session_id), {
            "type": "SNAPSHOT_INSPECT_CARD",
            "snapshot_id": target_snap_id,
            "log_date": log_date,
            "snapshot_time": snap_time,
            "total_count": total_cnt,
            "present_count": present_cnt,
            "absent_count": absent_cnt,
            "records": rows_list
        }

    # -------------------------------------------------------------------------
    # 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
    # -------------------------------------------------------------------------
    if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
        active_prompt = get_active_system_prompt()
        state_data = session_state.get("data_json") if session_state else {}
        draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt

        db_set_session_state(session_id, "PROMPT_PREVIEW", {
            "draft_text": draft_text,
            "action": "MANUAL_EDIT",
            "idle_turns": 0
        })
        reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
        db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
        return reply, db_get_chat_history(session_id), {
            "type": "PROMPT_EDITOR",
            "raw_draft": draft_text,
            "baseline_prompt": active_prompt
        }

    # -------------------------------------------------------------------------
    # 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
    # -------------------------------------------------------------------------
    if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
        rules_data = get_rules()
        rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
        raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])

        db_set_session_state(session_id, "RULES_PREVIEW", {
            "draft_text": raw_rules_text,
            "action": "MANUAL_EDIT_RULES",
            "idle_turns": 0
        })
        reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
        db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
        return reply, db_get_chat_history(session_id), {
            "type": "RULES_EDITOR",
            "raw_draft": raw_rules_text,
            "baseline_prompt": raw_rules_text
        }

    # -------------------------------------------------------------------------
    # 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
    # -------------------------------------------------------------------------
    if msg_raw.startswith("action:save_draft_prompt:::"):
        new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
        active_prompt = get_active_system_prompt()
        diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)

        db_set_session_state(session_id, "PROMPT_PREVIEW", {
            "draft_text": new_draft_content,
            "action": "MANUAL_EDIT",
            "idle_turns": 0
        })

        preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
        db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
        return preview_reply, db_get_chat_history(session_id), {
            "type": "PROMPT_PREVIEW",
            "raw_draft": new_draft_content,
            "baseline_prompt": active_prompt,
            "diff_html": diff_html,
            "buttons": [
                {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
                {"label": "Отменить", "value": "отмена", "style": "danger"},
                {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
            ]
        }

    # -------------------------------------------------------------------------
    # 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
    # -------------------------------------------------------------------------
    if msg_raw.startswith("action:save_draft_rules:::"):
        new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
        rules_data = get_rules()
        rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
        baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
        diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)

        db_set_session_state(session_id, "RULES_PREVIEW", {
            "draft_text": new_draft_content,
            "action": "MANUAL_EDIT_RULES",
            "idle_turns": 0
        })

        preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие."
        db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
        return preview_reply, db_get_chat_history(session_id), {
            "type": "PROMPT_PREVIEW",
            "raw_draft": new_draft_content,
            "baseline_prompt": baseline_rules,
            "diff_html": diff_html,
            "buttons": [
                {"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
                {"label": "Отменить", "value": "отмена", "style": "danger"},
                {"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
            ]
        }

    if not session_state:
        return None

    state_type = session_state.get("state_type")
    state_data = session_state.get("data_json") or {}
    if not isinstance(state_data, dict):
        state_data = {}

    # -------------------------------------------------------------------------
    # 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
    # -------------------------------------------------------------------------
    if state_type == "PROMPT_PREVIEW":
        is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
        is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]

        if is_confirm:
            draft_text = state_data.get("draft_text", "")
            if draft_text:
                save_full_prompt_draft(draft_text, prompt_name="main_agent")

            close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
            db_clear_session_state(session_id)

            reply = "✅ Системный промпт успешно сохранен и применен в базе данных."
            db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
            db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
            return reply, db_get_chat_history(session_id), None

        elif is_cancel:
            close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
            db_clear_session_state(session_id)

            reply = "❌ Изменения системного промпта отменены."
            db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
            db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
            return reply, db_get_chat_history(session_id), None

    # -------------------------------------------------------------------------
    # 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
    # -------------------------------------------------------------------------
    elif state_type == "RULES_PREVIEW":
        is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
        is_cancel = msg_lower in ["отмена", "отменить", "нет"]

        if is_confirm:
            draft_text = state_data.get("draft_text", "")
            lines = [l.strip() for l in draft_text.splitlines() if l.strip()]
            for line in lines:
                clean_line = line
                if line[0].isdigit() and "." in line[:5]:
                    clean_line = line.split(".", 1)[1].strip()
                add_rule(clean_line)

            close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
            db_clear_session_state(session_id)

            reply = "✅ База знаний и правила компании успешно сохранены в базе данных."
            db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
            db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
            return reply, db_get_chat_history(session_id), None

        elif is_cancel:
            close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED")
            db_clear_session_state(session_id)

            reply = "❌ Изменение правил компании отменено."
            db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
            db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
            return reply, db_get_chat_history(session_id), None

    return None

File: ./modules/web_api/llm/core/context_manager.py

"""
===============================================================================
FILE: modules/web_api/llm/core/context_manager.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
      инструментов и содержательного диалога с сохранением Topic Drift.
===============================================================================
"""

import logging
from typing import Dict, Any, Optional, List

from ..db.connection import get_db_connection
from ..db_tools import (
    db_save_chat_message,
    db_get_chat_history,
    db_purge_ephemeral_messages,
    db_clear_session_state,
    db_set_session_state
)

logger = logging.getLogger("CONTEXT_MANAGER")


def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
    """
    Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
    При очистке удалятся оба сообщения, не оставляя сирот.
    """
    db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
    db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)


def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
    """
    Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
    Эти сообщения остаются в истории навсегда (включая Topic Drift).
    """
    db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
    db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)


def mark_last_user_message_ephemeral(session_id: str) -> None:
    """Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
    with get_db_connection() as conn:
        cursor = conn.cursor()
        cursor.execute("""
            UPDATE chat_messages 
            SET is_ephemeral = 1 
            WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
        """, (session_id,))
        conn.commit()


def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
    """
    Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
    Весь содержательный диалог сохраняется.
    """
    db_clear_session_state(session_id)
    deleted_count = db_purge_ephemeral_messages(session_id)
    logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
    return deleted_count

File: ./modules/web_api/llm/core/ollama_client.py

"""
===============================================================================
FILE: modules/web_api/llm/core/ollama_client.py
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
===============================================================================
"""

# ANCHOR[OLLAMA_CLIENT_IMPORTS]
import json
import urllib.request
import urllib.error
import logging
from typing import Dict, Any, Optional

logger = logging.getLogger("OLLAMA_CLIENT")

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

LLM_OPTIONS = {
    "num_predict": 8192,
    "num_ctx": 8192,
    "temperature": 0.0,
    "repeat_penalty": 1.0,
    "presence_penalty": 0.0,
    "top_p": 0.9
}


# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
def call_ollama_chat(
    messages: list, 
    tools: Optional[list] = None, 
    is_vision: bool = False,
    timeout: int = 120
) -> Dict[str, Any]:
    """
    Отправляет подготовленный массив сообщений в API Ollama.
    Возвращает разобранный словарь сообщения ответа или генерирует исключение.
    """
    model_name = VISION_MODEL if is_vision else TEXT_MODEL
    payload = {
        "model": model_name,
        "messages": messages,
        "stream": False,
        "options": LLM_OPTIONS
    }
    
    if tools and not is_vision:
        payload["tools"] = tools

    req = urllib.request.Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"}
    )

    with urllib.request.urlopen(req, timeout=timeout) as response:
        res_data = json.loads(response.read().decode("utf-8"))
        return res_data.get("message", {})

File: ./modules/web_api/llm/core/tool_injector.py

"""
===============================================================================
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
===============================================================================
"""

import re
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 ""
    cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
    cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
    return cleaned.strip()


def clean_output(text: str) -> str:
    """Очищает маркеры форматирования."""
    return text.strip() if text else ""


def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """
    Модель управляется через TOOLS_SCHEMA и системный контекст.
    Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
    """
    return existing_tool_calls

File: ./modules/web_api/static/index.html

<!DOCTYPE html>
<html lang="ru" class="h-full bg-slate-100">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SCUD Orion AI Assistant</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="icon" href="/favicon.ico" type="image/x-icon">
    <style>
     /* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
    * {
        overflow-anchor: none !important;
    }

    #chat-messages-container, main {
        overflow-anchor: none !important;
    }   

    /* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
    #chat-messages-container {
        padding-bottom: clamp(400px, 85vh, 900px) !important;
    }

    /* Принудительное увеличение шрифта сообщений чата */
    #chat-messages-container .message-content,
    #chat-messages-container .text-xs,
    #chat-messages-container .text-sm {
        font-size: 14.5px !important;
        line-height: 1.6 !important;
    }
    #chat-messages-container pre, 
    #chat-messages-container code {
        font-size: 13.5px !important;
    }
    /* Смещение для точной прокрутки под фиксированный заголовок */
    .user-chat-bubble {
        scroll-margin-top: 24px !important;
    }
</style>
</head>
<body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">

    <div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">

        <!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР 5-ХАБОВ) -->
        <aside class="w-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
            
            <!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
            <div id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>

            <!-- ДИНАМИЧЕСКИЙ КОНТЕНТНЫЙ СЛОТ -->
            <div id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
                <div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
                    <div class="text-center py-10 text-xs text-slate-400">
                        <i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
                    </div>
                </div>
            </div>

            <!-- ПОДВАЛ САЙДБАРА: ПРОФИЛЬ, НАСТРОЙКИ, АДМИНКА И ВЫХОД -->
            <div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
                <div class="flex items-center gap-2.5 min-w-0">
                    <div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
                        <i class="fa-solid fa-user"></i>
                    </div>
                    <div class="min-w-0 flex flex-col">
                        <span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
                        <span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
                    </div>
                </div>
                
                <div class="flex items-center gap-1">
                    <button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
                        <i class="fa-solid fa-users-gear text-sm"></i>
                    </button>

                    <button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
                        <i class="fa-solid fa-gear text-sm"></i>
                    </button>

                    <button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
                        <i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
                    </button>
                </div>
            </div>
        </aside>

        <!-- ПРАВАЯ ОБЛАСТЬ (ЧАТ И ИИ-АССИСТЕНТ) -->
        <main class="flex-1 flex flex-col h-full min-w-0 bg-slate-50 relative">
            <header class="h-14 bg-white border-b border-slate-200 px-4 flex items-center justify-between shrink-0 shadow-sm z-10">
                <div class="flex items-center gap-2.5">
                    <div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shadow-sm">
                        <i class="fa-solid fa-robot text-xs"></i>
                    </div>
                    <div>
                        <div class="flex items-center gap-2">
                            <h1 class="text-sm font-bold text-slate-800">SCUD Orion AI Assistant</h1>
                            <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">Online</span>
                        </div>
                        <p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
                    </div>
                </div>
            </header>

            <!-- ЛЕНТА ЧАТА -->
            <div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
                <div class="flex gap-3 max-w-4xl mx-auto w-full">
                    <div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
                        <i class="fa-solid fa-robot text-xs"></i>
                    </div>
                    <div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
                        <div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
                        <div class="text-xs text-slate-700 leading-relaxed">
                            Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
                        </div>
                    </div>
                </div>
            </div>

            <!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
            <div id="file-attachment-preview" class="hidden max-w-4xl mx-auto w-full px-4 pt-2">
                <div class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-700 shadow-sm">
                    <i class="fa-solid fa-file text-indigo-600"></i>
                    <span id="file-attachment-name" class="font-medium truncate max-w-xs"></span>
                    <button type="button" onclick="clearAttachedFile()" class="text-indigo-400 hover:text-rose-600 ml-1">
                        <i class="fa-solid fa-xmark"></i>
                    </button>
                </div>
            </div>

            <!-- Контейнер строки ввода сообщения -->
            <div class="px-4 py-2 bg-white border-t border-slate-200">
                <div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
                    <!-- Скрепка файлов -->
                    <button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0">
                        <i class="fa-solid fa-paperclip text-xs"></i>
                    </button>
                    <input type="file" id="file-upload-input" class="hidden" />

                    <!-- Поле ввода -->
                    <textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..." 
                            class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>

                    <!-- Кнопка отправки -->
                    <button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
                        <i class="fa-solid fa-paper-plane text-[10px]"></i>
                    </button>
                </div>
            </div>
        </main>
    </div>

    <!-- МОДАЛЬНОЕ ОКНО: УДАЛЕННЫЙ СОТРУДНИК -->
    <div id="remote-worker-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
        <div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
            <div class="flex items-center justify-between">
                <h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
                    <i class="fa-solid fa-house-laptop text-emerald-600"></i>
                    <span>Параметры удаленной работы</span>
                </h3>
                <button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
                    <i class="fa-solid fa-xmark"></i>
                </button>
            </div>

            <form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
                <input type="hidden" id="rw-mode" value="ADD" />

                <div>
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
                    <input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
                           class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
                </div>

                <div>
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
                    <input type="text" id="rw-dept" placeholder="Все"
                           class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
                </div>

                <div class="grid grid-cols-2 gap-3">
                    <div>
                        <label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
                        <input type="date" id="rw-date-from"
                               class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
                        <span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = с сегодняшнего дня</span>
                    </div>
                    <div>
                        <label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
                        <input type="date" id="rw-date-to"
                               class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
                        <span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
                    </div>
                </div>

                <div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>

                <div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
                    <button type="button" onclick="closeRemoteWorkerModal()" 
                            class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
                        Отмена
                    </button>
                    <button type="submit" id="rw-submit-btn" 
                            class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
                        Сохранить
                    </button>
                </div>
            </form>
        </div>
    </div>

    <!-- МОДАЛЬНОЕ ОКНО: МЕСТНАЯ КОМАНДИРОВКА И ИНОЕ С АВТОКОМПЛИТОМ -->
    <div id="manual-absence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
        <div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
            <div class="flex items-center justify-between">
                <h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
                    <i class="fa-solid fa-location-dot text-indigo-600"></i>
                    <span>Добавление в реестр</span>
                </h3>
                <button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
                    <i class="fa-solid fa-xmark"></i>
                </button>
            </div>

            <div class="flex flex-col gap-3">
                <!-- Поле ФИО с автодополнением по 1С -->
                <div class="relative">
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
                    <input type="text" id="manual-absence-fio-input" autocomplete="off" placeholder="Начните вводить фамилию..."
                           class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" />
                    <div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
                </div>

                <input type="hidden" id="manual-absence-dept" />
                <input type="hidden" id="manual-absence-pos" />

                <!-- Выпадающий список причин (только для "Иное") -->
                <div id="manual-absence-reason-block" class="hidden">
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
                    <select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50"></select>
                </div>

                <div class="grid grid-cols-2 gap-3">
                    <div>
                        <label class="block text-[11px] font-bold text-slate-600 mb-1">Начало:</label>
                        <input type="date" id="manual-absence-start-date"
                               class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
                        <span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
                    </div>
                    <div>
                        <label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
                        <input type="date" id="manual-absence-end-date"
                               class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
                        <span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
                    </div>
                </div>

                <div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
                    <button type="button" onclick="closeManualAbsenceModal()" 
                            class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
                        Отмена
                    </button>
                    <button type="button" onclick="submitManualAbsence()" 
                            class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
                        Сохранить
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
    <div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
        <div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
            <div class="flex items-center gap-3">
                <div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
                    <i class="fa-solid fa-shield-halved"></i>
                </div>
                <div>
                    <h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
                    <p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
                </div>
            </div>

            <form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
                <div>
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
                    <input type="text" id="auth-username" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="Логин" />
                </div>
                <div>
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
                    <input type="password" id="auth-password" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="••••••••" />
                </div>
                <div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
                <button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
                    Войти в систему
                </button>
            </form>
        </div>
    </div>

    <!-- МОДАЛЬНОЕ ОКНО СМЕНЫ ПАРОЛЯ -->
    <div id="profile-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
        <div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
            <div class="flex items-center justify-between">
                <h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
                    <i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
                </h3>
                <button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
            </div>
            <form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
                <div>
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
                    <input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
                </div>
                <div>
                    <label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
                    <input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
                </div>
                <div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
                <div class="flex items-center justify-end gap-2 mt-2">
                    <button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
                    <button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
                </div>
            </form>
        </div>
    </div>

    <!-- МОДАЛЬНОЕ ОКНО ПАНЕЛИ АДМИНИСТРАТОРА -->
    <div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
        <div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
            <div class="flex items-center justify-between">
                <h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
                    <i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
                </h3>
                <button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
            </div>

            <form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
                <span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
                <div class="grid grid-cols-2 gap-2">
                    <input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
                    <input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
                </div>
                <input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
                <div class="flex items-center justify-between">
                    <label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
                        <input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
                        <span>Права администратора</span>
                    </label>
                    <button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
                        Создать
                    </button>
                </div>
            </form>

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

    <!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ -->
    <script src="/static/js/auth.js?v=2.5.7"></script>
    <script src="/static/js/tasks.js?v=2.5.7"></script>
    <script src="/static/js/manual_absences.js?v=2.5.7"></script>
    <script src="/static/js/sidebar.js?v=2.5.7"></script>
    <script src="/static/js/chat/task_widget.js?v=2.5.7"></script>
    <script src="/static/js/chat/core.js?v=2.5.7"></script>
    <script src="/static/js/app.js?v=2.5.7"></script>

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

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

        function updateUIState() {
            const nameEl = document.getElementById("user-display-name");
            const roleEl = document.getElementById("user-display-role");
            const adminBtn = document.getElementById("admin-panel-btn");

            if (nameEl) nameEl.innerText = AuthManager.getFullName();
            if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";

            if (adminBtn) {
                if (AuthManager.isAdmin()) {
                    adminBtn.classList.remove("hidden");
                } else {
                    adminBtn.classList.add("hidden");
                }
            }
        }

        async function handleLoginSubmit(e) {
            e.preventDefault();
            const uInput = document.getElementById("auth-username");
            const pInput = document.getElementById("auth-password");
            const errEl = document.getElementById("auth-error");
            errEl.classList.add("hidden");

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

                if (res.ok) {
                    const data = await res.json();
                    AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
                    hideAuthModal();
                    updateUIState();
                    if (window.SidebarManager) SidebarManager.setHub('TASKS');
                } else {
                    const err = await res.json();
                    errEl.innerText = err.detail || "Неверный логин или пароль";
                    errEl.classList.remove("hidden");
                }
            } catch (err) {
                errEl.innerText = "Ошибка соединения с сервером";
                errEl.classList.remove("hidden");
            }
        }

        function openProfileModal() {
            document.getElementById("profile-modal").classList.remove("hidden");
        }
        function closeProfileModal() {
            document.getElementById("profile-modal").classList.add("hidden");
        }
        async function handleChangePassword(e) {
            e.preventDefault();
            const oldP = document.getElementById("old-pass").value;
            const newP = document.getElementById("new-pass").value;
            const errEl = document.getElementById("pass-error");
            errEl.classList.add("hidden");

            try {
                const res = await fetch("/api/v1/auth/change-password", {
                    method: "POST",
                    headers: AuthManager.getAuthHeaders(),
                    body: JSON.stringify({ old_password: oldP, new_password: newP })
                });
                if (res.ok) {
                    alert("Пароль успешно изменен");
                    closeProfileModal();
                } else {
                    const err = await res.json();
                    errEl.innerText = err.detail || "Ошибка изменения пароля";
                    errEl.classList.remove("hidden");
                }
            } catch (e) {
                errEl.innerText = "Ошибка сети";
                errEl.classList.remove("hidden");
            }
        }

        function openAdminModal() {
            document.getElementById("admin-modal").classList.remove("hidden");
            loadAdminUsers();
        }
        function closeAdminModal() {
            document.getElementById("admin-modal").classList.add("hidden");
        }
        async function loadAdminUsers() {
            const listEl = document.getElementById("admin-users-list");
            listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
            try {
                const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
                if (res.ok) {
                    const users = await res.json();
                    listEl.innerHTML = users.map(u => `
                        <div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
                            <div>
                                <div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
                                <div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
                            </div>
                            <button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
                                <i class="fa-solid fa-trash-can"></i>
                            </button>
                        </div>
                    `).join('');
                } else {
                    listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
                }
            } catch (e) {
                listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
            }
        }

        async function handleCreateUser(e) {
            e.preventDefault();
            const u = document.getElementById("new-user-username").value;
            const p = document.getElementById("new-user-password").value;
            const f = document.getElementById("new-user-fullname").value;
            const a = document.getElementById("new-user-admin").checked;

            try {
                const res = await fetch("/api/v1/admin/users", {
                    method: "POST",
                    headers: AuthManager.getAuthHeaders(),
                    body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
                });
                if (res.ok) {
                    document.getElementById("new-user-username").value = "";
                    document.getElementById("new-user-password").value = "";
                    document.getElementById("new-user-fullname").value = "";
                    document.getElementById("new-user-admin").checked = false;
                    loadAdminUsers();
                } else {
                    const err = await res.json();
                    alert(err.detail || "Ошибка создания пользователя");
                }
            } catch (e) {
                alert("Ошибка сети");
            }
        }

        async function deleteAdminUser(id, username) {
            if (!confirm(`Удалить пользователя ${username}?`)) return;
            try {
                const res = await fetch(`/api/v1/admin/users/${id}`, {
                    method: "DELETE",
                    headers: AuthManager.getAuthHeaders()
                });
                if (res.ok) {
                    loadAdminUsers();
                } else {
                    const err = await res.json();
                    alert(err.detail || "Ошибка удаления");
                }
            } catch (e) {
                alert("Ошибка сети");
            }
        }

        function dmyToYmd(str) {
            if (!str) return "";
            const parts = str.replace(/_/g, '.').split('.');
            if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
            return "";
        }

        function ymdToDmy(str) {
            if (!str) return "";
            const parts = str.split('-');
            if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
            return "";
        }

        function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
            const modal = document.getElementById("remote-worker-modal");
            const titleEl = document.getElementById("remote-modal-title");
            const modeInput = document.getElementById("rw-mode");
            const fioInput = document.getElementById("rw-fio");
            const deptInput = document.getElementById("rw-dept");
            const fromInput = document.getElementById("rw-date-from");
            const toInput = document.getElementById("rw-date-to");
            const errEl = document.getElementById("rw-error");

            errEl.classList.add("hidden");
            modeInput.value = mode;

            if (mode === 'EDIT') {
                titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
                fioInput.value = fio;
                fioInput.readOnly = true;
                fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
                deptInput.value = dept || "Все";
                deptInput.readOnly = true;
                deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
                fromInput.value = dmyToYmd(dateFrom);
                toInput.value = dmyToYmd(dateTo);
            } else {
                titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
                fioInput.value = "";
                fioInput.readOnly = false;
                fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
                deptInput.value = "Все";
                deptInput.readOnly = false;
                deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
                
                const today = new Date().toISOString().split('T')[0];
                fromInput.value = today;
                toInput.value = "";
            }

            modal.classList.remove("hidden");
        }

        function closeRemoteWorkerModal() {
            document.getElementById("remote-worker-modal").classList.add("hidden");
        }

        async function handleRemoteWorkerSubmit(e) {
            e.preventDefault();
            const mode = document.getElementById("rw-mode").value;
            const fio = document.getElementById("rw-fio").value.trim();
            const dept = document.getElementById("rw-dept").value.trim() || "Все";
            const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
            const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
            const errEl = document.getElementById("rw-error");
            errEl.classList.add("hidden");

            const method = (mode === 'EDIT') ? "PUT" : "POST";
            const payload = (mode === 'EDIT') 
                ? { fio: fio, date_from: fromVal, date_to: toVal }
                : { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };

            try {
                const res = await fetch("/api/v1/remote-workers", {
                    method: method,
                    headers: AuthManager.getAuthHeaders(),
                    body: JSON.stringify(payload)
                });

                if (res.ok) {
                    closeRemoteWorkerModal();
                    if (window.SidebarManager) SidebarManager.renderContent();
                } else {
                    const err = await res.json();
                    errEl.innerText = err.detail || "Ошибка сохранения";
                    errEl.classList.remove("hidden");
                }
            } catch (err) {
                errEl.innerText = "Ошибка соединения с сервером";
                errEl.classList.remove("hidden");
            }
        }

        document.addEventListener("DOMContentLoaded", () => {
            if (window.AuthManager && AuthManager.isAuthenticated()) {
                updateUIState();
            }
        });
    </script>

    <!-- Глобальная Drag-and-Drop зона на весь экран -->
    <div id="global-drag-overlay" 
         class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
        <div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
            <div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
                <i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
            </div>
            <div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
            <div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
        </div>
    </div>
</body>
</html>

File: ./modules/web_api/static/js/sidebar.js

/**
 * ===============================================================================
 * FILE: modules/web_api/static/js/sidebar.js
 * ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
 *       подробным описанием управления контекстом и интеграцией чата.
 * ===============================================================================
 */

window.escapeHtml = function(str) {
    if (str === null || str === undefined) return '';
    return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
};

window.SidebarManager = {
    currentHub: 'TASKS',
    currentSubTab: {
        'REGISTRIES': 'REMOTE'
    },

    hubs: [
        { id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
        { id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
        { id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
        { id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
        { id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
    ],

    // ⭐️ Сетка реестров 2x2
    subTabs: {
        'REGISTRIES': [
            { id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
            { id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
            { id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
            { id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
        ]
    },

    init() {
        this.renderHeader();
        this.renderContent();
    },

    setHub(hubId) {
        this.currentHub = hubId;
        this.renderHeader();
        this.renderContent();
    },

    setSubTab(subTabId) {
        this.currentSubTab[this.currentHub] = subTabId;
        this.renderHeader();
        this.renderContent();
    },

    renderHeader() {
        const headerContainer = document.getElementById("sidebar-dynamic-header");
        if (!headerContainer) return;

        // 1. Основные 5 Хабов
        const hubsHtml = `
            <div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
                ${this.hubs.map(h => {
                    const isActive = this.currentHub === h.id;
                    return `
                        <button onclick="SidebarManager.setHub('${h.id}')" 
                                class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
                                    isActive 
                                    ? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm' 
                                    : 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
                                }">
                            <i class="fa-solid ${h.icon} text-xs"></i>
                            <span class="truncate">${h.label}</span>
                        </button>
                    `;
                }).join('')}
            </div>
        `;

        // 2. Подвкладки реестров (Сетка 2x2 в строгом соответствии с макетом)
        let subTabsHtml = '';
        if (this.subTabs[this.currentHub]) {
            const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
            subTabsHtml = `
                <div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
                    ${this.subTabs[this.currentHub].map(st => {
                        const isSubActive = currentActiveSub === st.id;
                        return `
                            <button onclick="SidebarManager.setSubTab('${st.id}')" 
                                    class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
                                        isSubActive 
                                        ? 'bg-white text-indigo-700 shadow-sm' 
                                        : 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
                                    }">
                                <i class="fa-solid ${st.icon} text-[10px]"></i>
                                <span>${st.label}</span>
                            </button>
                        `;
                    }).join('')}
                </div>
            `;
        }

        headerContainer.innerHTML = hubsHtml + subTabsHtml;
    },

    renderContent() {
        const contentContainer = document.getElementById("sidebar-dynamic-content");
        if (!contentContainer) return;

        contentContainer.scrollTop = 0;

        switch (this.currentHub) {
            case 'TASKS':
                this.renderTasksView(contentContainer);
                break;
            case 'SNAPSHOTS':
                this.renderSnapshotsView(contentContainer);
                break;
            case 'REGISTRIES':
                this.renderRegistriesView(contentContainer);
                break;
            case 'PROMPT':
                this.renderPromptView(contentContainer);
                break;
            case 'CONTEXT':
                this.renderContextView(contentContainer);
                break;
            default:
                contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
        }
    },

    // =========================================================================
    // ХАБ 1: ЗАДАЧИ
    // =========================================================================
    renderTasksView(container) {
        container.innerHTML = `
            <div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
                <div class="text-center py-10 text-xs text-slate-400">
                    <i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
                </div>
            </div>
        `;
        if (window.loadTasks) {
            window.loadTasks();
        }
    },

    // =========================================================================
    // ХАБ 2: СРЕЗЫ СКУД
    // =========================================================================
    async renderSnapshotsView(container) {
        container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
        try {
            const res = await fetch("/api/v1/snapshots", { headers: AuthManager.getAuthHeaders() });
            const data = res.ok ? await res.json() : { snapshots: [] };
            const snaps = data.snapshots || [];

            if (snaps.length === 0) {
                container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
                return;
            }

            const itemsHtml = snaps.map(s => `
                <div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
                    <div class="min-w-0 flex-1">
                        <div class="flex items-center gap-1.5">
                            <span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
                            ${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
                        </div>
                        <div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
                    </div>
                    <button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
                        Инспекция
                    </button>
                </div>
            `).join('');

            container.innerHTML = `
                <div class="p-2 flex flex-col gap-2">
                    <div class="flex items-center justify-between px-1">
                        <span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
                        <button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
                            <i class="fa-solid fa-arrows-rotate text-xs"></i>
                        </button>
                    </div>
                    <div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
                </div>
            `;
        } catch (e) {
            container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
        }
    },

    // =========================================================================
    // ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
    // =========================================================================
    renderRegistriesView(container) {
        const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
        if (subTab === 'REMOTE') {
            this.renderRemoteWorkersView(container);
        } else if (subTab === 'EXCEPTIONS') {
            this.renderExceptionsView(container);
        } else {
            this.renderManualAbsencesView(container, subTab);
        }
    },

    async renderRemoteWorkersView(container) {
        container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>`;
        try {
            const res = await fetch("/api/v1/remote-workers", { headers: AuthManager.getAuthHeaders() });
            const data = res.ok ? await res.json() : { workers: [] };
            const workers = data.workers || [];

            const listHtml = workers.map(w => {
                const dFrom = w.date_from ? w.date_from : 'сегодня';
                const dTo = w.date_to ? w.date_to : 'бессрочно';
                const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`;

                return `
                    <div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
                        <div class="min-w-0 flex-1">
                            <div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
                            <div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
                            <div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
                                <i class="fa-regular fa-calendar-days text-[8px]"></i>
                                <span>${escapeHtml(periodLabel)}</span>
                            </div>
                        </div>
                        <div class="flex items-center gap-0.5 shrink-0">
                            <button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')" 
                                    class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition" 
                                    title="Изменить сроки удаленки">
                                <i class="fa-solid fa-pen-to-square text-xs"></i>
                            </button>
                            <button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')" 
                                    class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition" 
                                    title="Удалить">
                                <i class="fa-solid fa-trash-can text-xs"></i>
                            </button>
                        </div>
                    </div>
                `;
            }).join('');

            container.innerHTML = `
                <div class="p-2 flex flex-col gap-2.5">
                    <div class="flex items-center justify-between px-1">
                        <span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
                        <button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
                            <i class="fa-solid fa-plus text-[10px]"></i> Добавить
                        </button>
                    </div>
                    <div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
                        ${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
                    </div>
                </div>
            `;
        } catch (e) {
            container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
        }
    },

    async deleteRemoteWorker(fio) {
        if (!confirm(`Удалить сотрудника ${fio} из реестра удаленщиков?`)) return;
        try {
            const res = await fetch(`/api/v1/remote-workers?fio=${encodeURIComponent(fio)}`, {
                method: "DELETE",
                headers: AuthManager.getAuthHeaders()
            });
            if (res.ok) {
                this.renderContent();
            } else {
                alert("Ошибка удаления");
            }
        } catch (e) {
            alert("Ошибка сети");
        }
    },

    async renderExceptionsView(container) {
        container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка исключений...</div>`;
        try {
            const res = await fetch("/api/v1/exceptions/", { headers: AuthManager.getAuthHeaders() });
            const data = res.ok ? await res.json() : {};
            const categories = [
                { key: 'include_fio', title: 'Белый список (ФИО)' },
                { key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
                { key: 'departments', title: 'Исключенные отделы' },
                { key: 'positions', title: 'Исключенные должности' }
            ];

            const html = categories.map(cat => {
                const items = data[cat.key] || [];
                return `
                    <div class="bg-white border border-slate-200 rounded-xl p-3 flex flex-col gap-2 shadow-sm">
                        <div class="flex items-center justify-between">
                            <span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
                            <button onclick="SidebarManager.addExceptionPrompt('${cat.key}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
                                + Добавить
                            </button>
                        </div>
                        <div class="flex flex-wrap gap-1">
                            ${items.map(it => `
                                <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-700 border border-slate-200">
                                    ${escapeHtml(it)}
                                    <button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
                                </span>
                            `).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
                        </div>
                    </div>
                `;
            }).join('');

            container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
        } catch (e) {
            container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
        }
    },

    async addExceptionPrompt(category) {
        const val = prompt(`Введите значение для категории [${category}]:`);
        if (!val || !val.trim()) return;
        try {
            const res = await fetch("/api/v1/exceptions/", {
                method: "POST",
                headers: AuthManager.getAuthHeaders(),
                body: JSON.stringify({ category: category, value: val.trim() })
            });
            if (res.ok) this.renderContent();
            else alert("Ошибка добавления");
        } catch (e) {
            alert("Ошибка сети");
        }
    },

    async deleteExceptionItem(category, value) {
        if (!confirm(`Удалить "${value}" из ${category}?`)) return;
        try {
            const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
                method: "DELETE",
                headers: AuthManager.getAuthHeaders()
            });
            if (res.ok) this.renderContent();
            else alert("Ошибка удаления");
        } catch (e) {
            alert("Ошибка сети");
        }
    },

    // ⭐️ Новые подвкладки: Местная командировка и Иное
    async renderManualAbsencesView(container, absenceType) {
        const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий';
        container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
        try {
            const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`);
            const data = res.ok ? await res.json() : { items: [] };
            const items = data.items || [];

            const listHtml = items.map(it => {
                const dFrom = it.date_start ? it.date_start : 'сегодня';
                const dTo = it.date_end ? it.date_end : 'сегодня';
                const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`;
                const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason);

                return `
                    <div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
                        <div class="min-w-0 flex-1">
                            <div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
                            <div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
                            <div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
                                <i class="fa-regular fa-calendar-days text-[8px]"></i>
                                <span>${escapeHtml(periodLabel)}</span>
                            </div>
                        </div>
                        <div class="flex items-center gap-0.5 shrink-0">
                            <button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})" 
                                    class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition" 
                                    title="Удалить">
                                <i class="fa-solid fa-trash-can text-xs"></i>
                            </button>
                        </div>
                    </div>
                `;
            }).join('');

            container.innerHTML = `
                <div class="p-2 flex flex-col gap-2.5">
                    <div class="flex items-center justify-between px-1">
                        <span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
                        <button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
                            <i class="fa-solid fa-plus text-[10px]"></i> Добавить
                        </button>
                    </div>
                    <div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
                        ${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
                    </div>
                </div>
            `;
        } catch (e) {
            container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
        }
    },

    async deleteManualAbsenceRecord(id) {
        if (!confirm("Удалить эту запись из реестра?")) return;
        try {
            const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" });
            if (res.ok) {
                this.renderContent();
            } else {
                alert("Ошибка удаления");
            }
        } catch (e) {
            alert("Ошибка сети");
        }
    },

    // =========================================================================
    // ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
    // =========================================================================
    renderPromptView(container) {
        container.innerHTML = `
            <div class="p-3 flex flex-col gap-3">
                <div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
                    <span class="font-bold text-slate-800 flex items-center gap-1.5">
                        <i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
                    </span>
                    <span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
                </div>

                <div class="flex flex-col gap-2">
                    <button onclick="window.sendChatAction('покажи системный промпт')" 
                            class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
                        <i class="fa-solid fa-terminal text-xs"></i>
                        <span>Показать системный промпт</span>
                    </button>

                    <button onclick="window.sendChatAction('покажи правила компании')" 
                            class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
                        <i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
                        <span>База знаний и правила компании</span>
                    </button>
                </div>
            </div>
        `;
    },

    // =========================================================================
    // ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
    // =========================================================================
    renderContextView(container) {
        container.innerHTML = `
            <div class="p-3 flex flex-col gap-3">
                <div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
                    <span class="font-bold text-slate-800 flex items-center gap-1.5">
                        <i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
                    </span>
                    
                    <div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
                        <div class="flex items-start gap-1.5">
                            <span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
                            <div>
                                <span class="font-bold text-slate-700">Мягкая очистка:</span>
                                <span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
                            </div>
                        </div>

                        <div class="flex items-start gap-1.5">
                            <span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
                            <div>
                                <span class="font-bold text-slate-700">Полный сброс:</span>
                                <span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
                            </div>
                        </div>
                    </div>
                </div>

                <div class="flex flex-col gap-2">
                    <button onclick="window.sendChatAction('очисти контекст')" 
                            class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
                        <i class="fa-solid fa-broom text-xs"></i>
                        <span>Мягкая очистка контекста</span>
                    </button>

                    <button onclick="SidebarManager.handleFullSessionReset()" 
                            class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
                        <i class="fa-solid fa-trash-arrow-up text-xs"></i>
                        <span>Полный сброс сессии</span>
                    </button>
                </div>
            </div>
        `;
    },

    async handleFullSessionReset() {
        if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
            return;
        }
        
        const chatContainer = document.getElementById("chat-messages-container");
        if (chatContainer) {
            chatContainer.innerHTML = `
                <div class="flex gap-3 max-w-4xl mx-auto w-full">
                    <div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
                        <i class="fa-solid fa-robot text-xs"></i>
                    </div>
                    <div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
                        <div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
                        <div class="text-xs text-slate-700 leading-relaxed">
                            Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
                        </div>
                    </div>
                </div>
            `;
        }

        try {
            await fetch("/api/v1/chat", {
                method: "POST",
                headers: AuthManager.getAuthHeaders(),
                body: JSON.stringify({
                    message: "сбрось сессию полностью",
                    session_id: "web_session_main"
                })
            });
        } catch (e) {
            console.error("Ошибка запроса сброса сессии:", e);
        }
    }
};

document.addEventListener("DOMContentLoaded", () => {
    if (window.AuthManager && AuthManager.isAuthenticated()) {
        SidebarManager.init();
    }
});

File: ./modules/web_api/static/js/tasks.js

/**
 * ===============================================================================
 * FILE: modules/web_api/static/js/tasks.js
 * ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
 * ===============================================================================
 */

let currentTasksFilter = 'ALL';
let tasksCache = [];

function getTasksContainer() {
    return document.getElementById("tasks-list-container") || 
           document.getElementById("sidebar-dynamic-content") ||
           document.getElementById("tasks-list");
}

async function loadTasks() {
    const container = getTasksContainer();
    if (!container) return;

    container.innerHTML = `
        <div class="text-center py-8 text-xs text-slate-400">
            <i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
        </div>
    `;

    try {
        const res = await fetch("/api/v1/tasks", {
            headers: AuthManager.getAuthHeaders()
        });

        if (!res.ok) {
            if (res.status === 401) {
                showAuthModal();
                return;
            }
            throw new Error(`Ошибка сервера (${res.status})`);
        }

        const data = await res.json();
        tasksCache = Array.isArray(data) ? data : (data.tasks || []);
        renderTasksUI();
    } catch (err) {
        console.error("[Tasks] Ошибка загрузки:", err);
        container.innerHTML = `
            <div class="p-4 text-xs text-rose-500 text-center flex flex-col items-center gap-2">
                <i class="fa-solid fa-triangle-exclamation text-base"></i>
                <span>Не удалось загрузить задачи</span>
                <button onclick="loadTasks()" class="px-2.5 py-1 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded text-[11px] font-semibold transition">Повторить</button>
            </div>
        `;
    }
}

function setTaskFilter(filter) {
    currentTasksFilter = filter;
    renderTasksUI();
}

function renderTasksUI() {
    const container = getTasksContainer();
    if (!container) return;

    let filtered = tasksCache;
    if (currentTasksFilter === 'IN_PROGRESS') {
        filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
    } else if (currentTasksFilter === 'BACKLOG') {
        filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
    } else if (currentTasksFilter === 'COMPLETED') {
        filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
    }

    const filtersHtml = `
        <div class="flex items-center gap-1 p-1 bg-slate-200/70 rounded-lg text-[11px] font-semibold mb-2">
            <button onclick="setTaskFilter('ALL')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'ALL' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Все</button>
            <button onclick="setTaskFilter('IN_PROGRESS')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'IN_PROGRESS' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">В работе</button>
            <button onclick="setTaskFilter('BACKLOG')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'BACKLOG' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Планы</button>
            <button onclick="setTaskFilter('COMPLETED')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'COMPLETED' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Готово</button>
        </div>
    `;

    const addBtnHtml = `
        <div class="flex items-center justify-between px-1 mb-1.5">
            <span class="text-xs font-bold text-slate-700">Задачи: ${filtered.length}</span>
            <button onclick="openCreateTaskModal()" class="px-2 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
                <i class="fa-solid fa-plus text-[10px]"></i> Новая
            </button>
        </div>
    `;

    if (filtered.length === 0) {
        container.innerHTML = `
            ${filtersHtml}
            ${addBtnHtml}
            <div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
                Нет задач в выбранной категории
            </div>
        `;
        return;
    }

    const itemsHtml = filtered.map(t => {
        const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
        const priorityColors = {
            'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
            'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
            'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
        };
        const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];

        return `
            <div class="flex flex-col p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-1.5 shadow-sm hover:border-indigo-300 transition">
                <div class="flex items-start justify-between gap-2">
                    <div class="flex items-center gap-1.5 min-w-0">
                        <button onclick="toggleTaskStatus(${t.id}, '${t.status}')" class="text-slate-400 hover:text-indigo-600 transition shrink-0">
                            <i class="fa-${isDone ? 'solid fa-circle-check text-emerald-500' : 'regular fa-circle'} text-sm"></i>
                        </button>
                        <span class="font-bold text-slate-800 ${isDone ? 'line-through text-slate-400' : ''} truncate">${escapeHtml(t.title)}</span>
                    </div>
                    <span class="px-1.5 py-0.5 rounded text-[9px] font-semibold border ${pClass} shrink-0">${t.priority || 'NORMAL'}</span>
                </div>
                
                <div class="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-100">
                    <span>${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}</span>
                    <div class="flex items-center gap-1">
                        <button onclick="deleteTaskItem(${t.id})" class="text-slate-400 hover:text-rose-600 p-0.5 transition" title="Удалить">
                            <i class="fa-solid fa-trash-can text-[11px]"></i>
                        </button>
                    </div>
                </div>
            </div>
        `;
    }).join('');

    container.innerHTML = `
        ${filtersHtml}
        ${addBtnHtml}
        <div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
            ${itemsHtml}
        </div>
    `;
}

async function toggleTaskStatus(id, currentStatus) {
    const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED';
    try {
        const res = await fetch(`/api/v1/tasks/${id}`, {
            method: "PATCH",
            headers: AuthManager.getAuthHeaders(),
            body: JSON.stringify({ status: newStatus })
        });
        if (res.ok) {
            loadTasks();
        }
    } catch (e) {
        console.error("Ошибка смены статуса задачи:", e);
    }
}

async function deleteTaskItem(id) {
    if (!confirm("Удалить эту задачу?")) return;
    try {
        const res = await fetch(`/api/v1/tasks/${id}`, {
            method: "DELETE",
            headers: AuthManager.getAuthHeaders()
        });
        if (res.ok) {
            loadTasks();
        }
    } catch (e) {
        alert("Ошибка сети при удалении");
    }
}

async function openCreateTaskModal() {
    const title = prompt("Введите описание новой задачи:");
    if (!title || !title.trim()) return;

    try {
        const res = await fetch("/api/v1/tasks", {
            method: "POST",
            headers: AuthManager.getAuthHeaders(),
            body: JSON.stringify({
                title: title.trim(),
                priority: "MEDIUM",
                status: "IN_PROGRESS"
            })
        });
        if (res.ok) {
            loadTasks();
        } else {
            alert("Не удалось создать задачу");
        }
    } catch (e) {
        alert("Ошибка сети");
    }
}

window.loadTasks = loadTasks;
window.setTaskFilter = setTaskFilter;

File: ./modules/web_api/static/js/manual_absences.js

/**
 * ===============================================================================
 * FILE: modules/web_api/static/js/manual_absences.js
 * ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
 *       и мгновенная синхронизация с боковой панелью SidebarManager.
 * ===============================================================================
 */

let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
let reasonsCache = [];

async function loadAbsenceReasons() {
    try {
        const res = await fetch('/api/v1/manual-absences/reasons');
        if (res.ok) {
            const data = await res.json();
            reasonsCache = data.reasons || [];
        }
    } catch (e) {
        console.error('Ошибка загрузки причин:', e);
    }
}

function openManualAbsenceModal(type) {
    activeAbsenceType = type;
    const isTrip = type === 'LOCAL_TRIP';
    const titleEl = document.getElementById('manual-absence-modal-title');
    const reasonBlock = document.getElementById('manual-absence-reason-block');
    const reasonSelect = document.getElementById('manual-absence-reason-select');

    if (titleEl) {
        titleEl.innerHTML = isTrip 
            ? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
            : '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
    }

    if (reasonBlock && reasonSelect) {
        if (isTrip) {
            reasonBlock.classList.add('hidden');
        } else {
            reasonBlock.classList.remove('hidden');
            reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
        }
    }

    // Сброс полей ввода
    const fioInput = document.getElementById('manual-absence-fio-input');
    const deptInput = document.getElementById('manual-absence-dept');
    const posInput = document.getElementById('manual-absence-pos');
    const startDateInput = document.getElementById('manual-absence-start-date');
    const endDateInput = document.getElementById('manual-absence-end-date');
    const suggestionsBox = document.getElementById('manual-absence-suggestions');

    if (fioInput) fioInput.value = '';
    if (deptInput) deptInput.value = '';
    if (posInput) posInput.value = '';
    if (startDateInput) startDateInput.value = '';
    if (suggestionsBox) {
        suggestionsBox.classList.add('hidden');
        suggestionsBox.innerHTML = '';
    }
    
    // Окончание по умолчанию — сегодняшний день
    if (endDateInput) {
        const today = new Date().toISOString().split('T')[0];
        endDateInput.value = today;
    }

    loadManualAbsencesTable();
    const modal = document.getElementById('manual-absence-modal');
    if (modal) modal.classList.remove('hidden');
}

function closeManualAbsenceModal() {
    const modal = document.getElementById('manual-absence-modal');
    if (modal) modal.classList.add('hidden');
}

// Живой автокомплит ФИО из базы zup_staff
let searchTimeout = null;
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
    const box = document.getElementById(suggestionsBoxId);
    if (!inputEl || !box) return;

    inputEl.addEventListener('input', function() {
        const val = this.value.trim();
        clearTimeout(searchTimeout);
        if (val.length < 2) {
            box.classList.add('hidden');
            box.innerHTML = '';
            return;
        }

        searchTimeout = setTimeout(async () => {
            try {
                const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
                if (!res.ok) return;
                const items = await res.json();
                if (items.length === 0) {
                    box.classList.add('hidden');
                    return;
                }

                box.innerHTML = items.map(it => `
                    <div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs" 
                         onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
                        <span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
                        <span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
                    </div>
                `).join('');
                box.classList.remove('hidden');
            } catch (e) {
                console.error(e);
            }
        }, 200);
    });
}

function selectStaffSuggestion(fio, dept, pos) {
    const fioInput = document.getElementById('manual-absence-fio-input');
    const deptInput = document.getElementById('manual-absence-dept');
    const posInput = document.getElementById('manual-absence-pos');
    const box = document.getElementById('manual-absence-suggestions');

    if (fioInput) fioInput.value = fio;
    if (deptInput) deptInput.value = dept;
    if (posInput) posInput.value = pos;
    if (box) box.classList.add('hidden');
}

async function submitManualAbsence() {
    const fioInput = document.getElementById('manual-absence-fio-input');
    const fio = fioInput ? fioInput.value.trim() : '';
    if (!fio) {
        alert('Укажите ФИО сотрудника');
        return;
    }

    const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
    const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
    const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
    const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
    const reasonSelect = document.getElementById('manual-absence-reason-select');

    const payload = {
        absence_type: activeAbsenceType,
        fio: fio,
        department: deptVal,
        position: posVal,
        date_start: startDateVal,
        date_end: endDateVal,
        reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
    };

    try {
        const res = await fetch('/api/v1/manual-absences/', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
        });
        if (res.ok) {
            if (fioInput) fioInput.value = '';
            loadManualAbsencesTable();
            // Обновляем список карточек в боковой панели и закрываем окно
            if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
                SidebarManager.renderContent();
            }
            closeManualAbsenceModal();
        } else {
            alert('Ошибка добавления записи');
        }
    } catch (e) {
        alert('Сетевая ошибка при добавлении');
    }
}

async function loadManualAbsencesTable() {
    const tableContainer = document.getElementById('manual-absences-table-body');
    if (!tableContainer) return;

    try {
        const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`);
        if (!res.ok) return;
        const data = await res.json();
        const items = data.items || [];

        if (items.length === 0) {
            tableContainer.innerHTML = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
            return;
        }

        tableContainer.innerHTML = items.map(it => `
            <tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
                <td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
                <td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
                <td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
                <td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
                <td class="p-2 text-center">
                    <button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
                        <i class="fa-solid fa-trash-can"></i>
                    </button>
                </td>
            </tr>
        `).join('');
    } catch (e) {
        console.error(e);
    }
}

async function deleteManualAbsenceRecord(id) {
    if (!confirm('Удалить эту запись?')) return;
    try {
        const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' });
        if (res.ok) {
            loadManualAbsencesTable();
            if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
                SidebarManager.renderContent();
            }
        }
    } catch (e) {
        alert('Ошибка при удалении');
    }
}

document.addEventListener('DOMContentLoaded', () => {
    loadAbsenceReasons();
    setupStaffAutocomplete(
        document.getElementById('manual-absence-fio-input'), 
        'manual-absence-suggestions'
    );
});

File: ./modules/web_api/static/js/chat/core.js

/**
 * ===============================================================================
 * FILE: modules/web_api/static/js/chat/core.js
 * ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px),
 *       надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm.
 * ===============================================================================
 */

let currentAttachedFile = null;

const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history";
let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]");
let chatHistoryIndex = -1;
let temporaryCurrentInput = "";

function saveCommandToHistory(commandText) {
    if (!commandText || !commandText.trim()) return;
    const cleanCmd = commandText.trim();
    if (cleanCmd.startsWith("action:save_draft_")) return;

    chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd);
    chatInputHistory.push(cleanCmd);
    if (chatInputHistory.length > 50) chatInputHistory.shift();
    localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory));
    chatHistoryIndex = -1;
}

// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
function scrollToUserMessageTop() {
    const container = document.getElementById("chat-messages-container");
    if (!container) return;

    const userBubbles = container.querySelectorAll(".user-chat-bubble");
    const targetEl = userBubbles[userBubbles.length - 1];
    if (!targetEl) return;

    requestAnimationFrame(() => {
        const targetScroll = targetEl.offsetTop - container.offsetTop - 12;

        container.scrollTo({
            top: Math.max(0, targetScroll),
            behavior: 'smooth'
        });
    });
}

function updateInputHeightAndFade(textarea) {
    if (!textarea) return;

    if (!textarea.value || textarea.value.trim() === '') {
        textarea.style.height = '24px';
        textarea.style.overflowY = 'hidden';
        textarea.style.maskImage = 'none';
        textarea.style.webkitMaskImage = 'none';
        return;
    }

    textarea.style.height = 'auto';
    const minHeight = 24;
    const maxHeight = 120;
    const currentScrollHeight = textarea.scrollHeight;

    if (currentScrollHeight <= minHeight + 2) {
        textarea.style.height = minHeight + 'px';
        textarea.style.overflowY = 'hidden';
        textarea.style.maskImage = 'none';
        textarea.style.webkitMaskImage = 'none';
    } else if (currentScrollHeight > maxHeight) {
        textarea.style.height = maxHeight + 'px';
        textarea.style.overflowY = 'auto';
        textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
        textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
    } else {
        textarea.style.height = currentScrollHeight + 'px';
        textarea.style.overflowY = 'hidden';
        textarea.style.maskImage = 'none';
        textarea.style.webkitMaskImage = 'none';
    }
}

function appendUserMessage(text, filename = null) {
    const container = document.getElementById("chat-messages-container");
    if (!container) return;

    const msgId = 'user-msg-' + Date.now();
    let fileBadge = '';
    if (filename) {
        fileBadge = `
            <div class="inline-flex items-center gap-1.5 px-2.5 py-1 mb-2 bg-indigo-700/80 rounded-lg text-xs font-semibold text-white shadow-xs">
                <i class="fa-solid fa-paperclip text-xs"></i>
                <span class="truncate max-w-xs">${escapeHtml(filename)}</span>
            </div>
        `;
    }

    const msgHtml = `
        <div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
            <div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
                ${fileBadge}
                <div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
            </div>
            <div class="w-8 h-8 rounded-lg bg-slate-200 text-slate-600 flex items-center justify-center shrink-0 shadow-sm mt-0.5 font-bold text-sm">
                <i class="fa-solid fa-user"></i>
            </div>
        </div>
    `;
    container.insertAdjacentHTML("beforeend", msgHtml);
}

function appendAssistantLoading() {
    const container = document.getElementById("chat-messages-container");
    if (!container) return null;

    const loadingId = 'loading-' + Date.now();
    const html = `
        <div id="${loadingId}" class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
            <div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
                <i class="fa-solid fa-robot text-sm"></i>
            </div>
            <div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
                <div class="text-sm text-slate-500 flex items-center gap-2">
                    <i class="fa-solid fa-spinner fa-spin text-indigo-600"></i>
                    <span>ИИ обрабатывает запрос...</span>
                </div>
            </div>
        </div>
    `;
    container.insertAdjacentHTML("beforeend", html);
    return loadingId;
}

function appendAssistantMessage(text, buttons = [], actionPayload = null) {
    const container = document.getElementById("chat-messages-container");
    if (!container) return;

    let payloadHtml = '';
    let isHtmlBody = false;

    if (actionPayload) {
        if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') {
            payloadHtml = renderSnapshotsCard(actionPayload.data);
        } else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') {
            payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
        } else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
            const dlUrl = actionPayload.download_url || '#';
            const fName = actionPayload.filename || 'ROADMAP.md';
            const count = actionPayload.tasks_count || '';
            payloadHtml = `
                <div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
                    <div class="flex items-center gap-3 min-w-0">
                        <div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
                            <i class="fa-solid fa-file-lines text-base"></i>
                        </div>
                        <div class="min-w-0">
                            <div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
                            <div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
                        </div>
                    </div>
                    <a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
                       class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
                        <i class="fa-solid fa-download text-xs"></i>
                        <span>Скачать файл</span>
                    </a>
                </div>
            `;
        } else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') {
            const records = actionPayload.records || [];
            const inspectTableId = 'inspect-table-' + Date.now();
            const searchInputId = 'inspect-search-' + Date.now();

            const rowsHtml = records.map((r, idx) => `
                <tr class="inspect-row border-b border-slate-100 text-xs ${r.is_present ? 'bg-white' : 'bg-slate-50/60'} hover:bg-indigo-50/40"
                    data-fio="${escapeHtml(r.fio).toLowerCase()}" data-dept="${escapeHtml(r.department).toLowerCase()}">
                    <td class="p-2.5 text-slate-400 text-center font-mono w-10">${idx + 1}</td>
                    <td class="p-2.5 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
                    <td class="p-2.5 text-slate-500 text-center">${escapeHtml(r.department)}</td>
                    <td class="p-2.5 text-center ${r.time_in !== 'Нет входа' ? 'font-bold text-emerald-700' : 'text-slate-400'}">${escapeHtml(r.time_in)}</td>
                    <td class="p-2.5 text-center text-slate-500">${escapeHtml(r.first_activity)}</td>
                    <td class="p-2.5 text-center ${r.time_out !== 'Нет выхода' ? 'font-bold text-slate-800' : 'text-slate-400'}">${escapeHtml(r.time_out)}</td>
                    <td class="p-2.5 text-center font-mono text-slate-600">${escapeHtml(r.in_building)}</td>
                    <td class="p-2.5 text-center font-bold">${r.is_present ? '<span class="text-emerald-600">✓ Да</span>' : '<span class="text-slate-400">Нет</span>'}</td>
                </tr>
            `).join('');

            payloadHtml = `
                <div class="mt-3 bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm flex flex-col">
                    <div class="px-4 py-3 bg-slate-100/90 border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
                        <div class="flex items-center gap-2">
                            <span class="text-sm font-bold text-slate-800">Срез #${escapeHtml(actionPayload.snapshot_id)}</span>
                            <span class="text-xs text-slate-500">· Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0})</span>
                        </div>
                        <div class="flex items-center gap-2">
                            <input type="text" id="${searchInputId}" placeholder="Поиск в срезе (ФИО / отдел)..." 
                                   oninput="window.filterInspectTable('${inspectTableId}', this.value)"
                                   class="text-xs px-3 py-1.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 w-60" />
                            <button onclick="window.sendChatAction('покажи срезы')" class="text-xs text-indigo-600 hover:underline font-semibold">Все срезы</button>
                        </div>
                    </div>
                    <div class="max-h-96 overflow-y-auto">
                        <table id="${inspectTableId}" class="w-full text-left border-collapse">
                            <thead class="bg-slate-50 text-[11px] uppercase text-slate-500 sticky top-0 border-b border-slate-200 shadow-xs">
                                <tr>
                                    <th class="p-2.5 text-center w-10">№</th>
                                    <th class="p-2.5">Сотрудник</th>
                                    <th class="p-2.5 text-center">Отдел</th>
                                    <th class="p-2.5 text-center">Вход</th>
                                    <th class="p-2.5 text-center">Активность</th>
                                    <th class="p-2.5 text-center">Выход</th>
                                    <th class="p-2.5 text-center">В здании</th>
                                    <th class="p-2.5 text-center">Статус</th>
                                </tr>
                            </thead>
                            <tbody>${rowsHtml}</tbody>
                        </table>
                    </div>
                </div>
            `;
        } else if (actionPayload.type === 'PROMPT_EDITOR') {
            const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
            const editorId = 'prompt-editor-' + Date.now();
            payloadHtml = `
                <div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
                    <div class="flex items-center justify-between text-xs font-bold text-slate-700">
                        <span><i class="fa-solid fa-pen-to-square text-indigo-600 mr-1"></i> Инлайн-редактор системного промпта:</span>
                        <span class="text-[11px] text-slate-400 font-normal">Прямое редактирование текста</span>
                    </div>
                    <textarea id="${editorId}" rows="14" 
                              class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
                    <div class="flex items-center justify-end gap-2 pt-1">
                        <button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
                        <button type="button" onclick="window.submitPromptDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
                            <i class="fa-solid fa-eye text-xs"></i>
                            <span>Показать превью изменений</span>
                        </button>
                    </div>
                </div>
            `;
        } else if (actionPayload.type === 'RULES_EDITOR') {
            const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
            const editorId = 'rules-editor-' + Date.now();
            payloadHtml = `
                <div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
                    <div class="flex items-center justify-between text-xs font-bold text-slate-700">
                        <span><i class="fa-solid fa-book-bookmark text-emerald-600 mr-1"></i> Редактор базы знаний и правил компании:</span>
                        <span class="text-[11px] text-slate-400 font-normal">Прямое изменение правил кадрового арбитража</span>
                    </div>
                    <textarea id="${editorId}" rows="12" 
                              class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
                    <div class="flex items-center justify-end gap-2 pt-1">
                        <button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
                        <button type="button" onclick="window.submitRulesDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
                            <i class="fa-solid fa-eye text-xs"></i>
                            <span>Показать превью изменений</span>
                        </button>
                    </div>
                </div>
            `;
        } else if (actionPayload.type === 'PROMPT_PREVIEW') {
            isHtmlBody = true;
        }
    }

    let buttonsHtml = '';
    if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) {
        const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []);
        if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) {
            buttonsHtml = `
                <div class="flex flex-wrap gap-2 mt-3.5 pt-2.5 border-t border-slate-100">
                    ${allButtons.map(b => `
                        <button onclick="window.sendChatAction('${escapeHtml(b.value || b.action || b.title || '')}')" 
                                class="px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold border border-indigo-200 transition">
                            ${escapeHtml(b.label || b.title || b.action)}
                        </button>
                    `).join('')}
                </div>
            `;
        }
    }

    const bodyContent = isHtmlBody ? text : escapeHtml(text);

    const html = `
        <div class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
            <div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
                <i class="fa-solid fa-robot text-sm"></i>
            </div>
            <div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm min-w-0">
                <div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1.5">ИИ-ассистент SCUD Orion AI</div>
                <div class="text-sm text-slate-800 leading-relaxed whitespace-pre-wrap">${bodyContent}</div>
                ${payloadHtml}
                ${buttonsHtml}
            </div>
        </div>
    `;
    container.insertAdjacentHTML("beforeend", html);
}

window.filterInspectTable = function(tableId, query) {
    const table = document.getElementById(tableId);
    if (!table) return;
    const q = (query || '').trim().toLowerCase();
    const rows = table.querySelectorAll('.inspect-row');
    rows.forEach(r => {
        const fio = r.getAttribute('data-fio') || '';
        const dept = r.getAttribute('data-dept') || '';
        if (!q || fio.includes(q) || dept.includes(q)) {
            r.classList.remove('hidden');
        } else {
            r.classList.add('hidden');
        }
    });
};

window.sendChatAction = function(actionText) {
    if (!actionText || !actionText.trim()) return;
    const input = document.getElementById("user-input");
    if (input) {
        input.value = actionText.trim();
        updateInputHeightAndFade(input);
    }
    window.sendMessage();
};

window.submitPromptDraftToDiff = function(editorId) {
    const textarea = document.getElementById(editorId);
    if (!textarea) return;
    const newText = textarea.value.trim();
    if (!newText) {
        alert("Текст системного промпта не может быть пустым");
        return;
    }

    const command = `action:save_draft_prompt:::${newText}`;
    const input = document.getElementById("user-input");
    if (input) input.value = command;
    window.sendMessage();
};

window.submitRulesDraftToDiff = function(editorId) {
    const textarea = document.getElementById(editorId);
    if (!textarea) return;
    const newText = textarea.value.trim();
    if (!newText) {
        alert("Правила не могут быть пустыми");
        return;
    }

    const command = `action:save_draft_rules:::${newText}`;
    const input = document.getElementById("user-input");
    if (input) input.value = command;
    window.sendMessage();
};

window.sendMessage = async function() {
    const input = document.getElementById("user-input");
    if (!input) return;

    const messageText = input.value.trim();
    const fileToSend = currentAttachedFile;

    if (!messageText && !fileToSend) return;

    saveCommandToHistory(messageText);

    input.value = "";
    input.style.height = '24px';
    input.style.overflowY = 'hidden';
    input.style.maskImage = 'none';
    input.style.webkitMaskImage = 'none';
    window.clearAttachedFile();

    // 1. Отрисовка сообщения пользователя в ленте
    if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
        appendUserMessage(
            messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''), 
            fileToSend ? fileToSend.name : null
        );
    } else {
        appendUserMessage("Сформировать предпросмотр изменений");
    }

    // 2. Вставка лоадера
    const loadingId = appendAssistantLoading();

    // 3. Вызов точного скролла
    scrollToUserMessageTop();

    try {
        let res;
        if (fileToSend) {
            const formData = new FormData();
            formData.append("file", fileToSend);
            formData.append("message", messageText);
            formData.append("session_id", "web_session_main");

            res = await fetch("/api/v1/chat/upload", {
                method: "POST",
                headers: {
                    "Authorization": AuthManager.getAuthHeaders()["Authorization"]
                },
                body: formData
            });

            if (res.status === 404) {
                res = await fetch("/api/v1/chat", {
                    method: "POST",
                    headers: AuthManager.getAuthHeaders(),
                    body: JSON.stringify({
                        message: messageText || `Загружен файл: ${fileToSend.name}`,
                        session_id: "web_session_main"
                    })
                });
            }
        } else {
            res = await fetch("/api/v1/chat", {
                method: "POST",
                headers: AuthManager.getAuthHeaders(),
                body: JSON.stringify({
                    message: messageText,
                    session_id: "web_session_main"
                })
            });
        }

        const loaderEl = document.getElementById(loadingId);
        if (loaderEl) loaderEl.remove();

        if (res.ok) {
            const data = await res.json();
            const replyText = data.response || data.text || data.message || "Запрос выполнен.";
            const buttons = data.buttons || [];
            const actionPayload = data.action_payload || null;

            appendAssistantMessage(replyText, buttons, actionPayload);

            if (window.SidebarManager) {
                SidebarManager.renderContent();
            }
        } else {
            const err = await res.json().catch(() => ({}));
            appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`);
        }
    } catch (err) {
        console.error("Ошибка отправки сообщения:", err);
        const loaderEl = document.getElementById(loadingId);
        if (loaderEl) loaderEl.remove();
        appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения.");
    }
};

window.clearAttachedFile = function() {
    currentAttachedFile = null;
    const preview = document.getElementById("file-attachment-preview");
    const nameEl = document.getElementById("file-attachment-name");
    const fileInput = document.getElementById("file-upload-input");

    if (preview) preview.classList.add("hidden");
    if (nameEl) nameEl.innerText = "";
    if (fileInput) fileInput.value = "";
};

function handleFileSelected(file) {
    if (!file) return;
    currentAttachedFile = file;
    const preview = document.getElementById("file-attachment-preview");
    const nameEl = document.getElementById("file-attachment-name");

    if (preview && nameEl) {
        nameEl.innerText = file.name;
        preview.classList.remove("hidden");
    }
}

document.addEventListener("DOMContentLoaded", () => {
    const input = document.getElementById("user-input");
    const fileInput = document.getElementById("file-upload-input");
    const overlay = document.getElementById("global-drag-overlay");

    if (input) {
        input.style.lineHeight = '24px';
        input.style.height = '24px';

        input.addEventListener("keydown", (e) => {
            if (e.key === "Enter") {
                if (e.shiftKey) return;
                e.preventDefault();
                window.sendMessage();
                return;
            }

            if (e.key === "ArrowUp") {
                const isSingleLine = !input.value.includes("\n");
                const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0;

                if (isSingleLine || isAtBeginning) {
                    if (chatInputHistory.length > 0) {
                        e.preventDefault();
                        if (chatHistoryIndex === -1) {
                            temporaryCurrentInput = input.value;
                            chatHistoryIndex = chatInputHistory.length - 1;
                        } else if (chatHistoryIndex > 0) {
                            chatHistoryIndex--;
                        }
                        input.value = chatInputHistory[chatHistoryIndex];
                        updateInputHeightAndFade(input);
                        input.setSelectionRange(input.value.length, input.value.length);
                    }
                }
            } else if (e.key === "ArrowDown") {
                const isSingleLine = !input.value.includes("\n");
                const isAtEnd = input.selectionStart === input.value.length;

                if (isSingleLine || isAtEnd) {
                    if (chatHistoryIndex !== -1) {
                        e.preventDefault();
                        if (chatHistoryIndex < chatInputHistory.length - 1) {
                            chatHistoryIndex++;
                            input.value = chatInputHistory[chatHistoryIndex];
                        } else {
                            chatHistoryIndex = -1;
                            input.value = temporaryCurrentInput;
                        }
                        updateInputHeightAndFade(input);
                        input.setSelectionRange(input.value.length, input.value.length);
                    }
                }
            }
        });

        input.addEventListener("input", function() {
            updateInputHeightAndFade(this);
            chatHistoryIndex = -1;
        });
    }

    if (fileInput) {
        fileInput.addEventListener("change", (e) => {
            if (e.target.files && e.target.files[0]) {
                handleFileSelected(e.target.files[0]);
            }
        });
    }

    let dragCounter = 0;

    window.addEventListener('dragenter', (e) => {
        e.preventDefault();
        dragCounter++;
        if (overlay) {
            overlay.classList.remove('hidden');
        }
    }, false);

    window.addEventListener('dragleave', (e) => {
        e.preventDefault();
        dragCounter--;
        if (dragCounter <= 0) {
            dragCounter = 0;
            if (overlay) {
                overlay.classList.add('hidden');
            }
        }
    }, false);

    window.addEventListener('dragover', (e) => {
        e.preventDefault();
    }, false);

    window.addEventListener('drop', (e) => {
        e.preventDefault();
        dragCounter = 0;
        if (overlay) {
            overlay.classList.add('hidden');
        }

        const dt = e.dataTransfer;
        if (dt && dt.files && dt.files[0]) {
            handleFileSelected(dt.files[0]);
        }
    }, false);
});

File: ./modules/web_api/static/js/chat/task_widget.js

/*
===============================================================================
FILE: modules/web_api/static/js/chat/task_widget.js
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
===============================================================================
*/

window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
window.currentTasksCache = window.currentTasksCache || [];

function getAuthHeaders() {
    const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
    const headers = { 'Content-Type': 'application/json' };
    if (token) {
        headers['Authorization'] = 'Bearer ' + token;
    }
    return headers;
}

window.sendChatAction = function(actionText) {
    const input = document.getElementById("user-input");
    if (input && typeof sendMessage === "function") {
        input.value = actionText;
        if (typeof setInputLocked === "function") setInputLocked(false);
        sendMessage();
    }
};

// ============================================================================
// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ
// ============================================================================
function renderSnapshotsCard(data) {
    if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return '';
    const queryDate = data.query_date || 'выбранную дату';
    const snapshots = data.snapshots;

    if (snapshots.length === 0) {
        return `
            <div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
                📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
            </div>
        `;
    }

    const rowsHtml = snapshots.map((s, idx) => {
        const snapId = s.snapshot_id || `ID-${idx}`;
        const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
        const count = s.record_count || 0;
        const isFinal = snapId.startsWith('Y');

        if (isFinal) {
            return `
                <div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
                    <div class="flex items-center gap-2.5 min-w-0">
                        <span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
                            <i class="fa-solid fa-lock text-[11px]"></i>
                        </span>
                        <span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
                            #${snapId}
                        </span>
                        <div class="flex items-center gap-3 text-xs text-slate-600">
                            <span class="flex items-center gap-1 font-semibold text-purple-900">
                                <i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
                            </span>
                            <span class="flex items-center gap-1 text-slate-500">
                                <i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
                            </span>
                        </div>
                    </div>
                    <span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
                        Итоговый Y-срез
                    </span>
                </div>
            `;
        }

        return `
            <div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
                <div class="flex items-center gap-2.5 min-w-0">
                    <input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)" 
                           class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
                    <span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
                        #${snapId}
                    </span>
                    <div class="flex items-center gap-3 text-xs text-slate-600">
                        <span class="flex items-center gap-1 font-semibold text-slate-800">
                            <i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
                        </span>
                        <span class="flex items-center gap-1 text-slate-500">
                            <i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
                        </span>
                    </div>
                </div>
                <button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')" 
                        class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition" 
                        title="Удалить срез">
                    <i class="fa-solid fa-trash-can text-xs"></i>
                </button>
            </div>
        `;
    }).join('');

    return `
        <div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
            <div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
                <div class="flex items-center gap-2">
                    <div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
                        <i class="fa-solid fa-camera text-xs"></i>
                    </div>
                    <div>
                        <span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
                        <span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
                    </div>
                </div>
                <div class="flex items-center gap-3">
                    <label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
                        <input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
                        <span>Выбрать все</span>
                    </label>
                    <span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
                        Срезов: ${snapshots.length}
                    </span>
                </div>
            </div>

            <div class="p-3 flex flex-col gap-2">
                ${rowsHtml}
            </div>

            <!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
            <div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
                <span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
                <button type="button" onclick="window.submitBulkDeleteSnapshots(this)" 
                        class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
                    <i class="fa-solid fa-trash-can"></i>
                    <span>Удалить выбранные</span>
                </button>
            </div>
        </div>
    `;
}

// ⭐️ Обработчики чекбоксов
window.toggleSelectAllSnapshots = function(masterCb) {
    const root = masterCb.closest('.snapshots-widget-root');
    if (!root) return;
    const checkboxes = root.querySelectorAll('.snapshot-item-checkbox');
    checkboxes.forEach(cb => cb.checked = masterCb.checked);
    window.syncBulkDeleteFooter(root);
};

window.updateSelectedSnapshots = function(itemCb) {
    const root = itemCb.closest('.snapshots-widget-root');
    if (!root) return;
    window.syncBulkDeleteFooter(root);
};

window.syncBulkDeleteFooter = function(root) {
    const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked');
    const footer = root.querySelector('.snapshot-bulk-actions-footer');
    const counter = root.querySelector('.bulk-selected-counter');
    const masterCb = root.querySelector('.select-all-snapshots-cb');
    const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox');

    if (masterCb) {
        masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
    }

    if (checkboxes.length > 0) {
        if (footer) footer.classList.remove('hidden');
        if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`;
    } else {
        if (footer) footer.classList.add('hidden');
    }
};

// Одиночная корзина в строке снапшота:
// onclick="window.sendChatAction('удали снапшот ${snapId}')"

// Кнопка пакетного удаления в подвале карточки:
window.submitBulkDeleteSnapshots = function(btnEl) {
    const root = btnEl.closest('.snapshots-widget-root');
    if (!root) return;
    const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value);
    if (selected.length === 0) return;
    window.sendChatAction(`удали снапшоты ${selected.join(', ')}`);
};

// ============================================================================
// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD)
// ============================================================================
function renderInteractiveTaskCard(tasks) {
    if (!tasks || !Array.isArray(tasks)) return '';
    window.currentTasksCache = tasks;

    const counts = {
        ALL: tasks.length,
        IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length,
        PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length,
        COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length
    };

    const currentFilter = window.activeTaskFilter || 'IN_PROGRESS';

    const filteredTasks = tasks.filter(t => {
        const s = (t.status || 'BACKLOG').toUpperCase();
        if (currentFilter === 'ALL') return true;
        if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS';
        if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED';
        if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE';
        return true;
    });

    const getPrioBadge = (prio) => {
        const p = (prio || 'MEDIUM').toUpperCase();
        if (p === 'HIGH' || p === 'CRITICAL') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
        if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
        return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
    };

    const getStatusBadge = (status) => {
        const s = (status || 'BACKLOG').toUpperCase();
        if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
        if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
        return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
    };

    const taskRows = filteredTasks.map(t => {
        const id = t.id;
        const title = t.title || 'Без названия';
        const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
        const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS';

        const actionBtn = isInProgress 
            ? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
            : (!isDone 
                ? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
                : '');

        return `
            <div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
                <div class="task-view-mode flex items-center justify-between gap-3">
                    <div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
                        <span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
                        <span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
                    </div>
                    <div class="flex items-center gap-1.5 shrink-0">
                        ${actionBtn}
                        <button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
                        <button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
                    </div>
                </div>

                <div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
                    ${getPrioBadge(t.priority)}
                    ${getStatusBadge(t.status)}
                    <span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
                    ${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
                    ${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
                </div>

                <!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
                <div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
                    <input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '&quot;')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
                    <div class="flex items-center gap-2 flex-wrap">
                        <input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
                        <select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
                            <option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
                            <option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
                            <option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
                            <option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
                        </select>
                        <button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
                        <button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
                    </div>
                </div>
            </div>
        `;
    }).join('');

    const emptyState = `
        <div class="p-8 text-center text-slate-400">
            <div class="text-3xl mb-2">📭</div>
            <div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
        </div>
    `;

    return `
        <div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
            <div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
                <div class="flex items-center gap-1.5 flex-wrap">
                    <button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
                        ⚙️ В работе (${counts.IN_PROGRESS})
                    </button>
                    <button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
                        📋 В планах (${counts.PLANNED})
                    </button>
                    <button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
                        ✓ Готово (${counts.COMPLETED})
                    </button>
                    <button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
                        Все (${counts.ALL})
                    </button>
                </div>
                
                <button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
                    ➕ Добавить задачу
                </button>
            </div>

            <div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
                <div class="text-xs font-bold text-indigo-900">Новая задача:</div>
                <input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
                <div class="flex items-center gap-2 flex-wrap">
                    <input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
                    <select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
                        <option value="LOW">☕ LOW (Низкий)</option>
                        <option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
                        <option value="HIGH">🔥 HIGH (Высокий)</option>
                        <option value="CRITICAL">🚨 CRITICAL (Критический)</option>
                    </select>
                    <select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
                        <option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
                        <option value="IN_PROGRESS">⚙️ Сразу в работу</option>
                    </select>
                    <button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
                    <button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
                </div>
            </div>

            <div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
                ${filteredTasks.length > 0 ? taskRows : emptyState}
            </div>
        </div>
    `;
}

window.switchTaskFilter = function(filterName, btnEl) {
    window.activeTaskFilter = filterName;
    const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
    if (root && window.currentTasksCache && window.currentTasksCache.length > 0) {
        root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache);
    }
};

window.toggleCreateTaskForm = function(btnEl) {
    const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
    if (!root) return;
    const form = root.querySelector('.new-task-creation-form');
    if (form) {
        form.classList.toggle('hidden');
        if (!form.classList.contains('hidden')) {
            const input = form.querySelector('.new-task-title');
            if (input) input.focus();
        }
    }
};

window.submitCreateTask = async function(btnEl) {
    const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
    if (!root) return;

    const titleInput = root.querySelector('.new-task-title');
    const dateInput = root.querySelector('.new-task-date');
    const prioInput = root.querySelector('.new-task-prio');
    const statusInput = root.querySelector('.new-task-status');

    if (!titleInput || !titleInput.value.trim()) {
        alert('Введите описание задачи');
        return;
    }

    try {
        const res = await fetch('/api/v1/tasks', {
            method: 'POST',
            headers: getAuthHeaders(),
            body: JSON.stringify({
                title: titleInput.value.trim(),
                due_date: dateInput ? (dateInput.value || null) : null,
                priority: prioInput ? prioInput.value : 'MEDIUM',
                status: statusInput ? statusInput.value : 'BACKLOG'
            })
        });

        if (res.ok) {
            window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
            window.sendChatAction('покажи задачи');
        } else {
            const err = await res.json();
            alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
        }
    } catch (e) {
        console.error('Ошибка создания задачи:', e);
        alert('Сетевая ошибка при создании задачи');
    }
};

window.openTaskInlineEditor = function(id) {
    const card = document.getElementById(`task-card-${id}`);
    if (!card) return;
    card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden'));
    const editor = document.getElementById(`task-editor-${id}`);
    if (editor) editor.classList.remove('hidden');
};

window.closeTaskInlineEditor = function(id) {
    const card = document.getElementById(`task-card-${id}`);
    if (!card) return;
    card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden'));
    const editor = document.getElementById(`task-editor-${id}`);
    if (editor) editor.classList.add('hidden');
};

window.saveTaskInlineEdit = async function(id) {
    const titleInput = document.getElementById(`task-edit-title-${id}`);
    const dateInput = document.getElementById(`task-edit-date-${id}`);
    const prioInput = document.getElementById(`task-edit-prio-${id}`);

    if (!titleInput || !titleInput.value.trim()) {
        alert('Описание задачи не может быть пустым');
        return;
    }

    try {
        const res = await fetch(`/api/v1/tasks/${id}`, {
            method: 'PATCH',
            headers: getAuthHeaders(),
            body: JSON.stringify({
                title: titleInput.value.trim(),
                due_date: dateInput ? (dateInput.value || null) : null,
                priority: prioInput ? prioInput.value : 'MEDIUM'
            })
        });

        if (res.ok) {
            window.sendChatAction('покажи задачи');
        } else {
            const err = await res.json();
            alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
        }
    } catch (e) {
        console.error('Ошибка сохранения задачи:', e);
        alert('Сетевая ошибка при обновлении задачи');
    }
};

File: ./modules/web_api/static/js/auth.js

/**
 * ===============================================================================
 * FILE: modules/web_api/static/js/auth.js
 * ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
 * ===============================================================================
 */

const AUTH_STORAGE_KEY = "scud_auth_token";
const USERNAME_STORAGE_KEY = "scud_username";
const FULLNAME_STORAGE_KEY = "scud_full_name";
const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
const USER_ID_STORAGE_KEY = "scud_user_id";

const AuthManager = {
    getToken() {
        return localStorage.getItem(AUTH_STORAGE_KEY) || "";
    },

    getUserId() {
        const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
        return uid ? parseInt(uid, 10) : 1;
    },

    getUsername() {
        return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
    },

    getFullName() {
        return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
    },

    isAdmin() {
        return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
    },

    isAuthenticated() {
        return Boolean(this.getToken());
    },

    setSession(token, username, fullName, isAdmin, userId = 1) {
        localStorage.setItem(AUTH_STORAGE_KEY, token);
        localStorage.setItem(USERNAME_STORAGE_KEY, username);
        localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username);
        localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin));
        localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
        localStorage.setItem("scud_api_auth_token", token);
        localStorage.setItem("auth_token", token);
    },

    getAuthHeaders() {
        const token = this.getToken();
        const headers = { "Content-Type": "application/json" };
        if (token) {
            headers["Authorization"] = `Bearer ${token}`;
        }
        return headers;
    },

    logout() {
        console.log("[Auth] Полный выход из системы...");
        localStorage.clear();
        sessionStorage.clear();

        document.cookie.split(";").forEach((cookie) => {
            const eqPos = cookie.indexOf("=");
            const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
            document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
        });

        window.location.href = "/";
    }
};

window.AuthManager = AuthManager;
window.logout = () => AuthManager.logout();

File: ./modules/web_api/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();
    }
});