""" =============================================================================== 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