f9b9785857
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>
116 lines
4.5 KiB
Python
116 lines
4.5 KiB
Python
import json
|
|
import logging
|
|
|
|
from app.config import settings
|
|
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,
|
|
"generated_adventure": str | None}.
|
|
"""
|
|
client = get_llm_client()
|
|
system_prompt = get_game_setup_prompt()
|
|
if adventure_text.strip():
|
|
excerpt = adventure_text.strip()[:ADVENTURE_EXCERPT_CHARS]
|
|
system_prompt = (
|
|
f"{system_prompt}\n\nDer Spieler hat bereits ein vorhandenes Abenteuer mitgegeben "
|
|
f"(Anfang unten). Leite Genre/Welt daraus ab, statt danach zu fragen — frage nur noch, "
|
|
f"was daraus nicht hervorgeht:\n\n{excerpt}"
|
|
)
|
|
messages = [{"role": "system", "content": system_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 = fix_double_escaped_unicode(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"}, ensure_ascii=False),
|
|
}
|
|
)
|
|
if proposal:
|
|
break
|
|
|
|
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,
|
|
}
|