diff --git a/README.md b/README.md index 489ed57..a237132 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,46 @@ -``` -# Interactive Choose‑Your‑Own‑Adventure +# Текстовая игра на основе LLM + interrupt -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+ -- An OpenAI API key. Set it in your environment: +- OpenAI API ключ (или другая модель, настроенная через LangChain) + +## Установка ```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 +git clone https://git.brojs.ru/kuzakhmetovartur/tekstovaya-igra-na-osnove-llm-interrupt +cd tekstovaya-igra-na-osnove-llm-interrupt +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt ``` -## Usage +Создайте файл `.env` в корне проекта и добавьте ваш ключ: + +``` +OPENAI_API_KEY=sk-... +``` + +## Запуск ```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`. +Игра начнётся в консоли. После появления вопросов выберите вариант, используя клавиши со стрелками, и нажмите `Enter`. -## Project Structure +## Как работает -``` -interactive-adventure/ -├── src/ -│ └── main.py # Main application logic -├── requirements.txt # Python dependencies -└── README.md # Documentation -``` +1. **intro_node** – генерирует завязку и три варианта действий. +2. **interrupt** – приостанавливает выполнение и передаёт варианты пользователю. +3. **ending_node** – после выбора пользователя генерирует короткую концовку. -## 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 License -``` \ No newline at end of file +MIT \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cbb0173..ecd8012 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ -``` -langgraph +langgraph==0.0.34 langchain==1.2.10 langchain-openai==1.1.9 -questionary -python-dotenv -``` \ No newline at end of file +questionary==1.10.0 +python-dotenv==1.0.1 \ No newline at end of file diff --git a/src/graph.py b/src/graph.py new file mode 100644 index 0000000..cd8212c --- /dev/null +++ b/src/graph.py @@ -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 \ No newline at end of file diff --git a/src/main.py b/src/main.py index f3598d1..52c2ca3 100644 --- a/src/main.py +++ b/src/main.py @@ -1,180 +1,56 @@ -```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 +import uuid +from dotenv import load_dotenv +from graph import build_graph 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 +import questionary -# 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 +def main(): + load_dotenv() 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.") + graph = build_graph(checkpoint) + thread_id = str(uuid.uuid4()) + config = {"configurable": {"thread_id": thread_id}} + print("=== Начало истории ===") + # Start the graph and handle interrupt + stream = graph.stream(config=config) + for chunk in stream: + if "__interrupt__" in chunk: + payload = chunk["__interrupt__"] + if payload.get("type") == "choice": + question = payload.get("question", "") + options = payload.get("options", []) + if not options: + print("Нет вариантов выбора.") + return + 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 - state["choice"] = choice - continue - # If the graph has finished, print the ending - if "ending" in result: - print("\n" + result["ending"]) - break + else: + text = chunk.get("text", "") + if text: + print(text, end="") + # After finishing, print the ending + 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__": - main() -``` \ No newline at end of file + main() \ No newline at end of file