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": , "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, }