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:
@@ -0,0 +1,3 @@
|
|||||||
|
OPENAI_API_KEY=your-api-key-here
|
||||||
|
OPENAI_API_BASE=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
@@ -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=...) → результат
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user