This commit is contained in:
Mikan
2026-06-19 16:31:45 +03:00
parent d0d1f003ae
commit 5a78def096
21 changed files with 1250 additions and 548 deletions

View File

@@ -1,4 +1,13 @@
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
"""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.
"""
from __future__ import annotations
import json
@@ -14,6 +23,7 @@ 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
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
log = get_logger("world_builder")
@@ -36,7 +46,8 @@ async def start_world_builder(
) -> WorldBuilderReply:
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
session_id = uuid.uuid4()
llm = await LlmClient.from_db(db)
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
preset_payload: Optional[Dict[str, Any]] = None
if preset_id:
@@ -55,7 +66,9 @@ async def start_world_builder(
language=language,
)
system_prompt = get_prompt("world_builder", language)
# 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)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief},
@@ -63,20 +76,27 @@ async def start_world_builder(
response = await llm.chat(
messages=messages,
temperature=0.7,
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_builder",
user_id=user.id,
db=db,
)
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
_DIALOGUES[session_id] = {
"user_id": user.id,
"world_name": world_name,
"language": language,
"preset_id": preset_id,
"messages": messages + [{"role": "assistant", "content": response.text}],
"messages": messages + [
{
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
},
],
"turn": 1,
"last_proposed": proposed.model_dump() if proposed else None,
}
@@ -87,7 +107,7 @@ async def start_world_builder(
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
followup_questions=[],
)
@@ -104,20 +124,26 @@ async def continue_world_builder(
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
llm = await LlmClient.from_db(db)
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
dialogue["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1
response = await llm.chat(
messages=dialogue["messages"],
temperature=0.7,
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_builder",
user_id=user.id,
db=db,
)
dialogue["messages"].append({"role": "assistant", "content": response.text})
dialogue["messages"].append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
})
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
if proposed:
dialogue["last_proposed"] = proposed.model_dump()
@@ -127,7 +153,7 @@ async def continue_world_builder(
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
followup_questions=[],
)
@@ -151,7 +177,7 @@ async def commit_world_builder(
world = World(
owner_id=user.id,
name=name or dialogue.get("world_name") or "New World",
language=dialogue.get("language", "ru"),
language=dialogue.get("language", "en"),
definition=definition.model_dump(),
state=definition.initial_state or {},
current_time=definition.initial_time,
@@ -176,7 +202,8 @@ def _build_user_brief(
preset_payload: Optional[Dict[str, Any]],
language: str,
) -> str:
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
parts = [f"=== WORLD BRIEF ==="]
parts.append(f"Player-facing language: {language}")
parts.append(f"Name: {world_name}")
if preset_payload:
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
@@ -189,57 +216,78 @@ def _build_user_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.")
parts.append("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.")
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
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
is_final = False
followups: List[str] = []
ai_text = text or ""
# 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
# 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
# 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"]):
# 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", "мир готов"]):
is_final = True
return text, proposed, is_final, followups
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
def _extract_json_block(text: str) -> Optional[str]:
"""Find the first JSON object/array block in text."""
"""Find the first JSON object/array block in text (fallback path only)."""
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