feat: текстовая игра на основе LLM + interrupt

- GameState (TypedDict) с полями: topic, setup, choices, selected_choice, ending
- story_node: генерация завязки → interrupt() → генерация концовки
- Цикл обработки __interrupt__ с Command(resume=...)
- Поддержка questionary и fallback на input()
- requirements.txt, .env.example, README.md
This commit is contained in:
2026-05-08 17:17:41 +03:00
commit f4b9ee156c
4 changed files with 227 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
OPENAI_API_KEY=your-api-key-here
OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
+34
View File
@@ -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=...) → результат
+185
View File
@@ -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)
+5
View File
@@ -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