kikikita commited on
Commit
d8e2b36
·
1 Parent(s): d9ec72e

feat: implement LLM game graph and user state management

Browse files
src/agent/game_generator.py DELETED
File without changes
src/agent/llm.py CHANGED
@@ -1,43 +1,50 @@
1
- from langchain_google_genai import ChatGoogleGenerativeAI
 
2
  import logging
 
 
3
  from config import settings
4
 
5
  logger = logging.getLogger(__name__)
6
 
7
- _google_api_keys_list = []
8
- _current_google_key_idx = 0
 
9
 
10
 
11
- def create_llm(temperature: float = settings.temperature, top_p: float = settings.top_p):
12
- global _google_api_keys_list, _current_google_key_idx
 
13
 
14
- if not _google_api_keys_list:
15
- api_keys_str = settings.gemini_api_key.get_secret_value()
16
- if api_keys_str:
17
- _google_api_keys_list = [key.strip() for key in api_keys_str.split(',') if key.strip()]
18
-
19
- if not _google_api_keys_list:
20
- logger.error("Google API keys are not configured or are empty in settings.")
21
- raise ValueError("Google API keys are not configured or are invalid for round-robin.")
22
 
23
- if not _google_api_keys_list: # Safeguard, though previous block should handle it.
24
- logger.error("No Google API keys available for round-robin.")
25
- raise ValueError("No Google API keys available for round-robin.")
 
26
 
27
- key_index_to_use = _current_google_key_idx
28
- selected_api_key = _google_api_keys_list[key_index_to_use]
29
-
30
- _current_google_key_idx = (key_index_to_use + 1) % len(_google_api_keys_list)
31
-
32
- logger.debug(f"Using Google API key at index {key_index_to_use} (ending with ...{selected_api_key[-4:] if len(selected_api_key) > 4 else selected_api_key}) for round-robin.")
33
 
 
 
 
 
 
34
  return ChatGoogleGenerativeAI(
35
- model="gemini-2.5-flash-preview-05-20",
36
- google_api_key=selected_api_key,
37
  temperature=temperature,
38
  top_p=top_p,
39
- thinking_budget=1024
40
  )
41
 
42
- def create_precise_llm():
 
 
43
  return create_llm(temperature=0, top_p=1)
 
1
+ """Utility functions for working with the language model."""
2
+
3
  import logging
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+
6
  from config import settings
7
 
8
  logger = logging.getLogger(__name__)
9
 
10
+ _API_KEYS: list[str] = []
11
+ _current_key_idx = 0
12
+ MODEL_NAME = "gemini-2.5-flash-preview-05-20"
13
 
14
 
15
+ def _get_api_key() -> str:
16
+ """Return an API key using round-robin selection."""
17
+ global _API_KEYS, _current_key_idx
18
 
19
+ if not _API_KEYS:
20
+ keys_str = settings.gemini_api_key.get_secret_value()
21
+ if keys_str:
22
+ _API_KEYS = [k.strip() for k in keys_str.split(",") if k.strip()]
23
+ if not _API_KEYS:
24
+ msg = "Google API keys are not configured or invalid"
25
+ logger.error(msg)
26
+ raise ValueError(msg)
27
 
28
+ key = _API_KEYS[_current_key_idx]
29
+ _current_key_idx = (_current_key_idx + 1) % len(_API_KEYS)
30
+ logger.debug("Using Google API key index %s", _current_key_idx)
31
+ return key
32
 
 
 
 
 
 
 
33
 
34
+ def create_llm(
35
+ temperature: float = settings.temperature,
36
+ top_p: float = settings.top_p,
37
+ ) -> ChatGoogleGenerativeAI:
38
+ """Create a standard LLM instance."""
39
  return ChatGoogleGenerativeAI(
40
+ model=MODEL_NAME,
41
+ google_api_key=_get_api_key(),
42
  temperature=temperature,
43
  top_p=top_p,
44
+ thinking_budget=1024,
45
  )
46
 
47
+
48
+ def create_precise_llm() -> ChatGoogleGenerativeAI:
49
+ """Return an LLM tuned for deterministic output."""
50
  return create_llm(temperature=0, top_p=1)
src/agent/llm_agent.py CHANGED
@@ -1,38 +1,61 @@
1
- from agent.llm import create_llm
2
- from pydantic import BaseModel, Field
3
- from typing import Optional, List
4
  import logging
 
 
 
 
 
5
 
6
  logger = logging.getLogger(__name__)
7
 
 
8
  class ChangeScene(BaseModel):
9
- change_scene: bool = Field(description="Whether the scene should be changed")
 
 
10
  scene_description: Optional[str] = None
11
-
 
12
  class ChangeMusic(BaseModel):
13
- change_music: bool = Field(description="Whether the music should be changed")
 
 
14
  music_description: Optional[str] = None
15
-
 
16
  class PlayerOption(BaseModel):
17
- option_description: str = Field(description="The description of the option, Examples: [Change location] Go to the forest; [Say] Hello!")
18
-
 
 
 
 
 
 
 
 
19
  class LLMOutput(BaseModel):
 
 
20
  change_scene: ChangeScene
21
  change_music: ChangeMusic
22
- game_message: str = Field(description="The message to the player, Example: You entered the forest, and you see unknown scary creatures. What do you do?")
23
- player_options: List[PlayerOption] = Field(description="The list of up to 3 options for the player to choose from.")
24
-
25
- llm = create_llm().with_structured_output(LLMOutput)
26
-
27
- async def process_user_input(input: str) -> LLMOutput:
28
- """
29
- Process user input and update the state.
30
- """
31
- logger.info(f"User's choice: {input}")
32
-
33
- response: LLMOutput = await llm.ainvoke(input)
34
-
35
- logger.info(f"LLM response: {response}")
36
-
 
 
 
37
  return response
38
-
 
1
+ """Simple interface for querying the LLM directly."""
2
+
 
3
  import logging
4
+ from typing import List, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from agent.llm import create_llm
9
 
10
  logger = logging.getLogger(__name__)
11
 
12
+
13
  class ChangeScene(BaseModel):
14
+ """Information about a scene change."""
15
+
16
+ change_scene: bool = Field(description="Whether the scene should change")
17
  scene_description: Optional[str] = None
18
+
19
+
20
  class ChangeMusic(BaseModel):
21
+ """Information about a music change."""
22
+
23
+ change_music: bool = Field(description="Whether the music should change")
24
  music_description: Optional[str] = None
25
+
26
+
27
  class PlayerOption(BaseModel):
28
+ """Single option for the player."""
29
+
30
+ option_description: str = Field(
31
+ description=(
32
+ "Description of the option, e.g. '[Say] Hello!' "
33
+ "or 'Go to the forest'"
34
+ )
35
+ )
36
+
37
+
38
  class LLMOutput(BaseModel):
39
+ """Expected structure returned by the LLM."""
40
+
41
  change_scene: ChangeScene
42
  change_music: ChangeMusic
43
+ game_message: str = Field(
44
+ description=(
45
+ "Message shown to the player, e.g. 'You entered the forest...'"
46
+ )
47
+ )
48
+ player_options: List[PlayerOption] = Field(
49
+ description="Up to three options for the player"
50
+ )
51
+
52
+
53
+ _llm = create_llm().with_structured_output(LLMOutput)
54
+
55
+
56
+ async def process_user_input(text: str) -> LLMOutput:
57
+ """Send user text to the LLM and return the parsed response."""
58
+ logger.info("User choice: %s", text)
59
+ response: LLMOutput = await _llm.ainvoke(text)
60
+ logger.info("LLM response: %s", response)
61
  return response
 
src/agent/llm_graph.py CHANGED
@@ -1,14 +1,137 @@
1
- from agent.tools import available_tools
2
- from agent.llm import create_llm
3
- from langgraph.graph import MessagesState
4
- class CustomState(MessagesState):
5
- """Расширенное состояние графа."""
6
 
 
 
 
7
 
 
8
 
9
- llm = create_llm().bind_tools(available_tools)
 
 
 
 
 
 
 
10
 
 
11
 
12
 
 
 
 
13
 
 
 
 
 
 
 
 
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph setup for the interactive fiction agent."""
 
 
 
 
2
 
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional
6
 
7
+ from langgraph.graph import END, StateGraph
8
 
9
+ from agent.tools import (
10
+ check_ending,
11
+ generate_scene,
12
+ generate_scene_image,
13
+ generate_story_frame,
14
+ update_state_with_choice,
15
+ )
16
+ from agent.state import get_user_state
17
 
18
+ logger = logging.getLogger(__name__)
19
 
20
 
21
+ @dataclass
22
+ class GraphState:
23
+ """Mutable state passed between graph nodes."""
24
 
25
+ user_hash: Optional[str] = None
26
+ step: Optional[str] = None
27
+ setting: Optional[str] = None
28
+ character: Optional[Dict[str, Any]] = None
29
+ genre: Optional[str] = None
30
+ choice_text: Optional[str] = None
31
+ scene: Optional[Dict[str, Any]] = None
32
+ ending: Optional[Dict[str, Any]] = None
33
 
34
+
35
+ async def node_entry(state: GraphState) -> GraphState:
36
+ logger.debug("[Graph] entry state: %s", state)
37
+ return state
38
+
39
+
40
+ def route_step(state: GraphState) -> str:
41
+ if state.step == "start":
42
+ return "init_game"
43
+ if state.step == "choose":
44
+ return "player_step"
45
+ logger.warning("route_step received unknown step '%s'", state.step)
46
+ return "init_game"
47
+
48
+
49
+ async def node_init_game(state: GraphState) -> GraphState:
50
+ logger.debug("[Graph] node_init_game state: %s", state)
51
+ await generate_story_frame.ainvoke(
52
+ {
53
+ "user_hash": state.user_hash,
54
+ "setting": state.setting,
55
+ "character": state.character,
56
+ "genre": state.genre,
57
+ }
58
+ )
59
+ first_scene = await generate_scene.ainvoke(
60
+ {"user_hash": state.user_hash, "last_choice": "start"}
61
+ )
62
+ await generate_scene_image.ainvoke(
63
+ {
64
+ "user_hash": state.user_hash,
65
+ "scene_id": first_scene["scene_id"],
66
+ "prompt": first_scene["description"],
67
+ }
68
+ )
69
+ state.scene = first_scene
70
+ return state
71
+
72
+
73
+ async def node_player_step(state: GraphState) -> GraphState:
74
+ logger.debug("[Graph] node_player_step state: %s", state)
75
+ user_state = get_user_state(state.user_hash)
76
+ scene_id = user_state.current_scene_id
77
+ if state.choice_text:
78
+ await update_state_with_choice.ainvoke(
79
+ {
80
+ "user_hash": state.user_hash,
81
+ "scene_id": scene_id,
82
+ "choice_text": state.choice_text,
83
+ }
84
+ )
85
+ ending = await check_ending.ainvoke({"user_hash": state.user_hash})
86
+ state.ending = ending
87
+ if not ending.get("ending_reached", False):
88
+ next_scene = await generate_scene.ainvoke(
89
+ {
90
+ "user_hash": state.user_hash,
91
+ "last_choice": state.choice_text,
92
+ }
93
+ )
94
+ await generate_scene_image.ainvoke(
95
+ {
96
+ "user_hash": state.user_hash,
97
+ "scene_id": next_scene["scene_id"],
98
+ "prompt": next_scene["description"],
99
+ }
100
+ )
101
+ state.scene = next_scene
102
+ return state
103
+
104
+
105
+ def route_ending(state: GraphState) -> str:
106
+ return "game_over" if state.ending.get("ending_reached") else "continue"
107
+
108
+
109
+ async def node_game_over(state: GraphState) -> GraphState:
110
+ logger.info("[Graph] Game over for user %s", state.user_hash)
111
+ return state
112
+
113
+
114
+ def build_llm_game_graph() -> StateGraph:
115
+ graph = StateGraph(GraphState)
116
+ graph.add_node("entry", node_entry)
117
+ graph.add_node("init_game", node_init_game)
118
+ graph.add_node("player_step", node_player_step)
119
+ graph.add_node("game_over", node_game_over)
120
+
121
+ graph.set_entry_point("entry")
122
+ graph.add_conditional_edges(
123
+ "entry",
124
+ route_step,
125
+ {"init_game": "init_game", "player_step": "player_step"},
126
+ )
127
+ graph.add_edge("init_game", END)
128
+ graph.add_conditional_edges(
129
+ "player_step",
130
+ route_ending,
131
+ {"game_over": "game_over", "continue": END},
132
+ )
133
+ graph.add_edge("game_over", END)
134
+ return graph.compile()
135
+
136
+
137
+ llm_game_graph = build_llm_game_graph()
src/agent/models.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models representing game state and LLM outputs."""
2
+
3
+ from typing import Dict, List, Optional, Set
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class Milestone(BaseModel):
9
+ """Milestone that can be achieved during the story."""
10
+
11
+ id: str
12
+ description: str
13
+
14
+
15
+ class Ending(BaseModel):
16
+ """Possible game ending."""
17
+
18
+ id: str
19
+ type: str # "good" or "bad"
20
+ condition: str
21
+ description: Optional[str] = None
22
+
23
+
24
+ class StoryFrame(BaseModel):
25
+ """Overall plot information generated by the LLM."""
26
+
27
+ lore: str
28
+ goal: str
29
+ milestones: List[Milestone]
30
+ endings: List[Ending]
31
+ setting: str
32
+ character: Dict[str, str]
33
+ genre: str
34
+
35
+
36
+ class StoryFrameLLM(BaseModel):
37
+ """Structure returned by the LLM for story frame generation."""
38
+
39
+ lore: str
40
+ goal: str
41
+ milestones: List[Milestone]
42
+ endings: List[Ending]
43
+
44
+
45
+ class SceneChoice(BaseModel):
46
+ """User choice leading to another scene."""
47
+
48
+ text: str
49
+ next_scene_short_desc: str
50
+
51
+
52
+ class PlayerOption(BaseModel):
53
+ """Option presented to the player in a scene."""
54
+
55
+ option_description: str = Field(
56
+ description=(
57
+ "Description of the option, e.g. '[Say] Hello!' or "
58
+ "'Go to the forest'"
59
+ )
60
+ )
61
+
62
+
63
+ class Scene(BaseModel):
64
+ """Game scene with choices and optional assets."""
65
+
66
+ scene_id: str
67
+ description: str
68
+ choices: List[SceneChoice]
69
+ image: Optional[str] = None
70
+ music: Optional[str] = None
71
+
72
+
73
+ class SceneLLM(BaseModel):
74
+ """Structure expected from the LLM when generating a scene."""
75
+
76
+ description: str
77
+ choices: List[SceneChoice]
78
+
79
+
80
+ class EndingCheckResult(BaseModel):
81
+ """Result returned from the LLM when checking for an ending."""
82
+
83
+ ending_reached: bool = Field(default=False)
84
+ ending: Optional[Ending] = None
85
+
86
+
87
+ class UserChoice(BaseModel):
88
+ """Single player choice recorded in the history."""
89
+
90
+ scene_id: str
91
+ choice_text: str
92
+ timestamp: Optional[str] = None
93
+
94
+
95
+ class UserState(BaseModel):
96
+ """State stored for each user."""
97
+
98
+ story_frame: Optional[StoryFrame] = None
99
+ current_scene_id: Optional[str] = None
100
+ scenes: Dict[str, Scene] = Field(default_factory=dict)
101
+ milestones_achieved: Set[str] = Field(default_factory=set)
102
+ user_choices: List[UserChoice] = Field(default_factory=list)
103
+ ending: Optional[Ending] = None
104
+ assets: Dict[str, str] = Field(default_factory=dict)
src/agent/prompts.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ STORY_FRAME_PROMPT = """
2
+ You are a narrative game designer. Use the player data below to
3
+ create a story frame for an interactive adventure.
4
+ Setting: {setting}
5
+ Character: {character}
6
+ Genre: {genre}
7
+ Return ONLY a JSON object with:
8
+ - lore: brief world description
9
+ - goal: main player objective
10
+ - milestones: 2-4 key events (id, description)
11
+ - endings: good/bad endings (id, type, condition, description)
12
+ Translate the lore, goal, milestones and endings into
13
+ a langueage of setting language.
14
+ """
15
+
16
+ SCENE_PROMPT = """
17
+ Using the provided lore and history, generate the next scene.
18
+ Lore: {lore}
19
+ Goal: {goal}
20
+ Milestones: {milestones}
21
+ Endings: {endings}
22
+ History: {history}
23
+ Last choice: {last_choice}
24
+ Respond ONLY with JSON containing:
25
+ - description: short summary of the scene
26
+ - choices: exactly two dicts {{"text": ..., "next_scene_short_desc": ...}}
27
+ Translate the scene description and choices into a language of lore language.
28
+ """
29
+
30
+ ENDING_CHECK_PROMPT = """
31
+ History: {history}
32
+ Endings: {endings}
33
+ Check if any ending conditions are met.
34
+ If none are met return ending_reached: false.
35
+ If an ending is reached return ending_reached: true and provide the
36
+ ending object (id, type, description).
37
+ Respond ONLY with JSON.
38
+ """
src/agent/runner.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point for executing a graph step."""
2
+
3
+ import logging
4
+ from dataclasses import asdict
5
+ from typing import Dict, Optional
6
+
7
+ from agent.llm_graph import GraphState, llm_game_graph
8
+ from agent.models import UserState
9
+ from agent.state import get_user_state
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def process_step(
15
+ user_hash: str,
16
+ step: str,
17
+ setting: Optional[str] = None,
18
+ character: Optional[dict] = None,
19
+ genre: Optional[str] = None,
20
+ choice_text: Optional[str] = None,
21
+ ) -> Dict:
22
+ """Run one interaction step through the graph."""
23
+ logger.info("[Runner] Step %s for user %s", step, user_hash)
24
+
25
+ graph_state = GraphState(user_hash=user_hash, step=step)
26
+ if step == "start":
27
+ assert setting and character and genre, "Missing start parameters"
28
+ graph_state.setting = setting
29
+ graph_state.character = character
30
+ graph_state.genre = genre
31
+ elif step == "choose":
32
+ assert choice_text, "choice_text is required"
33
+ graph_state.choice_text = choice_text
34
+
35
+ final_state = await llm_game_graph.ainvoke(asdict(graph_state))
36
+
37
+ user_state: UserState = get_user_state(user_hash)
38
+ response: Dict = {}
39
+
40
+ ending = final_state.get("ending")
41
+ if ending and ending.get("ending_reached"):
42
+ ending_info = ending["ending"]
43
+ if (
44
+ ("description" not in ending_info
45
+ or not ending_info["description"])
46
+ and user_state.story_frame
47
+ ):
48
+ for e in user_state.story_frame.endings:
49
+ if e.id == ending_info.get("id"):
50
+ ending_info["description"] = e.description
51
+ break
52
+ response["ending"] = ending_info
53
+ response["game_over"] = True
54
+ else:
55
+ if (
56
+ user_state.current_scene_id
57
+ and user_state.current_scene_id in user_state.scenes
58
+ ):
59
+ current_scene = user_state.scenes[
60
+ user_state.current_scene_id
61
+ ].dict()
62
+ else:
63
+ current_scene = final_state.get("scene")
64
+ response["scene"] = current_scene
65
+ response["game_over"] = False
66
+
67
+ return response
src/agent/state.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simple in-memory user state storage."""
2
+
3
+ from typing import Dict
4
+
5
+ from agent.models import UserState
6
+
7
+ _USER_STATE: Dict[str, UserState] = {}
8
+
9
+
10
+ def get_user_state(user_hash: str) -> UserState:
11
+ """Return user state for the given id, creating it if necessary."""
12
+ if user_hash not in _USER_STATE:
13
+ _USER_STATE[user_hash] = UserState()
14
+ return _USER_STATE[user_hash]
15
+
16
+
17
+ def set_user_state(user_hash: str, state: UserState) -> None:
18
+ """Persist updated user state."""
19
+ _USER_STATE[user_hash] = state
20
+
21
+
22
+ def reset_user_state(user_hash: str) -> None:
23
+ """Reset stored state for a user."""
24
+ _USER_STATE[user_hash] = UserState()
src/agent/tools.py CHANGED
@@ -1,40 +1,169 @@
 
 
 
 
 
 
1
  from langchain_core.tools import tool
2
- from typing import Annotated, Any, Dict, List
 
 
 
 
 
 
 
 
 
 
 
 
3
  from images.image_generator import generate_image
4
- from langgraph.prebuilt import InjectedState
5
- import logging
6
 
7
  logger = logging.getLogger(__name__)
8
 
 
9
  def _err(msg: str) -> str:
10
  logger.error(msg)
11
- return f"{{ 'error': '{msg}' }}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- def _success(msg: str) -> str:
14
- logger.info(msg)
15
- return f"{{ 'success': '{msg}' }}"
16
 
17
  @tool
18
  async def generate_scene_image(
19
- prompt: Annotated[
20
- str,
21
- "The prompt to generate an image from"
22
- ],
23
- state: InjectedState,
24
- ) -> Annotated[
25
- str,
26
- "The path to the generated image"
27
- ]:
28
- """
29
- Generate an image from a prompt and set current scene image.
30
- """
31
  try:
32
- image_path, img_description = generate_image(prompt)
33
- state["current_scene"]["image"] = image_path
34
- state["current_scene"]["image_description"] = img_description
35
- return _success(f"Image generated and set as current scene image: {img_description}")
36
- except Exception as e:
37
- return _err(str(e))
38
-
39
-
40
- available_tools = [generate_scene_image]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM tools used by the game graph."""
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Annotated, Dict
6
+
7
  from langchain_core.tools import tool
8
+
9
+ from agent.llm import create_llm
10
+ from agent.models import (
11
+ EndingCheckResult,
12
+ Scene,
13
+ SceneChoice,
14
+ SceneLLM,
15
+ StoryFrame,
16
+ StoryFrameLLM,
17
+ UserChoice,
18
+ )
19
+ from agent.prompts import ENDING_CHECK_PROMPT, SCENE_PROMPT, STORY_FRAME_PROMPT
20
+ from agent.state import get_user_state, set_user_state
21
  from images.image_generator import generate_image
 
 
22
 
23
  logger = logging.getLogger(__name__)
24
 
25
+
26
  def _err(msg: str) -> str:
27
  logger.error(msg)
28
+ return f"{{'error': '{msg}'}}"
29
+
30
+
31
+ @tool
32
+ async def generate_story_frame(
33
+ user_hash: Annotated[str, "User session ID"],
34
+ setting: Annotated[str, "Game world setting"],
35
+ character: Annotated[Dict[str, str], "Character info"],
36
+ genre: Annotated[str, "Genre"],
37
+ ) -> Annotated[Dict, "Generated story frame"]:
38
+ """Create the initial story frame and store it in user state."""
39
+ llm = create_llm().with_structured_output(StoryFrameLLM)
40
+ prompt = STORY_FRAME_PROMPT.format(
41
+ setting=setting,
42
+ character=character,
43
+ genre=genre,
44
+ )
45
+ resp: StoryFrameLLM = await llm.ainvoke(prompt)
46
+ story_frame = StoryFrame(
47
+ lore=resp.lore,
48
+ goal=resp.goal,
49
+ milestones=resp.milestones,
50
+ endings=resp.endings,
51
+ setting=setting,
52
+ character=character,
53
+ genre=genre,
54
+ )
55
+ state = get_user_state(user_hash)
56
+ state.story_frame = story_frame
57
+ set_user_state(user_hash, state)
58
+ return story_frame.dict()
59
+
60
+
61
+ @tool
62
+ async def generate_scene(
63
+ user_hash: Annotated[str, "User session ID"],
64
+ last_choice: Annotated[str, "Last user choice"],
65
+ ) -> Annotated[Dict, "Generated scene"]:
66
+ """Generate a new scene based on the current user state."""
67
+ state = get_user_state(user_hash)
68
+ if not state.story_frame:
69
+ return _err("Story frame not initialized")
70
+ llm = create_llm().with_structured_output(SceneLLM)
71
+ prompt = SCENE_PROMPT.format(
72
+ lore=state.story_frame.lore,
73
+ goal=state.story_frame.goal,
74
+ milestones=','.join(m.id for m in state.story_frame.milestones),
75
+ endings=','.join(e.id for e in state.story_frame.endings),
76
+ history='; '.join(
77
+ f"{c.scene_id}:{c.choice_text}" for c in state.user_choices
78
+ ),
79
+ last_choice=last_choice,
80
+ )
81
+ resp: SceneLLM = await llm.ainvoke(prompt)
82
+ if len(resp.choices) < 2:
83
+ resp = await llm.ainvoke(
84
+ prompt + "\nThe scene must contain exactly two choices."
85
+ )
86
+ scene_id = str(uuid.uuid4())
87
+ choices = [
88
+ SceneChoice(**ch.model_dump())
89
+ if hasattr(ch, "model_dump")
90
+ else SceneChoice(**ch)
91
+ for ch in resp.choices[:2]
92
+ ]
93
+ scene = Scene(
94
+ scene_id=scene_id,
95
+ description=resp.description,
96
+ choices=choices,
97
+ image=None,
98
+ music=None,
99
+ )
100
+ state.current_scene_id = scene_id
101
+ state.scenes[scene_id] = scene
102
+ set_user_state(user_hash, state)
103
+ return scene.dict()
104
 
 
 
 
105
 
106
  @tool
107
  async def generate_scene_image(
108
+ user_hash: Annotated[str, "User session ID"],
109
+ scene_id: Annotated[str, "Scene ID"],
110
+ prompt: Annotated[str, "Prompt for image generation"],
111
+ ) -> Annotated[str, "Path to generated image"]:
112
+ """Generate an image for a scene and save the path in the state."""
 
 
 
 
 
 
 
113
  try:
114
+ image_path, _ = await generate_image(prompt)
115
+ state = get_user_state(user_hash)
116
+ if scene_id in state.scenes:
117
+ state.scenes[scene_id].image = image_path
118
+ state.assets[scene_id] = image_path
119
+ set_user_state(user_hash, state)
120
+ return image_path
121
+ except Exception as exc: # noqa: BLE001
122
+ return _err(str(exc))
123
+
124
+
125
+ @tool
126
+ async def update_state_with_choice(
127
+ user_hash: Annotated[str, "User session ID"],
128
+ scene_id: Annotated[str, "Scene ID"],
129
+ choice_text: Annotated[str, "Chosen option"],
130
+ ) -> Annotated[Dict, "Updated state"]:
131
+ """Record the player's choice in the state."""
132
+ import datetime
133
+
134
+ state = get_user_state(user_hash)
135
+ state.user_choices.append(
136
+ UserChoice(
137
+ scene_id=scene_id,
138
+ choice_text=choice_text,
139
+ timestamp=datetime.datetime.utcnow().isoformat(),
140
+ )
141
+ )
142
+ set_user_state(user_hash, state)
143
+ return state.dict()
144
+
145
+
146
+ @tool
147
+ async def check_ending(
148
+ user_hash: Annotated[str, "User session ID"],
149
+ ) -> Annotated[Dict, "Ending check result"]:
150
+ """Check whether an ending has been reached."""
151
+ state = get_user_state(user_hash)
152
+ if not state.story_frame:
153
+ return _err("No story frame")
154
+ llm = create_llm().with_structured_output(EndingCheckResult)
155
+ history = '; '.join(
156
+ f"{c.scene_id}:{c.choice_text}" for c in state.user_choices
157
+ )
158
+ prompt = ENDING_CHECK_PROMPT.format(
159
+ history=history,
160
+ endings=','.join(
161
+ f"{e.id}:{e.condition}" for e in state.story_frame.endings
162
+ ),
163
+ )
164
+ resp: EndingCheckResult = await llm.ainvoke(prompt)
165
+ if resp.ending_reached and resp.ending:
166
+ state.ending = resp.ending
167
+ set_user_state(user_hash, state)
168
+ return {"ending_reached": True, "ending": resp.ending.dict()}
169
+ return {"ending_reached": False}
src/config.py CHANGED
@@ -1,16 +1,18 @@
1
  from dotenv import load_dotenv
2
-
3
- load_dotenv()
4
  from pydantic_settings import BaseSettings
5
  import logging
6
  from pydantic import SecretStr
7
 
 
 
 
8
  logging.basicConfig(
9
  level=logging.INFO,
10
  format="%(levelname)s:\t%(asctime)s [%(name)s] %(message)s",
11
  datefmt="%Y-%m-%d %H:%M:%S %z",
12
  )
13
 
 
14
  class BaseAppSettings(BaseSettings):
15
  """Base settings class with common configuration."""
16
 
@@ -18,11 +20,12 @@ class BaseAppSettings(BaseSettings):
18
  env_file = ".env"
19
  env_file_encoding = "utf-8"
20
  extra = "ignore"
21
-
 
22
  class AppSettings(BaseAppSettings):
23
  gemini_api_key: SecretStr
24
  top_p: float = 0.95
25
  temperature: float = 0.5
26
-
27
 
28
  settings = AppSettings()
 
1
  from dotenv import load_dotenv
 
 
2
  from pydantic_settings import BaseSettings
3
  import logging
4
  from pydantic import SecretStr
5
 
6
+ load_dotenv()
7
+
8
+
9
  logging.basicConfig(
10
  level=logging.INFO,
11
  format="%(levelname)s:\t%(asctime)s [%(name)s] %(message)s",
12
  datefmt="%Y-%m-%d %H:%M:%S %z",
13
  )
14
 
15
+
16
  class BaseAppSettings(BaseSettings):
17
  """Base settings class with common configuration."""
18
 
 
20
  env_file = ".env"
21
  env_file_encoding = "utf-8"
22
  extra = "ignore"
23
+
24
+
25
  class AppSettings(BaseAppSettings):
26
  gemini_api_key: SecretStr
27
  top_p: float = 0.95
28
  temperature: float = 0.5
29
+
30
 
31
  settings = AppSettings()
src/css.py CHANGED
@@ -119,6 +119,14 @@ img {
119
  display: none !important;
120
  }
121
 
 
 
 
 
 
 
 
 
122
  /* Make form element transparent */
123
  .overlay-content .form {
124
  background: transparent !important;
@@ -145,4 +153,4 @@ loading_css_styles = """
145
  font-size: 2em;
146
  text-align: center;
147
  }
148
- """
 
119
  display: none !important;
120
  }
121
 
122
+ /* Position the back button in the top-right corner */
123
+ #back-btn {
124
+ position: fixed !important;
125
+ top: 10px !important;
126
+ right: 10px !important;
127
+ z-index: 20 !important;
128
+ }
129
+
130
  /* Make form element transparent */
131
  .overlay-content .form {
132
  background: transparent !important;
 
153
  font-size: 2em;
154
  text-align: center;
155
  }
156
+ """
src/game_constructor.py CHANGED
@@ -2,9 +2,7 @@ import gradio as gr
2
  import json
3
  import uuid
4
  from game_setting import Character, GameSetting
5
- from game_state import story, state, get_current_scene
6
- from agent.llm_agent import process_user_input
7
- from images.image_generator import generate_image
8
  from audio.audio_generator import start_music_generation
9
  import asyncio
10
 
@@ -142,45 +140,21 @@ async def start_game_with_settings(
142
 
143
  game_setting = GameSetting(character=character, setting=setting_desc, genre=genre)
144
 
145
- # Initialize the game story with the custom settings
146
- initial_story = f"""Welcome to your story, {game_setting.character.name}!
147
-
148
- Setting: {game_setting.setting}
149
-
150
- You are {game_setting.character.name}, a {game_setting.character.age}-year-old character. {game_setting.character.background}
151
-
152
- Your personality: {game_setting.character.personality}
153
-
154
- Genre: {game_setting.genre}
155
-
156
- You find yourself at the beginning of your adventure. The world around you feels alive with possibilities. What do you choose to do first?
157
-
158
- NOTE FOR THE ASSISTANT: YOU HAVE TO GENERATE THE IMAGE FOR THE START SCENE.
159
- """
160
-
161
- response = await process_user_input(initial_story)
162
-
163
- music_tone = response.change_music.music_description or "neutral"
164
-
165
- asyncio.create_task(start_music_generation(user_hash, music_tone))
166
-
167
- img = "forest.jpg"
168
-
169
- if response.change_scene.change_scene:
170
- img_path, _ = await generate_image(response.change_scene.scene_description)
171
- if img_path:
172
- img = img_path
173
 
174
- story["start"] = {
175
- "text": response.game_message,
176
- "image": img,
177
- "choices": [option.option_description for option in response.player_options],
178
- "music_tone": response.change_music.music_description,
179
- }
180
- state["scene"] = "start"
181
 
182
- # Get the current scene data
183
- scene_text, scene_image, scene_choices = get_current_scene()
 
 
184
 
185
  return (
186
  gr.update(visible=False), # loading indicator
 
2
  import json
3
  import uuid
4
  from game_setting import Character, GameSetting
5
+ from agent.runner import process_step
 
 
6
  from audio.audio_generator import start_music_generation
7
  import asyncio
8
 
 
140
 
141
  game_setting = GameSetting(character=character, setting=setting_desc, genre=genre)
142
 
143
+ # Запускаем LLM-граф для инициализации истории
144
+ result = await process_step(
145
+ user_hash=user_hash,
146
+ step="start",
147
+ setting=game_setting.setting,
148
+ character=game_setting.character.model_dump(),
149
+ genre=game_setting.genre,
150
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
+ asyncio.create_task(start_music_generation(user_hash, "neutral"))
 
 
 
 
 
 
153
 
154
+ scene = result["scene"]
155
+ scene_text = scene["description"]
156
+ scene_image = scene.get("image", "")
157
+ scene_choices = [ch["text"] for ch in scene.get("choices", [])]
158
 
159
  return (
160
  gr.update(visible=False), # loading indicator
src/main.py CHANGED
@@ -2,14 +2,11 @@ import gradio as gr
2
  from css import custom_css, loading_css_styles
3
  from audio.audio_generator import (
4
  update_audio,
5
- change_music_tone,
6
  cleanup_music_session,
7
  )
8
  import logging
9
- from agent.llm_agent import process_user_input
10
- from images.image_generator import generate_image
11
  import uuid
12
- from game_state import story, state
13
  from game_constructor import (
14
  SETTING_SUGGESTIONS,
15
  CHARACTER_SUGGESTIONS,
@@ -18,61 +15,53 @@ from game_constructor import (
18
  load_character_suggestion,
19
  start_game_with_settings,
20
  )
21
- import asyncio
22
 
23
  logger = logging.getLogger(__name__)
24
 
25
 
26
- def return_to_constructor():
27
- """Return to the game constructor interface, ensure loading is hidden."""
 
 
 
 
 
 
28
  return (
29
  gr.update(visible=False), # loading_indicator
30
  gr.update(visible=True), # constructor_interface
31
  gr.update(visible=False), # game_interface
32
  gr.update(visible=False), # error_message
 
33
  )
34
 
35
 
36
  async def update_scene(user_hash: str, choice):
37
  logger.info(f"Updating scene with choice: {choice}")
38
- if isinstance(choice, str):
39
- old_scene = state["scene"]
40
- new_scene = str(uuid.uuid4())
41
- story[new_scene] = {
42
- **story[old_scene],
43
- }
44
- state["scene"] = new_scene
45
-
46
- user_story = f"""Current scene description:
47
- {story[old_scene]["text"]}
48
- User's choice: {choice}
49
- """
50
-
51
- response = await process_user_input(user_story)
52
-
53
- story[new_scene]["text"] = response.game_message
54
-
55
- story[new_scene]["choices"] = [
56
- option.option_description for option in response.player_options
57
- ]
58
-
59
- # run both tasks in parallel
60
- img_res, _ = await asyncio.gather(
61
- generate_image(response.change_scene.scene_description) if response.change_scene.change_scene else asyncio.sleep(0),
62
- change_music_tone(user_hash, response.change_music.music_description) if response.change_music.change_music else asyncio.sleep(0)
63
  )
64
-
65
- if img_res and response.change_scene.change_scene:
66
- img_path, _ = img_res
67
- if img_path:
68
- story[new_scene]["image"] = img_path
69
 
70
- scene = story[state["scene"]]
71
  return (
72
- scene["text"],
73
- scene["image"],
74
  gr.Radio(
75
- choices=scene["choices"],
76
  label="What do you choose?",
77
  value=None,
78
  elem_classes=["choice-buttons"],
@@ -235,8 +224,12 @@ with gr.Blocks(
235
  gr.Markdown("# 🎮 Your Interactive Story")
236
 
237
  with gr.Row():
238
- back_btn = gr.Button("⬅️ Back to Constructor", variant="secondary")
239
  gr.Markdown("### Playing your custom game!")
 
 
 
 
 
240
 
241
  # Audio component for background music
242
  audio_out = gr.Audio(
@@ -323,12 +316,13 @@ with gr.Blocks(
323
 
324
  back_btn.click(
325
  fn=return_to_constructor,
326
- inputs=[],
327
  outputs=[
328
  loading_indicator,
329
  constructor_interface,
330
  game_interface,
331
  error_message,
 
332
  ],
333
  )
334
 
 
2
  from css import custom_css, loading_css_styles
3
  from audio.audio_generator import (
4
  update_audio,
 
5
  cleanup_music_session,
6
  )
7
  import logging
8
+ from agent.runner import process_step
 
9
  import uuid
 
10
  from game_constructor import (
11
  SETTING_SUGGESTIONS,
12
  CHARACTER_SUGGESTIONS,
 
15
  load_character_suggestion,
16
  start_game_with_settings,
17
  )
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
 
22
+ async def return_to_constructor(user_hash: str):
23
+ """Return to the constructor and reset user state and audio."""
24
+ from agent.state import reset_user_state
25
+
26
+ reset_user_state(user_hash)
27
+ await cleanup_music_session(user_hash)
28
+ # Generate a new hash to avoid stale state
29
+ new_hash = str(uuid.uuid4())
30
  return (
31
  gr.update(visible=False), # loading_indicator
32
  gr.update(visible=True), # constructor_interface
33
  gr.update(visible=False), # game_interface
34
  gr.update(visible=False), # error_message
35
+ gr.update(value=new_hash), # local_storage
36
  )
37
 
38
 
39
  async def update_scene(user_hash: str, choice):
40
  logger.info(f"Updating scene with choice: {choice}")
41
+ if not isinstance(choice, str):
42
+ return gr.update(), gr.update(), gr.update()
43
+
44
+ result = await process_step(
45
+ user_hash=user_hash,
46
+ step="choose",
47
+ choice_text=choice,
48
+ )
49
+
50
+ if result.get("game_over"):
51
+ ending = result["ending"]
52
+ ending_text = ending.get("description") or ending.get("condition", "")
53
+ return (
54
+ gr.update(value=ending_text),
55
+ gr.update(value=None),
56
+ gr.Radio(choices=[], label="", value=None),
 
 
 
 
 
 
 
 
 
57
  )
 
 
 
 
 
58
 
59
+ scene = result["scene"]
60
  return (
61
+ scene["description"],
62
+ scene.get("image", ""),
63
  gr.Radio(
64
+ choices=[ch["text"] for ch in scene.get("choices", [])],
65
  label="What do you choose?",
66
  value=None,
67
  elem_classes=["choice-buttons"],
 
224
  gr.Markdown("# 🎮 Your Interactive Story")
225
 
226
  with gr.Row():
 
227
  gr.Markdown("### Playing your custom game!")
228
+ back_btn = gr.Button(
229
+ "⬅️ Back to Constructor",
230
+ variant="secondary",
231
+ elem_id="back-btn",
232
+ )
233
 
234
  # Audio component for background music
235
  audio_out = gr.Audio(
 
316
 
317
  back_btn.click(
318
  fn=return_to_constructor,
319
+ inputs=[local_storage],
320
  outputs=[
321
  loading_indicator,
322
  constructor_interface,
323
  game_interface,
324
  error_message,
325
+ local_storage,
326
  ],
327
  )
328