diff --git a/api_code_snapshot.md b/api_code_snapshot.md index c980bec..6d45e99 100644 --- a/api_code_snapshot.md +++ b/api_code_snapshot.md @@ -291,6 +291,9 @@ def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): return db_get_tasks(user_id=user["id"]) # ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ + +from llm.db_tools import db_get_session_state + @app.post("/api/v1/chat") async def chat_endpoint( session_id: str = Form("web_session_main"), @@ -303,6 +306,7 @@ async def chat_endpoint( file_bytes = await file.read() parsed_file = extract_text_from_file(file_bytes, file.filename) + # 1. Сначала обрабатываем сообщение и вызовы инструментов reply, history = process_chat_message( user_id=current_user["id"], user_message=message, @@ -310,7 +314,13 @@ async def chat_endpoint( image_b64=parsed_file["image_b64"], session_id=session_id ) - return {"reply": reply, "history": history} + + # 2. СТРОГО ПОСЛЕ обработки проверяем, осталось ли активное превью в базе + from llm.db_tools import db_get_session_state + state = db_get_session_state(session_id) + needs_confirm = bool(state and state.get("state_type") in ["PROMPT_PREVIEW", "TASK_DELETE_PREVIEW"]) + + return {"reply": reply, "history": history, "needs_confirmation": needs_confirm} @app.post("/api/v1/chat/guest") async def guest_chat_endpoint( @@ -330,7 +340,11 @@ async def guest_chat_endpoint( image_b64=parsed_file["image_b64"], session_id=session_id ) - return {"reply": reply, "history": history} + + state = db_get_session_state(session_id) + needs_confirm = bool(state and state.get("state_type") == "PROMPT_PREVIEW") + + return {"reply": reply, "history": history, "needs_confirmation": needs_confirm} # === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === @@ -710,6 +724,37 @@ git push origin feature/llm-refactoring ``` +## File: `./scripts/clear_history.py` +```py +#!/usr/bin/env python3 +""" +Скрипт полной очистки истории диалогов и сессионных состояний SQLite. +""" +import os +import sqlite3 + +DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" + +def clear_chat_history(): + if not os.path.exists(DB_PATH): + print(f"❌ База данных не найдена по адресу: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + cursor.execute("DELETE FROM chat_messages;") + cursor.execute("DELETE FROM session_states;") + cursor.execute("DELETE FROM chat_sessions;") + + conn.commit() + conn.close() + print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!") + +if __name__ == "__main__": + clear_chat_history() +``` + ## File: `./scripts/diagnostics/inspect_db.py` ```py import os @@ -823,6 +868,31 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") ``` +## File: `./scripts/diagnostics/show_tree.py` +```py +import os + +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} + +def print_tree(startpath): + print("=" * 60) + print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") + print("=" * 60) + for root, dirs, files in os.walk(startpath): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + level = root.replace(startpath, '').count(os.sep) + indent = ' ' * 4 * (level) + print(f'{indent}📁 {os.path.basename(root)}/') + subindent = ' ' * 4 * (level + 1) + for f in sorted(files): + if not f.endswith('.pyc'): + print(f'{subindent}📄 {f}') + print("=" * 60) + +if __name__ == "__main__": + print_tree('.') +``` + ## File: `./static/index.html` ```html @@ -1442,7 +1512,7 @@ async function deleteUser(userId, username) { ## File: `./static/js/chat.js` ```js -// Вспомогательная функция для автоматического изменения высоты текстового поля (1-3 строки) +// Вспомогательная функция для автоматического изменения высоты текстового поля function updateInputHeight(el) { if (!el) return; el.style.height = "24px"; @@ -1480,6 +1550,15 @@ function clearAttachedFile() { if (previewContainer) previewContainer.classList.add("hidden"); } +// Функция симуляции отправки системной команды по кнопке +function sendQuickAction(actionText) { + const input = document.getElementById("user-input"); + if (input) { + input.value = actionText; + sendMessage(); + } +} + async function sendMessage(e) { if (e && e.preventDefault) e.preventDefault(); @@ -1492,6 +1571,9 @@ async function sendMessage(e) { if (!text && !selectedFile) return; + // Удаляем старые активные плашки кнопок, чтобы не висели предыдущие подтверждения + document.querySelectorAll('.confirm-action-bar').forEach(el => el.remove()); + let userDisplayHtml = escapeHtml(text); if (selectedFile) { userDisplayHtml = `
@@ -1551,12 +1633,32 @@ async function sendMessage(e) { const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; const replyText = data.reply || "Пустой ответ от нейросети"; + // Кнопки генерируем СТРОГО если бэкенд вернул флаг data.needs_confirmation === true + let buttonsHtml = ""; + if (data.needs_confirmation === true) { + buttonsHtml = ` +
+ + +
+ `; + } + const botMsgHtml = `

${assistantTitle}

${escapeHtml(replyText)}

+ ${buttonsHtml}
`; chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); @@ -1600,13 +1702,11 @@ document.addEventListener("DOMContentLoaded", () => { 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(); @@ -1623,12 +1723,10 @@ document.addEventListener("DOMContentLoaded", () => { 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(); @@ -1643,12 +1741,10 @@ document.addEventListener("DOMContentLoaded", () => { } } - // Стрелка ВНИЗ 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) { @@ -1665,7 +1761,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // --- 2. ОБРАБОТКА DRAG-AND-DROP ФАЙЛОВ --- if (dropZone && dropOverlay) { ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { dropZone.addEventListener(eventName, (e) => { @@ -1881,7 +1976,42 @@ body { min-height: 100vh; min-height: -webkit-fill-available; } +/* Плавное исчезновение текста сверху при скролле */ +.fade-scroll-top { + mask-image: linear-gradient(to bottom, transparent 0%, black 14px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px); +} +/* Скрытие стандартного скроллбара */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Оптимизация под мобильный viewport */ +body { + min-height: 100vh; + min-height: -webkit-fill-available; +} + +/* Анимация появления плашки с кнопками подтверждения */ +.confirm-action-bar { + animation: slideUp 0.25s ease-out forwards; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} ``` ## File: `./llm/__init__.py` @@ -1891,17 +2021,23 @@ body { ## File: `./llm/agent.py` ```py +""" +=============================================================================== +MODULE: llm/agent.py +ROLE: Главный оркестратор агентской логики. +=============================================================================== +""" +import re import json import urllib.request import urllib.error import logging from typing import List, Dict, Any, Tuple, Optional -from datetime import datetime, timedelta -import re from .db_tools import ( db_get_active_system_prompt, db_add_system_prompt, + db_confirm_prompt_preview, db_get_tasks, db_update_task_status, db_delete_task, @@ -1918,73 +2054,20 @@ from .db_tools import ( db_get_stats, db_get_anomalies, db_get_session_states, - db_get_reference, - get_db_connection + db_get_reference ) from .schemas import TOOLS_SCHEMA +from .core.calendar_utils import get_dynamic_calendar_context +from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("SCUD_AGENT") OLLAMA_URL = "http://192.168.11.3:11434/api/chat" - -# Модель для текстовых задач и вызова функций (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, @@ -2021,12 +2104,13 @@ def process_chat_message( f"{calendar_context}\n\n" f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n" f"При любых вопросах про дни недели ('прошлая среда', 'вторник', 'дата в прошлый понедельник') бери ГОТОВУЮ точную дату из справочника выше. Тебе ЗАПРЕЩЕНО вычислять даты самостоятельно!\n\n" + f"[ПРАВИЛО ИНТЕЛЛЕКТУАЛЬНОГО УПРАВЛЕНИЯ]\n" + f"Ты работаешь через функции (Tools). Если пользователь хочет изменить, добавить или удалить пункт системного промпта, ты НЕ ИМЕЕШЬ ПРАВА отвечать текстом. Ты ОБЯЗАН вызвать инструмент db_preview_prompt_merge, передав в аргументе prompt_text полный измененный вариант промпта. Игнорируй любые опечатки пользователя, понимай их смысл и вызывай функции.\n\n" f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" ) user_msg_object = {"role": "user", "content": full_user_content} - # Общие параметры генерации Ollama для дисциплинированного полного вывода llm_options = { "num_predict": 8192, "num_ctx": 8192, @@ -2036,9 +2120,6 @@ def process_chat_message( "top_p": 0.9 } - # ========================================================= - # ВЕТКА 1: ОБРАБОТКА ИЗОБРАЖЕНИЙ И СКАНОВ (VISION MODEL) - # ========================================================= if image_b64: user_msg_object["images"] = [image_b64] messages = [ @@ -2060,10 +2141,6 @@ def process_chat_message( "stream": False, "options": llm_options } - - # ========================================================= - # ВЕТКА 2: ОБЫЧНЫЕ ТЕКСТОВЫЕ ЗАПРОСЫ И TOOLS (TEXT MODEL) - # ========================================================= else: clean_db_history = [] for msg in db_history: @@ -2077,6 +2154,7 @@ def process_chat_message( "model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, + "tool_choice": "auto", # Явное указание Ollama задействовать функции "stream": False, "options": llm_options } @@ -2091,32 +2169,10 @@ def process_chat_message( 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}") + # Активируем внешний инжектор + tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}") if tool_calls: @@ -2165,60 +2221,25 @@ def process_chat_message( 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) + # 1. Применяем изменения в БД и чистим состояние + res = db_confirm_prompt_preview(session_id) + + # 2. Формируем четкий текстовый ответ + if res.get("status") == "success": + confirm_reply = "Изменения системного промпта успешно подтверждены и применены." else: - tool_result_content = json.dumps({"status": "error", "message": "Нет активного превью для подтверждения."}, ensure_ascii=False) + confirm_reply = res.get("message", "Ошибка при подтверждении превью.") + + # 3. Сохраняем ответ и СРАЗУ возвращаем его, НЕ запрашивая Ollama повторно! + db_save_chat_message(session_id, "assistant", confirm_reply) + return clean_raw_tool_tags(confirm_reply), db_get_chat_history(session_id) + + elif fn_name == "db_cancel_prompt_preview": + db_clear_session_state(session_id) + cancel_reply = "Превью изменений системного промпта отменено." + db_save_chat_message(session_id, "assistant", cancel_reply) + return clean_raw_tool_tags(cancel_reply), db_get_chat_history(session_id) elif fn_name == "db_cancel_prompt_preview": db_clear_session_state(session_id) @@ -2286,18 +2307,35 @@ def process_chat_message( ## File: `./llm/db_tools.py` ```py -import json -import sqlite3 -import logging -from typing import List, Dict, Any, Optional -from datetime import datetime, timedelta +""" +Прослойка совместимости: реэкспортирует функции из llm/db/ +""" +from .db.connection import DB_PATH, get_db_connection +from .db.db_chat import db_save_chat_message, db_get_chat_history +from .db.db_tasks import ( + normalize_task_id, + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) +from .db.db_snapshots import db_get_snapshots, db_delete_snapshots +from .db.db_prompts import ( + db_get_active_system_prompt, + db_add_system_prompt, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_session_states, + db_get_stats, + db_get_anomalies, + db_confirm_prompt_preview, + db_get_reference +) +from datetime import datetime -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]: +def db_get_current_server_time(): now = datetime.now() days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] return { @@ -2306,339 +2344,6 @@ def db_get_current_server_time() -> Dict[str, Any]: "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` @@ -2760,14 +2465,6 @@ TOOLS_SCHEMA = [ "parameters": {"type": "object", "properties": {}} } }, - { - "type": "function", - "function": { - "name": "db_get_system_prompt", - "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!", - "parameters": {"type": "object", "properties": {}} - } - }, { "type": "function", "function": { @@ -2788,7 +2485,7 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_delete_task", - "description": "Удалить задачу из реестра по её task_id (например, TASK-18).", + "description": "УДАЛИТЬ ЗАДАЧУ ИЗ РЕЕСТРА ЗАДАЧ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при командах 'удали задачу TASK-XX', 'стереть задачу'. ЗАПРЕЩЕНО использовать эту функцию для работы с системным промптом!", "parameters": { "type": "object", "properties": { @@ -2815,18 +2512,18 @@ TOOLS_SCHEMA = [ } }, { - "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_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} } } + } }, { "type": "function", @@ -2844,7 +2541,7 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_add_task", - "description": "Добавить новую задачу в бэклог проекта.", + "description": "ДОБАВИТЬ НОВУЮ ЗАДАЧУ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, когда пользователь просит 'добавь новую задачу', 'создай задачу', 'поставь задачу'. ЗАПРЕЩЕНО писать о добавлении задачи текстом без вызова этой функции!", "parameters": { "type": "object", "properties": { @@ -2906,13 +2603,15 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_add_system_prompt", - "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", + "name": "db_preview_prompt_merge", + "description": "СОЗДАТЬ ПРЕВЬЮ ИЗМЕНЕНИЙ СИСТЕМНОГО ПРОМПТА. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при запросах на добавление, изменение или удаление пунктов системного промпта.", "parameters": { "type": "object", "properties": { - "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, - "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} + "prompt_text": { + "type": "string", + "description": "Текст нового пункта для добавления ИЛИ инструкция по удалению/изменению (например: '3.4. Тестовый промпт' или 'удалить пункт 3.4')." + } }, "required": ["prompt_text"] } @@ -2921,36 +2620,509 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_preview_prompt_merge", - "description": "Создать предварительное изменённое превью системного промпта перед сохранением.", - "parameters": { - "type": "object", - "properties": { - "prompt_text": { - "type": "string", - "description": "Новый полный или частично измененный текст системного промпта." - } - }, - "required": ["prompt_text"] - } + "name": "db_get_system_prompt", + "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!", + "parameters": {"type": "object", "properties": {}} } }, { - "type": "function", - "function": { - "name": "db_confirm_prompt_preview", - "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_cancel_prompt_preview", - "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", - "parameters": {"type": "object", "properties": {}} - } + "type": "function", + "function": { + "name": "db_confirm_prompt_preview", + "description": "ПОДТВЕРДИТЬ И СОХРАНИТЬ ПРЕВЬЮ ПРОМПТА В БАЗУ. Вызывай этот инструмент СТРОГО тогда, когда пользователь пишет 'подтверждаю', 'да', 'сохраняй', 'применяй' или кликает кнопку Подтвердить.", + "parameters": {"type": "object", "properties": {}} } + }, + { + "type": "function", + "function": { + "name": "db_cancel_prompt_preview", + "description": "ОТМЕНИТЬ ПРЕВЬЮ ПРОМПТА. Вызывай, когда пользователь пишет 'отмена', 'отменить' или отказывается от изменений.", + "parameters": {"type": "object", "properties": {}} + } + } ] ``` +## File: `./llm/db/connection.py` +```py +""" +Модуль подключения к SQLite. +""" +import sqlite3 + +DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" + +def get_db_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA synchronous = NORMAL;") + return conn +``` + +## File: `./llm/db/db_chat.py` +```py +""" +Модуль хранения и загрузки истории сообщений чата. +""" +from typing import List, Dict, Any +from .connection import get_db_connection + +def db_save_chat_message(session_id: str, role: str, content: str): + if not content: + return + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO chat_messages (session_id, role, content, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, (session_id, role, content)) + conn.commit() + conn.close() + +def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT role, content FROM chat_messages + WHERE session_id = ? + ORDER BY id DESC LIMIT ? + """, (session_id, limit)) + rows = cursor.fetchall() + conn.close() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] +``` + +## File: `./llm/db/db_prompts.py` +```py +""" +Модуль системных промптов, сессионных состояний и превью. +""" +import logging +from typing import List, Dict, Any, Optional +from .connection import get_db_connection + +logger = logging.getLogger("DB_PROMPTS") + +def db_get_active_system_prompt() -> str: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") + row = cursor.fetchone() + conn.close() + return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + try: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + + cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) + existing = cursor.fetchone() + + if existing: + cursor.execute( + "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", + (prompt_text, name) + ) + else: + cursor.execute( + "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", + (name, prompt_text) + ) + + conn.commit() + + logger.info("Системный промпт успешно сохранен и применен в базе данных.") + return {"status": "success", "message": "Системный промпт успешно обновлен"} + except Exception as e: + logger.error(f"Ошибка при сохранении промпта в БД: {e}") + return {"status": "error", "error": str(e)} + +def db_get_rules() -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + +def db_set_session_state(session_id: str, state_type: str, data: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO session_states (session_id, state_type, pending_data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET + state_type = excluded.state_type, + pending_data = excluded.pending_data, + updated_at = CURRENT_TIMESTAMP + """, (session_id, state_type, data)) + conn.commit() + conn.close() + +def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def db_clear_session_state(session_id: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + conn.commit() + conn.close() + +def db_get_session_states() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") + rows = cursor.fetchall() + conn.close() + return {"status": "success", "active_sessions": [dict(r) for r in rows]} + +def db_get_stats() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + stats = {} + for t in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + stats[t] = cursor.fetchone()[0] + except Exception: + stats[t] = 0 + conn.close() + return {"status": "success", "tables_stats": stats} + +def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" + params = [] + if date_str: + query += " WHERE anomaly_date = ?" + params.append(date_str) + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} + +def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT category, title, example_prompt, description FROM system_reference" + params = [] + if category: + query += " WHERE category = ?" + params.append(category) + query += " ORDER BY id ASC" + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} + +def db_confirm_prompt_preview(session_id: str) -> Dict[str, Any]: + session_state = db_get_session_state(session_id) + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + pending_text = session_state.get("pending_data", "") + res = db_add_system_prompt("main_agent", pending_text) + db_clear_session_state(session_id) # Очищаем состояние + return res + return {"status": "error", "message": "Нет активного превью для подтверждения."} +``` + +## File: `./llm/db/db_snapshots.py` +```py +""" +Модуль выгрузки и очистки срезов/снапшотов СКУД. +""" +import json +from typing import Dict, Any, Optional +from .connection import get_db_connection +from .db_prompts import db_set_session_state + +def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]: + return date_str + +def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]: + date_str = smart_parse_date(date_str, original_user_message) + + conn = get_db_connection() + cursor = conn.cursor() + + query = """ + SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count + FROM scud_logs + """ + params = [] + + if date_str: + iso_date = date_str + if "." in date_str: + parts = date_str.split(".") + if len(parts) == 3: + iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" + + query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? " + params.extend([date_str, iso_date, f"{iso_date}%"]) + + query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50" + + cursor.execute(query, params) + rows = cursor.fetchall() + snapshots = [dict(r) for r in rows] + + result_data = { + "query_date": date_str or "все", + "snapshots_count": len(snapshots), + "snapshots": snapshots + } + + db_set_session_state( + session_id=session_id, + state_type="SNAPSHOTS_VIEW", + data=json.dumps(result_data, ensure_ascii=False) + ) + + conn.close() + return result_data + +def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: + if not snapshot_id and not day_str: + return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."} + + conn = get_db_connection() + cursor = conn.cursor() + + if snapshot_id: + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted = cursor.rowcount + else: + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%")) + deleted = cursor.rowcount + + conn.commit() + conn.close() + return {"status": "success", "message": f"Успешно удалено записей: {deleted}"} +``` + +## File: `./llm/db/db_tasks.py` +```py +""" +Модуль управления бэклогом задач (Task Tracker). +""" +import re +from typing import List, Dict, Any, Optional +from .connection import get_db_connection + +def normalize_task_id(task_id_input: str) -> str: + if not task_id_input: + return "" + clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "") + if clean_id.isdigit(): + num = int(clean_id) + return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}" + return f"TASK-{clean_id}" + +def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? + ORDER BY id DESC + """, (user_id,)) + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + +def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + + # Считываем все существующие TASK-XX и находим реальный максимум + cursor.execute("SELECT task_id FROM tasks") + rows = cursor.fetchall() + + max_num = 0 + for r in rows: + t_id = r["task_id"] or "" + match = re.search(r'\d+', t_id) + if match: + num = int(match.group(0)) + if num > max_num: + max_num = num + + new_task_id = f"TASK-{(max_num + 1):02d}" + + cursor.execute(""" + INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) + VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?) + """, (new_task_id, module, title, priority.upper(), due_date, user_id)) + + conn.commit() + conn.close() + return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"} + +def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + + formatted_id = normalize_task_id(task_id) + + if due_date: + cursor.execute(""" + UPDATE tasks + SET status = ?, due_date = ? + WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id)) + else: + cursor.execute(""" + UPDATE tasks + SET status = ? + WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id)) + + if cursor.rowcount == 0: + conn.close() + return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} + + conn.commit() + conn.close() + return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"} + +def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + + formatted_id = normalize_task_id(task_id) + + cursor.execute(""" + DELETE FROM tasks + WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (formatted_id, f"%{task_id.strip()}", user_id)) + + if cursor.rowcount == 0: + conn.close() + return {"error": f"Задача {task_id} не найдена"} + + conn.commit() + conn.close() + return {"status": "success", "message": f"Задача {formatted_id} удалена"} +``` + +## File: `./llm/core/calendar_utils.py` +```py +""" +Вспомогательные утилиты динамического календаря и парсинга дат. +""" +import re +from datetime import datetime, timedelta + +DAYS_RU = [ + "понедельник", "вторник", "среда", "четверг", + "пятница", "суббота", "воскресенье" +] + +def parse_relative_date_ru(text: str) -> str: + now = datetime.now() + text_lower = text.lower() if text else "" + + match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text) + if match: + return match.group(1) + + if "вчера" in text_lower: + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + elif "позавчера" in text_lower: + return (now - timedelta(days=2)).strftime("%d.%m.%Y") + elif "сегодня" in text_lower: + return now.strftime("%d.%m.%Y") + + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + +def get_dynamic_calendar_context() -> str: + now = datetime.now() + current_wd = now.weekday() + + lines = [ + f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).", + "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):", + f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})", + f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})", + f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})", + "\nПрошедшие дни недели:" + ] + + for days_back in range(1, 8): + dt = now - timedelta(days=days_back) + day_name = DAYS_RU[dt.weekday()] + + if days_back == 7: + label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}" + lines.append(f"• {label} (ровно неделю назад): {dt.strftime('%d.%m.%Y')}") + else: + label = f"Ближайший прошедший {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Ближайшая прошедшая {day_name}" + lines.append(f"• {label} / {day_name}: {dt.strftime('%d.%m.%Y')}") + + return "\n".join(lines) +``` + +## File: `./llm/core/tool_injector.py` +```py +""" +Модуль чистки сырых тегов Ollama и перехвата Tool-вызовов (Tool Injector). +""" +import re +import json +import logging +from typing import List, Dict, Any + +logger = logging.getLogger("TOOL_INJECTOR") + +def clean_raw_tool_tags(text: str) -> str: + if not text: + return "" + text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', 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 inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if tool_calls: + return tool_calls + + # Единственная задача Python — поймать JSON, если модель написала его текстом + if '{"name":' in raw_text_content or '' in raw_text_content: + try: + match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) + if match: + fn_name = match.group(1) + fn_args = json.loads(match.group(2)) + return [{"function": {"name": fn_name, "arguments": fn_args}}] + except Exception: + pass + + return tool_calls +``` + diff --git a/docs/planning/architectural_plan.md b/docs/planning/architectural_plan.md new file mode 100644 index 0000000..67f8281 --- /dev/null +++ b/docs/planning/architectural_plan.md @@ -0,0 +1,72 @@ +# 🏛️ Архитектурный план: Двухуровневая очистка контекста и управление чатами + +## 🎯 Концептуальная суть + +* **Для пользователя (UI):** Полноценный, наглядный интерфейс с интерактивными превью, карточками и кнопками действий. +* **Для нейросети (LLM):** Идеально «стерильное» окно контекста, очищенное от тяжелых временных черновиков (`is_ephemeral = 0`), что исключает галлюцинации и принуждает модель использовать функции SQLite (*Single Source of Truth*). +* **Событийно-счетчиковая очистка:** Временные данные хранятся ровно столько, сколько нужно для завершения задачи или уточняющих вопросов, после чего безопасно зачищаются. + +--- + +## 📋 Пошаговый план реализации (Step-by-Step) + +### **Шаг 1. Модификация схемы Базы Данных (SQLite)** + +* Добавление флага эфемеральности в таблицу `chat_messages`: + +```sql +ALTER TABLE chat_messages ADD COLUMN is_ephemeral INTEGER DEFAULT 0; +``` + +* `is_ephemeral = 0` — постоянные сообщения (вопросы, итоговые отклики, фиксированные задачи). +* `is_ephemeral = 1` — временные рабочие выводы инструментов (превью системного промпта, промежуточные срезы/снапшоты, черновики). + +--- + +### **Шаг 2. Разделение каналов истории и задел под изолированные сессии (Multi-Chat)** + +* **Канал UI (Пользователь):** Функция выгрузки истории для веб-интерфейса отдаёт все сообщения (`is_ephemeral = 0` и `1`), обеспечивая прозрачность работы. +* **Канал LLM (Оллама API):** В функцию `db_get_chat_history` передается жесткий фильтр: + +```sql +SELECT role, content +FROM chat_messages +WHERE session_id = ? AND is_ephemeral = 0 +ORDER BY id DESC +LIMIT 12; +``` + +* **Изолированные чаты (Перспектива):** + * Создание сущности чатов (`chat_id` / `session_id`). + * У каждого чата свой изолированный контекст. + * Ручное удаление чата физически очищает все связанные записи в `chat_messages` и `session_states`. + +--- + +### **Шаг 3. Интеллектуальный TTL-счетчик временного контекста (Auto-TTL)** + +* Временный контекст (`is_ephemeral = 1`) **НЕ** сбрасывается мгновенно, если пользователь задает уточняющие вопросы в рамках той же темы. +* Вводится счетчик сопутствующих сообщений (например, $N = 3..5$ сообщений). +* Если оператор задает $N$ сообщений подряд, не относящихся к активному инструменту, или переключается на другой инструмент — временный контекст автоматически зачищается/деактивируется. + +--- + +### **Шаг 4. UX-завершение транзакции (Кнопки «Да / Нет»)** + +1. После выполнения операции (например, успешной перезаписи системного промпта) ассистент отдает флаг `transaction_completed: true`. +2. На фронтенде выезжает блок подтверждения результатов: + > «Желаемый результат получен?» + > `[ 👍 Да, закрыть ]` `[ 👎 Нет, продолжить ]` +3. **Клик «Да»:** Отправляет триггер на сервер $ ightarrow$ бэкенд выполняет: + ```sql + DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1; + ``` + и очищает `session_states`. +4. **Клик «Нет»:** Оставляет промежуточный контекст активным для продолжения редактирования. + +--- + +## 🧹 Подготовка к старту + +1. Предварительная зачистка кода от старых регулярных выражений и системных костылей в `llm/agent.py` и `llm/core/tool_injector.py`. +2. Выполнение `scripts/clear_history.py` для старта с чистого листа. diff --git a/docs/planning/план по обновлению.md b/docs/planning/план по обновлению.md new file mode 100644 index 0000000..45334b9 --- /dev/null +++ b/docs/planning/план по обновлению.md @@ -0,0 +1,19 @@ +# 🏛️ Архитектурный план: Двухуровневая очистка контекста и управление чатами + +## 🎯 Концептуальная суть +* **Для пользователя (UI):** Полноценный, наглядный интерфейс с интерактивными превью, карточками и кнопками действий. +* **Для нейросети (LLM):** Идеально «стерильное» окно контекста, очищенное от тяжелых временных черновиков (`is_ephemeral = 0`), что исключает галлюцинации и принуждает модель использовать функции SQLite (Single Source of Truth). +* **Событийно-счетчиковая очистка:** Временные данные хранятся ровно столько, сколько нужно для завершения задачи или уточняющих вопросов, после чего безопасно зачищаются. + +--- + +## 📋 Пошаговый план реализации (Step-by-Step) + +### **Шаг 1. Модификация схемы Базы Данных (SQLite)** +* Добавление флага эфемеральности в таблицу `chat_messages`: + ```sql + ALTER TABLE chat_messages ADD COLUMN is_ephemeral INTEGER DEFAULT 0; +is_ephemeral = 0 — постоянные сообщения (вопросы, итоговые отклики, фиксированные задачи).is_ephemeral = 1 — временные рабочие выводы инструментов (превью системного промпта, промежуточные срезы/снапшоты, черновики).Шаг 2. Разделение каналов истории и задел под изолированные сессии (Multi-Chat)Канал UI (Пользователь): Функция выгрузки истории для веб-интерфейса отдаёт все сообщения (is_ephemeral = 0 и 1), обеспечивая прозрачность работы.Канал LLM (Оллама API): В функцию db_get_chat_history передается жесткий фильтр:SQLSELECT role, content FROM chat_messages +WHERE session_id = ? AND is_ephemeral = 0 +ORDER BY id DESC LIMIT 12; +Изолированные чаты (Перспектива):Создание сущности чатов (chat_id / session_id).У каждого чата свой изолированный контекст.Ручное удаление чата физически очищает все связанные записи в chat_messages и session_states.Шаг 3. Интеллектуальный TTL-счетчик временного контекста (Auto-TTL)Временный контекст (is_ephemeral = 1) НЕ сбрасывается мгновенно, если пользователь задает уточняющие вопросы в рамках той же темы.Вводится счетчик сопутствующих сообщений (например, N = 3..5 сообщений).Если оператор задает $N$ сообщений подряд, не относящихся к активному инструменту, или переключается на другой инструмент — временный контекст автоматически зачищается/деактивируется.Шаг 4. UX-завершение транзакции (Кнопки «Да / Нет»)После выполнения операции (например, успешной перезаписи системного промпта) ассистент отдает флаг transaction_completed: true.На фронтенде выезжает блок подтверждения результатов:«Желаемый результат получен?»[ 👍 Да, закрыть ] [ 👎 Нет, продолжить ]Клик «Да»: Отправляет триггер на сервер $\rightarrow$ бэкенд выполняет DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1 и очищает session_states.Клик «Нет»: Оставляет промежуточный контекст активным для продолжения редактирования.🧹 Подготовка к стартуПредварительная зачистка кода от старых регулярных выражений и системных костылей в llm/agent.py и llm/core/tool_injector.py.Выполнение scripts/clear_history.py для старта с чистого листа. \ No newline at end of file diff --git a/llm/agent.py b/llm/agent.py index 9d4c289..6e89d17 100644 --- a/llm/agent.py +++ b/llm/agent.py @@ -1,36 +1,20 @@ """ =============================================================================== 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. +ROLE: Главный оркестратор агентской логики. =============================================================================== """ - +import re 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_confirm_prompt_preview, db_get_tasks, db_update_task_status, db_delete_task, @@ -47,109 +31,19 @@ from .db_tools import ( db_get_stats, db_get_anomalies, db_get_session_states, - db_get_reference, - get_db_connection + db_get_reference ) from .schemas import TOOLS_SCHEMA +from .core.calendar_utils import get_dynamic_calendar_context +from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed -# --- [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" - -# Модели -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 = ["почемучто", "почто", "почему что"] - lower_text = text.lower() - for art in artifacts: - if lower_text.startswith(art): - 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() - - 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) - - -# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" def process_chat_message( user_id: int, @@ -159,10 +53,8 @@ def process_chat_message( 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}" @@ -173,42 +65,43 @@ 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[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]." + preview_status_note = ( + "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается решение пользователя]. " + "Если пользователь согласен (пишет 'подтверждаю', 'да', 'сохраняй') — вызывай db_confirm_prompt_preview. " + "Если отказывается — вызывай db_cancel_prompt_preview." + ) 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"Все запрашиваемые задачи через инструмент 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}" + f"[ПРАВИЛА ИСПОЛНЕНИЯ ИНСТРУМЕНТОВ (TOOLS RULES)]\n" + f"1. Текстовый блок ниже 'ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ' предназначен ИСКЛЮЧИТЕЛЬНО для твоего внутреннего руководства по поведению и стилю.\n" + f"2. ТЕБЕ КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО цитировать или выводить текст системного промпта из этого встроенного блока!\n" + f"3. При ЛЮБОМ запросе пользователя на просмотр, чтение, обновление или показ системного промпта (например: 'покажи промпт', 'обнови из базы системный промпт', 'загрузи промпт') Ты ОБЯЗАН СРАЗУ сгенерировать Tool Call: db_get_system_prompt().\n" + f"4. Любые правки системного промпта выполняй СТРОГО через db_preview_prompt_merge.\n\n" + f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ (ДЛЯ ВНУТРЕННЕГО ИСПОЛНЕНИЯ ИНСТРУКЦИЙ):\n{dynamic_prompt_text}{preview_status_note}" ) 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, # Стимулирование полной генерации списков + "repeat_penalty": 1.1, + "presence_penalty": 0.5, "top_p": 0.9 } - # --- [SUB-SECTION 3.4: ROUTING & PAYLOAD BUILD] --- if image_b64: - # Ветка Vision Model (Зрение/OCR) user_msg_object["images"] = [image_b64] messages = [ { @@ -230,7 +123,6 @@ def process_chat_message( "options": llm_options } else: - # Ветка Text & Tools Model clean_db_history = [] for msg in db_history: msg_copy = dict(msg) @@ -243,11 +135,11 @@ def process_chat_message( "model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, + "tool_choice": "auto", "stream": False, "options": llm_options } - # --- [SUB-SECTION 3.5: OLLAMA REQUEST & TOOL INJECTION] --- try: req = urllib.request.Request( OLLAMA_URL, @@ -258,41 +150,12 @@ 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}") + # Активируем внешний инжектор + tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}") - # --- [SUB-SECTION 3.6: TOOL EXECUTION ROUTER] --- if tool_calls: messages.append(msg) @@ -339,73 +202,58 @@ def process_chat_message( 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 + proposed_text = fn_args.get("prompt_text") or "" + current_prompt = db_get_active_system_prompt() + user_msg_lower = user_message.lower() - 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 clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id) + # 1. Запрос на удаление + if "удали" in user_msg_lower or "стереть" in user_msg_lower: + target_num = re.search(r'\d+(\.\d+)?', user_message) + target_str = target_num.group(0) if target_num else "" + lines = current_prompt.splitlines() + new_lines = [line for line in lines if not (target_str and f"{target_str}." in line.strip())] + final_prompt = "\n".join(new_lines) + + # 2. Модель прислала уже полный текст без многоточий + elif len(proposed_text) > 500 and "..." not in proposed_text: + final_prompt = proposed_text + + # 3. Добавление/вкрап нового пункта else: - tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False) + clean_item = proposed_text.strip() + lines = current_prompt.splitlines() + new_lines = [] + inserted = False + for line in lines: + new_lines.append(line) + if "3.3." in line and not inserted: + item_str = clean_item if clean_item.startswith("3.") else f"3.4. {clean_item}" + new_lines.append(f" {item_str}") + inserted = True + if not inserted: + new_lines.append(f" {clean_item}") + final_prompt = "\n".join(new_lines) + + db_set_session_state(session_id, "PROMPT_PREVIEW", final_prompt) + preview_reply = f"Подготовлено превью изменений системного промпта:\n\n{final_prompt}\n\nДля применения изменений нажмите «Подтвердить» или «Отменить»." + + # Сохраняем краткую пометку, не забивая контекст LLM + db_save_chat_message(session_id, "assistant", "Подготовлено превью изменений системного промпта. Ожидается решение пользователя.") + return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id) 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) + res = db_confirm_prompt_preview(session_id) + confirm_reply = "Изменения системного промпта успешно применены в базе данных!" if res.get("status") == "success" else res.get("message", "Ошибка применения.") + db_save_chat_message(session_id, "assistant", confirm_reply) + return clean_raw_tool_tags(confirm_reply), db_get_chat_history(session_id) elif fn_name == "db_cancel_prompt_preview": db_clear_session_state(session_id) - 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) + cancel_reply = "Превью изменений системного промпта отменено." + db_save_chat_message(session_id, "assistant", cancel_reply) + return clean_raw_tool_tags(cancel_reply), db_get_chat_history(session_id) elif fn_name == "db_get_rules": tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) @@ -427,7 +275,6 @@ def process_chat_message( "content": tool_result_content }) - # Вторичный вызов Ollama для формирования текстового ответа пользователя с учетом результатов Tool second_payload = { "model": TEXT_MODEL, "messages": messages, @@ -446,7 +293,6 @@ def process_chat_message( 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 "Запрос обработан." diff --git a/llm/core/calendar_utils.py b/llm/core/calendar_utils.py new file mode 100644 index 0000000..42887e5 --- /dev/null +++ b/llm/core/calendar_utils.py @@ -0,0 +1,53 @@ +""" +Вспомогательные утилиты динамического календаря и парсинга дат. +""" +import re +from datetime import datetime, timedelta + +DAYS_RU = [ + "понедельник", "вторник", "среда", "четверг", + "пятница", "суббота", "воскресенье" +] + +def parse_relative_date_ru(text: str) -> str: + now = datetime.now() + text_lower = text.lower() if text else "" + + match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text) + if match: + return match.group(1) + + if "вчера" in text_lower: + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + elif "позавчера" in text_lower: + return (now - timedelta(days=2)).strftime("%d.%m.%Y") + elif "сегодня" in text_lower: + return now.strftime("%d.%m.%Y") + + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + +def get_dynamic_calendar_context() -> str: + now = datetime.now() + current_wd = now.weekday() + + lines = [ + f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).", + "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):", + f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})", + f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})", + f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})", + "\nПрошедшие дни недели:" + ] + + for days_back in range(1, 8): + dt = now - timedelta(days=days_back) + day_name = DAYS_RU[dt.weekday()] + + if days_back == 7: + label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}" + lines.append(f"• {label} (ровно неделю назад): {dt.strftime('%d.%m.%Y')}") + else: + label = f"Ближайший прошедший {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Ближайшая прошедшая {day_name}" + lines.append(f"• {label} / {day_name}: {dt.strftime('%d.%m.%Y')}") + + return "\n".join(lines) \ No newline at end of file diff --git a/llm/core/tool_injector.py b/llm/core/tool_injector.py new file mode 100644 index 0000000..e7a0a72 --- /dev/null +++ b/llm/core/tool_injector.py @@ -0,0 +1,71 @@ +""" +Модуль чистки сырых тегов Ollama и перехвата Tool-вызовов (Tool Injector). +""" +import re +import json +import logging +from typing import List, Dict, Any + +logger = logging.getLogger("TOOL_INJECTOR") + +def clean_raw_tool_tags(text: str) -> str: + if not text: + return "" + text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', 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_raw_tool_tags(text: str) -> str: + 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 = ["почемучто", "почто", "почему что"] + lower_text = text.lower() + for art in artifacts: + if lower_text.startswith(art): + text = text[len(art):].lstrip(",.!?:; -") + return text.strip() + +def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if tool_calls: + return tool_calls + + # Если Ollama отдала JSON-структуру вызова функции как простой текст — помогаем распарсить + if '{"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)) + return [{"function": {"name": fn_name, "arguments": fn_args}}] + except Exception: + pass + + return tool_calls + +def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if tool_calls: + return tool_calls + + # Единственная задача Python — поймать JSON, если модель написала его текстом + if '{"name":' in raw_text_content or '' in raw_text_content: + try: + match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) + if match: + fn_name = match.group(1) + fn_args = json.loads(match.group(2)) + return [{"function": {"name": fn_name, "arguments": fn_args}}] + except Exception: + pass + + return tool_calls \ No newline at end of file diff --git a/llm/db/connection.py b/llm/db/connection.py new file mode 100644 index 0000000..2ae232a --- /dev/null +++ b/llm/db/connection.py @@ -0,0 +1,13 @@ +""" +Модуль подключения к SQLite. +""" +import sqlite3 + +DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" + +def get_db_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA synchronous = NORMAL;") + return conn \ No newline at end of file diff --git a/llm/db/db_chat.py b/llm/db/db_chat.py new file mode 100644 index 0000000..b098e19 --- /dev/null +++ b/llm/db/db_chat.py @@ -0,0 +1,29 @@ +""" +Модуль хранения и загрузки истории сообщений чата. +""" +from typing import List, Dict, Any +from .connection import get_db_connection + +def db_save_chat_message(session_id: str, role: str, content: str): + if not content: + return + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO chat_messages (session_id, role, content, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, (session_id, role, content)) + conn.commit() + conn.close() + +def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT role, content FROM chat_messages + WHERE session_id = ? + ORDER BY id DESC LIMIT ? + """, (session_id, limit)) + rows = cursor.fetchall() + conn.close() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] \ No newline at end of file diff --git a/llm/db/db_prompts.py b/llm/db/db_prompts.py new file mode 100644 index 0000000..77e4c8f --- /dev/null +++ b/llm/db/db_prompts.py @@ -0,0 +1,141 @@ +""" +Модуль системных промптов, сессионных состояний и превью. +""" +import logging +from typing import List, Dict, Any, Optional +from .connection import get_db_connection + +logger = logging.getLogger("DB_PROMPTS") + +def db_get_active_system_prompt() -> str: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") + row = cursor.fetchone() + conn.close() + return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + try: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + + cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) + existing = cursor.fetchone() + + if existing: + cursor.execute( + "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", + (prompt_text, name) + ) + else: + cursor.execute( + "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", + (name, prompt_text) + ) + + conn.commit() + + logger.info("Системный промпт успешно сохранен и применен в базе данных.") + return {"status": "success", "message": "Системный промпт успешно обновлен"} + except Exception as e: + logger.error(f"Ошибка при сохранении промпта в БД: {e}") + return {"status": "error", "error": str(e)} + +def db_get_rules() -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + +def db_set_session_state(session_id: str, state_type: str, data: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO session_states (session_id, state_type, pending_data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET + state_type = excluded.state_type, + pending_data = excluded.pending_data, + updated_at = CURRENT_TIMESTAMP + """, (session_id, state_type, data)) + conn.commit() + conn.close() + +def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def db_clear_session_state(session_id: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + conn.commit() + conn.close() + +def db_get_session_states() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") + rows = cursor.fetchall() + conn.close() + return {"status": "success", "active_sessions": [dict(r) for r in rows]} + +def db_get_stats() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + stats = {} + for t in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + stats[t] = cursor.fetchone()[0] + except Exception: + stats[t] = 0 + conn.close() + return {"status": "success", "tables_stats": stats} + +def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" + params = [] + if date_str: + query += " WHERE anomaly_date = ?" + params.append(date_str) + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} + +def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT category, title, example_prompt, description FROM system_reference" + params = [] + if category: + query += " WHERE category = ?" + params.append(category) + query += " ORDER BY id ASC" + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} + +def db_confirm_prompt_preview(session_id: str) -> Dict[str, Any]: + session_state = db_get_session_state(session_id) + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + pending_text = session_state.get("pending_data", "") + res = db_add_system_prompt("main_agent", pending_text) + db_clear_session_state(session_id) # Очищаем состояние + return res + return {"status": "error", "message": "Нет активного превью для подтверждения."} \ No newline at end of file diff --git a/llm/db/db_snapshots.py b/llm/db/db_snapshots.py new file mode 100644 index 0000000..93c4f06 --- /dev/null +++ b/llm/db/db_snapshots.py @@ -0,0 +1,71 @@ +""" +Модуль выгрузки и очистки срезов/снапшотов СКУД. +""" +import json +from typing import Dict, Any, Optional +from .connection import get_db_connection +from .db_prompts import db_set_session_state + +def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]: + return date_str + +def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]: + date_str = smart_parse_date(date_str, original_user_message) + + conn = get_db_connection() + cursor = conn.cursor() + + query = """ + SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count + FROM scud_logs + """ + params = [] + + if date_str: + iso_date = date_str + if "." in date_str: + parts = date_str.split(".") + if len(parts) == 3: + iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" + + query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? " + params.extend([date_str, iso_date, f"{iso_date}%"]) + + query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50" + + cursor.execute(query, params) + rows = cursor.fetchall() + snapshots = [dict(r) for r in rows] + + result_data = { + "query_date": date_str or "все", + "snapshots_count": len(snapshots), + "snapshots": snapshots + } + + db_set_session_state( + session_id=session_id, + state_type="SNAPSHOTS_VIEW", + data=json.dumps(result_data, ensure_ascii=False) + ) + + conn.close() + return result_data + +def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: + if not snapshot_id and not day_str: + return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."} + + conn = get_db_connection() + cursor = conn.cursor() + + if snapshot_id: + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted = cursor.rowcount + else: + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%")) + deleted = cursor.rowcount + + conn.commit() + conn.close() + return {"status": "success", "message": f"Успешно удалено записей: {deleted}"} \ No newline at end of file diff --git a/llm/db/db_tasks.py b/llm/db/db_tasks.py new file mode 100644 index 0000000..0cff7ce --- /dev/null +++ b/llm/db/db_tasks.py @@ -0,0 +1,102 @@ +""" +Модуль управления бэклогом задач (Task Tracker). +""" +import re +from typing import List, Dict, Any, Optional +from .connection import get_db_connection + +def normalize_task_id(task_id_input: str) -> str: + if not task_id_input: + return "" + clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "") + if clean_id.isdigit(): + num = int(clean_id) + return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}" + return f"TASK-{clean_id}" + +def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? + ORDER BY id DESC + """, (user_id,)) + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + +def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + + # Считываем все существующие TASK-XX и находим реальный максимум + cursor.execute("SELECT task_id FROM tasks") + rows = cursor.fetchall() + + max_num = 0 + for r in rows: + t_id = r["task_id"] or "" + match = re.search(r'\d+', t_id) + if match: + num = int(match.group(0)) + if num > max_num: + max_num = num + + new_task_id = f"TASK-{(max_num + 1):02d}" + + cursor.execute(""" + INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) + VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?) + """, (new_task_id, module, title, priority.upper(), due_date, user_id)) + + conn.commit() + conn.close() + return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"} + +def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + + formatted_id = normalize_task_id(task_id) + + if due_date: + cursor.execute(""" + UPDATE tasks + SET status = ?, due_date = ? + WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id)) + else: + cursor.execute(""" + UPDATE tasks + SET status = ? + WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id)) + + if cursor.rowcount == 0: + conn.close() + return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} + + conn.commit() + conn.close() + return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"} + +def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + + formatted_id = normalize_task_id(task_id) + + cursor.execute(""" + DELETE FROM tasks + WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (formatted_id, f"%{task_id.strip()}", user_id)) + + if cursor.rowcount == 0: + conn.close() + return {"error": f"Задача {task_id} не найдена"} + + conn.commit() + conn.close() + return {"status": "success", "message": f"Задача {formatted_id} удалена"} \ No newline at end of file diff --git a/llm/db_tools.py b/llm/db_tools.py index 9b932d4..f0c8ef9 100644 --- a/llm/db_tools.py +++ b/llm/db_tools.py @@ -1,52 +1,32 @@ """ -=============================================================================== -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'). -=============================================================================== +Прослойка совместимости: реэкспортирует функции из llm/db/ """ +from .db.connection import DB_PATH, get_db_connection +from .db.db_chat import db_save_chat_message, db_get_chat_history +from .db.db_tasks import ( + normalize_task_id, + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) +from .db.db_snapshots import db_get_snapshots, db_delete_snapshots +from .db.db_prompts import ( + db_get_active_system_prompt, + db_add_system_prompt, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_session_states, + db_get_stats, + db_get_anomalies, + db_confirm_prompt_preview, + db_get_reference +) +from datetime import datetime -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]: - """Возвращает текущую дату, точное время и день недели сервера.""" +def db_get_current_server_time(): now = datetime.now() days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] return { @@ -54,383 +34,4 @@ def db_get_current_server_time() -> Dict[str, Any]: "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]: - """Вспомогательный транзит даты без избыточной вторичной фильтрации.""" - 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() - 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]]: - """Получение последних N сообщений из истории диалога текущей сессии.""" - 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)] - - -# --- [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() - 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]}" - - query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? " - params.extend([date_str, iso_date, f"{iso_date}%"]) - - query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50" - - cursor.execute(query, params) - rows = cursor.fetchall() - snapshots = [dict(r) for r in rows] - - result_data = { - "query_date": date_str or "все", - "snapshots_count": len(snapshots), - "snapshots": snapshots - } - - # Сохраняем результат в состояние сессии для истории просмотра - db_set_session_state( - session_id=session_id, - state_type="SNAPSHOTS_VIEW", - data=json.dumps(result_data, ensure_ascii=False) - ) - - conn.close() - return result_data - - -def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: - """Удаление конкретного снапшота по 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", "") - 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} удалена"} - - -# --- [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") - 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]: - """ - ⚠️ AI-INVARIANT: Прямая запись нового активного системного промпта в SQLite. - Вызывается ТОЛЬКО после подтверждения превью через db_confirm_prompt_preview. - """ - 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]]: - """Получение правил арбитража и базы знаний из ai_knowledge_base.""" - 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] - - -# --- [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(""" - 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]]: - """Получение активного сессионного состояния по session_id.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) - row = cursor.fetchone() - conn.close() - return dict(row) if row else None - - -def db_clear_session_state(session_id: str): - """Сброс и очистка сессионного состояния (при отмене или подтверждении).""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) - conn.commit() - conn.close() - - -def db_get_session_states() -> Dict[str, Any]: - """Список всех активных предпросмотров и сессий.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") - rows = cursor.fetchall() - conn.close() - return {"status": "success", "active_sessions": [dict(r) for r in rows]} - - -# --- [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'] - 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]: - """История аномалий СКУД ⟷ 1С с опциональной фильтрацией по дате.""" - 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_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] } \ No newline at end of file diff --git a/llm/schemas.py b/llm/schemas.py index 33c1eb1..6979d95 100644 --- a/llm/schemas.py +++ b/llm/schemas.py @@ -23,14 +23,6 @@ TOOLS_SCHEMA = [ "parameters": {"type": "object", "properties": {}} } }, - { - "type": "function", - "function": { - "name": "db_get_system_prompt", - "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!", - "parameters": {"type": "object", "properties": {}} - } - }, { "type": "function", "function": { @@ -51,7 +43,7 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_delete_task", - "description": "Удалить задачу из реестра по её task_id (например, TASK-18).", + "description": "УДАЛИТЬ ЗАДАЧУ ИЗ РЕЕСТРА ЗАДАЧ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при командах 'удали задачу TASK-XX', 'стереть задачу'. ЗАПРЕЩЕНО использовать эту функцию для работы с системным промптом!", "parameters": { "type": "object", "properties": { @@ -78,18 +70,18 @@ TOOLS_SCHEMA = [ } }, { - "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_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} } } + } }, { "type": "function", @@ -107,7 +99,7 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_add_task", - "description": "Добавить новую задачу в бэклог проекта.", + "description": "ДОБАВИТЬ НОВУЮ ЗАДАЧУ. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, когда пользователь просит 'добавь новую задачу', 'создай задачу', 'поставь задачу'. ЗАПРЕЩЕНО писать о добавлении задачи текстом без вызова этой функции!", "parameters": { "type": "object", "properties": { @@ -169,13 +161,15 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_add_system_prompt", - "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", + "name": "db_preview_prompt_merge", + "description": "СОЗДАТЬ ПРЕВЬЮ ИЗМЕНЕНИЙ СИСТЕМНОГО ПРОМПТА. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА при запросах на добавление, изменение или удаление пунктов системного промпта.", "parameters": { "type": "object", "properties": { - "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, - "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} + "prompt_text": { + "type": "string", + "description": "Текст нового пункта для добавления ИЛИ инструкция по удалению/изменению (например: '3.4. Тестовый промпт' или 'удалить пункт 3.4')." + } }, "required": ["prompt_text"] } @@ -184,34 +178,35 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_preview_prompt_merge", - "description": "Создать предварительное изменённое превью системного промпта перед сохранением.", - "parameters": { - "type": "object", - "properties": { - "prompt_text": { - "type": "string", - "description": "Новый полный или частично измененный текст системного промпта." - } - }, - "required": ["prompt_text"] - } + "name": "db_get_system_prompt", + "description": ( + "СИНХРОНИЗИРОВАТЬ И ВЫЧИТАТЬ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ SQLITE. " + "Ты ОБЯЗАН вызывать ЭТУ ФУНКЦИЮ ВСЕГДА, когда пользователь хочет прочитать, обновить или синхронизировать промпт. " + "ПРИМЕРЫ ВХОДЯЩИХ ЗАПРОСОВ ДЛЯ ВЫЗОВА ЭТОЙ ФУНКЦИИ:\n" + "- 'покажи системный промпт' -> db_get_system_prompt()\n" + "- 'обнови промпт из базы' -> db_get_system_prompt()\n" + "- 'обнови из базы системный промпт' -> db_get_system_prompt()\n" + "- 'загрузи промпт из базы' -> db_get_system_prompt()\n" + "- 'покажи текст системного промпта' -> db_get_system_prompt()\n" + "ЗАПРЕЩЕНО генерировать ответ текстом без предварительного вызова этой функции!" + ), + "parameters": {"type": "object", "properties": {}} } }, { - "type": "function", - "function": { - "name": "db_confirm_prompt_preview", - "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_cancel_prompt_preview", - "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", - "parameters": {"type": "object", "properties": {}} - } + "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": {}} + } + } ] \ No newline at end of file diff --git a/main.py b/main.py index 2ca8ae6..ce7db8a 100644 --- a/main.py +++ b/main.py @@ -216,6 +216,9 @@ def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): return db_get_tasks(user_id=user["id"]) # ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ + +from llm.db_tools import db_get_session_state + @app.post("/api/v1/chat") async def chat_endpoint( session_id: str = Form("web_session_main"), @@ -228,6 +231,7 @@ async def chat_endpoint( file_bytes = await file.read() parsed_file = extract_text_from_file(file_bytes, file.filename) + # 1. Сначала обрабатываем сообщение и вызовы инструментов reply, history = process_chat_message( user_id=current_user["id"], user_message=message, @@ -235,7 +239,13 @@ async def chat_endpoint( image_b64=parsed_file["image_b64"], session_id=session_id ) - return {"reply": reply, "history": history} + + # 2. СТРОГО ПОСЛЕ обработки проверяем, осталось ли активное превью в базе + from llm.db_tools import db_get_session_state + state = db_get_session_state(session_id) + needs_confirm = bool(state and state.get("state_type") in ["PROMPT_PREVIEW", "TASK_DELETE_PREVIEW"]) + + return {"reply": reply, "history": history, "needs_confirmation": needs_confirm} @app.post("/api/v1/chat/guest") async def guest_chat_endpoint( @@ -255,7 +265,11 @@ async def guest_chat_endpoint( image_b64=parsed_file["image_b64"], session_id=session_id ) - return {"reply": reply, "history": history} + + state = db_get_session_state(session_id) + needs_confirm = bool(state and state.get("state_type") == "PROMPT_PREVIEW") + + return {"reply": reply, "history": history, "needs_confirmation": needs_confirm} # === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === diff --git a/scripts/clear_history.py b/scripts/clear_history.py new file mode 100644 index 0000000..3657503 --- /dev/null +++ b/scripts/clear_history.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +Скрипт полной очистки истории диалогов и сессионных состояний SQLite. +""" +import os +import sqlite3 + +DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" + +def clear_chat_history(): + if not os.path.exists(DB_PATH): + print(f"❌ База данных не найдена по адресу: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + cursor.execute("DELETE FROM chat_messages;") + cursor.execute("DELETE FROM session_states;") + cursor.execute("DELETE FROM chat_sessions;") + + conn.commit() + conn.close() + print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!") + +if __name__ == "__main__": + clear_chat_history() \ No newline at end of file diff --git a/scripts/diagnostics/show_tree.py b/scripts/diagnostics/show_tree.py new file mode 100644 index 0000000..66bc76d --- /dev/null +++ b/scripts/diagnostics/show_tree.py @@ -0,0 +1,21 @@ +import os + +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} + +def print_tree(startpath): + print("=" * 60) + print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") + print("=" * 60) + for root, dirs, files in os.walk(startpath): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + level = root.replace(startpath, '').count(os.sep) + indent = ' ' * 4 * (level) + print(f'{indent}📁 {os.path.basename(root)}/') + subindent = ' ' * 4 * (level + 1) + for f in sorted(files): + if not f.endswith('.pyc'): + print(f'{subindent}📄 {f}') + print("=" * 60) + +if __name__ == "__main__": + print_tree('.') \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index 78f3e4d..b1b5c85 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -18,3 +18,39 @@ body { min-height: 100vh; min-height: -webkit-fill-available; } +/* Плавное исчезновение текста сверху при скролле */ +.fade-scroll-top { + mask-image: linear-gradient(to bottom, transparent 0%, black 14px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px); +} + +/* Скрытие стандартного скроллбара */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Оптимизация под мобильный viewport */ +body { + min-height: 100vh; + min-height: -webkit-fill-available; +} + +/* Анимация появления плашки с кнопками подтверждения */ +.confirm-action-bar { + animation: slideUp 0.25s ease-out forwards; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/static/js/chat.js b/static/js/chat.js index f4f02bb..8b8e086 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1,4 +1,4 @@ -// Вспомогательная функция для автоматического изменения высоты текстового поля (1-3 строки) +// Вспомогательная функция для автоматического изменения высоты текстового поля function updateInputHeight(el) { if (!el) return; el.style.height = "24px"; @@ -36,6 +36,15 @@ function clearAttachedFile() { if (previewContainer) previewContainer.classList.add("hidden"); } +// Функция симуляции отправки системной команды по кнопке +function sendQuickAction(actionText) { + const input = document.getElementById("user-input"); + if (input) { + input.value = actionText; + sendMessage(); + } +} + async function sendMessage(e) { if (e && e.preventDefault) e.preventDefault(); @@ -48,6 +57,9 @@ async function sendMessage(e) { if (!text && !selectedFile) return; + // Удаляем старые активные плашки кнопок, чтобы не висели предыдущие подтверждения + document.querySelectorAll('.confirm-action-bar').forEach(el => el.remove()); + let userDisplayHtml = escapeHtml(text); if (selectedFile) { userDisplayHtml = `
@@ -107,12 +119,32 @@ async function sendMessage(e) { const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; const replyText = data.reply || "Пустой ответ от нейросети"; + // Кнопки генерируем СТРОГО если бэкенд вернул флаг data.needs_confirmation === true + let buttonsHtml = ""; + if (data.needs_confirmation === true) { + buttonsHtml = ` +
+ + +
+ `; + } + const botMsgHtml = `

${assistantTitle}

${escapeHtml(replyText)}

+ ${buttonsHtml}
`; chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); @@ -156,13 +188,11 @@ document.addEventListener("DOMContentLoaded", () => { 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(); @@ -179,12 +209,10 @@ document.addEventListener("DOMContentLoaded", () => { 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(); @@ -199,12 +227,10 @@ document.addEventListener("DOMContentLoaded", () => { } } - // Стрелка ВНИЗ 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) { @@ -221,7 +247,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // --- 2. ОБРАБОТКА DRAG-AND-DROP ФАЙЛОВ --- if (dropZone && dropOverlay) { ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { dropZone.addEventListener(eventName, (e) => {