diff --git a/modules/ai_engine/agent.py b/modules/ai_engine/agent.py
new file mode 100644
index 0000000..16f55fb
--- /dev/null
+++ b/modules/ai_engine/agent.py
@@ -0,0 +1,178 @@
+"""
+===============================================================================
+FILE: modules/ai_engine/agent.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: modules / ai_engine
+ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers
+ и менеджер свободных диалогов (Topic Drift).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message.
+===============================================================================
+"""
+
+import sys
+import json
+import logging
+from typing import List, Dict, Any, Tuple, Optional
+
+from modules.web_api.llm.schemas import TOOLS_SCHEMA
+from modules.web_api.llm.core.ollama_client import call_ollama_chat
+from modules.web_api.llm.core.fast_path import handle_fast_path_intercept
+from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
+from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup
+from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
+from modules.web_api.llm.db.db_prompts import (
+ db_get_session_state, db_clear_session_state, db_set_session_state,
+ db_get_stats, db_get_anomalies, db_get_reference
+)
+from services.knowledge.service import get_rules
+
+from .context_builder import build_agent_system_context
+from .handlers.task_handler import handle_tasks_call
+from .handlers.prompt_handler import handle_prompt_call
+from .handlers.snapshot_handler import handle_snapshots_call
+
+logger = logging.getLogger("AI_AGENT")
+logger.setLevel(logging.INFO)
+
+
+# ANCHOR[AGENT_PIPELINE_ENTRY]
+def process_chat_message(
+ user_id: int,
+ user_message: str,
+ file_context: str = "",
+ image_b64: Optional[str] = None,
+ chat_history: List[Dict[str, Any]] = None,
+ session_id: str = "web_session_main"
+) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
+ """Главный конвейер обработки сообщений чата."""
+ logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
+
+ session_state = db_get_session_state(session_id)
+ if not session_state:
+ db_purge_ephemeral_messages(session_id)
+
+ full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
+
+ # 1. Быстрый Fast-Path перехват кнопок подтверждения
+ fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
+ if fast_path_res:
+ return fast_path_res
+
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
+ db_history = db_get_chat_history(session_id, limit=20)
+ system_prompt = build_agent_system_context(user_id, session_state)
+
+ user_msg_obj = {"role": "user", "content": full_user_content}
+
+ try:
+ if image_b64:
+ user_msg_obj["images"] = [image_b64]
+ messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj]
+ msg = call_ollama_chat(messages, is_vision=True)
+ else:
+ clean_history = [dict(m) for m in db_history]
+ for m in clean_history: m.pop("images", None)
+ messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj]
+ msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
+
+ raw_reply = msg.get("content", "")
+ tool_calls = msg.get("tool_calls", [])
+
+ # 2. Гибридный семантический классификатор намерений (Fallback Safety Net)
+ tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls)
+
+ # 3. Исполнение инструментов через изолированные handlers
+ if tool_calls:
+ tool = tool_calls[0]
+ fn_name = tool["function"]["name"]
+ fn_args = tool["function"].get("arguments", {})
+ if isinstance(fn_args, str):
+ try: fn_args = json.loads(fn_args)
+ except Exception: fn_args = {}
+
+ logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}")
+ close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
+ mark_last_user_message_ephemeral(session_id)
+
+ state_data = session_state.get("data_json") or {} if session_state else {}
+
+ if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
+ return handle_tasks_call(fn_name, fn_args, user_id, session_id)
+
+ elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]:
+ return handle_prompt_call(fn_name, fn_args, session_id)
+
+ elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]:
+ return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data)
+
+ elif fn_name == "db_get_rules":
+ res_str = json.dumps(get_rules(), ensure_ascii=False)
+ elif fn_name == "db_get_stats":
+ res_str = json.dumps(db_get_stats(), ensure_ascii=False)
+ elif fn_name == "db_get_anomalies":
+ res_str = 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_reference":
+ res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
+ else:
+ res_str = "{}"
+
+ messages.append(msg)
+ messages.append({"role": "tool", "content": res_str})
+ sec_msg = call_ollama_chat(messages, is_vision=False)
+ final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен."
+ db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
+ return final_content, db_get_chat_history(session_id), None
+
+ # 4. Обычный содержательный диалог и управление Topic Drift
+ final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан."
+ for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]:
+ if final_reply.lower().startswith(artifact):
+ final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
+
+ action_payload = None
+
+ # Обработка ответа "нет / спасибо" в режиме открытого инструмента
+ if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]):
+ close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL")
+ db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
+ return final_reply, db_get_chat_history(session_id), None
+
+ # Инкремент счётчика шагов в сторону от инструмента (Topic Drift)
+ if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]:
+ state_type = session_state.get("state_type")
+ state_data = session_state.get("data_json") or {}
+ if not isinstance(state_data, dict):
+ state_data = {}
+
+ idle_turns = state_data.get("idle_turns", 0) + 1
+ state_data["idle_turns"] = idle_turns
+
+ if idle_turns >= 4:
+ # 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки
+ close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT")
+ elif idle_turns == 3:
+ # 3-й шаг: выводим вежливое напоминание с кнопками
+ tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД"
+ guard_question = f"Желаете продолжить работу с {tool_label}?"
+ final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
+ action_payload = {
+ "type": "FOLLOW_UP_ACTION",
+ "buttons": [
+ {"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"},
+ {"label": "Завершить", "value": "нет, спасибо", "style": "secondary"}
+ ]
+ }
+ db_set_session_state(session_id, state_type, state_data)
+ else:
+ # 1-й и 2-й шаг: фиксируем обновленный счётчик
+ db_set_session_state(session_id, state_type, state_data)
+
+ is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0
+ db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply)
+ return final_reply, db_get_chat_history(session_id), action_payload
+
+ except Exception as ex:
+ logger.exception(f"Ошибка в агенте: {ex}")
+ return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None
\ No newline at end of file
diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py
index f6dd033..40381d8 100644
--- a/modules/web_api/llm/core/tool_injector.py
+++ b/modules/web_api/llm/core/tool_injector.py
@@ -3,35 +3,136 @@
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
-ROLE: Базовая санитарная очистка артефактов без эвристик и регулярных выражений.
+ROLE: Семантический анализ намерений оператора (Intent Classifier) и
+ детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
AI-CONTEXT-ANCHORS:
- - ANCHOR[CLEAN_RAW_TOOLS]: Очистка строковых тегов.
- - ANCHOR[PASS_THROUGH_TOOLS]: Чистый проходной интерфейс инструментов.
+ - ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
===============================================================================
"""
-# ANCHOR[CLEAN_RAW_TOOLS]
+import re
+import logging
from typing import List, Dict, Any
+logger = logging.getLogger("TOOL_INJECTOR")
+
def clean_raw_tool_tags(text: str) -> str:
- """Удаляет только технические теги разметки, если они попали в текст."""
+ """Удаляет сырые теги вызова инструментов и системный шум."""
if not text:
return ""
- return text.replace("
Авторизация в системе
-- ИИ-Ассистент -
-- Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи. -
-