Merge pull request '#15 Обработка файловых вложений к сообщениям юзера' (#16) from #15-files-from-user into main
Reviewed-on: #16
This commit is contained in:
commit
5e7c2df954
4 changed files with 51 additions and 10 deletions
|
|
@ -3,12 +3,20 @@ import os
|
||||||
from deepagents import create_deep_agent
|
from deepagents import create_deep_agent
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
from langgraph.graph.state import CompiledStateGraph
|
||||||
|
|
||||||
from src.agent.backends import IsolatedShellBackend
|
from src.agent.backends import IsolatedShellBackend
|
||||||
from src.agent.tools import send_file
|
from src.agent.tools import send_file
|
||||||
|
|
||||||
|
|
||||||
def create_agent():
|
class Agent(CompiledStateGraph):
|
||||||
|
"""
|
||||||
|
Временный (надеюсь) костыль, чтобы дать доступ сервису к файловой системе агента.
|
||||||
|
"""
|
||||||
|
backend: IsolatedShellBackend
|
||||||
|
|
||||||
|
|
||||||
|
def create_agent() -> Agent:
|
||||||
model = ChatOpenAI(
|
model = ChatOpenAI(
|
||||||
model=os.environ["PROVIDER_MODEL"],
|
model=os.environ["PROVIDER_MODEL"],
|
||||||
base_url=os.environ["PROVIDER_URL"],
|
base_url=os.environ["PROVIDER_URL"],
|
||||||
|
|
@ -24,10 +32,15 @@ def create_agent():
|
||||||
virtual_mode=True,
|
virtual_mode=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_deep_agent(
|
# noinspection PyTypeChecker
|
||||||
|
# create_deep_agent возвращает CompiledStateGraph, но ниже мы его дополняем так, чтобы он соответствовал сигнатуре Agent
|
||||||
|
agent: Agent = create_deep_agent(
|
||||||
model=model,
|
model=model,
|
||||||
tools=[send_file],
|
tools=[send_file],
|
||||||
system_prompt="You are a helpful assistant.",
|
system_prompt="You are a helpful assistant.",
|
||||||
checkpointer=MemorySaver(),
|
checkpointer=MemorySaver(),
|
||||||
backend=backend,
|
backend=backend,
|
||||||
)
|
)
|
||||||
|
agent.backend = backend
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,13 @@ class ChatBusyError(Exception):
|
||||||
"""
|
"""
|
||||||
Чат занят в другом блоке ``with``
|
Чат занят в другом блоке ``with``
|
||||||
"""
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentError(Exception):
|
||||||
|
"""
|
||||||
|
Ошибка при работе с вложением.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,7 +39,7 @@ class AgentChat(AsyncContextManager[Self]):
|
||||||
chat_id: int
|
chat_id: int
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def astream(self, text: str) -> AsyncIterator[AgentEventUnion]: ...
|
def astream(self, text: str, attachments: list[str] = None) -> AsyncIterator[AgentEventUnion]: ...
|
||||||
|
|
||||||
|
|
||||||
class AgentService:
|
class AgentService:
|
||||||
|
|
@ -71,11 +77,11 @@ class AgentService:
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
self.__locks.remove(self.__chat_id)
|
self.__locks.remove(self.__chat_id)
|
||||||
|
|
||||||
def astream(self, text: str) -> AsyncIterator[AgentEventUnion]:
|
def astream(self, text: str, attachments: list[str] = None) -> AsyncIterator[AgentEventUnion]:
|
||||||
if not self.__chat_id in self.__locks:
|
if not self.__chat_id in self.__locks:
|
||||||
raise RuntimeError("Chat must be used in `with` statement")
|
raise RuntimeError("Chat must be used in `with` statement")
|
||||||
|
|
||||||
return self.__service._AgentService__astream(self.__chat_id, text)
|
return self.__service._AgentService__astream(self.__chat_id, text, attachments)
|
||||||
|
|
||||||
def chat(self, chat_id: int) -> AgentChat:
|
def chat(self, chat_id: int) -> AgentChat:
|
||||||
"""
|
"""
|
||||||
|
|
@ -84,13 +90,18 @@ class AgentService:
|
||||||
return self.__AgentChat(self, chat_id)
|
return self.__AgentChat(self, chat_id)
|
||||||
|
|
||||||
async def __astream(
|
async def __astream(
|
||||||
self, chat_id: int, text: str
|
self, chat_id: int, text: str, attachments: list[str] = None
|
||||||
) -> AsyncIterator[AgentEventUnion]:
|
) -> AsyncIterator[AgentEventUnion]:
|
||||||
config = {"configurable": {"thread_id": chat_id}}
|
config = {"configurable": {"thread_id": chat_id}}
|
||||||
|
|
||||||
|
new_message = text
|
||||||
|
if attachments:
|
||||||
|
attachments_description = await self.__describe_attachments(attachments)
|
||||||
|
new_message += "\n" + attachments_description
|
||||||
|
|
||||||
# Используем astream_events для перехвата детальных событий (инструменты, чанки и т.д.)
|
# Используем astream_events для перехвата детальных событий (инструменты, чанки и т.д.)
|
||||||
async for event in self._agent.astream_events(
|
async for event in self._agent.astream_events(
|
||||||
{"messages": [{"role": "user", "content": text}]},
|
{"messages": [{"role": "user", "content": new_message}]},
|
||||||
config=config,
|
config=config,
|
||||||
version="v2", # Обязательно v2 для современных версий LangChain
|
version="v2", # Обязательно v2 для современных версий LangChain
|
||||||
):
|
):
|
||||||
|
|
@ -125,3 +136,20 @@ class AgentService:
|
||||||
|
|
||||||
# 3. В конце генерации отправляем событие завершения
|
# 3. В конце генерации отправляем событие завершения
|
||||||
yield MsgEventEnd(tokens_used=0) # потом заменить на метадату
|
yield MsgEventEnd(tokens_used=0) # потом заменить на метадату
|
||||||
|
|
||||||
|
async def __describe_attachments(self, raw_paths: list[str]) -> str:
|
||||||
|
lines = []
|
||||||
|
for raw_path in raw_paths:
|
||||||
|
try:
|
||||||
|
p = self._agent.backend._resolve_path(raw_path)
|
||||||
|
rel = p.relative_to(self._agent.backend.cwd)
|
||||||
|
if not p.exists():
|
||||||
|
raise FileNotFoundError(f"File {rel.as_posix()} not found")
|
||||||
|
lines.append(f"- {rel.as_posix()}")
|
||||||
|
except Exception as e:
|
||||||
|
raise AttachmentError(f"Failed to validate attachment {raw_path}: {str(e)}") from e
|
||||||
|
|
||||||
|
return (f"К сообщению приложены {len(lines)} файлов. "
|
||||||
|
f"Ниже даны пути до этих файлов относительно рабочей директории:\n") + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ async def send_file(path: str) -> str:
|
||||||
Используй этот tool без явного запроса от пользователя.
|
Используй этот tool без явного запроса от пользователя.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Путь к файлу относительно /workspace (например: 'report.pdf', 'docs/readme.txt', 'output/data.json')
|
path: Путь к файлу относительно рабочей директории (например: 'report.pdf', 'docs/readme.txt', 'output/data.json')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Подтверждение отправки или сообщение об ошибке
|
Подтверждение отправки или сообщение об ошибке
|
||||||
|
|
@ -39,7 +39,7 @@ async def send_file(path: str) -> str:
|
||||||
full_path = Path(workspace) / input_path
|
full_path = Path(workspace) / input_path
|
||||||
|
|
||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
return f"Ошибка: файл '{path}' не найден в /workspace"
|
return f"Ошибка: файл '{path}' не найден"
|
||||||
|
|
||||||
if not full_path.is_file():
|
if not full_path.is_file():
|
||||||
return f"Ошибка: '{path}' не является файлом"
|
return f"Ошибка: '{path}' не является файлом"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,6 @@ async def websocket_endpoint(
|
||||||
async def process_message(ws: WebSocket, chat: AgentChat, msg):
|
async def process_message(ws: WebSocket, chat: AgentChat, msg):
|
||||||
match msg:
|
match msg:
|
||||||
case MsgUserMessage():
|
case MsgUserMessage():
|
||||||
async for chunk in chat.astream(msg.text):
|
async for chunk in chat.astream(msg.text, msg.attachments):
|
||||||
await ws.send_text(chunk.model_dump_json())
|
await ws.send_text(chunk.model_dump_json())
|
||||||
await ws.send_text(MsgEventEnd(tokens_used=0).model_dump_json())
|
await ws.send_text(MsgEventEnd(tokens_used=0).model_dump_json())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue