feat: solution for 'текстовая игра на основе llm + interrupt'

This commit is contained in:
2026-06-24 14:46:12 +03:00
commit f7e3f2a9fa
4 changed files with 260 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
dist/
build/
*.log
+68
View File
@@ -0,0 +1,68 @@
```
# Interactive ChooseYourOwnAdventure
This project demonstrates a simple text adventure game that uses an LLM (OpenAI) to generate a story opening, presents the user with three choices, and then continues the story based on the chosen option.
The flow is built with **LangGraph** and uses **interrupts** to pause the graph and wait for user input in the console.
## Features
- Generates a short opening and three distinct choices using an LLM.
- Pauses execution with an interrupt and presents the choices via a console menu.
- Resumes the graph with the users selection and generates a short ending.
- Stores the entire story (topic, opening, choice, ending) in a JSON file.
## Prerequisites
- Python 3.10+
- An OpenAI API key. Set it in your environment:
```bash
export OPENAI_API_KEY="sk-..."
```
## Installation
```bash
# Clone the repository
git clone https://github.com/yourusername/interactive-adventure.git
cd interactive-adventure
# Create a virtual environment (optional but recommended)
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
## Usage
```bash
python src/main.py
```
You will be prompted to enter a topic for the adventure.
After the LLM generates the opening and choices, a menu will appear.
Select an option, and the story will continue with a short ending.
The final story will be printed to the console and saved to `adventure_story.json`.
## Project Structure
```
interactive-adventure/
├── src/
│ └── main.py # Main application logic
├── requirements.txt # Python dependencies
└── README.md # Documentation
```
## Customization
- **LLM Model**: Change the `model` parameter in `ChatOpenAI` inside `src/main.py` to use a different model.
- **Prompt Templates**: Modify the prompt strings in `generate_scene_and_choices` and `generate_ending` to tweak the story style.
- **Number of Choices**: Adjust the parsing logic if you want more or fewer options.
## License
MIT License
```
+7
View File
@@ -0,0 +1,7 @@
```
langgraph
langchain==1.2.10
langchain-openai==1.1.9
questionary
python-dotenv
```
+180
View File
@@ -0,0 +1,180 @@
```python
#!/usr/bin/env python3
"""
Interactive choose-your-own-adventure using LangGraph, OpenAI LLM, and console interrupts.
"""
import os
from typing import TypedDict, List
import questionary
from langgraph.graph import StateGraph
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt
from langchain_openai import ChatOpenAI
from langchain.prompts import PromptTemplate
from langchain.schema import HumanMessage
# Ensure OpenAI key is set
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
raise RuntimeError("Please set the OPENAI_API_KEY environment variable.")
# -----------------------------
# State definition
# -----------------------------
class AdventureState(TypedDict):
topic: str
opening: str
choices: List[str]
choice: str
ending: str
# -----------------------------
# LLM setup
# -----------------------------
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.7,
api_key=OPENAI_API_KEY,
)
# -----------------------------
# Node: Generate opening and choices
# -----------------------------
def generate_scene_and_choices(state: AdventureState) -> AdventureState:
topic = state["topic"]
prompt = PromptTemplate(
input_variables=["topic"],
template=(
"You are a creative storyteller. "
"Topic: {topic}. "
"Write a short opening (23 sentences) and exactly three distinct choices for the hero. "
"Respond with the opening first, then a numbered list of the choices. "
"Example format:\n"
"Opening: ...\n"
"1. ...\n"
"2. ...\n"
"3. ..."
),
)
messages = [HumanMessage(content=prompt.format(topic=topic))]
response = llm.invoke(messages)
text = response.content.strip()
# Parse opening and choices
lines = [line.strip() for line in text.splitlines() if line.strip()]
opening_line = lines[0]
if opening_line.lower().startswith("opening:"):
opening = opening_line[len("opening:"):].strip()
else:
opening = opening_line
choices = []
for line in lines[1:]:
if len(line) >= 2 and line[0].isdigit() and line[1] in {".", ":"}:
choice_text = line.split(" ", 1)[1].strip()
choices.append(choice_text)
else:
choices.append(line)
state["opening"] = opening
state["choices"] = choices
return state
# -----------------------------
# Node: Interrupt for user choice
# -----------------------------
def interrupt_choice(state: AdventureState) -> AdventureState:
question = f"{state['opening']}\n\nWhat do you do?"
payload = {
"type": "choice",
"question": question,
"options": state["choices"],
}
# The interrupt will pause the graph and return a dict with "__interrupt__" key
return interrupt(payload)
# -----------------------------
# Node: Generate ending
# -----------------------------
def generate_ending(state: AdventureState) -> AdventureState:
prompt = PromptTemplate(
input_variables=["opening", "choice"],
template=(
"You are a creative storyteller. "
"Opening: {opening}\n"
"The hero chose: {choice}\n"
"Write a short ending (23 sentences) that concludes the story."
),
)
messages = [HumanMessage(content=prompt.format(opening=state["opening"], choice=state["choice"]))]
response = llm.invoke(messages)
state["ending"] = response.content.strip()
return state
# -----------------------------
# Build the graph
# -----------------------------
def build_graph() -> StateGraph[AdventureState]:
graph = StateGraph(AdventureState)
graph.add_node("generate_scene_and_choices", generate_scene_and_choices)
graph.add_node("interrupt_choice", interrupt_choice)
graph.add_node("generate_ending", generate_ending)
graph.set_entry_point("generate_scene_and_choices")
graph.add_edge("generate_scene_and_choices", "interrupt_choice")
graph.add_edge("interrupt_choice", "generate_ending")
graph.add_edge("generate_ending", "__end__")
return graph
# -----------------------------
# Main execution
# -----------------------------
def main() -> None:
topic = questionary.text("Enter a topic for your adventure:").ask()
if not topic:
print("No topic provided. Exiting.")
return
# Initial state
state: AdventureState = {
"topic": topic,
"opening": "",
"choices": [],
"choice": "",
"ending": "",
}
graph = build_graph()
# Use an in-memory checkpoint to allow resuming after interrupt
checkpoint = InMemorySaver()
compiled = graph.compile(checkpointer=checkpoint)
# Run the graph, handling interrupts manually
config = {"configurable": {"thread_id": "adventure_thread"}}
while True:
result = compiled.run(state, config=config)
# If an interrupt occurs, handle it
if "__interrupt__" in result:
interrupt_data = result["__interrupt__"]
# Prompt user for choice
choice = questionary.select(
interrupt_data["question"],
choices=interrupt_data["options"]
).ask()
if not choice:
print("No choice selected. Exiting.")
break
state["choice"] = choice
continue
# If the graph has finished, print the ending
if "ending" in result:
print("\n" + result["ending"])
break
if __name__ == "__main__":
main()
```