feat(llm): двухфазный жизненный цикл сессий, двусторонний Diff, Topic Drift Guard и актуализация документации

This commit is contained in:
2026-08-18 13:47:26 +03:00
parent 828ce67817
commit 4b1b8a3586
20 changed files with 1763 additions and 2006 deletions
+183 -56
View File
@@ -3,15 +3,18 @@
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов.
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта,
управление сессионными стейтами, Topic Drift Guard и очистка контекста.
===============================================================================
"""
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
# =============================================================================
# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ
# =============================================================================
import sys
import re
import json
import logging
import re
from typing import List, Dict, Any, Tuple, Optional
from .db.connection import get_db_connection
@@ -44,7 +47,6 @@ from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
logger.propagate = False
@@ -56,7 +58,9 @@ if not logger.handlers:
logger.addHandler(handler)
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
# =============================================================================
# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА
# =============================================================================
def process_chat_message(
user_id: int,
user_message: str,
@@ -65,53 +69,91 @@ def process_chat_message(
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Главный конвейер диалога с поддержкой реляционного редактора промпта."""
"""
Главная функция обработки сообщения:
1. Очищает осиротевшие эфемерные сообщения.
2. Перехватывает быстрые кнопки (Fast-Path).
3. Собирает контекст и передает управление Ollama.
4. Выполняет Tool Calls и управляет счетчиком Topic Drift.
"""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
# 1. Fast-Path перехват
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
# 2. Сохранение пользовательского сообщения
# Сохранение входящего сообщения оператора
is_user_ephemeral = 1 if session_state else 0
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
db_history = db_get_chat_history(session_id, limit=20)
# 3. Сборка системного промпта
# =========================================================================
# БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА
# =========================================================================
dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
# Извлечение текущего состояния и счетчика отвлечений (idle_turns)
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
state_data = {}
idle_turns = state_data.get("idle_turns", 0)
active_state_context = ""
if session_state:
state_type = session_state.get("state_type", "GENERAL")
active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n"
if current_state_type == "PROMPT_PREVIEW":
active_state_context = (
"\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n"
"- Сейчас открыт предпросмотр изменения системного промпта.\n"
"- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n"
"- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n"
)
elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = (
"\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n"
"- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n"
"- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n"
"- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n"
)
system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
f"[ОКРУЖЕНИЕ]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}\n"
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_prompt_node_edit(action=..., section_id=..., item_id=..., content=...). Абсолютно запрещено изменять промпт напрямую текстом.\n"
f" Пример: если просят 'добавь пункт 2.9 Тестовый пункт', ты ОБЯЗАН вызвать инструмент db_prompt_node_edit с аргументами: action='ADD', section_id=2, item_id=9, content='Тестовый пункт'.\n"
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
)
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
f"[ОКРУЖЕНИЕ]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}\n"
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n"
f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n"
f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n"
f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n"
f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n"
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
)
user_msg_object = {"role": "user", "content": full_user_content}
# --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER]
# =========================================================================
# БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING
# =========================================================================
try:
# Режим Vision (OCR)
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
@@ -120,6 +162,7 @@ def process_chat_message(
]
msg = call_ollama_chat(messages, is_vision=True)
else:
# Текстовый диалог
clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history:
m.pop("images", None)
@@ -130,20 +173,32 @@ def process_chat_message(
tool_calls = msg.get("tool_calls", [])
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
# Исполнение вызванных моделью инструментов
if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
messages.append(msg)
tool_names_called = []
# -----------------------------------------------------------------
# TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ:
# Если вызваны сторонние инструменты (снапшоты, задачи, статы)
# — мгновенно закрываем сессию и вычищаем эфемерный контекст
# -----------------------------------------------------------------
is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls)
if not is_prompt_tool and session_state:
logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.")
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
session_state = None
for tool in tool_calls:
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
tool_names_called.append(fn_name)
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
tool_result_content = ""
action_cfg = db_get_tool_action(fn_name)
# --- 4.1. Модуль задач ---
if fn_name == "db_get_tasks":
raw_tasks = db_get_tasks(user_id)
reply_text = "Вот интерактивный список ваших текущих задач:"
@@ -153,47 +208,88 @@ def process_chat_message(
"tasks": raw_tasks
}
# --- 4.2. Прямой просмотр системного промпта из SQLite ---
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
active_prompt = db_get_active_system_prompt()
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
# Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
# --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) ---
elif fn_name == "db_prompt_node_edit":
action = fn_args.get("action", "ADD").upper()
sec_id = int(fn_args.get("section_id", 2))
itm_id = int(fn_args.get("item_id", 1))
content = fn_args.get("content", "").strip()
baseline_prompt = db_get_active_system_prompt()
with get_db_connection() as temp_conn:
temp_cursor = temp_conn.cursor()
temp_cursor.execute("SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = 'main_agent' AND is_active = 1 ORDER BY section_id, item_id")
temp_cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = 'main_agent' AND is_active = 1
ORDER BY section_id, item_id
""")
existing_nodes = temp_cursor.fetchall()
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
if action == "DELETE":
nodes_dict.pop((sec_id, itm_id), None)
else:
nodes_dict[(sec_id, itm_id)] = content
virtual_lines = []
if action == "DELETE":
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)}
else:
nodes_dict_for_draft = dict(nodes_dict)
nodes_dict_for_draft[(sec_id, itm_id)] = content
# Формирование чистого текста для редактора
draft_lines = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict.items()):
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
if i_id == 0:
if curr_sec is not None:
virtual_lines.append("")
virtual_lines.append(f"{s_id}. {txt}")
draft_lines.append("")
draft_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
# Формирование HTML Diff с подсветкой
diff_lines = []
curr_sec = None
display_nodes = dict(nodes_dict)
if action != "DELETE":
display_nodes[(sec_id, itm_id)] = content
for (s_id, i_id), txt in sorted(display_nodes.items()):
if i_id == 0:
if curr_sec is not None:
diff_lines.append("")
diff_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
line_str = f" {s_id}.{i_id}. {txt}"
if s_id == sec_id and i_id == itm_id:
line_str = f'<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)
if action == "DELETE":
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
else:
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
else:
line_str = f" {s_id}.{i_id}. {txt}"
diff_lines.append(line_str)
merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines])
diff_html = "\n".join(virtual_lines)
diff_html = "\n".join(diff_lines)
# Фиксация предпросмотра в session_states
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": action,
"section_id": sec_id,
"item_id": itm_id,
"content": content
"content": content,
"idle_turns": 0
})
with get_db_connection() as conn_fix:
@@ -214,6 +310,7 @@ def process_chat_message(
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": merged_prompt,
"baseline_prompt": baseline_prompt,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
@@ -221,9 +318,7 @@ def process_chat_message(
]
}
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
# --- 4.4. Сервисные инструменты ---
elif fn_name == "db_get_snapshots":
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
@@ -237,9 +332,6 @@ def process_chat_message(
elif fn_name == "db_get_anomalies":
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_session_states":
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
@@ -264,6 +356,7 @@ def process_chat_message(
messages.append({"role": "tool", "content": tool_result_content})
# Интерпретация результатов вызова инструментов
sec_msg = call_ollama_chat(messages, is_vision=False)
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content))
@@ -273,13 +366,47 @@ def process_chat_message(
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
return final_content, db_get_chat_history(session_id), None
# =====================================================================
# БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD
# =====================================================================
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
action_payload = None
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
# Обработка отвлечений оператора при активной сессии настройки/просмотра
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
idle_turns += 1
logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3")
# Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка
if idle_turns > 3:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.")
action_payload = None
session_state = None
elif idle_turns == 3:
# На 3-м сообщении стороннего диалога вежливо спрашиваем оператора
tool_action = db_get_tool_action("db_confirm_prompt_preview")
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
buttons = tool_action.get("buttons", []) if tool_action else []
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = {
"type": "PROMPT_FOLLOWUP",
"buttons": buttons
}
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
else:
# Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns})
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0))
return final_reply, db_get_chat_history(session_id), action_payload
# Обработка исключений
except Exception as ex:
logger.exception(f"Непредвиденная ошибка: {ex}")
logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}"
return error_reply, db_get_chat_history(session_id), None
+68 -31
View File
@@ -3,17 +3,18 @@
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки
эфемерного контекста без повторного вывода кнопок-вилок.
ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP),
атомарная фиксация узлов и мягкая зачистка эфемерного контекста.
===============================================================================
"""
# ANCHOR[FAST_PATH_IMPORTS]
import logging
from typing import Dict, Any, Tuple, Optional
from llm.db_tools import (
db_apply_prompt_node_action,
db_add_system_prompt,
db_set_session_state,
db_clear_session_state,
db_purge_ephemeral_messages,
db_save_chat_message,
@@ -23,47 +24,83 @@ from llm.db_tools import (
logger = logging.getLogger("FAST_PATH")
# ANCHOR[FAST_PATH_ROUTER]
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
"""
1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст.
2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст.
"""
if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW":
"""Обрабатывает команды подтверждения, отмены и завершения сессии."""
if not session_state:
return None
state_type = session_state.get("state_type")
user_msg_clean = user_message.lower().strip(" .!?:;")
draft_text = ""
if session_state.get("data_json") and isinstance(session_state["data_json"], dict):
draft_text = session_state["data_json"].get("draft_text", "")
else:
draft_text = session_state.get("pending_data", "")
# =========================================================================
# ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW
# =========================================================================
if state_type == "PROMPT_PREVIEW":
state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
db_add_system_prompt("main_agent", draft_text)
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Системный промпт сохранен в БД. Удалено эфемерных сообщений: {deleted_count}")
draft_text = state_data.get("draft_text", "")
action = state_data.get("action")
sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id")
content = state_data.get("content", "")
reply_text = "Системный промпт успешно сохранен и применен в базе данных."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
if action == "MANUAL_EDIT" or not action or sec_id is None:
if draft_text:
db_add_system_prompt("main_agent", draft_text)
else:
db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
# 2. ОТМЕНИТЬ ИЗМЕНЕНИЯ
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.")
# Переводим сессию в режим ожидания продолжения/завершения
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {})
reply_text = "Изменения системного промпта отменены. Контекст диалога чист."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
action_payload = {
"type": "PROMPT_FOLLOWUP",
"buttons": [
{"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"},
{"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"}
]
}
return reply_text, db_get_chat_history(session_id), action_payload
# 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
reply_text = "Изменения системного промпта отменены. Контекст диалога чист."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
# =========================================================================
# ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP
# =========================================================================
elif state_type == "PROMPT_FOLLOWUP":
# Явный сигнал завершения работы
if user_msg_clean in [
"завершить работу", "завершить", "закончить", "готово", "всё",
"все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти"
]:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}")
reply_text = "Работа с системным промптом завершена. Чем могу помочь?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
return None
+10 -13
View File
@@ -1,8 +1,12 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any
@@ -10,6 +14,7 @@ logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст."""
if not text:
return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
@@ -20,10 +25,10 @@ def clean_raw_tool_tags(text: str) -> str:
def clean_output(text: str) -> str:
"""Удаляет слова-паразиты и склейки в начале ответа."""
if not text:
return text
# Фильтрация паразитных артефактов токенизатора Qwen
artifacts = [
"почемучка", "почемучка,", "почемучка!", "почемучка?",
"почемучто", "почто", "почему что", "почему-то",
@@ -39,15 +44,7 @@ def clean_output(text: str) -> str:
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if tool_calls:
return tool_calls
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
try:
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
if match:
fn_name = match.group(1)
fn_args = json.loads(match.group(2))
return [{"function": {"name": fn_name, "arguments": fn_args}}]
except Exception as parse_err:
logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}")
"""
Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM.
"""
return tool_calls
+21 -22
View File
@@ -7,6 +7,7 @@ ROLE: Реляционное управление системным промп
базой знаний, реестром действий и сессионными стейтами.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any, Optional
@@ -239,7 +240,7 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Мост обратной совместимости: парсит текст промпта и сохраняет по узлам в БД."""
"""Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов."""
try:
init_prompt_nodes_table()
with get_db_connection() as conn:
@@ -249,30 +250,28 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
current_sec = 1
current_itm = 0
for line in prompt_text.splitlines():
stripped = line.strip()
if not stripped:
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
# Проверяем заголовок раздела (например, "1. РОЛЬ И ЗАДАЧИ...")
parts = stripped.split(".", 1)
if len(parts) == 2 and parts[0].strip().isdigit():
sec_num = int(parts[0].strip())
rest = parts[1].strip()
# Проверяем, это подпункт (например, "1.1. Текст") или заголовок раздела
sub_parts = rest.split(".", 1)
if len(sub_parts) == 2 and sub_parts[0].strip().isdigit():
current_sec = sec_num
current_itm = int(sub_parts[0].strip())
content = sub_parts[1].strip()
else:
current_sec = sec_num
current_itm = 0
content = rest
# 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст")
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
# 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ")
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
if sub_match:
current_sec = int(sub_match.group(1))
current_itm = int(sub_match.group(2))
content = sub_match.group(3).strip()
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
# Заголовок раздела (обычно капсом)
current_sec = int(sec_match.group(1))
current_itm = 0
content = sec_match.group(2).strip()
else:
current_itm += 1
content = stripped
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
+35 -34
View File
@@ -1,4 +1,39 @@
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_prompt_node_edit",
"description": (
"ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. "
"Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. "
"При удалении: action='DELETE', section_id=X, item_id=Y, content=''. "
"Запрещено отвечать текстовым подтверждением без вызова этой функции!"
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "UPDATE", "DELETE"],
"description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например: 1, 2, 3)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта (например: 4, 8, 9)"
},
"content": {
"type": "string",
"description": "Текст пункта (для DELETE передается пустая строка)"
}
},
"required": ["action", "section_id", "item_id"]
}
}
},
{
"type": "function",
"function": {
@@ -169,39 +204,5 @@ TOOLS_SCHEMA = [
}
}
}
},
{
"type": "function",
"function": {
"name": "db_prompt_node_edit",
"description": (
"Сформировать предпросмотр изменения системного промпта через реляционные узлы. "
"Вызывай при любых запросах на добавление, изменение или удаление пунктов. "
"Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "UPDATE", "DELETE"],
"description": "Действие: ADD (добавить пункт), UPDATE (изменить пункт), DELETE (удалить пункт)"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например, 2)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта внутри раздела (например, 9)"
},
"content": {
"type": "string",
"description": "Текст формулировки пункта (для ADD и UPDATE). Без префикса номера!"
}
},
"required": ["action", "section_id", "item_id"]
}
}
}
]