diff --git a/api_code_snapshot.md b/api_code_snapshot.md
new file mode 100644
index 0000000..c980bec
--- /dev/null
+++ b/api_code_snapshot.md
@@ -0,0 +1,2956 @@
+# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api
+
+## File: `./init_db.py`
+```py
+import os
+import sqlite3
+
+DB_NAME = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
+
+def init_db():
+ os.makedirs(os.path.dirname(DB_NAME), exist_ok=True)
+ conn = sqlite3.connect(DB_NAME)
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS tasks (
+ task_id TEXT PRIMARY KEY,
+ module TEXT,
+ title TEXT,
+ status TEXT DEFAULT 'BACKLOG',
+ priority TEXT DEFAULT 'HIGH',
+ due_date TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ completed_at TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS chat_sessions (
+ session_id TEXT PRIMARY KEY,
+ history_json TEXT NOT NULL,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS system_prompts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL,
+ prompt_text TEXT NOT NULL,
+ is_active INTEGER DEFAULT 1,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # Добавление новой таблицы для сессионных состояний
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS session_states (
+ session_id TEXT PRIMARY KEY,
+ state_type TEXT NOT NULL,
+ pending_data TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ role TEXT NOT NULL, -- 'user', 'assistant', 'tool'
+ content TEXT NOT NULL, -- текст сообщения
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ conn.commit()
+ conn.close()
+ print(f"[✓] Единая база данных SQLite ({DB_NAME}) успешно инициализирована!")
+
+if __name__ == "__main__":
+ init_db()
+```
+
+## File: `./main.py`
+```py
+import json
+import sqlite3
+import logging
+import urllib.request
+import os
+from datetime import datetime, timedelta
+from typing import List, Dict, Any, Optional
+
+import jwt
+from passlib.context import CryptContext
+from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+from pydantic import BaseModel
+
+from llm.agent import process_chat_message
+from llm.db_tools import db_get_tasks, DB_PATH
+from llm.file_parser import extract_text_from_file
+
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[logging.StreamHandler()]
+)
+
+JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
+ALGORITHM = "HS256"
+OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
+MODEL_NAME = "qwen2.5:14b"
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+security = HTTPBearer()
+
+def get_db():
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
+ payload = {
+ "sub": str(user_id),
+ "username": username,
+ "is_admin": is_admin,
+ "exp": datetime.utcnow() + timedelta(days=30)
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
+
+def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
+ try:
+ token = credentials.credentials
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
+ user_id = int(payload.get("sub"))
+ username = payload.get("username")
+ is_admin = bool(payload.get("is_admin", False))
+ return {"id": user_id, "username": username, "is_admin": is_admin}
+ except Exception as e:
+ logging.warning(f"Auth error: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Недействительный или просроченный токен авторизации",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+app = FastAPI(title="SCUD Orion AI Context API")
+
+if os.path.exists("static"):
+ app.mount("/static", StaticFiles(directory="static"), name="static")
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request, exc):
+ logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
+ return JSONResponse(
+ status_code=422,
+ content={"detail": exc.errors(), "body": str(exc)}
+ )
+
+class AuthRequest(BaseModel):
+ username: str
+ password: str
+
+class CreateUserRequest(BaseModel):
+ username: str
+ password: str
+ full_name: Optional[str] = None
+ is_admin: Optional[bool] = False
+
+class ChangePasswordRequest(BaseModel):
+ old_password: str
+ new_password: str
+
+# === API МАРШРУТЫ ===
+
+@app.get("/")
+def read_root():
+ return FileResponse("static/index.html")
+
+@app.get("/favicon.ico")
+async def favicon():
+ file_path = os.path.join("static", "favicon.ico")
+ if os.path.exists(file_path):
+ return FileResponse(file_path)
+ raise HTTPException(status_code=404)
+
+
+@app.post("/api/v1/auth/login")
+def login(req: AuthRequest):
+ username = req.username.strip().lower()
+ logging.info(f"===> Попытка входа для пользователя: {username}")
+
+ conn = get_db()
+ cursor = conn.cursor()
+ cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
+ user = cursor.fetchone()
+ conn.close()
+
+ if not user:
+ logging.warning(f"===> Ошибка: Пользователь {username} не найден")
+ raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
+
+ if not pwd_context.verify(req.password, user["password_hash"]):
+ logging.warning(f"===> Ошибка: Неверный пароль для {username}")
+ raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
+
+ is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
+ token = create_access_token(user["id"], user["username"], is_admin)
+ logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
+
+ return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
+
+@app.post("/api/v1/auth/change-password")
+def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
+ if not req.new_password or len(req.new_password) < 4:
+ raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
+
+ conn = get_db()
+ cursor = conn.cursor()
+ cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
+ user = cursor.fetchone()
+
+ if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
+ conn.close()
+ raise HTTPException(status_code=400, detail="Неверный старый пароль")
+
+ new_hash = pwd_context.hash(req.new_password)
+ cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
+ conn.commit()
+ conn.close()
+
+ logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}")
+ return {"status": "success", "message": "Пароль успешно изменен"}
+
+@app.get("/api/v1/admin/users")
+def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
+ if not current_user["is_admin"]:
+ raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
+
+ conn = get_db()
+ cursor = conn.cursor()
+ cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
+ users = [dict(r) for r in cursor.fetchall()]
+ conn.close()
+ return users
+
+@app.post("/api/v1/admin/users")
+def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
+ if not current_user["is_admin"]:
+ raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
+
+ username = req.username.strip().lower()
+ if not username or not req.password:
+ raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
+
+ conn = get_db()
+ cursor = conn.cursor()
+ cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
+ if cursor.fetchone():
+ conn.close()
+ raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
+
+ pwd_hash = pwd_context.hash(req.password)
+ full_name = req.full_name.strip() if req.full_name else None
+ is_admin = 1 if req.is_admin else 0
+
+ cursor.execute(
+ "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
+ (username, pwd_hash, full_name, is_admin)
+ )
+ conn.commit()
+ conn.close()
+
+ logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
+ return {"status": "success", "message": f"Пользователь {username} создан"}
+
+@app.delete("/api/v1/admin/users/{user_id}")
+def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
+ if not current_user["is_admin"]:
+ raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
+
+ if user_id == current_user["id"]:
+ raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
+
+ conn = get_db()
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
+ conn.commit()
+ conn.close()
+
+ logging.info(f"Удален пользователь ID: {user_id}")
+ return {"status": "success", "message": "Пользователь удален"}
+
+@app.get("/api/v1/tasks")
+def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
+ return db_get_tasks(user_id=user["id"])
+
+# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ
+@app.post("/api/v1/chat")
+async def chat_endpoint(
+ session_id: str = Form("web_session_main"),
+ message: str = Form(""),
+ file: Optional[UploadFile] = File(default=None),
+ current_user: dict = Depends(get_current_user)
+):
+ parsed_file = {"text": "", "image_b64": None}
+ if file and file.filename:
+ file_bytes = await file.read()
+ parsed_file = extract_text_from_file(file_bytes, file.filename)
+
+ reply, history = process_chat_message(
+ user_id=current_user["id"],
+ user_message=message,
+ file_context=parsed_file["text"],
+ image_b64=parsed_file["image_b64"],
+ session_id=session_id
+ )
+ return {"reply": reply, "history": history}
+
+@app.post("/api/v1/chat/guest")
+async def guest_chat_endpoint(
+ session_id: str = Form("web_session_main"),
+ message: str = Form(""),
+ file: Optional[UploadFile] = File(default=None)
+):
+ parsed_file = {"text": "", "image_b64": None}
+ if file and file.filename:
+ file_bytes = await file.read()
+ parsed_file = extract_text_from_file(file_bytes, file.filename)
+
+ reply, history = process_chat_message(
+ user_id=0,
+ user_message=message,
+ file_context=parsed_file["text"],
+ image_b64=parsed_file["image_b64"],
+ session_id=session_id
+ )
+ return {"reply": reply, "history": history}
+
+# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ ===
+
+@app.get("/{file_path:path}")
+def serve_static_fallback(file_path: str):
+ clean_path = file_path.lstrip("/")
+
+ target = os.path.join("static", clean_path)
+ if os.path.isfile(target):
+ return FileResponse(target)
+
+ filename = os.path.basename(clean_path)
+ target_js = os.path.join("static/js", filename)
+ if filename.endswith(".js") and os.path.isfile(target_js):
+ return FileResponse(target_js, media_type="application/javascript")
+
+ target_css = os.path.join("static/css", filename)
+ if filename.endswith(".css") and os.path.isfile(target_css):
+ return FileResponse(target_css, media_type="text/css")
+
+ raise HTTPException(status_code=404, detail="File not found")
+
+```
+
+## File: `./update_index.sh`
+```sh
+cd /home/puh/scud_context_api
+
+# 1. Создаем необходимые директории для CSS и JS
+mkdir -p static/css static/js
+
+# 2. Выносим CSS в static/css/styles.css
+cat << 'EOF' > static/css/styles.css
+/* Плавное (градиентное) исчезновение текста сверху при скролле */
+.fade-scroll-top {
+ mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
+}
+
+/* Скрытие стандартного скроллбара */
+.no-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+.no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+EOF
+
+# 3. Выносим JavaScript в static/js/app.js
+cat << 'EOF' > static/js/app.js
+const API_TOKEN = "scud_secret_token_2026";
+const SESSION_ID = "web_session_main";
+const STORAGE_KEY = 'scud_chat_input_history';
+
+let currentFilter = 'ALL';
+let allTasks = [];
+
+// === ИСТОРИЯ КОМАНД (LOCALSTORAGE + СТРЕЛКИ ВВЕРХ/ВНИЗ) ===
+let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
+let historyIndex = -1;
+
+document.addEventListener('DOMContentLoaded', () => {
+ const userInputEl = document.getElementById('user-input');
+
+ if (userInputEl) {
+ // Динамическое расширение высоты поля до 4 строк (~96px)
+ userInputEl.addEventListener('input', function() {
+ this.style.height = 'auto';
+ this.style.height = Math.min(this.scrollHeight, 96) + 'px';
+ });
+
+ userInputEl.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ document.getElementById('chat-form').requestSubmit();
+ }
+ else if (e.key === 'ArrowUp') {
+ if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
+ e.preventDefault();
+ if (historyIndex === -1) {
+ this.dataset.draft = this.value;
+ }
+ historyIndex++;
+ this.value = inputHistory[inputHistory.length - 1 - historyIndex];
+ this.dispatchEvent(new Event('input'));
+ setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
+ }
+ }
+ else if (e.key === 'ArrowDown') {
+ if (historyIndex !== -1) {
+ e.preventDefault();
+ if (historyIndex > 0) {
+ historyIndex--;
+ this.value = inputHistory[inputHistory.length - 1 - historyIndex];
+ } else {
+ historyIndex = -1;
+ this.value = this.dataset.draft || '';
+ }
+ this.dispatchEvent(new Event('input'));
+ setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
+ }
+ }
+ });
+ }
+
+ loadTasks();
+});
+
+function toggleDrawer() {
+ const drawer = document.getElementById('task-drawer');
+ const backdrop = document.getElementById('drawer-backdrop');
+ const isHidden = drawer.classList.contains('translate-x-full');
+ if (isHidden) {
+ drawer.classList.remove('translate-x-full');
+ backdrop.classList.remove('hidden');
+ } else {
+ drawer.classList.add('translate-x-full');
+ backdrop.classList.add('hidden');
+ }
+}
+
+function setFilter(status) {
+ currentFilter = status;
+ ['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
+ const btn = document.getElementById(`filter-${f}`);
+ if (f === status) {
+ btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold";
+ } else {
+ btn.className = "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent hover:text-slate-700";
+ }
+ });
+ renderTasks();
+}
+
+async function loadTasks() {
+ try {
+ const res = await fetch('/api/v1/tasks', {
+ headers: { 'Authorization': `Bearer ${API_TOKEN}` }
+ });
+ allTasks = await res.json();
+ document.getElementById('task-count-badge').innerText = allTasks.length;
+ renderTasks();
+ } catch (err) {
+ document.getElementById('tasks-container').innerHTML = `
Ошибка загрузки задач
`;
+ }
+}
+
+function renderTasks() {
+ const container = document.getElementById('tasks-container');
+ const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);
+
+ if (filtered.length === 0) {
+ container.innerHTML = `Нет задач с выбранным фильтром
`;
+ return;
+ }
+
+ container.innerHTML = filtered.map(t => {
+ let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
+ let cardBg = 'bg-white';
+ if (t.status === 'COMPLETED') {
+ statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
+ cardBg = 'bg-emerald-50/20';
+ } else if (t.status === 'IN_PROGRESS') {
+ statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
+ cardBg = 'bg-amber-50/20 border-amber-200';
+ }
+
+ let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
+ if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';
+
+ let dueDateHtml = '';
+ if (t.due_date) {
+ dueDateHtml = `
+
+
+ Срок: ${t.due_date}
+
+ `;
+ }
+
+ return `
+
+
+
+ ${t.task_id}
+ ${t.priority || 'HIGH'}
+
+
${t.status}
+
+
+
${t.title}
+
+
+
+ ${t.module}
+
+
+ ${dueDateHtml}
+
+ `;
+ }).join('');
+}
+
+async function sendMessage(e) {
+ e.preventDefault();
+ const input = document.getElementById('user-input');
+ const chatWindow = document.getElementById('chat-window');
+ const sendBtn = document.getElementById('send-btn');
+ const text = input.value.trim();
+
+ if (!text) return;
+
+ if (inputHistory[inputHistory.length - 1] !== text) {
+ inputHistory.push(text);
+ if (inputHistory.length > 50) inputHistory.shift();
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
+ }
+ historyIndex = -1;
+
+ chatWindow.innerHTML += `
+
+ `;
+ input.value = '';
+ input.style.height = 'auto';
+ chatWindow.scrollTop = chatWindow.scrollHeight;
+
+ sendBtn.disabled = true;
+ sendBtn.classList.add('opacity-50');
+
+ try {
+ const res = await fetch('/api/v1/chat', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${API_TOKEN}`
+ },
+ body: JSON.stringify({ session_id: SESSION_ID, message: text })
+ });
+
+ const data = await res.json();
+
+ chatWindow.innerHTML += `
+
+
ИИ-Ассистент
+
${data.reply}
+
+ `;
+ chatWindow.scrollTop = chatWindow.scrollHeight;
+ loadTasks();
+
+ } catch (err) {
+ chatWindow.innerHTML += `
+
+ Ошибка связи с сервером API.
+
+ `;
+ } finally {
+ sendBtn.disabled = false;
+ sendBtn.classList.remove('opacity-50');
+ }
+}
+EOF
+
+# 4. Обновляем чистый static/index.html
+cat << 'EOF' > static/index.html
+
+
+
+
+
+ SCUD Orion AI — Context Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ИИ-Ассистент
+
+
+ Привет! Я подключен к вашей базе задач и системным промптам. Вы можете писать команды прямо в чат или управлять задачами через панель справа.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+EOF
+
+# 5. Фиксируем изменения фронтенда в Git
+git add static/
+git commit -m "refactor: декомпозиция фронтенда index.html (вынос static/css/styles.css и static/js/app.js)"
+git push origin feature/llm-refactoring
+
+```
+
+## File: `./scripts/diagnostics/inspect_db.py`
+```py
+import os
+import sqlite3
+
+# Автопоиск файла базы данных в проекте
+db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
+
+print("=" * 80)
+print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
+print("=" * 80)
+
+if not os.path.exists(db_path):
+ print(f"❌ Файл базы данных {db_path} не найден!")
+ exit(1)
+
+conn = sqlite3.connect(db_path)
+cursor = conn.cursor()
+
+# 1. Список всех таблиц и колонок
+cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+tables = [t[0] for t in cursor.fetchall()]
+
+print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
+print("-" * 80)
+for t_name in tables:
+ cursor.execute(f"PRAGMA table_info({t_name})")
+ cols = [c[1] for c in cursor.fetchall()]
+
+ cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
+ count = cursor.fetchone()[0]
+
+ print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
+
+# 2. Просмотр правил Базы Знаний
+if 'ai_knowledge_base' in tables:
+ print("\n" + "=" * 80)
+ print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
+ print("=" * 80)
+ cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
+ rules = cursor.fetchall()
+ if not rules:
+ print("Таблица ai_knowledge_base пуста.")
+ else:
+ for r_id, r_text, r_author in rules:
+ print(f" {r_id}. [{r_author}] {r_text}\n")
+
+conn.close()
+print("=" * 80)
+```
+
+## File: `./scripts/diagnostics/inspect_files.py`
+```py
+import os
+
+print("=" * 80)
+print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
+print("=" * 80)
+
+total_files = 0
+total_size = 0
+
+for root, dirs, files in os.walk('.'):
+ # Исключаем служебные каталоги
+ dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
+
+ for f in files:
+ p = os.path.join(root, f)
+ size = os.path.getsize(p)
+ total_files += 1
+ total_size += size
+ print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
+
+print("-" * 80)
+print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
+print("=" * 80)
+```
+
+## File: `./scripts/diagnostics/make_code_snapshot.py`
+```py
+import os
+
+OUTPUT_SNAPSHOT = "api_code_snapshot.md"
+
+# Расширения файлов для включения в снимок
+ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
+EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
+EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
+
+print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
+
+with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
+ out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
+
+ for root, dirs, files in os.walk('.'):
+ dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
+
+ for file in sorted(files):
+ ext = os.path.splitext(file)[1].lower()
+ if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
+ filepath = os.path.join(root, file)
+ out.write(f"## File: `{filepath}`\n")
+ out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
+ try:
+ with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
+ out.write(f.read())
+ except Exception as e:
+ out.write(f"// Ошибка чтения файла: {e}\n")
+ out.write("\n```\n\n")
+
+print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+```
+
+## File: `./static/index.html`
+```html
+
+
+
+
+
+ SCUD Orion AI — Context Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SCUD Orion AI
+
Авторизация в системе
+
+
+
+
+
+ Имя пользователя
+
+
+
+
+ Пароль
+
+
+
+
+
+
+
+ Войти в систему
+
+
+
+
+
+
+
+ Войти как гость (Локальный ИИ)
+
+
+
+
+
+
+
+
+
+ Смена пароля
+
+
+
+
+
+
+
+
+ Старый пароль
+
+
+
+ Новый пароль
+
+
+
+ Повторите новый пароль
+
+
+
+
+
+
+
+ Сохранить новый пароль
+
+
+
+
+
+
+
+
+
+
+ Управление пользователями
+
+
+
+
+
+
+
+
+
+
Загрузка пользователей...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Перетащите файл сюда
+
Поддерживаются PDF, изображения, таблицы, TXT
+
+
+
+
+ ИИ-Ассистент
+
+
+ Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
+
+
+
+
+
+
+
+
+ file.pdf
+ (0 KB)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Отправить
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## File: `./static/js/app.js`
+```js
+const AUTH_TOKEN_KEY = "scud_api_auth_token";
+const SESSION_ID = "web_session_main";
+const STORAGE_KEY = "scud_chat_input_history";
+
+let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
+let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
+let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
+let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
+
+let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
+let historyIndex = -1;
+
+document.addEventListener("DOMContentLoaded", () => {
+ const userInputEl = document.getElementById("user-input");
+
+ if (userInputEl) {
+ userInputEl.addEventListener("input", function() {
+ this.style.height = "24px";
+ const newHeight = Math.min(this.scrollHeight, 120);
+ this.style.height = newHeight + "px";
+ });
+ }
+
+ if (IS_GUEST) {
+ hideAuthModal();
+ updateUIState();
+ } else if (API_TOKEN) {
+ hideAuthModal();
+ updateUIState();
+ if (typeof loadTasks === "function") {
+ loadTasks();
+ }
+ } else {
+ showAuthModal();
+ }
+});
+```
+
+## File: `./static/js/auth.js`
+```js
+function showAuthModal() {
+ const el = document.getElementById("auth-modal");
+ if (el) el.classList.remove("hidden");
+}
+
+function hideAuthModal() {
+ const el = document.getElementById("auth-modal");
+ if (el) el.classList.add("hidden");
+}
+
+async function handleLogin(e) {
+ if (e && e.preventDefault) e.preventDefault();
+
+ const usernameInput = document.getElementById("auth-username-input");
+ const passwordInput = document.getElementById("auth-password-input");
+ const errorEl = document.getElementById("auth-error");
+
+ if (!usernameInput || !passwordInput) return;
+
+ const username = usernameInput.value.trim();
+ const password = passwordInput.value;
+
+ if (!username || !password) return;
+
+ if (errorEl) errorEl.classList.add("hidden");
+
+ try {
+ const res = await fetch("/api/v1/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password })
+ });
+
+ const data = await res.json();
+
+ if (res.status === 200) {
+ // Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
+ API_TOKEN = data.token;
+ CURRENT_USERNAME = data.username;
+ IS_ADMIN = data.is_admin;
+ IS_GUEST = false;
+
+ localStorage.setItem("scud_api_auth_token", data.token);
+ localStorage.setItem("scud_username", data.username);
+ localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
+ localStorage.removeItem("scud_is_guest");
+
+ hideAuthModal();
+ updateUIState();
+
+ if (typeof loadTasks === 'function') {
+ loadTasks();
+ }
+ } else {
+ if (errorEl) {
+ errorEl.innerText = data.detail || "Ошибка авторизации";
+ errorEl.classList.remove("hidden");
+ }
+ }
+ } catch (err) {
+ console.error("[Auth Error]", err);
+ if (errorEl) {
+ errorEl.innerText = "Ошибка соединения с сервером";
+ errorEl.classList.remove("hidden");
+ }
+ }
+}
+
+function enableGuestMode() {
+ IS_GUEST = true;
+ API_TOKEN = "";
+ CURRENT_USERNAME = "Гость";
+ IS_ADMIN = false;
+ localStorage.setItem("scud_is_guest", "true");
+ hideAuthModal();
+ updateUIState();
+}
+
+function logout() {
+ localStorage.removeItem("scud_api_auth_token");
+ localStorage.removeItem("scud_username");
+ localStorage.removeItem("scud_is_admin");
+ localStorage.removeItem("scud_is_guest");
+ API_TOKEN = "";
+ CURRENT_USERNAME = "";
+ IS_ADMIN = false;
+ IS_GUEST = false;
+ showAuthModal();
+}
+
+function updateUIState() {
+ const tasksBtn = document.getElementById("tasks-drawer-btn");
+ const adminBtn = document.getElementById("admin-users-btn");
+ const changePwdBtn = document.getElementById("change-pwd-btn");
+ const guestBadge = document.getElementById("guest-badge");
+ const usernameBadge = document.getElementById("username-badge");
+
+ if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
+ if (tasksBtn) tasksBtn.classList.add("hidden");
+ if (adminBtn) adminBtn.classList.add("hidden");
+ if (changePwdBtn) changePwdBtn.classList.add("hidden");
+ if (guestBadge) guestBadge.classList.remove("hidden");
+ if (usernameBadge) usernameBadge.classList.add("hidden");
+ } else {
+ if (tasksBtn) tasksBtn.classList.remove("hidden");
+ if (changePwdBtn) changePwdBtn.classList.remove("hidden");
+ if (guestBadge) guestBadge.classList.add("hidden");
+
+ if (usernameBadge) {
+ usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
+ usernameBadge.classList.remove("hidden");
+ }
+
+ if (adminBtn) {
+ const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
+ if (isAdminUser) {
+ adminBtn.classList.remove("hidden");
+ } else {
+ adminBtn.classList.add("hidden");
+ }
+ }
+ }
+}
+
+function openChangePasswordModal() {
+ const el = document.getElementById("change-pwd-modal");
+ if (el) el.classList.remove("hidden");
+}
+
+function closeChangePasswordModal() {
+ const el = document.getElementById("change-pwd-modal");
+ if (el) el.classList.add("hidden");
+
+ const err = document.getElementById("pwd-error");
+ const succ = document.getElementById("pwd-success");
+ if (err) err.classList.add("hidden");
+ if (succ) succ.classList.add("hidden");
+
+ document.getElementById("old-pwd-input").value = "";
+ document.getElementById("new-pwd-input").value = "";
+ const confirmInput = document.getElementById("confirm-pwd-input");
+ if (confirmInput) confirmInput.value = "";
+}
+
+async function handleChangePassword(e) {
+ if (e && e.preventDefault) e.preventDefault();
+ const old_password = document.getElementById("old-pwd-input").value;
+ const new_password = document.getElementById("new-pwd-input").value;
+ const confirmInput = document.getElementById("confirm-pwd-input");
+ const confirm_password = confirmInput ? confirmInput.value : new_password;
+ const errorEl = document.getElementById("pwd-error");
+ const successEl = document.getElementById("pwd-success");
+
+ if (errorEl) errorEl.classList.add("hidden");
+ if (successEl) successEl.classList.add("hidden");
+
+ if (new_password !== confirm_password) {
+ if (errorEl) {
+ errorEl.innerText = "Новые пароли не совпадают";
+ errorEl.classList.remove("hidden");
+ }
+ return;
+ }
+
+ try {
+ const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
+ const res = await fetch("/api/v1/auth/change-password", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + token
+ },
+ body: JSON.stringify({ old_password, new_password })
+ });
+
+ const data = await res.json();
+
+ if (res.status === 200) {
+ if (successEl) {
+ successEl.innerText = "Пароль успешно изменен!";
+ successEl.classList.remove("hidden");
+ }
+ setTimeout(closeChangePasswordModal, 1500);
+ } else {
+ if (errorEl) {
+ errorEl.innerText = data.detail || "Ошибка при смене пароля";
+ errorEl.classList.remove("hidden");
+ }
+ }
+ } catch (err) {
+ if (errorEl) {
+ errorEl.innerText = "Ошибка соединения с сервером";
+ errorEl.classList.remove("hidden");
+ }
+ }
+}
+
+function openAdminModal() {
+ const el = document.getElementById("admin-modal");
+ if (el) el.classList.remove("hidden");
+ loadUsersList();
+}
+
+function closeAdminModal() {
+ const el = document.getElementById("admin-modal");
+ if (el) el.classList.add("hidden");
+}
+
+async function loadUsersList() {
+ const listEl = document.getElementById("admin-users-list");
+ if (!listEl) return;
+ listEl.innerHTML = 'Загрузка пользователей...
';
+
+ try {
+ const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
+ const res = await fetch("/api/v1/admin/users", {
+ headers: { "Authorization": "Bearer " + token }
+ });
+ const users = await res.json();
+
+ if (res.status === 200) {
+ listEl.innerHTML = users.map(u => {
+ const adminTag = u.is_admin ? 'ADMIN ' : 'USER ';
+ const fullNameHtml = u.full_name ? `${u.full_name}
` : '';
+ const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
+ const deleteBtn = u.username !== CURRENT_USERNAME ? ` ` : 'Вы ';
+
+ return `
+
+
+
+ ${u.username}
+ ${adminTag}
+
+ ${fullNameHtml}
+
Создан: ${dateStr}
+
+ ${deleteBtn}
+
+ `;
+ }).join("");
+ } else {
+ listEl.innerHTML = `${users.detail}
`;
+ }
+ } catch (err) {
+ listEl.innerHTML = 'Ошибка загрузки пользователей
';
+ }
+}
+
+async function handleCreateUser(e) {
+ if (e && e.preventDefault) e.preventDefault();
+ const username = document.getElementById("new-user-name").value.trim();
+ const password = document.getElementById("new-user-pwd").value;
+ const fullNameInput = document.getElementById("new-user-fullname");
+ const full_name = fullNameInput ? fullNameInput.value.trim() : "";
+ const adminCheckbox = document.getElementById("new-user-is-admin");
+ const is_admin = adminCheckbox ? adminCheckbox.checked : false;
+ const msgEl = document.getElementById("admin-msg");
+
+ if (msgEl) msgEl.classList.add("hidden");
+
+ try {
+ const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
+ const res = await fetch("/api/v1/admin/users", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + token
+ },
+ body: JSON.stringify({ username, password, full_name, is_admin })
+ });
+
+ const data = await res.json();
+
+ if (res.status === 200) {
+ document.getElementById("new-user-name").value = "";
+ document.getElementById("new-user-pwd").value = "";
+ if (fullNameInput) fullNameInput.value = "";
+ if (adminCheckbox) adminCheckbox.checked = false;
+ loadUsersList();
+ } else {
+ if (msgEl) {
+ msgEl.innerText = data.detail || "Ошибка";
+ msgEl.classList.remove("hidden");
+ }
+ }
+ } catch (err) {
+ if (msgEl) {
+ msgEl.innerText = "Ошибка связи с сервером";
+ msgEl.classList.remove("hidden");
+ }
+ }
+}
+
+async function deleteUser(userId, username) {
+ if (!confirm("Удалить пользователя " + username + "?")) return;
+
+ try {
+ const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
+ await fetch("/api/v1/admin/users/" + userId, {
+ method: "DELETE",
+ headers: { "Authorization": "Bearer " + token }
+ });
+ loadUsersList();
+ } catch (err) {
+ alert("Ошибка при удалении");
+ }
+}
+```
+
+## File: `./static/js/chat.js`
+```js
+// Вспомогательная функция для автоматического изменения высоты текстового поля (1-3 строки)
+function updateInputHeight(el) {
+ if (!el) return;
+ el.style.height = "24px";
+ const newHeight = Math.min(el.scrollHeight, 120);
+ el.style.height = newHeight + "px";
+}
+
+let selectedFile = null;
+
+function handleFileSelect(e) {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ if (file.size > 15 * 1024 * 1024) {
+ alert("Файл слишком большой. Максимальный размер: 15 МБ");
+ e.target.value = "";
+ return;
+ }
+
+ selectedFile = file;
+ const fileNameEl = document.getElementById("file-name-display");
+ const fileSizeEl = document.getElementById("file-size-display");
+ const previewContainer = document.getElementById("file-preview-container");
+
+ if (fileNameEl) fileNameEl.innerText = file.name;
+ if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
+ if (previewContainer) previewContainer.classList.remove("hidden");
+}
+
+function clearAttachedFile() {
+ selectedFile = null;
+ const fileInput = document.getElementById("file-input");
+ const previewContainer = document.getElementById("file-preview-container");
+ if (fileInput) fileInput.value = "";
+ if (previewContainer) previewContainer.classList.add("hidden");
+}
+
+async function sendMessage(e) {
+ if (e && e.preventDefault) e.preventDefault();
+
+ const input = document.getElementById("user-input");
+ const chatWindow = document.getElementById("chat-window");
+ const sendBtn = document.getElementById("send-btn");
+
+ if (!input || !chatWindow) return;
+ const text = input.value.trim();
+
+ if (!text && !selectedFile) return;
+
+ let userDisplayHtml = escapeHtml(text);
+ if (selectedFile) {
+ userDisplayHtml = `
+ ${escapeHtml(selectedFile.name)}
+
` + userDisplayHtml;
+ }
+
+ const userMsgHtml = `
+
+
+ ${userDisplayHtml}
+
+
+ `;
+ chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
+
+ input.value = "";
+ updateInputHeight(input);
+ chatWindow.scrollTop = chatWindow.scrollHeight;
+
+ if (sendBtn) {
+ sendBtn.disabled = true;
+ sendBtn.classList.add("opacity-50");
+ }
+
+ const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
+ const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
+
+ const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
+
+ const formData = new FormData();
+ formData.append("session_id", "web_session_main");
+ formData.append("message", text || "Проанализируй прикрепленный файл");
+
+ if (selectedFile instanceof File) {
+ formData.append("file", selectedFile, selectedFile.name);
+ }
+
+ const headers = {};
+ if (!isGuest && token) {
+ headers["Authorization"] = "Bearer " + token;
+ }
+
+ try {
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: headers,
+ body: formData
+ });
+
+ if (res.status === 401 && !isGuest) {
+ if (typeof logout === 'function') logout();
+ return;
+ }
+
+ const data = await res.json();
+ const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
+ const replyText = data.reply || "Пустой ответ от нейросети";
+
+ const botMsgHtml = `
+
+
+ ${assistantTitle}
+
+
${escapeHtml(replyText)}
+
+ `;
+ chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
+ chatWindow.scrollTop = chatWindow.scrollHeight;
+
+ clearAttachedFile();
+
+ if (!isGuest && typeof loadTasks === 'function') {
+ loadTasks();
+ }
+
+ } catch (err) {
+ console.error("[Chat Error]", err);
+ const errorHtml = `
+
+ Ошибка связи с сервером.
+
+ `;
+ chatWindow.insertAdjacentHTML("beforeend", errorHtml);
+ chatWindow.scrollTop = chatWindow.scrollHeight;
+ } finally {
+ if (sendBtn) {
+ sendBtn.disabled = false;
+ sendBtn.classList.remove("opacity-50");
+ }
+ }
+}
+
+function escapeHtml(text) {
+ if (!text) return "";
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ const input = document.getElementById("user-input");
+ const dropZone = document.getElementById("chat-window")?.parentElement;
+ const dropOverlay = document.getElementById("drop-overlay");
+
+ // --- 1. УМНАЯ НАВИГАЦИЯ СТРЕЛКАМИ В МНОГОСТРОЧНОМ ТЕКСТЕ ---
+ if (input) {
+ let historyIndex = -1;
+ let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
+
+ input.addEventListener("keydown", (e) => {
+ // Отправка по Enter без Shift
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (text) {
+ if (localHistory.length === 0 || localHistory[0] !== text) {
+ localHistory.unshift(text);
+ if (localHistory.length > 50) localHistory.pop();
+ localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
+ }
+ historyIndex = -1;
+ }
+ sendMessage(e);
+ updateInputHeight(input);
+ return;
+ }
+
+ // Стрелка ВВЕРХ
+ if (e.key === "ArrowUp") {
+ const textBeforeCursor = input.value.substring(0, input.selectionStart);
+ const isFirstLine = !textBeforeCursor.includes("\n");
+
+ // Переключаем историю ТОЛЬКО когда курсор на 1-й строке И уперся в самое начало (позиция 0)
+ if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
+ if (historyIndex < localHistory.length - 1) {
+ e.preventDefault();
+ if (historyIndex === -1) {
+ input.dataset.draft = input.value;
+ }
+ historyIndex++;
+ input.value = localHistory[historyIndex];
+ updateInputHeight(input);
+ input.setSelectionRange(input.value.length, input.value.length);
+ }
+ }
+ }
+
+ // Стрелка ВНИЗ
+ if (e.key === "ArrowDown") {
+ const textAfterCursor = input.value.substring(input.selectionEnd);
+ const isLastLine = !textAfterCursor.includes("\n");
+
+ // Переключаем историю ТОЛЬКО когда курсор на последней строке И уперся в самый конец
+ if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
+ e.preventDefault();
+ if (historyIndex > 0) {
+ historyIndex--;
+ input.value = localHistory[historyIndex];
+ } else {
+ historyIndex = -1;
+ input.value = input.dataset.draft || "";
+ }
+ updateInputHeight(input);
+ input.setSelectionRange(input.value.length, input.value.length);
+ }
+ }
+ });
+ }
+
+ // --- 2. ОБРАБОТКА DRAG-AND-DROP ФАЙЛОВ ---
+ if (dropZone && dropOverlay) {
+ ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
+ dropZone.addEventListener(eventName, (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, false);
+ });
+
+ ["dragenter", "dragover"].forEach(eventName => {
+ dropZone.addEventListener(eventName, () => {
+ dropOverlay.classList.remove("hidden");
+ dropOverlay.classList.add("flex");
+ }, false);
+ });
+
+ ["dragleave", "drop"].forEach(eventName => {
+ dropZone.addEventListener(eventName, (e) => {
+ if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
+ dropOverlay.classList.add("hidden");
+ dropOverlay.classList.remove("flex");
+ }
+ }, false);
+ });
+
+ dropZone.addEventListener("drop", (e) => {
+ const dt = e.dataTransfer;
+ const files = dt.files;
+
+ if (files && files.length > 0) {
+ const file = files[0];
+ handleFileSelect({ target: { files: [file] } });
+
+ const fileInput = document.getElementById("file-input");
+ if (fileInput) {
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ fileInput.files = dataTransfer.files;
+ }
+ }
+ }, false);
+ }
+});
+```
+
+## File: `./static/js/tasks.js`
+```js
+let currentFilter = 'ALL';
+let allTasks = [];
+
+function toggleDrawer() {
+ if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
+ const drawer = document.getElementById("task-drawer");
+ const backdrop = document.getElementById("drawer-backdrop");
+ if (!drawer) return;
+
+ const isHidden = drawer.classList.contains("translate-x-full");
+ if (isHidden) {
+ drawer.classList.remove("translate-x-full");
+ if (backdrop) backdrop.classList.remove("hidden");
+ loadTasks();
+ } else {
+ drawer.classList.add("translate-x-full");
+ if (backdrop) backdrop.classList.add("hidden");
+ }
+}
+
+function setFilter(status) {
+ currentFilter = status;
+ ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
+ const btn = document.getElementById(`filter-${f}`);
+ if (btn) {
+ btn.className = (f === status)
+ ? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
+ : "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
+ }
+ });
+ renderTasks();
+}
+
+async function loadTasks() {
+ const badge = document.getElementById("task-count-badge");
+ const container = document.getElementById("tasks-container");
+
+ const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
+ const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
+
+ if (isGuest || !token) {
+ if (badge) badge.innerText = "0";
+ return;
+ }
+
+ try {
+ const res = await fetch("/api/v1/tasks", {
+ headers: {
+ "Authorization": "Bearer " + token,
+ "Content-Type": "application/json"
+ }
+ });
+
+ if (res.status === 401) {
+ if (typeof logout === 'function') logout();
+ return;
+ }
+
+ if (!res.ok) {
+ throw new Error(`HTTP error! status: ${res.status}`);
+ }
+
+ const data = await res.json();
+
+ // Гибкое определение структуры данных (массив или объект с ключом tasks)
+ if (Array.isArray(data)) {
+ allTasks = data;
+ } else if (data && Array.isArray(data.tasks)) {
+ allTasks = data.tasks;
+ } else if (data && typeof data === 'object') {
+ allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
+ } else {
+ allTasks = [];
+ }
+
+ if (badge) {
+ badge.innerText = allTasks.length.toString();
+ }
+
+ renderTasks();
+
+ } catch (err) {
+ console.error("[Tasks Error]", err);
+ if (badge) badge.innerText = "0";
+ if (container) {
+ container.innerHTML = `Ошибка обработки списка задач
`;
+ }
+ }
+}
+
+function renderTasks() {
+ const container = document.getElementById("tasks-container");
+ if (!container) return;
+
+ if (!Array.isArray(allTasks)) {
+ allTasks = [];
+ }
+
+ const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
+
+ if (filtered.length === 0) {
+ container.innerHTML = `Нет задач с выбранным фильтром
`;
+ return;
+ }
+
+ container.innerHTML = filtered.map(t => {
+ let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
+ let cardBg = "bg-white";
+
+ if (t.status === "COMPLETED") {
+ statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
+ cardBg = "bg-emerald-50/20";
+ } else if (t.status === "IN_PROGRESS") {
+ statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
+ cardBg = "bg-amber-50/20 border-amber-200";
+ }
+
+ let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
+ if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
+
+ let dueDateHtml = t.due_date ? `
+
+
+ Срок: ${t.due_date}
+
` : "";
+
+ return `
+
+
+
+ ${t.task_id || t.id || 'TASK'}
+ ${t.priority || 'MEDIUM'}
+
+
${t.status || 'BACKLOG'}
+
+
${t.title || t.description || ''}
+
+
+ ${t.module || 'General'}
+
+ ${dueDateHtml}
+
+ `;
+ }).join("");
+}
+```
+
+## File: `./static/css/styles.css`
+```css
+/* Плавное исчезновение текста сверху при скролле */
+.fade-scroll-top {
+ mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
+}
+
+/* Скрытие стандартного скроллбара */
+.no-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+.no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */
+body {
+ min-height: 100vh;
+ min-height: -webkit-fill-available;
+}
+
+```
+
+## File: `./llm/__init__.py`
+```py
+
+```
+
+## File: `./llm/agent.py`
+```py
+import json
+import urllib.request
+import urllib.error
+import logging
+from typing import List, Dict, Any, Tuple, Optional
+from datetime import datetime, timedelta
+import re
+
+from .db_tools import (
+ db_get_active_system_prompt,
+ db_add_system_prompt,
+ db_get_tasks,
+ db_update_task_status,
+ db_delete_task,
+ db_add_task,
+ db_get_rules,
+ db_set_session_state,
+ db_get_session_state,
+ db_get_snapshots,
+ db_delete_snapshots,
+ db_clear_session_state,
+ db_get_current_server_time,
+ db_save_chat_message,
+ db_get_chat_history,
+ db_get_stats,
+ db_get_anomalies,
+ db_get_session_states,
+ db_get_reference,
+ get_db_connection
+)
+
+from .schemas import TOOLS_SCHEMA
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
+logger = logging.getLogger("SCUD_AGENT")
+
+OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
+
+# Модель для текстовых задач и вызова функций (Tools)
+TEXT_MODEL = "qwen2.5:14b"
+# Модель для распознавания изображений и сканов PDF
+VISION_MODEL = "qwen2.5vl:7b-q8_0"
+
+DAYS_RU = [
+ "понедельник", "вторник", "среда", "четверг",
+ "пятница", "суббота", "воскресенье"
+]
+
+def clean_raw_tool_tags(text: str) -> str:
+ if not text:
+ return ""
+ # Удаляем сырые спецтеги Ollama и JSON-вызовы функций
+ text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text)
+ text = re.sub(r'.*? ', '', text, flags=re.DOTALL)
+ text = re.sub(r'\w*\[\]\(\)', '', text)
+ text = re.sub(r'', '', text)
+ return text.strip()
+
+def clean_output(text: str) -> str:
+ if not text:
+ return text
+ artifacts = ["почемучто", "почто", "почему что"]
+ lower_text = text.lower()
+ for art in artifacts:
+ if lower_text.startswith(art):
+ text = text[len(art):].lstrip(",.!?:; -")
+ return text.strip()
+
+def get_dynamic_calendar_context() -> str:
+ now = datetime.now()
+ current_wd = now.weekday()
+
+ lines = [
+ f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).",
+ "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):",
+ f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})",
+ f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})",
+ f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})",
+ "\nПрошедшие дни недели:"
+ ]
+
+ for days_back in range(1, 8):
+ dt = now - timedelta(days=days_back)
+ day_name = DAYS_RU[dt.weekday()]
+
+ if days_back == 7:
+ label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}"
+ lines.append(f"• {label} (ровно неделю назад): {dt.strftime('%d.%m.%Y')}")
+ else:
+ label = f"Ближайший прошедший {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Ближайшая прошедшая {day_name}"
+ lines.append(f"• {label} / {day_name}: {dt.strftime('%d.%m.%Y')}")
+
+ return "\n".join(lines)
+
+def process_chat_message(
+ user_id: int,
+ user_message: str,
+ file_context: str = "",
+ image_b64: Optional[str] = None,
+ chat_history: List[Dict[str, Any]] = None,
+ session_id: str = "web_session_main"
+) -> Tuple[str, List[Dict[str, Any]]]:
+ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
+
+ full_user_content = user_message
+ if file_context:
+ full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
+
+ db_history = db_get_chat_history(session_id, limit=20)
+ db_save_chat_message(session_id, "user", full_user_content)
+
+ dynamic_prompt_text = db_get_active_system_prompt()
+ calendar_context = get_dynamic_calendar_context()
+
+ session_state = db_get_session_state(session_id)
+ preview_status_note = ""
+ if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
+ preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]."
+
+ user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
+
+ system_prompt_content = (
+ f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
+ f"Вы общаетесь с пользователем: {user_info}.\n"
+ f"Все запрашиваемые задачи через инструмент db_get_tasks автоматически принадлежат ИМЕННО этому пользователю. "
+ f"Тебе НЕ НУЖНО уточнять, чьи это задачи или просить дополнительные идентификаторы. При запросах 'покажи мои задачи', 'список задач', 'мои дела' — СРАЗУ вызывай db_get_tasks.\n\n"
+ f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
+ f"{calendar_context}\n\n"
+ f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n"
+ f"При любых вопросах про дни недели ('прошлая среда', 'вторник', 'дата в прошлый понедельник') бери ГОТОВУЮ точную дату из справочника выше. Тебе ЗАПРЕЩЕНО вычислять даты самостоятельно!\n\n"
+ f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}"
+ )
+
+ user_msg_object = {"role": "user", "content": full_user_content}
+
+ # Общие параметры генерации Ollama для дисциплинированного полного вывода
+ llm_options = {
+ "num_predict": 8192,
+ "num_ctx": 8192,
+ "temperature": 0.1,
+ "repeat_penalty": 1.1,
+ "presence_penalty": 0.5,
+ "top_p": 0.9
+ }
+
+ # =========================================================
+ # ВЕТКА 1: ОБРАБОТКА ИЗОБРАЖЕНИЙ И СКАНОВ (VISION MODEL)
+ # =========================================================
+ if image_b64:
+ user_msg_object["images"] = [image_b64]
+ messages = [
+ {
+ "role": "system",
+ "content": (
+ "Ты — строгий модуль OCR для документов. Твоя задача — дословно переписать весь печатный и рукописный текст с изображения.\n"
+ "ПРАВИЛА:\n"
+ "1. Переписывай рукописный текст СТРОГО буква в букву так, как он написан от руки. Не додумывай слова от себя!\n"
+ "2. Отдельно выдели блок с рукописными записями, подписями и датами.\n"
+ "3. Не добавляй лишних слов, которых нет в графической части."
+ )
+ },
+ user_msg_object
+ ]
+ payload = {
+ "model": VISION_MODEL,
+ "messages": messages,
+ "stream": False,
+ "options": llm_options
+ }
+
+ # =========================================================
+ # ВЕТКА 2: ОБЫЧНЫЕ ТЕКСТОВЫЕ ЗАПРОСЫ И TOOLS (TEXT MODEL)
+ # =========================================================
+ else:
+ clean_db_history = []
+ for msg in db_history:
+ msg_copy = dict(msg)
+ msg_copy.pop("images", None)
+ clean_db_history.append(msg_copy)
+
+ system_prompt = {"role": "system", "content": system_prompt_content}
+ messages = [system_prompt] + clean_db_history + [user_msg_object]
+ payload = {
+ "model": TEXT_MODEL,
+ "messages": messages,
+ "tools": TOOLS_SCHEMA,
+ "stream": False,
+ "options": llm_options
+ }
+
+ try:
+ req = urllib.request.Request(
+ OLLAMA_URL,
+ data=json.dumps(payload).encode("utf-8"),
+ headers={"Content-Type": "application/json"}
+ )
+ with urllib.request.urlopen(req) as response:
+ res_data = json.loads(response.read().decode("utf-8"))
+ msg = res_data.get("message", {})
+ tool_calls = msg.get("tool_calls", [])
+
+ # --- ПЕРЕХВАТ СЫРОГО JSON В ТЕКСТЕ, ЕСЛИ OLLAMA ВЫДАЛА ЕГО В CONTENT ---
+ raw_text_content = msg.get("content", "")
+ user_msg_lower = user_message.lower()
+
+ # Если в сообщении пользователя есть просьба обновить/запросить заново снапшоты, а модель этого не сделала
+ is_snapshot_refresh_req = any(w in user_msg_lower for w in ["запроси", "из базы", "обнови", "свежие", "снапшот"])
+
+ if not tool_calls and (is_snapshot_refresh_req or '{"name":' in raw_text_content or '' in raw_text_content):
+ try:
+ if is_snapshot_refresh_req and not tool_calls:
+ # Принудительно формируем вызов db_get_snapshots
+ date_match = re.search(r'(\d{2}\.\d{2}\.\d{4})', user_message) or re.search(r'(\d{2}\.\d{2}\.\d{4})', system_prompt_content)
+ date_str = date_match.group(1) if date_match else "12.08.2026"
+ tool_calls = [{"function": {"name": "db_get_snapshots", "arguments": {"date_str": date_str}}}]
+ logger.info(f"Принудительно активирован Tool Call db_get_snapshots для обновления данных из БД.")
+ else:
+ match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
+ if match:
+ fn_name = match.group(1)
+ fn_args = json.loads(match.group(2))
+ tool_calls = [{"function": {"name": fn_name, "arguments": fn_args}}]
+ logger.info(f"Успешно извлечен сырой Tool Call из текста: {fn_name}")
+ except Exception as parse_err:
+ logger.warning(f"Не удалось распарсить сырой tool call: {parse_err}")
+
+ logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}")
+
+ if tool_calls:
+ messages.append(msg)
+
+ for tool in tool_calls:
+ fn_name = tool["function"]["name"]
+ fn_args = tool["function"].get("arguments", {})
+ logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
+ tool_result_content = ""
+
+ if fn_name == "db_get_snapshots":
+ date_arg = fn_args.get("date_str")
+ snapshots_res = db_get_snapshots(session_id=session_id, date_str=date_arg, original_user_message=user_message)
+ tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
+
+ elif fn_name == "db_get_current_server_time":
+ time_res = db_get_current_server_time()
+ tool_result_content = json.dumps(time_res, ensure_ascii=False)
+
+ elif fn_name == "db_get_tasks":
+ tasks = db_get_tasks(user_id)
+ tool_result_content = json.dumps(tasks, ensure_ascii=False)
+
+ elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
+ prompt_content = db_get_active_system_prompt()
+ tool_result_content = json.dumps({"system_prompt": prompt_content}, ensure_ascii=False)
+
+ elif fn_name == "db_get_stats":
+ tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
+
+ elif fn_name == "db_get_anomalies":
+ limit_arg = fn_args.get("limit", 100)
+ date_arg = fn_args.get("date_str")
+ tool_result_content = json.dumps(db_get_anomalies(limit=limit_arg, date_str=date_arg), ensure_ascii=False)
+
+ elif fn_name == "db_get_session_states":
+ tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
+
+ elif fn_name == "db_delete_snapshots":
+ snap_id = fn_args.get("snapshot_id")
+ day_arg = fn_args.get("day_str")
+ tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=snap_id, day_str=day_arg), ensure_ascii=False)
+
+ elif fn_name == "db_get_reference":
+ cat_arg = fn_args.get("category")
+ tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False)
+
+ elif fn_name == "db_preview_prompt_merge":
+ proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or fn_args.get("section_3_4") or ""
+ if isinstance(fn_args, str):
+ proposed_text = fn_args
+
+ if proposed_text:
+ # Если передана точечная правка
+ if len(proposed_text) < 500:
+ current_prompt = db_get_active_system_prompt()
+ lines = current_prompt.splitlines()
+ new_lines = []
+ found_3_4 = False
+
+ clean_text = proposed_text.strip()
+ if clean_text.startswith("3.4."):
+ clean_text = clean_text[4:].strip()
+
+ for line in lines:
+ if line.strip().startswith("3.4."):
+ new_lines.append(f" 3.4. {clean_text}")
+ found_3_4 = True
+ else:
+ new_lines.append(line)
+
+ # Если пункта 3.4 в промпте еще не было, добавляем его в раздел 3
+ if not found_3_4:
+ final_lines = []
+ added = False
+ for l in new_lines:
+ final_lines.append(l)
+ if l.strip().startswith("3.3."):
+ final_lines.append(f" 3.4. {clean_text}")
+ added = True
+ if not added:
+ final_lines.append(f" 3.4. {clean_text}")
+ new_lines = final_lines
+
+ proposed_text = "\n".join(new_lines)
+
+ db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text)
+ preview_reply = f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n{proposed_text}\n\nДля применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
+ db_save_chat_message(session_id, "assistant", preview_reply)
+ return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id)
+ else:
+ tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False)
+
+ elif fn_name == "db_confirm_prompt_preview":
+ if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
+ pending_text = session_state.get("pending_data", "")
+ res = db_add_system_prompt("main_agent", pending_text)
+ db_clear_session_state(session_id)
+ tool_result_content = json.dumps(res, ensure_ascii=False)
+ else:
+ tool_result_content = json.dumps({"status": "error", "message": "Нет активного превью для подтверждения."}, ensure_ascii=False)
+
+ elif fn_name == "db_cancel_prompt_preview":
+ db_clear_session_state(session_id)
+ tool_result_content = json.dumps({"status": "success", "message": "Превью отменено."}, ensure_ascii=False)
+
+ elif fn_name == "db_add_system_prompt":
+ try:
+ prompt_text = fn_args.get("prompt_text") if isinstance(fn_args, dict) else str(fn_args)
+ name = fn_args.get("name", "main_agent") if isinstance(fn_args, dict) else "main_agent"
+ res = db_add_system_prompt(name=name, prompt_text=prompt_text)
+ db_clear_session_state(session_id)
+ tool_result_content = json.dumps(res, ensure_ascii=False)
+ except Exception as e:
+ tool_result_content = json.dumps({"status": "error", "error": str(e)}, ensure_ascii=False)
+
+ elif fn_name == "db_get_rules":
+ tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
+
+ elif fn_name == "db_add_task":
+ res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
+ tool_result_content = json.dumps(res, ensure_ascii=False)
+
+ elif fn_name == "db_update_task_status":
+ res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
+ tool_result_content = json.dumps(res, ensure_ascii=False)
+
+ elif fn_name == "db_delete_task":
+ res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
+ tool_result_content = json.dumps(res, ensure_ascii=False)
+
+ messages.append({
+ "role": "tool",
+ "content": tool_result_content
+ })
+
+ second_payload = {
+ "model": TEXT_MODEL,
+ "messages": messages,
+ "stream": False,
+ "options": llm_options
+ }
+ sec_req = urllib.request.Request(
+ OLLAMA_URL,
+ data=json.dumps(second_payload).encode("utf-8"),
+ headers={"Content-Type": "application/json"}
+ )
+ with urllib.request.urlopen(sec_req) as sec_response:
+ sec_res_data = json.loads(sec_response.read().decode("utf-8"))
+ raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
+ final_content = clean_raw_tool_tags(clean_output(raw_content))
+ db_save_chat_message(session_id, "assistant", final_content)
+ return final_content, db_get_chat_history(session_id)
+
+ raw_str = msg.get("content", "").strip().replace("**", "")
+ content_str = clean_raw_tool_tags(clean_output(raw_str))
+ final_reply = content_str or "Запрос обработан."
+ db_save_chat_message(session_id, "assistant", final_reply)
+ return final_reply, db_get_chat_history(session_id)
+
+ except Exception as ex:
+ logger.exception(f"Непредвиденная ошибка: {ex}")
+ error_reply = f"Внутренняя ошибка сервера: {ex}"
+ return error_reply, db_get_chat_history(session_id)
+```
+
+## File: `./llm/db_tools.py`
+```py
+import json
+import sqlite3
+import logging
+from typing import List, Dict, Any, Optional
+from datetime import datetime, timedelta
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
+logger = logging.getLogger("DB_TOOLS")
+
+DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
+
+def db_get_current_server_time() -> Dict[str, Any]:
+ now = datetime.now()
+ days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
+ return {
+ "current_date": now.strftime("%d.%m.%Y"),
+ "current_time": now.strftime("%H:%M:%S"),
+ "day_of_week": days_ru[now.weekday()],
+ "iso_date": now.strftime("%Y-%m-%d")
+ }
+
+def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]:
+ """
+ Дата уже точно подготовлена моделью на основе системного календаря.
+ Возвращаем date_str без повторной тяжелой фильтрации.
+ """
+ return date_str
+
+def db_save_chat_message(session_id: str, role: str, content: str):
+ if not content:
+ return
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT INTO chat_messages (session_id, role, content, created_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ """, (session_id, role, content))
+ conn.commit()
+ conn.close()
+
+def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT role, content FROM chat_messages
+ WHERE session_id = ?
+ ORDER BY id DESC LIMIT ?
+ """, (session_id, limit))
+ rows = cursor.fetchall()
+ conn.close()
+ return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
+
+def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
+ date_str = smart_parse_date(date_str, original_user_message)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = """
+ SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count
+ FROM scud_logs
+ """
+ params = []
+
+ if date_str:
+ # Приводим дату ДД.ММ.ГГГГ к ISO YYYY-MM-DD
+ iso_date = date_str
+ if "." in date_str:
+ parts = date_str.split(".")
+ if len(parts) == 3:
+ iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
+
+ # Строгий поиск: ищем совпадение строго по log_date или началу snapshot_time/created_at
+ query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? OR created_at LIKE ? "
+ params.extend([date_str, iso_date, f"{iso_date}%", f"{iso_date}%"])
+
+ query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 20"
+
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ snapshots = [dict(r) for r in rows]
+
+ result_data = {
+ "query_date": date_str or "все",
+ "snapshots_count": len(snapshots),
+ "snapshots": snapshots
+ }
+
+ db_set_session_state(
+ session_id=session_id,
+ state_type="SNAPSHOTS_VIEW",
+ data=json.dumps(result_data, ensure_ascii=False)
+ )
+
+ conn.close()
+ return result_data
+
+def get_db_connection():
+ conn = sqlite3.connect(DB_PATH, timeout=30.0)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode = WAL;")
+ conn.execute("PRAGMA synchronous = NORMAL;")
+ return conn
+
+def normalize_task_id(task_id_input: str) -> str:
+ if not task_id_input:
+ return ""
+ clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
+ if clean_id.isdigit():
+ num = int(clean_id)
+ return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
+ return f"TASK-{clean_id}"
+
+def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT id, task_id, module, title, priority, status, due_date, created_at
+ FROM tasks
+ WHERE user_id = ?
+ ORDER BY id DESC
+ """, (user_id,))
+ rows = cursor.fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("SELECT MAX(id) FROM tasks")
+ max_id = cursor.fetchone()[0] or 0
+ new_task_id = f"TASK-{(max_id + 1):02d}"
+
+ cursor.execute("""
+ INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
+ VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
+ """, (new_task_id, module, title, priority.upper(), due_date, user_id))
+
+ conn.commit()
+ conn.close()
+ return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
+
+def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ formatted_id = normalize_task_id(task_id)
+
+ if due_date:
+ cursor.execute("""
+ UPDATE tasks
+ SET status = ?, due_date = ?
+ WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
+ """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
+ else:
+ cursor.execute("""
+ UPDATE tasks
+ SET status = ?
+ WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
+ """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
+
+ if cursor.rowcount == 0:
+ conn.close()
+ return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
+
+ conn.commit()
+ conn.close()
+ return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
+
+def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ formatted_id = normalize_task_id(task_id)
+
+ cursor.execute("""
+ DELETE FROM tasks
+ WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
+ """, (formatted_id, f"%{task_id.strip()}", user_id))
+
+ if cursor.rowcount == 0:
+ conn.close()
+ return {"error": f"Задача {task_id} не найдена"}
+
+ conn.commit()
+ conn.close()
+ return {"status": "success", "message": f"Задача {formatted_id} удалена"}
+
+def db_get_active_system_prompt() -> str:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
+ row = cursor.fetchone()
+ conn.close()
+ return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
+
+def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
+ try:
+ with get_db_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("BEGIN IMMEDIATE;")
+
+ cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
+ existing = cursor.fetchone()
+
+ if existing:
+ cursor.execute(
+ "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
+ (prompt_text, name)
+ )
+ else:
+ cursor.execute(
+ "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
+ (name, prompt_text)
+ )
+
+ conn.commit()
+
+ logger.info("Системный промпт успешно сохранен и применен в базе данных.")
+ return {"status": "success", "message": "Системный промпт успешно обновлен"}
+ except Exception as e:
+ logger.error(f"Ошибка при сохранении промпта в БД: {e}")
+ return {"status": "error", "error": str(e)}
+
+def db_get_rules() -> List[Dict[str, Any]]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
+ rows = cursor.fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+def db_set_session_state(session_id: str, state_type: str, data: str):
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(session_id) DO UPDATE SET
+ state_type = excluded.state_type,
+ pending_data = excluded.pending_data,
+ updated_at = CURRENT_TIMESTAMP
+ """, (session_id, state_type, data))
+ conn.commit()
+ conn.close()
+
+def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,))
+ row = cursor.fetchone()
+ conn.close()
+ return dict(row) if row else None
+
+def db_clear_session_state(session_id: str):
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
+ conn.commit()
+ conn.close()
+
+def db_get_stats() -> Dict[str, Any]:
+ """Возвращает общую статистику по количеству записей во всех таблицах БД."""
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks']
+ stats = {}
+ for t in tables:
+ try:
+ cursor.execute(f"SELECT COUNT(*) FROM {t}")
+ stats[t] = cursor.fetchone()[0]
+ except Exception:
+ stats[t] = 0
+ conn.close()
+ return {"status": "success", "tables_stats": stats}
+
+def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
+ """Возвращает историю аномалий СКУД с опциональной фильтрацией по дате."""
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
+ params = []
+
+ if date_str:
+ query += " WHERE anomaly_date = ?"
+ params.append(date_str)
+
+ query += " ORDER BY id DESC LIMIT ?"
+ params.append(limit)
+
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ conn.close()
+
+ anomalies_list = [dict(r) for r in rows]
+ return {
+ "status": "success",
+ "count": len(anomalies_list),
+ "anomalies": anomalies_list
+ }
+
+def db_get_session_states() -> Dict[str, Any]:
+ """Возвращает список всех активных сессий и состояний превью."""
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
+ rows = cursor.fetchall()
+ conn.close()
+ return {"status": "success", "active_sessions": [dict(r) for r in rows]}
+
+def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
+ """Удаляет снапшот по ID или за конкретную дату."""
+ if not snapshot_id and not day_str:
+ return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ if snapshot_id:
+ cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
+ deleted = cursor.rowcount
+ else:
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
+ deleted = cursor.rowcount
+
+ conn.commit()
+ conn.close()
+ return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
+
+def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
+ """Возвращает системные справочники и примеры команд для оператора."""
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = "SELECT category, title, example_prompt, description FROM system_reference"
+ params = []
+
+ if category:
+ query += " WHERE category = ?"
+ params.append(category)
+
+ query += " ORDER BY id ASC"
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ conn.close()
+
+ return {
+ "status": "success",
+ "count": len(rows),
+ "reference_items": [dict(r) for r in rows]
+ }
+```
+
+## File: `./llm/file_parser.py`
+```py
+import base64
+import os
+import subprocess
+import logging
+import pandas as pd
+
+logger = logging.getLogger("FILE_PARSER")
+
+def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
+ ext = os.path.splitext(filename)[1].lower()
+ temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
+
+ with open(temp_filepath, "wb") as f:
+ f.write(file_bytes)
+
+ try:
+ # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
+ if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
+ b64_str = base64.b64encode(file_bytes).decode('utf-8')
+ return {
+ "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
+ "image_b64": b64_str
+ }
+
+ # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
+ elif ext == '.pdf':
+ cmd = ['pdftotext', temp_filepath, '-']
+ res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ pdf_text = res.stdout.strip()
+
+ img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
+ subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
+
+ page_png = f"{img_prefix}-1.png"
+ b64_str = None
+ if os.path.exists(page_png):
+ with open(page_png, "rb") as pf:
+ b64_str = base64.b64encode(pf.read()).decode('utf-8')
+ os.remove(page_png)
+
+ context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
+ if pdf_text:
+ context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
+
+ return {
+ "text": context_text,
+ "image_b64": b64_str
+ }
+
+ # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
+ elif ext in ['.xlsx', '.xls', '.csv']:
+ if ext == '.csv':
+ df = pd.read_csv(temp_filepath)
+ else:
+ df = pd.read_excel(temp_filepath)
+
+ total_rows = len(df)
+ df_preview = df.head(100)
+ table_str = df_preview.to_string(index=False)
+ note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
+ return {
+ "text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
+ "image_b64": None
+ }
+
+ # 4. ТЕКСТОВЫЕ ФАЙЛЫ
+ elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
+ with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
+ return {
+ "text": tf.read().strip(),
+ "image_b64": None
+ }
+
+ else:
+ return {
+ "text": f"[ОШИБКА: Формат {ext} не поддерживается]",
+ "image_b64": None
+ }
+
+ except Exception as e:
+ logger.error(f"Ошибка при анализе файла {filename}: {e}")
+ return {
+ "text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
+ "image_b64": None
+ }
+ finally:
+ if os.path.exists(temp_filepath):
+ os.remove(temp_filepath)
+```
+
+## File: `./llm/schemas.py`
+```py
+TOOLS_SCHEMA = [
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_tasks",
+ "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_rules",
+ "description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
+ "parameters": {"type": "object", "properties": {}}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_system_prompt",
+ "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!",
+ "parameters": {"type": "object", "properties": {}}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_update_task_status",
+ "description": "Изменить статус и/или срок выполнения задачи в реестре.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
+ "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
+ "due_date": {"type": "string", "description": "Срок выполнения задачи"}
+ },
+ "required": ["task_id"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_delete_task",
+ "description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
+ },
+ "required": ["task_id"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_snapshots",
+ "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "date_str": {
+ "type": "string",
+ "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_delete_snapshots",
+ "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
+ "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
+ }
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_current_server_time",
+ "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ "required": []
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_add_task",
+ "description": "Добавить новую задачу в бэклог проекта.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string", "description": "Краткое описание задачи"},
+ "priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
+ "module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
+ "due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
+ },
+ "required": ["title"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_stats",
+ "description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
+ "parameters": {"type": "object", "properties": {}}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_anomalies",
+ "description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
+ "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
+ }
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_session_states",
+ "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
+ "parameters": {"type": "object", "properties": {}}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_reference",
+ "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_add_system_prompt",
+ "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"},
+ "prompt_text": {"type": "string", "description": "Полный текст системного промпта"}
+ },
+ "required": ["prompt_text"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_preview_prompt_merge",
+ "description": "Создать предварительное изменённое превью системного промпта перед сохранением.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "prompt_text": {
+ "type": "string",
+ "description": "Новый полный или частично измененный текст системного промпта."
+ }
+ },
+ "required": ["prompt_text"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_confirm_prompt_preview",
+ "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.",
+ "parameters": {"type": "object", "properties": {}}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_cancel_prompt_preview",
+ "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.",
+ "parameters": {"type": "object", "properties": {}}
+ }
+ }
+]
+```
+
diff --git a/llm/agent.py b/llm/agent.py
index 1469a17..9d4c289 100644
--- a/llm/agent.py
+++ b/llm/agent.py
@@ -1,10 +1,33 @@
+"""
+===============================================================================
+MODULE: llm/agent.py
+PROJECT: SCUD Orion AI Context API
+ROLE: Главный оркестратор взаимодействия с Ollama LLM, обработки вызовов
+ инструментов (Tools) и сохранения диалогов.
+
+DEPENDENCIES:
+ - llm/db_tools.py (доступ к SQLite)
+ - llm/schemas.py (схема функций TOOLS_SCHEMA)
+
+CRITICAL INVARIANTS:
+ 1. Tool Injector перехватывает фразы пользователя до/после запроса к LLM,
+ если Ollama вернула Tool calls: False или прислала JSON в content.
+ 2. parse_relative_date_ru всегда отсчитывает относительные даты
+ ('вчера', 'позавчера') от текущего серверного времени.
+ 3. Опции llm_options содержат repeat_penalty и presence_penalty для
+ предотвращения урезания ответов моделью Qwen2.5.
+===============================================================================
+"""
+
import json
import urllib.request
import urllib.error
import logging
from typing import List, Dict, Any, Tuple, Optional
from datetime import datetime, timedelta
+import re
+# Импорт внутренних утилит работы с БД
from .db_tools import (
db_get_active_system_prompt,
db_add_system_prompt,
@@ -30,18 +53,40 @@ from .db_tools import (
from .schemas import TOOLS_SCHEMA
+# --- [SECTION 1: LOGGING & CONSTANTS] ---
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("SCUD_AGENT")
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
-MODEL_NAME = "qwen2.5:14b"
+
+# Модели
+TEXT_MODEL = "qwen2.5:14b" # Основная модель для логики, вызовов тулов и текста
+VISION_MODEL = "qwen2.5vl:7b-q8_0" # Модель для OCR документов и изображений
DAYS_RU = [
"понедельник", "вторник", "среда", "четверг",
"пятница", "суббота", "воскресенье"
]
+
+# --- [SECTION 2: TEXT CLEANING & PARSING UTILS] ---
+
+def clean_raw_tool_tags(text: str) -> str:
+ """
+ ⚠️ AI-INVARIANT: Очистка текста от сырых тегов и JSON-артефактов Ollama,
+ вываливающихся в поле message.content.
+ """
+ if not text:
+ return ""
+ text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*( )?', '', text)
+ text = re.sub(r'.*? ', '', text, flags=re.DOTALL)
+ text = re.sub(r'\w*\[\]\(\)', '', text)
+ text = re.sub(r'', '', text)
+ return text.strip()
+
+
def clean_output(text: str) -> str:
+ """Удаление слов-паразитов и склеек в начале ответа."""
if not text:
return text
artifacts = ["почемучто", "почто", "почему что"]
@@ -51,7 +96,33 @@ def clean_output(text: str) -> str:
text = text[len(art):].lstrip(",.!?:; -")
return text.strip()
+
+def parse_relative_date_ru(text: str) -> str:
+ """
+ ⚠️ AI-INVARIANT: Определение точной даты ДД.ММ.ГГГГ для инструмента db_get_snapshots.
+ Защищает от галлюцинаций даты, когда модель не передает аргументы за 'вчера/сегодня'.
+ """
+ now = datetime.now()
+ text_lower = text.lower() if text else ""
+
+ # 1. Поиск явной даты ДД.ММ.ГГГГ
+ match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
+ if match:
+ return match.group(1)
+
+ # 2. Обработка относительно текущего дня
+ if "вчера" in text_lower:
+ return (now - timedelta(days=1)).strftime("%d.%m.%Y")
+ elif "позавчера" in text_lower:
+ return (now - timedelta(days=2)).strftime("%d.%m.%Y")
+ elif "сегодня" in text_lower:
+ return now.strftime("%d.%m.%Y")
+
+ return (now - timedelta(days=1)).strftime("%d.%m.%Y")
+
+
def get_dynamic_calendar_context() -> str:
+ """Генерация справочника прошедших дат для системного промпта."""
now = datetime.now()
current_wd = now.weekday()
@@ -77,16 +148,21 @@ def get_dynamic_calendar_context() -> str:
return "\n".join(lines)
+
+# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] ---
+
def process_chat_message(
user_id: int,
user_message: str,
file_context: str = "",
+ image_b64: Optional[str] = None,
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]]]:
+ """Главный входной метод обработки пользовательского сообщения."""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
- # Формируем итоговое содержимое запроса
+ # 3.1. Формирование контекста сообщения пользователя
full_user_content = user_message
if file_context:
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
@@ -97,12 +173,20 @@ def process_chat_message(
dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context()
+ # Check состояния превью системного промпта
session_state = db_get_session_state(session_id)
preview_status_note = ""
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]."
+ user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
+
+ # 3.2. Сборка системного контекста
system_prompt_content = (
+ f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
+ f"Вы общаетесь с пользователем: {user_info}.\n"
+ f"Все запрашиваемые задачи через инструмент db_get_tasks автоматически принадлежат ИМЕННО этому пользователю. "
+ f"Тебе НЕ НУЖНО уточнять, чьи это задачи или просить дополнительные идентификаторы. При запросах 'покажи мои задачи', 'список задач', 'мои дела' — СРАЗУ вызывай db_get_tasks.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"{calendar_context}\n\n"
f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n"
@@ -110,21 +194,60 @@ def process_chat_message(
f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}"
)
- system_prompt = {
- "role": "system",
- "content": system_prompt_content
+ user_msg_object = {"role": "user", "content": full_user_content}
+
+ # 3.3. Параметры инференса (Отказ от "ленивого вывода" Qwen)
+ llm_options = {
+ "num_predict": 8192,
+ "num_ctx": 8192,
+ "temperature": 0.1,
+ "repeat_penalty": 1.1, # Запрет на скомканное завершение ответа
+ "presence_penalty": 0.5, # Стимулирование полной генерации списков
+ "top_p": 0.9
}
- messages = [system_prompt] + db_history + [{"role": "user", "content": full_user_content}]
+ # --- [SUB-SECTION 3.4: ROUTING & PAYLOAD BUILD] ---
+ if image_b64:
+ # Ветка Vision Model (Зрение/OCR)
+ user_msg_object["images"] = [image_b64]
+ messages = [
+ {
+ "role": "system",
+ "content": (
+ "Ты — строгий модуль OCR для документов. Твоя задача — дословно переписать весь печатный и рукописный текст с изображения.\n"
+ "ПРАВИЛА:\n"
+ "1. Переписывай рукописный текст СТРОГО буква в букву так, как он написан от руки. Не додумывай слова от себя!\n"
+ "2. Отдельно выдели блок с рукописными записями, подписями и датами.\n"
+ "3. Не добавляй лишних слов, которых нет в графической части."
+ )
+ },
+ user_msg_object
+ ]
+ payload = {
+ "model": VISION_MODEL,
+ "messages": messages,
+ "stream": False,
+ "options": llm_options
+ }
+ else:
+ # Ветка Text & Tools Model
+ clean_db_history = []
+ for msg in db_history:
+ msg_copy = dict(msg)
+ msg_copy.pop("images", None)
+ clean_db_history.append(msg_copy)
- payload = {
- "model": MODEL_NAME,
- "messages": messages,
- "tools": TOOLS_SCHEMA,
- "stream": False,
- "options": {"num_predict": 8192, "num_ctx": 8192, "temperature": 0.1}
- }
+ system_prompt = {"role": "system", "content": system_prompt_content}
+ messages = [system_prompt] + clean_db_history + [user_msg_object]
+ payload = {
+ "model": TEXT_MODEL,
+ "messages": messages,
+ "tools": TOOLS_SCHEMA,
+ "stream": False,
+ "options": llm_options
+ }
+ # --- [SUB-SECTION 3.5: OLLAMA REQUEST & TOOL INJECTION] ---
try:
req = urllib.request.Request(
OLLAMA_URL,
@@ -135,8 +258,41 @@ def process_chat_message(
res_data = json.loads(response.read().decode("utf-8"))
msg = res_data.get("message", {})
tool_calls = msg.get("tool_calls", [])
+
+ raw_text_content = msg.get("content", "")
+ user_msg_lower = user_message.lower()
+
+ # ⚠️ AI-INVARIANT: TOOL INJECTOR (Инжектор вызовов)
+ # Если модель проигнорировала вызов функции или вывела его текстом
+ is_snapshot_req = any(w in user_msg_lower for w in ["снапшот", "срез", "среза", "лог"])
+ is_prompt_req = any(w in user_msg_lower for w in ["покажи системный промпт", "покажи промпт", "весь промпт"])
+
+ # Снапшоты запрашиваем из БД только при явных командах выгрузки/обновления
+ is_snapshot_fetch_req = any(w in user_msg_lower for w in ["покажи снапшоты", "список снапшотов", "выведи снапшоты", "срезы за", "логи за"])
+ is_refresh_req = any(w in user_msg_lower for w in ["запроси из базы", "обнови из базы", "повторно запроси", "свежие данные"])
+
+ if not tool_calls:
+ if is_prompt_req:
+ tool_calls = [{"function": {"name": "db_get_system_prompt", "arguments": {}}}]
+ logger.info("ИНЖЕКТОР: Активирован вызов db_get_system_prompt.")
+ elif (is_snapshot_fetch_req or is_refresh_req) and "задач" not in user_msg_lower:
+ target_date = parse_relative_date_ru(user_message)
+ tool_calls = [{"function": {"name": "db_get_snapshots", "arguments": {"date_str": target_date}}}]
+ logger.info(f"ИНЖЕКТОР: Активирован принудительный вызов db_get_snapshots за {target_date}.")
+ elif '{"name":' in raw_text_content or '' in raw_text_content:
+ try:
+ match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
+ if match:
+ fn_name = match.group(1)
+ fn_args = json.loads(match.group(2))
+ tool_calls = [{"function": {"name": fn_name, "arguments": fn_args}}]
+ logger.info(f"ИНЖЕКТОР: Извлечен сырой Tool Call из текста: {fn_name}")
+ except Exception as parse_err:
+ logger.warning(f"Ошибка парсинга сырого tool call: {parse_err}")
+
logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}")
+ # --- [SUB-SECTION 3.6: TOOL EXECUTION ROUTER] ---
if tool_calls:
messages.append(msg)
@@ -184,12 +340,47 @@ def process_chat_message(
tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False)
elif fn_name == "db_preview_prompt_merge":
- proposed_text = fn_args.get("proposed_prompt", "")
+ # Обработка точечных правок системного промпта
+ proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or fn_args.get("section_3_4") or ""
+ if isinstance(fn_args, str):
+ proposed_text = fn_args
+
if proposed_text:
+ if len(proposed_text) < 500:
+ current_prompt = db_get_active_system_prompt()
+ lines = current_prompt.splitlines()
+ new_lines = []
+ found_3_4 = False
+
+ clean_text = proposed_text.strip()
+ if clean_text.startswith("3.4."):
+ clean_text = clean_text[4:].strip()
+
+ for line in lines:
+ if line.strip().startswith("3.4."):
+ new_lines.append(f" 3.4. {clean_text}")
+ found_3_4 = True
+ else:
+ new_lines.append(line)
+
+ if not found_3_4:
+ final_lines = []
+ added = False
+ for l in new_lines:
+ final_lines.append(l)
+ if l.strip().startswith("3.3."):
+ final_lines.append(f" 3.4. {clean_text}")
+ added = True
+ if not added:
+ final_lines.append(f" 3.4. {clean_text}")
+ new_lines = final_lines
+
+ proposed_text = "\n".join(new_lines)
+
db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text)
preview_reply = f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n{proposed_text}\n\nДля применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
db_save_chat_message(session_id, "assistant", preview_reply)
- return preview_reply, db_get_chat_history(session_id)
+ return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id)
else:
tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False)
@@ -236,11 +427,12 @@ def process_chat_message(
"content": tool_result_content
})
+ # Вторичный вызов Ollama для формирования текстового ответа пользователя с учетом результатов Tool
second_payload = {
- "model": MODEL_NAME,
+ "model": TEXT_MODEL,
"messages": messages,
"stream": False,
- "options": {"num_predict": 8192, "num_ctx": 8192, "temperature": 0.1}
+ "options": llm_options
}
sec_req = urllib.request.Request(
OLLAMA_URL,
@@ -249,11 +441,14 @@ def process_chat_message(
)
with urllib.request.urlopen(sec_req) as sec_response:
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
- final_content = clean_output(sec_res_data.get("message", {}).get("content", "").strip().replace("**", ""))
+ raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
+ final_content = clean_raw_tool_tags(clean_output(raw_content))
db_save_chat_message(session_id, "assistant", final_content)
return final_content, db_get_chat_history(session_id)
- content_str = clean_output(msg.get("content", "").strip().replace("**", ""))
+ # Если вызовов функций не было
+ raw_str = msg.get("content", "").strip().replace("**", "")
+ content_str = clean_raw_tool_tags(clean_output(raw_str))
final_reply = content_str or "Запрос обработан."
db_save_chat_message(session_id, "assistant", final_reply)
return final_reply, db_get_chat_history(session_id)
diff --git a/llm/db_tools.py b/llm/db_tools.py
index 6da7acc..9b932d4 100644
--- a/llm/db_tools.py
+++ b/llm/db_tools.py
@@ -1,15 +1,52 @@
+"""
+===============================================================================
+MODULE: llm/db_tools.py
+PROJECT: SCUD Orion AI Context API
+ROLE: Низкоуровневый модуль работы с СУБД SQLite. Реализует CRUD-операции
+ для задач, истории чатов, состояния превью промпта и запросов к
+ логам/снапшотам СКУД.
+
+DB PATH: /home/puh/scud_orion_ai_v2/data/scud_orion_ai.db
+
+CRITICAL INVARIANTS:
+ 1. db_get_snapshots выполняет фильтрацию СТРОГО по log_date или snapshot_time,
+ чтобы исключить попадание логов за другие даты по служебномуcreated_at.
+ 2. WAL-режим (PRAGMA journal_mode = WAL) обязателен для предотвращения
+ блокировок файла БД при параллельных запросах FastAPI/Uvicorn.
+ 3. normalize_task_id гарантирует единый формат ID задач ('TASK-01', 'TASK-12').
+===============================================================================
+"""
+
import json
import sqlite3
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
+# --- [SECTION 1: LOGGING & CONFIGURATION] ---
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("DB_TOOLS")
+# ⚠️ AI-INVARIANT: Единый абсолютный путь к рабочей БД проекта
DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
+
+def get_db_connection() -> sqlite3.Connection:
+ """
+ ⚠️ AI-INVARIANT: Фабрика подключений к SQLite.
+ Включает WAL-режим и timeout=30.0 для высокой отказоустойчивости при конкурентном доступе.
+ """
+ conn = sqlite3.connect(DB_PATH, timeout=30.0)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode = WAL;")
+ conn.execute("PRAGMA synchronous = NORMAL;")
+ return conn
+
+
+# --- [SECTION 2: TIME & DATE HELPERS] ---
+
def db_get_current_server_time() -> Dict[str, Any]:
+ """Возвращает текущую дату, точное время и день недели сервера."""
now = datetime.now()
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
return {
@@ -19,14 +56,16 @@ def db_get_current_server_time() -> Dict[str, Any]:
"iso_date": now.strftime("%Y-%m-%d")
}
+
def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]:
- """
- Дата уже точно подготовлена моделью на основе системного календаря.
- Возвращаем date_str без повторной тяжелой фильтрации.
- """
+ """Вспомогательный транзит даты без избыточной вторичной фильтрации."""
return date_str
+
+# --- [SECTION 3: CHAT HISTORY STORAGE] ---
+
def db_save_chat_message(session_id: str, role: str, content: str):
+ """Сохранение отдельного сообщения (user / assistant / tool) в историю чата."""
if not content:
return
conn = get_db_connection()
@@ -38,7 +77,9 @@ def db_save_chat_message(session_id: str, role: str, content: str):
conn.commit()
conn.close()
+
def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение последних N сообщений из истории диалога текущей сессии."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
@@ -50,7 +91,15 @@ def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]
conn.close()
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
+
+# --- [SECTION 4: SCUD LOGS & SNAPSHOTS ENGINE] ---
+
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
+ """
+ ⚠️ AI-INVARIANT: Функция получения реестра снапшотов/срезов СКУД.
+ Фильтрация делается СТРОГО по log_date или snapshot_time. Оператор OR created_at LIKE
+ исключен, чтобы исключить подмешивание артефактных снапшотов за другие дни!
+ """
date_str = smart_parse_date(date_str, original_user_message)
conn = get_db_connection()
@@ -63,18 +112,17 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
params = []
if date_str:
- # Приводим дату ДД.ММ.ГГГГ к ISO YYYY-MM-DD
+ # Приведение даты ДД.ММ.ГГГГ к ISO YYYY-MM-DD
iso_date = date_str
if "." in date_str:
parts = date_str.split(".")
if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
- # Строгий поиск: ищем совпадение строго по log_date или началу snapshot_time/created_at
- query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? OR created_at LIKE ? "
- params.extend([date_str, iso_date, f"{iso_date}%", f"{iso_date}%"])
+ query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
+ params.extend([date_str, iso_date, f"{iso_date}%"])
- query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 20"
+ query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
cursor.execute(query, params)
rows = cursor.fetchall()
@@ -86,6 +134,7 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
"snapshots": snapshots
}
+ # Сохраняем результат в состояние сессии для истории просмотра
db_set_session_state(
session_id=session_id,
state_type="SNAPSHOTS_VIEW",
@@ -95,14 +144,34 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
conn.close()
return result_data
-def get_db_connection():
- conn = sqlite3.connect(DB_PATH, timeout=30.0)
- conn.row_factory = sqlite3.Row
- conn.execute("PRAGMA journal_mode = WAL;")
- conn.execute("PRAGMA synchronous = NORMAL;")
- return conn
+
+def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
+ """Удаление конкретного снапшота по ID или всех снапшотов за день."""
+ if not snapshot_id and not day_str:
+ return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ if snapshot_id:
+ cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
+ deleted = cursor.rowcount
+ else:
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
+ deleted = cursor.rowcount
+
+ conn.commit()
+ conn.close()
+ return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
+
+
+# --- [SECTION 5: TASK TRACKER CRUD ENGINE] ---
def normalize_task_id(task_id_input: str) -> str:
+ """
+ ⚠️ AI-INVARIANT: Приведение ID задачи к каноническому виду 'TASK-XX'.
+ Примеры: '17' -> 'TASK-17', 'task-5' -> 'TASK-05'.
+ """
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
@@ -111,7 +180,9 @@ def normalize_task_id(task_id_input: str) -> str:
return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
return f"TASK-{clean_id}"
+
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
+ """Получение всех задач, принадлежащих конкретному авторизованному пользователю."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
@@ -124,7 +195,9 @@ def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
conn.close()
return [dict(r) for r in rows]
+
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
+ """Создание новой задачи в бэклоге пользователя."""
conn = get_db_connection()
cursor = conn.cursor()
@@ -141,7 +214,9 @@ def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM",
conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
+
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
+ """Обновление статуса и/или срока задачи с проверкой прав пользователя."""
conn = get_db_connection()
cursor = conn.cursor()
@@ -168,7 +243,9 @@ def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED",
conn.close()
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
+
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
+ """Удаление задачи из бэклога."""
conn = get_db_connection()
cursor = conn.cursor()
@@ -187,7 +264,11 @@ def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
conn.close()
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
+
+# --- [SECTION 6: SYSTEM PROMPTS & KNOWLEDGE BASE] ---
+
def db_get_active_system_prompt() -> str:
+ """Извлечение текущего активного системного промпта из БД."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
@@ -195,7 +276,12 @@ def db_get_active_system_prompt() -> str:
conn.close()
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
+
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
+ """
+ ⚠️ AI-INVARIANT: Прямая запись нового активного системного промпта в SQLite.
+ Вызывается ТОЛЬКО после подтверждения превью через db_confirm_prompt_preview.
+ """
try:
with get_db_connection() as conn:
cursor = conn.cursor()
@@ -223,7 +309,9 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)}
+
def db_get_rules() -> List[Dict[str, Any]]:
+ """Получение правил арбитража и базы знаний из ai_knowledge_base."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
@@ -231,7 +319,11 @@ def db_get_rules() -> List[Dict[str, Any]]:
conn.close()
return [dict(r) for r in rows]
+
+# --- [SECTION 7: SESSION STATES & PREVIEW STORAGE] ---
+
def db_set_session_state(session_id: str, state_type: str, data: str):
+ """Сохранение временного состояния сессии (например, PROMPT_PREVIEW)."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
@@ -245,7 +337,9 @@ def db_set_session_state(session_id: str, state_type: str, data: str):
conn.commit()
conn.close()
+
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
+ """Получение активного сессионного состояния по session_id."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,))
@@ -253,15 +347,30 @@ def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
conn.close()
return dict(row) if row else None
+
def db_clear_session_state(session_id: str):
+ """Сброс и очистка сессионного состояния (при отмене или подтверждении)."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
+
+def db_get_session_states() -> Dict[str, Any]:
+ """Список всех активных предпросмотров и сессий."""
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
+ rows = cursor.fetchall()
+ conn.close()
+ return {"status": "success", "active_sessions": [dict(r) for r in rows]}
+
+
+# --- [SECTION 8: SYSTEM STATS & REFERENCE] ---
+
def db_get_stats() -> Dict[str, Any]:
- """Возвращает общую статистику по количеству записей во всех таблицах БД."""
+ """Возвращает общую статистику по количеству записей во всех таблицах СУБД."""
conn = get_db_connection()
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks']
@@ -275,8 +384,9 @@ def db_get_stats() -> Dict[str, Any]:
conn.close()
return {"status": "success", "tables_stats": stats}
+
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
- """Возвращает историю аномалий СКУД с опциональной фильтрацией по дате."""
+ """История аномалий СКУД ⟷ 1С с опциональной фильтрацией по дате."""
conn = get_db_connection()
cursor = conn.cursor()
@@ -301,36 +411,9 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
"anomalies": anomalies_list
}
-def db_get_session_states() -> Dict[str, Any]:
- """Возвращает список всех активных сессий и состояний превью."""
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
- rows = cursor.fetchall()
- conn.close()
- return {"status": "success", "active_sessions": [dict(r) for r in rows]}
-
-def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
- """Удаляет снапшот по ID или за конкретную дату."""
- if not snapshot_id and not day_str:
- return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
-
- conn = get_db_connection()
- cursor = conn.cursor()
-
- if snapshot_id:
- cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
- deleted = cursor.rowcount
- else:
- cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
- deleted = cursor.rowcount
-
- conn.commit()
- conn.close()
- return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
- """Возвращает системные справочники и примеры команд для оператора."""
+ """Получение системных справочников и примеров команд для оператора."""
conn = get_db_connection()
cursor = conn.cursor()
diff --git a/llm/file_parser.py b/llm/file_parser.py
index 945930e..0bce46f 100644
--- a/llm/file_parser.py
+++ b/llm/file_parser.py
@@ -1,37 +1,53 @@
-import io
+import base64
import os
import subprocess
import logging
import pandas as pd
-from PIL import Image
logger = logging.getLogger("FILE_PARSER")
-def extract_text_from_file(file_bytes: bytes, filename: str) -> str:
- """Извлекает текст из изображений (OCR), PDF, таблиц и текстовых файлов."""
+def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
ext = os.path.splitext(filename)[1].lower()
-
- # Создаем временный файл во избежание проблем с памятью
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
+
with open(temp_filepath, "wb") as f:
f.write(file_bytes)
try:
- # 1. ИЗОБРАЖЕНИЯ (OCR через системный /usr/bin/tesseract)
+ # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
- cmd = ['tesseract', temp_filepath, 'stdout', '-l', 'rus+eng']
- res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
- text = res.stdout.strip()
- return text if text else "[OCR: На изображении не удалось распознать текст]"
+ b64_str = base64.b64encode(file_bytes).decode('utf-8')
+ return {
+ "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
+ "image_b64": b64_str
+ }
- # 2. PDF ДОКУМЕНТЫ (через системный /usr/bin/pdftotext из poppler-utils)
+ # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
elif ext == '.pdf':
cmd = ['pdftotext', temp_filepath, '-']
- res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
- text = res.stdout.strip()
- return text if text else "[PDF: Текстовый слой не найден. Возможно, скан без OCR]"
+ res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ pdf_text = res.stdout.strip()
- # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (XLSX, CSV через Pandas)
+ img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
+ subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
+
+ page_png = f"{img_prefix}-1.png"
+ b64_str = None
+ if os.path.exists(page_png):
+ with open(page_png, "rb") as pf:
+ b64_str = base64.b64encode(pf.read()).decode('utf-8')
+ os.remove(page_png)
+
+ context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
+ if pdf_text:
+ context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
+
+ return {
+ "text": context_text,
+ "image_b64": b64_str
+ }
+
+ # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
elif ext in ['.xlsx', '.xls', '.csv']:
if ext == '.csv':
df = pd.read_csv(temp_filepath)
@@ -39,23 +55,34 @@ def extract_text_from_file(file_bytes: bytes, filename: str) -> str:
df = pd.read_excel(temp_filepath)
total_rows = len(df)
- df_preview = df.head(100) # Показываем первые 100 строк
-
+ df_preview = df.head(100)
table_str = df_preview.to_string(index=False)
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
- return f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}"
+ return {
+ "text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
+ "image_b64": None
+ }
- # 4. ТЕКСТОВЫЕ ФАЙЛЫ (TXT, LOG, JSON)
+ # 4. ТЕКСТОВЫЕ ФАЙЛЫ
elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
- return tf.read().strip()
+ return {
+ "text": tf.read().strip(),
+ "image_b64": None
+ }
else:
- return f"[ОШИБКА: Формат {ext} не поддерживается для анализа]"
+ return {
+ "text": f"[ОШИБКА: Формат {ext} не поддерживается]",
+ "image_b64": None
+ }
except Exception as e:
logger.error(f"Ошибка при анализе файла {filename}: {e}")
- return f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]"
+ return {
+ "text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
+ "image_b64": None
+ }
finally:
if os.path.exists(temp_filepath):
os.remove(temp_filepath)
\ No newline at end of file
diff --git a/llm/schemas.py b/llm/schemas.py
index da7325f..33c1eb1 100644
--- a/llm/schemas.py
+++ b/llm/schemas.py
@@ -3,7 +3,7 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_tasks",
- "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ПРОЕКТА. Вызывай ТОЛЬКО когда пользователь просит показать задачи, бэклог или список дел.",
+ "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
"parameters": {
"type": "object",
"properties": {
@@ -27,7 +27,7 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_system_prompt",
- "description": "ПОЛУЧИТЬ ТЕКУЩИЙ СИСТЕМНЫЙ ПРОМПТ ИИ (system_prompts). Вызывай когда пользователь просит показать системный промпт, инструкции ассистента или промпт из базы.",
+ "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!",
"parameters": {"type": "object", "properties": {}}
}
},
@@ -62,21 +62,35 @@ TOOLS_SCHEMA = [
}
},
{
- "type": "function",
- "function": {
- "name": "db_get_snapshots",
- "description": "ПОЛУЧИТЬ СНИМКИ/СНАПШОТЫ СКУД (из таблицы scud_logs). Вызывай, когда пользователь просит показать снапшоты, срезы или логи за дату/день недели.",
- "parameters": {
- "type": "object",
- "properties": {
- "date_str": {
- "type": "string",
- "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '05.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
+ "type": "function",
+ "function": {
+ "name": "db_get_snapshots",
+ "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "date_str": {
+ "type": "string",
+ "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
+ }
}
}
}
- }
-},
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "db_delete_snapshots",
+ "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
+ "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
+ }
+ }
+ }
+ },
{
"type": "function",
"function": {
@@ -136,20 +150,6 @@ TOOLS_SCHEMA = [
"parameters": {"type": "object", "properties": {}}
}
},
- {
- "type": "function",
- "function": {
- "name": "db_delete_snapshots",
- "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
- "parameters": {
- "type": "object",
- "properties": {
- "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
- "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
- }
- }
- }
- },
{
"type": "function",
"function": {
@@ -170,7 +170,7 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_add_system_prompt",
- "description": "ВНУТРЕННИЙ СИСТЕМНЫЙ ИНСТРУМЕНТ. ЗАПРЕЩЕНО вызывать напрямую при запросах пользователя на изменение промпта! Для ЛЮБЫХ изменений системного промпта ты ОБЯЗАН сначала вызвать db_preview_prompt_merge.",
+ "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
"parameters": {
"type": "object",
"properties": {
@@ -185,16 +185,16 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_preview_prompt_merge",
- "description": "ОБЯЗАТЕЛЬНЫЙ ИНСТРУМЕНТ для ЛЮБЫХ изменений системного промпта (добавление пунктов, удаление, форматирование, отступы). Вызывай его ВСЕГДА, когда пользователь просит изменить промпт. В параметре proposed_prompt передавай ИТОГОВЫЙ полный текст со всеми разделами целиком.",
+ "description": "Создать предварительное изменённое превью системного промпта перед сохранением.",
"parameters": {
"type": "object",
"properties": {
- "proposed_prompt": {
- "type": "string",
- "description": "Полный текст системного промпта, содержащий все разделы от 1 до 3 с учетом внесенных изменений."
+ "prompt_text": {
+ "type": "string",
+ "description": "Новый полный или частично измененный текст системного промпта."
}
},
- "required": ["proposed_prompt"]
+ "required": ["prompt_text"]
}
}
},
diff --git a/main.py b/main.py
index e33edfd..2ca8ae6 100644
--- a/main.py
+++ b/main.py
@@ -104,28 +104,6 @@ async def favicon():
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("/")
-
- # Игнорируем сканеры WordPress / PHP
- if any(clean_path.startswith(prefix) for prefix in ["wp-", "wordpress", "php", "cms", "shop"]):
- raise HTTPException(status_code=404, detail="Not Found")
-
- target = os.path.join("static", clean_path)
- if os.path.isfile(target):
- return FileResponse(target)
-
- filename = os.path.basename(clean_path)
- target_js = os.path.join("static/js", filename)
- if filename.endswith(".js") and os.path.isfile(target_js):
- return FileResponse(target_js, media_type="application/javascript")
-
- target_css = os.path.join("static/css", filename)
- if filename.endswith(".css") and os.path.isfile(target_css):
- return FileResponse(target_css, media_type="text/css")
-
- raise HTTPException(status_code=404, detail="File not found")
@app.post("/api/v1/auth/login")
def login(req: AuthRequest):
@@ -245,37 +223,36 @@ async def chat_endpoint(
file: Optional[UploadFile] = File(default=None),
current_user: dict = Depends(get_current_user)
):
- logging.info(f"=== [CHAT API] Входящий запрос от user_id={current_user['id']}, file={file.filename if file else 'None'} ===")
- file_content_text = ""
+ parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
- file_content_text = extract_text_from_file(file_bytes, file.filename)
+ parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history = process_chat_message(
user_id=current_user["id"],
user_message=message,
- file_context=file_content_text,
+ file_context=parsed_file["text"],
+ image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history}
-# ЕДИНЫЙ ГОСТЕВОЙ ЧАТ (FormData + Файлы)
@app.post("/api/v1/chat/guest")
async def guest_chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None)
):
- logging.info(f"=== [GUEST CHAT API] Входящий запрос, file={file.filename if file else 'None'} ===")
- file_content_text = ""
+ parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
- file_content_text = extract_text_from_file(file_bytes, file.filename)
+ parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history = process_chat_message(
user_id=0,
user_message=message,
- file_context=file_content_text,
+ file_context=parsed_file["text"],
+ image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history}
@@ -299,4 +276,4 @@ def serve_static_fallback(file_path: str):
if filename.endswith(".css") and os.path.isfile(target_css):
return FileResponse(target_css, media_type="text/css")
- raise HTTPException(status_code=404, detail="File not found")
\ No newline at end of file
+ raise HTTPException(status_code=404, detail="File not found")
diff --git a/scripts/diagnostics/inspect_db.py b/scripts/diagnostics/inspect_db.py
new file mode 100644
index 0000000..4f7a02f
--- /dev/null
+++ b/scripts/diagnostics/inspect_db.py
@@ -0,0 +1,47 @@
+import os
+import sqlite3
+
+# Автопоиск файла базы данных в проекте
+db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
+
+print("=" * 80)
+print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
+print("=" * 80)
+
+if not os.path.exists(db_path):
+ print(f"❌ Файл базы данных {db_path} не найден!")
+ exit(1)
+
+conn = sqlite3.connect(db_path)
+cursor = conn.cursor()
+
+# 1. Список всех таблиц и колонок
+cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+tables = [t[0] for t in cursor.fetchall()]
+
+print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
+print("-" * 80)
+for t_name in tables:
+ cursor.execute(f"PRAGMA table_info({t_name})")
+ cols = [c[1] for c in cursor.fetchall()]
+
+ cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
+ count = cursor.fetchone()[0]
+
+ print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
+
+# 2. Просмотр правил Базы Знаний
+if 'ai_knowledge_base' in tables:
+ print("\n" + "=" * 80)
+ print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
+ print("=" * 80)
+ cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
+ rules = cursor.fetchall()
+ if not rules:
+ print("Таблица ai_knowledge_base пуста.")
+ else:
+ for r_id, r_text, r_author in rules:
+ print(f" {r_id}. [{r_author}] {r_text}\n")
+
+conn.close()
+print("=" * 80)
\ No newline at end of file
diff --git a/scripts/diagnostics/inspect_files.py b/scripts/diagnostics/inspect_files.py
new file mode 100644
index 0000000..fa3a4f3
--- /dev/null
+++ b/scripts/diagnostics/inspect_files.py
@@ -0,0 +1,23 @@
+import os
+
+print("=" * 80)
+print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
+print("=" * 80)
+
+total_files = 0
+total_size = 0
+
+for root, dirs, files in os.walk('.'):
+ # Исключаем служебные каталоги
+ dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
+
+ for f in files:
+ p = os.path.join(root, f)
+ size = os.path.getsize(p)
+ total_files += 1
+ total_size += size
+ print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
+
+print("-" * 80)
+print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
+print("=" * 80)
\ No newline at end of file
diff --git a/scripts/diagnostics/make_code_snapshot.py b/scripts/diagnostics/make_code_snapshot.py
new file mode 100644
index 0000000..066bb1e
--- /dev/null
+++ b/scripts/diagnostics/make_code_snapshot.py
@@ -0,0 +1,31 @@
+import os
+
+OUTPUT_SNAPSHOT = "api_code_snapshot.md"
+
+# Расширения файлов для включения в снимок
+ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
+EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
+EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
+
+print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
+
+with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
+ out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
+
+ for root, dirs, files in os.walk('.'):
+ dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
+
+ for file in sorted(files):
+ ext = os.path.splitext(file)[1].lower()
+ if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
+ filepath = os.path.join(root, file)
+ out.write(f"## File: `{filepath}`\n")
+ out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
+ try:
+ with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
+ out.write(f.read())
+ except Exception as e:
+ out.write(f"// Ошибка чтения файла: {e}\n")
+ out.write("\n```\n\n")
+
+print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
\ No newline at end of file
diff --git a/scud_context_api.tar.gz b/scud_context_api.tar.gz
deleted file mode 100644
index c020912..0000000
Binary files a/scud_context_api.tar.gz and /dev/null differ
diff --git a/static/index.html b/static/index.html
index f977ce1..a39b46d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -173,6 +173,13 @@
+
+
+
+
Перетащите файл сюда
+
Поддерживаются PDF, изображения, таблицы, TXT
+
+
ИИ-Ассистент
@@ -205,8 +212,8 @@
+ placeholder="Команда, вопрос или перетащите файл сюда..."
+ class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar">
Отправить
diff --git a/static/js/app.js b/static/js/app.js
index 80abe00..4a727d9 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -15,58 +15,9 @@ document.addEventListener("DOMContentLoaded", () => {
if (userInputEl) {
userInputEl.addEventListener("input", function() {
- this.style.height = "auto";
- this.style.height = Math.min(this.scrollHeight, 80) + "px";
- });
-
- userInputEl.addEventListener("keydown", function(e) {
- // Отправка по Enter
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
-
- const val = this.value.trim();
- if (val) {
- // Сохраняем команду в историю
- if (inputHistory.length === 0 || inputHistory[inputHistory.length - 1] !== val) {
- inputHistory.push(val);
- if (inputHistory.length > 50) inputHistory.shift();
- localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
- }
- historyIndex = -1;
- }
-
- if (typeof sendMessage === "function") {
- sendMessage(e);
- }
- }
- // История: стрелка ВВЕРХ
- else if (e.key === "ArrowUp") {
- if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
- e.preventDefault();
- if (historyIndex === -1) {
- this.dataset.draft = this.value;
- }
- historyIndex++;
- this.value = inputHistory[inputHistory.length - 1 - historyIndex];
- this.dispatchEvent(new Event("input"));
- setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
- }
- }
- // История: стрелка ВНИЗ
- else if (e.key === "ArrowDown") {
- if (historyIndex !== -1) {
- e.preventDefault();
- if (historyIndex > 0) {
- historyIndex--;
- this.value = inputHistory[inputHistory.length - 1 - historyIndex];
- } else {
- historyIndex = -1;
- this.value = this.dataset.draft || "";
- }
- this.dispatchEvent(new Event("input"));
- setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
- }
- }
+ this.style.height = "24px";
+ const newHeight = Math.min(this.scrollHeight, 120);
+ this.style.height = newHeight + "px";
});
}
diff --git a/static/js/chat.js b/static/js/chat.js
index 6f3444a..f4f02bb 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -1,3 +1,11 @@
+// Вспомогательная функция для автоматического изменения высоты текстового поля (1-3 строки)
+function updateInputHeight(el) {
+ if (!el) return;
+ el.style.height = "24px";
+ const newHeight = Math.min(el.scrollHeight, 120);
+ el.style.height = newHeight + "px";
+}
+
let selectedFile = null;
function handleFileSelect(e) {
@@ -57,7 +65,7 @@ async function sendMessage(e) {
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
input.value = "";
- input.style.height = "auto";
+ updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight;
if (sendBtn) {
@@ -70,7 +78,6 @@ async function sendMessage(e) {
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
- // Формируем единый FormData без дублирования
const formData = new FormData();
formData.append("session_id", "web_session_main");
formData.append("message", text || "Проанализируй прикрепленный файл");
@@ -142,4 +149,118 @@ function escapeHtml(text) {
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
-}
\ No newline at end of file
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ const input = document.getElementById("user-input");
+ const dropZone = document.getElementById("chat-window")?.parentElement;
+ const dropOverlay = document.getElementById("drop-overlay");
+
+ // --- 1. УМНАЯ НАВИГАЦИЯ СТРЕЛКАМИ В МНОГОСТРОЧНОМ ТЕКСТЕ ---
+ if (input) {
+ let historyIndex = -1;
+ let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
+
+ input.addEventListener("keydown", (e) => {
+ // Отправка по Enter без Shift
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (text) {
+ if (localHistory.length === 0 || localHistory[0] !== text) {
+ localHistory.unshift(text);
+ if (localHistory.length > 50) localHistory.pop();
+ localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
+ }
+ historyIndex = -1;
+ }
+ sendMessage(e);
+ updateInputHeight(input);
+ return;
+ }
+
+ // Стрелка ВВЕРХ
+ if (e.key === "ArrowUp") {
+ const textBeforeCursor = input.value.substring(0, input.selectionStart);
+ const isFirstLine = !textBeforeCursor.includes("\n");
+
+ // Переключаем историю ТОЛЬКО когда курсор на 1-й строке И уперся в самое начало (позиция 0)
+ if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
+ if (historyIndex < localHistory.length - 1) {
+ e.preventDefault();
+ if (historyIndex === -1) {
+ input.dataset.draft = input.value;
+ }
+ historyIndex++;
+ input.value = localHistory[historyIndex];
+ updateInputHeight(input);
+ input.setSelectionRange(input.value.length, input.value.length);
+ }
+ }
+ }
+
+ // Стрелка ВНИЗ
+ if (e.key === "ArrowDown") {
+ const textAfterCursor = input.value.substring(input.selectionEnd);
+ const isLastLine = !textAfterCursor.includes("\n");
+
+ // Переключаем историю ТОЛЬКО когда курсор на последней строке И уперся в самый конец
+ if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
+ e.preventDefault();
+ if (historyIndex > 0) {
+ historyIndex--;
+ input.value = localHistory[historyIndex];
+ } else {
+ historyIndex = -1;
+ input.value = input.dataset.draft || "";
+ }
+ updateInputHeight(input);
+ input.setSelectionRange(input.value.length, input.value.length);
+ }
+ }
+ });
+ }
+
+ // --- 2. ОБРАБОТКА DRAG-AND-DROP ФАЙЛОВ ---
+ if (dropZone && dropOverlay) {
+ ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
+ dropZone.addEventListener(eventName, (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, false);
+ });
+
+ ["dragenter", "dragover"].forEach(eventName => {
+ dropZone.addEventListener(eventName, () => {
+ dropOverlay.classList.remove("hidden");
+ dropOverlay.classList.add("flex");
+ }, false);
+ });
+
+ ["dragleave", "drop"].forEach(eventName => {
+ dropZone.addEventListener(eventName, (e) => {
+ if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
+ dropOverlay.classList.add("hidden");
+ dropOverlay.classList.remove("flex");
+ }
+ }, false);
+ });
+
+ dropZone.addEventListener("drop", (e) => {
+ const dt = e.dataTransfer;
+ const files = dt.files;
+
+ if (files && files.length > 0) {
+ const file = files[0];
+ handleFileSelect({ target: { files: [file] } });
+
+ const fileInput = document.getElementById("file-input");
+ if (fileInput) {
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ fileInput.files = dataTransfer.files;
+ }
+ }
+ }, false);
+ }
+});
\ No newline at end of file