From 4e3c99b7288fb3ffcef8f0d8ff2c125bcaf5009a Mon Sep 17 00:00:00 2001 From: manoraga Date: Sat, 15 Aug 2026 15:41:37 +0300 Subject: [PATCH] refactor(web_api): fix import paths, modularize routers and decompose agent pipeline --- modules/web_api/llm/agent.py | 526 +++++++--------------- modules/web_api/llm/core/fast_path.py | 95 ++++ modules/web_api/llm/core/ollama_client.py | 61 +++ modules/web_api/llm/core/prompt_merger.py | 59 +++ modules/web_api/main.py | 258 +---------- modules/web_api/routers/admin.py | 83 ++++ modules/web_api/routers/auth.py | 112 +++++ modules/web_api/routers/chat.py | 60 +++ modules/web_api/routers/tasks.py | 71 +++ modules/web_api/static/index.html | 29 +- modules/web_api/static/js/chat.js | 237 +++++++++- modules/web_api/static/js/tasks.js | 25 +- 12 files changed, 995 insertions(+), 621 deletions(-) create mode 100644 modules/web_api/llm/core/fast_path.py create mode 100644 modules/web_api/llm/core/ollama_client.py create mode 100644 modules/web_api/llm/core/prompt_merger.py create mode 100644 modules/web_api/routers/admin.py create mode 100644 modules/web_api/routers/auth.py create mode 100644 modules/web_api/routers/chat.py create mode 100644 modules/web_api/routers/tasks.py diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index fc5b762..dafc2bd 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -2,25 +2,17 @@ =============================================================================== FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) -ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов - инструментов (Function Calling), интеграция с декларативным реестром - действий SQLite (tool_action_registry), детерминированный Fast-Path - для подтверждений, отслеживание Topic Drift и Context Guard с кнопками. +MODULE: web_api / llm (Core Agent Coordinator) +ROLE: Оркестратор диалога, диспетчер Function Calling и Topic Drift контроллер. =============================================================================== """ # --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] -import os import sys import json -import urllib.request -import urllib.error import logging -import re from typing import List, Dict, Any, Tuple, Optional -# Импорт фасада базы данных и прямого подключения from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, @@ -48,8 +40,11 @@ from .db_tools import ( ) from .schemas import TOOLS_SCHEMA -from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru -from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from .core.calendar_utils import get_dynamic_calendar_context +from .core.tool_injector import clean_raw_tool_tags, clean_output +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") @@ -62,10 +57,6 @@ if not logger.handlers: handler.setFormatter(formatter) logger.addHandler(handler) -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -TEXT_MODEL = "qwen2.5:14b" -VISION_MODEL = "qwen2.5vl:7b-q8_0" - # --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] def process_chat_message( @@ -77,100 +68,38 @@ def process_chat_message( session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: """ - Главный конвейер обработки входящего сообщения: - 1. Fast-Path перехват подтверждений/отмен при активном session_state. - 2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. - 3. Формирование системного контекста с учетом активного действия (черновика). - 4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. - 5. Проверка счетчика отвлечений (idle_turns), Context Guard на 3-м шаге и автоочистка при N > 3. - 6. Возврат кортежа: (reply_text, chat_history, action_metadata). + Главный конвейер диалога: + 1. Проверка Fast-Path команд (подтверждение, отмена, завершение). + 2. Формирование контекста и вызов Ollama LLM. + 3. Выполнение инструментов и обработка Context Guard. """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - # 3.1. Обогащение текста вложением (при наличии) - full_user_content = user_message - if file_context: - full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" - + 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) - user_msg_clean = user_message.lower().strip(" .!?:;") - # --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- - 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 + # 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 - # --- [FAST-PATH 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", "") - - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", draft_text) - - # Переводим сессию в режим follow-up диалога - 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 [] - } - - # 3.2. Сохраняем входящее сообщение в историю диалога + # 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) - - # 3.3. Извлекаем полную актуальную историю для передачи в LLM db_history = db_get_chat_history(session_id, limit=20) + # 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 "Гость" - # Информируем модель о наличии незавершенного действия в сессии 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" + "- Если оператор просит продолжить правки — продолжай работу с ним.\n" + "- Если оператор переключился на другую тему — выполни его команду штатно.\n" ) system_prompt_content = ( @@ -181,307 +110,165 @@ def process_chat_message( f"- {calendar_context}\n" f"{active_state_context}\n" f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\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" - f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n" - f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n" - f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n" - f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n" + f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\n\n" f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" ) - llm_options = { - "num_predict": 8192, - "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, - "top_p": 0.9 - } - user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD] - if image_b64: - user_msg_object["images"] = [image_b64] - messages = [ - {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."}, - user_msg_object - ] - payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} - else: - clean_db_history = [dict(m) for m in db_history] - for m in clean_db_history: - m.pop("images", None) - messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] - payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} - - # --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] + # --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER] try: - req = urllib.request.Request( - OLLAMA_URL, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req) as response: - res_data = json.loads(response.read().decode("utf-8")) - msg = res_data.get("message", {}) - tool_calls = msg.get("tool_calls", []) - raw_text_content = msg.get("content", "") + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + 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) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) - #tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + tool_calls = msg.get("tool_calls", []) - if tool_calls: - logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") - messages.append(msg) - - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - tool_result_content = "" + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + messages.append(msg) + + for tool in tool_calls: + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") + tool_result_content = "" + action_cfg = db_get_tool_action(fn_name) - action_cfg = db_get_tool_action(fn_name) - - if fn_name == "db_confirm_prompt_preview": - 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", "") + # ANCHOR[TASK_INTERACTIVE_DISPATCH] + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } - db_add_system_prompt("main_agent", draft_text) - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) - - 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() - is_eph = action_cfg.get("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"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False) - else: - err_reply = "Нет активного превью для подтверждения." - db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1) - return err_reply, db_get_chat_history(session_id), None + 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_cancel_prompt_preview": - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) - 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() - is_eph = action_cfg.get("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"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False) + 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}) - 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) + with get_db_connection() as conn_fix: + conn_fix.cursor().execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn_fix.commit() - elif fn_name == "db_get_current_server_time": - tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + preview_reply = ( + f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" + f"{merged_prompt}\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), { + "type": "PROMPT_PREVIEW", + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"} + ] + } - elif fn_name == "db_get_tasks": - tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) + 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) - 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) + 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) - elif fn_name == "db_get_stats": - tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) - 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_stats": + tool_result_content = json.dumps(db_get_stats(), 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_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_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() - is_eph = action_cfg.get("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"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, 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_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - # --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC] - 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_get_reference": + tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - current_prompt = db_get_active_system_prompt() - user_msg_lower = user_message.lower() + elif fn_name == "db_add_task": + res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) + tool_result_content = json.dumps(res, ensure_ascii=False) - # 1. Обработка удаления пункта - if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): - target_num_match = re.search(r'\d+(\.\d+)*', user_message) - target_num = target_num_match.group(0) if target_num_match else "" - - lines = current_prompt.splitlines() - if target_num: - new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] - else: - new_lines = lines - proposed_text = "\n".join(new_lines) + elif fn_name == "db_update_task_status": + res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) + tool_result_content = json.dumps(res, ensure_ascii=False) - # 2. Обработка добавления / изменения пункта - elif proposed_text: - if len(proposed_text) < 500: - clean_item = proposed_text.strip() - for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: - if prefix in clean_item.lower(): - clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:") - - lines = current_prompt.splitlines() - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if "3.3." in line and not inserted: - item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" - new_lines.append(f" {item_str}") - inserted = True - if not inserted: - new_lines.append(f" {clean_item}") - proposed_text = "\n".join(new_lines) - - # Сохраняем черновик в структурированном виде с idle_turns = 0 - state_payload = { - "draft_text": proposed_text, - "idle_turns": 0 + 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", []) } - db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload) + tool_result_content = json.dumps(res, ensure_ascii=False) - # Помечаем последнее сообщение пользователя как эфемерное - with get_db_connection() as conn_fix: - cursor_fix = conn_fix.cursor() - cursor_fix.execute(""" - UPDATE chat_messages - SET is_ephemeral = 1 - WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') - """, (session_id,)) - conn_fix.commit() - - preview_reply = ( - f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" - f"{proposed_text}\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), { - "type": "PROMPT_PREVIEW", - "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"} - ] + 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) - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + messages.append({"role": "tool", "content": tool_result_content}) - elif fn_name == "db_add_task": - res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) + # 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)) - elif fn_name == "db_update_task_status": - res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - 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() - is_eph = action_cfg.get("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"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) - - messages.append({"role": "tool", "content": tool_result_content}) - - # --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS] - second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} - sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(sec_req) as sec_response: - sec_res_data = json.loads(sec_response.read().decode("utf-8")) - raw_content = sec_res_data.get("message", {}).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 - - # Отслеживание отвлечений при активном процессе настройки - 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) - - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) - return final_content, db_get_chat_history(session_id), None - - # Если вызовов инструментов не было (обычный текстовый диалог) - raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") - content_str = clean_raw_tool_tags(clean_output(raw_str)) - final_reply = content_str or "Запрос обработан." + 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}") + 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), { + 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"}, @@ -493,8 +280,35 @@ def process_chat_message( 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 + 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 except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py new file mode 100644 index 0000000..1649bce --- /dev/null +++ b/modules/web_api/llm/core/fast_path.py @@ -0,0 +1,95 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/fast_path.py +ROLE: Детерминированный быстрый перехват команд оператора без вызова LLM: + подтверждение, отмена черновиков и завершение диалога с очисткой памяти. +=============================================================================== +""" + +# ANCHOR[FAST_PATH_IMPORTS] +import logging +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, + db_get_chat_history +) + +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]]]]: + """ + Проверяет входящее сообщение на детерминированные команды. + Если команда перехвачена — возвращает кортеж (reply_text, chat_history, metadata). + Если перехват не требуется — возвращает None. + """ + user_msg_clean = user_message.lower().strip(" .!?:;") + + # 1. Перехват завершения работы с очисткой эфемерных сообщений + 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 + + # 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", "") + + 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 [] + } + + return None \ No newline at end of file diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py new file mode 100644 index 0000000..a6fd772 --- /dev/null +++ b/modules/web_api/llm/core/ollama_client.py @@ -0,0 +1,61 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/ollama_client.py +ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. +=============================================================================== +""" + +# ANCHOR[OLLAMA_CLIENT_IMPORTS] +import json +import urllib.request +import urllib.error +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger("OLLAMA_CLIENT") + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +LLM_OPTIONS = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 +} + + +# ANCHOR[OLLAMA_REQUEST_DISPATCHER] +def call_ollama_chat( + messages: list, + tools: Optional[list] = None, + is_vision: bool = False, + timeout: int = 120 +) -> Dict[str, Any]: + """ + Отправляет подготовленный массив сообщений в API Ollama. + Возвращает разобранный словарь сообщения ответа или генерирует исключение. + """ + model_name = VISION_MODEL if is_vision else TEXT_MODEL + payload = { + "model": model_name, + "messages": messages, + "stream": False, + "options": LLM_OPTIONS + } + + if tools and not is_vision: + payload["tools"] = tools + + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=timeout) as response: + res_data = json.loads(response.read().decode("utf-8")) + return res_data.get("message", {}) \ No newline at end of file diff --git a/modules/web_api/llm/core/prompt_merger.py b/modules/web_api/llm/core/prompt_merger.py new file mode 100644 index 0000000..240d6c7 --- /dev/null +++ b/modules/web_api/llm/core/prompt_merger.py @@ -0,0 +1,59 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/prompt_merger.py +ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок + системного промпта (добавление, удаление и замена пунктов). +=============================================================================== +""" + +# ANCHOR[PROMPT_MERGER_IMPORTS] +import re +import logging +from typing import Tuple + +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() + + # 1. Сценарий удаления пункта + if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): + target_num_match = re.search(r'\d+(\.\d+)*', user_message) + target_num = target_num_match.group(0) if target_num_match else "" + + lines = current_prompt.splitlines() + if target_num: + new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] + else: + new_lines = lines + return "\n".join(new_lines) + + # 2. Сценарий добавления или замены пункта + if proposed_text: + if len(proposed_text) < 500: + clean_item = proposed_text.strip() + for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: + if prefix in clean_item.lower(): + clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:") + + lines = current_prompt.splitlines() + new_lines = [] + inserted = False + for line in lines: + new_lines.append(line) + if "3.3." in line and not inserted: + item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" + new_lines.append(f" {item_str}") + inserted = True + if not inserted: + new_lines.append(f" {clean_item}") + return "\n".join(new_lines) + + return proposed_text or current_prompt \ No newline at end of file diff --git a/modules/web_api/main.py b/modules/web_api/main.py index 4f66862..365d054 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -2,68 +2,37 @@ =============================================================================== FILE: modules/web_api/main.py PROJECT: SCUD Orion AI (Unified Repository) -MODULE: web_api (FastAPI REST Server & Context Management) -ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), - маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. - -AI-CONTEXT-ANCHORS & INVARIANTS: - - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов - независимо от рабочей директории запуска (root или web_api). - - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного - рендеринга интерфейса и ассетов (css/js/favicon). - - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). - - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и - генерации ответов LLM (agent.process_chat_message). - -DEPENDENCIES: - - modules/web_api/llm/agent.py (process_chat_message) - - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) - - modules/web_api/llm/file_parser.py (extract_text_from_file) +MODULE: web_api (Main Application Entry Point) +ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] +# ANCHOR[APP_INIT_IMPORTS] import os import sys -import json -import sqlite3 import logging -import urllib.request -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional -# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) -import jwt -from passlib.context import CryptContext -from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import RequestValidationError -from pydantic import BaseModel -# Внутренние модули LLM и БД -from llm.agent import process_chat_message -from llm.db_tools import db_get_tasks, DB_PATH -from llm.file_parser import extract_text_from_file +from routers.auth import router as auth_router +from routers.admin import router as admin_router +from routers.tasks import router as tasks_router +from routers.chat import router as chat_router -# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] +# ANCHOR[APP_CONFIG] logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) -JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" -ALGORITHM = "HS256" - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -security = HTTPBearer() - STATIC_DIR = os.path.join(CURRENT_DIR, "static") app = FastAPI(title="SCUD Orion AI Context API", version="2.5") @@ -79,57 +48,15 @@ async def validation_exception_handler(request, exc): content={"detail": exc.errors(), "body": str(exc)} ) -# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] -def get_db(): - """Создает безопасное соединение с SQLite БД модуля.""" - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn +# ANCHOR[ROUTER_REGISTRATION] +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(tasks_router) +app.include_router(chat_router) -def create_access_token(user_id: int, username: str, is_admin: bool) -> str: - payload = { - "sub": str(user_id), - "username": username, - "is_admin": is_admin, - "exp": datetime.utcnow() + timedelta(days=30) - } - return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - try: - token = credentials.credentials - payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) - user_id = int(payload.get("sub")) - username = payload.get("username") - is_admin = bool(payload.get("is_admin", False)) - return {"id": user_id, "username": username, "is_admin": is_admin} - except Exception as e: - logging.warning(f"Auth error: {e}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Недействительный или просроченный токен авторизации", - headers={"WWW-Authenticate": "Bearer"}, - ) - -# Pydantic-схемы валидации запросов -class AuthRequest(BaseModel): - username: str - password: str - -class CreateUserRequest(BaseModel): - username: str - password: str - full_name: Optional[str] = None - is_admin: Optional[bool] = False - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - -# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] +# ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") def read_root(): - """Отдает главную страницу панели управления.""" index_path = os.path.join(STATIC_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) @@ -142,164 +69,9 @@ async def favicon(): return FileResponse(file_path) raise HTTPException(status_code=404) -# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] -@app.post("/api/v1/auth/login") -def login(req: AuthRequest): - username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) - user = cursor.fetchone() - conn.close() - - if not user or not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - is_admin = bool(user["is_admin"]) or (user["username"] == "puh") - token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") - - return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} - -@app.post("/api/v1/auth/change-password") -def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not req.new_password or len(req.new_password) < 4: - raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) - user = cursor.fetchone() - - if not user or not pwd_context.verify(req.old_password, user["password_hash"]): - conn.close() - raise HTTPException(status_code=400, detail="Неверный старый пароль") - - new_hash = pwd_context.hash(req.new_password) - cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) - conn.commit() - conn.close() - - logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") - return {"status": "success", "message": "Пароль успешно изменен"} - -@app.get("/api/v1/admin/users") -def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") - users = [dict(r) for r in cursor.fetchall()] - conn.close() - return users - -@app.post("/api/v1/admin/users") -def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - username = req.username.strip().lower() - if not username or not req.password: - raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) - if cursor.fetchone(): - conn.close() - raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") - - pwd_hash = pwd_context.hash(req.password) - full_name = req.full_name.strip() if req.full_name else None - is_admin = 1 if req.is_admin else 0 - - cursor.execute( - "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", - (username, pwd_hash, full_name, is_admin) - ) - conn.commit() - conn.close() - - logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") - return {"status": "success", "message": f"Пользователь {username} создан"} - -@app.delete("/api/v1/admin/users/{user_id}") -def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - if user_id == current_user["id"]: - raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - conn.commit() - conn.close() - - logging.info(f"Удален пользователь ID: {user_id}") - return {"status": "success", "message": "Пользователь удален"} - -# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] -@app.get("/api/v1/tasks") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - """Получить задачи текущего авторизованного пользователя.""" - return db_get_tasks(user_id=user["id"]) - -# --- ЧАТ С АВТОРИЗАЦИЕЙ --- -@app.post("/api/v1/chat") -async def chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None), - current_user: dict = Depends(get_current_user) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=current_user["id"], - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - - -# --- ГОСТЕВОЙ ЧАТ --- -@app.post("/api/v1/chat/guest") -async def guest_chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=0, - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - -# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") - target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): return FileResponse(target) diff --git a/modules/web_api/routers/admin.py b/modules/web_api/routers/admin.py new file mode 100644 index 0000000..e55aeca --- /dev/null +++ b/modules/web_api/routers/admin.py @@ -0,0 +1,83 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/admin.py +ROLE: Администрирование пользователей и прав доступа. +=============================================================================== +""" + +# ANCHOR[ADMIN_ROUTER_IMPORTS] +import logging +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user, get_db, pwd_context + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +# ANCHOR[ADMIN_SCHEMAS] +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +# ANCHOR[ADMIN_ENDPOINTS] +@router.get("/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@router.post("/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} \ No newline at end of file diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py new file mode 100644 index 0000000..73ba0c4 --- /dev/null +++ b/modules/web_api/routers/auth.py @@ -0,0 +1,112 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/auth.py +ROLE: Аутентификация, валидация JWT-токенов и управление паролями. +=============================================================================== +""" + +# ANCHOR[AUTH_ROUTER_IMPORTS] +import sqlite3 +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel + +from llm.db_tools import DB_PATH + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +# ANCHOR[AUTH_DB_HELPERS] +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# ANCHOR[AUTH_SCHEMAS] +class AuthRequest(BaseModel): + username: str + password: str + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + +# ANCHOR[AUTH_ENDPOINTS] +@router.post("/login") +def login(req: AuthRequest): + username = req.username.strip().lower() + logging.info(f"===> Попытка входа для пользователя: {username}") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) + user = cursor.fetchone() + conn.close() + + if not user or not pwd_context.verify(req.password, user["password_hash"]): + logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + is_admin = bool(user["is_admin"]) or (user["username"] == "puh") + token = create_access_token(user["id"], user["username"], is_admin) + logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") + + return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + +@router.post("/change-password") +def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not req.new_password or len(req.new_password) < 4: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py new file mode 100644 index 0000000..ce8a46c --- /dev/null +++ b/modules/web_api/routers/chat.py @@ -0,0 +1,60 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/chat.py +ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты). +=============================================================================== +""" + +# ANCHOR[CHAT_ROUTER_IMPORTS] +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, UploadFile, File, Form + +from .auth import get_current_user +from llm.agent import process_chat_message +from llm.file_parser import extract_text_from_file + +router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) + +# ANCHOR[CHAT_ENDPOINTS] +@router.post("") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Диалог авторизованного пользователя с агентом.""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=current_user["id"], + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + +@router.post("/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + """Гостевой диалог (user_id=0).""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=0, + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} \ No newline at end of file diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py new file mode 100644 index 0000000..5260cfb --- /dev/null +++ b/modules/web_api/routers/tasks.py @@ -0,0 +1,71 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/tasks.py +ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +=============================================================================== +""" + +# ANCHOR[TASKS_ROUTER_IMPORTS] +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user +from llm.db_tools import ( + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) + +router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) + +# ANCHOR[TASKS_SCHEMAS] +class CreateTaskRequest(BaseModel): + title: str + priority: Optional[str] = "MEDIUM" + module: Optional[str] = "general" + due_date: Optional[str] = None + +class UpdateTaskRequest(BaseModel): + status: Optional[str] = "COMPLETED" + due_date: Optional[str] = None + +# ANCHOR[TASKS_ENDPOINTS] +@router.get("") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить список всех задач текущего авторизованного пользователя.""" + return db_get_tasks(user_id=user["id"]) + +@router.post("") +def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое создание задачи.""" + res = db_add_task( + user_id=user["id"], + module=req.module or "general", + title=req.title.strip(), + priority=req.priority or "MEDIUM", + due_date=req.due_date + ) + return res + +@router.patch("/{task_id}") +def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое обновление статуса и срока задачи.""" + res = db_update_task_status( + user_id=user["id"], + task_id=task_id, + status=req.status or "COMPLETED", + due_date=req.due_date + ) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res + +@router.delete("/{task_id}") +def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)): + """Прямое удаление задачи.""" + res = db_delete_task(user_id=user["id"], task_id=task_id) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index a39b46d..5227356 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -10,7 +10,7 @@ - +
@@ -56,7 +56,7 @@
- +