commit f7e3f2a9faca0f16da50f9a240ecb95f45e67206 Author: kuzakhmetovartur Date: Wed Jun 24 14:46:12 2026 +0300 feat: solution for 'текстовая игра на основе llm + interrupt' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b16538b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ +build/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..489ed57 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +``` +# 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 + +- 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+ +- 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 +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cbb0173 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +``` +langgraph +langchain==1.2.10 +langchain-openai==1.1.9 +questionary +python-dotenv +``` \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f3598d1 --- /dev/null +++ b/src/main.py @@ -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 (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() + 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() +``` \ No newline at end of file