Files
DungeonsDragons/backend/app/llm/orchestrator.py
T
Thorsten 7bbdee868a DM speaks first automatically; stop asking who's playing
Two issues from live testing: (1) the DM only ever spoke after a
player sent something, so starting a fresh game meant typing a
throwaway message like "start" just to get it going; (2) Session 0
asked for player headcount and names even though that's already
known — everyone who wants in has already logged in and joined via
the invite link/code, tracked in game_participants.

Fix 1: ws_game.py now runs a DM turn immediately on the first-ever
WebSocket connection to a game with zero messages, before entering
the normal receive loop — guarded by the game's existing lock plus a
"does this game have messages yet" check, so two tabs opening around
the same time can't produce two greetings. Extracted the
run-turn-then-broadcast logic (typing/rolling/game_ended/message)
into _run_and_broadcast_dm_turn, shared between the kickoff and the
normal per-message path instead of duplicated.

Fix 2: orchestrator.run_dm_turn() now queries game_participants and
injects the actual joined-player names into the system prompt
alongside the game's name/description. dm_system_prompt.txt drops the
"ask for player count and names" question entirely and instructs the
DM to greet whoever's already listed instead, since more players can
join mid-session and get recognized automatically once they speak.

Verified live: a freshly created game showed "Dungeon Master schreibt
…" and then a real opening message addressed to "Thorsten" by name
before any player message existed, skipped straight to the character
question, and reloading the page didn't produce a duplicate greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:04:42 +02:00

178 lines
7.1 KiB
Python

import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice, end_game, monster, world_state
from app.models.game import Game, GameParticipant
from app.models.message import Message
from app.models.user import User
from app.rag.adventure_retrieval import build_adventure_rag_block
from app.rag.retrieval import build_rag_block
logger = logging.getLogger("app.llm.orchestrator")
MAX_TOOL_ROUNDS = 5
MAX_TOKENS = 4096
TOOLS = [
{"type": "function", "function": dice.TOOL_SCHEMA},
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
{"type": "function", "function": end_game.TOOL_SCHEMA},
{"type": "function", "function": monster.TOOL_SCHEMA},
{"type": "function", "function": world_state.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)
if tool_name == "end_game":
return await end_game.end(session, game_id, tool_input)
if tool_name == "update_monster_hp":
return await monster.update(session, game_id, tool_input)
if tool_name == "update_world_state":
return await world_state.update(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 _check_hp_game_over(session: AsyncSession, game_id: uuid.UUID, result: dict) -> str | None:
"""After an upsert_character_sheet call, auto-ends the game if the character it touched
dropped to 0 HP or below. Returns the end reason if the game was just ended, else None."""
current_hp = result.get("current_hp")
if current_hp is None or current_hp > 0:
return None
game = await session.get(Game, game_id)
if game is None or game.status == "ended":
return None
reason = f"{result.get('name', 'Ein Charakter')} ist bei {current_hp} Trefferpunkten zusammengebrochen."
game.status = "ended"
game.ended_reason = reason
await session.commit()
return reason
async def run_dm_turn(
session: AsyncSession,
game_id: uuid.UUID,
latest_player_message: str | None = None,
on_roll: Callable[[str], Awaitable[None]] | None = None,
on_game_ended: Callable[[str], Awaitable[None]] | None = None,
) -> Message:
client = get_llm_client()
system_prompt = get_dm_system_prompt()
game = await session.get(Game, game_id)
if game is not None:
system_prompt = (
f"{system_prompt}\n\n"
"Spiel-Rahmendaten (vom Ersteller beim Anlegen bereits festgelegt — NICHT erneut "
"abfragen, in Session 0 höchstens knapp bestätigen):\n"
f"Name: {game.name}\nBeschreibung: {game.description}"
)
participant_names = (
await session.execute(
select(User.name)
.join(GameParticipant, GameParticipant.user_id == User.id)
.where(GameParticipant.game_id == game_id)
)
).scalars().all()
if participant_names:
system_prompt += (
"\n\nBereits beigetretene Spieler (über Login/Einladungslink — NICHT nach Anzahl "
"oder Namen fragen): " + ", ".join(participant_names) + ". Weitere können jederzeit "
"über den Einladungslink dazukommen; du erkennst sie automatisch am Namen, sobald sie schreiben."
)
await world_state.refresh_if_due(session, game_id, client, settings.dm_model)
world_summary = await world_state.get_summary(session, game_id)
if world_summary:
system_prompt = (
f"{system_prompt}\n\nBisheriger Weltzustand (automatisch/von dir über update_world_state "
f"aktuell gehalten — der Chatverlauf unten zeigt nur ein aktuelles Fenster, das hier "
f"trägt alles Ältere):\n{world_summary}"
)
if latest_player_message:
rag_block = await build_rag_block(session, latest_player_message)
if rag_block:
system_prompt = f"{system_prompt}\n\n{rag_block}"
adventure_block = await build_adventure_rag_block(session, game_id, latest_player_message)
if adventure_block:
system_prompt = f"{system_prompt}\n\n{adventure_block}"
messages = [{"role": "system", "content": 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 = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
if tool_call.function.name == "roll_dice" and on_roll is not None:
await on_roll(tool_input.get("notation", ""))
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
end_reason = None
if tool_call.function.name == "end_game" and "reason" in result:
end_reason = result["reason"]
elif tool_call.function.name == "upsert_character_sheet":
end_reason = await _check_hp_game_over(session, game_id, result)
if end_reason:
result = {**result, "game_ended": True, "end_reason": end_reason}
if end_reason and on_game_ended is not None:
await on_game_ended(end_reason)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False),
}
)
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