fix
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user