Files
ai-rpg/backend/app/engine/world_builder.py

316 lines
10 KiB
Python
Raw Normal View History

2026-06-19 16:31:45 +03:00
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition.
Design (v2 tool-calling-first):
The world-builder LLM is given a single tool, `submit_world_definition`,
which it calls when it has enough information to propose a world. The LLM's
text response is the conversational reply shown to the player (in
world.language). This replaces the old "return JSON in your text response"
pattern which conflicted with tool use and caused raw JSON to leak into
the chat.
"""
2026-06-19 11:28:04 +03:00
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
2026-06-19 16:31:45 +03:00
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
2026-06-19 11:28:04 +03:00
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()
2026-06-19 16:31:45 +03:00
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
2026-06-19 11:28:04 +03:00
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,
)
2026-06-19 16:31:45 +03:00
# System prompt is English (system content convention). The LLM is told to
# produce player-facing text in `language` (interpolated as world_language).
system_prompt = get_prompt("world_builder", language).format(world_language=language)
2026-06-19 11:28:04 +03:00
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief},
]
response = await llm.chat(
messages=messages,
2026-06-19 16:31:45 +03:00
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
2026-06-19 11:28:04 +03:00
purpose="world_builder",
user_id=user.id,
db=db,
)
2026-06-19 16:31:45 +03:00
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
2026-06-19 11:28:04 +03:00
_DIALOGUES[session_id] = {
"user_id": user.id,
"world_name": world_name,
"language": language,
"preset_id": preset_id,
2026-06-19 16:31:45 +03:00
"messages": messages + [
{
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
},
],
2026-06-19 11:28:04 +03:00
"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,
2026-06-19 16:31:45 +03:00
followup_questions=[],
2026-06-19 11:28:04 +03:00
)
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")
2026-06-19 16:31:45 +03:00
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
2026-06-19 11:28:04 +03:00
dialogue["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1
response = await llm.chat(
messages=dialogue["messages"],
2026-06-19 16:31:45 +03:00
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
2026-06-19 11:28:04 +03:00
purpose="world_builder",
user_id=user.id,
db=db,
)
2026-06-19 16:31:45 +03:00
dialogue["messages"].append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
})
2026-06-19 11:28:04 +03:00
2026-06-19 16:31:45 +03:00
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
2026-06-19 11:28:04 +03:00
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,
2026-06-19 16:31:45 +03:00
followup_questions=[],
2026-06-19 11:28:04 +03:00
)
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",
2026-06-19 16:31:45 +03:00
language=dialogue.get("language", "en"),
2026-06-19 11:28:04 +03:00
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:
2026-06-19 16:31:45 +03:00
parts = [f"=== WORLD BRIEF ==="]
parts.append(f"Player-facing language: {language}")
2026-06-19 11:28:04 +03:00
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}")
2026-06-19 16:31:45 +03:00
parts.append("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.")
2026-06-19 11:28:04 +03:00
return "\n".join(parts)
2026-06-19 16:31:45 +03:00
def _extract_world_definition(
text: str,
tool_calls: Optional[List[Dict[str, Any]]],
) -> tuple[str, Optional[WorldDefinition], bool]:
"""Extract AI message text, proposed definition (if any), and is_final flag.
Looks for a `submit_world_definition` tool call first. Falls back to
JSON-in-text parse for older models that don't honor the tool.
"""
proposed: Optional[WorldDefinition] = None
2026-06-19 11:28:04 +03:00
is_final = False
2026-06-19 16:31:45 +03:00
ai_text = text or ""
# 1) Prefer the submit_world_definition tool call (the proper way).
if tool_calls:
for tc in tool_calls:
if tc.get("function", {}).get("name") == "submit_world_definition":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
data = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
data = {}
proposed = _try_build_definition(data)
is_final = bool(data.get("is_final", False))
break
# 2) Fallback: parse a JSON block from the text (older models).
if proposed is None:
json_str = _extract_json_block(text)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, dict):
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
proposed = _try_build_definition(data["proposed_definition"])
if "is_final" in data:
is_final = bool(data["is_final"])
if "ai_message" in data and isinstance(data["ai_message"], str):
ai_text = data["ai_message"]
except json.JSONDecodeError:
pass
# 3) Heuristic: if a definition was proposed and the text mentions "ready",
# mark as final.
if proposed is not None and not is_final:
low = ai_text.lower()
if any(kw in low for kw in ["world is ready", "world_ready", "ready to commit", "мир готов"]):
2026-06-19 11:28:04 +03:00
is_final = True
2026-06-19 16:31:45 +03:00
return ai_text, proposed, is_final
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
try:
return WorldDefinition.model_validate(data)
except Exception as e:
log.warning("world_definition_invalid", error=str(e))
return None
2026-06-19 11:28:04 +03:00
def _extract_json_block(text: str) -> Optional[str]:
2026-06-19 16:31:45 +03:00
"""Find the first JSON object/array block in text (fallback path only)."""
2026-06-19 11:28:04 +03:00
if not text:
return None
import re
m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
if m:
return m.group(1)
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