fix
This commit is contained in:
@@ -2,11 +2,15 @@
|
||||
|
||||
Flow (see §9.1 of TDD):
|
||||
1. Receive template (preset or form).
|
||||
2. Generate schemas + environment_schema + rules + time_schema.
|
||||
3. Generate initial environment (player + current_location + plot_rails).
|
||||
4. Generate initial entities (locations, NPCs, items).
|
||||
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
|
||||
@@ -15,12 +19,13 @@ 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 summarize_schemas
|
||||
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
|
||||
@@ -41,93 +46,80 @@ async def run_world_builder(
|
||||
) -> None:
|
||||
"""Run the full world_builder flow for a draft world.
|
||||
|
||||
Emits SSE events and updates the world row in place. On error, emits `error`
|
||||
and returns (the world stays in status='draft').
|
||||
Each stage is resumable: if the world already has the data from a previous
|
||||
run (e.g. schemas exist), that stage is skipped.
|
||||
"""
|
||||
try:
|
||||
# ---- Step 1: Generate schemas / rules / time_schema / environment_schema
|
||||
await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."})
|
||||
schema_prompt = get_prompt("world_builder_schema", "en").format(
|
||||
mode="preset" if preset else "form",
|
||||
form_data=json.dumps({}, ensure_ascii=False),
|
||||
preset_name=preset.name if preset else "",
|
||||
player_name=player_name,
|
||||
language=world.language,
|
||||
notes=notes or "",
|
||||
)
|
||||
# If we have a preset, use its schemas directly instead of calling LLM
|
||||
if preset and preset.schemas:
|
||||
world.schemas = preset.schemas
|
||||
world.environment_schema = preset.environment_schema
|
||||
world.rules = preset.rules
|
||||
world.time_schema = preset.time_schema
|
||||
world.environment = dict(preset.environment_initial)
|
||||
# ============ 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:
|
||||
resp = await llm.complete(
|
||||
stage="world_builder_schema",
|
||||
messages=[{"role": "system", "content": schema_prompt}],
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
world_id=world.id,
|
||||
session=db,
|
||||
)
|
||||
try:
|
||||
content = resp["message"].get("content", "")
|
||||
# Strip markdown fences if present
|
||||
content = _strip_code_fence(content)
|
||||
schema_data = json.loads(content)
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
await sse.error("schema_generation_failed", f"Invalid JSON from LLM: {e}")
|
||||
return
|
||||
world.schemas = schema_data.get("schemas", [])
|
||||
world.environment_schema = schema_data.get("environment_schema", [])
|
||||
world.rules = schema_data.get("rules", [])
|
||||
world.time_schema = schema_data.get("time_schema", {"hours_in_day": 24, "initial_date": "day_1_hour_8"})
|
||||
world.environment = schema_data.get("environment_initial", {})
|
||||
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:
|
||||
env["player"] = {"name": player_name}
|
||||
# If no player, create a minimal one
|
||||
env["player"] = {"name": player_name, "stats": {"health": 100}}
|
||||
world.environment = env
|
||||
await db.commit()
|
||||
await sse.emit("world_schema_generated", {
|
||||
"schemas": world.schemas, "environment_schema": world.environment_schema,
|
||||
})
|
||||
|
||||
# ---- Step 2: Generate environment (skip if preset provided one)
|
||||
if not preset or not preset.environment_initial:
|
||||
# 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..."})
|
||||
env_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,
|
||||
)
|
||||
resp = await llm.complete(
|
||||
stage="world_builder_env",
|
||||
messages=[{"role": "system", "content": env_prompt}],
|
||||
temperature=0.6,
|
||||
max_tokens=2048,
|
||||
world_id=world.id,
|
||||
session=db,
|
||||
)
|
||||
try:
|
||||
content = _strip_code_fence(resp["message"].get("content", ""))
|
||||
env_data = json.loads(content)
|
||||
env_data.setdefault("player", {}).setdefault("name", player_name)
|
||||
world.environment = env_data
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
await sse.error("env_generation_failed", f"Invalid env JSON: {e}")
|
||||
return
|
||||
await db.commit()
|
||||
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
|
||||
# Validate world so far
|
||||
ok, errors = validate_world({
|
||||
"name": world.name, "language": world.language,
|
||||
"schemas": world.schemas, "environment_schema": world.environment_schema,
|
||||
@@ -135,76 +127,93 @@ async def run_world_builder(
|
||||
"current_time": world.current_time,
|
||||
})
|
||||
if not ok:
|
||||
await sse.error("world_invalid", "World validation failed", details=errors)
|
||||
return
|
||||
|
||||
# ---- Step 3: Generate entities via tool-calling loop
|
||||
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=12,
|
||||
),
|
||||
terminal_tool="submit_plan",
|
||||
max_substeps=12,
|
||||
settings={}, # world_builder uses fixed defaults
|
||||
)
|
||||
await db.commit()
|
||||
await sse.emit("entities_generated", {"world_id": str(world.id)})
|
||||
|
||||
# ---- Step 4: Generate intro scene
|
||||
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
|
||||
from sqlalchemy import select
|
||||
# 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
|
||||
|
||||
entities = (
|
||||
existing_entities = (
|
||||
await db.execute(
|
||||
select(Entity).where(
|
||||
Entity.world_id == world.id, Entity.deleted_at.is_(None)
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalars().all()
|
||||
entities_summary = "\n".join(
|
||||
f"- {e.entity_type}: {e.name}" for e in entities[:20]
|
||||
)
|
||||
intro_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, ensure_ascii=False, indent=2),
|
||||
entities_summary=entities_summary,
|
||||
)
|
||||
# Phase 2: scene_text + delta_time
|
||||
scene_result = await _run_tool_loop(
|
||||
db=db, world=world, llm=llm, sse=sse,
|
||||
stage="intro_scene",
|
||||
system_prompt=intro_prompt,
|
||||
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")
|
||||
world.intro_scene = scene_text
|
||||
from app.core.time_utils import advance_time
|
||||
).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..."})
|
||||
|
||||
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,
|
||||
})
|
||||
# ============ 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"
|
||||
@@ -215,11 +224,76 @@ async def run_world_builder(
|
||||
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("```"):
|
||||
# Remove first line (``` or ```json)
|
||||
s = s.split("\n", 1)[1] if "\n" in s else s
|
||||
if s.endswith("```"):
|
||||
s = s[:-3]
|
||||
@@ -246,7 +320,7 @@ async def _run_tool_loop(
|
||||
)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Begin {stage}."},
|
||||
{"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
|
||||
@@ -273,20 +347,19 @@ async def _run_tool_loop(
|
||||
messages.append({"role": "assistant", "content": msg.get("content", "")})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "You must call a tool. Available terminal tool: " + terminal_tool,
|
||||
"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", {})
|
||||
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)
|
||||
# Tool result as a tool message
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
|
||||
Reference in New Issue
Block a user