Auto-generate an adventure outline when no adventure text is provided

When a player creates a game without pasting their own adventure, the
DM previously improvised turn-by-turn with no overall arc, ending, or
calibrated difficulty. Now the setup-chat interview generates a full
outline (hook, three-to-four-part structure, milestone leveling,
explicit ending condition, GM guidance) as a follow-up LLM call once
genre/length/experience are known, and feeds it through the existing
adventure_text -> RAG ingestion -> "follow strictly" pipeline exactly
like a pasted adventure. Falls back silently to today's improvised
behavior if generation fails.

Verified end-to-end in the browser: setup interview -> outline
generated and previewed -> game created -> RAG chunks ingested ->
has_adventure true -> cascade-cleaned on delete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-07 11:58:42 +02:00
parent 1b64010280
commit f9b9785857
6 changed files with 108 additions and 6 deletions
+51 -3
View File
@@ -1,24 +1,63 @@
import json
import logging
from app.config import settings
from app.llm.client import get_game_setup_prompt, get_llm_client
from app.llm.client import get_adventure_outline_prompt, get_game_setup_prompt, get_llm_client
from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import game_setup as game_setup_tool
logger = logging.getLogger("app.llm.game_setup")
MAX_ROUNDS = 3
MAX_TOKENS = 1024
# Only the opening of a pasted adventure is needed here to infer genre/world/scope for the
# proposal — this is a cheap interview call, not the RAG pipeline that handles the full text.
ADVENTURE_EXCERPT_CHARS = 3000
# The outline is a free-text document of several thousand words, unlike the short
# name+description proposal above, so it needs a much larger budget.
OUTLINE_MAX_TOKENS = 4096
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
async def _generate_adventure_outline(client, messages: list[dict], proposal: dict) -> str | None:
"""One-shot follow-up call: draft a full adventure outline from the interview so far.
Only called when the player didn't paste their own adventure text. Best-effort — any
failure just means the game falls back to today's fully-improvised DM, never blocks
game creation.
"""
outline_messages = [
{"role": "system", "content": get_adventure_outline_prompt()},
*messages[1:],
{
"role": "user",
"content": (
f"Vorgeschlagener Spielname: {proposal.get('name', '')}\n"
f"Kurzbeschreibung: {proposal.get('description', '')}\n\n"
"Entwirf jetzt das vollständige Abenteuer-Outline wie beschrieben."
),
},
]
try:
response = await client.chat.completions.create(
model=settings.dm_model,
max_tokens=OUTLINE_MAX_TOKENS,
messages=outline_messages,
)
content = response.choices[0].message.content
return content.strip() if content and content.strip() else None
except Exception:
logger.exception("Adventure outline generation failed")
return None
async def run_game_setup_turn(client_messages: list[dict], adventure_text: str = "") -> 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}.
"proposal": {"name": str, "description": str} | None,
"generated_adventure": str | None}.
"""
client = get_llm_client()
system_prompt = get_game_setup_prompt()
@@ -64,4 +103,13 @@ async def run_game_setup_turn(client_messages: list[dict], adventure_text: str =
if proposal:
break
return {"messages": messages[1:], "assistant_text": assistant_text, "proposal": proposal}
generated_adventure: str | None = None
if proposal and not adventure_text.strip():
generated_adventure = await _generate_adventure_outline(client, messages, proposal)
return {
"messages": messages[1:],
"assistant_text": assistant_text,
"proposal": proposal,
"generated_adventure": generated_adventure,
}