""" =============================================================================== FILE: modules/web_api/llm/db/db_prompts.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / db ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes), базой знаний, реестром действий и сессионными стейтами. =============================================================================== """ import re import json import logging from typing import List, Dict, Any, Optional from .connection import get_db_connection logger = logging.getLogger("DB_PROMPTS") def init_prompt_nodes_table(): """Создает реляционную таблицу узлов промпта и заполняет базовыми данными.""" with get_db_connection() as conn: cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS system_prompt_nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt_name TEXT DEFAULT 'main_agent', section_id INTEGER NOT NULL, item_id INTEGER NOT NULL, content TEXT NOT NULL, is_active INTEGER DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(prompt_name, section_id, item_id) ); """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'") if cursor.fetchone()[0] == 0: seed_nodes = [ # Раздел 1 (1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"), (1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."), (1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."), (1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."), (1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."), (1, 5, "Поддержка диалога с операторами и администраторами системы."), (1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."), # Раздел 2 (2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"), (2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."), (2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."), (2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."), (2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."), (2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."), (2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."), (2, 7, "Запрещено писать названия функций или код вызова текстом на экран."), (2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."), # Раздел 3 (3, 0, "ПРАВИЛА СТИЛЯ"), (3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."), (3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."), (3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.") ] cursor.executemany(""" INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content) VALUES ('main_agent', ?, ?, ?) """, seed_nodes) conn.commit() def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str: """Собирает структурированный текст промпта из реляционной таблицы узлов.""" init_prompt_nodes_table() with get_db_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = ? AND is_active = 1 ORDER BY section_id ASC, item_id ASC """, (prompt_name,)) rows = cursor.fetchall() if not rows: return "Ты — ИИ-ассистент SCUD Orion AI." lines = [] current_section = None for sec_id, itm_id, content in rows: if itm_id == 0: if current_section is not None: lines.append("") lines.append(f"{sec_id}. {content}") current_section = sec_id else: lines.append(f" {sec_id}.{itm_id}. {content}") return "\n".join(lines) def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"): """Прямое добавление, изменение или удаление узла в БД.""" init_prompt_nodes_table() with get_db_connection() as conn: cursor = conn.cursor() action_clean = action.upper() if action_clean in ["ADD", "UPDATE"]: cursor.execute(""" INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at) VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET content = excluded.content, is_active = 1, updated_at = CURRENT_TIMESTAMP """, (prompt_name, section_id, item_id, content)) elif action_clean == "DELETE": cursor.execute(""" DELETE FROM system_prompt_nodes WHERE prompt_name = ? AND section_id = ? AND item_id = ? """, (prompt_name, section_id, item_id)) conn.commit() def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: 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_get_rules() -> List[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") rows = cursor.fetchall() conn.close() return [dict(r) for r in rows] def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: """Сохраняет состояние сессии в SQLite.""" conn = get_db_connection() cursor = conn.cursor() payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (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) ON CONFLICT(session_id) DO UPDATE SET state_type = excluded.state_type, pending_data = excluded.pending_data, updated_at = CURRENT_TIMESTAMP """, (session_id, state_type, payload_str)) conn.commit() conn.close() def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,)) row = cursor.fetchone() conn.close() if not row: return None res = dict(row) raw_data = res.get("pending_data") or "" try: res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None except Exception: res["data_json"] = None return res 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_stats() -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks'] stats = {} for t in tables: try: cursor.execute(f"SELECT COUNT(*) FROM {t}") stats[t] = cursor.fetchone()[0] except Exception: stats[t] = 0 conn.close() return {"status": "success", "tables_stats": stats} def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" params = [] if date_str: query += " WHERE anomaly_date = ?" params.append(date_str) query += " ORDER BY id DESC LIMIT ?" params.append(limit) cursor.execute(query, params) rows = cursor.fetchall() conn.close() return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() query = "SELECT category, title, example_prompt, description FROM system_reference" params = [] if category: query += " WHERE category = ?" params.append(category) query += " ORDER BY id ASC" cursor.execute(query, params) rows = cursor.fetchall() conn.close() return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: """Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов.""" try: init_prompt_nodes_table() with get_db_connection() as conn: cursor = conn.cursor() cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,)) current_sec = 1 current_itm = 0 for raw_line in prompt_text.splitlines(): clean_line = re.sub(r'<[^>]+>', '', raw_line).strip() if not clean_line: continue # 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 = clean_line cursor.execute(""" INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) VALUES (?, ?, ?, ?, 1) """, (name, current_sec, current_itm, content)) conn.commit() return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"} except Exception as e: logger.error(f"Ошибка при разборе промпта в узлы: {e}") return {"status": "error", "error": str(e)}