Spaces:
Running
Running
feat: implement Redis-backed user state management and update app structure
Browse files- README.md +3 -3
- packages.txt +1 -0
- requirements.txt +4 -1
- src/agent/llm_graph.py +1 -1
- src/agent/redis_state.py +51 -0
- src/agent/runner.py +1 -1
- src/agent/state.py +0 -24
- src/agent/tools.py +1 -1
- src/app.py +44 -0
- src/main.py +3 -2
README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🎮
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.32.0
|
| 8 |
python_version: "3.11"
|
| 9 |
-
app_file: src/
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
---
|
|
|
|
| 1 |
---
|
| 2 |
+
title: LLMGameHub
|
| 3 |
+
emoji: "🎮"
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.32.0
|
| 8 |
python_version: "3.11"
|
| 9 |
+
app_file: src/app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
---
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
redis-server
|
requirements.txt
CHANGED
|
@@ -7,6 +7,7 @@ asyncio
|
|
| 7 |
aiohttp==3.9.1
|
| 8 |
pygame==2.5.2
|
| 9 |
numpy
|
|
|
|
| 10 |
langchain==0.3.17
|
| 11 |
langchain-core==0.3.58
|
| 12 |
langchain-community==0.3.16
|
|
@@ -14,4 +15,6 @@ langchain-google-genai==2.1.4
|
|
| 14 |
pydantic-core==2.23.4
|
| 15 |
pydantic-settings==2.7.1
|
| 16 |
pydantic==2.9.2
|
| 17 |
-
|
|
|
|
|
|
|
|
|
| 7 |
aiohttp==3.9.1
|
| 8 |
pygame==2.5.2
|
| 9 |
numpy
|
| 10 |
+
langgraph
|
| 11 |
langchain==0.3.17
|
| 12 |
langchain-core==0.3.58
|
| 13 |
langchain-community==0.3.16
|
|
|
|
| 15 |
pydantic-core==2.23.4
|
| 16 |
pydantic-settings==2.7.1
|
| 17 |
pydantic==2.9.2
|
| 18 |
+
redis[hiredis]>=5
|
| 19 |
+
aioredis>=2
|
| 20 |
+
msgpack
|
src/agent/llm_graph.py
CHANGED
|
@@ -14,7 +14,7 @@ from agent.tools import (
|
|
| 14 |
generate_story_frame,
|
| 15 |
update_state_with_choice,
|
| 16 |
)
|
| 17 |
-
from agent.
|
| 18 |
from audio.audio_generator import change_music_tone
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
|
|
|
| 14 |
generate_story_frame,
|
| 15 |
update_state_with_choice,
|
| 16 |
)
|
| 17 |
+
from agent.redis_state import get_user_state
|
| 18 |
from audio.audio_generator import change_music_tone
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
src/agent/redis_state.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async Redis-backed user state storage."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import msgpack
|
| 7 |
+
import redis.asyncio as redis
|
| 8 |
+
|
| 9 |
+
from agent.models import UserState
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class UserRepository:
|
| 13 |
+
"""Repository for storing UserState objects in Redis."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, redis_url: str = "redis://localhost") -> None:
|
| 16 |
+
self.redis = redis.from_url(redis_url)
|
| 17 |
+
|
| 18 |
+
async def get(self, user_id: str) -> UserState:
|
| 19 |
+
"""Return user state for the given id, creating it if absent."""
|
| 20 |
+
key = f"llmgamehub:{user_id}"
|
| 21 |
+
data = await self.redis.hget(key, "data")
|
| 22 |
+
if data is None:
|
| 23 |
+
return UserState()
|
| 24 |
+
state_dict = msgpack.unpackb(data, raw=False)
|
| 25 |
+
return UserState.parse_obj(state_dict)
|
| 26 |
+
|
| 27 |
+
async def set(self, user_id: str, state: UserState) -> None:
|
| 28 |
+
"""Persist updated user state."""
|
| 29 |
+
key = f"llmgamehub:{user_id}"
|
| 30 |
+
packed = msgpack.packb(json.loads(state.json()))
|
| 31 |
+
await self.redis.hset(key, mapping={"data": packed})
|
| 32 |
+
|
| 33 |
+
async def reset(self, user_id: str) -> None:
|
| 34 |
+
"""Remove stored state for a user."""
|
| 35 |
+
key = f"llmgamehub:{user_id}"
|
| 36 |
+
await self.redis.delete(key)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
_repo = UserRepository()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def get_user_state(user_hash: str) -> UserState:
|
| 43 |
+
return await _repo.get(user_hash)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def set_user_state(user_hash: str, state: UserState) -> None:
|
| 47 |
+
await _repo.set(user_hash, state)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def reset_user_state(user_hash: str) -> None:
|
| 51 |
+
await _repo.reset(user_hash)
|
src/agent/runner.py
CHANGED
|
@@ -10,7 +10,7 @@ from agent.tools import generate_scene_image
|
|
| 10 |
|
| 11 |
from agent.llm_graph import GraphState, llm_game_graph
|
| 12 |
from agent.models import UserState
|
| 13 |
-
from agent.
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
|
|
|
| 10 |
|
| 11 |
from agent.llm_graph import GraphState, llm_game_graph
|
| 12 |
from agent.models import UserState
|
| 13 |
+
from agent.redis_state import get_user_state
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
src/agent/state.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 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
|
@@ -17,7 +17,7 @@ from agent.models import (
|
|
| 17 |
UserChoice,
|
| 18 |
)
|
| 19 |
from agent.prompts import ENDING_CHECK_PROMPT, SCENE_PROMPT, STORY_FRAME_PROMPT
|
| 20 |
-
from agent.
|
| 21 |
from images.image_generator import modify_image, generate_image
|
| 22 |
from agent.image_agent import ChangeScene
|
| 23 |
|
|
|
|
| 17 |
UserChoice,
|
| 18 |
)
|
| 19 |
from agent.prompts import ENDING_CHECK_PROMPT, SCENE_PROMPT, STORY_FRAME_PROMPT
|
| 20 |
+
from agent.redis_state import get_user_state, set_user_state
|
| 21 |
from images.image_generator import modify_image, generate_image
|
| 22 |
from agent.image_agent import ChangeScene
|
| 23 |
|
src/app.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import time
|
| 3 |
+
import atexit
|
| 4 |
+
import shutil
|
| 5 |
+
from redis import Redis, ConnectionError
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
REDIS_BIN = shutil.which("redis-server")
|
| 9 |
+
|
| 10 |
+
if not REDIS_BIN:
|
| 11 |
+
raise RuntimeError("redis-server not found. Ensure redis is installed via packages.txt")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
redis_cmd = [
|
| 15 |
+
REDIS_BIN,
|
| 16 |
+
"--save",
|
| 17 |
+
"",
|
| 18 |
+
"--appendonly",
|
| 19 |
+
"no",
|
| 20 |
+
"--dir",
|
| 21 |
+
"/tmp",
|
| 22 |
+
"--pidfile",
|
| 23 |
+
"/tmp/redis.pid",
|
| 24 |
+
]
|
| 25 |
+
redis_process = subprocess.Popen(redis_cmd)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
redis_client = Redis()
|
| 29 |
+
for _ in range(20):
|
| 30 |
+
try:
|
| 31 |
+
redis_client.ping()
|
| 32 |
+
break
|
| 33 |
+
except ConnectionError:
|
| 34 |
+
time.sleep(0.5)
|
| 35 |
+
else:
|
| 36 |
+
raise RuntimeError("Failed to start redis-server")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
atexit.register(redis_process.terminate)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
time.sleep(0.5)
|
| 43 |
+
|
| 44 |
+
import main
|
src/main.py
CHANGED
|
@@ -21,14 +21,15 @@ import asyncio
|
|
| 21 |
from game_setting import get_user_story
|
| 22 |
from config import settings
|
| 23 |
|
|
|
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
|
| 27 |
async def return_to_constructor(user_hash: str):
|
| 28 |
"""Return to the constructor and reset user state and audio."""
|
| 29 |
-
from agent.
|
| 30 |
|
| 31 |
-
reset_user_state(user_hash)
|
| 32 |
await cleanup_music_session(user_hash)
|
| 33 |
# Generate a new hash to avoid stale state
|
| 34 |
new_hash = str(uuid.uuid4())
|
|
|
|
| 21 |
from game_setting import get_user_story
|
| 22 |
from config import settings
|
| 23 |
|
| 24 |
+
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
|
| 28 |
async def return_to_constructor(user_hash: str):
|
| 29 |
"""Return to the constructor and reset user state and audio."""
|
| 30 |
+
from agent.redis_state import reset_user_state
|
| 31 |
|
| 32 |
+
await reset_user_state(user_hash)
|
| 33 |
await cleanup_music_session(user_hash)
|
| 34 |
# Generate a new hash to avoid stale state
|
| 35 |
new_hash = str(uuid.uuid4())
|