diff --git a/modules/ai_engine/agent.py b/modules/ai_engine/agent.py new file mode 100644 index 0000000..16f55fb --- /dev/null +++ b/modules/ai_engine/agent.py @@ -0,0 +1,178 @@ +""" +=============================================================================== +FILE: modules/ai_engine/agent.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine +ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers + и менеджер свободных диалогов (Topic Drift). + +AI-CONTEXT-ANCHORS: + - ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message. +=============================================================================== +""" + +import sys +import json +import logging +from typing import List, Dict, Any, Tuple, Optional + +from modules.web_api.llm.schemas import TOOLS_SCHEMA +from modules.web_api.llm.core.ollama_client import call_ollama_chat +from modules.web_api.llm.core.fast_path import handle_fast_path_intercept +from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages +from modules.web_api.llm.db.db_prompts import ( + db_get_session_state, db_clear_session_state, db_set_session_state, + db_get_stats, db_get_anomalies, db_get_reference +) +from services.knowledge.service import get_rules + +from .context_builder import build_agent_system_context +from .handlers.task_handler import handle_tasks_call +from .handlers.prompt_handler import handle_prompt_call +from .handlers.snapshot_handler import handle_snapshots_call + +logger = logging.getLogger("AI_AGENT") +logger.setLevel(logging.INFO) + + +# ANCHOR[AGENT_PIPELINE_ENTRY] +def process_chat_message( + user_id: int, + user_message: str, + file_context: str = "", + image_b64: Optional[str] = None, + chat_history: List[Dict[str, Any]] = None, + session_id: str = "web_session_main" +) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Главный конвейер обработки сообщений чата.""" + logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}") + + session_state = db_get_session_state(session_id) + if not session_state: + db_purge_ephemeral_messages(session_id) + + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + + # 1. Быстрый Fast-Path перехват кнопок подтверждения + fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) + if fast_path_res: + return fast_path_res + + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_history = db_get_chat_history(session_id, limit=20) + system_prompt = build_agent_system_context(user_id, session_state) + + user_msg_obj = {"role": "user", "content": full_user_content} + + try: + if image_b64: + user_msg_obj["images"] = [image_b64] + messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj] + msg = call_ollama_chat(messages, is_vision=True) + else: + clean_history = [dict(m) for m in db_history] + for m in clean_history: m.pop("images", None) + messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) + + raw_reply = msg.get("content", "") + tool_calls = msg.get("tool_calls", []) + + # 2. Гибридный семантический классификатор намерений (Fallback Safety Net) + tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls) + + # 3. Исполнение инструментов через изолированные handlers + if tool_calls: + tool = tool_calls[0] + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + if isinstance(fn_args, str): + try: fn_args = json.loads(fn_args) + except Exception: fn_args = {} + + logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}") + close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") + mark_last_user_message_ephemeral(session_id) + + state_data = session_state.get("data_json") or {} if session_state else {} + + if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]: + return handle_tasks_call(fn_name, fn_args, user_id, session_id) + + elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]: + return handle_prompt_call(fn_name, fn_args, session_id) + + elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]: + return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data) + + elif fn_name == "db_get_rules": + res_str = json.dumps(get_rules(), ensure_ascii=False) + elif fn_name == "db_get_stats": + res_str = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": + res_str = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_reference": + res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + else: + res_str = "{}" + + messages.append(msg) + messages.append({"role": "tool", "content": res_str}) + sec_msg = call_ollama_chat(messages, is_vision=False) + final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен." + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) + return final_content, db_get_chat_history(session_id), None + + # 4. Обычный содержательный диалог и управление Topic Drift + final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан." + for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]: + if final_reply.lower().startswith(artifact): + final_reply = final_reply[len(artifact):].lstrip(",.!?:; -") + + action_payload = None + + # Обработка ответа "нет / спасибо" в режиме открытого инструмента + if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]): + close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL") + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), None + + # Инкремент счётчика шагов в сторону от инструмента (Topic Drift) + if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]: + state_type = session_state.get("state_type") + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} + + idle_turns = state_data.get("idle_turns", 0) + 1 + state_data["idle_turns"] = idle_turns + + if idle_turns >= 4: + # 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки + close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT") + elif idle_turns == 3: + # 3-й шаг: выводим вежливое напоминание с кнопками + tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД" + guard_question = f"Желаете продолжить работу с {tool_label}?" + final_reply += f"\n\n💡 *Напоминание:* {guard_question}" + action_payload = { + "type": "FOLLOW_UP_ACTION", + "buttons": [ + {"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"}, + {"label": "Завершить", "value": "нет, спасибо", "style": "secondary"} + ] + } + db_set_session_state(session_id, state_type, state_data) + else: + # 1-й и 2-й шаг: фиксируем обновленный счётчик + db_set_session_state(session_id, state_type, state_data) + + is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0 + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply) + return final_reply, db_get_chat_history(session_id), action_payload + + except Exception as ex: + logger.exception(f"Ошибка в агенте: {ex}") + return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None \ No newline at end of file diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py index f6dd033..40381d8 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -3,35 +3,136 @@ FILE: modules/web_api/llm/core/tool_injector.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Базовая санитарная очистка артефактов без эвристик и регулярных выражений. +ROLE: Семантический анализ намерений оператора (Intent Classifier) и + детерминированная сборка вызовов инструментов при сбоях нативного Function Calling. AI-CONTEXT-ANCHORS: - - ANCHOR[CLEAN_RAW_TOOLS]: Очистка строковых тегов. - - ANCHOR[PASS_THROUGH_TOOLS]: Чистый проходной интерфейс инструментов. + - ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed. =============================================================================== """ -# ANCHOR[CLEAN_RAW_TOOLS] +import re +import logging from typing import List, Dict, Any +logger = logging.getLogger("TOOL_INJECTOR") + def clean_raw_tool_tags(text: str) -> str: - """Удаляет только технические теги разметки, если они попали в текст.""" + """Удаляет сырые теги вызова инструментов и системный шум.""" if not text: return "" - return text.replace("", "").replace("", "").strip() + cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL) + cleaned = re.sub(r'<\|.*?\|>', '', cleaned) + return cleaned.strip() def clean_output(text: str) -> str: - """Возвращает текст ответа без изменения смысла.""" - if not text: - return "" - return text.strip() + """Очищает маркеры форматирования.""" + return text.strip() if text else "" -# ANCHOR[PASS_THROUGH_TOOLS] -def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +# ANCHOR[INTENT_INJECTOR_MAIN] +def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ - Чистый сквозной проход: все решения принимает исключительно языковая модель. + Гибридный семантический анализатор: + Если модель ответила текстом с сомнениями или пропустила tool_call, + распознает доменное намерение и конструирует синтетический tool_call. """ - return tool_calls \ No newline at end of file + if existing_tool_calls: + return existing_tool_calls + + msg_clean = user_message.strip().lower() + + # 1. СЕМАНТИКА: Просмотр системного промпта + # Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт" + if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]): + logger.info("[IntentInjector] Распознано намерение просмотра системного промпта") + return [{ + "function": { + "name": "db_get_system_prompt", + "arguments": {} + } + }] + + # 2. СЕМАНТИКА: Удаление задач + # Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37" + if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]): + task_match = re.search(r'#?\s*(\d+)', msg_clean) + if task_match: + task_id = task_match.group(1) + logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}") + return [{ + "function": { + "name": "db_tasks_edit", + "arguments": {"action": "DELETE", "task_id": task_id} + } + }] + + # 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление) + # Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта" + if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]): + node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message) + if node_matches: + formatted_nodes = [f"{s}.{i}" for s, i in node_matches] + logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}") + return [{ + "function": { + "name": "db_prompt_node_edit", + "arguments": { + "action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE", + "section_id": int(node_matches[0][0]), + "item_id": int(node_matches[0][1]), + "nodes_list": formatted_nodes, + "content": "" + } + } + }] + + # 4. СЕМАНТИКА: Добавление пункта промпта + # Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст" + if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]): + add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message) + if add_match: + sec_id = int(add_match.group(1)) + itm_id = int(add_match.group(2)) + content = add_match.group(3).strip() + logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}") + return [{ + "function": { + "name": "db_prompt_node_edit", + "arguments": { + "action": "ADD", + "section_id": sec_id, + "item_id": itm_id, + "content": content + } + } + }] + + # 5. СЕМАНТИКА: Смена статуса задач + if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]): + task_match = re.search(r'#?\s*(\d+)', msg_clean) + if task_match: + task_id = task_match.group(1) + logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}") + return [{ + "function": { + "name": "db_tasks_edit", + "arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"} + } + }] + + if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]): + task_match = re.search(r'#?\s*(\d+)', msg_clean) + if task_match: + task_id = task_match.group(1) + logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}") + return [{ + "function": { + "name": "db_tasks_edit", + "arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"} + } + }] + + return existing_tool_calls \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index f537f33..a21f5a3 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -2,263 +2,164 @@ - - SCUD Orion AI — Context Manager + + SCUD Orion AI — Управление и Аналитика + + - + - + - -
-
-
-
- -
-
-

SCUD Orion AI

-

Авторизация в системе

-
-
- -
-
- - -
- -
- - -
- - - - -
- -
-
-
Или
-
- - -
-
- - - - - - - - -
-
-
- -
-
-

SCUD Orion AI

- Task & Context API -
-
- -
- - - - - - - - - - - -
-
- - -
-
- -
-

- ИИ-Ассистент -

-

- Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи. -

-
-
- - - - - -
+ +
` + userDisplayHtml; } + const turnId = "turn-" + Date.now(); + const userMsgHtml = ` -
-
+
+
${userDisplayHtml}
`; - chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); + + // Вставляем вопрос строго перед невидимой распоркой + const spacer = document.getElementById("chat-bottom-spacer"); + if (spacer) { + spacer.insertAdjacentHTML("beforebegin", userMsgHtml); + } else { + chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); + } input.value = ""; updateInputHeight(input); - chatWindow.scrollTop = chatWindow.scrollHeight; + + // Скроллим сразу при отправке вопроса + setTimeout(() => scrollToTurnTop(turnId), 50); if (sendBtn) { sendBtn.disabled = true; @@ -384,7 +435,6 @@ async function sendMessage(e) { snapshotsWidgetHtml = renderSnapshotsCard(actionData.data); } - // ⭐️ БЛОК КАРТОЧКИ СКАЧИВАНИЯ ФАЙЛА let fileDownloadHtml = ""; if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") { fileDownloadHtml = ` @@ -409,20 +459,28 @@ async function sendMessage(e) { const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl"; const botMsgHtml = ` -
-

- ${assistantTitle} -

-
${replyText}
- ${previewEditorHtml} - ${interactiveWidgetHtml} - ${snapshotsWidgetHtml} - ${fileDownloadHtml} - ${actionButtonsHtml} -
- `; - chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; +
+

+ ${assistantTitle} +

+
${replyText}
+ ${previewEditorHtml} + ${interactiveWidgetHtml} + ${snapshotsWidgetHtml} + ${fileDownloadHtml} + ${actionButtonsHtml} +
+ `; + + // Вставляем карточку ответа строго перед невидимой распоркой + if (spacer) { + spacer.insertAdjacentHTML("beforebegin", botMsgHtml); + } else { + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + } + + // Скроллим к началу хода после рендера карточки ответа + setTimeout(() => scrollToTurnTop(turnId), 100); clearAttachedFile(); @@ -437,8 +495,12 @@ async function sendMessage(e) { Ошибка связи с сервером.
`; - chatWindow.insertAdjacentHTML("beforeend", errorHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; + if (spacer) { + spacer.insertAdjacentHTML("beforebegin", errorHtml); + } else { + chatWindow.insertAdjacentHTML("beforeend", errorHtml); + } + setTimeout(() => scrollToTurnTop(turnId), 50); setInputLocked(false); } finally { const inputEl = document.getElementById("user-input"); diff --git a/modules/web_api/static/js/tasks.js b/modules/web_api/static/js/tasks.js index b0ba7fe..a375a93 100644 --- a/modules/web_api/static/js/tasks.js +++ b/modules/web_api/static/js/tasks.js @@ -1,164 +1,117 @@ /** =============================================================================== FILE: modules/web_api/static/js/tasks.js -ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек. - -AI-CONTEXT-ANCHORS: - - ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели. - - ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED). - - ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks. - - ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню. +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / static / js +ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer). =============================================================================== */ -let currentFilter = 'ALL'; -let allTasks = []; +let currentTaskFilter = 'IN_PROGRESS'; -// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE] -function toggleDrawer() { - if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return; - const drawer = document.getElementById("task-drawer"); - const backdrop = document.getElementById("drawer-backdrop"); - if (!drawer) return; - - const isHidden = drawer.classList.contains("translate-x-full"); - if (isHidden) { - drawer.classList.remove("translate-x-full"); - if (backdrop) backdrop.classList.remove("hidden"); - loadTasks(); - } else { - drawer.classList.add("translate-x-full"); - if (backdrop) backdrop.classList.add("hidden"); - } -} - -// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER] -function setFilter(status) { - currentFilter = status; - ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => { - const btn = document.getElementById(`filter-${f}`); - if (btn) { - btn.className = (f === status) - ? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap" - : "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap"; - } - }); - renderTasks(); -} - -// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH] async function loadTasks() { - const badge = document.getElementById("task-count-badge"); - const container = document.getElementById("tasks-container"); - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); - - if (isGuest || !token) { - if (badge) badge.innerText = "0"; - return; - } + if (!token) return; try { const res = await fetch("/api/v1/tasks", { - headers: { - "Authorization": "Bearer " + token, - "Content-Type": "application/json" - } + headers: { "Authorization": "Bearer " + token } }); - - if (res.status === 401) { - if (typeof logout === 'function') logout(); - return; - } - - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - - const data = await res.json(); - - if (Array.isArray(data)) { - allTasks = data; - } else if (data && Array.isArray(data.tasks)) { - allTasks = data.tasks; - } else if (data && typeof data === 'object') { - allTasks = Object.values(data).find(val => Array.isArray(val)) || []; + if (res.ok) { + const data = await res.json(); + renderSidebarTasks(data.tasks || []); } else { - allTasks = []; - } - - if (badge) { - badge.innerText = allTasks.length.toString(); - } - - renderTasks(); - - } catch (err) { - console.error("[Tasks Error]", err); - if (badge) badge.innerText = "0"; - if (container) { - container.innerHTML = `
Ошибка обработки списка задач
`; + renderSidebarError("Ошибка доступа. Авторизуйтесь снова."); } + } catch (e) { + console.error("Ошибка загрузки задач:", e); + renderSidebarError("Ошибка сети. Сервер недоступен."); } } -// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM] -function renderTasks() { - const container = document.getElementById("tasks-container"); +function renderSidebarError(msg) { + // Поддерживаем оба варианта ID (новый и старый) для обратной совместимости + const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container"); + if (container) { + container.innerHTML = `
${msg}
`; + } +} + +function filterTasksByTab(status) { + currentTaskFilter = status; + + // Сброс стилей всех кнопок-вкладок + document.querySelectorAll('.task-tab-btn').forEach(btn => { + btn.classList.remove('text-indigo-600', 'bg-indigo-50'); + btn.classList.add('text-slate-600'); + }); + + // Установка активного стиля для выбранной вкладки + const activeBtnId = { + 'IN_PROGRESS': 'tab-in-progress', + 'BACKLOG': 'tab-backlog', + 'COMPLETED': 'tab-completed', + 'ALL': 'tab-all' + }[status]; + + if (activeBtnId) { + const btn = document.getElementById(activeBtnId); + if (btn) { + btn.classList.remove('text-slate-600', 'hover:bg-slate-100'); + btn.classList.add('text-indigo-600', 'bg-indigo-50'); + } + } + + loadTasks(); +} + +function renderSidebarTasks(tasks) { + const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container"); if (!container) return; - if (!Array.isArray(allTasks)) { - allTasks = []; + let filtered = tasks; + if (currentTaskFilter !== 'ALL') { + filtered = tasks.filter(t => t.status === currentTaskFilter); } - const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter); - if (filtered.length === 0) { - container.innerHTML = `
Нет задач с выбранным фильтром
`; + container.innerHTML = `
Нет задач в этой категории
`; return; } container.innerHTML = filtered.map(t => { - let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; - let statusLabel = "В планах"; - let cardBg = "bg-white"; + const isCompleted = t.status === 'COMPLETED'; + const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL' + ? 'text-rose-600 bg-rose-50 border-rose-200' + : t.priority === 'MEDIUM' + ? 'text-amber-600 bg-amber-50 border-amber-200' + : 'text-slate-600 bg-slate-50 border-slate-200'; - if (t.status === "COMPLETED") { - statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold"; - statusLabel = "Завершено"; - cardBg = "bg-emerald-50/20"; - } else if (t.status === "IN_PROGRESS") { - statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold"; - statusLabel = "В работе"; - cardBg = "bg-amber-50/20 border-amber-200"; - } - - let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200"; - if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold"; - - let dueDateHtml = t.due_date ? ` -
- - Срок: ${t.due_date} -
` : ""; - return ` -
-
-
- ${t.task_id || t.id || 'TASK'} - ${t.priority || 'MEDIUM'} -
- ${statusLabel} +
+ +
+ #${t.id} + ${isCompleted + ? ` Готово` + : `${t.priority || 'LOW'}` + }
-

${t.title || t.description || ''}

-
- - ${t.module || 'General'} + +
${escapeHtml(t.title || 'Без названия')}
+ +
+ ${escapeHtml(t.module || 'general')} + ${t.due_date ? `${escapeHtml(t.due_date)}` : ''}
- ${dueDateHtml}
`; - }).join(""); -} \ No newline at end of file + }).join(''); +} + +// Глобальная инициализация при загрузке DOM +document.addEventListener("DOMContentLoaded", () => { + // Небольшая задержка, чтобы гарантировать применение токена + setTimeout(loadTasks, 200); +}); \ No newline at end of file diff --git a/services/prompts/diff_engine.py b/services/prompts/diff_engine.py index ab0c677..1ba229a 100644 --- a/services/prompts/diff_engine.py +++ b/services/prompts/diff_engine.py @@ -10,16 +10,24 @@ AI-CONTEXT-ANCHORS: =============================================================================== """ -from typing import Tuple, Dict +from typing import Tuple, Dict, List, Set from core.connection import get_connection - -# ANCHOR[PROMPT_DIFF_BUILDER] -def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> Tuple[str, str]: - """ - Возвращает кортеж (merged_draft_text, diff_html_for_ui). - """ +def build_prompt_diff( + action: str, + section_id: int = None, + item_id: int = None, + content: str = "", + delete_nodes: List[Tuple[int, int]] = None, + prompt_name: str = "main_agent" +) -> Tuple[str, str]: act = (action or "ADD").upper() + nodes_to_delete: Set[Tuple[int, int]] = set() + + if delete_nodes: + nodes_to_delete.update(delete_nodes) + elif act == "DELETE" and section_id is not None and item_id is not None: + nodes_to_delete.add((section_id, item_id)) with get_connection(row_factory=True) as conn: cursor = conn.cursor() @@ -32,46 +40,40 @@ def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = existing_nodes = cursor.fetchall() nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes} + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k not in nodes_to_delete} - # Формируем словарь для чистого текста - nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (section_id, item_id)} if act == "DELETE" else dict(nodes_dict) - if act != "DELETE": + if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None: nodes_dict_for_draft[(section_id, item_id)] = content draft_lines = [] curr_sec = None for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): if i_id == 0: - if curr_sec is not None: - draft_lines.append("") + if curr_sec is not None: draft_lines.append("") draft_lines.append(f"{s_id}. {txt}") curr_sec = s_id else: draft_lines.append(f" {s_id}.{i_id}. {txt}") merged_prompt = "\n".join(draft_lines) - # Формируем HTML Diff diff_lines = [] curr_sec = None display_nodes = dict(nodes_dict) - if act != "DELETE": + if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None: display_nodes[(section_id, item_id)] = content for (s_id, i_id), txt in sorted(display_nodes.items()): if i_id == 0: - if curr_sec is not None: - diff_lines.append("") + if curr_sec is not None: diff_lines.append("") diff_lines.append(f"{s_id}. {txt}") curr_sec = s_id else: - if s_id == section_id and i_id == item_id: - if act == "DELETE": - line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' - else: - line_str = f' {s_id}.{i_id}. {txt}' + if (s_id, i_id) in nodes_to_delete: + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' + elif act == "ADD" and s_id == section_id and i_id == item_id: + line_str = f' {s_id}.{i_id}. {txt}' else: line_str = f" {s_id}.{i_id}. {txt}" diff_lines.append(line_str) - diff_html = "\n".join(diff_lines) - return merged_prompt, diff_html \ No newline at end of file + return merged_prompt, "\n".join(diff_lines) \ No newline at end of file diff --git a/services/prompts/service.py b/services/prompts/service.py index 2906e29..8111144 100644 --- a/services/prompts/service.py +++ b/services/prompts/service.py @@ -7,7 +7,7 @@ ROLE: Единый доменный сервис управления систе =============================================================================== """ -from typing import Tuple, Dict, Any +from typing import Tuple, Dict, Any, List, Optional from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt from .diff_engine import build_prompt_diff @@ -22,16 +22,28 @@ def apply_prompt_action(action: str, section_id: int, item_id: int, content: str repo_apply_prompt_action(action, section_id, item_id, content) -def save_full_prompt_draft(draft_text: str) -> None: +def save_full_prompt_draft(draft_text: str, prompt_name: str = "main_agent") -> None: """Сохранить полный черновик промпта.""" - repo_save_full_prompt(draft_text) + repo_save_full_prompt(draft_text, prompt_name=prompt_name) -def create_prompt_preview(action: str, section_id: int, item_id: int, content: str = "") -> Tuple[str, str, str]: +def create_prompt_preview( + action: str, + section_id: Optional[int] = None, + item_id: Optional[int] = None, + content: str = "", + delete_nodes: Optional[List[Tuple[int, int]]] = None +) -> Tuple[str, str, str]: """ Формирует черновик и diff. Возвращает (merged_draft, diff_html, baseline_prompt). """ baseline = repo_get_active_prompt() - draft, diff_html = build_prompt_diff(action, section_id, item_id, content) + draft, diff_html = build_prompt_diff( + action=action, + section_id=section_id, + item_id=item_id, + content=content, + delete_nodes=delete_nodes + ) return draft, diff_html, baseline \ No newline at end of file