From 0e33e61ab25ee74273e035be294b70a07005524d Mon Sep 17 00:00:00 2001 From: manoraga Date: Sat, 15 Aug 2026 12:45:02 +0300 Subject: [PATCH] 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"