llm-agent-backend/app/workflows.py
2025-11-11 23:03:04 +03:00

271 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Определяет логику рабочих процессов агента,
используя LangGraph для управления состояниями
и переходами между узлами обработки.
"""
from typing import Dict, Any, Optional, Set
from langgraph.graph import StateGraph, END
from graph_history_manager import GraphHistoryManager
from models import AgentState
from nodes import parse_command_node, execute_command_node, call_llm_node, generate_images_node, analyze_image_node, get_meet_subtitles_node, get_teams_subtitles_node, summarize_history_node, handle_error_node, help_node
import uuid # Добавлено для генерации UUID
from llm_client import get_llm
import os
import base64
from pathlib import Path
# --- LangGraph: Построение графа ---
graph_history_manager = GraphHistoryManager()
workflow = StateGraph(AgentState)
# Добавляем узлы
workflow.add_node("parse_command", parse_command_node)
workflow.add_node("execute_command",
execute_command_node) # Это по сути роутер
workflow.add_node("call_llm", call_llm_node)
workflow.add_node("generate_images", generate_images_node)
workflow.add_node("analyze_image", analyze_image_node)
workflow.add_node("get_meet_subtitles", get_meet_subtitles_node)
workflow.add_node("get_teams_subtitles", get_teams_subtitles_node)
workflow.add_node("summarize_history", summarize_history_node)
workflow.add_node("handle_error", handle_error_node)
workflow.add_node("help", help_node)
# Устанавливаем точку входа
workflow.set_entry_point("parse_command")
# Определяем маршрутизацию после parse_command
def route_command(state: AgentState) -> str:
"""Маршрутизирует выполнение на основе распознанной команды."""
if state.error:
return "handle_error" # Если была ошибка парсинга команды
elif state.command == "imagine":
return "generate_images"
elif state.command == "analyze":
return "analyze_image"
elif state.command == "subtitles_meet":
return "get_meet_subtitles"
elif state.command == "subtitles_teams":
return "get_teams_subtitles"
elif state.command == "summarize":
return "summarize_history"
elif state.command == "help":
return "help"
elif state.command == "chat":
return "call_llm"
elif state.command == "error": # Команда была, но не известная
return "handle_error"
else:
# Fallback на LLM, если команда не распознана или пуста (хотя parse_command должен был бы ее поймать)
return "call_llm"
workflow.add_conditional_edges(
"parse_command", # Откуда
route_command, # Функция, определяющая куда идти
{
"generate_images": "generate_images",
"analyze_image": "analyze_image",
"get_meet_subtitles": "get_meet_subtitles",
"get_teams_subtitles": "get_teams_subtitles",
"summarize_history": "summarize_history",
"help": "help",
"call_llm": "call_llm",
"handle_error":
"handle_error", # Для ошибок, найденных в parse_command
},
)
# Все узлы операций (кроме ошибок) ведут к END после завершения
workflow.add_edge("call_llm", END)
workflow.add_edge("generate_images", END)
workflow.add_edge("analyze_image", END)
workflow.add_edge("get_meet_subtitles", END)
workflow.add_edge("get_teams_subtitles", END)
workflow.add_edge("summarize_history", END)
workflow.add_edge("help", END)
workflow.add_edge("handle_error",
END) # Ошибка - это тоже конечный пункт для текущего запроса
# Компилируем граф
app = workflow.compile()
def run_agent_streaming(graph_id: str,
user_node_id: str,
assistant_node_id: str,
system_prompt: Optional[str] = None,
model: Optional[str] = None,
cache_folder: Optional[str] = None):
"""Генератор для стримингового ответа LLM с поддержкой вложений."""
try:
loaded_graph_data = graph_history_manager.get_graph(
graph_id, target_node_id=user_node_id)
if not loaded_graph_data:
yield {"type": "error", "error": "Граф не найден."}
return
messages = loaded_graph_data.get("messages", [])
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
messages_for_llm = []
if system_prompt:
messages_for_llm.append(SystemMessage(content=system_prompt))
# Обрабатываем вложения
for i, msg in enumerate(messages):
is_last = (i == len(messages) - 1)
if msg["role"] == "user":
text_content = msg["content"]
attachments = msg.get("attachments", [])
# Проверяем, есть ли мультимодальный контент
has_multimodal = False
content_parts = [{"type": "text", "text": text_content}]
# Обрабатываем вложения
if attachments:
for att in attachments:
# Раскрываем только permanent или вложения последнего сообщения
if att.get("permanent") or is_last:
attachment_content = prepare_attachment_for_llm(
att, cache_folder)
# Если это изображение, то это мультимодальный контент
if attachment_content.get("type") == "image_url":
has_multimodal = True
content_parts.append(attachment_content)
# Формируем финальный контент
if has_multimodal:
# Мультимодальное сообщение
messages_for_llm.append(
HumanMessage(content=content_parts))
else:
# Текстовое сообщение (объединяем все текстовые части)
full_text = text_content
for part in content_parts[
1:]: # Пропускаем первую часть (основной текст)
if part.get("type") == "text":
full_text += part.get("text", "")
messages_for_llm.append(HumanMessage(content=full_text))
elif msg["role"] == "assistant":
messages_for_llm.append(AIMessage(content=msg["content"]))
# Стриминг ответа
llm = get_llm(model or "gemini-2.5-flash")
for chunk in llm.stream2(messages_for_llm):
content = chunk.content if hasattr(chunk,
'content') else str(chunk)
yield {"type": "chunk", "content": content}
except Exception as e:
print(f"Ошибка при стриминге: {e}")
import traceback
traceback.print_exc()
yield {"type": "error", "error": str(e)}
def prepare_attachment_for_llm(attachment: Dict[str, Any],
cache_folder: str) -> Dict[str, Any]:
"""Подготавливает вложение для отправки в LLM."""
mime_type = attachment.get("mimeType", "application/octet-stream")
cached_path = attachment.get("cachedPath")
att_cache_folder = attachment.get("cacheFolder", cache_folder) # ИЗМЕНЕНО
source_path = attachment.get("sourcePath")
att_type = attachment.get("type")
name = attachment.get("name")
# Получаем полный путь к кешированному файлу
full_cached_path = os.path.join(att_cache_folder, cached_path)
# Проверяем существование кеша
if not os.path.exists(full_cached_path):
print(f"❌ Кеш не найден: {full_cached_path}")
return {"type": "text", "text": f"\n[Файл {name} недоступен]\n"}
# Проверяем тип файла
if mime_type.startswith('image/'):
# Для изображений отправляем как base64
with open(full_cached_path, 'rb') as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
return {
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{image_data}"
}
}
elif mime_type.startswith('text/') or \
mime_type in ['application/json', 'application/xml'] or \
mime_type.startswith('application/x-') or \
mime_type == 'application/octet-stream': # TODO: Спорно, но пока вводим - фронтенд не смог сопоставить расширению mime_type
# неразрывно связано с методом getMimeType() на фронтенде
# Для текстовых файлов (или потенциально текстовых с неизвестным типом)
try:
with open(full_cached_path, 'r', encoding='utf-8') as f:
content = f.read()
except UnicodeDecodeError:
# Если не получилось прочитать как текст
return {
"type": "text",
"text": f"\n[Файл {name} не может быть прочитан как текст]\n"
}
att_type_ru = "промпта" if att_type == "prompt" else "файла"
header = f"\n\n--- Содержимое {att_type_ru}: {name} ---\n"
footer = f"\n--- Конец {att_type_ru}: {name} ---\n\n"
return {"type": "text", "text": header + content.strip() + footer}
else:
# Для других типов файлов возвращаем информацию
return {
"type":
"text",
"text":
f"\n[Файл {name} ({mime_type}) не может быть отображен в чате]\n"
}
def restore_cache_from_source(source_path: str, att_type: str,
cache_folder: str, cached_path: str) -> str:
"""Восстанавливает кеш из исходного файла."""
full_cached_path = os.path.join(cache_folder, cached_path)
# Создаем папку кеша, если не существует
os.makedirs(cache_folder, exist_ok=True)
if att_type == 'external':
# Для внешних файлов source_path - абсолютный
if not os.path.exists(source_path):
raise FileNotFoundError(f"Исходный файл не найден: {source_path}")
import shutil
shutil.copy2(source_path, full_cached_path)
else:
# Для внутренних файлов и промптов нужно получить полный путь
# Это требует доступа к vault, что на бэкенде недоступно
# Поэтому это нужно делать на фронтенде
raise NotImplementedError(
"Восстановление кеша для внутренних файлов должно происходить на фронтенде"
)
return full_cached_path
def format_attachment_for_llm(attachment: Dict[str, Any]) -> str:
"""Форматирует вложение для вставки в сообщение LLM."""
att_type = "промпта" if attachment["type"] == "prompt" else "файла"
header = f"\n\n--- Содержимое {att_type}: {attachment['name']} ---\n"
footer = f"\n--- Конец {att_type}: {attachment['name']} ---\n\n"
return header + attachment.get("content", "").strip() + footer