Add rulebook RAG pipeline and LLM-driven game setup wizard

RAG: switch Postgres to pgvector, chunk and embed the three D&D
rulebooks locally via sentence-transformers, and retrieve relevant
excerpts per DM turn (query = latest player message) to ground the
system prompt. Retrieval runs off the event loop and is capped by a
relevance threshold and a max character budget so it can't blow up
context size or cost.

Game setup wizard: creating a game now opens a short chat where the
DM asks about genre, length, and the player's experience level, then
proposes a name and description via a tool call. The player can edit
both before creating the game. Stateless endpoint — the frontend
carries the conversation, no DB needed since the game doesn't exist
yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-08-31 20:03:05 +02:00
parent f37dc9fa76
commit 419f5e3a89
26 changed files with 82471 additions and 51 deletions
+5
View File
@@ -13,6 +13,11 @@ def get_dm_system_prompt() -> str:
return (PROMPTS_DIR / "dm_system_prompt.txt").read_text(encoding="utf-8")
@lru_cache
def get_game_setup_prompt() -> str:
return (PROMPTS_DIR / "game_setup_prompt.txt").read_text(encoding="utf-8")
@lru_cache
def get_llm_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=settings.xai_api_key, base_url=settings.xai_base_url)
+55
View File
@@ -0,0 +1,55 @@
import json
from app.config import settings
from app.llm.client import get_game_setup_prompt, get_llm_client
from app.llm.tools import game_setup as game_setup_tool
MAX_ROUNDS = 3
MAX_TOKENS = 1024
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
async def run_game_setup_turn(client_messages: list[dict]) -> dict:
"""Stateless setup-wizard turn: the caller owns conversation history (no DB, no game yet).
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
"proposal": {"name": str, "description": str} | None}.
"""
client = get_llm_client()
messages = [{"role": "system", "content": get_game_setup_prompt()}] + client_messages
assistant_text: str | None = None
proposal: dict | None = None
for _ in range(MAX_ROUNDS):
response = await client.chat.completions.create(
model=settings.dm_model,
max_tokens=MAX_TOKENS,
messages=messages,
tools=TOOLS,
)
message = response.choices[0].message
if message.content:
assistant_text = message.content
if not message.tool_calls:
messages.append({"role": "assistant", "content": message.content or ""})
break
messages.append(message.model_dump(exclude_unset=True))
for tool_call in message.tool_calls:
args = json.loads(tool_call.function.arguments)
if tool_call.function.name == "propose_game_setup":
proposal = {"name": args.get("name", ""), "description": args.get("description", "")}
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps({"status": "ok"}),
}
)
if proposal:
break
return {"messages": messages[1:], "assistant_text": assistant_text, "proposal": proposal}
+14 -4
View File
@@ -4,13 +4,14 @@ 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
from app.rag.retrieval import build_rag_block
logger = logging.getLogger("app.llm.orchestrator")
MAX_TOOL_ROUNDS = 5
MAX_TOKENS = 4096
@@ -38,9 +39,18 @@ async def _execute_tool_call(
return {"error": str(exc)}
async def run_dm_turn(session: AsyncSession, game_id: uuid.UUID) -> Message:
async def run_dm_turn(
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str | None = None
) -> Message:
client = get_llm_client()
messages = [{"role": "system", "content": get_dm_system_prompt()}]
system_prompt = get_dm_system_prompt()
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 = ""
@@ -0,0 +1,21 @@
Rolle
Du hilfst einem Spieler dabei, ein neues D&D-Spiel einzurichten, bevor es losgeht. Du bist derselbe erfahrene Dungeon Master, aber in dieser kurzen Setup-Phase geht es nicht ums eigentliche Abenteuer, sondern nur darum, die Eckdaten zu klären.
Ablauf
Stelle dem Spieler nacheinander (eine Frage nach der anderen, nicht alle auf einmal) folgende drei Fragen, bis du zu allen dreien eine Antwort hast:
1. Welches Genre bzw. welche Welt wünscht er sich (z. B. High Fantasy, düster, humorvoll, Horror, Piraten, eine eigene Idee)?
2. Wie lang soll das Spiel werden — ein einzelnes One-Shot-Abenteuer oder eine längere Kampagne über mehrere Sitzungen?
3. Wie ist sein Erfahrungsstand mit D&D — komplette(r) Anfänger(in), gelegentlich gespielt, oder erfahrene(r) Regelkenner(in)?
Sobald du alle drei Antworten hast, rufe das Werkzeug propose_game_setup auf mit:
- einem passenden, einprägsamen Namen für das Spiel
- einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand)
Der Spieler sieht deinen Vorschlag danach in einem Formular und kann Name und Beschreibung noch selbst anpassen, bevor er das Spiel erstellt.
Ton
Bleib locker, freundlich und kurz angebunden — das ist nur die Einrichtung, kein Rollenspiel. Sprich den Spieler per "Du" an.
Sprache
Antworte in der Sprache, in der der Spieler schreibt.
+22
View File
@@ -0,0 +1,22 @@
TOOL_SCHEMA = {
"name": "propose_game_setup",
"description": (
"Call this once you know the desired genre/world, the intended length (one-shot vs. "
"campaign), and the player's experience level. Propose a fitting name and a short "
"description for the new game — the player can still edit both before creating it."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A fitting, evocative name for the game.",
},
"description": {
"type": "string",
"description": "Short description covering genre and world/setting.",
},
},
"required": ["name", "description"],
},
}