Files
ai-rpg/app/engine/world_builder.py
Mikan e98559a587 fix
2026-06-21 06:12:28 +03:00

375 lines
15 KiB
Python

"""World Builder — generates a new world from a preset or form, then intro scene.
Flow (see §9.1 of TDD):
1. Receive template (preset or form).
2. Generate schemas + environment_schema + rules + time_schema (via tools or preset).
3. Generate initial environment (player + current_location + plot_rails) via tools.
4. Generate initial entities (locations, NPCs, items) via entity_create tool.
5. Generate intro scene + suggested actions.
6. Mark world status='ready'.
Resumability: each stage checks if the world already has the needed data
and skips if so. This allows re-running the builder after a failure at any
stage without redoing earlier stages.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger
from app.core.state_validator import validate_world
from app.core.time_utils import advance_time, summarize_schemas
from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry
from app.models import World, WorldPreset
from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
async def run_world_builder(
*,
db: AsyncSession,
world: World,
player_name: str,
notes: str | None,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
preset: WorldPreset | None = None,
) -> None:
"""Run the full world_builder flow for a draft world.
Each stage is resumable: if the world already has the data from a previous
run (e.g. schemas exist), that stage is skipped.
"""
try:
# ============ Stage 1: Schemas ============
# If preset provided AND world already has schemas (from preset), skip.
# If no preset, generate schemas via LLM tool-calling.
if not world.schemas:
await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."})
if preset and preset.schemas:
# Use preset schemas directly
world.schemas = preset.schemas
world.environment_schema = preset.environment_schema
world.rules = preset.rules
world.time_schema = preset.time_schema
await db.commit()
else:
# Generate via LLM using schema_add_type tool
ok = await _generate_schemas_via_tools(
db=db, world=world, llm=llm, sse=sse,
player_name=player_name, notes=notes,
)
if not ok:
await sse.error("schema_generation_failed", "Failed to generate schemas")
return
await sse.emit("world_schema_generated", {
"schemas": world.schemas, "environment_schema": world.environment_schema,
})
else:
await sse.emit("step", {"step": "skipping_schema", "message": "Schemas already exist, skipping..."})
# ============ Stage 2: Environment ============
# Ensure player name is set
env = dict(world.environment or {})
if isinstance(env.get("player"), dict):
env["player"]["name"] = player_name
else:
# If no player, create a minimal one
env["player"] = {"name": player_name, "stats": {"health": 100}}
world.environment = env
# Check if environment has current_location and plot_rails
needs_env = (
not env.get("current_location")
or not env.get("plot_rails")
or not (env.get("plot_rails") or {}).get("hooks")
)
if needs_env and not (preset and preset.environment_initial and env.get("current_location")):
await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."})
if preset and preset.environment_initial and not env.get("current_location"):
# Use preset environment but ensure player name
preset_env = dict(preset.environment_initial)
if isinstance(preset_env.get("player"), dict):
preset_env["player"]["name"] = player_name
world.environment = preset_env
await db.commit()
else:
# Generate via LLM using env_update tool
ok = await _generate_environment_via_tools(
db=db, world=world, llm=llm, sse=sse, player_name=player_name,
)
if not ok:
await sse.error("env_generation_failed", "Failed to generate environment")
return
await sse.emit("environment_generated", {"environment": world.environment})
else:
# Ensure plot_rails exists (duplicate to world.plot_rails column)
pr = (world.environment or {}).get("plot_rails")
if pr:
world.plot_rails = pr
await db.commit()
await sse.emit("step", {"step": "skipping_environment", "message": "Environment already set, skipping..."})
# Validate world so far
ok, errors = validate_world({
"name": world.name, "language": world.language,
"schemas": world.schemas, "environment_schema": world.environment_schema,
"environment": world.environment, "plot_rails": world.plot_rails,
"current_time": world.current_time,
})
if not ok:
# Don't fail — log and continue, the world may still be usable
_logger.warning("world_validation_partial", world_id=str(world.id), errors=errors)
await sse.emit("warning", {
"code": "validation_warnings",
"message": "World has validation issues: " + "; ".join(errors[:3]),
})
# ============ Stage 3: Entities ============
# Check if world already has entities
from app.models import Entity
existing_entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
).limit(1)
)
).scalars().first()
if not existing_entities:
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_entities",
system_prompt=get_prompt("world_builder_entities", "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),
max_substeps=15,
),
terminal_tool="submit_plan",
max_substeps=15,
settings={},
)
await db.commit()
await sse.emit("entities_generated", {"world_id": str(world.id)})
else:
await sse.emit("step", {"step": "skipping_entities", "message": "Entities already exist, skipping..."})
# ============ Stage 4: Intro scene ============
if not world.intro_scene:
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities[:20]
) or "(no entities)"
scene_result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="intro_scene",
system_prompt=get_prompt("intro_scene", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
current_time=world.current_time,
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2),
entities_summary=entities_summary,
),
terminal_tool="submit_step",
max_substeps=3,
settings={},
)
scene_text = ""
delta_time = "hours_1"
if scene_result and scene_result.get("ok"):
scene_text = scene_result.get("data", {}).get("scene_text", "")
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
# Apply text replacements
if scene_text:
from app.core.settings_service import apply_text_replacements
scene_text = await apply_text_replacements(db, scene_text)
if scene_text:
world.intro_scene = scene_text
world.current_time = advance_time(world.current_time, delta_time, world.time_schema)
await db.commit()
await sse.emit("intro_scene_complete", {
"text": scene_text, "delta_time": delta_time, "current_time": world.current_time,
})
else:
await sse.emit("step", {"step": "skipping_intro", "message": "Intro scene already exists, skipping..."})
# Mark ready
world.status = "ready"
await db.commit()
await sse.done({"world_id": str(world.id), "status": "ready"})
except Exception as e: # noqa: BLE001
_logger.exception("world_builder_failed", world_id=str(world.id), error=str(e))
await sse.error("internal_error", str(e))
async def _generate_schemas_via_tools(
*,
db: AsyncSession,
world: World,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
player_name: str,
notes: str | None,
) -> bool:
"""Generate world schemas by having the LLM call schema_add_type tools."""
prompt = get_prompt("world_builder_schema", "en").format(
mode="form",
form_data="{}",
preset_name="",
player_name=player_name,
language=world.language,
notes=notes or "",
)
result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_schema",
system_prompt=prompt,
terminal_tool="submit_plan",
max_substeps=10,
settings={},
)
# After tool loop, check if schemas were created
await db.refresh(world)
return bool(world.schemas)
async def _generate_environment_via_tools(
*,
db: AsyncSession,
world: World,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
player_name: str,
) -> bool:
"""Generate environment by having the LLM call env_update tools."""
prompt = get_prompt("world_builder_env", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
rules="\n".join(f"- {r}" for r in (world.rules or [])),
schemas_summary=summarize_schemas(world.schemas or []),
environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2),
player_name=player_name,
)
result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_env",
system_prompt=prompt,
terminal_tool="submit_plan",
max_substeps=10,
settings={},
)
await db.refresh(world)
env = world.environment or {}
# Sync plot_rails
if isinstance(env.get("plot_rails"), dict):
world.plot_rails = env["plot_rails"]
await db.commit()
return bool(env.get("current_location"))
def _strip_code_fence(text: str) -> str:
"""Remove ```json ... ``` fences if present."""
s = text.strip()
if s.startswith("```"):
s = s.split("\n", 1)[1] if "\n" in s else s
if s.endswith("```"):
s = s[:-3]
return s.strip()
async def _run_tool_loop(
*,
db: AsyncSession,
world: World,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
stage: str,
system_prompt: str,
terminal_tool: str,
max_substeps: int,
settings: dict[str, Any],
) -> dict[str, Any] | None:
"""Generic tool-calling loop. Returns the result of the terminal tool call."""
registry = get_registry()
ctx = ToolContext(
db=db, world=world, stage=stage,
sse_emitter=sse.emit,
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Begin {stage}. Use the available tools to accomplish the task. When done, call {terminal_tool}."},
]
tools = registry.to_openai_format(stage)
last_terminal_result: dict[str, Any] | None = None
for substep in range(max_substeps):
await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")})
resp = await llm.complete(
stage=stage,
messages=messages,
tools=tools,
temperature=0.7,
max_tokens=2048,
world_id=world.id,
session=db,
)
await sse.emit("llm_call_end", {
"stage": stage, "latency_ms": resp.get("latency_ms", 0),
"tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0),
})
msg = resp.get("message", {})
tool_calls = msg.get("tool_calls") or []
if not tool_calls:
# No tool calls — append assistant message and ask again
messages.append({"role": "assistant", "content": msg.get("content", "")})
messages.append({
"role": "user",
"content": f"You must call a tool. Available terminal tool: {terminal_tool}. If you are done with your work, call {terminal_tool} now.",
})
continue
messages.append(msg)
for tc in tool_calls:
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
tname = fn.get("name", "")
try:
targs = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
targs = {}
result = await registry.execute(tname, targs, ctx)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps(result.to_dict(), ensure_ascii=False),
})
if tname == terminal_tool:
last_terminal_result = result.to_dict()
return last_terminal_result
# If we exhausted substeps without terminal, return None
return last_terminal_result