From 0e33e61ab25ee74273e035be294b70a07005524d Mon Sep 17 00:00:00 2001 From: manoraga Date: Sat, 15 Aug 2026 12:45:02 +0300 Subject: [PATCH 1/6] chore(checkpoint): save working state before clean state context refactoring --- data/scud_orion_ai.db | Bin 6033408 -> 6062080 bytes data/scud_orion_ai.db-shm | Bin 32768 -> 0 bytes data/scud_orion_ai.db-wal | Bin 37112 -> 0 bytes docs/development_roadmap.md | 9 + modules/web_api/llm/agent.py | 111 +++- modules/web_api/llm/db/db_prompts.py | 104 +++- modules/web_api/llm/db_tools.py | 4 +- project_code_snapshot.md | 877 +++++++++++++++++++++++---- 8 files changed, 967 insertions(+), 138 deletions(-) delete mode 100644 data/scud_orion_ai.db-shm delete mode 100644 data/scud_orion_ai.db-wal diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index b24e2af9c8cb2e222a542d012801f96219ea4c21..fdd1888ae448365bc75f4fe38c66aa31be66cfaf 100644 GIT binary patch delta 4900 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 Zg&Hk{V(H`6M9D$}Nxkn2EOz+7{{dw~a*+T4 diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm deleted file mode 100644 index 5ca3d3a78fa4759f2d582ca16109910efed0ec02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*J!%3$7zW_+-*l47);1uyf)}u~$rWt1_Yyfm?xA3jHfbz_R$`}xt$t&**ewXM zY4yt!TBBdN0whV)FU@{_%R)Oc&$dxAXcm`PvSCn#aFUzvTLS zc1}L`QpoLR;j@qNte(<5S|i5GsNudZEH@-TfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1S%=eOzqUIWaWw`K!5-N0t5&UAV7csfyW8NHMBzDp9! diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal deleted file mode 100644 index ebf0535ed6d4ae7b451bd2495db84f6867d7dc36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37112 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 3: + guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" + final_content += guard_note + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=1) + return final_content, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW_GUARD", + "buttons": [ + {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, + {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + ] + } + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) return final_content, db_get_chat_history(session_id), None - # Если вызовов функций не было - raw_str = msg.get("content", "").strip().replace("**", "") + # Если вызовов инструментов не было (обычный текстовый диалог) + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") content_str = clean_raw_tool_tags(clean_output(raw_str)) final_reply = content_str or "Запрос обработан." + + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + idle_count = db_increment_session_idle(session_id) + logger.info(f"Текстовый диалог вне черновика. Текущий idle_turns: {idle_count}") + if idle_count >= 3: + guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" + final_reply += guard_note + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=1) + return final_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW_GUARD", + "buttons": [ + {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, + {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + ] + } + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) return final_reply, db_get_chat_history(session_id), None diff --git a/modules/web_api/llm/db/db_prompts.py b/modules/web_api/llm/db/db_prompts.py index e4de939..a3487f5 100644 --- a/modules/web_api/llm/db/db_prompts.py +++ b/modules/web_api/llm/db/db_prompts.py @@ -1,5 +1,10 @@ """ FILE: modules/web_api/llm/db/db_prompts.py +PROJECT: SCUD Orion AI +MODULE: web_api / llm / db +ROLE: Управление системными промптами, правилами базы знаний, + декларативным реестром действий инструментов (tool_action_registry) + и расширенным сессионным состоянием (session_states + idle_turns). """ import json import logging @@ -8,7 +13,9 @@ from .connection import get_db_connection logger = logging.getLogger("DB_PROMPTS") + def db_get_active_system_prompt() -> str: + """Извлекает актуальный активный системный промпт из SQLite.""" 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") @@ -16,6 +23,7 @@ def db_get_active_system_prompt() -> str: conn.close() return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: """Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite.""" conn = get_db_connection() @@ -35,7 +43,9 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: return res return None + def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + """Сохраняет новую версию системного промпта и активирует её.""" try: with get_db_connection() as conn: cursor = conn.cursor() @@ -58,7 +68,9 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: logger.error(f"Ошибка при сохранении промпта в БД: {e}") return {"status": "error", "error": str(e)} + def db_get_rules() -> List[Dict[str, Any]]: + """Извлекает список глобальных правил компании из ai_knowledge_base.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") @@ -66,9 +78,24 @@ def db_get_rules() -> List[Dict[str, Any]]: conn.close() return [dict(r) for r in rows] -def db_set_session_state(session_id: str, state_type: str, data: str): + +# ============================================================================= +# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS) +# ============================================================================= + +def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: + """ + Сохраняет состояние сессии в SQLite. + Если передан dict/list — автоматически сериализует его в JSON-строку. + """ conn = get_db_connection() cursor = conn.cursor() + + if isinstance(data, (dict, list)): + payload_str = json.dumps(data, ensure_ascii=False) + else: + payload_str = str(data) if data is not None else "" + cursor.execute(""" INSERT INTO session_states (session_id, state_type, pending_data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) @@ -76,26 +103,78 @@ def db_set_session_state(session_id: str, state_type: str, data: str): state_type = excluded.state_type, pending_data = excluded.pending_data, updated_at = CURRENT_TIMESTAMP - """, (session_id, state_type, data)) + """, (session_id, state_type, payload_str)) conn.commit() conn.close() + def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: + """ + Извлекает состояние сессии из SQLite. + Если в pending_data лежит валидный JSON-объект — парсит его в data_json. + """ conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + cursor.execute( + "SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", + (session_id,) + ) row = cursor.fetchone() conn.close() - return dict(row) if row else None + + if not row: + return None -def db_clear_session_state(session_id: str): + res = dict(row) + raw_data = res.get("pending_data") or "" + + try: + if raw_data.strip().startswith("{") or raw_data.strip().startswith("["): + res["data_json"] = json.loads(raw_data) + else: + res["data_json"] = None + except Exception: + res["data_json"] = None + + return res + + +def db_increment_session_idle(session_id: str) -> int: + """ + Инкрементирует счетчик idle_turns для активного черновика сессии. + Возвращает обновленное значение счетчика отвлечений. + """ + state = db_get_session_state(session_id) + if not state: + return 0 + + data_meta = state.get("data_json") + if isinstance(data_meta, dict): + current_turns = data_meta.get("idle_turns", 0) + 1 + data_meta["idle_turns"] = current_turns + db_set_session_state(session_id, state["state_type"], data_meta) + return current_turns + else: + # Если ранее данные были сохранены обычной строкой + new_meta = { + "draft_text": state.get("pending_data", ""), + "idle_turns": 1 + } + db_set_session_state(session_id, state["state_type"], new_meta) + return 1 + + +def db_clear_session_state(session_id: str) -> None: + """Сбрасывает и удаляет активное состояние сессии.""" 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") @@ -103,10 +182,19 @@ def db_get_session_states() -> Dict[str, Any]: 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'] + tables = [ + 'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', + 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks' + ] stats = {} for t in tables: try: @@ -117,7 +205,9 @@ def db_get_stats() -> Dict[str, Any]: conn.close() return {"status": "success", "tables_stats": stats} + def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + """Извлекает журнал зафиксированных аномалий.""" conn = get_db_connection() cursor = conn.cursor() query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" @@ -132,7 +222,9 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s 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" diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index 375a726..fc8ad23 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -14,11 +14,11 @@ from .db.db_prompts import ( db_set_session_state, db_get_session_state, db_clear_session_state, + db_increment_session_idle, db_get_session_states, db_get_stats, db_get_anomalies, - db_get_reference, - db_get_tool_action + db_get_reference ) def db_get_current_server_time(): diff --git a/project_code_snapshot.md b/project_code_snapshot.md index 71c9bbe..4af2c2c 100644 --- a/project_code_snapshot.md +++ b/project_code_snapshot.md @@ -741,6 +741,31 @@ from core.database import ( DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") +def print_tool_actions(): + """Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" + print("\n" + "=" * 110) + print("🧰 ДЕКЛАРАТИВНЫЙ РЕЕСТР ДЕЙСТВИЙ ИНСТРУМЕНТОВ (tool_action_registry):") + print("=" * 110) + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json + FROM tool_action_registry + WHERE is_active = 1 + ORDER BY id ASC + """) + rows = cursor.fetchall() + if not rows: + print("Таблица tool_action_registry пуста.") + else: + for r in rows: + print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}") + print(f" • Сообщение: {r[4]}") + if r[5]: + print(f" • Вопрос: {r[5]}") + print(f" • Кнопки: {r[6]}") + print("-" * 110) + print("=" * 110 + "\n") def print_stats(): """Выводит общую статистику по записям в таблицах БД.""" @@ -989,6 +1014,7 @@ CLI-утилита инспекции и управления SQLite базой anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite prompts -- Посмотреть системные промпты (system_prompts) + tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) sessions -- Посмотреть активные сессии и превью (session_states) dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день @@ -1000,6 +1026,7 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py scud --snapshot Y20260805-007 python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts + python scripts/db_cli.py tools python scripts/db_cli.py sessions python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py snapshot del --day 04.08.2026 @@ -1017,7 +1044,7 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False ) - parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot'], help="Основная команда") + parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда") parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") @@ -1048,6 +1075,8 @@ def main(): print_system_prompts() elif args.command == 'sessions': print_session_states() + elif args.command == 'tools': + print_tool_actions() elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) @@ -1165,6 +1194,115 @@ if __name__ == "__main__": fix_snapshots() ``` +## File: `./scripts/init_tool_registry.py` +```py +""" +Скрипт инициализации таблицы tool_action_registry в SQLite БД. +""" +import os +import sys +import json +import sqlite3 + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") + +def init_tool_action_registry(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # 1. Создаем таблицу + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tool_action_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_name TEXT UNIQUE NOT NULL, + category TEXT DEFAULT 'general', + bypass_llm INTEGER DEFAULT 1, + success_template TEXT NOT NULL, + follow_up_question TEXT, + action_type TEXT DEFAULT 'BUTTONS', + buttons_json TEXT, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 2. Начальное наполнение базовыми сценариями + seed_tools = [ + ( + "db_confirm_prompt_preview", + "prompt", + 1, + "✅ Системный промпт успешно сохранен и применен в базе данных.", + "Желаете продолжить работу с системным промптом?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать промпт", "value": "покажи системный промпт"}, + {"label": "Готово", "value": "нет, спасибо"} + ], ensure_ascii=False) + ), + ( + "db_cancel_prompt_preview", + "prompt", + 1, + "❌ Изменения системного промпта отменены.", + "Желаете продолжить работу с системным промптом?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать промпт", "value": "покажи системный промпт"}, + {"label": "Готово", "value": "нет, спасибо"} + ], ensure_ascii=False) + ), + ( + "db_delete_task", + "tasks", + 1, + "🗑 Задача успешно удалена из вашего реестра.", + "Хотите посмотреть обновленный список задач?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать задачи", "value": "покажи список задач"}, + {"label": "Готово", "value": "нет"} + ], ensure_ascii=False) + ), + ( + "db_delete_snapshots", + "snapshots", + 1, + "🗑 Выбранные снапшоты успешно удалены из базы данных.", + "Вывести актуальный список снапшотов?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать снапшоты", "value": "покажи список снапшотов"}, + {"label": "Готово", "value": "нет"} + ], ensure_ascii=False) + ) + ] + + for item in seed_tools: + cursor.execute(""" + INSERT INTO tool_action_registry ( + tool_name, category, bypass_llm, success_template, follow_up_question, action_type, buttons_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tool_name) DO UPDATE SET + category = excluded.category, + bypass_llm = excluded.bypass_llm, + success_template = excluded.success_template, + follow_up_question = excluded.follow_up_question, + action_type = excluded.action_type, + buttons_json = excluded.buttons_json, + updated_at = CURRENT_TIMESTAMP + """, item) + + conn.commit() + conn.close() + print("[✓] Таблица tool_action_registry успешно создана и наполнена!") + +if __name__ == "__main__": + init_tool_action_registry() + +``` + ## File: `./scripts/diagnostics/inspect_db.py` ```py import os @@ -3038,6 +3176,60 @@ def sync_zup_to_excel(target_date) -> bool: return False ``` +## File: `./docs/development_roadmap.md` +``` +# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI + +## 📌 Зафиксированные вехи и концептуальные архитектурные решения + +Схема веток Gitea для ближайшего развития проекта: + +main (100% стабильный релиз) + │ + ├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite) + │ ▼ + ├───────────────────────────────► merge to main + │ + ├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде) + │ ▼ + ├───────────────────────────────► merge to main + │ + └──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM) + ▼ + merge to main + + + + +### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) +* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM. +* **Решение (Сферическое / Фундаментальное):** + 1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`). + 2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст. + 3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`. + +--- + +## 🚀 Будущие модули и запланированный функционал (Backlog) + +### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine) +* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных. +* **Ключевые возможности:** + * Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`). + * Фиксация промежуточных комментариев и статусов выполнения по каждому пункту. + * Синхронизация задач плана с SQLite-таблицей `tasks`. + * Экспорт планов в Markdown/Excel и отображение на дашборде. + + +### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine) +* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных. +* **Ключевые возможности:** + * Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy). + * Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде. + * Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам. + * Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели. +``` + ## File: `./core/__init__.py` ```py @@ -3539,41 +3731,90 @@ def load_absences_from_db(date_str): ## File: `./modules/web_api/main.py` ```py +""" +=============================================================================== +FILE: modules/web_api/main.py +PROJECT: SCUD Orion AI (Unified Repository) +MODULE: web_api (FastAPI REST Server & Context Management) +ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), + маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. + +AI-CONTEXT-ANCHORS & INVARIANTS: + - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов + независимо от рабочей директории запуска (root или web_api). + - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного + рендеринга интерфейса и ассетов (css/js/favicon). + - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). + - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и + генерации ответов LLM (agent.process_chat_message). + +DEPENDENCIES: + - modules/web_api/llm/agent.py (process_chat_message) + - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) + - modules/web_api/llm/file_parser.py (extract_text_from_file) +=============================================================================== +""" + +# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] +import os +import sys import json import sqlite3 import logging import urllib.request -import os from datetime import datetime, timedelta from typing import List, Dict, Any, Optional +# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +if CURRENT_DIR not in sys.path: + sys.path.insert(0, CURRENT_DIR) + import jwt from passlib.context import CryptContext from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse +from fastapi.exceptions import RequestValidationError from pydantic import BaseModel +# Внутренние модули LLM и БД from llm.agent import process_chat_message from llm.db_tools import db_get_tasks, DB_PATH from llm.file_parser import extract_text_from_file +# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] logging.basicConfig( - level=logging.DEBUG, + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" ALGORITHM = "HS256" -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -MODEL_NAME = "qwen2.5:14b" pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") security = HTTPBearer() +STATIC_DIR = os.path.join(CURRENT_DIR, "static") + +app = FastAPI(title="SCUD Orion AI Context API", version="2.5") + +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": str(exc)} + ) + +# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] def get_db(): + """Создает безопасное соединение с SQLite БД модуля.""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn @@ -3603,21 +3844,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit headers={"WWW-Authenticate": "Bearer"}, ) -app = FastAPI(title="SCUD Orion AI Context API") - -if os.path.exists("static"): - app.mount("/static", StaticFiles(directory="static"), name="static") -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse - -@app.exception_handler(RequestValidationError) -async def validation_exception_handler(request, exc): - logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") - return JSONResponse( - status_code=422, - content={"detail": exc.errors(), "body": str(exc)} - ) - +# Pydantic-схемы валидации запросов class AuthRequest(BaseModel): username: str password: str @@ -3632,20 +3859,23 @@ class ChangePasswordRequest(BaseModel): old_password: str new_password: str -# === API МАРШРУТЫ === - +# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] @app.get("/") def read_root(): - return FileResponse("static/index.html") + """Отдает главную страницу панели управления.""" + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + raise HTTPException(status_code=404, detail="Frontend index.html not found") @app.get("/favicon.ico") async def favicon(): - file_path = os.path.join("static", "favicon.ico") + file_path = os.path.join(STATIC_DIR, "favicon.ico") if os.path.exists(file_path): return FileResponse(file_path) raise HTTPException(status_code=404) - +# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] @app.post("/api/v1/auth/login") def login(req: AuthRequest): username = req.username.strip().lower() @@ -3657,12 +3887,8 @@ def login(req: AuthRequest): user = cursor.fetchone() conn.close() - if not user: - logging.warning(f"===> Ошибка: Пользователь {username} не найден") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - if not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный пароль для {username}") + if not user or not pwd_context.verify(req.password, user["password_hash"]): + logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") is_admin = bool(user["is_admin"]) or (user["username"] == "puh") @@ -3690,7 +3916,7 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D conn.commit() conn.close() - logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}") + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") return {"status": "success", "message": "Пароль успешно изменен"} @app.get("/api/v1/admin/users") @@ -3752,11 +3978,13 @@ def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current logging.info(f"Удален пользователь ID: {user_id}") return {"status": "success", "message": "Пользователь удален"} +# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] @app.get("/api/v1/tasks") def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить задачи текущего авторизованного пользователя.""" return db_get_tasks(user_id=user["id"]) -# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ +# --- ЧАТ С АВТОРИЗАЦИЕЙ --- @app.post("/api/v1/chat") async def chat_endpoint( session_id: str = Form("web_session_main"), @@ -3769,15 +3997,17 @@ async def chat_endpoint( file_bytes = await file.read() parsed_file = extract_text_from_file(file_bytes, file.filename) - reply, history = process_chat_message( + reply, history, action_type = process_chat_message( user_id=current_user["id"], user_message=message, file_context=parsed_file["text"], image_b64=parsed_file["image_b64"], session_id=session_id ) - return {"reply": reply, "history": history} + return {"reply": reply, "history": history, "action_type": action_type} + +# --- ГОСТЕВОЙ ЧАТ --- @app.post("/api/v1/chat/guest") async def guest_chat_endpoint( session_id: str = Form("web_session_main"), @@ -3789,36 +4019,34 @@ async def guest_chat_endpoint( file_bytes = await file.read() parsed_file = extract_text_from_file(file_bytes, file.filename) - reply, history = process_chat_message( + reply, history, action_type = process_chat_message( user_id=0, user_message=message, file_context=parsed_file["text"], image_b64=parsed_file["image_b64"], session_id=session_id ) - return {"reply": reply, "history": history} - -# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === + return {"reply": reply, "history": history, "action_type": action_type} +# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") - target = os.path.join("static", clean_path) + target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): return FileResponse(target) filename = os.path.basename(clean_path) - target_js = os.path.join("static/js", filename) + target_js = os.path.join(STATIC_DIR, "js", filename) if filename.endswith(".js") and os.path.isfile(target_js): return FileResponse(target_js, media_type="application/javascript") - target_css = os.path.join("static/css", filename) + target_css = os.path.join(STATIC_DIR, "css", filename) if filename.endswith(".css") and os.path.isfile(target_css): return FileResponse(target_css, media_type="text/css") raise HTTPException(status_code=404, detail="File not found") - ``` ## File: `./modules/web_api/scripts/clear_history.py` @@ -3829,7 +4057,9 @@ def serve_static_fallback(file_path: str): import os import sqlite3 -DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" +# Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") def clear_chat_history(): if not os.path.exists(DB_PATH): @@ -3861,7 +4091,6 @@ def clear_chat_history(): if __name__ == "__main__": clear_chat_history() - ``` ## File: `./modules/web_api/scripts/diagnostics/inspect_db.py` @@ -4010,16 +4239,32 @@ if __name__ == "__main__": ## File: `./modules/web_api/llm/agent.py` ```py """ +=============================================================================== FILE: modules/web_api/llm/agent.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) +ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов + инструментов (Function Calling), интеграция с декларативным реестром + действий SQLite (tool_action_registry), детерминированный Fast-Path + для подтверждений, отслеживание Topic Drift и Context Guard с кнопками. +=============================================================================== """ + +# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] +import os +import sys import json import urllib.request +import urllib.error import logging +import re from typing import List, Dict, Any, Tuple, Optional +# Импорт фасада базы данных from .db_tools import ( db_get_active_system_prompt, db_add_system_prompt, + db_get_tool_action, db_get_tasks, db_update_task_status, db_delete_task, @@ -4027,12 +4272,14 @@ from .db_tools import ( db_get_rules, db_set_session_state, db_get_session_state, + db_clear_session_state, + db_increment_session_idle, db_get_snapshots, db_delete_snapshots, - db_clear_session_state, db_get_current_server_time, db_save_chat_message, db_get_chat_history, + db_purge_ephemeral_messages, db_get_stats, db_get_anomalies, db_get_session_states, @@ -4043,13 +4290,23 @@ from .schemas import TOOLS_SCHEMA from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru 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") +# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] logger = logging.getLogger("SCUD_AGENT") +logger.setLevel(logging.INFO) +logger.propagate = False + +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) OLLAMA_URL = "http://192.168.11.3:11434/api/chat" TEXT_MODEL = "qwen2.5:14b" VISION_MODEL = "qwen2.5vl:7b-q8_0" + +# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] def process_chat_message( user_id: int, user_message: str, @@ -4057,29 +4314,118 @@ def process_chat_message( image_b64: Optional[str] = None, chat_history: List[Dict[str, Any]] = None, session_id: str = "web_session_main" -) -> Tuple[str, List[Dict[str, Any]]]: +) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """ + Главный конвейер обработки входящего сообщения: + 1. Fast-Path перехват подтверждений/отмен при активном session_state. + 2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. + 3. Формирование системного контекста с учетом активного действия (черновика). + 4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. + 5. Проверка счетчика отвлечений (idle_turns) и Context Guard на 3-м шаге. + 6. Возврат кортежа: (reply_text, chat_history, action_metadata). + """ 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}" + session_state = db_get_session_state(session_id) + user_msg_clean = user_message.lower().strip(" .!?:;") + + # --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- + if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}") + + reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] --- + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + draft_text = "" + if session_state.get("data_json") and isinstance(session_state["data_json"], dict): + draft_text = session_state["data_json"].get("draft_text", "") + else: + draft_text = session_state.get("pending_data", "") + + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + db_add_system_prompt("main_agent", draft_text) + db_clear_session_state(session_id) + + action_cfg = db_get_tool_action("db_confirm_prompt_preview") + reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." + if action_cfg and action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", + "buttons": action_cfg.get("buttons", []) if action_cfg else [] + } + + elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: + db_clear_session_state(session_id) + + action_cfg = db_get_tool_action("db_cancel_prompt_preview") + reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." + if action_cfg and action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", + "buttons": action_cfg.get("buttons", []) if action_cfg else [] + } + + # 3.2. Сохраняем входящее сообщение в историю диалога + is_user_ephemeral = 1 if session_state else 0 + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) + + # 3.3. Извлекаем полную актуальную историю для передачи в LLM db_history = db_get_chat_history(session_id, limit=20) - db_save_chat_message(session_id, "user", full_user_content) dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() - session_state = db_get_session_state(session_id) - preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]." if session_state and session_state.get("state_type") == "PROMPT_PREVIEW" else "" user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + # Информируем модель о наличии незавершенного действия в сессии + active_state_context = "" + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + active_state_context = ( + "\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n" + "В данный момент оператор рассматривает подготовленный черновик системного промпта.\n" + "- Если оператор просит продолжить правки или уточняет детали по черновику — продолжай работу с ним.\n" + "- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\n" + ) + system_prompt_content = ( - f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n" - f"Вы общаетесь с пользователем: {user_info}.\n" - f"При запросах 'покажи мои задачи', 'список задач' — СРАЗУ вызывай db_get_tasks.\n\n" - f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" - f"{calendar_context}\n\n" - f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" + f"Ты — интеллектуальный ассистент SCUD Orion AI. " + f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" + f"[ОКРУЖЕНИЕ]\n" + f"- Пользователь: {user_info}\n" + f"- {calendar_context}\n" + f"{active_state_context}\n" + f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" + f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\n" + f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" + f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n" + f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n" + f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n" + f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n" + f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n" + f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n" + f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n" + f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" ) llm_options = { @@ -4093,10 +4439,11 @@ def process_chat_message( user_msg_object = {"role": "user", "content": full_user_content} + # --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD] if image_b64: user_msg_object["images"] = [image_b64] messages = [ - {"role": "system", "content": "Ты — модуль OCR. Перепиши весь текст с изображения буква в букву."}, + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."}, user_msg_object ] payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} @@ -4107,101 +4454,264 @@ def process_chat_message( messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} + # --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] try: - req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}) + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) with urllib.request.urlopen(req) as response: res_data = json.loads(response.read().decode("utf-8")) msg = res_data.get("message", {}) tool_calls = msg.get("tool_calls", []) raw_text_content = msg.get("content", "") - user_msg_lower = user_message.lower() - if not tool_calls: - if any(w in user_msg_lower for w in ["покажи системный промпт", "покажи промпт", "весь промпт"]): - tool_calls = [{"function": {"name": "db_get_system_prompt", "arguments": {}}}] - elif any(w in user_msg_lower for w in ["покажи снапшоты", "список снапшотов", "выведи снапшоты"]): - target_date = parse_relative_date_ru(user_message) - tool_calls = [{"function": {"name": "db_get_snapshots", "arguments": {"date_str": target_date}}}] - else: - tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + #tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") messages.append(msg) + for tool in tool_calls: fn_name = tool["function"]["name"] fn_args = tool["function"].get("arguments", {}) + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") tool_result_content = "" - if fn_name == "db_get_snapshots": + action_cfg = db_get_tool_action(fn_name) + + if fn_name == "db_confirm_prompt_preview": + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + draft_text = "" + if session_state.get("data_json") and isinstance(session_state["data_json"], dict): + draft_text = session_state["data_json"].get("draft_text", "") + else: + draft_text = session_state.get("pending_data", "") + + db_add_system_prompt("main_agent", draft_text) + db_clear_session_state(session_id) + + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + is_eph = action_cfg.get("is_ephemeral", 1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) + } + tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False) + else: + err_reply = "Нет активного превью для подтверждения." + db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1) + return err_reply, db_get_chat_history(session_id), None + + elif fn_name == "db_cancel_prompt_preview": + db_clear_session_state(session_id) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + is_eph = action_cfg.get("is_ephemeral", 1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) + } + tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False) + + elif fn_name == "db_get_snapshots": snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + elif fn_name == "db_get_current_server_time": tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + elif fn_name == "db_get_tasks": tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) + elif fn_name == "db_get_stats": tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_session_states": tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) + elif fn_name == "db_delete_snapshots": - tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")), ensure_ascii=False) + res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + is_eph = action_cfg.get("is_ephemeral", 1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) + } + tool_result_content = json.dumps(res, ensure_ascii=False) + elif fn_name == "db_get_reference": tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + + # --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC] elif fn_name == "db_preview_prompt_merge": proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" if isinstance(fn_args, str): proposed_text = fn_args - 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) - elif fn_name == "db_confirm_prompt_preview": - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - res = db_add_system_prompt("main_agent", session_state.get("pending_data", "")) - db_clear_session_state(session_id) - tool_result_content = json.dumps(res, ensure_ascii=False) - else: - tool_result_content = json.dumps({"status": "error", "message": "Нет активного превью."}, ensure_ascii=False) - elif fn_name == "db_cancel_prompt_preview": - db_clear_session_state(session_id) - tool_result_content = json.dumps({"status": "success", "message": "Превью отменено."}, ensure_ascii=False) + + current_prompt = db_get_active_system_prompt() + user_msg_lower = user_message.lower() + + # 1. Обработка удаления пункта + if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): + target_num_match = re.search(r'\d+(\.\d+)*', user_message) + target_num = target_num_match.group(0) if target_num_match else "" + + lines = current_prompt.splitlines() + if target_num: + new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] + else: + new_lines = lines + proposed_text = "\n".join(new_lines) + + # 2. Обработка добавления / изменения пункта + elif proposed_text: + if len(proposed_text) < 500: + clean_item = proposed_text.strip() + for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: + if prefix in clean_item.lower(): + clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).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 re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" + new_lines.append(f" {item_str}") + inserted = True + if not inserted: + new_lines.append(f" {clean_item}") + proposed_text = "\n".join(new_lines) + + # Сохраняем черновик в структурированном виде с idle_turns = 0 + state_payload = { + "draft_text": proposed_text, + "idle_turns": 0 + } + db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload) + + preview_reply = ( + f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" + f"{proposed_text}\n\n" + f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." + ) + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"} + ] + } + elif fn_name == "db_get_rules": tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + elif fn_name == "db_add_task": res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) tool_result_content = json.dumps(res, ensure_ascii=False) + elif fn_name == "db_update_task_status": res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) tool_result_content = json.dumps(res, ensure_ascii=False) + elif fn_name == "db_delete_task": res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + is_eph = action_cfg.get("is_ephemeral", 1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) + } tool_result_content = json.dumps(res, ensure_ascii=False) messages.append({"role": "tool", "content": tool_result_content}) + # --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS] second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(sec_req) as sec_response: sec_res_data = json.loads(sec_response.read().decode("utf-8")) - raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "") + raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "") final_content = clean_raw_tool_tags(clean_output(raw_content)) - db_save_chat_message(session_id, "assistant", final_content) - return final_content, db_get_chat_history(session_id) - raw_str = msg.get("content", "").strip().replace("**", "") + # Если была активна фоновая транзакция, инкрементируем счетчик Topic Shift + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + idle_count = db_increment_session_idle(session_id) + logger.info(f"Выполнен инструмент вне черновика. Текущий idle_turns: {idle_count}") + + if idle_count >= 3: + guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" + final_content += guard_note + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=1) + return final_content, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW_GUARD", + "buttons": [ + {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, + {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + ] + } + + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) + return final_content, db_get_chat_history(session_id), None + + # Если вызовов инструментов не было (обычный текстовый диалог) + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") content_str = clean_raw_tool_tags(clean_output(raw_str)) final_reply = content_str or "Запрос обработан." - db_save_chat_message(session_id, "assistant", final_reply) - return final_reply, db_get_chat_history(session_id) + + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + idle_count = db_increment_session_idle(session_id) + logger.info(f"Текстовый диалог вне черновика. Текущий idle_turns: {idle_count}") + if idle_count >= 3: + guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" + final_reply += guard_note + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=1) + return final_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW_GUARD", + "buttons": [ + {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, + {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + ] + } + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), None except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" - return error_reply, db_get_chat_history(session_id) + return error_reply, db_get_chat_history(session_id), None ``` ## File: `./modules/web_api/llm/db_tools.py` @@ -4211,16 +4721,18 @@ FILE: modules/web_api/llm/db_tools.py """ from datetime import datetime 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_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages 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_tool_action, db_get_rules, db_set_session_state, db_get_session_state, db_clear_session_state, + db_increment_session_idle, db_get_session_states, db_get_stats, db_get_anomalies, @@ -4361,8 +4873,12 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_get_system_prompt", - "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!", - "parameters": {"type": "object", "properties": {}} + "description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } } }, { @@ -4519,13 +5035,13 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_preview_prompt_merge", - "description": "Создать предварительное изменённое превью системного промпта перед сохранением.", + "description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').", "parameters": { "type": "object", "properties": { "prompt_text": { "type": "string", - "description": "Новый полный или частично измененный текст системного промпта." + "description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта." } }, "required": ["prompt_text"] @@ -4581,47 +5097,82 @@ def get_db_connection() -> sqlite3.Connection: ```py """ FILE: modules/web_api/llm/db/db_chat.py +ROLE: Управление историей сообщений и очисткой эфемерного контекста. """ 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 +def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None: + """Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных).""" 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)) + INSERT INTO chat_messages (session_id, role, content, is_ephemeral) + VALUES (?, ?, ?, ?) + """, (session_id, role, content, is_ephemeral)) 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 + SELECT role, content + FROM chat_messages WHERE session_id = ? - ORDER BY id DESC LIMIT ? + 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_purge_ephemeral_messages(session_id: str) -> int: + """ + Физически удаляет все временные служебные сообщения выбранной сессии + после завершения сценария работы с инструментом. + """ + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted + + +def db_clear_chat_history(session_id: str) -> None: + """Полная очистка всех сообщений сессии.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) + conn.commit() + conn.close() ``` ## File: `./modules/web_api/llm/db/db_prompts.py` ```py """ FILE: modules/web_api/llm/db/db_prompts.py +PROJECT: SCUD Orion AI +MODULE: web_api / llm / db +ROLE: Управление системными промптами, правилами базы знаний, + декларативным реестром действий инструментов (tool_action_registry) + и расширенным сессионным состоянием (session_states + idle_turns). """ +import json 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: + """Извлекает актуальный активный системный промпт из SQLite.""" 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") @@ -4629,7 +5180,29 @@ def db_get_active_system_prompt() -> str: conn.close() return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + +def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: + """Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT tool_name, category, bypass_llm, success_template, + follow_up_question, action_type, buttons_json, is_ephemeral + FROM tool_action_registry + WHERE tool_name = ? AND is_active = 1 + """, (tool_name,)) + row = cursor.fetchone() + conn.close() + + if row: + res = dict(row) + res["buttons"] = json.loads(res["buttons_json"]) if res.get("buttons_json") else [] + return res + return None + + def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + """Сохраняет новую версию системного промпта и активирует её.""" try: with get_db_connection() as conn: cursor = conn.cursor() @@ -4652,7 +5225,9 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: logger.error(f"Ошибка при сохранении промпта в БД: {e}") return {"status": "error", "error": str(e)} + def db_get_rules() -> List[Dict[str, Any]]: + """Извлекает список глобальных правил компании из ai_knowledge_base.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") @@ -4660,9 +5235,24 @@ def db_get_rules() -> List[Dict[str, Any]]: conn.close() return [dict(r) for r in rows] -def db_set_session_state(session_id: str, state_type: str, data: str): + +# ============================================================================= +# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS) +# ============================================================================= + +def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: + """ + Сохраняет состояние сессии в SQLite. + Если передан dict/list — автоматически сериализует его в JSON-строку. + """ conn = get_db_connection() cursor = conn.cursor() + + if isinstance(data, (dict, list)): + payload_str = json.dumps(data, ensure_ascii=False) + else: + payload_str = str(data) if data is not None else "" + cursor.execute(""" INSERT INTO session_states (session_id, state_type, pending_data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) @@ -4670,26 +5260,78 @@ def db_set_session_state(session_id: str, state_type: str, data: str): state_type = excluded.state_type, pending_data = excluded.pending_data, updated_at = CURRENT_TIMESTAMP - """, (session_id, state_type, data)) + """, (session_id, state_type, payload_str)) conn.commit() conn.close() + def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: + """ + Извлекает состояние сессии из SQLite. + Если в pending_data лежит валидный JSON-объект — парсит его в data_json. + """ conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + cursor.execute( + "SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", + (session_id,) + ) row = cursor.fetchone() conn.close() - return dict(row) if row else None + + if not row: + return None -def db_clear_session_state(session_id: str): + res = dict(row) + raw_data = res.get("pending_data") or "" + + try: + if raw_data.strip().startswith("{") or raw_data.strip().startswith("["): + res["data_json"] = json.loads(raw_data) + else: + res["data_json"] = None + except Exception: + res["data_json"] = None + + return res + + +def db_increment_session_idle(session_id: str) -> int: + """ + Инкрементирует счетчик idle_turns для активного черновика сессии. + Возвращает обновленное значение счетчика отвлечений. + """ + state = db_get_session_state(session_id) + if not state: + return 0 + + data_meta = state.get("data_json") + if isinstance(data_meta, dict): + current_turns = data_meta.get("idle_turns", 0) + 1 + data_meta["idle_turns"] = current_turns + db_set_session_state(session_id, state["state_type"], data_meta) + return current_turns + else: + # Если ранее данные были сохранены обычной строкой + new_meta = { + "draft_text": state.get("pending_data", ""), + "idle_turns": 1 + } + db_set_session_state(session_id, state["state_type"], new_meta) + return 1 + + +def db_clear_session_state(session_id: str) -> None: + """Сбрасывает и удаляет активное состояние сессии.""" 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") @@ -4697,10 +5339,19 @@ def db_get_session_states() -> Dict[str, Any]: 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'] + tables = [ + 'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', + 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks' + ] stats = {} for t in tables: try: @@ -4711,7 +5362,9 @@ def db_get_stats() -> Dict[str, Any]: conn.close() return {"status": "success", "tables_stats": stats} + def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + """Извлекает журнал зафиксированных аномалий.""" conn = get_db_connection() cursor = conn.cursor() query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" @@ -4726,7 +5379,9 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s 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" From 7ee7520c986187b513d431dfaef55178b714b922 Mon Sep 17 00:00:00 2001 From: manoraga Date: Sat, 15 Aug 2026 13:22:03 +0300 Subject: [PATCH 2/6] feat(llm): implement full ephemeral context purging and topic drift state guard --- modules/web_api/llm/agent.py | 86 ++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index 1149e49..fc5b762 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -20,7 +20,8 @@ import logging import re from typing import List, Dict, Any, Tuple, Optional -# Импорт фасада базы данных +# Импорт фасада базы данных и прямого подключения +from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, db_add_system_prompt, @@ -81,7 +82,7 @@ def process_chat_message( 2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. 3. Формирование системного контекста с учетом активного действия (черновика). 4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. - 5. Проверка счетчика отвлечений (idle_turns) и Context Guard на 3-м шаге. + 5. Проверка счетчика отвлечений (idle_turns), Context Guard на 3-м шаге и автоочистка при N > 3. 6. Возврат кортежа: (reply_text, chat_history, action_metadata). """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") @@ -114,7 +115,9 @@ def process_chat_message( if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: db_add_system_prompt("main_agent", draft_text) - db_clear_session_state(session_id) + + # Переводим сессию в режим follow-up диалога + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) action_cfg = db_get_tool_action("db_confirm_prompt_preview") reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." @@ -123,15 +126,16 @@ def process_chat_message( reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) return reply_text, db_get_chat_history(session_id), { "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", "buttons": action_cfg.get("buttons", []) if action_cfg else [] } elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_clear_session_state(session_id) + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) action_cfg = db_get_tool_action("db_cancel_prompt_preview") reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." @@ -140,8 +144,9 @@ def process_chat_message( reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) return reply_text, db_get_chat_history(session_id), { "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", "buttons": action_cfg.get("buttons", []) if action_cfg else [] @@ -160,11 +165,11 @@ def process_chat_message( # Информируем модель о наличии незавершенного действия в сессии active_state_context = "" - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: active_state_context = ( "\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n" - "В данный момент оператор рассматривает подготовленный черновик системного промпта.\n" - "- Если оператор просит продолжить правки или уточняет детали по черновику — продолжай работу с ним.\n" + "В данный момент оператор находится в процессе настройки системного промпта.\n" + "- Если оператор просит продолжить правки или уточняет детали — продолжай работу с ним.\n" "- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\n" ) @@ -250,7 +255,7 @@ def process_chat_message( draft_text = session_state.get("pending_data", "") db_add_system_prompt("main_agent", draft_text) - db_clear_session_state(session_id) + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) if action_cfg and action_cfg.get("bypass_llm"): reply_text = action_cfg["success_template"] @@ -271,7 +276,7 @@ def process_chat_message( return err_reply, db_get_chat_history(session_id), None elif fn_name == "db_cancel_prompt_preview": - db_clear_session_state(session_id) + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) if action_cfg and action_cfg.get("bypass_llm"): reply_text = action_cfg["success_template"] if action_cfg.get("follow_up_question"): @@ -374,6 +379,16 @@ def process_chat_message( "idle_turns": 0 } db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload) + + # Помечаем последнее сообщение пользователя как эфемерное + with get_db_connection() as conn_fix: + cursor_fix = conn_fix.cursor() + cursor_fix.execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn_fix.commit() preview_reply = ( f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" @@ -425,24 +440,32 @@ def process_chat_message( raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "") final_content = clean_raw_tool_tags(clean_output(raw_content)) - # Если была активна фоновая транзакция, инкрементируем счетчик Topic Shift - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + # Определяем, был ли вызов служебного инструмента просмотра промпта + tool_names_called = [t["function"]["name"] for t in tool_calls] + is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0 + + # Отслеживание отвлечений при активном процессе настройки + if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: idle_count = db_increment_session_idle(session_id) - logger.info(f"Выполнен инструмент вне черновика. Текущий idle_turns: {idle_count}") + logger.info(f"Смена темы ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}") - if idle_count >= 3: - guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" + if idle_count == 3: + guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?" final_content += guard_note - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) return final_content, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW_GUARD", + "type": "FOLLOW_UP_ACTION", "buttons": [ - {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, - {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + {"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"}, + {"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"} ] } + elif idle_count > 3: + logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).") + db_clear_session_state(session_id) + db_purge_ephemeral_messages(session_id) - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) return final_content, db_get_chat_history(session_id), None # Если вызовов инструментов не было (обычный текстовый диалог) @@ -450,20 +473,25 @@ def process_chat_message( content_str = clean_raw_tool_tags(clean_output(raw_str)) final_reply = content_str or "Запрос обработан." - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: idle_count = db_increment_session_idle(session_id) - logger.info(f"Текстовый диалог вне черновика. Текущий idle_turns: {idle_count}") - if idle_count >= 3: - guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" + logger.info(f"Текстовый диалог вне настройки ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}") + + if idle_count == 3: + guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?" final_reply += guard_note - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) return final_reply, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW_GUARD", + "type": "FOLLOW_UP_ACTION", "buttons": [ - {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, - {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + {"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"}, + {"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"} ] } + elif idle_count > 3: + logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).") + db_clear_session_state(session_id) + db_purge_ephemeral_messages(session_id) db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) return final_reply, db_get_chat_history(session_id), None From c0a5145604aa30401dd5583cabda0fa87c5d5903 Mon Sep 17 00:00:00 2001 From: manoraga Date: Sat, 15 Aug 2026 13:23:38 +0300 Subject: [PATCH 3/6] =?UTF-8?q?15.08.2026=2013:20=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D1=81=D1=8F=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=8D=D1=84=D0=B5=D0=BC=D0=B5=D1=80=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=D0=BC=D0=BE=D1=89=D1=8C=D1=8E=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=20=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0?= =?UTF-8?q?=20=D1=81=20=D0=BF=D0=BE=D0=BC=D0=BE=D1=89=D1=8C=D1=8E=20=D0=98?= =?UTF-8?q?=D0=98=20=D0=B8=20=D0=BF=D0=BE=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20"=D0=97?= =?UTF-8?q?=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B8=D1=82=D1=8C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/scud_orion_ai.db | Bin 6062080 -> 6062080 bytes data/scud_orion_ai.db-shm | Bin 0 -> 32768 bytes data/scud_orion_ai.db-wal | Bin 0 -> 346112 bytes scripts/diagnostics/inspect_ephemeral.py | 58 +++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 data/scud_orion_ai.db-shm create mode 100644 data/scud_orion_ai.db-wal create mode 100644 scripts/diagnostics/inspect_ephemeral.py diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index fdd1888ae448365bc75f4fe38c66aa31be66cfaf..7aa2bcec216168653c08e91a6393bb4bc916fe08 100644 GIT binary patch delta 986 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 Go&N^%v?wY7 diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe594b33dac4e42ef4130a58f8c9ea9b990e862c GIT binary patch literal 32768 zcmeI)SxyvD6oBC$5U~ZZQNacr0dd3u1O#y&z!9f+w_phFL7rTLYf-^<7$1CR0eDY` zN;;h~!308A``@JcR_;A@hHn8~`8h_clj&)J^1HPEXot_gX1^|#R#ra@4!vLhwAi;+ zni-!PD34bDF+Os~io6>&t9n@T)7@IFPg^>R_o?)JNqu}z&+l}$Tz4OL(PzZh?h%)F zmS(G-$9JoB-{$3BOEGc&akFlHPg%`~i+ZtVH6k|ZeA~Q>JLBwZ!^aHkw5k< zM|@vfBkrPp)>G7QDWHG?3Mim}0tzUgfC36Apnw7jD4>7>3Mim}0tzUgfC36Apnw7j zD4>7>3Mim}0tzUgfC36Apnw7jD4>7>3Mim}0tzUgfC36AuoZzRZg7*u>i+_4CEgeX zwqKx?{q%F1bBr^`1D7> z3MlZO2$VQXg;R_&!+oAM=+t{Tz#t=BWRg2P=4FHSX0iq*2+D$pbX?ObS* zNgP7~1r$&~0R?tTpoKkjG0b_cbDKxJ*sVsatAGLuD4>7>3Mim}0tzUgfC7aO*vGv> U^l6g{D4>7>3Mim}0{H@e02Db=*Z=?k literal 0 HcmV?d00001 diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..16609b646fb816886d8b8d151c16c7f3f2a617b3 GIT binary patch literal 346112 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%0ABuc1WUhsZ_zW6 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 Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: """ - Главный конвейер обработки входящего сообщения: - 1. Fast-Path перехват подтверждений/отмен при активном session_state. - 2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. - 3. Формирование системного контекста с учетом активного действия (черновика). - 4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. - 5. Проверка счетчика отвлечений (idle_turns), Context Guard на 3-м шаге и автоочистка при N > 3. - 6. Возврат кортежа: (reply_text, chat_history, action_metadata). + Главный конвейер диалога: + 1. Проверка Fast-Path команд (подтверждение, отмена, завершение). + 2. Формирование контекста и вызов Ollama LLM. + 3. Выполнение инструментов и обработка Context Guard. """ 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}" - + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message session_state = db_get_session_state(session_id) - user_msg_clean = user_message.lower().strip(" .!?:;") - # --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- - if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}") - - reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + # 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 - # --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] --- - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") - - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", draft_text) - - # Переводим сессию в режим follow-up диалога - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) - - action_cfg = db_get_tool_action("db_confirm_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - - is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } - - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) - - action_cfg = db_get_tool_action("db_cancel_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - - is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } - - # 3.2. Сохраняем входящее сообщение в историю диалога + # 2. Сохранение пользовательского сообщения is_user_ephemeral = 1 if session_state else 0 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) - - # 3.3. Извлекаем полную актуальную историю для передачи в LLM db_history = db_get_chat_history(session_id, limit=20) + # 3. Сборка системного промпта dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" - # Информируем модель о наличии незавершенного действия в сессии active_state_context = "" if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: active_state_context = ( "\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n" "В данный момент оператор находится в процессе настройки системного промпта.\n" - "- Если оператор просит продолжить правки или уточняет детали — продолжай работу с ним.\n" - "- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\n" + "- Если оператор просит продолжить правки — продолжай работу с ним.\n" + "- Если оператор переключился на другую тему — выполни его команду штатно.\n" ) system_prompt_content = ( @@ -181,307 +110,165 @@ def process_chat_message( f"- {calendar_context}\n" f"{active_state_context}\n" f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\n" + f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_preview_prompt_merge(prompt_text=...).\n" f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n" - f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n" - f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n" - f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n" - f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n" - f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n" - f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n" + f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\n\n" f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" ) - llm_options = { - "num_predict": 8192, - "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, - "top_p": 0.9 - } - user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD] - if image_b64: - user_msg_object["images"] = [image_b64] - messages = [ - {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."}, - user_msg_object - ] - payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} - else: - clean_db_history = [dict(m) for m in db_history] - for m in clean_db_history: - m.pop("images", None) - messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] - payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} - - # --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] + # --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER] try: - req = urllib.request.Request( - OLLAMA_URL, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req) as response: - res_data = json.loads(response.read().decode("utf-8")) - msg = res_data.get("message", {}) - tool_calls = msg.get("tool_calls", []) - raw_text_content = msg.get("content", "") + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + msg = call_ollama_chat(messages, is_vision=True) + else: + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) - #tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + tool_calls = msg.get("tool_calls", []) - if tool_calls: - logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") - messages.append(msg) - - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - tool_result_content = "" + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + messages.append(msg) + + for tool in tool_calls: + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") + tool_result_content = "" + action_cfg = db_get_tool_action(fn_name) - action_cfg = db_get_tool_action(fn_name) - - if fn_name == "db_confirm_prompt_preview": - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") + # ANCHOR[TASK_INTERACTIVE_DISPATCH] + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } - db_add_system_prompt("main_agent", draft_text) - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) - - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False) - else: - err_reply = "Нет активного превью для подтверждения." - db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1) - return err_reply, db_get_chat_history(session_id), None + elif fn_name == "db_preview_prompt_merge": + proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" + if isinstance(fn_args, str): + proposed_text = fn_args - elif fn_name == "db_cancel_prompt_preview": - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False) + merged_prompt = build_prompt_preview_merge(db_get_active_system_prompt(), user_message, proposed_text) + db_set_session_state(session_id, "PROMPT_PREVIEW", {"draft_text": merged_prompt, "idle_turns": 0}) - elif fn_name == "db_get_snapshots": - snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) - tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + with get_db_connection() as conn_fix: + conn_fix.cursor().execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn_fix.commit() - elif fn_name == "db_get_current_server_time": - tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + preview_reply = ( + f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" + f"{merged_prompt}\n\n" + f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." + ) + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"} + ] + } - elif fn_name == "db_get_tasks": - tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) - elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: - tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) + elif fn_name == "db_get_snapshots": + snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) + tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) - elif fn_name == "db_get_stats": - tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) - elif fn_name == "db_get_anomalies": - tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) - elif fn_name == "db_get_session_states": - tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": + tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) - elif fn_name == "db_delete_snapshots": - res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) + elif fn_name == "db_get_session_states": + tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) - elif fn_name == "db_get_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - # --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC] - elif fn_name == "db_preview_prompt_merge": - proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" - if isinstance(fn_args, str): - proposed_text = fn_args + elif fn_name == "db_get_reference": + tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - current_prompt = db_get_active_system_prompt() - user_msg_lower = user_message.lower() + elif fn_name == "db_add_task": + res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) + tool_result_content = json.dumps(res, ensure_ascii=False) - # 1. Обработка удаления пункта - if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): - target_num_match = re.search(r'\d+(\.\d+)*', user_message) - target_num = target_num_match.group(0) if target_num_match else "" - - lines = current_prompt.splitlines() - if target_num: - new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] - else: - new_lines = lines - proposed_text = "\n".join(new_lines) + elif fn_name == "db_update_task_status": + res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) + tool_result_content = json.dumps(res, ensure_ascii=False) - # 2. Обработка добавления / изменения пункта - elif proposed_text: - if len(proposed_text) < 500: - clean_item = proposed_text.strip() - for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: - if prefix in clean_item.lower(): - clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).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 re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" - new_lines.append(f" {item_str}") - inserted = True - if not inserted: - new_lines.append(f" {clean_item}") - proposed_text = "\n".join(new_lines) - - # Сохраняем черновик в структурированном виде с idle_turns = 0 - state_payload = { - "draft_text": proposed_text, - "idle_turns": 0 + elif fn_name == "db_delete_task": + res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1)) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) } - db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload) + tool_result_content = json.dumps(res, ensure_ascii=False) - # Помечаем последнее сообщение пользователя как эфемерное - with get_db_connection() as conn_fix: - cursor_fix = conn_fix.cursor() - cursor_fix.execute(""" - UPDATE chat_messages - SET is_ephemeral = 1 - WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') - """, (session_id,)) - conn_fix.commit() - - preview_reply = ( - f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" - f"{proposed_text}\n\n" - f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." - ) - db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW", - "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"} - ] + elif fn_name == "db_delete_snapshots": + res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1)) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) } + tool_result_content = json.dumps(res, ensure_ascii=False) - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + messages.append({"role": "tool", "content": tool_result_content}) - elif fn_name == "db_add_task": - res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) + # ANCHOR[SECONDARY_PASS] + sec_msg = call_ollama_chat(messages, is_vision=False) + raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") + final_content = clean_raw_tool_tags(clean_output(raw_content)) - elif fn_name == "db_update_task_status": - res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_delete_task": - res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) - - messages.append({"role": "tool", "content": tool_result_content}) - - # --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS] - second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} - sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(sec_req) as sec_response: - sec_res_data = json.loads(sec_response.read().decode("utf-8")) - raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "") - final_content = clean_raw_tool_tags(clean_output(raw_content)) - - # Определяем, был ли вызов служебного инструмента просмотра промпта - tool_names_called = [t["function"]["name"] for t in tool_calls] - is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0 - - # Отслеживание отвлечений при активном процессе настройки - if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: - idle_count = db_increment_session_idle(session_id) - logger.info(f"Смена темы ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}") - - if idle_count == 3: - guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?" - final_content += guard_note - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) - return final_content, db_get_chat_history(session_id), { - "type": "FOLLOW_UP_ACTION", - "buttons": [ - {"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"}, - {"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"} - ] - } - elif idle_count > 3: - logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).") - db_clear_session_state(session_id) - db_purge_ephemeral_messages(session_id) - - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) - return final_content, db_get_chat_history(session_id), None - - # Если вызовов инструментов не было (обычный текстовый диалог) - raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") - content_str = clean_raw_tool_tags(clean_output(raw_str)) - final_reply = content_str or "Запрос обработан." + tool_names_called = [t["function"]["name"] for t in tool_calls] + is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0 + # Topic Drift & Context Guard if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: idle_count = db_increment_session_idle(session_id) - logger.info(f"Текстовый диалог вне настройки ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}") + logger.info(f"Смена темы ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}") if idle_count == 3: guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?" - final_reply += guard_note - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), { + final_content += guard_note + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + return final_content, db_get_chat_history(session_id), { "type": "FOLLOW_UP_ACTION", "buttons": [ {"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"}, @@ -493,8 +280,35 @@ def process_chat_message( db_clear_session_state(session_id) db_purge_ephemeral_messages(session_id) - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), None + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + return final_content, db_get_chat_history(session_id), None + + # Обычный текстовый ответ без Tool Calls + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") + final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + + if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: + idle_count = db_increment_session_idle(session_id) + logger.info(f"Текстовый диалог вне настройки ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}") + + if idle_count == 3: + guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?" + final_reply += guard_note + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), { + "type": "FOLLOW_UP_ACTION", + "buttons": [ + {"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"}, + {"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"} + ] + } + elif idle_count > 3: + logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).") + db_clear_session_state(session_id) + db_purge_ephemeral_messages(session_id) + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), None except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py new file mode 100644 index 0000000..1649bce --- /dev/null +++ b/modules/web_api/llm/core/fast_path.py @@ -0,0 +1,95 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/fast_path.py +ROLE: Детерминированный быстрый перехват команд оператора без вызова LLM: + подтверждение, отмена черновиков и завершение диалога с очисткой памяти. +=============================================================================== +""" + +# ANCHOR[FAST_PATH_IMPORTS] +import logging +from typing import Dict, Any, Tuple, Optional + +from llm.db_tools import ( + db_add_system_prompt, + db_get_tool_action, + db_set_session_state, + db_clear_session_state, + db_purge_ephemeral_messages, + db_save_chat_message, + db_get_chat_history +) + +logger = logging.getLogger("FAST_PATH") + + +# ANCHOR[FAST_PATH_ROUTER] +def handle_fast_path_intercept( + session_id: str, + user_message: str, + full_user_content: str, + session_state: Optional[Dict[str, Any]] +) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: + """ + Проверяет входящее сообщение на детерминированные команды. + Если команда перехвачена — возвращает кортеж (reply_text, chat_history, metadata). + Если перехват не требуется — возвращает None. + """ + user_msg_clean = user_message.lower().strip(" .!?:;") + + # 1. Перехват завершения работы с очисткой эфемерных сообщений + if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}") + + reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # 2. Перехват подтверждения или отмены превью системного промпта + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + draft_text = "" + if session_state.get("data_json") and isinstance(session_state["data_json"], dict): + draft_text = session_state["data_json"].get("draft_text", "") + else: + draft_text = session_state.get("pending_data", "") + + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + db_add_system_prompt("main_agent", draft_text) + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + + action_cfg = db_get_tool_action("db_confirm_prompt_preview") + reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." + if action_cfg and action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + + is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", + "buttons": action_cfg.get("buttons", []) if action_cfg else [] + } + + elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + + action_cfg = db_get_tool_action("db_cancel_prompt_preview") + reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." + if action_cfg and action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + + is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", + "buttons": action_cfg.get("buttons", []) if action_cfg else [] + } + + return None \ No newline at end of file diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py new file mode 100644 index 0000000..a6fd772 --- /dev/null +++ b/modules/web_api/llm/core/ollama_client.py @@ -0,0 +1,61 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/ollama_client.py +ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. +=============================================================================== +""" + +# ANCHOR[OLLAMA_CLIENT_IMPORTS] +import json +import urllib.request +import urllib.error +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger("OLLAMA_CLIENT") + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +LLM_OPTIONS = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 +} + + +# ANCHOR[OLLAMA_REQUEST_DISPATCHER] +def call_ollama_chat( + messages: list, + tools: Optional[list] = None, + is_vision: bool = False, + timeout: int = 120 +) -> Dict[str, Any]: + """ + Отправляет подготовленный массив сообщений в API Ollama. + Возвращает разобранный словарь сообщения ответа или генерирует исключение. + """ + model_name = VISION_MODEL if is_vision else TEXT_MODEL + payload = { + "model": model_name, + "messages": messages, + "stream": False, + "options": LLM_OPTIONS + } + + if tools and not is_vision: + payload["tools"] = tools + + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=timeout) as response: + res_data = json.loads(response.read().decode("utf-8")) + return res_data.get("message", {}) \ No newline at end of file diff --git a/modules/web_api/llm/core/prompt_merger.py b/modules/web_api/llm/core/prompt_merger.py new file mode 100644 index 0000000..240d6c7 --- /dev/null +++ b/modules/web_api/llm/core/prompt_merger.py @@ -0,0 +1,59 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/prompt_merger.py +ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок + системного промпта (добавление, удаление и замена пунктов). +=============================================================================== +""" + +# ANCHOR[PROMPT_MERGER_IMPORTS] +import re +import logging +from typing import Tuple + +logger = logging.getLogger("PROMPT_MERGER") + + +# ANCHOR[PROMPT_MERGE_ENGINE] +def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str: + """ + Вычисляет результирующий текст промпта на основе намерения пользователя: + 1. Удаление пункта по номеру. + 2. Добавление/изменение пункта в соответствующую секцию. + """ + user_msg_lower = user_message.lower() + + # 1. Сценарий удаления пункта + if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): + target_num_match = re.search(r'\d+(\.\d+)*', user_message) + target_num = target_num_match.group(0) if target_num_match else "" + + lines = current_prompt.splitlines() + if target_num: + new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] + else: + new_lines = lines + return "\n".join(new_lines) + + # 2. Сценарий добавления или замены пункта + if proposed_text: + if len(proposed_text) < 500: + clean_item = proposed_text.strip() + for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: + if prefix in clean_item.lower(): + clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).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 re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" + new_lines.append(f" {item_str}") + inserted = True + if not inserted: + new_lines.append(f" {clean_item}") + return "\n".join(new_lines) + + return proposed_text or current_prompt \ No newline at end of file diff --git a/modules/web_api/main.py b/modules/web_api/main.py index 4f66862..365d054 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -2,68 +2,37 @@ =============================================================================== FILE: modules/web_api/main.py PROJECT: SCUD Orion AI (Unified Repository) -MODULE: web_api (FastAPI REST Server & Context Management) -ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), - маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. - -AI-CONTEXT-ANCHORS & INVARIANTS: - - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов - независимо от рабочей директории запуска (root или web_api). - - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного - рендеринга интерфейса и ассетов (css/js/favicon). - - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). - - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и - генерации ответов LLM (agent.process_chat_message). - -DEPENDENCIES: - - modules/web_api/llm/agent.py (process_chat_message) - - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) - - modules/web_api/llm/file_parser.py (extract_text_from_file) +MODULE: web_api (Main Application Entry Point) +ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] +# ANCHOR[APP_INIT_IMPORTS] import os import sys -import json -import sqlite3 import logging -import urllib.request -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional -# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) -import jwt -from passlib.context import CryptContext -from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import RequestValidationError -from pydantic import BaseModel -# Внутренние модули LLM и БД -from llm.agent import process_chat_message -from llm.db_tools import db_get_tasks, DB_PATH -from llm.file_parser import extract_text_from_file +from routers.auth import router as auth_router +from routers.admin import router as admin_router +from routers.tasks import router as tasks_router +from routers.chat import router as chat_router -# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] +# ANCHOR[APP_CONFIG] logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) -JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" -ALGORITHM = "HS256" - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -security = HTTPBearer() - STATIC_DIR = os.path.join(CURRENT_DIR, "static") app = FastAPI(title="SCUD Orion AI Context API", version="2.5") @@ -79,57 +48,15 @@ async def validation_exception_handler(request, exc): content={"detail": exc.errors(), "body": str(exc)} ) -# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] -def get_db(): - """Создает безопасное соединение с SQLite БД модуля.""" - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn +# ANCHOR[ROUTER_REGISTRATION] +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(tasks_router) +app.include_router(chat_router) -def create_access_token(user_id: int, username: str, is_admin: bool) -> str: - payload = { - "sub": str(user_id), - "username": username, - "is_admin": is_admin, - "exp": datetime.utcnow() + timedelta(days=30) - } - return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - try: - token = credentials.credentials - payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) - user_id = int(payload.get("sub")) - username = payload.get("username") - is_admin = bool(payload.get("is_admin", False)) - return {"id": user_id, "username": username, "is_admin": is_admin} - except Exception as e: - logging.warning(f"Auth error: {e}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Недействительный или просроченный токен авторизации", - headers={"WWW-Authenticate": "Bearer"}, - ) - -# Pydantic-схемы валидации запросов -class AuthRequest(BaseModel): - username: str - password: str - -class CreateUserRequest(BaseModel): - username: str - password: str - full_name: Optional[str] = None - is_admin: Optional[bool] = False - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - -# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] +# ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") def read_root(): - """Отдает главную страницу панели управления.""" index_path = os.path.join(STATIC_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) @@ -142,164 +69,9 @@ async def favicon(): return FileResponse(file_path) raise HTTPException(status_code=404) -# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] -@app.post("/api/v1/auth/login") -def login(req: AuthRequest): - username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) - user = cursor.fetchone() - conn.close() - - if not user or not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - is_admin = bool(user["is_admin"]) or (user["username"] == "puh") - token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") - - return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} - -@app.post("/api/v1/auth/change-password") -def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not req.new_password or len(req.new_password) < 4: - raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) - user = cursor.fetchone() - - if not user or not pwd_context.verify(req.old_password, user["password_hash"]): - conn.close() - raise HTTPException(status_code=400, detail="Неверный старый пароль") - - new_hash = pwd_context.hash(req.new_password) - cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) - conn.commit() - conn.close() - - logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") - return {"status": "success", "message": "Пароль успешно изменен"} - -@app.get("/api/v1/admin/users") -def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") - users = [dict(r) for r in cursor.fetchall()] - conn.close() - return users - -@app.post("/api/v1/admin/users") -def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - username = req.username.strip().lower() - if not username or not req.password: - raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) - if cursor.fetchone(): - conn.close() - raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") - - pwd_hash = pwd_context.hash(req.password) - full_name = req.full_name.strip() if req.full_name else None - is_admin = 1 if req.is_admin else 0 - - cursor.execute( - "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", - (username, pwd_hash, full_name, is_admin) - ) - conn.commit() - conn.close() - - logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") - return {"status": "success", "message": f"Пользователь {username} создан"} - -@app.delete("/api/v1/admin/users/{user_id}") -def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - if user_id == current_user["id"]: - raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - conn.commit() - conn.close() - - logging.info(f"Удален пользователь ID: {user_id}") - return {"status": "success", "message": "Пользователь удален"} - -# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] -@app.get("/api/v1/tasks") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - """Получить задачи текущего авторизованного пользователя.""" - return db_get_tasks(user_id=user["id"]) - -# --- ЧАТ С АВТОРИЗАЦИЕЙ --- -@app.post("/api/v1/chat") -async def chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None), - current_user: dict = Depends(get_current_user) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=current_user["id"], - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - - -# --- ГОСТЕВОЙ ЧАТ --- -@app.post("/api/v1/chat/guest") -async def guest_chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=0, - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - -# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") - target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): return FileResponse(target) diff --git a/modules/web_api/routers/admin.py b/modules/web_api/routers/admin.py new file mode 100644 index 0000000..e55aeca --- /dev/null +++ b/modules/web_api/routers/admin.py @@ -0,0 +1,83 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/admin.py +ROLE: Администрирование пользователей и прав доступа. +=============================================================================== +""" + +# ANCHOR[ADMIN_ROUTER_IMPORTS] +import logging +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user, get_db, pwd_context + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +# ANCHOR[ADMIN_SCHEMAS] +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +# ANCHOR[ADMIN_ENDPOINTS] +@router.get("/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@router.post("/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} \ No newline at end of file diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py new file mode 100644 index 0000000..73ba0c4 --- /dev/null +++ b/modules/web_api/routers/auth.py @@ -0,0 +1,112 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/auth.py +ROLE: Аутентификация, валидация JWT-токенов и управление паролями. +=============================================================================== +""" + +# ANCHOR[AUTH_ROUTER_IMPORTS] +import sqlite3 +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel + +from llm.db_tools import DB_PATH + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +# ANCHOR[AUTH_DB_HELPERS] +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# ANCHOR[AUTH_SCHEMAS] +class AuthRequest(BaseModel): + username: str + password: str + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + +# ANCHOR[AUTH_ENDPOINTS] +@router.post("/login") +def login(req: AuthRequest): + username = req.username.strip().lower() + logging.info(f"===> Попытка входа для пользователя: {username}") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) + user = cursor.fetchone() + conn.close() + + if not user or not pwd_context.verify(req.password, user["password_hash"]): + logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + is_admin = bool(user["is_admin"]) or (user["username"] == "puh") + token = create_access_token(user["id"], user["username"], is_admin) + logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") + + return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + +@router.post("/change-password") +def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not req.new_password or len(req.new_password) < 4: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py new file mode 100644 index 0000000..ce8a46c --- /dev/null +++ b/modules/web_api/routers/chat.py @@ -0,0 +1,60 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/chat.py +ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты). +=============================================================================== +""" + +# ANCHOR[CHAT_ROUTER_IMPORTS] +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, UploadFile, File, Form + +from .auth import get_current_user +from llm.agent import process_chat_message +from llm.file_parser import extract_text_from_file + +router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) + +# ANCHOR[CHAT_ENDPOINTS] +@router.post("") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Диалог авторизованного пользователя с агентом.""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=current_user["id"], + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + +@router.post("/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + """Гостевой диалог (user_id=0).""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=0, + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} \ No newline at end of file diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py new file mode 100644 index 0000000..5260cfb --- /dev/null +++ b/modules/web_api/routers/tasks.py @@ -0,0 +1,71 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/tasks.py +ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +=============================================================================== +""" + +# ANCHOR[TASKS_ROUTER_IMPORTS] +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user +from llm.db_tools import ( + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) + +router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) + +# ANCHOR[TASKS_SCHEMAS] +class CreateTaskRequest(BaseModel): + title: str + priority: Optional[str] = "MEDIUM" + module: Optional[str] = "general" + due_date: Optional[str] = None + +class UpdateTaskRequest(BaseModel): + status: Optional[str] = "COMPLETED" + due_date: Optional[str] = None + +# ANCHOR[TASKS_ENDPOINTS] +@router.get("") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить список всех задач текущего авторизованного пользователя.""" + return db_get_tasks(user_id=user["id"]) + +@router.post("") +def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое создание задачи.""" + res = db_add_task( + user_id=user["id"], + module=req.module or "general", + title=req.title.strip(), + priority=req.priority or "MEDIUM", + due_date=req.due_date + ) + return res + +@router.patch("/{task_id}") +def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое обновление статуса и срока задачи.""" + res = db_update_task_status( + user_id=user["id"], + task_id=task_id, + status=req.status or "COMPLETED", + due_date=req.due_date + ) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res + +@router.delete("/{task_id}") +def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое удаление задачи.""" + res = db_delete_task(user_id=user["id"], task_id=task_id) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index a39b46d..5227356 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -10,7 +10,7 @@ - +
@@ -56,7 +56,7 @@
- + + - + + \ No newline at end of file diff --git a/modules/web_api/static/js/chat.js b/modules/web_api/static/js/chat.js index 76824b3..94b41dc 100644 --- a/modules/web_api/static/js/chat.js +++ b/modules/web_api/static/js/chat.js @@ -2,18 +2,10 @@ =============================================================================== FILE: modules/web_api/static/js/chat.js ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических - кнопок подтверждений и рендеринг Generative UI интерактивного виджета задач. - -AI-CONTEXT-ANCHORS: - - ANCHOR[FILE_UPLOAD_UTILS]: Выбор, превью и очистка прикрепленных файлов. - - ANCHOR[INTERACTIVE_BUTTONS_CORE]: Деактивация и быстрая отправка нажатых кнопок. - - ANCHOR[INTERACTIVE_TASK_WIDGET_JS]: Рендерер и REST-обработчики виджета задач. - - ANCHOR[CHAT_SEND_PIPELINE]: Главный метод sendMessage и вставка сообщений в DOM. - - ANCHOR[DRAG_DROP_HANDLERS]: Обработка перетаскивания файлов в окно чата. + кнопок подтверждений и блокировка ввода при активном действии. =============================================================================== */ -// --- [SECTION 1: FILE UPLOAD HELPERS] --- # ANCHOR[FILE_UPLOAD_UTILS] function updateInputHeight(el) { if (!el) return; el.style.height = "24px"; @@ -51,7 +43,29 @@ function clearAttachedFile() { if (previewContainer) previewContainer.classList.add("hidden"); } -// --- [SECTION 2: ACTION BUTTONS CORE] --- # ANCHOR[INTERACTIVE_BUTTONS_CORE] +function setInputLocked(isLocked) { + const input = document.getElementById("user-input"); + const sendBtn = document.getElementById("send-btn"); + if (input) { + input.disabled = isLocked; + if (isLocked) { + input.placeholder = "Выберите действие с помощью кнопок выше..."; + input.classList.add("cursor-not-allowed", "opacity-60"); + } else { + input.placeholder = "Команда, вопрос или перетащите файл сюда..."; + input.classList.remove("cursor-not-allowed", "opacity-60"); + } + } + if (sendBtn) { + sendBtn.disabled = isLocked; + if (isLocked) { + sendBtn.classList.add("opacity-40", "cursor-not-allowed"); + } else { + sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); + } + } +} + function disableAllActionButtons() { const allBtnContainers = document.querySelectorAll(".action-buttons-container"); allBtnContainers.forEach(container => { @@ -64,6 +78,7 @@ function disableAllActionButtons() { function handleActionButtonClick(text) { disableAllActionButtons(); + setInputLocked(false); const input = document.getElementById("user-input"); if (input) { input.value = text; @@ -71,7 +86,7 @@ function handleActionButtonClick(text) { } } -// --- [SECTION 3: INTERACTIVE TASK WIDGET] --- # ANCHOR[INTERACTIVE_TASK_WIDGET_JS] +// --- [INTERACTIVE TASK WIDGET] --- let activeWidgetTasksMap = new Map(); function renderTaskItemsHtml(tasks, filterStatus) { @@ -134,7 +149,6 @@ function renderInteractiveTaskCard(tasks) { return `
-
@@ -142,12 +156,10 @@ function renderInteractiveTaskCard(tasks) {
-
${itemsHtml}
-
`; + + // Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры + if (actionData.type === "PROMPT_PREVIEW") { + setInputLocked(true); + } else { + setInputLocked(false); + } + } else { + setInputLocked(false); } let interactiveWidgetHtml = ""; @@ -421,8 +440,9 @@ async function sendMessage(e) { `; chatWindow.insertAdjacentHTML("beforeend", errorHtml); chatWindow.scrollTop = chatWindow.scrollHeight; + setInputLocked(false); } finally { - if (sendBtn) { + if (sendBtn && !document.getElementById("user-input")?.disabled) { sendBtn.disabled = false; sendBtn.classList.remove("opacity-50"); } @@ -439,7 +459,6 @@ function escapeHtml(text) { .replace(/'/g, "'"); } -// --- [SECTION 5: DRAG & DROP AND KEYBOARD LISTENERS] --- # ANCHOR[DRAG_DROP_HANDLERS] document.addEventListener("DOMContentLoaded", () => { const input = document.getElementById("user-input"); const dropZone = document.getElementById("chat-window")?.parentElement; @@ -452,6 +471,7 @@ document.addEventListener("DOMContentLoaded", () => { input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); + if (input.disabled) return; const text = input.value.trim(); if (text) { if (localHistory.length === 0 || localHistory[0] !== text) { diff --git a/modules/web_api/static/js/chat/core.js b/modules/web_api/static/js/chat/core.js new file mode 100644 index 0000000..2545048 --- /dev/null +++ b/modules/web_api/static/js/chat/core.js @@ -0,0 +1,439 @@ +/** +=============================================================================== +FILE: modules/web_api/static/js/chat/core.js +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / static / js / chat +ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода + при активных кнопках, вызов онлайн-редактора и отправка правок. +=============================================================================== +*/ + +function updateInputHeight(el) { + if (!el) return; + el.style.height = "24px"; + const newHeight = Math.min(el.scrollHeight, 120); + el.style.height = newHeight + "px"; +} + +let selectedFile = null; + +function handleFileSelect(e) { + const file = e.target.files[0]; + if (!file) return; + + if (file.size > 15 * 1024 * 1024) { + alert("Файл слишком большой. Максимальный размер: 15 МБ"); + e.target.value = ""; + return; + } + + selectedFile = file; + const fileNameEl = document.getElementById("file-name-display"); + const fileSizeEl = document.getElementById("file-size-display"); + const previewContainer = document.getElementById("file-preview-container"); + + if (fileNameEl) fileNameEl.innerText = file.name; + if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; + if (previewContainer) previewContainer.classList.remove("hidden"); +} + +function clearAttachedFile() { + selectedFile = null; + const fileInput = document.getElementById("file-input"); + const previewContainer = document.getElementById("file-preview-container"); + if (fileInput) fileInput.value = ""; + if (previewContainer) previewContainer.classList.add("hidden"); +} + +function setInputLocked(isLocked) { + const input = document.getElementById("user-input"); + const sendBtn = document.getElementById("send-btn"); + if (input) { + input.disabled = isLocked; + if (isLocked) { + input.placeholder = "Выберите действие с помощью кнопок выше..."; + input.classList.add("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); + } else { + input.placeholder = "Команда, вопрос или перетащите файл сюда..."; + input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); + } + } + if (sendBtn) { + sendBtn.disabled = isLocked; + if (isLocked) { + sendBtn.classList.add("opacity-40", "cursor-not-allowed"); + } else { + sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); + } + } +} + +function disableAllActionButtons() { + const allBtnContainers = document.querySelectorAll(".action-buttons-container"); + allBtnContainers.forEach(container => { + container.querySelectorAll("button").forEach(btn => { + btn.disabled = true; + btn.classList.add("opacity-40", "cursor-not-allowed"); + }); + }); +} + +function handleActionButtonClick(text) { + if (text === "action:open_editor") { + openInlinePromptEditor(); + return; + } + + disableAllActionButtons(); + setInputLocked(false); + const input = document.getElementById("user-input"); + if (input) { + input.value = text; + sendMessage(); + } +} + +function openInlinePromptEditor() { + const editorContainer = document.getElementById("inline-prompt-editor-container"); + if (editorContainer) { + editorContainer.classList.remove("hidden"); + const textarea = document.getElementById("inline-prompt-textarea"); + if (textarea) textarea.focus(); + } +} + +function closeInlinePromptEditor() { + const editorContainer = document.getElementById("inline-prompt-editor-container"); + if (editorContainer) editorContainer.classList.add("hidden"); +} + +async function saveManualPromptDraft() { + const textarea = document.getElementById("inline-prompt-textarea"); + if (!textarea) return; + + const newText = textarea.value.trim(); + if (!newText) return; + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch("/api/v1/chat/draft", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ + session_id: "web_session_main", + draft_text: newText + }) + }); + + if (res.ok) { + closeInlinePromptEditor(); + const previewTextEl = document.getElementById("prompt-preview-diff-view"); + if (previewTextEl) { + previewTextEl.innerText = newText; + } + alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД."); + } + } catch (err) { + alert("Ошибка сохранения черновика: " + err); + } +} + +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --- [CHAT SEND PIPELINE] --- +async function sendMessage(e) { + if (e && e.preventDefault) e.preventDefault(); + + const input = document.getElementById("user-input"); + const chatWindow = document.getElementById("chat-window"); + const sendBtn = document.getElementById("send-btn"); + + if (!input || !chatWindow) return; + const text = input.value.trim(); + + if (!text && !selectedFile) return; + + disableAllActionButtons(); + + let userDisplayHtml = escapeHtml(text); + if (selectedFile) { + userDisplayHtml = `
+ ${escapeHtml(selectedFile.name)} +
` + userDisplayHtml; + } + + const userMsgHtml = ` +
+
+ ${userDisplayHtml} +
+
+ `; + chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); + + input.value = ""; + updateInputHeight(input); + chatWindow.scrollTop = chatWindow.scrollHeight; + + if (sendBtn) { + sendBtn.disabled = true; + sendBtn.classList.add("opacity-50"); + } + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); + + const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; + + const formData = new FormData(); + formData.append("session_id", "web_session_main"); + formData.append("message", text || "Проанализируй прикрепленный файл"); + + if (selectedFile instanceof File) { + formData.append("file", selectedFile, selectedFile.name); + } + + const headers = {}; + if (!isGuest && token) { + headers["Authorization"] = "Bearer " + token; + } + + try { + const res = await fetch(endpoint, { + method: "POST", + headers: headers, + body: formData + }); + + if (res.status === 401 && !isGuest) { + if (typeof logout === 'function') logout(); + return; + } + + const data = await res.json(); + const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; + const replyText = data.reply || "Пустой ответ от нейросети"; + + let actionButtonsHtml = ""; + const actionData = data.action_type || data.action; + + if (actionData && actionData.buttons && actionData.buttons.length > 0) { + const buttonsMarkup = actionData.buttons.map(btn => { + let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; + let iconMarkup = ''; + + const labelLower = (btn.label || "").toLowerCase(); + const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); + const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); + const isEdit = labelLower.includes("редактир"); + + if (isPrimary) { + btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; + iconMarkup = ''; + } else if (isDanger) { + btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; + iconMarkup = ''; + } else if (isEdit) { + btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300"; + iconMarkup = ''; + } + + return ` + + `; + }).join(""); + + actionButtonsHtml = ` +
+ ${buttonsMarkup} +
+ `; + + // Блокируем ввод при наличии кнопок + setInputLocked(true); + } else { + setInputLocked(false); + } + + let previewEditorHtml = ""; + if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) { + previewEditorHtml = ` + + `; + } + + let interactiveWidgetHtml = ""; + if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") { + interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); + } + + const botMsgHtml = ` +
+

+ ${assistantTitle} +

+
${replyText}
+ ${previewEditorHtml} + ${interactiveWidgetHtml} + ${actionButtonsHtml} +
+ `; + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + + clearAttachedFile(); + + if (!isGuest && typeof loadTasks === 'function') { + loadTasks(); + } + + } catch (err) { + console.error("[Chat Error]", err); + const errorHtml = ` +
+ Ошибка связи с сервером. +
+ `; + chatWindow.insertAdjacentHTML("beforeend", errorHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + setInputLocked(false); + } finally { + const inputEl = document.getElementById("user-input"); + if (sendBtn && (!inputEl || !inputEl.disabled)) { + sendBtn.disabled = false; + sendBtn.classList.remove("opacity-50"); + } + } +} + +// --- [DRAG & DROP AND KEYBOARD LISTENERS] --- +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("user-input"); + const dropZone = document.getElementById("chat-window")?.parentElement; + const dropOverlay = document.getElementById("drop-overlay"); + + if (input) { + let historyIndex = -1; + let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (input.disabled) return; + const text = input.value.trim(); + if (text) { + if (localHistory.length === 0 || localHistory[0] !== text) { + localHistory.unshift(text); + if (localHistory.length > 50) localHistory.pop(); + localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); + } + historyIndex = -1; + } + sendMessage(e); + updateInputHeight(input); + return; + } + + if (e.key === "ArrowUp") { + const textBeforeCursor = input.value.substring(0, input.selectionStart); + const isFirstLine = !textBeforeCursor.includes("\n"); + + if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { + if (historyIndex < localHistory.length - 1) { + e.preventDefault(); + if (historyIndex === -1) { + input.dataset.draft = input.value; + } + historyIndex++; + input.value = localHistory[historyIndex]; + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } + + if (e.key === "ArrowDown") { + const textAfterCursor = input.value.substring(input.selectionEnd); + const isLastLine = !textAfterCursor.includes("\n"); + + if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + input.value = localHistory[historyIndex]; + } else { + historyIndex = -1; + input.value = input.dataset.draft || ""; + } + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + }); + } + + if (dropZone && dropOverlay) { + ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + ["dragenter", "dragover"].forEach(eventName => { + dropZone.addEventListener(eventName, () => { + dropOverlay.classList.remove("hidden"); + dropOverlay.classList.add("flex"); + }, false); + }); + + ["dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { + dropOverlay.classList.add("hidden"); + dropOverlay.classList.remove("flex"); + } + }, false); + }); + + dropZone.addEventListener("drop", (e) => { + const dt = e.dataTransfer; + const files = dt.files; + + if (files && files.length > 0) { + const file = files[0]; + handleFileSelect({ target: { files: [file] } }); + + const fileInput = document.getElementById("file-input"); + if (fileInput) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + } + } + }, false); + } +}); \ No newline at end of file diff --git a/modules/web_api/static/js/chat/task_widget.js b/modules/web_api/static/js/chat/task_widget.js new file mode 100644 index 0000000..d6328e0 --- /dev/null +++ b/modules/web_api/static/js/chat/task_widget.js @@ -0,0 +1,208 @@ +/** +=============================================================================== +FILE: modules/web_api/static/js/chat/task_widget.js +ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата + (фильтры, inline-чекбоксы статусов, быстрое добавление и удаление). +=============================================================================== +*/ + +let activeWidgetTasksMap = new Map(); + +function renderTaskItemsHtml(tasks, filterStatus) { + const filtered = tasks.filter(t => { + if (filterStatus === "ALL") return true; + return t.status === filterStatus; + }); + + if (filtered.length === 0) { + const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; + return `
Нет задач со статусом «${label}»
`; + } + + return filtered.map(t => { + const isDone = t.status === "COMPLETED"; + const isProgress = t.status === "IN_PROGRESS"; + const checkedAttr = isDone ? "checked" : ""; + const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; + + let statusPill = `В планах`; + if (isDone) { + statusPill = `Завершено`; + } else if (isProgress) { + statusPill = `В работе`; + } + + let prioPill = ""; + if (t.priority === "HIGH") { + prioPill = `HIGH`; + } + + return ` +
+
+ +
+
+ ${t.task_id} + ${prioPill} + ${statusPill} +
+ ${escapeHtml(t.title)} +
+
+ +
+ `; + }).join(""); +} + +function renderInteractiveTaskCard(tasks) { + if (!tasks || !Array.isArray(tasks)) return ""; + const widgetId = "task_widget_" + Date.now(); + activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); + + const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); + + return ` +
+
+ + + + +
+ +
+ ${itemsHtml} +
+ +
+ + +
+
+ `; +} + +function filterTaskWidget(widgetId, status, btnEl) { + const state = activeWidgetTasksMap.get(widgetId); + if (!state) return; + state.filter = status; + + const widgetEl = document.getElementById(widgetId); + if (!widgetEl) return; + + widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { + b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; + }); + btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; + + const container = widgetEl.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, status); + } +} + +async function toggleTaskStatusInline(taskId, isChecked) { + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; + + try { + const res = await fetch(`/api/v1/tasks/${taskId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ status: newStatus }) + }); + + if (res.ok) { + for (let [wId, state] of activeWidgetTasksMap.entries()) { + const targetTask = state.tasks.find(t => t.task_id === taskId); + if (targetTask) { + targetTask.status = newStatus; + const widgetEl = document.getElementById(wId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); + } + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Patch Error]", err); + } +} + +async function deleteTaskInline(taskId) { + if (!confirm(`Удалить задачу ${taskId}?`)) return; + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch(`/api/v1/tasks/${taskId}`, { + method: "DELETE", + headers: { "Authorization": "Bearer " + token } + }); + + if (res.ok) { + for (let [wId, state] of activeWidgetTasksMap.entries()) { + state.tasks = state.tasks.filter(t => t.task_id !== taskId); + const widgetEl = document.getElementById(wId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Delete Error]", err); + } +} + +async function addTaskInline(widgetId, inputEl) { + if (!inputEl) return; + const title = inputEl.value.trim(); + if (!title) return; + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch("/api/v1/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) + }); + + if (res.ok) { + inputEl.value = ""; + const tasksRes = await fetch("/api/v1/tasks", { + headers: { "Authorization": "Bearer " + token } + }); + const updatedTasks = await tasksRes.json(); + + const state = activeWidgetTasksMap.get(widgetId); + if (state) { + state.tasks = updatedTasks; + const widgetEl = document.getElementById(widgetId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Add Error]", err); + } +} \ No newline at end of file diff --git a/project_code_snapshot.md b/project_code_snapshot.md index 4af2c2c..9bf59ee 100644 --- a/project_code_snapshot.md +++ b/project_code_snapshot.md @@ -1354,6 +1354,68 @@ conn.close() print("=" * 80) ``` +## File: `./scripts/diagnostics/inspect_ephemeral.py` +```py +import os +import sqlite3 + +DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/scud_orion_ai.db") +if not os.path.exists(DB_PATH): + DB_PATH = "data/scud_orion_ai.db" + +conn = sqlite3.connect(DB_PATH) +cursor = conn.cursor() + +print("=" * 90) +print(f"🔍 ИНСПЕКЦИЯ СООБЩЕНИЙ В БАЗЕ: {DB_PATH}") +print("=" * 90) + +# 1. Проверяем наличие колонки is_ephemeral +cursor.execute("PRAGMA table_info(chat_messages);") +cols = [c[1] for c in cursor.fetchall()] + +if "is_ephemeral" not in cols: + print("❌ Колонка is_ephemeral отсутствует в таблице chat_messages!") + conn.close() + exit(1) + +# 2. Считаем общее количество и распределение +cursor.execute("SELECT COUNT(*) FROM chat_messages") +total_count = cursor.fetchone()[0] + +cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 1") +ephemeral_count = cursor.fetchone()[0] + +cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 0") +permanent_count = cursor.fetchone()[0] + +print(f"Всего записей в истории: {total_count}") +print(f" • Постоянные (is_ephemeral = 0): {permanent_count}") +print(f" • Фантомные / Эфемерные (is_ephemeral = 1): {ephemeral_count}") +print("-" * 90) + +# 3. Выводим все фантомные записи +if ephemeral_count == 0: + print("В базе нет фантомных записей (is_ephemeral = 1 пуст).") +else: + print(f"📋 СПИСОК ВСЕХ ФАНТОМНЫХ СООБЩЕНИЙ ({ephemeral_count} шт.):") + cursor.execute(""" + SELECT id, session_id, role, created_at, content + FROM chat_messages + WHERE is_ephemeral = 1 + ORDER BY id ASC + """) + for row in cursor.fetchall(): + msg_id, session_id, role, created_at, content = row + print(f"\n[ID: {msg_id}] | Сессия: {session_id} | Роль: {role} | Время: {created_at}") + print("Содержимое:") + print(content.strip()) + print("-" * 70) + +conn.close() +print("=" * 90) +``` + ## File: `./scripts/diagnostics/inspect_files.py` ```py import os @@ -1388,7 +1450,7 @@ import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" # Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css', '.sql'} EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} @@ -3176,6 +3238,422 @@ def sync_zup_to_excel(target_date) -> bool: return False ``` +## File: `./docs/PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md` +``` +# **PROJECT BRAIN: SCUD Orion AI & Context API (Master Technical & Architectural Manifesto)** + +**Версия документа:** 5.0 (Ultimate Developer & LLM Context Engine) +**Назначение:** Исчерпывающий контекстный дамп проекта для немедленного продолжения разработки в любой передовой LLM без потери исторического контекста, с полным пониманием бизнес-логики, схем баз данных, подводных камней и нерешенных задач. + +## --- + +**1\. Архитектурный паспорт и инфраструктурный профиль** + +| Компонент | Параметры и стек | +| :---- | :---- | +| **Среда и ОС** | Linux Docker Home (Debian/Ubuntu), Python 3.11/3.12 в виртуальном окружении venv, Git/Gitea. Разработка ведется через VS Code Remote-SSH. | +| **Локальная СУБД проекта** | SQLite (data/scud\_orion\_ai.db), режим PRAGMA journal\_mode \= WAL, PRAGMA synchronous \= NORMAL, PRAGMA foreign\_keys \= ON. | +| **СКУД Источник (MSSQL)** | MS SQL Server 172.16.31.221\\SQL (база Orion-14.01.21-1), драйвер ODBC Driver 18 for SQL Server (TrustServerCertificate=yes, Encrypt=no). Доступ через pyodbc. | +| **1С:ЗУП 3.1 Источник (MSSQL)** | MS SQL Server ACCOUNT-01 (база ZUP30, пользователь scud\_reader). Прямое чтение таблиц dbo.\_Reference299 (сотрудники) и dbo.\_InfoRg16921 (состояния отсутствий). Резервная Samba-шара: /mnt/scud\_share. | +| **Локальный контур LLM** | Ollama на http://192.168.11.3:11434 (GPU NVIDIA RTX 5070 Ti 16GB). Модель qwen2.5:14b (основной агент, Function Calling, анализ аномалий), qwen2.5vl:7b-q8\_0 (OCR документов/картинок). | +| **Web API & Фронтенд** | FastAPI (Uvicorn), JWT (HS256, 30 дней), Passlib (bcrypt). Фронтенд: Single Page Application (HTML5, Tailwind CSS, Vanilla JS) с Generative UI виджетами. | + +## --- + +**2\. Энциклопедия подводных камней, инцидентов и принятых инженерных решений** + +### **2.1. Галлюцинации LLM и переход к парадигме Python-Driven Diff** + +> * **Проблема:** Попытка передать списки в 300 ФИО напрямую в промпт модели с командой «сравни списки» приводила к потере контекста, выдуманным опечаткам (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*) и плейсхолдерам ('Васильев Л.И.', ...). +> * **Решение:** Утверждена строгая гибридная схема: + 1. **Детерминированный слой (Python / Pandas / SQL):** читает файлы, нормализует строки, агрегирует совмещения и мгновенно находит 95%+ точных соответствий. + 2. **Интеллектуальный слой (Qwen 2.5:14b):** вызывается *только на спорный несовпавший остаток* (1–5 записей) в виде строгого JSON-интерфейса Entity Resolution. + +### **2.2. Символьная нормализация и кадровые совмещения (Кейс Балаевой О.Г.)** + +> * **Проблема символов:** Латинские омоглифы (буквы клавиатуры a, c, e, o, p, x, y вместо русских), разделители 1С в скобках ((осн.), (совм.), (внешн.)), неразрывные пробелы \\xa0, буква ё и случайный ввод цифры 0 вместо буквы О. +> * **Решение (normalize\_fio):** Очистка скобок через регулярное выражение re.sub(r'\\(.\*?\\)', '', fio), замена латиницы через str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ'), замена ё-\>е и удаление лишних пробелов. +> * **Коллизия 270 vs 271:** В СКУД 270 физических лиц (один пропуск на человека), в 1С — 271 строка. Сотрудник **Балаева Ольга Геннадьевна** числится на двух ставках в разных отделах (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*). +> * **Решение:** Внедрена группировка датафрейма 1С по fio\_clean с агрегацией должностей и подразделений через разделитель " / ". + +### **2.3. Защита от коллизий полных тёзок и ложных связок однофамильцев (Кейс Семеновых)** + +> * **Инцидент:** Неограниченная строгими правилами LLM связала двух разных сотрудниц — *«Семенову Юлию Борисовну»* (работает) и *«Семенову Дину Дмитриевну»* (в отпуске), посчитав разные имена опечаткой из\-за одинаковой фамилии. В результате Юлии Борисовне ошибочно был приписан статус отпуска. +> * **Двухконтурный барьер:** + 1. **В промпте:** Запрет объединения однофамильцев с разными именами/отчествами. + 2. **В Python:** Проверка совпадения первых букв имени и отчества. При несовпадении сопоставление ИИ аннулируется с логированием ⚠️ \[Блокировка фейка\]. + +### **2.4. Вынесение исключений в exceptions.json и синонимы отделов** + +> * Служебный персонал (отдел «ЭТО», уборщики производственных и служебных помещений, клинеры, дворники, гардеробщики) вынесен в exceptions.json. +> * Для сопоставления кратких аббревиатур СКУД и полных названий отделов 1С (*«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица department\_synonyms с автоматическим самообучением при работе человека (Human-in-the-Loop). + +### **2.5. Разгадка феномена «исчезающих неизвестных» и аппаратный сбой СКУД 04.08.2026** + +> * **Аномалия:** Утром 04.08.2026 на главном КПП зависли контроллеры входа. У 119 сотрудников не записалось утреннее событие (time\_in \= 'Нет входа'). Из них 57 человек были в отпусках/командировках по 1С. Оставшиеся 74 человека были на работе без утренней отметки. Однако вечерняя сводка показала всего 4–7 «неизвестных». +> * **Причина:** Скрипт агрегации видел дневные перемещения (обед, внутренние двери, туалет, вечерний выход) и автоматически проставлял Статус \= 'Присутствовал' и is\_present \= 1, несмотря на то, что колонка Начало\_дня оставалась 'Нет входа'. В итоге 70 человек тихо ушли в «Итого на работе», а системный сбой оборудования был замаскирован. +> * **Архитектурный фикс:** + 1. Введена колонка **first\_activity («Первая активность»)** — вычисление MIN(TimeVal) AS FirstRawEvent по таблице pLogData без учета направления двери. + 2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи ставится anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\. + 3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): если доля таких аномалий превышает 5% смены (\>10 чел.), ИИ генерирует системный алерт о массовом сбое контроллеров КПП. +> * **Кейс Равина В.Э.:** Главный специалист официально находился в основном отпуске по 1С, утреннего входа на КПП не было, но зафиксирована активность в 14:11:06 и вечерний выход в 17:18:38. Система зарегистрировала истинную кадровую аномалию (физическое присутствие при официальном отпуске). + +### **2.6. Система сквозных датированных снапшотов (срезов)** + +> * **Формат ID:** \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного). +> * **Оптимизация:** Функция has\_yesterday\_final\_snapshot() проверяет наличие вчерашнего среза в SQLite: если он есть, повторный тяжелый запрос к MS SQL Орион пропускается. +> * **CLI-управление:** Поддержка запуска отчетов по любому историческому снапшоту через ключ \--snapshot \[ID\] и пропуск выгрузки через \--skip-export. + +### **2.7. Web API, Generative UI и управление жизненным циклом сессий (Topic Drift & Ephemeral Buffer)** + +> * **Проблема засорения контекста LLM:** Вывод объемных списков задач, системных промптов и таблиц срезов забивал окно диалога, вызывая галлюцинации и «залипание» модели на старых данных. +> * **Решение:** + * В chat\_messages введен флаг is\_ephemeral \= 1 для временных сообщений. + * Двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик idle\_turns. + * **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages(). + * **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю») и выхода («нет, спасибо») без обращения к LLM (ответ за 0.05 сек). + * **Декларативный реестр (tool\_action\_registry):** Хранение шаблонов ответов, кнопок и настроек эфемерности в SQLite. +> * **Generative UI (Задачи):** Инструмент db\_get\_tasks возвращает TASK\_INTERACTIVE\_CARD, рендерящий на клиенте живой виджет задач с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API. + +## --- + +**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)** + +| Таблица | Назначение и бизнес-логика | Структура полей | +| :---- | :---- | :---- | +| **scud\_logs** | Логи проходов СКУД, расчетное время в здании и снапшоты. | id INTEGER PK AUTOINCREMENT, log\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, time\_in TEXT, first\_activity TEXT DEFAULT '—', time\_out TEXT, time\_in\_building TEXT, is\_present INTEGER, anomaly\_flag TEXT DEFAULT 'NONE', snapshot\_time TEXT, snapshot\_id TEXT, created\_at TIMESTAMP | +| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id INTEGER PK AUTOINCREMENT, snapshot\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, created\_at TIMESTAMP | +| **zup\_absences** | Документы отклонений 1С (отпуска, больничные, командировки, декреты). | id INTEGER PK AUTOINCREMENT, absence\_date TEXT, fio TEXT, fio\_clean TEXT, absence\_type TEXT, created\_at TIMESTAMP | +| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id INTEGER PK AUTOINCREMENT, anomaly\_date TEXT, fio TEXT, anomaly\_type TEXT, details TEXT, human\_status TEXT DEFAULT 'Pending', created\_at TIMESTAMP | +| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id INTEGER PK AUTOINCREMENT, rule\_text TEXT UNIQUE, added\_by TEXT DEFAULT 'Human', created\_at TIMESTAMP | +| **system\_prompts** | Версионные системные промпты агента (активный по is\_active=1). | id INTEGER PK AUTOINCREMENT, name TEXT, prompt\_text TEXT, is\_active INTEGER DEFAULT 1, updated\_at TIMESTAMP | +| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id INTEGER PK AUTOINCREMENT, tool\_name TEXT UNIQUE, category TEXT, bypass\_llm INTEGER, success\_template TEXT, follow\_up\_question TEXT, action\_type TEXT, buttons\_json TEXT, is\_active INTEGER, is\_ephemeral INTEGER, updated\_at TIMESTAMP | +| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id TEXT PRIMARY KEY, state\_type TEXT, pending\_data TEXT, updated\_at TIMESTAMP | +| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id INTEGER PK AUTOINCREMENT, session\_id TEXT, role TEXT, content TEXT, is\_ephemeral INTEGER DEFAULT 0, created\_at TIMESTAMP | +| **tasks** | Реестр задач операторов с привязкой к user\_id. | id INTEGER PK AUTOINCREMENT, task\_id TEXT, module TEXT, title TEXT, priority TEXT, status TEXT, due\_date TEXT, user\_id INTEGER, created\_at TIMESTAMP | +| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id INTEGER PK AUTOINCREMENT, username TEXT UNIQUE, password\_hash TEXT, is\_admin INTEGER, full\_name TEXT, created\_at TIMESTAMP | +| **department\_synonyms** | База соответствия аббревиатур СКУД полным названиям отделов 1С. | id INTEGER PK AUTOINCREMENT, short\_name TEXT UNIQUE, full\_name TEXT, created\_at TIMESTAMP | +| **system\_reference** | Справочник команд и примеров промптов для операторов. | id INTEGER PK AUTOINCREMENT, category TEXT, title TEXT, example\_prompt TEXT, description TEXT | + +## --- + +**4\. Архитектурная структура каталогов** + +scud\_ai/ +├── config.py \# Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL +├── exceptions.json \# Декларативные фильтры исключений (ОВК, уборщики, подрядчики) +├── main\_etl.py \# Оркестратор ETL: экспорт СКУД/1С \-\> SQLite \-\> Excel/MD отчеты +├── project\_code\_snapshot.md \# Полный монолитный исходный код всех файлов репозитория +├── core/ +│ └── database.py \# Драйвер SQLite (WAL), функции сохранения/загрузки срезов +├── services/ +│ ├── scud\_export.py \# Прямой SQL-экспорт из MS SQL «Орион Pro» +│ ├── zup\_extractor.py \# Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1 +│ ├── data\_loader.py \# Загрузка данных, нормализация ФИО, расчет первой активности +│ ├── data\_validator.py \# Проверка свежести и наличия файлов за Вчера и Сегодня +│ ├── excel\_exporter.py \# Генерация сводки и детального отчета в Excel (openpyxl) +│ ├── text\_reporter.py \# Построение итогового Markdown-отчета через Qwen 2.5 +│ ├── ai\_verifier.py \# Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД +│ ├── feedback\_loop.py \# Интерактивный консольный модуль Human-in-the-Loop +│ ├── knowledge\_base.py \# Интерфейс работы с базой знаний правил +│ └── share\_copier.py \# Резервное скачивание файлов 1С с шары /mnt/scud\_share +├── scripts/ +│ ├── db\_cli.py \# CLI-утилита управления БД, снапшотами, дампами и сессиями +│ ├── fix\_snapshots.py \# Скрипт переиндексации и нормализации снапшотов +│ ├── init\_tool\_registry.py \# Инициализация декларативного реестра инструментов +│ └── diagnostics/ +│ ├── inspect\_db.py \# Диагностика структуры таблиц и количества записей +│ ├── inspect\_ephemeral.py \# Анализ распределения постоянных и эфемерных сообщений +│ ├── inspect\_files.py \# Аудит файлов и размеров на диске +│ └── show\_tree.py \# Вывод дерева каталогов +├── modules/web\_api/ +│ ├── main.py \# Точка входа FastAPI приложения, роутеры и раздача статики +│ ├── routers/ (admin, auth, chat, tasks) \# REST эндпоинты +│ ├── llm/ +│ │ ├── agent.py \# Координатор диалога, Tool Calls и Topic Drift Guard +│ │ ├── schemas.py \# Описание JSON-схем инструментов для Ollama +│ │ ├── file\_parser.py \# Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT) +│ │ ├── db\_tools.py \# Фасадные вызовы функций БД для агента +│ │ ├── core/ (calendar\_utils, fast\_path, ollama\_client, prompt\_merger, tool\_injector) +│ │ └── db/ (connection, db\_chat, db\_prompts, db\_snapshots, db\_tasks) +│ └── static/ (index.html, css/styles.css, js/app.js, js/auth.js, js/chat.js, js/tasks.js) +└── docs/ + └── development\_roadmap.md \# Стратегическая дорожная карта и архитектурный бэклог + +## --- + +**5\. Справочник команд CLI (HOWTO)** + +### **ETL Пайплайн (main\_etl.py):** + +> * python main\_etl.py — стандартный дневной запуск (экспорт из MS SQL, сохранение снапшота, генерация отчетов). +> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL. +> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота. +> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG). + +### **CLI базы данных (scripts/db\_cli.py):** + +> * python scripts/db\_cli.py stats — статистика по количеству строк во всех таблицах БД. +> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (свежие сверху). +> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — инспекция логов СКУД и экспорт в Excel. +> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП. +> * python scripts/db\_cli.py anomalies — история выявленных аномалий. +> * python scripts/db\_cli.py rules — правила базы знаний компании. +> * python scripts/db\_cli.py prompts — системные промпты из system\_prompts. +> * python scripts/db\_cli.py tools — декларативный реестр действий инструментов и кнопок. +> * python scripts/db\_cli.py sessions — активные сессионные стейты и висящие черновики. +> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID. +> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за день. +> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel. + +## --- + +**6\. Стратегическая дорожная карта и нерешенные задачи (Roadmap & Backlog)** + +> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):** + * **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP. + * **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений. + * **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py. +> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):** + * Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData. + * Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут). + * Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку. +> 3. **Интерактивный Task Planner & Roadmap Engine:** + * Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks. +> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):** + * Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст. +> 5. **Автоматизация расписания срезов и Telegram-алерты:** + * Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time. + * Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram. +``` + +## File: `./docs/SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md` +``` +# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта** + +**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии) +**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS. + +## --- + +**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи** + +Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников. + +### **Источники первичных данных:** + +> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы: + * pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex. + * pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива). + * PDivision, PPost — справочники подразделений и должностей СКУД. +> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30). + * dbo.\_Reference299 — эталонный справочник сотрудников. + * dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты). + * Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки). +> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов). + +## --- + +**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок** + +### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма** + +> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*). +> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError. +> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff). + +### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)** + +> * **Символьные ловушки:** + * Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке). + * Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)). + * Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О. +> * **Функция normalize\_fio():** Разработан строгий конвейер очистки: +> `def normalize_fio(fio):` +> `if not fio or not isinstance(fio, str): return ""` +> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок` +> `fio_clean = fio_clean.replace('\xa0', ' ')` +> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')` +> `fio_clean = fio_clean.translate(trans)` +> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')` +> `return " ".join(fio_clean.strip().split()).title()` +> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД. +> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ". + +### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)** + +> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»). +> * **Двухконтурная защита:** + 1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено. + 2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\]. + +### **Фаза 4: Декларативные исключения и синонимы отделов** + +> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords). +> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора. + +### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность** + +> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных». +> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался. +> * **Внедрение «Первой активности»:** + 1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери). + 2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\. + 3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП. +> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске). + +### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)** + +> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure. +> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного). +> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot). +> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del. + +### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard** + +> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью. +> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах. +> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):** + * В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений. + * Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns. + * **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages(). + * **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек). + * **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite. +> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM. + +## --- + +**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)** + +| Таблица | Назначение и бизнес-логика | Полная структура колонок | +| :---- | :---- | :---- | +| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at | +| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at | +| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at | +| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at | +| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at | +| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at | +| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at | +| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at | +| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at | +| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at | +| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at | +| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at | +| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description | + +## --- + +**4\. Актуальная архитектура каталогов и назначение файлов** + +`scud_ai/` +`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL` +`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)` +`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты` +`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория` +`├── core/` +`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов` +`├── services/` +`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»` +`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1` +`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности` +`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня` +`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)` +`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5` +`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД` +`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop` +`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил` +`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share` +`├── scripts/` +`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями` +`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов` +`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов` +`│ └── diagnostics/` +`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей` +`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений` +`│ ├── inspect_files.py # Аудит файлов и размеров на диске` +`│ └── show_tree.py # Вывод дерева каталогов` +`├── modules/web_api/` +`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики` +`│ ├── routers/` +`│ │ ├── admin.py # Управление пользователями` +`│ │ ├── auth.py # JWT аутентификация и смена паролей` +`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)` +`│ │ └── tasks.py # REST API управления задачами` +`│ ├── llm/` +`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard` +`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama` +`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)` +`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента` +`│ │ ├── core/` +`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели` +`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)` +`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API` +`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта` +`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls` +`│ │ └── db/` +`│ │ ├── connection.py # Подключение к scud_orion_ai.db` +`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений` +`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий` +`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов` +`│ │ └── db_tasks.py # CRUD операции с задачами пользователей` +`│ └── static/` +`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)` +`│ ├── css/styles.css # Стили и мобильные оптимизации` +`│ └── js/` +`│ ├── app.js # Инициализация приложения` +`│ ├── auth.js # Логика входа, смены паролей и администрирования` +`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки` +`│ └── tasks.js # Управление боковой панелью задач (Drawer)` +`└── docs/` + `└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог` + +## --- + +**5\. Справочник команд командной строки (CLI Reference)** + +### **Главный конвейер контроллинга (main\_etl.py)** + +> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов. +> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL. +> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота. +> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG). + +### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)** + +> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД. +> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху). +> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel. +> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП. +> * python scripts/db\_cli.py anomalies — история выявленных аномалий. +> * python scripts/db\_cli.py rules — правила базы знаний компании. +> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts. +> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок. +> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков. +> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID. +> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день. +> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл. + +## --- + +**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)** + +> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):** + * **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP. + * **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений. + * **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py. +> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):** + * Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData. + * Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут). + * Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку. +> 3. **Интерактивный Task Planner & Roadmap Engine:** + * Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks. +> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):** + * Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст. +> 5. **Автоматизация расписания срезов и Telegram-алерты:** + * Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time. + * Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram. +``` + ## File: `./docs/development_roadmap.md` ``` # 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI @@ -3228,6 +3706,53 @@ main (100% стабильный релиз) * Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде. * Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам. * Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели. + + +### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) +* **Статус:** `MVP Реализован и протестирован` (на базе инструментов системного промпта: `db_preview_prompt_merge`, `db_confirm_prompt_preview`, `db_cancel_prompt_preview`). +* **Концептуальное решение:** + 1. **Двухфазное состояние сессии:** Разделение жизненного цикла на фазу черновика (`PROMPT_PREVIEW`) и фазу интерактивного подтверждения/диалога (`PROMPT_FOLLOWUP`). + 2. **Эфемерный буфер:** Промежуточные экраны, подтверждения и тяжелые выводы помечаются `is_ephemeral = 1` и не засоряют постоянную историю диалога. + 3. **Детерминированный счетчик (Topic Drift):** Бэкенд инкрементирует `idle_turns` при отвлечении на сторонние темы. На шаге $N = 3$ срабатывает **Context Guard** с напоминанием и кнопками действий, а при $N > 3$ происходит автоматический сброс сессии и вызов `db_purge_ephemeral_messages`. + 4. **Fast-Path очистка:** Прямой перехват команд завершения (`нет, спасибо`, `закончить настройку`) моментально очищает эфемерный контекст. + +--- + +### 1.1. План масштабирования механизма эфемерного контекста на все инструменты (Rollout Roadmap) + +План: + +[1. REST API (main.py)] ────► Добавляем POST, PATCH, DELETE для /api/v1/tasks + │ +[2. Agent (agent.py)] ────► При db_get_tasks отдаем payload {type: "TASK_INTERACTIVE_CARD", tasks: [...]} + │ +[3. UI (chat.js / CSS)] ────► Рендерим карточку с табами [Все] [В работе] [В планах] [Завершенные] + +#### Этап 1. Модуль задач и бэклога (`tasks`) +* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`. +* **Задачи реализации:** + * Перевод вывода объемных плоских списков задач в эфемерный режим (`is_ephemeral = 1`), чтобы история диалога не забивалась длинными перечнями. + * Реализация состояния сессии `TASKS_FOLLOWUP` после создания/изменения/удаления задачи. + * Интеграция с Context Guard: напоминание о завершении работы с задачами на 3-м шаге отвлечения. + * Автоочистка промежуточных диалоговых карточек задач при переключении на аналитику СКУД или общие вопросы. + +#### Этап 2. Модуль срезов и архива СКУД (`snapshots`) +* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`. +* **Задачи реализации:** + * Маркировка объемных таблиц срезов и логов как эфемерных сообщений. + * Внедрение интерактивного подтверждения при удалении снапшотов по ID или по дате (`SNAPSHOTS_PREVIEW` / `SNAPSHOTS_FOLLOWUP`). + * Полная автоочистка временных таблиц срезов из памяти диалога после получения пользователем нужной информации. + +#### Этап 3. Модуль базы знаний, правил и аномалий (`kb & anomalies`) +* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`. +* **Задачи реализации:** + * Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой. + * Предотвращение «залипания» модели на старых правилах из истории: гарантия того, что LLM всегда запрашивает актуальные правила через Tool Call, а не читает их из контекста предыдущих сообщений. + +#### Этап 4. Декларативная унификация в `tool_action_registry` +* **Целевые изменения:** + * Расширение схемы `tool_action_registry` новыми декларативными полями: `followup_state_type`, `auto_purge_on_exit`, `guard_question_template`. + * Устранение жестких проверок названий функций в `agent.py`: перевод всей логики жизненного цикла сессий и очистки памяти на декларативные параметры из базы данных SQLite. ``` ## File: `./core/__init__.py` @@ -3735,68 +4260,37 @@ def load_absences_from_db(date_str): =============================================================================== FILE: modules/web_api/main.py PROJECT: SCUD Orion AI (Unified Repository) -MODULE: web_api (FastAPI REST Server & Context Management) -ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), - маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. - -AI-CONTEXT-ANCHORS & INVARIANTS: - - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов - независимо от рабочей директории запуска (root или web_api). - - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного - рендеринга интерфейса и ассетов (css/js/favicon). - - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). - - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и - генерации ответов LLM (agent.process_chat_message). - -DEPENDENCIES: - - modules/web_api/llm/agent.py (process_chat_message) - - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) - - modules/web_api/llm/file_parser.py (extract_text_from_file) +MODULE: web_api (Main Application Entry Point) +ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] +# ANCHOR[APP_INIT_IMPORTS] import os import sys -import json -import sqlite3 import logging -import urllib.request -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional -# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) -import jwt -from passlib.context import CryptContext -from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import RequestValidationError -from pydantic import BaseModel -# Внутренние модули LLM и БД -from llm.agent import process_chat_message -from llm.db_tools import db_get_tasks, DB_PATH -from llm.file_parser import extract_text_from_file +from routers.auth import router as auth_router +from routers.admin import router as admin_router +from routers.tasks import router as tasks_router +from routers.chat import router as chat_router -# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] +# ANCHOR[APP_CONFIG] logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) -JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" -ALGORITHM = "HS256" - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -security = HTTPBearer() - STATIC_DIR = os.path.join(CURRENT_DIR, "static") app = FastAPI(title="SCUD Orion AI Context API", version="2.5") @@ -3812,57 +4306,15 @@ async def validation_exception_handler(request, exc): content={"detail": exc.errors(), "body": str(exc)} ) -# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] -def get_db(): - """Создает безопасное соединение с SQLite БД модуля.""" - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn +# ANCHOR[ROUTER_REGISTRATION] +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(tasks_router) +app.include_router(chat_router) -def create_access_token(user_id: int, username: str, is_admin: bool) -> str: - payload = { - "sub": str(user_id), - "username": username, - "is_admin": is_admin, - "exp": datetime.utcnow() + timedelta(days=30) - } - return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - try: - token = credentials.credentials - payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) - user_id = int(payload.get("sub")) - username = payload.get("username") - is_admin = bool(payload.get("is_admin", False)) - return {"id": user_id, "username": username, "is_admin": is_admin} - except Exception as e: - logging.warning(f"Auth error: {e}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Недействительный или просроченный токен авторизации", - headers={"WWW-Authenticate": "Bearer"}, - ) - -# Pydantic-схемы валидации запросов -class AuthRequest(BaseModel): - username: str - password: str - -class CreateUserRequest(BaseModel): - username: str - password: str - full_name: Optional[str] = None - is_admin: Optional[bool] = False - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - -# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] +# ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") def read_root(): - """Отдает главную страницу панели управления.""" index_path = os.path.join(STATIC_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) @@ -3875,164 +4327,9 @@ async def favicon(): return FileResponse(file_path) raise HTTPException(status_code=404) -# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] -@app.post("/api/v1/auth/login") -def login(req: AuthRequest): - username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) - user = cursor.fetchone() - conn.close() - - if not user or not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - is_admin = bool(user["is_admin"]) or (user["username"] == "puh") - token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") - - return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} - -@app.post("/api/v1/auth/change-password") -def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not req.new_password or len(req.new_password) < 4: - raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) - user = cursor.fetchone() - - if not user or not pwd_context.verify(req.old_password, user["password_hash"]): - conn.close() - raise HTTPException(status_code=400, detail="Неверный старый пароль") - - new_hash = pwd_context.hash(req.new_password) - cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) - conn.commit() - conn.close() - - logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") - return {"status": "success", "message": "Пароль успешно изменен"} - -@app.get("/api/v1/admin/users") -def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") - users = [dict(r) for r in cursor.fetchall()] - conn.close() - return users - -@app.post("/api/v1/admin/users") -def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - username = req.username.strip().lower() - if not username or not req.password: - raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) - if cursor.fetchone(): - conn.close() - raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") - - pwd_hash = pwd_context.hash(req.password) - full_name = req.full_name.strip() if req.full_name else None - is_admin = 1 if req.is_admin else 0 - - cursor.execute( - "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", - (username, pwd_hash, full_name, is_admin) - ) - conn.commit() - conn.close() - - logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") - return {"status": "success", "message": f"Пользователь {username} создан"} - -@app.delete("/api/v1/admin/users/{user_id}") -def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - if user_id == current_user["id"]: - raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - conn.commit() - conn.close() - - logging.info(f"Удален пользователь ID: {user_id}") - return {"status": "success", "message": "Пользователь удален"} - -# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] -@app.get("/api/v1/tasks") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - """Получить задачи текущего авторизованного пользователя.""" - return db_get_tasks(user_id=user["id"]) - -# --- ЧАТ С АВТОРИЗАЦИЕЙ --- -@app.post("/api/v1/chat") -async def chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None), - current_user: dict = Depends(get_current_user) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=current_user["id"], - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - - -# --- ГОСТЕВОЙ ЧАТ --- -@app.post("/api/v1/chat/guest") -async def guest_chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=0, - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - -# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") - target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): return FileResponse(target) @@ -4231,6 +4528,1731 @@ if __name__ == "__main__": print_tree('.') ``` +## File: `./modules/web_api/static/index.html` +```html + + + + + + SCUD Orion AI — Context Manager + + + + + + + +
+
+
+
+ +
+
+

SCUD Orion AI

+

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

+
+
+ +
+
+ + +
+ +
+ + +
+ + + + +
+ +
+
+
Или
+
+ + +
+
+ + + + + + + + +
+
+
+ +
+
+

SCUD Orion AI

+ Task & Context API +
+
+ +
+ + + + + + + + + + + +
+
+ + +
+
+ +
+

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

+

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

+
+
+ + + + + +
+
+ + + +
+ +
+ +
+
+
+ + + + + + + + + + + + +``` + +## File: `./modules/web_api/static/js/app.js` +```js +const AUTH_TOKEN_KEY = "scud_api_auth_token"; +const SESSION_ID = "web_session_main"; +const STORAGE_KEY = "scud_chat_input_history"; + +let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || ""; +let CURRENT_USERNAME = localStorage.getItem("scud_username") || ""; +let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true"; +let IS_GUEST = localStorage.getItem("scud_is_guest") === "true"; + +let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); +let historyIndex = -1; + +document.addEventListener("DOMContentLoaded", () => { + const userInputEl = document.getElementById("user-input"); + + if (userInputEl) { + userInputEl.addEventListener("input", function() { + this.style.height = "24px"; + const newHeight = Math.min(this.scrollHeight, 120); + this.style.height = newHeight + "px"; + }); + } + + if (IS_GUEST) { + hideAuthModal(); + updateUIState(); + } else if (API_TOKEN) { + hideAuthModal(); + updateUIState(); + if (typeof loadTasks === "function") { + loadTasks(); + } + } else { + showAuthModal(); + } +}); +``` + +## File: `./modules/web_api/static/js/auth.js` +```js +function showAuthModal() { + const el = document.getElementById("auth-modal"); + if (el) el.classList.remove("hidden"); +} + +function hideAuthModal() { + const el = document.getElementById("auth-modal"); + if (el) el.classList.add("hidden"); +} + +async function handleLogin(e) { + if (e && e.preventDefault) e.preventDefault(); + + const usernameInput = document.getElementById("auth-username-input"); + const passwordInput = document.getElementById("auth-password-input"); + const errorEl = document.getElementById("auth-error"); + + if (!usernameInput || !passwordInput) return; + + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + if (!username || !password) return; + + if (errorEl) errorEl.classList.add("hidden"); + + try { + const res = await fetch("/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + const data = await res.json(); + + if (res.status === 200) { + // Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError + API_TOKEN = data.token; + CURRENT_USERNAME = data.username; + IS_ADMIN = data.is_admin; + IS_GUEST = false; + + localStorage.setItem("scud_api_auth_token", data.token); + localStorage.setItem("scud_username", data.username); + localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false"); + localStorage.removeItem("scud_is_guest"); + + hideAuthModal(); + updateUIState(); + + if (typeof loadTasks === 'function') { + loadTasks(); + } + } else { + if (errorEl) { + errorEl.innerText = data.detail || "Ошибка авторизации"; + errorEl.classList.remove("hidden"); + } + } + } catch (err) { + console.error("[Auth Error]", err); + if (errorEl) { + errorEl.innerText = "Ошибка соединения с сервером"; + errorEl.classList.remove("hidden"); + } + } +} + +function enableGuestMode() { + IS_GUEST = true; + API_TOKEN = ""; + CURRENT_USERNAME = "Гость"; + IS_ADMIN = false; + localStorage.setItem("scud_is_guest", "true"); + hideAuthModal(); + updateUIState(); +} + +function logout() { + localStorage.removeItem("scud_api_auth_token"); + localStorage.removeItem("scud_username"); + localStorage.removeItem("scud_is_admin"); + localStorage.removeItem("scud_is_guest"); + API_TOKEN = ""; + CURRENT_USERNAME = ""; + IS_ADMIN = false; + IS_GUEST = false; + showAuthModal(); +} + +function updateUIState() { + const tasksBtn = document.getElementById("tasks-drawer-btn"); + const adminBtn = document.getElementById("admin-users-btn"); + const changePwdBtn = document.getElementById("change-pwd-btn"); + const guestBadge = document.getElementById("guest-badge"); + const usernameBadge = document.getElementById("username-badge"); + + if (typeof IS_GUEST !== 'undefined' && IS_GUEST) { + if (tasksBtn) tasksBtn.classList.add("hidden"); + if (adminBtn) adminBtn.classList.add("hidden"); + if (changePwdBtn) changePwdBtn.classList.add("hidden"); + if (guestBadge) guestBadge.classList.remove("hidden"); + if (usernameBadge) usernameBadge.classList.add("hidden"); + } else { + if (tasksBtn) tasksBtn.classList.remove("hidden"); + if (changePwdBtn) changePwdBtn.classList.remove("hidden"); + if (guestBadge) guestBadge.classList.add("hidden"); + + if (usernameBadge) { + usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User"; + usernameBadge.classList.remove("hidden"); + } + + if (adminBtn) { + const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh"); + if (isAdminUser) { + adminBtn.classList.remove("hidden"); + } else { + adminBtn.classList.add("hidden"); + } + } + } +} + +function openChangePasswordModal() { + const el = document.getElementById("change-pwd-modal"); + if (el) el.classList.remove("hidden"); +} + +function closeChangePasswordModal() { + const el = document.getElementById("change-pwd-modal"); + if (el) el.classList.add("hidden"); + + const err = document.getElementById("pwd-error"); + const succ = document.getElementById("pwd-success"); + if (err) err.classList.add("hidden"); + if (succ) succ.classList.add("hidden"); + + document.getElementById("old-pwd-input").value = ""; + document.getElementById("new-pwd-input").value = ""; + const confirmInput = document.getElementById("confirm-pwd-input"); + if (confirmInput) confirmInput.value = ""; +} + +async function handleChangePassword(e) { + if (e && e.preventDefault) e.preventDefault(); + const old_password = document.getElementById("old-pwd-input").value; + const new_password = document.getElementById("new-pwd-input").value; + const confirmInput = document.getElementById("confirm-pwd-input"); + const confirm_password = confirmInput ? confirmInput.value : new_password; + const errorEl = document.getElementById("pwd-error"); + const successEl = document.getElementById("pwd-success"); + + if (errorEl) errorEl.classList.add("hidden"); + if (successEl) successEl.classList.add("hidden"); + + if (new_password !== confirm_password) { + if (errorEl) { + errorEl.innerText = "Новые пароли не совпадают"; + errorEl.classList.remove("hidden"); + } + return; + } + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ old_password, new_password }) + }); + + const data = await res.json(); + + if (res.status === 200) { + if (successEl) { + successEl.innerText = "Пароль успешно изменен!"; + successEl.classList.remove("hidden"); + } + setTimeout(closeChangePasswordModal, 1500); + } else { + if (errorEl) { + errorEl.innerText = data.detail || "Ошибка при смене пароля"; + errorEl.classList.remove("hidden"); + } + } + } catch (err) { + if (errorEl) { + errorEl.innerText = "Ошибка соединения с сервером"; + errorEl.classList.remove("hidden"); + } + } +} + +function openAdminModal() { + const el = document.getElementById("admin-modal"); + if (el) el.classList.remove("hidden"); + loadUsersList(); +} + +function closeAdminModal() { + const el = document.getElementById("admin-modal"); + if (el) el.classList.add("hidden"); +} + +async function loadUsersList() { + const listEl = document.getElementById("admin-users-list"); + if (!listEl) return; + listEl.innerHTML = '
Загрузка пользователей...
'; + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/admin/users", { + headers: { "Authorization": "Bearer " + token } + }); + const users = await res.json(); + + if (res.status === 200) { + listEl.innerHTML = users.map(u => { + const adminTag = u.is_admin ? 'ADMIN' : 'USER'; + const fullNameHtml = u.full_name ? `
${u.full_name}
` : ''; + const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—'; + const deleteBtn = u.username !== CURRENT_USERNAME ? `` : 'Вы'; + + return ` +
+
+
+ ${u.username} + ${adminTag} +
+ ${fullNameHtml} +
Создан: ${dateStr}
+
+ ${deleteBtn} +
+ `; + }).join(""); + } else { + listEl.innerHTML = `
${users.detail}
`; + } + } catch (err) { + listEl.innerHTML = '
Ошибка загрузки пользователей
'; + } +} + +async function handleCreateUser(e) { + if (e && e.preventDefault) e.preventDefault(); + const username = document.getElementById("new-user-name").value.trim(); + const password = document.getElementById("new-user-pwd").value; + const fullNameInput = document.getElementById("new-user-fullname"); + const full_name = fullNameInput ? fullNameInput.value.trim() : ""; + const adminCheckbox = document.getElementById("new-user-is-admin"); + const is_admin = adminCheckbox ? adminCheckbox.checked : false; + const msgEl = document.getElementById("admin-msg"); + + if (msgEl) msgEl.classList.add("hidden"); + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/admin/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ username, password, full_name, is_admin }) + }); + + const data = await res.json(); + + if (res.status === 200) { + document.getElementById("new-user-name").value = ""; + document.getElementById("new-user-pwd").value = ""; + if (fullNameInput) fullNameInput.value = ""; + if (adminCheckbox) adminCheckbox.checked = false; + loadUsersList(); + } else { + if (msgEl) { + msgEl.innerText = data.detail || "Ошибка"; + msgEl.classList.remove("hidden"); + } + } + } catch (err) { + if (msgEl) { + msgEl.innerText = "Ошибка связи с сервером"; + msgEl.classList.remove("hidden"); + } + } +} + +async function deleteUser(userId, username) { + if (!confirm("Удалить пользователя " + username + "?")) return; + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + await fetch("/api/v1/admin/users/" + userId, { + method: "DELETE", + headers: { "Authorization": "Bearer " + token } + }); + loadUsersList(); + } catch (err) { + alert("Ошибка при удалении"); + } +} +``` + +## File: `./modules/web_api/static/js/chat.js` +```js +/** +=============================================================================== +FILE: modules/web_api/static/js/chat.js +ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических + кнопок подтверждений и блокировка ввода при активном действии. +=============================================================================== +*/ + +function updateInputHeight(el) { + if (!el) return; + el.style.height = "24px"; + const newHeight = Math.min(el.scrollHeight, 120); + el.style.height = newHeight + "px"; +} + +let selectedFile = null; + +function handleFileSelect(e) { + const file = e.target.files[0]; + if (!file) return; + + if (file.size > 15 * 1024 * 1024) { + alert("Файл слишком большой. Максимальный размер: 15 МБ"); + e.target.value = ""; + return; + } + + selectedFile = file; + const fileNameEl = document.getElementById("file-name-display"); + const fileSizeEl = document.getElementById("file-size-display"); + const previewContainer = document.getElementById("file-preview-container"); + + if (fileNameEl) fileNameEl.innerText = file.name; + if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; + if (previewContainer) previewContainer.classList.remove("hidden"); +} + +function clearAttachedFile() { + selectedFile = null; + const fileInput = document.getElementById("file-input"); + const previewContainer = document.getElementById("file-preview-container"); + if (fileInput) fileInput.value = ""; + if (previewContainer) previewContainer.classList.add("hidden"); +} + +function setInputLocked(isLocked) { + const input = document.getElementById("user-input"); + const sendBtn = document.getElementById("send-btn"); + if (input) { + input.disabled = isLocked; + if (isLocked) { + input.placeholder = "Выберите действие с помощью кнопок выше..."; + input.classList.add("cursor-not-allowed", "opacity-60"); + } else { + input.placeholder = "Команда, вопрос или перетащите файл сюда..."; + input.classList.remove("cursor-not-allowed", "opacity-60"); + } + } + if (sendBtn) { + sendBtn.disabled = isLocked; + if (isLocked) { + sendBtn.classList.add("opacity-40", "cursor-not-allowed"); + } else { + sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); + } + } +} + +function disableAllActionButtons() { + const allBtnContainers = document.querySelectorAll(".action-buttons-container"); + allBtnContainers.forEach(container => { + container.querySelectorAll("button").forEach(btn => { + btn.disabled = true; + btn.classList.add("opacity-40", "cursor-not-allowed"); + }); + }); +} + +function handleActionButtonClick(text) { + disableAllActionButtons(); + setInputLocked(false); + const input = document.getElementById("user-input"); + if (input) { + input.value = text; + sendMessage(); + } +} + +// --- [INTERACTIVE TASK WIDGET] --- +let activeWidgetTasksMap = new Map(); + +function renderTaskItemsHtml(tasks, filterStatus) { + const filtered = tasks.filter(t => { + if (filterStatus === "ALL") return true; + return t.status === filterStatus; + }); + + if (filtered.length === 0) { + const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; + return `
Нет задач со статусом «${label}»
`; + } + + return filtered.map(t => { + const isDone = t.status === "COMPLETED"; + const isProgress = t.status === "IN_PROGRESS"; + const checkedAttr = isDone ? "checked" : ""; + const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; + + let statusPill = `В планах`; + if (isDone) { + statusPill = `Завершено`; + } else if (isProgress) { + statusPill = `В работе`; + } + + let prioPill = ""; + if (t.priority === "HIGH") { + prioPill = `HIGH`; + } + + return ` +
+
+ +
+
+ ${t.task_id} + ${prioPill} + ${statusPill} +
+ ${escapeHtml(t.title)} +
+
+ +
+ `; + }).join(""); +} + +function renderInteractiveTaskCard(tasks) { + if (!tasks || !Array.isArray(tasks)) return ""; + const widgetId = "task_widget_" + Date.now(); + activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); + + const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); + + return ` +
+
+ + + + +
+ +
+ ${itemsHtml} +
+ +
+ + +
+
+ `; +} + +function filterTaskWidget(widgetId, status, btnEl) { + const state = activeWidgetTasksMap.get(widgetId); + if (!state) return; + state.filter = status; + + const widgetEl = document.getElementById(widgetId); + if (!widgetEl) return; + + widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { + b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; + }); + btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; + + const container = widgetEl.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, status); + } +} + +async function toggleTaskStatusInline(taskId, isChecked) { + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; + + try { + const res = await fetch(`/api/v1/tasks/${taskId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ status: newStatus }) + }); + + if (res.ok) { + for (let [wId, state] of activeWidgetTasksMap.entries()) { + const targetTask = state.tasks.find(t => t.task_id === taskId); + if (targetTask) { + targetTask.status = newStatus; + const widgetEl = document.getElementById(wId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); + } + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Patch Error]", err); + } +} + +async function deleteTaskInline(taskId) { + if (!confirm(`Удалить задачу ${taskId}?`)) return; + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch(`/api/v1/tasks/${taskId}`, { + method: "DELETE", + headers: { "Authorization": "Bearer " + token } + }); + + if (res.ok) { + for (let [wId, state] of activeWidgetTasksMap.entries()) { + state.tasks = state.tasks.filter(t => t.task_id !== taskId); + const widgetEl = document.getElementById(wId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Delete Error]", err); + } +} + +async function addTaskInline(widgetId, inputEl) { + if (!inputEl) return; + const title = inputEl.value.trim(); + if (!title) return; + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch("/api/v1/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) + }); + + if (res.ok) { + inputEl.value = ""; + const tasksRes = await fetch("/api/v1/tasks", { + headers: { "Authorization": "Bearer " + token } + }); + const updatedTasks = await tasksRes.json(); + + const state = activeWidgetTasksMap.get(widgetId); + if (state) { + state.tasks = updatedTasks; + const widgetEl = document.getElementById(widgetId); + const container = widgetEl?.querySelector(".widget-items-container"); + if (container) { + container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); + } + } + if (typeof loadTasks === "function") loadTasks(); + } + } catch (err) { + console.error("[Task Add Error]", err); + } +} + +// --- [CHAT SEND PIPELINE] --- +async function sendMessage(e) { + if (e && e.preventDefault) e.preventDefault(); + + const input = document.getElementById("user-input"); + const chatWindow = document.getElementById("chat-window"); + const sendBtn = document.getElementById("send-btn"); + + if (!input || !chatWindow) return; + const text = input.value.trim(); + + if (!text && !selectedFile) return; + + disableAllActionButtons(); + + let userDisplayHtml = escapeHtml(text); + if (selectedFile) { + userDisplayHtml = `
+ ${escapeHtml(selectedFile.name)} +
` + userDisplayHtml; + } + + const userMsgHtml = ` +
+
+ ${userDisplayHtml} +
+
+ `; + chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); + + input.value = ""; + updateInputHeight(input); + chatWindow.scrollTop = chatWindow.scrollHeight; + + if (sendBtn) { + sendBtn.disabled = true; + sendBtn.classList.add("opacity-50"); + } + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); + + const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; + + const formData = new FormData(); + formData.append("session_id", "web_session_main"); + formData.append("message", text || "Проанализируй прикрепленный файл"); + + if (selectedFile instanceof File) { + formData.append("file", selectedFile, selectedFile.name); + } + + const headers = {}; + if (!isGuest && token) { + headers["Authorization"] = "Bearer " + token; + } + + try { + const res = await fetch(endpoint, { + method: "POST", + headers: headers, + body: formData + }); + + if (res.status === 401 && !isGuest) { + if (typeof logout === 'function') logout(); + return; + } + + const data = await res.json(); + const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; + const replyText = data.reply || "Пустой ответ от нейросети"; + + let actionButtonsHtml = ""; + const actionData = data.action_type || data.action; + + if (actionData && actionData.buttons && actionData.buttons.length > 0) { + const buttonsMarkup = actionData.buttons.map(btn => { + let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; + let iconMarkup = ''; + + const labelLower = (btn.label || "").toLowerCase(); + const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); + const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); + + if (isPrimary) { + btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; + iconMarkup = ''; + } else if (isDanger) { + btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; + iconMarkup = ''; + } + + return ` + + `; + }).join(""); + + actionButtonsHtml = ` +
+ ${buttonsMarkup} +
+ `; + + // Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры + if (actionData.type === "PROMPT_PREVIEW") { + setInputLocked(true); + } else { + setInputLocked(false); + } + } else { + setInputLocked(false); + } + + let interactiveWidgetHtml = ""; + if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) { + interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); + } + + const botMsgHtml = ` +
+

+ ${assistantTitle} +

+

${escapeHtml(replyText)}

+ ${interactiveWidgetHtml} + ${actionButtonsHtml} +
+ `; + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + + clearAttachedFile(); + + if (!isGuest && typeof loadTasks === 'function') { + loadTasks(); + } + + } catch (err) { + console.error("[Chat Error]", err); + const errorHtml = ` +
+ Ошибка связи с сервером. +
+ `; + chatWindow.insertAdjacentHTML("beforeend", errorHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + setInputLocked(false); + } finally { + if (sendBtn && !document.getElementById("user-input")?.disabled) { + sendBtn.disabled = false; + sendBtn.classList.remove("opacity-50"); + } + } +} + +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("user-input"); + const dropZone = document.getElementById("chat-window")?.parentElement; + const dropOverlay = document.getElementById("drop-overlay"); + + if (input) { + let historyIndex = -1; + let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (input.disabled) return; + const text = input.value.trim(); + if (text) { + if (localHistory.length === 0 || localHistory[0] !== text) { + localHistory.unshift(text); + if (localHistory.length > 50) localHistory.pop(); + localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); + } + historyIndex = -1; + } + sendMessage(e); + updateInputHeight(input); + return; + } + + if (e.key === "ArrowUp") { + const textBeforeCursor = input.value.substring(0, input.selectionStart); + const isFirstLine = !textBeforeCursor.includes("\n"); + + if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { + if (historyIndex < localHistory.length - 1) { + e.preventDefault(); + if (historyIndex === -1) { + input.dataset.draft = input.value; + } + historyIndex++; + input.value = localHistory[historyIndex]; + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } + + if (e.key === "ArrowDown") { + const textAfterCursor = input.value.substring(input.selectionEnd); + const isLastLine = !textAfterCursor.includes("\n"); + + if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + input.value = localHistory[historyIndex]; + } else { + historyIndex = -1; + input.value = input.dataset.draft || ""; + } + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + }); + } + + if (dropZone && dropOverlay) { + ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + ["dragenter", "dragover"].forEach(eventName => { + dropZone.addEventListener(eventName, () => { + dropOverlay.classList.remove("hidden"); + dropOverlay.classList.add("flex"); + }, false); + }); + + ["dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { + dropOverlay.classList.add("hidden"); + dropOverlay.classList.remove("flex"); + } + }, false); + }); + + dropZone.addEventListener("drop", (e) => { + const dt = e.dataTransfer; + const files = dt.files; + + if (files && files.length > 0) { + const file = files[0]; + handleFileSelect({ target: { files: [file] } }); + + const fileInput = document.getElementById("file-input"); + if (fileInput) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + } + } + }, false); + } +}); +``` + +## File: `./modules/web_api/static/js/tasks.js` +```js +/** +=============================================================================== +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-карточек в боковом меню. +=============================================================================== +*/ + +let currentFilter = 'ALL'; +let allTasks = []; + +// --- [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; + } + + try { + const res = await fetch("/api/v1/tasks", { + headers: { + "Authorization": "Bearer " + token, + "Content-Type": "application/json" + } + }); + + if (res.status === 401) { + if (typeof logout === 'function') logout(); + return; + } + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + const data = await res.json(); + + if (Array.isArray(data)) { + allTasks = data; + } else if (data && Array.isArray(data.tasks)) { + allTasks = data.tasks; + } else if (data && typeof data === 'object') { + allTasks = Object.values(data).find(val => Array.isArray(val)) || []; + } else { + allTasks = []; + } + + if (badge) { + badge.innerText = allTasks.length.toString(); + } + + renderTasks(); + + } catch (err) { + console.error("[Tasks Error]", err); + if (badge) badge.innerText = "0"; + if (container) { + container.innerHTML = `
Ошибка обработки списка задач
`; + } + } +} + +// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM] +function renderTasks() { + const container = document.getElementById("tasks-container"); + if (!container) return; + + if (!Array.isArray(allTasks)) { + allTasks = []; + } + + const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter); + + if (filtered.length === 0) { + container.innerHTML = `
Нет задач с выбранным фильтром
`; + return; + } + + container.innerHTML = filtered.map(t => { + let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; + let statusLabel = "В планах"; + let cardBg = "bg-white"; + + 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.title || t.description || ''}

+
+ + ${t.module || 'General'} +
+ ${dueDateHtml} +
+ `; + }).join(""); +} +``` + +## File: `./modules/web_api/static/css/styles.css` +```css +/* Плавное исчезновение текста сверху при скролле */ +.fade-scroll-top { + mask-image: linear-gradient(to bottom, transparent 0%, black 14px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px); +} + +/* Скрытие стандартного скроллбара */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */ +body { + min-height: 100vh; + min-height: -webkit-fill-available; +} + +``` + +## File: `./modules/web_api/routers/admin.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/admin.py +ROLE: Администрирование пользователей и прав доступа. +=============================================================================== +""" + +# ANCHOR[ADMIN_ROUTER_IMPORTS] +import logging +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user, get_db, pwd_context + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +# ANCHOR[ADMIN_SCHEMAS] +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +# ANCHOR[ADMIN_ENDPOINTS] +@router.get("/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@router.post("/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} +``` + +## File: `./modules/web_api/routers/auth.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/auth.py +ROLE: Аутентификация, валидация JWT-токенов и управление паролями. +=============================================================================== +""" + +# ANCHOR[AUTH_ROUTER_IMPORTS] +import sqlite3 +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel + +from llm.db_tools import DB_PATH + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +# ANCHOR[AUTH_DB_HELPERS] +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# ANCHOR[AUTH_SCHEMAS] +class AuthRequest(BaseModel): + username: str + password: str + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + +# ANCHOR[AUTH_ENDPOINTS] +@router.post("/login") +def login(req: AuthRequest): + username = req.username.strip().lower() + logging.info(f"===> Попытка входа для пользователя: {username}") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) + user = cursor.fetchone() + conn.close() + + if not user or not pwd_context.verify(req.password, user["password_hash"]): + logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + is_admin = bool(user["is_admin"]) or (user["username"] == "puh") + token = create_access_token(user["id"], user["username"], is_admin) + logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") + + return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + +@router.post("/change-password") +def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not req.new_password or len(req.new_password) < 4: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} +``` + +## File: `./modules/web_api/routers/chat.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/chat.py +ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты). +=============================================================================== +""" + +# ANCHOR[CHAT_ROUTER_IMPORTS] +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, UploadFile, File, Form + +from .auth import get_current_user +from llm.agent import process_chat_message +from llm.file_parser import extract_text_from_file + +router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) + +# ANCHOR[CHAT_ENDPOINTS] +@router.post("") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Диалог авторизованного пользователя с агентом.""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=current_user["id"], + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + +@router.post("/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + """Гостевой диалог (user_id=0).""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=0, + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} +``` + +## File: `./modules/web_api/routers/tasks.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/tasks.py +ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +=============================================================================== +""" + +# ANCHOR[TASKS_ROUTER_IMPORTS] +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user +from llm.db_tools import ( + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) + +router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) + +# ANCHOR[TASKS_SCHEMAS] +class CreateTaskRequest(BaseModel): + title: str + priority: Optional[str] = "MEDIUM" + module: Optional[str] = "general" + due_date: Optional[str] = None + +class UpdateTaskRequest(BaseModel): + status: Optional[str] = "COMPLETED" + due_date: Optional[str] = None + +# ANCHOR[TASKS_ENDPOINTS] +@router.get("") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить список всех задач текущего авторизованного пользователя.""" + return db_get_tasks(user_id=user["id"]) + +@router.post("") +def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое создание задачи.""" + res = db_add_task( + user_id=user["id"], + module=req.module or "general", + title=req.title.strip(), + priority=req.priority or "MEDIUM", + due_date=req.due_date + ) + return res + +@router.patch("/{task_id}") +def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое обновление статуса и срока задачи.""" + res = db_update_task_status( + user_id=user["id"], + task_id=task_id, + status=req.status or "COMPLETED", + due_date=req.due_date + ) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res + +@router.delete("/{task_id}") +def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое удаление задачи.""" + res = db_delete_task(user_id=user["id"], task_id=task_id) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res +``` + ## File: `./modules/web_api/llm/__init__.py` ```py @@ -4242,25 +6264,18 @@ if __name__ == "__main__": =============================================================================== FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) -ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов - инструментов (Function Calling), интеграция с декларативным реестром - действий SQLite (tool_action_registry), детерминированный Fast-Path - для подтверждений, отслеживание Topic Drift и Context Guard с кнопками. +MODULE: web_api / llm (Core Agent Coordinator) +ROLE: Оркестратор диалога и диспетчер Function Calling. =============================================================================== """ # --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] -import os import sys import json -import urllib.request -import urllib.error import logging -import re from typing import List, Dict, Any, Tuple, Optional -# Импорт фасада базы данных +from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, db_add_system_prompt, @@ -4273,7 +6288,6 @@ from .db_tools import ( db_set_session_state, db_get_session_state, db_clear_session_state, - db_increment_session_idle, db_get_snapshots, db_delete_snapshots, db_get_current_server_time, @@ -4287,8 +6301,11 @@ from .db_tools import ( ) from .schemas import TOOLS_SCHEMA -from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru -from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from .core.calendar_utils import get_dynamic_calendar_context +from .core.tool_injector import clean_raw_tool_tags, clean_output +from .core.ollama_client import call_ollama_chat +from .core.fast_path import handle_fast_path_intercept +from .core.prompt_merger import build_prompt_preview_merge # --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] logger = logging.getLogger("SCUD_AGENT") @@ -4301,10 +6318,6 @@ if not logger.handlers: handler.setFormatter(formatter) logger.addHandler(handler) -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -TEXT_MODEL = "qwen2.5:14b" -VISION_MODEL = "qwen2.5vl:7b-q8_0" - # --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] def process_chat_message( @@ -4316,397 +6329,210 @@ def process_chat_message( session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: """ - Главный конвейер обработки входящего сообщения: - 1. Fast-Path перехват подтверждений/отмен при активном session_state. - 2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. - 3. Формирование системного контекста с учетом активного действия (черновика). - 4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. - 5. Проверка счетчика отвлечений (idle_turns) и Context Guard на 3-м шаге. - 6. Возврат кортежа: (reply_text, chat_history, action_metadata). + Главный конвейер диалога: + 1. Проверка Fast-Path кнопок (подтверждение, отмена, завершение с очисткой). + 2. Формирование актуального системного контекста и вызов Ollama LLM. + 3. Выполнение инструментов и фиксация в БД. """ 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}" - + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message session_state = db_get_session_state(session_id) - user_msg_clean = user_message.lower().strip(" .!?:;") - # --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- - if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}") - - reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + # 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 - # --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] --- - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") - - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", draft_text) - db_clear_session_state(session_id) - - action_cfg = db_get_tool_action("db_confirm_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } - - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_clear_session_state(session_id) - - action_cfg = db_get_tool_action("db_cancel_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } - - # 3.2. Сохраняем входящее сообщение в историю диалога + # 2. Сохранение пользовательского сообщения is_user_ephemeral = 1 if session_state else 0 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) - - # 3.3. Извлекаем полную актуальную историю для передачи в LLM db_history = db_get_chat_history(session_id, limit=20) + # 3. Сборка системного промпта dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" - # Информируем модель о наличии незавершенного действия в сессии active_state_context = "" - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + if session_state: + state_type = session_state.get("state_type", "GENERAL") active_state_context = ( - "\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n" - "В данный момент оператор рассматривает подготовленный черновик системного промпта.\n" - "- Если оператор просит продолжить правки или уточняет детали по черновику — продолжай работу с ним.\n" - "- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\n" + f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n" + f"Сейчас активен сценарий: {state_type}.\n" + f"- При запросе на изменение данных вызывай соответствующий инструмент.\n" ) system_prompt_content = ( f"Ты — интеллектуальный ассистент SCUD Orion AI. " f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" + f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" + f"- Запрещено использовать панибратские или шутливые обращения (например, 'Почемучка'). Отвечай профессионально и строго по существу.\n\n" f"[ОКРУЖЕНИЕ]\n" f"- Пользователь: {user_info}\n" f"- {calendar_context}\n" f"{active_state_context}\n" f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\n" + f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_preview_prompt_merge(prompt_text=...).\n" f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n" - f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n" - f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n" - f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n" - f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n" - f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n" - f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n" + f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\n\n" f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" ) - llm_options = { - "num_predict": 8192, - "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, - "top_p": 0.9 - } - user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD] - if image_b64: - user_msg_object["images"] = [image_b64] - messages = [ - {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."}, - user_msg_object - ] - payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} - else: - clean_db_history = [dict(m) for m in db_history] - for m in clean_db_history: - m.pop("images", None) - messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] - payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} - - # --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] + # --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER] try: - req = urllib.request.Request( - OLLAMA_URL, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req) as response: - res_data = json.loads(response.read().decode("utf-8")) - msg = res_data.get("message", {}) - tool_calls = msg.get("tool_calls", []) - raw_text_content = msg.get("content", "") + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + msg = call_ollama_chat(messages, is_vision=True) + else: + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) - #tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + tool_calls = msg.get("tool_calls", []) - if tool_calls: - logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") - messages.append(msg) - - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - tool_result_content = "" + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + messages.append(msg) + + tool_names_called = [] - action_cfg = db_get_tool_action(fn_name) - - if fn_name == "db_confirm_prompt_preview": - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") + for tool in tool_calls: + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + tool_names_called.append(fn_name) + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") + tool_result_content = "" + action_cfg = db_get_tool_action(fn_name) - db_add_system_prompt("main_agent", draft_text) - db_clear_session_state(session_id) - - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False) - else: - err_reply = "Нет активного превью для подтверждения." - db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1) - return err_reply, db_get_chat_history(session_id), None + # ANCHOR[TASK_INTERACTIVE_DISPATCH] + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } - elif fn_name == "db_cancel_prompt_preview": - db_clear_session_state(session_id) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False) + elif fn_name == "db_preview_prompt_merge": + proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" + if isinstance(fn_args, str): + proposed_text = fn_args - elif fn_name == "db_get_snapshots": - snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) - tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + merged_prompt = build_prompt_preview_merge(db_get_active_system_prompt(), user_message, proposed_text) + db_set_session_state(session_id, "PROMPT_PREVIEW", {"draft_text": merged_prompt}) - elif fn_name == "db_get_current_server_time": - tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + with get_db_connection() as conn_fix: + conn_fix.cursor().execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn_fix.commit() - elif fn_name == "db_get_tasks": - tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) - - elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: - tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) - - elif fn_name == "db_get_stats": - tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) - - elif fn_name == "db_get_anomalies": - tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) - - elif fn_name == "db_get_session_states": - tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) - - elif fn_name == "db_delete_snapshots": - res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_get_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - - # --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC] - elif fn_name == "db_preview_prompt_merge": - proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" - if isinstance(fn_args, str): - proposed_text = fn_args - - current_prompt = db_get_active_system_prompt() - user_msg_lower = user_message.lower() - - # 1. Обработка удаления пункта - if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): - target_num_match = re.search(r'\d+(\.\d+)*', user_message) - target_num = target_num_match.group(0) if target_num_match else "" - - lines = current_prompt.splitlines() - if target_num: - new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] - else: - new_lines = lines - proposed_text = "\n".join(new_lines) - - # 2. Обработка добавления / изменения пункта - elif proposed_text: - if len(proposed_text) < 500: - clean_item = proposed_text.strip() - for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: - if prefix in clean_item.lower(): - clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).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 re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" - new_lines.append(f" {item_str}") - inserted = True - if not inserted: - new_lines.append(f" {clean_item}") - proposed_text = "\n".join(new_lines) - - # Сохраняем черновик в структурированном виде с idle_turns = 0 - state_payload = { - "draft_text": proposed_text, - "idle_turns": 0 - } - db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload) - - preview_reply = ( - f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" - f"{proposed_text}\n\n" - f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." - ) - db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW", - "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"} - ] - } - - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - - elif fn_name == "db_add_task": - res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_update_task_status": - res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_delete_task": - res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) - - messages.append({"role": "tool", "content": tool_result_content}) - - # --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS] - second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} - sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(sec_req) as sec_response: - sec_res_data = json.loads(sec_response.read().decode("utf-8")) - raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "") - final_content = clean_raw_tool_tags(clean_output(raw_content)) - - # Если была активна фоновая транзакция, инкрементируем счетчик Topic Shift - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - idle_count = db_increment_session_idle(session_id) - logger.info(f"Выполнен инструмент вне черновика. Текущий idle_turns: {idle_count}") - - if idle_count >= 3: - guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" - final_content += guard_note - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=1) - return final_content, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW_GUARD", - "buttons": [ - {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, - {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} - ] - } - - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) - return final_content, db_get_chat_history(session_id), None - - # Если вызовов инструментов не было (обычный текстовый диалог) - raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") - content_str = clean_raw_tool_tags(clean_output(raw_str)) - final_reply = content_str or "Запрос обработан." - - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - idle_count = db_increment_session_idle(session_id) - logger.info(f"Текстовый диалог вне черновика. Текущий idle_turns: {idle_count}") - if idle_count >= 3: - guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?" - final_reply += guard_note - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=1) - return final_reply, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW_GUARD", + preview_reply = ( + f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" + f"{merged_prompt}\n\n" + f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." + ) + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", "buttons": [ - {"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, - {"label": "Сбросить черновик", "value": "отмена", "style": "danger"} + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"} ] } - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), None + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) + + elif fn_name == "db_get_snapshots": + snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) + tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + + elif fn_name == "db_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + + elif fn_name == "db_get_anomalies": + tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + + elif fn_name == "db_get_session_states": + tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) + + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + + elif fn_name == "db_get_reference": + tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + + elif fn_name == "db_add_task": + res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) + tool_result_content = json.dumps(res, ensure_ascii=False) + + elif fn_name == "db_update_task_status": + res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) + tool_result_content = json.dumps(res, ensure_ascii=False) + + elif fn_name == "db_delete_task": + res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1)) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) + } + tool_result_content = json.dumps(res, ensure_ascii=False) + + elif fn_name == "db_delete_snapshots": + res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) + if action_cfg and action_cfg.get("bypass_llm"): + reply_text = action_cfg["success_template"] + if action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1)) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type"), + "buttons": action_cfg.get("buttons", []) + } + tool_result_content = json.dumps(res, ensure_ascii=False) + + messages.append({"role": "tool", "content": tool_result_content}) + + # ANCHOR[SECONDARY_PASS] + sec_msg = call_ollama_chat(messages, is_vision=False) + raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") + final_content = clean_raw_tool_tags(clean_output(raw_content)) + + is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0 + + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + return final_content, db_get_chat_history(session_id), None + + # Обычный текстовый диалог + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") + final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), None except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") @@ -5055,15 +6881,41 @@ TOOLS_SCHEMA = [ "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", "parameters": {"type": "object", "properties": {}} } - }, - { + }, + { "type": "function", "function": { "name": "db_cancel_prompt_preview", "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", "parameters": {"type": "object", "properties": {}} } - } + }, + # ANCHOR[TOPIC_DRIFT_TOOL_SCHEMA] + { + "type": "function", + "function": { + "name": "db_report_topic_shift", + "description": ( + "Вызывай этот инструмент ВСЕГДА, когда в сессии активно контекстное действие " + "(настройка промпта, срез СКУД, мастер задач), но текущий вопрос пользователя " + "никак не связан с этой активностью и переключает диалог на стороннюю тему." + ), + "parameters": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Краткая семантическая причина, почему тема считается сменившейся" + }, + "is_explicit_exit": { + "type": "boolean", + "description": "True, если пользователь прямо попросил выйти/завершить работу" + } + }, + "required": ["reason"] + } + } + } ] ``` @@ -5580,8 +7432,239 @@ def get_dynamic_calendar_context() -> str: return "\n".join(lines) ``` +## File: `./modules/web_api/llm/core/fast_path.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/fast_path.py +ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки + эфемерного контекста по кнопкам завершения. +=============================================================================== +""" + +# ANCHOR[FAST_PATH_IMPORTS] +import logging +from typing import Dict, Any, Tuple, Optional + +from llm.db_tools import ( + db_add_system_prompt, + db_get_tool_action, + db_set_session_state, + db_clear_session_state, + db_purge_ephemeral_messages, + db_save_chat_message, + db_get_chat_history +) + +logger = logging.getLogger("FAST_PATH") + +TERMINATE_ACTIONS = {"нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "готово", "нет"} + + +# ANCHOR[FAST_PATH_ROUTER] +def handle_fast_path_intercept( + session_id: str, + user_message: str, + full_user_content: str, + session_state: Optional[Dict[str, Any]] +) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: + """ + 1. Перехват кнопок завершения -> моментальное удаление эфемерных записей. + 2. Перехват подтверждения/отмены черновика промпта. + """ + user_msg_clean = user_message.lower().strip(" .!?:;") + + # 1. Завершение работы с инструментом -> мгновенная очистка эфемерных сообщений + if user_msg_clean in TERMINATE_ACTIONS: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Сессия закрыта по кнопке/команде '{user_msg_clean}'. Удалено эфемерных сообщений: {deleted_count}") + + reply_text = "Работа с инструментом завершена. Контекст диалога чист. Чем могу помочь?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # 2. Подтверждение / отмена превью системного промпта + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + draft_text = "" + if session_state.get("data_json") and isinstance(session_state["data_json"], dict): + draft_text = session_state["data_json"].get("draft_text", "") + else: + draft_text = session_state.get("pending_data", "") + + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + db_add_system_prompt("main_agent", draft_text) + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) + + action_cfg = db_get_tool_action("db_confirm_prompt_preview") + reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных." + if action_cfg and action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + + is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", + "buttons": action_cfg.get("buttons", []) if action_cfg else [] + } + + elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад"]: + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) + + action_cfg = db_get_tool_action("db_cancel_prompt_preview") + reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены." + if action_cfg and action_cfg.get("follow_up_question"): + reply_text += f"\n\n{action_cfg['follow_up_question']}" + + reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() + + is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1 + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) + return reply_text, db_get_chat_history(session_id), { + "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", + "buttons": action_cfg.get("buttons", []) if action_cfg else [] + } + + return None +``` + +## File: `./modules/web_api/llm/core/ollama_client.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/ollama_client.py +ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. +=============================================================================== +""" + +# ANCHOR[OLLAMA_CLIENT_IMPORTS] +import json +import urllib.request +import urllib.error +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger("OLLAMA_CLIENT") + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +LLM_OPTIONS = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 +} + + +# ANCHOR[OLLAMA_REQUEST_DISPATCHER] +def call_ollama_chat( + messages: list, + tools: Optional[list] = None, + is_vision: bool = False, + timeout: int = 120 +) -> Dict[str, Any]: + """ + Отправляет подготовленный массив сообщений в API Ollama. + Возвращает разобранный словарь сообщения ответа или генерирует исключение. + """ + model_name = VISION_MODEL if is_vision else TEXT_MODEL + payload = { + "model": model_name, + "messages": messages, + "stream": False, + "options": LLM_OPTIONS + } + + if tools and not is_vision: + payload["tools"] = tools + + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=timeout) as response: + res_data = json.loads(response.read().decode("utf-8")) + return res_data.get("message", {}) +``` + +## File: `./modules/web_api/llm/core/prompt_merger.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/prompt_merger.py +ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок + системного промпта (добавление, удаление и замена пунктов). +=============================================================================== +""" + +# ANCHOR[PROMPT_MERGER_IMPORTS] +import re +import logging +from typing import Tuple + +logger = logging.getLogger("PROMPT_MERGER") + + +# ANCHOR[PROMPT_MERGE_ENGINE] +def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str: + """ + Вычисляет результирующий текст промпта на основе намерения пользователя: + 1. Удаление пункта по номеру. + 2. Добавление/изменение пункта в соответствующую секцию. + """ + user_msg_lower = user_message.lower() + + # 1. Сценарий удаления пункта + if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): + target_num_match = re.search(r'\d+(\.\d+)*', user_message) + target_num = target_num_match.group(0) if target_num_match else "" + + lines = current_prompt.splitlines() + if target_num: + new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] + else: + new_lines = lines + return "\n".join(new_lines) + + # 2. Сценарий добавления или замены пункта + if proposed_text: + if len(proposed_text) < 500: + clean_item = proposed_text.strip() + for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: + if prefix in clean_item.lower(): + clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).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 re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" + new_lines.append(f" {item_str}") + inserted = True + if not inserted: + new_lines.append(f" {clean_item}") + return "\n".join(new_lines) + + return proposed_text or current_prompt +``` + ## File: `./modules/web_api/llm/core/tool_injector.py` ```py +""" +FILE: modules/web_api/llm/core/tool_injector.py +""" import re import json import logging @@ -5589,6 +7672,7 @@ from typing import List, Dict, Any logger = logging.getLogger("TOOL_INJECTOR") + def clean_raw_tool_tags(text: str) -> str: if not text: return "" @@ -5598,15 +7682,25 @@ def clean_raw_tool_tags(text: str) -> str: text = re.sub(r'', '', text) return text.strip() + def clean_output(text: str) -> str: if not text: return text - artifacts = ["почемучто", "почто", "почему что"] - lower_text = text.lower() + + # Фильтрация паразитных артефактов токенизатора Qwen + artifacts = [ + "почемучка", "почемучка,", "почемучка!", "почемучка?", + "почемучто", "почто", "почему что", "почему-то", + "здравствуйте!", "привет!" + ] + + cleaned = text.strip() for art in artifacts: - if lower_text.startswith(art): - text = text[len(art):].lstrip(",.!?:; -") - return text.strip() + if cleaned.lower().startswith(art): + cleaned = cleaned[len(art):].lstrip(",.!?:; -") + + return cleaned.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: diff --git a/scripts/db_cli.py b/scripts/db_cli.py index 781afd2..f3cda0e 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -51,11 +51,14 @@ def print_stats(): print("=" * 60) with get_connection() as conn: cursor = conn.cursor() - tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states'] for t in tables: - cursor.execute(f"SELECT COUNT(*) FROM {t}") - cnt = cursor.fetchone()[0] - print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + except Exception: + pass print("=" * 60 + "\n") @@ -236,28 +239,35 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"): out_path = os.path.join(OUTPUT_DIR, out_filename) print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: - for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']: - df = pd.read_sql_query(f"SELECT * FROM {table}", conn) - df.to_excel(writer, sheet_name=table[:31], index=False) + for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']: + try: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + except Exception: + pass print(f"[✓] Дамп успешно сохранен: {out_path}\n") def print_system_prompts(): """Выводит все системные промпты из базы данных.""" print("\n" + "=" * 80) - print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):") print("=" * 80) with get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") - rows = cursor.fetchall() + try: + cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC") + rows = cursor.fetchall() + except Exception: + rows = [] + if not rows: - print("Таблица system_prompts пуста.") + print("Таблица system_prompt_nodes пуста.") else: for r in rows: - print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") + print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}") + print(f" {r[2]}") print("-" * 80) - print(f"{r[4]}\n") print("=" * 80 + "\n") @@ -280,6 +290,50 @@ def print_session_states(): print("=" * 80 + "\n") +def print_chat_messages(session_id=None, limit=50): + """Выводит таблицу истории сообщений чата (контекст).""" + print("\n" + "=" * 105) + print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:") + print("=" * 105) + + with get_connection() as conn: + cursor = conn.cursor() + if session_id: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + """, (session_id, limit)) + else: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + ORDER BY id DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + if not rows: + print("Таблица chat_messages пуста.") + else: + # Если выводили все без конкретной сессии в порядке DESC, развернем для читаемости + if not session_id: + rows = list(reversed(rows)) + + for r in rows: + msg_id, sess, role, content, ephemeral, created = r + eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" + print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") + print("-" * 105) + # Выводим контент, ограничивая длинные сообщения при необходимости + content_preview = content if content else "" + print(f"{content_preview}") + print("=" * 105) + print("\n") + + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -290,9 +344,10 @@ CLI-утилита инспекции и управления SQLite базой absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite - prompts -- Посмотреть системные промпты (system_prompts) + prompts -- Посмотреть узлы системного промпта (system_prompt_nodes) tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) sessions -- Посмотреть активные сессии и превью (session_states) + context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages) dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день @@ -300,13 +355,12 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py stats python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг - python scripts/db_cli.py scud --snapshot Y20260805-007 python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts python scripts/db_cli.py tools python scripts/db_cli.py sessions + python scripts/db_cli.py context web_session_main --limit 20 python scripts/db_cli.py snapshot del Y20260805-007 - python scripts/db_cli.py snapshot del --day 04.08.2026 python scripts/db_cli.py dump my_dump.xlsx """ @@ -321,12 +375,13 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False ) - parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда") - parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") + parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del') или session_id для контекста") parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") + parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)") if len(sys.argv) == 1: print_stats() @@ -354,6 +409,9 @@ def main(): print_session_states() elif args.command == 'tools': print_tool_actions() + elif args.command == 'context': + sess_id = args.action if args.action else None + print_chat_messages(session_id=sess_id, limit=args.limit) elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) diff --git a/scripts/diagnostics/make_code_snapshot.py b/scripts/diagnostics/make_code_snapshot.py index 8942f22..0c560aa 100644 --- a/scripts/diagnostics/make_code_snapshot.py +++ b/scripts/diagnostics/make_code_snapshot.py @@ -3,7 +3,7 @@ import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" # Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css', '.sql'} EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} From 4b1b8a358621737edef102ec4c6a5c8160289079 Mon Sep 17 00:00:00 2001 From: manoraga Date: Tue, 18 Aug 2026 13:47:26 +0300 Subject: [PATCH 6/6] =?UTF-8?q?feat(llm):=20=D0=B4=D0=B2=D1=83=D1=85=D1=84?= =?UTF-8?q?=D0=B0=D0=B7=D0=BD=D1=8B=D0=B9=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=B9,=20=D0=B4=D0=B2=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=BD=D0=B8=D0=B9=20Diff,=20Topi?= =?UTF-8?q?c=20Drift=20Guard=20=D0=B8=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/scud_orion_ai.db | Bin 6062080 -> 6520832 bytes data/scud_orion_ai.db-shm | Bin 0 -> 32768 bytes data/scud_orion_ai.db-wal | Bin 0 -> 185432 bytes docs/CHANGELOG.md | 43 +- docs/development_roadmap.md | 97 - .../план_оптимизации_работы_с_контекстом.md | 69 +- modules/web_api/llm/agent.py | 239 +- modules/web_api/llm/core/fast_path.py | 99 +- modules/web_api/llm/core/tool_injector.py | 23 +- modules/web_api/llm/db/db_prompts.py | 43 +- modules/web_api/llm/schemas.py | 69 +- modules/web_api/routers/chat.py | 9 +- .../web_api/scripts/diagnostics/inspect_db.py | 47 - .../scripts/diagnostics/inspect_files.py | 23 - .../scripts/diagnostics/make_code_snapshot.py | 31 - modules/web_api/static/js/chat.js | 568 ----- modules/web_api/static/js/chat/core.js | 170 +- project_code_snapshot.md | 2139 +++++++++-------- scripts/db_cli.py | 69 +- scripts/diagnostics/make_code_snapshot.py | 31 +- 20 files changed, 1763 insertions(+), 2006 deletions(-) create mode 100644 data/scud_orion_ai.db-shm create mode 100644 data/scud_orion_ai.db-wal delete mode 100644 docs/development_roadmap.md delete mode 100644 modules/web_api/scripts/diagnostics/inspect_db.py delete mode 100644 modules/web_api/scripts/diagnostics/inspect_files.py delete mode 100644 modules/web_api/scripts/diagnostics/make_code_snapshot.py delete mode 100644 modules/web_api/static/js/chat.js diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index df51d11d7c6b7567645845999fa748d8c08f5d89..7900e5b67b1cf5d637d56bf6727b0afa5a603e01 100644 GIT binary patch delta 192977 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 diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..ed9a00585a0606b9b5949d75c416215769a91573 GIT binary patch literal 32768 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 PD2(-9CVzqR;xF+3*=`p2 literal 0 HcmV?d00001 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1783921..e1ef71a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,37 +1,54 @@ # 📋 История изменений (CHANGELOG) -Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле. +Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6]. + +--- + +## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard + +### ✨ Добавлено +* **Двусторонний клиентский Diff (`core.js`)**: Реализован алгоритм сравнения черновика с baseline-промптом. Удаленные строки остаются на экране с красным зачеркиванием `[УДАЛЕНИЕ]`, а добавленные/измененные строки выделяются красной рамкой с авто-отступами. +* **Topic Drift Guard (`agent.py`)**: Внедрен двухуровневый контроль смены темы: + - Мгновенный сброс стейта и зачистка эфемерных сообщений при вызове сторонних инструментов (задачи, снапшоты, база). + - Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`. +* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий. + +### 🔧 Изменено +* **`modules/web_api/llm/agent.py`**: + - Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок. + - В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI. +* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up. --- ## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling ### ✨ Добавлено -* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`. -* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling. +* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6]. +* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6]. ### 🔧 Изменено -* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя. -* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога. +* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6]. +* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6]. --- ## [2026-08-15] — Реляционная архитектура системного промпта ### ✨ Добавлено -* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`). -* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта. -* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`. +* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6]. +* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6]. +* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6]. ### 🔧 Изменено -* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite. -* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных. +* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6]. +* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6]. --- ## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога ### ✨ Добавлено -* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков. -* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций. -* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия. \ No newline at end of file +* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6]. +* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6]. +* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6]. \ No newline at end of file diff --git a/docs/development_roadmap.md b/docs/development_roadmap.md deleted file mode 100644 index 625fc0a..0000000 --- a/docs/development_roadmap.md +++ /dev/null @@ -1,97 +0,0 @@ -# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI - -## 📌 Зафиксированные вехи и концептуальные архитектурные решения - -Схема веток Gitea для ближайшего развития проекта: - -main (100% стабильный релиз) - │ - ├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite) - │ ▼ - ├───────────────────────────────► merge to main - │ - ├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде) - │ ▼ - ├───────────────────────────────► merge to main - │ - └──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM) - ▼ - merge to main - - - - -### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) -* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM. -* **Решение (Сферическое / Фундаментальное):** - 1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`). - 2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст. - 3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`. - ---- - -## 🚀 Будущие модули и запланированный функционал (Backlog) - -### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine) -* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных. -* **Ключевые возможности:** - * Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`). - * Фиксация промежуточных комментариев и статусов выполнения по каждому пункту. - * Синхронизация задач плана с SQLite-таблицей `tasks`. - * Экспорт планов в Markdown/Excel и отображение на дашборде. - - -### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine) -* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных. -* **Ключевые возможности:** - * Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy). - * Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде. - * Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам. - * Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели. - - -### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking) -* **Статус:** `MVP Реализован и протестирован` (на базе инструментов системного промпта: `db_preview_prompt_merge`, `db_confirm_prompt_preview`, `db_cancel_prompt_preview`). -* **Концептуальное решение:** - 1. **Двухфазное состояние сессии:** Разделение жизненного цикла на фазу черновика (`PROMPT_PREVIEW`) и фазу интерактивного подтверждения/диалога (`PROMPT_FOLLOWUP`). - 2. **Эфемерный буфер:** Промежуточные экраны, подтверждения и тяжелые выводы помечаются `is_ephemeral = 1` и не засоряют постоянную историю диалога. - 3. **Детерминированный счетчик (Topic Drift):** Бэкенд инкрементирует `idle_turns` при отвлечении на сторонние темы. На шаге $N = 3$ срабатывает **Context Guard** с напоминанием и кнопками действий, а при $N > 3$ происходит автоматический сброс сессии и вызов `db_purge_ephemeral_messages`. - 4. **Fast-Path очистка:** Прямой перехват команд завершения (`нет, спасибо`, `закончить настройку`) моментально очищает эфемерный контекст. - ---- - -### 1.1. План масштабирования механизма эфемерного контекста на все инструменты (Rollout Roadmap) - -План: - -[1. REST API (main.py)] ────► Добавляем POST, PATCH, DELETE для /api/v1/tasks - │ -[2. Agent (agent.py)] ────► При db_get_tasks отдаем payload {type: "TASK_INTERACTIVE_CARD", tasks: [...]} - │ -[3. UI (chat.js / CSS)] ────► Рендерим карточку с табами [Все] [В работе] [В планах] [Завершенные] - -#### Этап 1. Модуль задач и бэклога (`tasks`) -* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`. -* **Задачи реализации:** - * Перевод вывода объемных плоских списков задач в эфемерный режим (`is_ephemeral = 1`), чтобы история диалога не забивалась длинными перечнями. - * Реализация состояния сессии `TASKS_FOLLOWUP` после создания/изменения/удаления задачи. - * Интеграция с Context Guard: напоминание о завершении работы с задачами на 3-м шаге отвлечения. - * Автоочистка промежуточных диалоговых карточек задач при переключении на аналитику СКУД или общие вопросы. - -#### Этап 2. Модуль срезов и архива СКУД (`snapshots`) -* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`. -* **Задачи реализации:** - * Маркировка объемных таблиц срезов и логов как эфемерных сообщений. - * Внедрение интерактивного подтверждения при удалении снапшотов по ID или по дате (`SNAPSHOTS_PREVIEW` / `SNAPSHOTS_FOLLOWUP`). - * Полная автоочистка временных таблиц срезов из памяти диалога после получения пользователем нужной информации. - -#### Этап 3. Модуль базы знаний, правил и аномалий (`kb & anomalies`) -* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`. -* **Задачи реализации:** - * Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой. - * Предотвращение «залипания» модели на старых правилах из истории: гарантия того, что LLM всегда запрашивает актуальные правила через Tool Call, а не читает их из контекста предыдущих сообщений. - -#### Этап 4. Декларативная унификация в `tool_action_registry` -* **Целевые изменения:** - * Расширение схемы `tool_action_registry` новыми декларативными полями: `followup_state_type`, `auto_purge_on_exit`, `guard_question_template`. - * Устранение жестких проверок названий функций в `agent.py`: перевод всей логики жизненного цикла сессий и очистки памяти на декларативные параметры из базы данных SQLite. \ No newline at end of file diff --git a/docs/in_progress/план_оптимизации_работы_с_контекстом.md b/docs/in_progress/план_оптимизации_работы_с_контекстом.md index bb04160..4587843 100644 --- a/docs/in_progress/план_оптимизации_работы_с_контекстом.md +++ b/docs/in_progress/план_оптимизации_работы_с_контекстом.md @@ -1,33 +1,72 @@ # План реализации ## 1. Очистка от регулярок и костылей (`tool_injector.py`) -- [ ] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов. -- [ ] Оставить в модуле только базовую санитарную очистку сырых тегов (``). +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. --- ## 2. Настройка контекста и инструкций сессии (`agent.py`) -- [ ] Передать управление диалогом языковой модели через системный блок `role: "system"`. -- [ ] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение. -- [ ] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью. +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. +- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. --- ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`) -- [ ] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**. -- [ ] Сбрасывать состояние сессии в базе данных после фиксации решения. +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста. --- ## 4. Инлайн-редактор в окне диалога (`core.js`) -- [ ] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**. -- [ ] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением. +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. --- -## 5. Тестирование и валидация -- [ ] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели. -- [ ] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором. -- [ ] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста. \ No newline at end of file +## 5. Тестирование и валидация системного промпта +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. + +--- + +## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) +- [ ] **Масштабирование UI задач:** + - Увеличить размер окна/виджета просмотра и управления задачами (`TASK_INTERACTIVE_CARD`) по аналогии с окном редактирования системного промпта для удобного управления большим списком. +- [ ] **Интерактивное управление и превью:** + - Карточка предпросмотра параметров создаваемой задачи (`db_add_task`) с кнопками `[Подтвердить]`, `[Отменить]`, `[✏️ Редактировать]`. + - Подтверждение опасных операций (удаление задачи `db_delete_task`) с кнопками валидации. + - Follow-up фаза с выводом актуализированного списка задач из `tool_action_registry`. +- [ ] **Доменная консолидация:** + - Объединить `db_add_task`, `db_update_task_status`, `db_delete_task` в единый диспетчер `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE"], ...)`[cite: 4]. + - Обновить `TOOLS_SCHEMA` в `schemas.py`[cite: 4]. + +--- + +## 7. Распространение на модуль снапшотов (`snapshots`) и правил (`rules`) +- [ ] **Консолидация модуля `snapshots`:** + - Интерактивное подтверждение массового удаления снапшотов за дату (`db_delete_snapshots`). + - Объединить операции удаления и управления срезами в единый `db_snapshots_edit(action: ["DELETE"], ...)`[cite: 4]. + - Авто-зачистка тяжелых таблиц снапшотов из контекста при переходе к другим темам. +- [ ] **Модуль правил арбитража (`rules`):** + - Редактирование правил базы знаний через единый паттерн узлов с подсветкой diff. + +--- + +## 8. Изолированная песочница кода (Code Execution Sandbox Engine) +- [ ] **Docker/gVisor контур:** + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. +- [ ] **Динамические Python/Pandas вычисления:** + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата. \ No newline at end of file diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index a62b435..a341b8b 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -3,15 +3,18 @@ FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) -ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов. +ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта, + управление сессионными стейтами, Topic Drift Guard и очистка контекста. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] +# ============================================================================= +# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ +# ============================================================================= import sys -import re import json import logging +import re from typing import List, Dict, Any, Tuple, Optional from .db.connection import get_db_connection @@ -44,7 +47,6 @@ from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_ from .core.ollama_client import call_ollama_chat from .core.fast_path import handle_fast_path_intercept -# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] logger = logging.getLogger("SCUD_AGENT") logger.setLevel(logging.INFO) logger.propagate = False @@ -56,7 +58,9 @@ if not logger.handlers: logger.addHandler(handler) -# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] +# ============================================================================= +# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА +# ============================================================================= def process_chat_message( user_id: int, user_message: str, @@ -65,53 +69,91 @@ def process_chat_message( chat_history: List[Dict[str, Any]] = None, session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: - """Главный конвейер диалога с поддержкой реляционного редактора промпта.""" + """ + Главная функция обработки сообщения: + 1. Очищает осиротевшие эфемерные сообщения. + 2. Перехватывает быстрые кнопки (Fast-Path). + 3. Собирает контекст и передает управление Ollama. + 4. Выполняет Tool Calls и управляет счетчиком Topic Drift. + """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения session_state = db_get_session_state(session_id) + if not session_state: + db_purge_ephemeral_messages(session_id) - # 1. Fast-Path перехват + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + + # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) if fast_path_res: return fast_path_res - # 2. Сохранение пользовательского сообщения + # Сохранение входящего сообщения оператора is_user_ephemeral = 1 if session_state else 0 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) db_history = db_get_chat_history(session_id, limit=20) - # 3. Сборка системного промпта + + # ========================================================================= + # БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА + # ========================================================================= dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + # Извлечение текущего состояния и счетчика отвлечений (idle_turns) + current_state_type = session_state.get("state_type") if session_state else None + state_data = session_state.get("data_json") or {} if session_state else {} + if not isinstance(state_data, dict): + state_data = {} + idle_turns = state_data.get("idle_turns", 0) + active_state_context = "" - if session_state: - state_type = session_state.get("state_type", "GENERAL") - active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n" + if current_state_type == "PROMPT_PREVIEW": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" + "- Сейчас открыт предпросмотр изменения системного промпта.\n" + "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" + "- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n" + ) + elif current_state_type == "PROMPT_FOLLOWUP": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n" + "- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n" + "- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n" + "- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n" + ) system_prompt_content = ( - f"Ты — интеллектуальный ассистент SCUD Orion AI. " - f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" - f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" - f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" - f"[ОКРУЖЕНИЕ]\n" - f"- Пользователь: {user_info}\n" - f"- {calendar_context}\n" - f"{active_state_context}\n" - f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_prompt_node_edit(action=..., section_id=..., item_id=..., content=...). Абсолютно запрещено изменять промпт напрямую текстом.\n" - f" Пример: если просят 'добавь пункт 2.9 Тестовый пункт', ты ОБЯЗАН вызвать инструмент db_prompt_node_edit с аргументами: action='ADD', section_id=2, item_id=9, content='Тестовый пункт'.\n" - f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" - f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" - f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" -) + f"Ты — интеллектуальный ассистент SCUD Orion AI. " + f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" + f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" + f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" + f"[ОКРУЖЕНИЕ]\n" + f"- Пользователь: {user_info}\n" + f"- {calendar_context}\n" + f"{active_state_context}\n" + f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" + f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n" + f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n" + f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" + f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n" + f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n" + f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" + f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" + f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" + ) user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER] + + # ========================================================================= + # БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING + # ========================================================================= try: + # Режим Vision (OCR) if image_b64: user_msg_object["images"] = [image_b64] messages = [ @@ -120,6 +162,7 @@ def process_chat_message( ] msg = call_ollama_chat(messages, is_vision=True) else: + # Текстовый диалог clean_db_history = [dict(m) for m in db_history] for m in clean_db_history: m.pop("images", None) @@ -130,20 +173,32 @@ def process_chat_message( tool_calls = msg.get("tool_calls", []) tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) + # Исполнение вызванных моделью инструментов if tool_calls: logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") messages.append(msg) - tool_names_called = [] + # ----------------------------------------------------------------- + # TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ: + # Если вызваны сторонние инструменты (снапшоты, задачи, статы) + # — мгновенно закрываем сессию и вычищаем эфемерный контекст + # ----------------------------------------------------------------- + is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls) + if not is_prompt_tool and session_state: + logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.") + db_clear_session_state(session_id) + db_purge_ephemeral_messages(session_id) + session_state = None + for tool in tool_calls: fn_name = tool["function"]["name"] fn_args = tool["function"].get("arguments", {}) tool_names_called.append(fn_name) logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") tool_result_content = "" - action_cfg = db_get_tool_action(fn_name) + # --- 4.1. Модуль задач --- if fn_name == "db_get_tasks": raw_tasks = db_get_tasks(user_id) reply_text = "Вот интерактивный список ваших текущих задач:" @@ -153,47 +208,88 @@ def process_chat_message( "tasks": raw_tasks } + # --- 4.2. Прямой просмотр системного промпта из SQLite --- + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + active_prompt = db_get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + + # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) --- elif fn_name == "db_prompt_node_edit": action = fn_args.get("action", "ADD").upper() sec_id = int(fn_args.get("section_id", 2)) itm_id = int(fn_args.get("item_id", 1)) content = fn_args.get("content", "").strip() + baseline_prompt = db_get_active_system_prompt() with get_db_connection() as temp_conn: temp_cursor = temp_conn.cursor() - temp_cursor.execute("SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = 'main_agent' AND is_active = 1 ORDER BY section_id, item_id") + temp_cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = 'main_agent' AND is_active = 1 + ORDER BY section_id, item_id + """) existing_nodes = temp_cursor.fetchall() nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} - - if action == "DELETE": - nodes_dict.pop((sec_id, itm_id), None) - else: - nodes_dict[(sec_id, itm_id)] = content - virtual_lines = [] + if action == "DELETE": + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} + else: + nodes_dict_for_draft = dict(nodes_dict) + nodes_dict_for_draft[(sec_id, itm_id)] = content + + # Формирование чистого текста для редактора + draft_lines = [] curr_sec = None - for (s_id, i_id), txt in sorted(nodes_dict.items()): + for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): if i_id == 0: if curr_sec is not None: - virtual_lines.append("") - virtual_lines.append(f"{s_id}. {txt}") + 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 action != "DELETE": + display_nodes[(sec_id, itm_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("") + diff_lines.append(f"{s_id}. {txt}") curr_sec = s_id else: - line_str = f" {s_id}.{i_id}. {txt}" if s_id == sec_id and i_id == itm_id: - line_str = f'{s_id}.{i_id}. {txt}' - virtual_lines.append(line_str) + if action == "DELETE": + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' + else: + line_str = f' {s_id}.{i_id}. {txt}' + else: + line_str = f" {s_id}.{i_id}. {txt}" + diff_lines.append(line_str) - merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines]) - diff_html = "\n".join(virtual_lines) + diff_html = "\n".join(diff_lines) + # Фиксация предпросмотра в session_states db_set_session_state(session_id, "PROMPT_PREVIEW", { "draft_text": merged_prompt, "action": action, "section_id": sec_id, "item_id": itm_id, - "content": content + "content": content, + "idle_turns": 0 }) with get_db_connection() as conn_fix: @@ -214,6 +310,7 @@ def process_chat_message( return preview_reply, db_get_chat_history(session_id), { "type": "PROMPT_PREVIEW", "raw_draft": merged_prompt, + "baseline_prompt": baseline_prompt, "buttons": [ {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, {"label": "Отменить", "value": "отмена", "style": "danger"}, @@ -221,9 +318,7 @@ def process_chat_message( ] } - elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: - tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) - + # --- 4.4. Сервисные инструменты --- elif fn_name == "db_get_snapshots": snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) @@ -237,9 +332,6 @@ def process_chat_message( elif fn_name == "db_get_anomalies": tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) - elif fn_name == "db_get_session_states": - tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) - elif fn_name == "db_get_rules": tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) @@ -264,6 +356,7 @@ def process_chat_message( messages.append({"role": "tool", "content": tool_result_content}) + # Интерпретация результатов вызова инструментов sec_msg = call_ollama_chat(messages, is_vision=False) raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") final_content = clean_raw_tool_tags(clean_output(raw_content)) @@ -273,13 +366,47 @@ def process_chat_message( db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) return final_content, db_get_chat_history(session_id), None + + # ===================================================================== + # БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD + # ===================================================================== raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + action_payload = None - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), None + # Обработка отвлечений оператора при активной сессии настройки/просмотра + if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: + idle_turns += 1 + logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3") + # Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка + if idle_turns > 3: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.") + action_payload = None + session_state = None + elif idle_turns == 3: + # На 3-м сообщении стороннего диалога вежливо спрашиваем оператора + tool_action = db_get_tool_action("db_confirm_prompt_preview") + guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?" + buttons = tool_action.get("buttons", []) if tool_action else [] + + final_reply += f"\n\n💡 *Напоминание:* {guard_question}" + action_payload = { + "type": "PROMPT_FOLLOWUP", + "buttons": buttons + } + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) + else: + # Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик + db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns}) + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0)) + return final_reply, db_get_chat_history(session_id), action_payload + + # Обработка исключений except Exception as ex: - logger.exception(f"Непредвиденная ошибка: {ex}") + logger.exception(f"Непредвиденная ошибка агента: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" return error_reply, db_get_chat_history(session_id), None \ No newline at end of file diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py index 2dcb9fa..69b6d7f 100644 --- a/modules/web_api/llm/core/fast_path.py +++ b/modules/web_api/llm/core/fast_path.py @@ -3,17 +3,18 @@ FILE: modules/web_api/llm/core/fast_path.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки - эфемерного контекста без повторного вывода кнопок-вилок. +ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP), + атомарная фиксация узлов и мягкая зачистка эфемерного контекста. =============================================================================== """ -# ANCHOR[FAST_PATH_IMPORTS] import logging from typing import Dict, Any, Tuple, Optional from llm.db_tools import ( + db_apply_prompt_node_action, db_add_system_prompt, + db_set_session_state, db_clear_session_state, db_purge_ephemeral_messages, db_save_chat_message, @@ -23,47 +24,83 @@ from llm.db_tools import ( logger = logging.getLogger("FAST_PATH") -# ANCHOR[FAST_PATH_ROUTER] def handle_fast_path_intercept( session_id: str, user_message: str, full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: - """ - 1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст. - 2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст. - """ - if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW": + """Обрабатывает команды подтверждения, отмены и завершения сессии.""" + if not session_state: return None + state_type = session_state.get("state_type") user_msg_clean = user_message.lower().strip(" .!?:;") - draft_text = "" - if session_state.get("data_json") and isinstance(session_state["data_json"], dict): - draft_text = session_state["data_json"].get("draft_text", "") - else: - draft_text = session_state.get("pending_data", "") + # ========================================================================= + # ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW + # ========================================================================= + if state_type == "PROMPT_PREVIEW": + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} - # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", draft_text) - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Системный промпт сохранен в БД. Удалено эфемерных сообщений: {deleted_count}") + draft_text = state_data.get("draft_text", "") + action = state_data.get("action") + sec_id = state_data.get("section_id") + itm_id = state_data.get("item_id") + content = state_data.get("content", "") - reply_text = "Системный промпт успешно сохранен и применен в базе данных." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + if action == "MANUAL_EDIT" or not action or sec_id is None: + if draft_text: + db_add_system_prompt("main_agent", draft_text) + else: + db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) - # 2. ОТМЕНИТЬ ИЗМЕНЕНИЯ - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") + logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.") + + # Переводим сессию в режим ожидания продолжения/завершения + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) - reply_text = "Изменения системного промпта отменены. Контекст диалога чист." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + + action_payload = { + "type": "PROMPT_FOLLOWUP", + "buttons": [ + {"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"}, + {"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"} + ] + } + return reply_text, db_get_chat_history(session_id), action_payload + + # 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА + elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") + + reply_text = "Изменения системного промпта отменены. Контекст диалога чист." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # ========================================================================= + # ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP + # ========================================================================= + elif state_type == "PROMPT_FOLLOWUP": + # Явный сигнал завершения работы + if user_msg_clean in [ + "завершить работу", "завершить", "закончить", "готово", "всё", + "все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти" + ]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}") + + reply_text = "Работа с системным промптом завершена. Чем могу помочь?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None return 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 0798e9d..3dd45c3 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -1,8 +1,12 @@ """ +=============================================================================== FILE: modules/web_api/llm/core/tool_injector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +=============================================================================== """ import re -import json import logging from typing import List, Dict, Any @@ -10,6 +14,7 @@ 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) @@ -20,10 +25,10 @@ def clean_raw_tool_tags(text: str) -> str: def clean_output(text: str) -> str: + """Удаляет слова-паразиты и склейки в начале ответа.""" if not text: return text - # Фильтрация паразитных артефактов токенизатора Qwen artifacts = [ "почемучка", "почемучка,", "почемучка!", "почемучка?", "почемучто", "почто", "почему что", "почему-то", @@ -39,15 +44,7 @@ def clean_output(text: str) -> str: 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 - 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 as parse_err: - logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}") + """ + Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + """ return tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db/db_prompts.py b/modules/web_api/llm/db/db_prompts.py index 95ff252..ea55934 100644 --- a/modules/web_api/llm/db/db_prompts.py +++ b/modules/web_api/llm/db/db_prompts.py @@ -7,6 +7,7 @@ ROLE: Реляционное управление системным промп базой знаний, реестром действий и сессионными стейтами. =============================================================================== """ +import re import json import logging from typing import List, Dict, Any, Optional @@ -239,7 +240,7 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: - """Мост обратной совместимости: парсит текст промпта и сохраняет по узлам в БД.""" + """Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов.""" try: init_prompt_nodes_table() with get_db_connection() as conn: @@ -249,30 +250,28 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: current_sec = 1 current_itm = 0 - for line in prompt_text.splitlines(): - stripped = line.strip() - if not stripped: + for raw_line in prompt_text.splitlines(): + clean_line = re.sub(r'<[^>]+>', '', raw_line).strip() + if not clean_line: continue - - # Проверяем заголовок раздела (например, "1. РОЛЬ И ЗАДАЧИ...") - parts = stripped.split(".", 1) - if len(parts) == 2 and parts[0].strip().isdigit(): - sec_num = int(parts[0].strip()) - rest = parts[1].strip() - - # Проверяем, это подпункт (например, "1.1. Текст") или заголовок раздела - sub_parts = rest.split(".", 1) - if len(sub_parts) == 2 and sub_parts[0].strip().isdigit(): - current_sec = sec_num - current_itm = int(sub_parts[0].strip()) - content = sub_parts[1].strip() - else: - current_sec = sec_num - current_itm = 0 - content = rest + + # 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст") + sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line) + # 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ") + sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line) + + if sub_match: + current_sec = int(sub_match.group(1)) + current_itm = int(sub_match.group(2)) + content = sub_match.group(3).strip() + elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]): + # Заголовок раздела (обычно капсом) + current_sec = int(sec_match.group(1)) + current_itm = 0 + content = sec_match.group(2).strip() else: current_itm += 1 - content = stripped + content = clean_line cursor.execute(""" INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index df64a8f..386139e 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -1,4 +1,39 @@ TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "db_prompt_node_edit", + "description": ( + "ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. " + "Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. " + "При удалении: action='DELETE', section_id=X, item_id=Y, content=''. " + "Запрещено отвечать текстовым подтверждением без вызова этой функции!" + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["ADD", "UPDATE", "DELETE"], + "description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)" + }, + "section_id": { + "type": "integer", + "description": "Номер раздела (например: 1, 2, 3)" + }, + "item_id": { + "type": "integer", + "description": "Номер пункта (например: 4, 8, 9)" + }, + "content": { + "type": "string", + "description": "Текст пункта (для DELETE передается пустая строка)" + } + }, + "required": ["action", "section_id", "item_id"] + } + } + }, { "type": "function", "function": { @@ -169,39 +204,5 @@ TOOLS_SCHEMA = [ } } } - }, - { - "type": "function", - "function": { - "name": "db_prompt_node_edit", - "description": ( - "Сформировать предпросмотр изменения системного промпта через реляционные узлы. " - "Вызывай при любых запросах на добавление, изменение или удаление пунктов. " - "Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)." - ), - "parameters": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["ADD", "UPDATE", "DELETE"], - "description": "Действие: ADD (добавить пункт), UPDATE (изменить пункт), DELETE (удалить пункт)" - }, - "section_id": { - "type": "integer", - "description": "Номер раздела (например, 2)" - }, - "item_id": { - "type": "integer", - "description": "Номер пункта внутри раздела (например, 9)" - }, - "content": { - "type": "string", - "description": "Текст формулировки пункта (для ADD и UPDATE). Без префикса номера!" - } - }, - "required": ["action", "section_id", "item_id"] - } - } } ] \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py index 639790b..1dce55e 100644 --- a/modules/web_api/routers/chat.py +++ b/modules/web_api/routers/chat.py @@ -82,5 +82,12 @@ def update_draft_endpoint( if not state or state.get("state_type") != "PROMPT_PREVIEW": raise HTTPException(status_code=400, detail="Нет активного превью для редактирования") - db_set_session_state(req.session_id, "PROMPT_PREVIEW", {"draft_text": req.draft_text.strip()}) + # Сохраняем чистый текст с пометкой MANUAL_EDIT + db_set_session_state(req.session_id, "PROMPT_PREVIEW", { + "draft_text": req.draft_text.strip(), + "action": "MANUAL_EDIT", + "section_id": None, + "item_id": None, + "content": "" + }) return {"status": "success", "message": "Черновик успешно обновлен в сессии"} \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_db.py b/modules/web_api/scripts/diagnostics/inspect_db.py deleted file mode 100644 index 4f7a02f..0000000 --- a/modules/web_api/scripts/diagnostics/inspect_db.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import sqlite3 - -# Автопоиск файла базы данных в проекте -db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db' - -print("=" * 80) -print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") -print("=" * 80) - -if not os.path.exists(db_path): - print(f"❌ Файл базы данных {db_path} не найден!") - exit(1) - -conn = sqlite3.connect(db_path) -cursor = conn.cursor() - -# 1. Список всех таблиц и колонок -cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") -tables = [t[0] for t in cursor.fetchall()] - -print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") -print("-" * 80) -for t_name in tables: - cursor.execute(f"PRAGMA table_info({t_name})") - cols = [c[1] for c in cursor.fetchall()] - - cursor.execute(f"SELECT COUNT(*) FROM {t_name}") - count = cursor.fetchone()[0] - - print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") - -# 2. Просмотр правил Базы Знаний -if 'ai_knowledge_base' in tables: - print("\n" + "=" * 80) - print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") - print("=" * 80) - cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") - rules = cursor.fetchall() - if not rules: - print("Таблица ai_knowledge_base пуста.") - else: - for r_id, r_text, r_author in rules: - print(f" {r_id}. [{r_author}] {r_text}\n") - -conn.close() -print("=" * 80) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_files.py b/modules/web_api/scripts/diagnostics/inspect_files.py deleted file mode 100644 index fa3a4f3..0000000 --- a/modules/web_api/scripts/diagnostics/inspect_files.py +++ /dev/null @@ -1,23 +0,0 @@ -import os - -print("=" * 80) -print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)") -print("=" * 80) - -total_files = 0 -total_size = 0 - -for root, dirs, files in os.walk('.'): - # Исключаем служебные каталоги - dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] - - for f in files: - p = os.path.join(root, f) - size = os.path.getsize(p) - total_files += 1 - total_size += size - print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) - -print("-" * 80) -print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") -print("=" * 80) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/make_code_snapshot.py b/modules/web_api/scripts/diagnostics/make_code_snapshot.py deleted file mode 100644 index 066bb1e..0000000 --- a/modules/web_api/scripts/diagnostics/make_code_snapshot.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -OUTPUT_SNAPSHOT = "api_code_snapshot.md" - -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'} - -print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") - -with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: - out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n") - - for root, dirs, files in os.walk('.'): - dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] - - for file in sorted(files): - ext = os.path.splitext(file)[1].lower() - if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: - filepath = os.path.join(root, file) - out.write(f"## File: `{filepath}`\n") - out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") - try: - with open(filepath, 'r', encoding='utf-8', errors='replace') as f: - out.write(f.read()) - except Exception as e: - out.write(f"// Ошибка чтения файла: {e}\n") - out.write("\n```\n\n") - -print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") \ No newline at end of file diff --git a/modules/web_api/static/js/chat.js b/modules/web_api/static/js/chat.js deleted file mode 100644 index 94b41dc..0000000 --- a/modules/web_api/static/js/chat.js +++ /dev/null @@ -1,568 +0,0 @@ -/** -=============================================================================== -FILE: modules/web_api/static/js/chat.js -ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических - кнопок подтверждений и блокировка ввода при активном действии. -=============================================================================== -*/ - -function updateInputHeight(el) { - if (!el) return; - el.style.height = "24px"; - const newHeight = Math.min(el.scrollHeight, 120); - el.style.height = newHeight + "px"; -} - -let selectedFile = null; - -function handleFileSelect(e) { - const file = e.target.files[0]; - if (!file) return; - - if (file.size > 15 * 1024 * 1024) { - alert("Файл слишком большой. Максимальный размер: 15 МБ"); - e.target.value = ""; - return; - } - - selectedFile = file; - const fileNameEl = document.getElementById("file-name-display"); - const fileSizeEl = document.getElementById("file-size-display"); - const previewContainer = document.getElementById("file-preview-container"); - - if (fileNameEl) fileNameEl.innerText = file.name; - if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; - if (previewContainer) previewContainer.classList.remove("hidden"); -} - -function clearAttachedFile() { - selectedFile = null; - const fileInput = document.getElementById("file-input"); - const previewContainer = document.getElementById("file-preview-container"); - if (fileInput) fileInput.value = ""; - if (previewContainer) previewContainer.classList.add("hidden"); -} - -function setInputLocked(isLocked) { - const input = document.getElementById("user-input"); - const sendBtn = document.getElementById("send-btn"); - if (input) { - input.disabled = isLocked; - if (isLocked) { - input.placeholder = "Выберите действие с помощью кнопок выше..."; - input.classList.add("cursor-not-allowed", "opacity-60"); - } else { - input.placeholder = "Команда, вопрос или перетащите файл сюда..."; - input.classList.remove("cursor-not-allowed", "opacity-60"); - } - } - if (sendBtn) { - sendBtn.disabled = isLocked; - if (isLocked) { - sendBtn.classList.add("opacity-40", "cursor-not-allowed"); - } else { - sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); - } - } -} - -function disableAllActionButtons() { - const allBtnContainers = document.querySelectorAll(".action-buttons-container"); - allBtnContainers.forEach(container => { - container.querySelectorAll("button").forEach(btn => { - btn.disabled = true; - btn.classList.add("opacity-40", "cursor-not-allowed"); - }); - }); -} - -function handleActionButtonClick(text) { - disableAllActionButtons(); - setInputLocked(false); - const input = document.getElementById("user-input"); - if (input) { - input.value = text; - sendMessage(); - } -} - -// --- [INTERACTIVE TASK WIDGET] --- -let activeWidgetTasksMap = new Map(); - -function renderTaskItemsHtml(tasks, filterStatus) { - const filtered = tasks.filter(t => { - if (filterStatus === "ALL") return true; - return t.status === filterStatus; - }); - - if (filtered.length === 0) { - const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; - return `
Нет задач со статусом «${label}»
`; - } - - return filtered.map(t => { - const isDone = t.status === "COMPLETED"; - const isProgress = t.status === "IN_PROGRESS"; - const checkedAttr = isDone ? "checked" : ""; - const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; - - let statusPill = `В планах`; - if (isDone) { - statusPill = `Завершено`; - } else if (isProgress) { - statusPill = `В работе`; - } - - let prioPill = ""; - if (t.priority === "HIGH") { - prioPill = `HIGH`; - } - - return ` -
-
- -
-
- ${t.task_id} - ${prioPill} - ${statusPill} -
- ${escapeHtml(t.title)} -
-
- -
- `; - }).join(""); -} - -function renderInteractiveTaskCard(tasks) { - if (!tasks || !Array.isArray(tasks)) return ""; - const widgetId = "task_widget_" + Date.now(); - activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); - - const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); - - return ` -
-
- - - - -
- -
- ${itemsHtml} -
- -
- - -
-
- `; -} - -function filterTaskWidget(widgetId, status, btnEl) { - const state = activeWidgetTasksMap.get(widgetId); - if (!state) return; - state.filter = status; - - const widgetEl = document.getElementById(widgetId); - if (!widgetEl) return; - - widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { - b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; - }); - btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; - - const container = widgetEl.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, status); - } -} - -async function toggleTaskStatusInline(taskId, isChecked) { - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; - - try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ status: newStatus }) - }); - - if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - const targetTask = state.tasks.find(t => t.task_id === taskId); - if (targetTask) { - targetTask.status = newStatus; - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Patch Error]", err); - } -} - -async function deleteTaskInline(taskId) { - if (!confirm(`Удалить задачу ${taskId}?`)) return; - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "DELETE", - headers: { "Authorization": "Bearer " + token } - }); - - if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - state.tasks = state.tasks.filter(t => t.task_id !== taskId); - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Delete Error]", err); - } -} - -async function addTaskInline(widgetId, inputEl) { - if (!inputEl) return; - const title = inputEl.value.trim(); - if (!title) return; - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch("/api/v1/tasks", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) - }); - - if (res.ok) { - inputEl.value = ""; - const tasksRes = await fetch("/api/v1/tasks", { - headers: { "Authorization": "Bearer " + token } - }); - const updatedTasks = await tasksRes.json(); - - const state = activeWidgetTasksMap.get(widgetId); - if (state) { - state.tasks = updatedTasks; - const widgetEl = document.getElementById(widgetId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Add Error]", err); - } -} - -// --- [CHAT SEND PIPELINE] --- -async function sendMessage(e) { - if (e && e.preventDefault) e.preventDefault(); - - const input = document.getElementById("user-input"); - const chatWindow = document.getElementById("chat-window"); - const sendBtn = document.getElementById("send-btn"); - - if (!input || !chatWindow) return; - const text = input.value.trim(); - - if (!text && !selectedFile) return; - - disableAllActionButtons(); - - let userDisplayHtml = escapeHtml(text); - if (selectedFile) { - userDisplayHtml = `
- ${escapeHtml(selectedFile.name)} -
` + userDisplayHtml; - } - - const userMsgHtml = ` -
-
- ${userDisplayHtml} -
-
- `; - chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); - - input.value = ""; - updateInputHeight(input); - chatWindow.scrollTop = chatWindow.scrollHeight; - - if (sendBtn) { - sendBtn.disabled = true; - sendBtn.classList.add("opacity-50"); - } - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); - - const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; - - const formData = new FormData(); - formData.append("session_id", "web_session_main"); - formData.append("message", text || "Проанализируй прикрепленный файл"); - - if (selectedFile instanceof File) { - formData.append("file", selectedFile, selectedFile.name); - } - - const headers = {}; - if (!isGuest && token) { - headers["Authorization"] = "Bearer " + token; - } - - try { - const res = await fetch(endpoint, { - method: "POST", - headers: headers, - body: formData - }); - - if (res.status === 401 && !isGuest) { - if (typeof logout === 'function') logout(); - return; - } - - const data = await res.json(); - const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; - const replyText = data.reply || "Пустой ответ от нейросети"; - - let actionButtonsHtml = ""; - const actionData = data.action_type || data.action; - - if (actionData && actionData.buttons && actionData.buttons.length > 0) { - const buttonsMarkup = actionData.buttons.map(btn => { - let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; - let iconMarkup = ''; - - const labelLower = (btn.label || "").toLowerCase(); - const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); - const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); - - if (isPrimary) { - btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; - iconMarkup = ''; - } else if (isDanger) { - btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; - iconMarkup = ''; - } - - return ` - - `; - }).join(""); - - actionButtonsHtml = ` -
- ${buttonsMarkup} -
- `; - - // Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры - if (actionData.type === "PROMPT_PREVIEW") { - setInputLocked(true); - } else { - setInputLocked(false); - } - } else { - setInputLocked(false); - } - - let interactiveWidgetHtml = ""; - if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) { - interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); - } - - const botMsgHtml = ` -
-

- ${assistantTitle} -

-

${escapeHtml(replyText)}

- ${interactiveWidgetHtml} - ${actionButtonsHtml} -
- `; - chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; - - clearAttachedFile(); - - if (!isGuest && typeof loadTasks === 'function') { - loadTasks(); - } - - } catch (err) { - console.error("[Chat Error]", err); - const errorHtml = ` -
- Ошибка связи с сервером. -
- `; - chatWindow.insertAdjacentHTML("beforeend", errorHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; - setInputLocked(false); - } finally { - if (sendBtn && !document.getElementById("user-input")?.disabled) { - sendBtn.disabled = false; - sendBtn.classList.remove("opacity-50"); - } - } -} - -function escapeHtml(text) { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -document.addEventListener("DOMContentLoaded", () => { - const input = document.getElementById("user-input"); - const dropZone = document.getElementById("chat-window")?.parentElement; - const dropOverlay = document.getElementById("drop-overlay"); - - if (input) { - let historyIndex = -1; - let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); - - input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (input.disabled) return; - const text = input.value.trim(); - if (text) { - if (localHistory.length === 0 || localHistory[0] !== text) { - localHistory.unshift(text); - if (localHistory.length > 50) localHistory.pop(); - localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); - } - historyIndex = -1; - } - sendMessage(e); - updateInputHeight(input); - return; - } - - if (e.key === "ArrowUp") { - const textBeforeCursor = input.value.substring(0, input.selectionStart); - const isFirstLine = !textBeforeCursor.includes("\n"); - - if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { - if (historyIndex < localHistory.length - 1) { - e.preventDefault(); - if (historyIndex === -1) { - input.dataset.draft = input.value; - } - historyIndex++; - input.value = localHistory[historyIndex]; - updateInputHeight(input); - input.setSelectionRange(input.value.length, input.value.length); - } - } - } - - if (e.key === "ArrowDown") { - const textAfterCursor = input.value.substring(input.selectionEnd); - const isLastLine = !textAfterCursor.includes("\n"); - - if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { - e.preventDefault(); - if (historyIndex > 0) { - historyIndex--; - input.value = localHistory[historyIndex]; - } else { - historyIndex = -1; - input.value = input.dataset.draft || ""; - } - updateInputHeight(input); - input.setSelectionRange(input.value.length, input.value.length); - } - } - }); - } - - if (dropZone && dropOverlay) { - ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { - dropZone.addEventListener(eventName, (e) => { - e.preventDefault(); - e.stopPropagation(); - }, false); - }); - - ["dragenter", "dragover"].forEach(eventName => { - dropZone.addEventListener(eventName, () => { - dropOverlay.classList.remove("hidden"); - dropOverlay.classList.add("flex"); - }, false); - }); - - ["dragleave", "drop"].forEach(eventName => { - dropZone.addEventListener(eventName, (e) => { - if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { - dropOverlay.classList.add("hidden"); - dropOverlay.classList.remove("flex"); - } - }, false); - }); - - dropZone.addEventListener("drop", (e) => { - const dt = e.dataTransfer; - const files = dt.files; - - if (files && files.length > 0) { - const file = files[0]; - handleFileSelect({ target: { files: [file] } }); - - const fileInput = document.getElementById("file-input"); - if (fileInput) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - fileInput.files = dataTransfer.files; - } - } - }, false); - } -}); \ No newline at end of file diff --git a/modules/web_api/static/js/chat/core.js b/modules/web_api/static/js/chat/core.js index 2545048..de806f9 100644 --- a/modules/web_api/static/js/chat/core.js +++ b/modules/web_api/static/js/chat/core.js @@ -4,7 +4,7 @@ FILE: modules/web_api/static/js/chat/core.js PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / static / js / chat ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода - при активных кнопках, вызов онлайн-редактора и отправка правок. + при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff. =============================================================================== */ @@ -79,11 +79,6 @@ function disableAllActionButtons() { } function handleActionButtonClick(text) { - if (text === "action:open_editor") { - openInlinePromptEditor(); - return; - } - disableAllActionButtons(); setInputLocked(false); const input = document.getElementById("user-input"); @@ -93,22 +88,123 @@ function handleActionButtonClick(text) { } } -function openInlinePromptEditor() { - const editorContainer = document.getElementById("inline-prompt-editor-container"); - if (editorContainer) { +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] --- +function openInlinePromptEditor(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + + const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); + const diffView = cardContainer.querySelector(".prompt-preview-diff-view"); + const textarea = cardContainer.querySelector(".inline-prompt-textarea"); + + if (editorContainer && textarea) { editorContainer.classList.remove("hidden"); - const textarea = document.getElementById("inline-prompt-textarea"); - if (textarea) textarea.focus(); + + if (diffView) { + const targetHeight = Math.max(diffView.offsetHeight, 320); + textarea.style.height = `${targetHeight}px`; + } + + textarea.focus(); } } -function closeInlinePromptEditor() { - const editorContainer = document.getElementById("inline-prompt-editor-container"); +function closeInlinePromptEditor(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); if (editorContainer) editorContainer.classList.add("hidden"); } -async function saveManualPromptDraft() { - const textarea = document.getElementById("inline-prompt-textarea"); +function parsePromptIntoMap(text) { + const map = new Map(); + if (!text) return map; + const lines = text.split("\n"); + for (let line of lines) { + let trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); + if (match) { + const key = `${match[1]}.${match[2]}`; + map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed }); + } + } + return map; +} + +function buildHighlightedPromptHtml(newRawText, originalBaselineText) { + const origMap = parsePromptIntoMap(originalBaselineText); + const newMap = parsePromptIntoMap(newRawText); + + const formattedLines = []; + const rawLines = newRawText.split("\n"); + const handledNewKeys = new Set(); + + let currentSection = null; + + for (let line of rawLines) { + let trimmed = line.trim(); + if (!trimmed) { + formattedLines.push(""); + continue; + } + + const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/); + const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); + + if (subMatch) { + const key = `${subMatch[1]}.${subMatch[2]}`; + handledNewKeys.add(key); + currentSection = parseInt(subMatch[1]); + const cleanContent = subMatch[3].trim(); + const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`; + + if (!origMap.has(key) || origMap.get(key).content !== cleanContent) { + // Добавленный или изменённый пункт + formattedLines.push(` ${escapeHtml(fullItemStr)}`); + } else { + formattedLines.push(` ${escapeHtml(fullItemStr)}`); + } + } else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) { + // Заголовок раздела + currentSection = parseInt(secMatch[1]); + formattedLines.push(escapeHtml(trimmed)); + } else { + formattedLines.push(escapeHtml(trimmed)); + } + } + + // Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте) + for (let [origKey, origObj] of origMap.entries()) { + if (!handledNewKeys.has(origKey)) { + const strikeMarkup = ` ${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]`; + formattedLines.push(strikeMarkup); + } + } + + return formattedLines.join("\n"); +} + +function anyLower(str) { + return /[а-яa-z]/.test(str); +} + +async function saveManualPromptDraft(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + + const textarea = cardContainer.querySelector(".inline-prompt-textarea"); + const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view"); if (!textarea) return; const newText = textarea.value.trim(); @@ -130,28 +226,18 @@ async function saveManualPromptDraft() { }); if (res.ok) { - closeInlinePromptEditor(); - const previewTextEl = document.getElementById("prompt-preview-diff-view"); + closeInlinePromptEditor(btnEl); if (previewTextEl) { - previewTextEl.innerText = newText; + const baseline = cardContainer.dataset.originalBaseline || ""; + const highlightedHtml = buildHighlightedPromptHtml(newText, baseline); + previewTextEl.innerHTML = highlightedHtml; } - alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД."); } } catch (err) { alert("Ошибка сохранения черновика: " + err); } } -function escapeHtml(text) { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - // --- [CHAT SEND PIPELINE] --- async function sendMessage(e) { if (e && e.preventDefault) e.preventDefault(); @@ -250,8 +336,10 @@ async function sendMessage(e) { iconMarkup = ''; } + const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`; + return ` -
`; - // Блокируем ввод при наличии кнопок setInputLocked(true); } else { setInputLocked(false); } let previewEditorHtml = ""; - if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) { + let rawDraftText = ""; + let baselineText = ""; + + if (actionData && actionData.type === "PROMPT_PREVIEW") { + rawDraftText = actionData.raw_draft || ""; + baselineText = actionData.baseline_prompt || rawDraftText; previewEditorHtml = ` -