From f4b9ee156c6173d985b32b398be10bb844225469 Mon Sep 17 00:00:00 2001 From: marat Date: Fri, 8 May 2026 17:17:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=B8=D0=B3=D1=80=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B5=20LLM=20+=20interrupt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameState (TypedDict) с полями: topic, setup, choices, selected_choice, ending - story_node: генерация завязки → interrupt() → генерация концовки - Цикл обработки __interrupt__ с Command(resume=...) - Поддержка questionary и fallback на input() - requirements.txt, .env.example, README.md --- .env.example | 3 + README.md | 34 +++++++++ main.py | 185 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ 4 files changed, 227 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..ffc9e87 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Задание 4: Текстовая игра на основе LLM + interrupt + +## Описание + +Интерактивная история «выбери свою историю» с LangGraph: +- LLM придумывает завязку и 3 варианта поступка +- Граф ставит на паузу через `interrupt()` +- Пользователь выбирает вариант в консоли +- LLM дописывает концовку на основе выбора + +## Стек + +- Python 3.10+ +- langchain-core, langchain-openai, langgraph, questionary + +## Установка + +```bash +pip install -r requirements.txt +cp .env.example .env +# Заполнить OPENAI_API_KEY в .env +``` + +## Запуск + +```bash +python main.py "космический кот" +``` + +## Архитектура + +- `GameState` — TypedDict с полями: topic, setup, choices, selected_choice, ending +- `story_node` — узел графа: генерация завязки → interrupt → генерация концовки +- `run_game()` — цикл: stream → обработка __interrupt__ → Command(resume=...) → результат diff --git a/main.py b/main.py new file mode 100644 index 0000000..792f269 --- /dev/null +++ b/main.py @@ -0,0 +1,185 @@ +""" +Задание 4: Текстовая игра на основе LLM + interrupt + +Интерактивная история «выбери свою историю»: +LLM придумывает завязку и варианты, граф ставит на паузу (interrupt), +пользователь выбирает вариант, LLM дописывает концовку. +""" + +import os +import re +from typing import TypedDict + +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import StateGraph, START, END +from langgraph.types import interrupt, Command + +try: + import questionary + HAS_QUESTIONARY = True +except ImportError: + HAS_QUESTIONARY = False + + +# ─── Состояние графа ───────────────────────────────────────────────────────── + +class GameState(TypedDict): + topic: str + setup: str + choices: list[str] + selected_choice: str + ending: str + + +# ─── Инициализация 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.7, +) + + +# ─── Узел: генерация завязки + прерывание + концовка ───────────────────────── + +def story_node(state: GameState) -> dict: + """Один узел: генерирует завязку, прерывается на выбор, генерирует концовку.""" + + if state.get("ending"): + return {} + + if state.get("selected_choice") and state.get("setup"): + response = llm.invoke([ + HumanMessage(content=( + f"Завязка истории:\n{state['setup']}\n\n" + f"Выбор пользователя: {state['selected_choice']}\n\n" + f"Допиши короткую концовку (2–3 предложения) на русском языке." + )) + ]) + return {"ending": response.content} + + prompt = ( + f"Тема: {state['topic']}.\n\n" + "Придумай короткую завязку для интерактивной истории (2–3 предложения) " + "и ровно 3 варианта поступка героя.\n\n" + "Ответь СТРОГО в формате:\n" + "ЗАВЯЗКА: <текст завязки>\n" + "ВАРИАНТ 1: <текст первого варианта>\n" + "ВАРИАНТ 2: <текст второго варианта>\n" + "ВАРИАНТ 3: <текст третьего варианта>\n" + ) + + response = llm.invoke([HumanMessage(content=prompt)]) + text = response.content + + setup_match = re.search(r"ЗАВЯЗКА:\s*(.+?)(?=ВАРИАНТ 1:)", text, re.DOTALL) + setup = setup_match.group(1).strip() if setup_match else text + + choices = [] + for i in range(1, 4): + m = re.search(rf"ВАРИАНТ {i}:\s*(.+?)(?=ВАРИАНТ {i+1}:|$)", text, re.DOTALL) + if m: + choices.append(m.group(1).strip()) + + if not choices: + choices = ["Пойти налево", "Пойти направо", "Остаться на месте"] + + user_choice = interrupt({ + "type": "choice", + "question": f"\n{setup}\n\nЧто делаем?", + "choices": choices, + }) + + return { + "setup": setup, + "choices": choices, + "selected_choice": user_choice, + } + + +# ─── Сборка графа ──────────────────────────────────────────────────────────── + +def build_graph(): + graph = StateGraph(GameState) + graph.add_node("story", story_node) + graph.add_edge(START, "story") + graph.add_edge("story", END) + + memory = MemorySaver() + return graph.compile(checkpointer=memory) + + +# ─── Интерактивный запуск ──────────────────────────────────────────────────── + +def run_game(topic: str): + """Запустить интерактивную игру с заданной темой.""" + graph = build_graph() + config = {"configurable": {"thread_id": "game-1"}} + + print("=" * 60) + print(" 🎮 ИНТЕРАКТИВНАЯ ИСТОРИЯ") + print("=" * 60) + print(f" Тема: {topic}") + print("=" * 60) + + state = {"topic": topic, "setup": "", "choices": [], "selected_choice": "", "ending": ""} + stream = graph.stream(state, config) + + while True: + result = None + for chunk in stream: + result = chunk + + if "__interrupt__" in chunk: + intr = chunk["__interrupt__"][0] + payload = intr.value + + print(f"\n{payload['question']}\n") + for i, choice in enumerate(payload["choices"], 1): + print(f" {i}. {choice}") + + if HAS_QUESTIONARY: + selected = questionary.select( + "Выберите вариант:", + choices=payload["choices"] + ).ask() + else: + while True: + try: + idx = int(input("\nВведите номер варианта: ")) - 1 + if 0 <= idx < len(payload["choices"]): + selected = payload["choices"][idx] + break + except (ValueError, IndexError): + pass + print("Неверный ввод, попробуйте снова.") + + stream = graph.stream(Command(resume=selected), config) + break + else: + if result and "story" in result: + story_state = result["story"] + if story_state.get("ending"): + print(f"\n{'=' * 60}") + print(f" 📖 КОНЦОВКА") + print(f"{'=' * 60}") + print(f"\n{story_state['ending']}\n") + print(f"{'=' * 60}") + print(" 🏁 КОНЕЦ ИСТОРИИ") + print(f"{'=' * 60}") + break + elif result is None: + break + + +if __name__ == "__main__": + import sys + topic = sys.argv[1] if len(sys.argv) > 1 else "космический кот" + run_game(topic) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65a3085 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +langchain-core>=0.3.0 +langchain-openai>=0.2.0 +langgraph>=0.2.0 +questionary>=2.0.0 +python-dotenv>=1.0.0