feat: solution for 'текстовая игра на основе llm + interrupt'
This commit is contained in:
@@ -1,68 +1,46 @@
|
|||||||
```
|
# Текстовая игра на основе LLM + interrupt
|
||||||
# Interactive Choose‑Your‑Own‑Adventure
|
|
||||||
|
|
||||||
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
|
Это простая консольная игра «Выбери свою историю», где генеративная модель (LLM) создаёт начало истории и варианты действий, а пользователь выбирает один из них. После выбора модель генерирует короткую концовку.
|
||||||
|
|
||||||
- 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 user’s selection and generates a short ending.
|
|
||||||
- Stores the entire story (topic, opening, choice, ending) in a JSON file.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- An OpenAI API key. Set it in your environment:
|
- OpenAI API ключ (или другая модель, настроенная через LangChain)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export OPENAI_API_KEY="sk-..."
|
git clone https://git.brojs.ru/kuzakhmetovartur/tekstovaya-igra-na-osnove-llm-interrupt
|
||||||
```
|
cd tekstovaya-igra-na-osnove-llm-interrupt
|
||||||
|
python -m venv venv
|
||||||
## Installation
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
|
||||||
```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
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
Создайте файл `.env` в корне проекта и добавьте ваш ключ:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/main.py
|
python src/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
You will be prompted to enter a topic for the adventure.
|
Игра начнётся в консоли. После появления вопросов выберите вариант, используя клавиши со стрелками, и нажмите `Enter`.
|
||||||
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
|
## Как работает
|
||||||
|
|
||||||
```
|
1. **intro_node** – генерирует завязку и три варианта действий.
|
||||||
interactive-adventure/
|
2. **interrupt** – приостанавливает выполнение и передаёт варианты пользователю.
|
||||||
├── src/
|
3. **ending_node** – после выбора пользователя генерирует короткую концовку.
|
||||||
│ └── main.py # Main application logic
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
└── README.md # Documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Customization
|
Граф реализован с помощью `langgraph`, а прерывание обрабатывается через `interrupt(...)`. Состояние сохраняется в `InMemorySaver`, поэтому можно приостановить и возобновить игру в любой момент.
|
||||||
|
|
||||||
- **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
|
||||||
|
|
||||||
MIT License
|
|
||||||
```
|
|
||||||
+3
-5
@@ -1,7 +1,5 @@
|
|||||||
```
|
langgraph==0.0.34
|
||||||
langgraph
|
|
||||||
langchain==1.2.10
|
langchain==1.2.10
|
||||||
langchain-openai==1.1.9
|
langchain-openai==1.1.9
|
||||||
questionary
|
questionary==1.10.0
|
||||||
python-dotenv
|
python-dotenv==1.0.1
|
||||||
```
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from typing import TypedDict, List, Dict, Any
|
||||||
|
import re
|
||||||
|
|
||||||
|
from langgraph.graph import StateGraph, START
|
||||||
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
|
||||||
|
class StoryState(TypedDict):
|
||||||
|
theme: str
|
||||||
|
intro: str
|
||||||
|
options: List[str]
|
||||||
|
choice: str
|
||||||
|
ending: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_output(text: str) -> (str, List[str]):
|
||||||
|
"""
|
||||||
|
Parses LLM output into intro text and list of options.
|
||||||
|
Expected format: first line intro, second line options separated by commas or numbered list.
|
||||||
|
"""
|
||||||
|
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
|
||||||
|
if not lines:
|
||||||
|
return "", []
|
||||||
|
intro = lines[0]
|
||||||
|
options_line = lines[1] if len(lines) > 1 else ""
|
||||||
|
options: List[str] = []
|
||||||
|
if options_line:
|
||||||
|
# Try comma separated
|
||||||
|
if "," in options_line:
|
||||||
|
options = [opt.strip() for opt in options_line.split(",") if opt.strip()]
|
||||||
|
else:
|
||||||
|
# Numbered list
|
||||||
|
for line in options_line.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# Remove leading number and dot
|
||||||
|
m = re.match(r"^\d+\.?\s*(.*)", line)
|
||||||
|
if m:
|
||||||
|
options.append(m.group(1).strip())
|
||||||
|
else:
|
||||||
|
options.append(line)
|
||||||
|
return intro, options
|
||||||
|
|
||||||
|
|
||||||
|
def intro_node(state: StoryState) -> Dict[str, Any]:
|
||||||
|
theme = state.get("theme", "Приключение в лесу")
|
||||||
|
llm = ChatOpenAI(temperature=0.7)
|
||||||
|
prompt = (
|
||||||
|
f"Тема: {theme}. "
|
||||||
|
"Придумай короткую завязку (2–3 предложения) и ровно 3 варианта поступка героя. "
|
||||||
|
"Ответь в формате: сначала текст завязки, затем строка с вариантами через запятую или пронумерованный список."
|
||||||
|
)
|
||||||
|
response = llm.invoke(prompt)
|
||||||
|
text = response.content
|
||||||
|
intro, options = parse_llm_output(text)
|
||||||
|
return {
|
||||||
|
"intro": intro,
|
||||||
|
"options": options,
|
||||||
|
"text": intro,
|
||||||
|
"__interrupt__": {
|
||||||
|
"type": "choice",
|
||||||
|
"question": f"{intro}\nЧто делаем?",
|
||||||
|
"options": options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ending_node(state: StoryState) -> Dict[str, Any]:
|
||||||
|
intro = state.get("intro", "")
|
||||||
|
choice = state.get("choice", "")
|
||||||
|
llm = ChatOpenAI(temperature=0.7)
|
||||||
|
prompt = (
|
||||||
|
f"Завязка: {intro}\n"
|
||||||
|
f"Выбор пользователя: {choice}\n"
|
||||||
|
"Допиши короткую концовку (2–3 предложения)."
|
||||||
|
)
|
||||||
|
response = llm.invoke(prompt)
|
||||||
|
ending = response.content.strip()
|
||||||
|
return {
|
||||||
|
"ending": ending,
|
||||||
|
"text": ending
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph(checkpoint: InMemorySaver) -> StateGraph[StoryState]:
|
||||||
|
graph = StateGraph(StoryState, checkpoint=checkpoint)
|
||||||
|
graph.add_node("intro", intro_node)
|
||||||
|
graph.add_node("ending", ending_node)
|
||||||
|
graph.set_entry_point("intro")
|
||||||
|
graph.add_edge("intro", "ending")
|
||||||
|
return graph
|
||||||
+48
-172
@@ -1,180 +1,56 @@
|
|||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Interactive choose-your-own-adventure using LangGraph, OpenAI LLM, and console interrupts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import TypedDict, List
|
import uuid
|
||||||
|
from dotenv import load_dotenv
|
||||||
import questionary
|
from graph import build_graph
|
||||||
from langgraph.graph import StateGraph
|
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
from langgraph.types import interrupt
|
import questionary
|
||||||
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.")
|
|
||||||
|
|
||||||
# -----------------------------
|
def main():
|
||||||
# State definition
|
load_dotenv()
|
||||||
# -----------------------------
|
|
||||||
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 (2–3 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 (2–3 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()
|
checkpoint = InMemorySaver()
|
||||||
compiled = graph.compile(checkpointer=checkpoint)
|
graph = build_graph(checkpoint)
|
||||||
|
thread_id = str(uuid.uuid4())
|
||||||
# Run the graph, handling interrupts manually
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
config = {"configurable": {"thread_id": "adventure_thread"}}
|
print("=== Начало истории ===")
|
||||||
while True:
|
# Start the graph and handle interrupt
|
||||||
result = compiled.run(state, config=config)
|
stream = graph.stream(config=config)
|
||||||
# If an interrupt occurs, handle it
|
for chunk in stream:
|
||||||
if "__interrupt__" in result:
|
if "__interrupt__" in chunk:
|
||||||
interrupt_data = result["__interrupt__"]
|
payload = chunk["__interrupt__"]
|
||||||
# Prompt user for choice
|
if payload.get("type") == "choice":
|
||||||
choice = questionary.select(
|
question = payload.get("question", "")
|
||||||
interrupt_data["question"],
|
options = payload.get("options", [])
|
||||||
choices=interrupt_data["options"]
|
if not options:
|
||||||
).ask()
|
print("Нет вариантов выбора.")
|
||||||
if not choice:
|
return
|
||||||
print("No choice selected. Exiting.")
|
choice = questionary.select(question, choices=options).ask()
|
||||||
|
if choice is None:
|
||||||
|
print("Выход из игры.")
|
||||||
|
return
|
||||||
|
# Resume graph with the chosen option
|
||||||
|
resume_config = {"configurable": {"thread_id": thread_id, "choice": choice}}
|
||||||
|
for resume_chunk in graph.stream(resume_config):
|
||||||
|
if "__interrupt__" in resume_chunk:
|
||||||
|
print("Unexpected interrupt during ending.")
|
||||||
|
return
|
||||||
|
text = resume_chunk.get("text", "")
|
||||||
|
if text:
|
||||||
|
print(text, end="")
|
||||||
break
|
break
|
||||||
state["choice"] = choice
|
else:
|
||||||
continue
|
text = chunk.get("text", "")
|
||||||
# If the graph has finished, print the ending
|
if text:
|
||||||
if "ending" in result:
|
print(text, end="")
|
||||||
print("\n" + result["ending"])
|
# After finishing, print the ending
|
||||||
break
|
final_state = checkpoint.get_state(thread_id)
|
||||||
|
ending = final_state.get("ending", "")
|
||||||
|
if ending:
|
||||||
|
print("\n\n=== Концовка ===")
|
||||||
|
print(ending)
|
||||||
|
else:
|
||||||
|
print("\nКонцовка не найдена.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user