Add Phase 1 MVP: D&D text-adventure server
FastAPI + Postgres backend with fastapi-users auth, games/characters CRUD, and a WebSocket chat endpoint where an x.ai Grok DM narrates play, rolls dice, and maintains character sheets via tool calls. React + Tailwind frontend covers registration/login, game list and creation, participation-code join flow, character viewer, and a real-time chat session view. Docker Compose skeleton (Postgres + backend) included; Caddy/frontend container wiring is deferred to the deploy milestone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger("app.llm.orchestrator")
|
||||
|
||||
from app.config import settings
|
||||
from app.llm.client import get_dm_system_prompt, get_llm_client
|
||||
from app.llm.context import build_context
|
||||
from app.llm.tools import character_sheet, dice
|
||||
from app.models.message import Message
|
||||
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
MAX_TOKENS = 4096
|
||||
|
||||
TOOLS = [
|
||||
{"type": "function", "function": dice.TOOL_SCHEMA},
|
||||
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
|
||||
]
|
||||
|
||||
|
||||
async def _execute_tool_call(
|
||||
session: AsyncSession, game_id: uuid.UUID, tool_name: str, tool_input: dict
|
||||
) -> dict:
|
||||
"""Runs one tool call. Errors are returned as a payload (not raised), so the DM sees them
|
||||
as a tool result and can recover instead of the whole turn crashing."""
|
||||
try:
|
||||
if tool_name == "roll_dice":
|
||||
return dice.roll(tool_input["notation"])
|
||||
if tool_name == "upsert_character_sheet":
|
||||
return await character_sheet.upsert(session, game_id, tool_input)
|
||||
return {"error": f"Unknown tool {tool_name!r}"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Tool call %s failed: %s", tool_name, exc)
|
||||
await session.rollback()
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
async def run_dm_turn(session: AsyncSession, game_id: uuid.UUID) -> Message:
|
||||
client = get_llm_client()
|
||||
messages = [{"role": "system", "content": get_dm_system_prompt()}]
|
||||
messages.extend(await build_context(session, game_id))
|
||||
|
||||
final_text = ""
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.dm_model,
|
||||
max_tokens=MAX_TOKENS,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
)
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
final_text = message.content or ""
|
||||
|
||||
if not message.tool_calls:
|
||||
break
|
||||
|
||||
messages.append(message.model_dump(exclude_unset=True))
|
||||
|
||||
for tool_call in message.tool_calls:
|
||||
tool_input = json.loads(tool_call.function.arguments)
|
||||
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": json.dumps(result),
|
||||
}
|
||||
)
|
||||
else:
|
||||
if not final_text:
|
||||
final_text = "(Der Dungeon Master braucht einen Moment länger als erwartet — bitte versuche es erneut.)"
|
||||
|
||||
dm_message = Message(game_id=game_id, sender_type="dm", content=final_text)
|
||||
session.add(dm_message)
|
||||
await session.commit()
|
||||
await session.refresh(dm_message)
|
||||
return dm_message
|
||||
Reference in New Issue
Block a user