add main.py
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Текстовая игра «выбери свою историю»: LLM + interrupt (LangGraph)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import TypedDict
|
||||
|
||||
import questionary
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.constants import START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.types import Command, interrupt
|
||||
|
||||
load_dotenv()
|
||||
|
||||
llm = ChatOpenAI(
|
||||
model=os.getenv("OPENAI_MODEL", "openai/gpt-oss-20b:free"),
|
||||
base_url=os.getenv("OPENAI_BASE_URL", "https://openrouter.ai/api/v1"),
|
||||
api_key=os.getenv("OPENAI_API_KEY", "fake"),
|
||||
temperature=0.8,
|
||||
)
|
||||
|
||||
DEFAULT_THEME = "космический кот"
|
||||
|
||||
|
||||
class StoryState(TypedDict):
|
||||
theme: str
|
||||
scene_text: str
|
||||
choices: list[str]
|
||||
user_choice: str
|
||||
ending: str
|
||||
|
||||
|
||||
def _call_llm(system: str, user: str) -> str:
|
||||
response = llm.invoke([SystemMessage(content=system), HumanMessage(content=user)])
|
||||
return str(response.content).strip()
|
||||
|
||||
|
||||
def _parse_setup(raw: str) -> tuple[str, list[str]]:
|
||||
"""Разбор ответа LLM: завязка + три варианта."""
|
||||
setup = raw
|
||||
choices: list[str] = []
|
||||
|
||||
if "ВАРИАНТЫ:" in raw:
|
||||
setup, variants_block = raw.split("ВАРИАНТЫ:", maxsplit=1)
|
||||
setup = setup.replace("ЗАВЯЗКА:", "").strip()
|
||||
for line in variants_block.strip().splitlines():
|
||||
line = re.sub(r"^\d+[\).\]]\s*", "", line.strip())
|
||||
if line:
|
||||
choices.append(line)
|
||||
else:
|
||||
numbered = re.findall(r"(?:^|\n)\s*\d+[\).\]]\s*(.+)", raw)
|
||||
if numbered:
|
||||
choices = [c.strip() for c in numbered[:3]]
|
||||
setup = re.split(r"\n\s*1[\).\]]", raw, maxsplit=1)[0].strip()
|
||||
|
||||
if len(choices) < 3:
|
||||
for line in raw.splitlines():
|
||||
if line.strip().startswith(("-", "•")):
|
||||
choices.append(line.strip().lstrip("-• ").strip())
|
||||
choices = choices[:3]
|
||||
|
||||
while len(choices) < 3:
|
||||
choices.append(f"Вариант {len(choices) + 1}")
|
||||
|
||||
return setup.strip(), choices[:3]
|
||||
|
||||
|
||||
def story_node(state: StoryState) -> StoryState:
|
||||
"""Генерация завязки → прерывание (выбор) → концовка от LLM."""
|
||||
theme = state.get("theme") or DEFAULT_THEME
|
||||
|
||||
setup_raw = _call_llm(
|
||||
system=(
|
||||
"Ты автор интерактивных текстовых игр. Отвечай строго в формате:\n"
|
||||
"ЗАВЯЗКА:\n<2-3 предложения>\n\n"
|
||||
"ВАРИАНТЫ:\n1) <вариант 1>\n2) <вариант 2>\n3) <вариант 3>"
|
||||
),
|
||||
user=f"Тема: {theme}. Придумай короткую завязку и ровно 3 варианта поступка героя.",
|
||||
)
|
||||
scene_text, choices = _parse_setup(setup_raw)
|
||||
|
||||
print(f"\n[LLM] {scene_text}\n")
|
||||
|
||||
payload = interrupt(
|
||||
{
|
||||
"type": "choice",
|
||||
"question": f"{scene_text}\n\nЧто делаем?",
|
||||
"allow_responds": choices,
|
||||
"theme": theme,
|
||||
"scene_text": scene_text,
|
||||
"choices": choices,
|
||||
}
|
||||
)
|
||||
|
||||
user_choice = payload.get("answer", choices[0])
|
||||
print(f"\n> {user_choice}\n")
|
||||
|
||||
ending = _call_llm(
|
||||
system="Ты автор интерактивных историй. Допиши короткую концовку (2–3 предложения).",
|
||||
user=(
|
||||
f"Тема: {theme}\n"
|
||||
f"Завязка: {scene_text}\n"
|
||||
f"Выбор пользователя: {user_choice}\n"
|
||||
"Допиши короткую концовку с учётом выбора."
|
||||
),
|
||||
)
|
||||
|
||||
print(f"[LLM] {ending}\n")
|
||||
|
||||
return {
|
||||
"theme": theme,
|
||||
"scene_text": scene_text,
|
||||
"choices": choices,
|
||||
"user_choice": user_choice,
|
||||
"ending": ending,
|
||||
}
|
||||
|
||||
|
||||
def _ask_user(payload: dict) -> dict:
|
||||
question = payload.get("question", "Что делаем?")
|
||||
options = payload.get("allow_responds") or ["Вариант 1", "Вариант 2", "Вариант 3"]
|
||||
choice = questionary.select(question, choices=options).ask()
|
||||
if choice is None:
|
||||
choice = options[0]
|
||||
updated = dict(payload)
|
||||
updated["answer"] = choice
|
||||
return updated
|
||||
|
||||
|
||||
def play_story(theme: str) -> StoryState:
|
||||
builder = StateGraph(StoryState)
|
||||
builder.add_node("story", story_node)
|
||||
builder.add_edge(START, "story")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
config = {"configurable": {"thread_id": f"story-{theme}"}}
|
||||
initial: StoryState = {
|
||||
"theme": theme,
|
||||
"scene_text": "",
|
||||
"choices": [],
|
||||
"user_choice": "",
|
||||
"ending": "",
|
||||
}
|
||||
|
||||
final: StoryState = initial
|
||||
stream = graph.stream(initial, config)
|
||||
|
||||
for chunk in stream:
|
||||
if "__interrupt__" in chunk:
|
||||
payload = dict(chunk["__interrupt__"][0].value)
|
||||
resumed = _ask_user(payload)
|
||||
resume_stream = graph.stream(Command(resume=resumed), config)
|
||||
for resume_chunk in resume_stream:
|
||||
if "story" in resume_chunk:
|
||||
final = resume_chunk["story"]
|
||||
elif "story" in chunk:
|
||||
final = chunk["story"]
|
||||
|
||||
return final
|
||||
|
||||
|
||||
def main() -> None:
|
||||
theme = " ".join(sys.argv[1:]).strip() or DEFAULT_THEME
|
||||
print(f"Тема: {theme}\n")
|
||||
result = play_story(theme)
|
||||
|
||||
print("=== Итоговая история ===")
|
||||
print(f"Тема: {result['theme']}")
|
||||
print(f"Завязка: {result['scene_text']}")
|
||||
print(f"Выбор: {result['user_choice']}")
|
||||
print(f"Концовка: {result['ending']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user