This commit is contained in:
Mikan
2026-06-21 04:11:38 +03:00
parent bd85e186dc
commit 4dee4fb0a8
19 changed files with 1423 additions and 115 deletions

View File

@@ -1,7 +1,21 @@
"""World Editor — chat-based editing of an existing world."""
"""World Editor — chat-based editing of an existing world.
Flow:
1. Player sends an instruction via POST /api/worlds/{id}/edit.
2. SSE stream opens (GET /api/sessions/worlds/{id}/editor/stream?instruction=...).
3. LLM gets the instruction + world state + tools. It can:
- Call entity_create/update/delete, env_update, schema_* tools directly (applied immediately).
- Call ask_user to clarify (emits clarification SSE event, stream stays open for 60s
waiting for POST /api/sessions/worlds/{id}/answer).
- Call propose_changes to suggest a batch (emits change_proposed SSE event, stream
stays open waiting for POST /api/sessions/worlds/{id}/apply or /discard).
- Call comment_to_user to send text.
4. When LLM stops calling tools, stream ends with done event.
"""
from __future__ import annotations
import asyncio
import json
import uuid
from typing import Any
@@ -20,6 +34,14 @@ from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
# In-memory pending propose_changes diffs, keyed by world_id.
# The frontend accepts/rejects via REST, which resolves the Future.
_pending_changes: dict[uuid.UUID, asyncio.Future[bool]] = {}
# In-memory pending ask_user clarifications, keyed by world_id.
_pending_clarifications: dict[uuid.UUID, asyncio.Future[str]] = {}
async def run_world_editor(
*,
db: AsyncSession,
@@ -28,17 +50,13 @@ async def run_world_editor(
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
max_iterations: int = 8,
answer_timeout: float = 120.0,
) -> None:
"""Run a world_editor iteration: instruction → propose_changes → done.
Simplified (vs §9.2): no `ask_user` blocking — the LLM gets one shot at
producing a `propose_changes` (or applies tool calls directly if simple).
"""
"""Run a world_editor iteration."""
try:
registry = get_registry()
ctx = ToolContext(db=db, world=world, stage="world_editor", sse_emitter=sse.emit)
# Snapshot current entities for the prompt
entities = (
await db.execute(
select(Entity).where(
@@ -48,13 +66,16 @@ async def run_world_editor(
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities
)
) or "(no entities yet)"
env_json = json.dumps(world.environment or {}, ensure_ascii=False, indent=2)
if env_json == "{}":
env_json = "(empty — world has no environment yet. Use env_update to add player, current_location, plot_rails.)"
sys_prompt = get_prompt("world_editor", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
schemas_summary=summarize_schemas(world.schemas or []) or "(no schemas yet)",
environment_json=env_json,
entities_summary=entities_summary,
instruction=instruction,
)
@@ -74,40 +95,103 @@ async def run_world_editor(
session=db,
)
msg = resp.get("message", {})
tcs = msg.get("tool_calls") or []
# Normalize tool_calls: handle cases where the model returns them as
# strings or in non-standard formats.
raw_tcs = msg.get("tool_calls") or []
tcs: list[dict] = []
for tc in raw_tcs:
if isinstance(tc, str):
# Try to parse as JSON
try:
tc = json.loads(tc)
except json.JSONDecodeError:
continue
if not isinstance(tc, dict):
continue
tcs.append(tc)
if not tcs:
# Done
await sse.emit("comment", {"text": msg.get("content", "")})
content = msg.get("content", "")
if content:
await sse.emit("comment", {"text": content})
break
messages.append(msg)
done = False
for tc in tcs:
fn = tc.get("function", {})
fn = tc.get("function") or {}
if not isinstance(fn, dict):
# Some models put the function name/args directly on tc
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
tname = fn.get("name", "")
args_str = fn.get("arguments", "{}")
if not isinstance(args_str, str):
args_str = json.dumps(args_str)
try:
targs = json.loads(fn.get("arguments") or "{}")
targs = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
targs = {}
if tname == "ask_user":
# Non-interactive: emit clarification and stop
await sse.emit("clarification", {
"question": targs.get("question"),
"question": targs.get("question", ""),
"options": targs.get("options"),
})
await sse.done({"status": "needs_clarification"})
return
if tname == "propose_changes":
await sse.emit("change_proposed", {
"diff": targs.get("diff", []),
"comment": targs.get("comment", ""),
# Wait for user answer via REST
fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
_pending_clarifications[world.id] = fut
try:
answer = await asyncio.wait_for(fut, timeout=answer_timeout)
except asyncio.TimeoutError:
await sse.emit("warning", {"code": "answer_timeout",
"message": "User did not answer in time"})
answer = ""
finally:
_pending_clarifications.pop(world.id, None)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps({"answer": answer}),
})
# Apply changes directly (simplified: auto-accept)
await _apply_diff(world, targs.get("diff", []))
await db.commit()
await sse.emit("apply_changes", {})
done = True
break
# Execute tool
continue
if tname == "propose_changes":
diff = targs.get("diff", [])
comment = targs.get("comment", "")
await sse.emit("change_proposed", {
"diff": diff,
"comment": comment,
})
# Wait for user accept/reject via REST
fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
_pending_changes[world.id] = fut2
try:
accepted = await asyncio.wait_for(fut2, timeout=answer_timeout)
except asyncio.TimeoutError:
accepted = False
await sse.emit("warning", {"code": "accept_timeout",
"message": "User did not respond in time, discarding changes"})
finally:
_pending_changes.pop(world.id, None)
if accepted:
await _apply_diff(world, diff)
await db.commit()
await sse.emit("apply_changes", {"diff": diff})
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps({"ok": True, "applied": True}),
})
else:
await sse.emit("discard_changes", {})
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps({"ok": True, "applied": False, "reason": "user_rejected"}),
})
continue
# Regular tool — execute
result = await registry.execute(tname, targs, ctx)
messages.append({
"role": "tool",
@@ -123,26 +207,61 @@ async def run_world_editor(
await sse.error("internal_error", str(e))
def submit_propose_changes_answer(world_id: uuid.UUID, accepted: bool) -> bool:
"""Resolve a pending propose_changes Future. Returns True if a pending call existed."""
fut = _pending_changes.get(world_id)
if fut is None or fut.done():
return False
fut.set_result(accepted)
return True
def submit_clarification_answer(world_id: uuid.UUID, answer: str) -> bool:
"""Resolve a pending ask_user Future. Returns True if a pending call existed."""
fut = _pending_clarifications.get(world_id)
if fut is None or fut.done():
return False
fut.set_result(answer)
return True
async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
"""Apply a propose_changes diff to the world.
Supports paths into environment and basic field operations.
Supported path formats:
- `environment.<field_path>` → apply_patch to world.environment
- `world.name` → set world.name
- `world.description` → set world.description
- `schemas` → replace entire world.schemas
"""
from app.core.state_validator import apply_patch
env_patch: dict[str, Any] = {}
schemas_patch: dict[str, Any] = {}
for d in diff:
path = d.get("path", "")
if not isinstance(d, dict):
continue
path = d.get("path", "") or ""
op = d.get("op", "replace")
new = d.get("new")
if path.startswith("environment."):
field = path[len("environment."):]
env_patch[field] = new
elif path == "world.name":
if isinstance(new, str):
world.name = new
elif path == "world.description":
if new is None or isinstance(new, str):
world.description = new
elif path == "schemas":
if isinstance(new, list):
world.schemas = new
elif path.startswith("schemas."):
# For simplicity, replace entire schemas if any schema patch present
schemas_patch[path] = new
# For simplicity, replace entire schemas
if isinstance(new, list):
world.schemas = new
if env_patch:
new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
if not errors:
world.environment = new_env
else:
_logger.warning("apply_diff_errors", errors=errors)