Files
DungeonsDragons/backend/app/llm/orchestrator.py
T
Thorsten 2b2af058b8 Add monster HP tracking, separate from the game-ending logic
Only the player character's HP was tracked before, so a monster's
death was never detected — only the DM's own (unenforced) narration
of it. Add a new update_monster_hp tool the DM calls whenever an
NPC/monster is introduced or takes damage, stored as DM-only
bookkeeping on Game (never exposed via GameRead, since HP/AC of NPCs
is meant to stay secret from players). The tool reports back
"defeated": true once HP drops to 0 so the DM can react to it — but
unlike a character dying, this does NOT auto-end the session; whether
a monster's death should end the game is a separate decision left to
the DM's own end_game call, or to a future rule.

Building an isolated test for this caught a real bug along the way:
the update mutated the matched monster's dict in place before
reassigning the list, which made SQLAlchemy's old-vs-new JSONB
comparison see identical content and silently skip writing the
change. Fixed by building fresh dicts instead of mutating shared ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:50:39 +02:00

145 lines
5.4 KiB
Python

import json
import logging
import uuid
from collections.abc import Awaitable, Callable
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
from app.models.game import Game
from app.models.message import Message
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},
]
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)
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}"
)
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}"
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