diff --git a/solutions/69a86305c46fd26feae6bcaa_Human-in-the-Loop_через_middleware/client.py b/solutions/69a86305c46fd26feae6bcaa_Human-in-the-Loop_через_middleware/client.py index a88382d..004c660 100644 --- a/solutions/69a86305c46fd26feae6bcaa_Human-in-the-Loop_через_middleware/client.py +++ b/solutions/69a86305c46fd26feae6bcaa_Human-in-the-Loop_через_middleware/client.py @@ -2,12 +2,21 @@ import sys import logging from typing import List -# LangChain imports -from langchain_ollama import OllamaEmbeddings +# ------------------------------------------------------------------ +# 1️⃣ Основные импорты LangChain / LangGraph +# ------------------------------------------------------------------ +from langchain_ollama import OllamaEmbeddings, OllamaLLM from langchain_qdrant import QdrantVectorStore -from langchain.schema import Document +from langchain.schema import Document, HumanMessage, AIMessage -# Настройка логирования +from langchain.agents import create_agent, Tool +from langchain.agents.middleware import HumanInTheLoopMiddleware +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import Command + +# ------------------------------------------------------------------ +# 2️⃣ Настройка логирования +# ------------------------------------------------------------------ logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", @@ -15,25 +24,63 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -# Конфигурация Ollama и Qdrant +# ------------------------------------------------------------------ +# 3️⃣ Конфигурация Ollama и Qdrant +# ------------------------------------------------------------------ EMBEDDING_MODEL = "nomic-embed-text" LLM_MODEL = "llama3" -# Создаём объект для получения эмбеддингов embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL) - -# Подключаемся к локальному экземпляру Qdrant (по умолчанию localhost:6333) vector_store = QdrantVectorStore( embedding_function=embeddings, url="http://localhost:6333", collection_name="documents", ) +# ------------------------------------------------------------------ +# 4️⃣ Создание LLM и вспомогательных инструментов +# ------------------------------------------------------------------ +llm = OllamaLLM(model=LLM_MODEL, temperature=0.7) +def get_weather(city: str, date: str) -> str: + """Пример простого инструмента получения погоды.""" + # В реальном проекте здесь будет запрос к API погоды. + return f"Погода в {city} на {date}: солнечно, 25°C." + +weather_tool = Tool( + name="get_weather", + func=get_weather, + description=( + "Получает прогноз погоды для указанного города и даты. " + "Аргументы: city (строка), date (строка)." + ), +) + +# ------------------------------------------------------------------ +# 5️⃣ Создание агента с HumanInTheLoopMiddleware +# ------------------------------------------------------------------ +memory = MemorySaver() + +agent = create_agent( + model=llm, + tools=[weather_tool], + system_prompt="Ты полезный ассистент, который может использовать инструменты.", + middleware=[ + HumanInTheLoopMiddleware( + interrupt_on={ + "get_weather": True, # разрешаем все решения: approve, edit, reject + }, + description_prefix="Подтвердите вызов инструмента", + ), + ], + checkpointer=memory, +) + +# ------------------------------------------------------------------ +# 6️⃣ Декоратор для обработки ошибок и логирования +# ------------------------------------------------------------------ def middleware(func): - """ - Декоратор для обработки ошибок и логирования. - """ + """Декоратор для обработки ошибок и логирования.""" def wrapper(*args, **kwargs): try: @@ -48,11 +95,12 @@ def middleware(func): return wrapper +# ------------------------------------------------------------------ +# 7️⃣ Функции работы с Qdrant (добавление и поиск) +# ------------------------------------------------------------------ @middleware def add_document(text: str) -> None: - """ - Добавляет документ в Qdrant. - """ + """Добавляет документ в Qdrant.""" doc = Document(page_content=text) vector_store.add_documents([doc]) print("Документ добавлен.") @@ -60,9 +108,7 @@ def add_document(text: str) -> None: @middleware def search(query: str, k: int = 3) -> List[Document]: - """ - Выполняет поиск по запросу и выводит результаты. - """ + """Выполняет поиск по запросу и выводит результаты.""" results = vector_store.similarity_search_with_score(query, k=k) if not results: print("Ничего не найдено.") @@ -70,14 +116,18 @@ def search(query: str, k: int = 3) -> List[Document]: for idx, (doc, score) in enumerate(results, start=1): print(f"\nРезультат {idx} (score: {score:.4f})") - print(doc.page_content[:500] + ("..." if len(doc.page_content) > 500 else "")) + snippet = doc.page_content[:500] + if len(doc.page_content) > 500: + snippet += "..." + print(snippet) return [doc for doc, _ in results] +# ------------------------------------------------------------------ +# 8️⃣ Парсинг команд CLI +# ------------------------------------------------------------------ def parse_command(line: str): - """ - Разбирает строку команды и вызывает соответствующую функцию. - """ + """Разбирает строку команды и вызывает соответствующую функцию.""" if not line.strip(): return @@ -94,6 +144,11 @@ def parse_command(line: str): print("[ERROR] Необходимо указать запрос для поиска.") return search(parts[1]) + elif cmd == "/ask": + if len(parts) < 2: + print("[ERROR] Необходимо задать вопрос.") + return + handle_agent_query(parts[1]) elif cmd == "/quit": print("Выход из программы.") sys.exit(0) @@ -101,12 +156,96 @@ def parse_command(line: str): print(f"[ERROR] Нераспознанная команда: {cmd}") +# ------------------------------------------------------------------ +# 9️⃣ Обработка запроса к агенту с Human‑in‑the‑Loop +# ------------------------------------------------------------------ +def handle_agent_query(user_text: str): + """Запускает агента и обрабатывает возможные паузы.""" + config = {"configurable": {"thread_id": "сессия-1"}} + + # Первый вызов + result = agent.invoke( + {"messages": [HumanMessage(content=user_text)]}, + config=config, + ) + + while "__interrupt__" in result: + interrupt_value = result["__interrupt__"][0].value + action_requests = interrupt_value["action_requests"] + review_configs = interrupt_value["review_configs"] + + decisions = [] + + print("\n--- Подтверждение ---") + for idx, (req, cfg) in enumerate(zip(action_requests, review_configs), start=1): + name = req.get("name", "unknown") + args = req.get("args", {}) + description = req.get("description", "") + + print(f"\n{idx}. Инструмент: {name}") + print(f" Аргументы: {args}") + if description: + print(f" Описание: {description}") + + allowed = cfg.get("allowed_decisions", ["approve", "edit", "reject"]) + prompt = f"a = approve, r = reject" + if "edit" in allowed: + prompt += ", e = edit" + + while True: + choice = input(prompt + ": ").strip().lower() + if choice == "a": + decisions.append({"type": "approve"}) + break + elif choice == "r": + msg = input("Сообщение для агента (причина отказа): ") + decisions.append({"type": "reject", "message": msg}) + break + elif choice == "e" and "edit" in allowed: + # простое редактирование аргументов в формате JSON + new_args = input("Введите новые аргументы в формате JSON: ") + try: + import json + + edited = json.loads(new_args) + decisions.append( + { + "type": "edit", + "edited_action": {"name": name, "args": edited}, + } + ) + break + except Exception as e: + print(f"[ERROR] Неверный JSON: {e}") + else: + print("Неверный ввод. Попробуйте снова.") + + # Возобновляем агент с решениями + result = agent.invoke( + Command(resume={"decisions": decisions}), + config=config, + ) + + # После завершения выводим финальный ответ + if result.get("messages"): + final_msg = result["messages"][-1] + if isinstance(final_msg, AIMessage): + print(f"\nАгент: {final_msg.content}") + else: + print("\nАгент не вернул сообщение.") + else: + print("\nАгент завершил работу без ответа.") + + +# ------------------------------------------------------------------ +# 🔄 Основной цикл CLI +# ------------------------------------------------------------------ def main(): - """ - Основной цикл CLI. - """ + """Основной цикл CLI.""" logger.info("Запуск клиента Human-in-the-Loop") - print("Добро пожаловать! Используйте команды /add, /search и /quit.") + print( + "Добро пожаловать! Используйте команды /add, /search, /ask и /quit." + ) while True: try: line = input("> ") @@ -119,7 +258,9 @@ def main(): break -# -------------------- UNIT TESTS -------------------- +# ------------------------------------------------------------------ +# 10️⃣ UNIT TESTS (не меняем) +# ------------------------------------------------------------------ import unittest from unittest.mock import patch, MagicMock @@ -140,19 +281,19 @@ class TestClient(unittest.TestCase): global vector_store vector_store = self.original_vector_store - @patch('builtins.print') + @patch("builtins.print") def test_add_document_success(self, mock_print): add_document("Sample text") mock_print.assert_called_with("Документ добавлен.") - @patch('builtins.print') + @patch("builtins.print") def test_search_results(self, mock_print): results = search("query") self.assertEqual(len(results), 2) # Проверяем, что print был вызван хотя бы один раз self.assertTrue(mock_print.call_count > 0) - @patch('builtins.print') + @patch("builtins.print") def test_parse_unknown_command(self, mock_print): parse_command("/unknown") mock_print.assert_called_with("[ERROR] Нераспознанная команда: /unknown")