280 lines
9.6 KiB
Python
280 lines
9.6 KiB
Python
|
|
"""AI-assisted editor for an EXISTING world.
|
||
|
|
|
||
|
|
The player chats with the AI; each turn the AI returns:
|
||
|
|
- a short player-facing message describing what it changed / will change,
|
||
|
|
- an updated `definition` (the full new WorldDefinition).
|
||
|
|
|
||
|
|
The caller (API endpoint) decides whether to persist the new definition
|
||
|
|
to the World row. The editor itself is stateless aside from the in-memory
|
||
|
|
dialogue cache (keyed by world_id), so the player can iterate.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import uuid
|
||
|
|
from typing import Any, Dict, List, Optional, Tuple
|
||
|
|
|
||
|
|
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 User, World
|
||
|
|
from app.prompts.templates import get_prompt
|
||
|
|
from app.schemas import WorldDefinition
|
||
|
|
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
|
||
|
|
|
||
|
|
log = get_logger("world_editor")
|
||
|
|
|
||
|
|
|
||
|
|
# In-memory dialogue cache: world_id -> list of messages.
|
||
|
|
_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
|
||
|
|
|
||
|
|
|
||
|
|
async def edit_world_via_chat(
|
||
|
|
db: AsyncSession,
|
||
|
|
user: User,
|
||
|
|
world: World,
|
||
|
|
message: str,
|
||
|
|
) -> Tuple[str, Optional[Dict[str, Any]], bool]:
|
||
|
|
"""Run one turn of AI-assisted world editing.
|
||
|
|
|
||
|
|
Returns (ai_message, new_definition_dict_or_None, changed).
|
||
|
|
- ai_message: short prose reply for the player (in world.language).
|
||
|
|
- new_definition_dict: the full updated definition if the AI proposed
|
||
|
|
changes this turn, else None.
|
||
|
|
- changed: True if new_definition_dict is not None and differs from
|
||
|
|
the current world.definition.
|
||
|
|
"""
|
||
|
|
settings_map = await get_all_settings(db)
|
||
|
|
llm = LlmClient(settings_map)
|
||
|
|
|
||
|
|
# Get / init dialogue state for this world.
|
||
|
|
dialogue = _DIALOGUES.get(world.id)
|
||
|
|
if not dialogue:
|
||
|
|
system_prompt = _build_system_prompt(world)
|
||
|
|
dialogue = {
|
||
|
|
"user_id": user.id,
|
||
|
|
"messages": [
|
||
|
|
{"role": "system", "content": system_prompt},
|
||
|
|
{"role": "user", "content": _build_seed_message(world)},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
_DIALOGUES[world.id] = dialogue
|
||
|
|
|
||
|
|
# Authorization: only the owner (or admin) may continue an existing dialogue.
|
||
|
|
if dialogue["user_id"] != user.id and not user.is_admin:
|
||
|
|
raise ValueError("forbidden")
|
||
|
|
|
||
|
|
dialogue["messages"].append({"role": "user", "content": message})
|
||
|
|
|
||
|
|
response = await llm.chat(
|
||
|
|
messages=dialogue["messages"],
|
||
|
|
tools=WORLD_BUILDER_TOOL_SCHEMAS,
|
||
|
|
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
|
||
|
|
purpose="world_editor",
|
||
|
|
user_id=user.id,
|
||
|
|
db=db,
|
||
|
|
)
|
||
|
|
|
||
|
|
ai_message, new_defn, _is_final = _extract_world_definition(
|
||
|
|
response.text, response.tool_calls,
|
||
|
|
)
|
||
|
|
|
||
|
|
# If the model did not return a tool call but did emit JSON in text,
|
||
|
|
# _extract_world_definition handles it. If still None, just return the
|
||
|
|
# conversational message without changes.
|
||
|
|
if new_defn is None:
|
||
|
|
dialogue["messages"].append({
|
||
|
|
"role": "assistant",
|
||
|
|
"content": response.text or "",
|
||
|
|
"tool_calls": response.tool_calls or None,
|
||
|
|
})
|
||
|
|
return ai_message, None, False
|
||
|
|
|
||
|
|
new_defn_dict = new_defn.model_dump()
|
||
|
|
changed = new_defn_dict != (world.definition or {})
|
||
|
|
|
||
|
|
dialogue["messages"].append({
|
||
|
|
"role": "assistant",
|
||
|
|
"content": response.text or "",
|
||
|
|
"tool_calls": response.tool_calls or None,
|
||
|
|
})
|
||
|
|
return ai_message, new_defn_dict, changed
|
||
|
|
|
||
|
|
|
||
|
|
def reset_editor_dialogue(world_id: uuid.UUID) -> None:
|
||
|
|
"""Drop the cached editor dialogue for a world (e.g. after manual save)."""
|
||
|
|
_DIALOGUES.pop(world_id, None)
|
||
|
|
|
||
|
|
|
||
|
|
def _build_system_prompt(world: World) -> str:
|
||
|
|
"""System prompt for the world editor.
|
||
|
|
|
||
|
|
Reuses the world-builder prompt but overrides the workflow: instead of
|
||
|
|
designing from scratch, the AI is told to MODIFY the existing definition.
|
||
|
|
"""
|
||
|
|
base = get_prompt("world_builder", world.language)
|
||
|
|
override = (
|
||
|
|
"\n\nADDITIONAL CONTEXT — YOU ARE EDITING AN EXISTING WORLD:\n"
|
||
|
|
"The world already exists with the definition provided in the first "
|
||
|
|
"user message. The player will give you edit instructions in their "
|
||
|
|
"language ({world_language}). For EACH instruction:\n"
|
||
|
|
"1. Call `submit_world_definition` with the FULL updated definition "
|
||
|
|
"(not just the changed fields — the entire object, all required keys).\n"
|
||
|
|
"2. Your text response should briefly summarize what you changed in "
|
||
|
|
"the player's language. 2-4 sentences max.\n"
|
||
|
|
"3. NEVER set `is_final=true` — the player will commit changes "
|
||
|
|
"manually via the Save button.\n"
|
||
|
|
"4. Preserve `initial_state` consistency with `world_schema`. If you "
|
||
|
|
"change the schema, update the state accordingly.\n"
|
||
|
|
"5. Preserve `initial_time` and `calendar` unless the player asks to "
|
||
|
|
"change them.\n"
|
||
|
|
).format(world_language=world.language or "en")
|
||
|
|
return base + override
|
||
|
|
|
||
|
|
|
||
|
|
def _build_seed_message(world: World) -> str:
|
||
|
|
"""First user message: dumps the current world definition as context."""
|
||
|
|
defn = world.definition or {}
|
||
|
|
parts = [
|
||
|
|
"=== CURRENT WORLD DEFINITION ===",
|
||
|
|
f"Name: {world.name}",
|
||
|
|
f"Language: {world.language}",
|
||
|
|
f"Current time: {world.current_time or '(none)'}",
|
||
|
|
f"Definition JSON:\n```json\n{json.dumps(defn, ensure_ascii=False, indent=2)}\n```",
|
||
|
|
f"Live state JSON:\n```json\n{json.dumps(world.state or {}, ensure_ascii=False, indent=2)[:2000]}\n```",
|
||
|
|
"",
|
||
|
|
"The player will now give you edit instructions. Apply each one by "
|
||
|
|
"calling submit_world_definition with the FULL updated definition.",
|
||
|
|
]
|
||
|
|
return "\n".join(parts)
|
||
|
|
|
||
|
|
|
||
|
|
# === Output extraction (mirrors world_builder._extract_world_definition) ===
|
||
|
|
def _extract_world_definition(
|
||
|
|
text: str,
|
||
|
|
tool_calls: Optional[List[Dict[str, Any]]],
|
||
|
|
) -> Tuple[str, Optional[WorldDefinition], bool]:
|
||
|
|
import re as _re
|
||
|
|
|
||
|
|
proposed: Optional[WorldDefinition] = None
|
||
|
|
is_final = False
|
||
|
|
ai_text = _sanitize_model_text(text or "")
|
||
|
|
|
||
|
|
if tool_calls:
|
||
|
|
for tc in tool_calls:
|
||
|
|
if tc.get("function", {}).get("name") == "submit_world_definition":
|
||
|
|
args_str = tc.get("function", {}).get("arguments", "{}")
|
||
|
|
data = _safe_json_loads(args_str, {})
|
||
|
|
if data:
|
||
|
|
proposed = _try_build_definition(data)
|
||
|
|
is_final = bool(data.get("is_final", False))
|
||
|
|
break
|
||
|
|
|
||
|
|
if proposed is None:
|
||
|
|
json_str = _extract_json_block(ai_text)
|
||
|
|
if json_str:
|
||
|
|
data = _safe_json_loads(json_str, None)
|
||
|
|
if isinstance(data, dict):
|
||
|
|
target = data
|
||
|
|
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
|
||
|
|
target = 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"]
|
||
|
|
else:
|
||
|
|
if "is_final" in data:
|
||
|
|
is_final = bool(data["is_final"])
|
||
|
|
proposed = _try_build_definition(target)
|
||
|
|
|
||
|
|
if proposed is not None:
|
||
|
|
ai_text = _strip_json_blocks(ai_text).strip()
|
||
|
|
if not ai_text:
|
||
|
|
ai_text = "(definition updated)"
|
||
|
|
|
||
|
|
return ai_text, proposed, is_final
|
||
|
|
|
||
|
|
|
||
|
|
def _sanitize_model_text(text: str) -> str:
|
||
|
|
if not text:
|
||
|
|
return ""
|
||
|
|
import re as _re
|
||
|
|
cleaned = _re.sub(r"<\s*/?\s*[a-zA-Z]+\s*>", "", text)
|
||
|
|
cleaned = _re.sub(r"\n{3,}", "\n\n", cleaned)
|
||
|
|
return cleaned.strip()
|
||
|
|
|
||
|
|
|
||
|
|
def _strip_json_blocks(text: str) -> str:
|
||
|
|
if not text:
|
||
|
|
return ""
|
||
|
|
import re as _re
|
||
|
|
cleaned = _re.sub(r"```[a-zA-Z]*\s*[\s\S]*?```", "", text)
|
||
|
|
m = _re.search(r"\n\{[\s\S]*\}\s*$", cleaned)
|
||
|
|
if m:
|
||
|
|
cleaned = cleaned[: m.start()] + cleaned[m.end():]
|
||
|
|
return cleaned
|
||
|
|
|
||
|
|
|
||
|
|
def _safe_json_loads(s: str, default: Any) -> Any:
|
||
|
|
if not s:
|
||
|
|
return default
|
||
|
|
try:
|
||
|
|
return json.loads(s)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
pass
|
||
|
|
import re as _re
|
||
|
|
repaired = _re.sub(r",\s*([}\]])", r"\1", s)
|
||
|
|
try:
|
||
|
|
return json.loads(repaired)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
repaired2 = repaired.replace("'", '"')
|
||
|
|
return json.loads(repaired2)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
return default
|
||
|
|
|
||
|
|
|
||
|
|
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
|
||
|
|
try:
|
||
|
|
return WorldDefinition.model_validate(data)
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("world_editor_definition_invalid", error=str(e), keys=list(data.keys()))
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _extract_json_block(text: str) -> Optional[str]:
|
||
|
|
if not text:
|
||
|
|
return None
|
||
|
|
import re as _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
|