From ebe9e913f50839621f7438b4f6f5e75df8926298 Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 8 May 2026 17:18:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Human-in-the-Loop=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Агент с HumanInTheLoopMiddleware для подтверждения вызовов инструментов - Инструменты: get_weather, search_web - Цикл: invoke → __interrupt__ → approve/reject → Command(resume=...) - Поддержка approve и reject с сообщением при отказе - requirements.txt, .env.example, README.md --- .env.example | 3 + README.md | 34 ++++++++++ main.py | 165 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 ++ 4 files changed, 206 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b9635d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +OPENAI_API_KEY=your-api-key-here +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini diff --git a/README.md b/README.md new file mode 100644 index 0000000..deea6fd --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Задание 5: Human-in-the-Loop через middleware + +## Описание + +Агент с `HumanInTheLoopMiddleware`: при каждом вызове инструмента +агент останавливается, в терминале показывается что он хочет сделать, +пользователь вводит решение (approve или reject), +после чего выполнение возобновляется через `Command(resume=...)`. + +## Стек + +- Python 3.10+ +- langchain-core, langchain-openai, langgraph + +## Установка + +```bash +pip install -r requirements.txt +cp .env.example .env +# Заполнить OPENAI_API_KEY в .env +``` + +## Запуск + +```bash +python main.py "Какая погода в Казани сегодня?" +``` + +## Архитектура + +- `get_weather`, `search_web` — инструменты агента +- `HumanInTheLoopMiddleware` — middleware для подтверждения вызовов +- `run_agent_with_hitl()` — цикл: invoke → __interrupt__ → решения → resume +- Поддерживаются `approve` и `reject` для каждого вызова инструмента diff --git a/main.py b/main.py new file mode 100644 index 0000000..325d8d1 --- /dev/null +++ b/main.py @@ -0,0 +1,165 @@ +""" +Задание 5: Human-in-the-Loop через middleware + +Агент с HumanInTheLoopMiddleware: при каждом вызове инструмента +агент останавливается, пользователь подтверждает (approve/reject), +после чего выполнение возобновляется. +""" + +import os +import json +from langchain_openai import ChatOpenAI +from langchain_core.tools import tool +from langchain.agents import create_agent +from langchain.agents.middleware import HumanInTheLoopMiddleware +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import Command + + +# ─── Инструменты ───────────────────────────────────────────────────────────── + +@tool +def get_weather(city: str, date: str = "сегодня") -> str: + """ + Получить информацию о погоде в городе. + Вход: city — название города, date — дата (по умолчанию 'сегодня'). + """ + # Заглушка — реальное обращение к API погоды + return f"В городе {city} на {date} ожидается переменная облачность, +15°C." + + +@tool +def search_web(query: str) -> str: + """ + Поиск информации в интернете. + Вход: query — поисковый запрос. + """ + return f"Результаты поиска по запросу '{query}': [результат-1, результат-2]" + + +TOOLS = [get_weather, search_web] + + +# ─── Инициализация LLM ─────────────────────────────────────────────────────── + +api_base = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") +api_key = os.environ.get("OPENAI_API_KEY", "") +model_name = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") + +llm = ChatOpenAI( + model=model_name, + openai_api_base=api_base, + openai_api_key=api_key, + temperature=0, +) + + +# ─── Сборка агента с HumanInTheLoopMiddleware ──────────────────────────────── + +memory = MemorySaver() + +agent = create_agent( + model=llm, + tools=TOOLS, + system_prompt="Ты полезный ассистент. Используй инструменты для ответа на вопросы.", + middleware=[ + HumanInTheLoopMiddleware( + interrupt_on={ + "get_weather": True, + "search_web": True, + }, + description_prefix="Подтвердите вызов инструмента", + ), + ], + checkpointer=memory, +) + + +# ─── Цикл подтверждения ───────────────────────────────────────────────────── + +def get_user_decisions(action_requests: list[dict], review_configs: list[dict]) -> list[dict]: + """ + Показать пользователю запросы на вызов инструментов и собрать решения. + Поддерживает approve и reject. + """ + decisions = [] + + for i, (action, review_cfg) in enumerate(zip(action_requests, review_configs)): + name = action.get("name", "unknown") + args = action.get("args", {}) + description = action.get("description", "") + allowed = review_cfg.get("allowed_decisions", ["approve", "reject"]) + + print(f"\n{'─' * 50}") + print(f" Подтверждение #{i + 1}") + print(f"{'─' * 50}") + print(f" Инструмент : {name}") + print(f" Аргументы : {json.dumps(args, ensure_ascii=False)}") + if description: + print(f" Описание : {description}") + print(f" Решения : {', '.join(allowed)}") + + while True: + choice = input(f"\n (a)pprove / (r)eject: ").strip().lower() + + if choice in ("a", "approve") and "approve" in allowed: + decisions.append({"type": "approve"}) + break + elif choice in ("r", "reject") and "reject" in allowed: + msg = input(" Сообщение для агента (причина отказа): ").strip() + decisions.append({ + "type": "reject", + "message": msg or "Запрос отклонён пользователем", + }) + break + else: + print(f" Неверный ввод. Доступные: {', '.join(allowed)}") + + return decisions + + +def run_agent_with_hitl(user_message: str, thread_id: str = "session-1"): + """ + Запустить агента с обработкой Human-in-the-Loop. + Цикл: invoke → проверка __interrupt__ → запрос решений → resume → ... + """ + config = {"configurable": {"thread_id": thread_id}} + + print("=" * 60) + print(" 🤖 Агент с Human-in-the-Loop Middleware") + print("=" * 60) + print(f"\n Вы: {user_message}\n") + + result = agent.invoke( + {"messages": [{"role": "human", "content": user_message}]}, + config=config, + ) + + # Цикл обработки прерываний + while "__interrupt__" in result: + interrupt_value = result["__interrupt__"][0].value + action_requests = interrupt_value.get("action_requests", []) + review_configs = interrupt_value.get("review_configs", []) + + decisions = get_user_decisions(action_requests, review_configs) + + result = agent.invoke( + Command(resume={"decisions": decisions}), + config=config, + ) + + # Финальный ответ + final_message = result["messages"][-1] + print(f"\n{'=' * 60}") + print(f" Агент: {final_message.content}") + print(f"{'=' * 60}") + + return result + + +# ─── Точка входа ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import sys + message = sys.argv[1] if len(sys.argv) > 1 else "Какая погода в Казани сегодня?" + run_agent_with_hitl(message) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf4430b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +langchain-core>=0.3.0 +langchain-openai>=0.2.0 +langgraph>=0.2.0 +python-dotenv>=1.0.0