feat(llm): переход на реляционные узлы промпта, db_cli context и очистка от регулярок

This commit is contained in:
2026-08-17 11:55:07 +03:00
parent 4e3c99b728
commit 828ce67817
25 changed files with 4447 additions and 1006 deletions
+78 -109
View File
@@ -3,12 +3,13 @@
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Оркестратор диалога, диспетчер Function Calling и Topic Drift контроллер.
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов.
===============================================================================
"""
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
import sys
import re
import json
import logging
from typing import List, Dict, Any, Tuple, Optional
@@ -16,7 +17,7 @@ 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,
db_apply_prompt_node_action,
db_get_tool_action,
db_get_tasks,
db_update_task_status,
@@ -26,7 +27,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,
@@ -35,16 +35,14 @@ from .db_tools import (
db_purge_ephemeral_messages,
db_get_stats,
db_get_anomalies,
db_get_session_states,
db_get_reference
)
from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
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")
@@ -67,12 +65,7 @@ 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. Проверка Fast-Path команд (подтверждение, отмена, завершение).
2. Формирование контекста и вызов Ollama LLM.
3. Выполнение инструментов и обработка Context Guard.
"""
"""Главный конвейер диалога с поддержкой реляционного редактора промпта."""
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
@@ -94,28 +87,26 @@ def process_chat_message(
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"
)
if session_state:
state_type = session_state.get("state_type", "GENERAL")
active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n"
system_prompt_content = (
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{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(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}"
)
user_msg_object = {"role": "user", "content": full_user_content}
@@ -135,20 +126,24 @@ def process_chat_message(
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
raw_text_reply = msg.get("content", "")
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 = []
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)
# ANCHOR[TASK_INTERACTIVE_DISPATCH]
if fn_name == "db_get_tasks":
raw_tasks = db_get_tasks(user_id)
reply_text = "Вот интерактивный список ваших текущих задач:"
@@ -158,13 +153,48 @@ def process_chat_message(
"tasks": raw_tasks
}
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_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()
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})
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")
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 = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict.items()):
if i_id == 0:
if curr_sec is not None:
virtual_lines.append("")
virtual_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'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{s_id}.{i_id}. {txt}</span>'
virtual_lines.append(line_str)
merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines])
diff_html = "\n".join(virtual_lines)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": action,
"section_id": sec_id,
"item_id": itm_id,
"content": content
})
with get_db_connection() as conn_fix:
conn_fix.cursor().execute("""
@@ -175,16 +205,19 @@ def process_chat_message(
conn_fix.commit()
preview_reply = (
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
f"{merged_prompt}\n\n"
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
f"Предпросмотр изменений системного промпта:\n\n"
f"{diff_html}\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), {
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": merged_prompt,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"}
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
@@ -223,90 +256,26 @@ def process_chat_message(
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))
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}")
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)
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] 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
# Обычный текстовый ответ без 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
+29 -55
View File
@@ -1,8 +1,10 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py
ROLE: Детерминированный быстрый перехват команд оператора без вызова LLM:
подтверждение, отмена черновиков и завершение диалога с очисткой памяти.
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки
эфемерного контекста без повторного вывода кнопок-вилок.
===============================================================================
"""
@@ -12,8 +14,6 @@ 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,
@@ -31,65 +31,39 @@ def handle_fast_path_intercept(
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
"""
Проверяет входящее сообщение на детерминированные команды.
Если команда перехвачена — возвращает кортеж (reply_text, chat_history, metadata).
Если перехват не требуется — возвращает None.
1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст.
2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст.
"""
if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW":
return None
user_msg_clean = user_message.lower().strip(" .!?:;")
# 1. Перехват завершения работы с очисткой эфемерных сообщений
if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]:
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. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ
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}")
reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?"
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", "")
# 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}")
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 []
}
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
+22 -7
View File
@@ -1,15 +1,15 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/prompt_merger.py
ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок
системного промпта (добавление, удаление и замена пунктов).
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок.
===============================================================================
"""
# ANCHOR[PROMPT_MERGER_IMPORTS]
import re
import logging
from typing import Tuple
logger = logging.getLogger("PROMPT_MERGER")
@@ -17,9 +17,7 @@ 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()
@@ -56,4 +54,21 @@ def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_
new_lines.append(f" {clean_item}")
return "\n".join(new_lines)
return proposed_text or current_prompt
return proposed_text or current_prompt
def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str:
"""
Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind.
"""
old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()}
diff_lines = []
for line in new_prompt.splitlines():
if line.strip() and line.strip() not in old_lines_set:
# Выделяем новую или измененную строку красным цветом
diff_lines.append(f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
else:
diff_lines.append(line)
return "\n".join(diff_lines)
+19 -5
View File
@@ -1,3 +1,6 @@
"""
FILE: modules/web_api/llm/core/tool_injector.py
"""
import re
import json
import logging
@@ -5,6 +8,7 @@ from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
if not text:
return ""
@@ -14,15 +18,25 @@ def clean_raw_tool_tags(text: str) -> str:
text = re.sub(r'</tool_call>', '', 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:
+164 -117
View File
@@ -1,10 +1,11 @@
"""
===============================================================================
FILE: modules/web_api/llm/db/db_prompts.py
PROJECT: SCUD Orion AI
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db
ROLE: Управление системными промптами, правилами базы знаний,
декларативным реестром действий инструментов (tool_action_registry)
и расширенным сессионным состоянием (session_states + idle_turns).
ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
базой знаний, реестром действий и сессионными стейтами.
===============================================================================
"""
import json
import logging
@@ -14,18 +15,115 @@ 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")
row = cursor.fetchone()
conn.close()
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
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]]:
"""Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
@@ -44,33 +142,7 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
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()
cursor.execute("BEGIN IMMEDIATE;")
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
(prompt_text, name)
)
else:
cursor.execute(
"INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
(name, prompt_text)
)
conn.commit()
return {"status": "success", "message": "Системный промпт успешно обновлен"}
except Exception as e:
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)}
def db_get_rules() -> List[Dict[str, Any]]:
"""Извлекает список глобальных правил компании из ai_knowledge_base."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
@@ -79,23 +151,11 @@ def db_get_rules() -> List[Dict[str, Any]]:
return [dict(r) for r in rows]
# =============================================================================
# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS)
# =============================================================================
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
"""
Сохраняет состояние сессии в SQLite.
Если передан dict/list — автоматически сериализует его в JSON-строку.
"""
"""Сохраняет состояние сессии в SQLite."""
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 ""
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)
@@ -109,63 +169,23 @@ def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
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 session_id, state_type, pending_data, updated_at 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()
if not row:
return None
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
res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else 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,))
@@ -173,28 +193,10 @@ def db_clear_session_state(session_id: str) -> None:
conn.close()
def db_get_session_states() -> Dict[str, Any]:
"""Возвращает реестр всех активных сессий."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
rows = cursor.fetchall()
conn.close()
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
# =============================================================================
# СЛУЖЕБНАЯ ДИАГНОСТИКА И СТАТИСТИКА
# =============================================================================
def db_get_stats() -> Dict[str, Any]:
"""Возвращает статистику по количеству записей в таблицах."""
conn = get_db_connection()
cursor = conn.cursor()
tables = [
'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'
]
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
stats = {}
for t in tables:
try:
@@ -207,7 +209,6 @@ def db_get_stats() -> Dict[str, Any]:
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"
@@ -224,7 +225,6 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
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"
@@ -236,4 +236,51 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
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 line in prompt_text.splitlines():
stripped = line.strip()
if not stripped:
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
else:
current_itm += 1
content = stripped
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)}
+1 -2
View File
@@ -9,13 +9,12 @@ 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_apply_prompt_node_action,
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,
db_get_reference
+33 -47
View File
@@ -82,18 +82,18 @@ TOOLS_SCHEMA = [
}
},
{
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
}
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
}
}
}
},
{
"type": "function",
@@ -173,49 +173,35 @@ TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_add_system_prompt",
"description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
"name": "db_prompt_node_edit",
"description": (
"Сформировать предпросмотр изменения системного промпта через реляционные узлы. "
"Вызывай при любых запросах на добавление, изменение или удаление пунктов. "
"Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"},
"prompt_text": {"type": "string", "description": "Полный текст системного промпта"}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_preview_prompt_merge",
"description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').",
"parameters": {
"type": "object",
"properties": {
"prompt_text": {
"action": {
"type": "string",
"description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта."
"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": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_confirm_prompt_preview",
"description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_cancel_prompt_preview",
"description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.",
"parameters": {"type": "object", "properties": {}}
"required": ["action", "section_id", "item_id"]
}
}
}
]