"""World builder: multi-turn dialogue to produce a finalized WorldDefinition.""" from __future__ import annotations import json import uuid from typing import Any, Dict, List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.llm import LlmClient from app.core.settings_service import cast_setting, get_all_settings from app.logging_setup import get_logger from app.models import Preset, User, World from app.prompts.templates import get_prompt from app.schemas import WorldBuilderReply, WorldDefinition log = get_logger("world_builder") # In-memory store of world-builder dialogues (session_id -> dialogue state). # For production scale, move this to Redis. For MVP single-instance it's fine. _DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {} async def start_world_builder( db: AsyncSession, user: User, world_name: str, language: str, preset_id: Optional[uuid.UUID], setting_brief: str, character_brief: str, rules_brief: str, notes: str, ) -> WorldBuilderReply: """Kick off a new world-builder dialogue. Returns the first AI reply.""" session_id = uuid.uuid4() llm = await LlmClient.from_db(db) preset_payload: Optional[Dict[str, Any]] = None if preset_id: result = await db.execute(select(Preset).where(Preset.id == preset_id)) preset = result.scalars().first() if preset: preset_payload = preset.payload user_brief = _build_user_brief( world_name=world_name, setting_brief=setting_brief, character_brief=character_brief, rules_brief=rules_brief, notes=notes, preset_payload=preset_payload, language=language, ) system_prompt = get_prompt("world_builder", language) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_brief}, ] response = await llm.chat( messages=messages, temperature=0.7, purpose="world_builder", user_id=user.id, db=db, ) ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text) _DIALOGUES[session_id] = { "user_id": user.id, "world_name": world_name, "language": language, "preset_id": preset_id, "messages": messages + [{"role": "assistant", "content": response.text}], "turn": 1, "last_proposed": proposed.model_dump() if proposed else None, } return WorldBuilderReply( session_id=session_id, turn=1, ai_message=ai_text, proposed_definition=proposed, is_final=is_final, followup_questions=followups, ) async def continue_world_builder( db: AsyncSession, user: User, session_id: uuid.UUID, user_message: str, ) -> WorldBuilderReply: """Continue an existing world-builder dialogue.""" dialogue = _DIALOGUES.get(session_id) if not dialogue: raise ValueError("dialogue_not_found") if dialogue["user_id"] != user.id: raise ValueError("forbidden") llm = await LlmClient.from_db(db) dialogue["messages"].append({"role": "user", "content": user_message}) dialogue["turn"] += 1 response = await llm.chat( messages=dialogue["messages"], temperature=0.7, purpose="world_builder", user_id=user.id, db=db, ) dialogue["messages"].append({"role": "assistant", "content": response.text}) ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text) if proposed: dialogue["last_proposed"] = proposed.model_dump() return WorldBuilderReply( session_id=session_id, turn=dialogue["turn"], ai_message=ai_text, proposed_definition=proposed, is_final=is_final, followup_questions=followups, ) async def commit_world_builder( db: AsyncSession, user: User, session_id: uuid.UUID, name: Optional[str] = None, ) -> World: """Commit the proposed world definition into a real World row.""" dialogue = _DIALOGUES.get(session_id) if not dialogue: raise ValueError("dialogue_not_found") if dialogue["user_id"] != user.id: raise ValueError("forbidden") proposed = dialogue.get("last_proposed") if not proposed: raise ValueError("no_proposed_definition") definition = WorldDefinition.model_validate(proposed) world = World( owner_id=user.id, name=name or dialogue.get("world_name") or "New World", language=dialogue.get("language", "ru"), definition=definition.model_dump(), state=definition.initial_state or {}, current_time=definition.initial_time, status="ready", preset_id=dialogue.get("preset_id"), ) db.add(world) await db.commit() await db.refresh(world) # Clean up dialogue _DIALOGUES.pop(session_id, None) return world def _build_user_brief( world_name: str, setting_brief: str, character_brief: str, rules_brief: str, notes: str, preset_payload: Optional[Dict[str, Any]], language: str, ) -> str: parts = [f"=== WORLD BRIEF ({language.upper()}) ==="] parts.append(f"Name: {world_name}") if preset_payload: parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}") parts.append(f"Suggested rules: {json.dumps(preset_payload.get('rules', {}), ensure_ascii=False)[:400]}") if setting_brief: parts.append(f"Setting: {setting_brief}") if character_brief: parts.append(f"Character: {character_brief}") if rules_brief: parts.append(f"Rules: {rules_brief}") if notes: parts.append(f"Notes: {notes}") parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.") return "\n".join(parts) def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]: """Extract AI message text, proposed definition (if any), is_final flag, and followup questions.""" proposed = None is_final = False followups: List[str] = [] # Try to find a JSON block in the response json_str = _extract_json_block(text) if json_str: try: data = json.loads(json_str) if isinstance(data, dict): if "proposed_definition" in data: pd = data["proposed_definition"] if isinstance(pd, dict): try: proposed = WorldDefinition.model_validate(pd) except Exception: proposed = None if "is_final" in data: is_final = bool(data["is_final"]) if "followup_questions" in data and isinstance(data["followup_questions"], list): followups = [str(q) for q in data["followup_questions"]] if "ai_message" in data and isinstance(data["ai_message"], str): text = data["ai_message"] except json.JSONDecodeError: pass # Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final if proposed is not None: low = text.lower() if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]): is_final = True return text, proposed, is_final, followups def _extract_json_block(text: str) -> Optional[str]: """Find the first JSON object/array block in text.""" if not text: return None # Try fenced ```json ... ``` import re m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) if m: return m.group(1) # Try raw {...} (greedy from first { to matching }) start = text.find("{") if start == -1: return None depth = 0 in_str = False esc = False for i in range(start, len(text)): c = text[i] if in_str: if esc: esc = False elif c == "\\": esc = True elif c == '"': in_str = False else: if c == '"': in_str = True elif c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return text[start:i + 1] return None