Debug faction turns

This commit is contained in:
dimitrievgs 2026-07-06 01:49:31 +03:00
parent 7d04a7227d
commit 7cb54a1641
5 changed files with 285 additions and 32 deletions

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# LLM Agent Backend
Бэкенд-сервер на Python для обеспечения работы интеллектуального агента в Obsidian. Использует LangGraph для управления логикой рассуждений и Flask для взаимодействия с плагином.
## Структура проекта
├── llm-agent-backend/ # Корневая директория бэкенда
│ ├── app/ # Исходный код приложения
│ │ ├── api.py # Flask сервер, HTTP эндпоинты и WebSocket (SocketIO)
│ │ ├── run.py # Точка входа для запуска приложения
│ │ ├── workflows.py # Определение графов LangGraph и логики потоков
│ │ ├── react_agent.py # Реализация ReAct (Reasoning and Acting) агента
│ │ ├── llm_client.py # Клиент для работы с моделями (OpenAI, Google, Mistral)
│ │ ├── mcp_tools.py # Интеграция с Model Context Protocol (MCP) инструментами
│ │ ├── graph_history_manager.py # Управление историей диалогов и состоянием графов в SQLite
│ │ ├── voice_service.py # Сервис распознавания речи (Vosk)
│ │ ├── title_generator.py # Генерация названий для чатов на основе контекста
│ │ ├── heartbeat_monitor.py # Мониторинг активности и состояния подключения
│ │ ├── models.py # Описание структур данных и схем
│ │ └── services.py # Вспомогательные сервисы
│ ├── docs/ # Техническая документация и скриншоты настроек
│ ├── env/ # Виртуальное окружение Python (venv)
│ ├── build.bat # Скрипт сборки проекта
│ ├── requirements.txt # Список зависимостей Python
│ ├── run-llm-backend.bat # Батник для быстрого запуска сервера
│ └── graph_history.db # БД SQLite с историей графов и сообщений
## Основные технологии
- **Python 3.11+**
- **LangChain / LangGraph**: Управление состоянием агента и цепочками рассуждений.
- **Flask / Flask-SocketIO**: API и real-time взаимодействие с Obsidian.
- **Vosk / SoundDevice**: Локальное распознавание речи.
- **SQLite**: Хранение истории взаимодействий.
## Установка
1. Убедитесь, что установлен Python 3.11.
2. Создайте виртуальное окружение в папке `env`:
```bash
python -m venv env
```
3. Активируйте окружение и установите зависимости:
```bash
env\Scripts\activate
pip install -r requirements.txt
```
## Запуск
### Вариант 1: Через батник (рекомендуется)
Запустите `run-llm-backend.bat` из корня проекта.
### Вариант 2: Вручную из терминала
```bash
python app/run.py
```
Сервер будет доступен по адресу: `http://localhost:5000`
## Ключевые возможности
- **Двустороннее общение**: Агент может инициировать запросы к Obsidian (через API плагина).
- **Инструменты (Tools)**: Поддержка вызова функций через MCP и кастомные инструменты.
- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места.
- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени.

View File

@ -12,6 +12,8 @@ from workflows import graph_history_manager, run_agent_streaming
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
import base64
from mcp_tools import get_raw_mcp_tools_list
import threading
import uuid as uuid_lib
api = Flask(__name__)
CORS(
@ -423,7 +425,8 @@ def fetch_mcp_tools_route():
return jsonify({"error": "Пустой конфиг сервера"}), 400
try:
tools_list = get_raw_mcp_tools_list(server_config)
vault_cwd = current_obsidian_settings.get("vaultAbsolutePath")
tools_list = get_raw_mcp_tools_list(server_config, vault_cwd=vault_cwd)
return jsonify(tools_list)
except Exception as e:
return jsonify({"error": str(e)}), 500
@ -488,4 +491,126 @@ def sync_settings():
graph_history_manager.title_generator.voice_llm = get_llm(current_obsidian_settings['voiceCommandModel'])
print(f"✅⚙️ Settings synced: {current_obsidian_settings}")
return jsonify({"status": "synced"})
return jsonify({"status": "synced"})
# ---------------------------------------------------------------------------
# Vault Query Bridge
# Позволяет Python-скриптам читать/писать файлы vault через Obsidian.
# Схема: POST /api/vault/query → socketio emit → Obsidian отвечает → возврат.
# ---------------------------------------------------------------------------
import threading
import uuid as uuid_lib
# Хранилище ожидающих ответа запросов.
# Ключ: request_id (str UUID)
# Значение: (threading.Event, dict результата)
_vault_query_pending: dict[str, tuple[threading.Event, dict]] = {}
@api.route('/api/vault/query', methods=['POST'])
def vault_query_endpoint():
"""
Проксирует запрос к Obsidian vault через WebSocket.
Принимает JSON:
{
"type": "get_frontmatter" | "get_all_factions" | ...,
"payload": { ... параметры операции ... }
}
Поддерживаемые типы (обрабатываются на стороне Obsidian VaultQueryHandler):
get_frontmatter YAML + тело одного файла
get_all_factions все файлы в папке (с фильтром по тегу)
get_recent_files последние N файлов (с фильтром по тегам)
resolve_wikilink wikilink путь к файлу
write_frontmatter перезаписать YAML + тело файла
create_file создать или перезаписать файл
Блокируется до ответа Obsidian или до истечения timeout (10с).
При timeout возвращает 504.
"""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Пустое тело запроса"}), 400
query_type = data.get("type")
payload = data.get("payload", {})
if not query_type:
return jsonify({"error": "Поле 'type' обязательно"}), 400
# Генерируем уникальный ID для сопоставления запрос↔ответ
request_id = str(uuid_lib.uuid4())
# Регистрируем ожидание ответа
event = threading.Event()
result_holder: dict = {}
_vault_query_pending[request_id] = (event, result_holder)
try:
# Отправляем запрос в Obsidian через WebSocket
socketio.emit('vault_query_request', {
"request_id": request_id,
"type": query_type,
"payload": payload
})
# Блокируемся — ждём пока handle_vault_query_response вызовет event.set()
answered = event.wait(timeout=10.0)
finally:
# Убираем из pending независимо от результата
_vault_query_pending.pop(request_id, None)
if not answered:
return jsonify({
"error": (
"Timeout: Obsidian не ответил за 10 секунд. "
"Проверьте что плагин LLM Agent запущен и подключён к WebSocket."
)
}), 504
if "error" in result_holder:
return jsonify({"error": result_holder["error"]}), 500
return jsonify(result_holder.get("data"))
@socketio.on('vault_query_response')
def handle_vault_query_response(data):
"""
Получает ответ от Obsidian на vault-запрос.
Будит заблокированный поток в vault_query_endpoint().
data ожидается вида:
{ "request_id": "...", "result": <любые данные> }
или
{ "request_id": "...", "error": "описание ошибки" }
"""
if not isinstance(data, dict):
print(f"⚠️ vault_query_response: неожиданный формат: {type(data)}")
return
request_id = data.get("request_id")
if not request_id:
print("⚠️ vault_query_response: отсутствует request_id")
return
pending = _vault_query_pending.get(request_id)
if not pending:
# Дубль после таймаута или успешной обработки — это нормально
# если исправлен WebSocketService. До исправления — признак бага.
print(f"⚠️ дубль vault_query_response [{request_id[:8]}] — игнорируем")
return
event, result_holder = pending
if "error" in data:
result_holder["error"] = data["error"]
else:
result_holder["data"] = data.get("result")
print(f"✅ vault_query_response [{request_id[:8]}]: тип={type(result_holder['data']).__name__}")
event.set()
# pending будет удалён в finally блока vault_query_endpoint

View File

@ -77,6 +77,18 @@ MODELS: Dict[str, Dict[str, Any]] = {
"include_reasoning": True
}
},
"gemini-3.0-flash-routerai": {
"name": "google/gemini-3-flash-preview",
"provider": "openai",
"model_name": "google/gemini-3-flash-preview",
"apiBase": "https://routerai.ru/api/v1",
"apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB",
"stream": True,
"capabilities": ["vision", "reasoning"],
"model_kwargs": {
"include_reasoning": True
}
},
"gemini-3.1-pro-openrouter": {
"name": "google/gemini-3.1-pro-preview",
"provider": "openai",
@ -144,6 +156,18 @@ MODELS: Dict[str, Dict[str, Any]] = {
"include_reasoning": True
}
},
"claude-sonnet-5-routerai": {
"name": "anthropic/claude-sonnet-5",
"provider": "openai",
"model_name": "anthropic/claude-sonnet-5",
"apiBase": "https://routerai.ru/api/v1",
"apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB",
"stream": True,
"capabilities": ["vision", "reasoning"],
"model_kwargs": {
"include_reasoning": True
}
},
"deepseek-v4-flash-openrouter": {
"name": "deepseek/deepseek-v4-flash",
"provider": "openai",
@ -156,6 +180,27 @@ MODELS: Dict[str, Dict[str, Any]] = {
"model_kwargs": {
"include_reasoning": True
}
},
"deepseek-v4-flash-routerai": {
"name": "deepseek/deepseek-chat",
"provider": "openai",
"model_name": "deepseek/deepseek-chat",
"apiBase": "https://routerai.ru/api/v1",
"apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB",
"stream": True,
"capabilities": ["vision", "reasoning"],
"model_kwargs": {
"include_reasoning": True
}
},
"deepseek-v3.2-exp-openrouter": {
"name": "deepseek/deepseek-v3.2-exp",
"provider": "openrouter",
"model_name": "deepseek/deepseek-v3.2-exp",
"apiBase": "https://openrouter.ai/api/v1",
"apiKey": "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",
"stream": True,
"capabilities": ["chat", "reasoning", "code"]
}
}

View File

@ -37,7 +37,7 @@ def json_schema_to_pydantic(schema: dict, model_name: str) -> type[BaseModel]:
return create_model(model_name, **fields)
def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None):
def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None, vault_cwd=None):
"""Обертка, которая поднимает контейнер/процесс ровно на 1 вызов тула и убивает его"""
args_schema = json_schema_to_pydantic(json_schema, f"MCP_{tool_name.replace('-','_')}_Schema")
command = server_config.get("command")
@ -48,7 +48,7 @@ def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env,
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
async def _run():
server_params = StdioServerParameters(command=command, args=args, env=full_env)
server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
@ -115,7 +115,7 @@ def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env,
handle_tool_error=True # Разрешает агенту "выжить" после ошибки тула и сгенерировать ответ
)
def fetch_mcp_tools(server_config, debug_callback=None):
def fetch_mcp_tools(server_config, debug_callback=None, vault_cwd=None):
"""Один раз читает список тулов от сервера и кеширует их схемы"""
if not MCP_AVAILABLE:
print("⚠️ MCP серверы настроены, но библиотека 'mcp' не установлена. Выполните: pip install mcp")
@ -151,7 +151,7 @@ def fetch_mcp_tools(server_config, debug_callback=None):
env_dict[k.strip()] = v.strip()
full_env = {**os.environ.copy(), **env_dict}
server_params = StdioServerParameters(command=command, args=args, env=full_env)
server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
@ -180,7 +180,7 @@ def fetch_mcp_tools(server_config, debug_callback=None):
else:
final_description = t.description
wrapped_tools.append(create_mcp_tool(server_config, t.name, final_description, t.inputSchema, full_env, args, debug_callback))
wrapped_tools.append(create_mcp_tool(server_config, t.name, final_description, t.inputSchema, full_env, args, debug_callback, vault_cwd))
_MCP_CACHED_TOOLS[config_hash] = wrapped_tools
print(f"🔌 Успешно загружено {len(wrapped_tools)} инструментов от MCP-сервера '{server_config.get('name')}'")
@ -189,7 +189,7 @@ def fetch_mcp_tools(server_config, debug_callback=None):
print(f"❌ Ошибка инициализации MCP сервера {server_config.get('name')}: {e}")
return []
def get_raw_mcp_tools_list(server_config):
def get_raw_mcp_tools_list(server_config, vault_cwd=None):
"""Метод для UI: просто возвращает список доступных инструментов в JSON-friendly формате"""
if not MCP_AVAILABLE:
return []
@ -212,7 +212,7 @@ def get_raw_mcp_tools_list(server_config):
env_dict[k.strip()] = v.strip()
full_env = {**os.environ.copy(), **env_dict}
server_params = StdioServerParameters(command=command, args=args, env=full_env)
server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:

View File

@ -55,6 +55,12 @@ def create_subprocess_tool(script_path: str, method_name: str,
spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not hasattr(module, method_name):
error_msg = f"в файле '{script_path}' не найден метод '{method_name}'"
print(f"{error_msg}")
raise AttributeError(error_msg)
func = getattr(module, method_name)
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
@ -77,6 +83,12 @@ import sys
import importlib.util
import os
# ПРИНУДИТЕЛЬНО устанавливаем кодировку UTF-8 для потоков вывода и ошибок, чтобы могло выводить эмодзи, например, без ошибок
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8')
# Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
original_path = sys.path.copy()
@ -174,30 +186,15 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
for ct in custom_tools:
name = ct.get("name")
desc = ct.get("description")
full_path = ct.get("scriptPath", "")
# Поддерживаем оба ключа, если в JS/TS они не раскрылись
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
if not name:
continue # Инструмент без имени создать нельзя
# Парсим путь к скрипту (учитываем Windows-пути с буквой диска)
script_path = ""
method_name = "main"
if full_path:
parts = full_path.rsplit(":", 1) # Разделяем строку с конца 1 раз
if len(parts) == 2:
# Если слева от разделения ровно 1 символ-буква (например, 'D'),
# значит это путь без метода, а двоеточие — от диска Windows.
if len(parts[0]) == 1 and parts[0].isalpha():
script_path = full_path
else:
script_path = parts[0]
method_name = parts[1]
else:
script_path = full_path
# Достаем путь и метод из отдельных полей, присланных фронтендом
script_path = ct.get("scriptPath", "")
method_name = ct.get("methodName") or "main"
# ПРОВЕРКА: Есть ли физический скрипт?
is_valid_script = script_path and os.path.exists(script_path)
@ -250,6 +247,8 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
)
tools.append(help_tool)
vault_cwd = obsidian_settings.get("vaultAbsolutePath")
# Инициализация внутренних MCP серверов
mcp_servers = obsidian_settings.get("mcpServers", [])
for mcp_server in mcp_servers:
@ -257,7 +256,7 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
if not mcp_server.get("isEnabled", True):
continue
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback)
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback, vault_cwd=vault_cwd)
tools.extend(mcp_tools)
return tools
@ -316,12 +315,33 @@ def _generate_commands_list(custom_tools: list[dict[str, Any]]) -> str:
if not custom_tools:
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
lines = ["Я могу использовать следующие инструменты/команды:\n"]
tool_raw_list = ["Я могу использовать следующие инструменты/команды:\n"]
for ct in custom_tools:
name = ct.get("name")
desc = ct.get("description", "Описание отсутствует")
if name:
lines.append(f"- **{name}**: {desc}")
tool_raw_list.append(f"- **{name}**: {desc}")
lines.append("\nПерескажи этот список пользователю в удобном формате.")
return "\n".join(lines)
# Приаттачиваем инструкцию формата вывода (hidden prompt для системы)
prompt = f"""
СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ:
{tool_raw_list}
ТВОЯ ЗАДАЧА:
Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц.
ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ:
1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты.
2. 🏗 [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение.
3. 🗺 [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии.
4. [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом.
ФОРМАТ ВЫВОДА:
Для каждой категории создай заголовок и таблицу:
| Команда | Функциональное назначение |
|:---|:---|
| **названиеоманды** | Суть работы (1 краткое предложение) |
Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен.
"""
return prompt