fix
This commit is contained in:
126
app/api/admin.py
126
app/api/admin.py
@@ -40,6 +40,76 @@ _logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Admin recovery — create a new admin when all existing admins lost access.
|
||||
# This endpoint is NOT behind require_admin (it's for recovery). It requires
|
||||
# the admin.setup_token from settings, which is printed on every startup.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.post("/recover", response_model=dict)
|
||||
async def recover_admin(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Create a new admin user using the admin setup token.
|
||||
|
||||
This endpoint is for disaster recovery when all existing admins have lost
|
||||
access. It requires the `admin.setup_token` (printed on backend startup)
|
||||
and creates a new admin user.
|
||||
|
||||
Body: {token, email, username, password}
|
||||
"""
|
||||
from app.core.security import hash_password, validate_password_strength
|
||||
from app.core.settings_service import get_admin_setup_token
|
||||
from app.models import User as UserModel
|
||||
from sqlalchemy import or_
|
||||
|
||||
token = body.get("token", "")
|
||||
expected_token = await get_admin_setup_token(db)
|
||||
if token != expected_token:
|
||||
raise HTTPException(403, "invalid_admin_token")
|
||||
|
||||
email = body.get("email", "").strip()
|
||||
username = body.get("username", "").strip()
|
||||
password = body.get("password", "")
|
||||
|
||||
if not email or not username or not password:
|
||||
raise HTTPException(400, "email, username, and password are required")
|
||||
|
||||
errors = validate_password_strength(password)
|
||||
if errors:
|
||||
raise HTTPException(400, errors[0])
|
||||
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(UserModel).where(
|
||||
or_(UserModel.email == email, UserModel.username == username)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if existing.email == email:
|
||||
raise HTTPException(400, "email_already_exists")
|
||||
raise HTTPException(400, "username_already_exists")
|
||||
|
||||
user = UserModel(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=True,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"is_admin": user.is_admin,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Settings
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -95,8 +165,13 @@ async def list_llm_logs(
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
items = []
|
||||
for r in rows:
|
||||
d = LlmLogOut.model_validate(r).model_dump(mode="json")
|
||||
d["world_id"] = str(r.world_id) if r.world_id else None
|
||||
items.append(d)
|
||||
return {
|
||||
"items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows],
|
||||
"items": items,
|
||||
"total": total, "page": page, "per_page": per_page,
|
||||
}
|
||||
|
||||
@@ -217,6 +292,55 @@ async def stats(
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# LLM model list — fetch available models from the LLM provider
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.post("/llm/models")
|
||||
async def list_llm_models(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Fetch the list of available models from an OpenAI-compatible API.
|
||||
|
||||
Returns {ok: true, models: ["model1", "model2", ...]} on success.
|
||||
Returns {ok: false, error: {...}} on failure (no auto-fetch available).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
settings = await get_all_settings(db)
|
||||
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
|
||||
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
|
||||
if not api_url:
|
||||
return {"ok": False, "error": {"code": "not_configured",
|
||||
"message": "llm.api_url is empty"},
|
||||
"models": []}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{api_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
return {"ok": False,
|
||||
"error": {"code": "api_error",
|
||||
"message": f"HTTP {resp.status_code}: {resp.text[:200]}"},
|
||||
"models": []}
|
||||
data = resp.json()
|
||||
models = []
|
||||
for m in data.get("data", []):
|
||||
mid = m.get("id") or m.get("name")
|
||||
if mid:
|
||||
models.append(mid)
|
||||
models.sort()
|
||||
return {"ok": True, "models": models, "count": len(models)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False,
|
||||
"error": {"code": "connection_failed", "message": str(e)},
|
||||
"models": []}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers for test endpoints
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -76,10 +76,14 @@ async def get_state(
|
||||
next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else []
|
||||
if world.intro_scene and not recent_steps:
|
||||
next_actions = []
|
||||
from app.core.time_utils import format_time_human
|
||||
return {
|
||||
"world": {
|
||||
"id": str(world.id), "name": world.name, "current_time": world.current_time,
|
||||
"id": str(world.id), "name": world.name,
|
||||
"current_time": world.current_time,
|
||||
"current_time_human": format_time_human(world.current_time, world.language),
|
||||
"language": world.language, "intro_scene": world.intro_scene,
|
||||
"status": world.status,
|
||||
},
|
||||
"environment": world.environment,
|
||||
"recent_steps": recent_steps,
|
||||
|
||||
@@ -69,11 +69,13 @@ async def list_worlds(
|
||||
env = w.environment or {}
|
||||
player = env.get("player") if isinstance(env, dict) else None
|
||||
pname = player.get("name") if isinstance(player, dict) else None
|
||||
from app.core.time_utils import format_time_human
|
||||
items.append({
|
||||
"id": str(w.id), "name": w.name, "description": w.description,
|
||||
"language": w.language, "status": w.status,
|
||||
"last_played_at": w.last_played_at.isoformat() if w.last_played_at else None,
|
||||
"current_time": w.current_time,
|
||||
"current_time_human": format_time_human(w.current_time, w.language),
|
||||
"created_at": w.created_at.isoformat(),
|
||||
"preview_player_name": pname,
|
||||
})
|
||||
|
||||
@@ -56,6 +56,10 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
|
||||
"ui.logo_url": {"value": "/icon.png", "description": "Logo URL"},
|
||||
"ui.og_image_url": {"value": "", "description": "OpenGraph image URL"},
|
||||
"admin.setup_token": {"value": "", "description": "Admin setup token"},
|
||||
"llm.text_replacements": {
|
||||
"value": [],
|
||||
"description": "List of {from, to} pairs. Each 'from' substring in LLM scene_text output is replaced with 'to' (can be empty string to remove).",
|
||||
},
|
||||
}
|
||||
|
||||
# Keys whose values should never be returned to the client in plaintext.
|
||||
@@ -185,3 +189,24 @@ async def get_admin_setup_token(session: AsyncSession) -> str:
|
||||
token = secrets.token_urlsafe(16)
|
||||
await set_setting(session, "admin.setup_token", token)
|
||||
return token
|
||||
|
||||
|
||||
async def apply_text_replacements(session: AsyncSession, text: str) -> str:
|
||||
"""Apply llm.text_replacements to a text string.
|
||||
|
||||
Each replacement is a dict {from: str, to: str}. The 'from' substring is
|
||||
replaced with 'to' (which can be empty to remove the substring).
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
replacements = await get_setting(session, "llm.text_replacements")
|
||||
if not replacements or not isinstance(replacements, list):
|
||||
return text
|
||||
for r in replacements:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
frm = r.get("from")
|
||||
to = r.get("to", "")
|
||||
if frm and isinstance(frm, str):
|
||||
text = text.replace(frm, to)
|
||||
return text
|
||||
|
||||
@@ -133,6 +133,41 @@ def time_le(a: str, b: str) -> bool:
|
||||
return ga.total_minutes() <= gb.total_minutes()
|
||||
|
||||
|
||||
def format_time_human(time_str: str, language: str = "en") -> str:
|
||||
"""Format a game time string as a human-readable localized string.
|
||||
|
||||
Examples:
|
||||
- "day_1_hour_8" → "Day 1, 08:00" (en) / "День 1, 08:00" (ru)
|
||||
- "day_3_hour_14_min_30" → "Day 3, 14:30" (en) / "День 3, 14:30" (ru)
|
||||
- "year_2_day_5_hour_12" → "Year 2, Day 5, 12:00" (en) / "Год 2, День 5, 12:00" (ru)
|
||||
"""
|
||||
try:
|
||||
gt = GameTime.parse(time_str)
|
||||
except ValueError:
|
||||
return time_str # return as-is if unparseable
|
||||
|
||||
if language == "ru":
|
||||
parts = []
|
||||
if gt.year != 1:
|
||||
parts.append(f"Год {gt.year}")
|
||||
parts.append(f"День {gt.day}")
|
||||
if gt.minute:
|
||||
parts.append(f"{gt.hour:02d}:{gt.minute:02d}")
|
||||
else:
|
||||
parts.append(f"{gt.hour:02d}:00")
|
||||
return ", ".join(parts)
|
||||
else: # en
|
||||
parts = []
|
||||
if gt.year != 1:
|
||||
parts.append(f"Year {gt.year}")
|
||||
parts.append(f"Day {gt.day}")
|
||||
if gt.minute:
|
||||
parts.append(f"{gt.hour:02d}:{gt.minute:02d}")
|
||||
else:
|
||||
parts.append(f"{gt.hour:02d}:00")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def summarize_schemas(schemas: Iterable[dict]) -> str:
|
||||
"""Render a compact human-readable summary of entity schemas for LLM prompts."""
|
||||
lines: list[str] = []
|
||||
|
||||
@@ -112,6 +112,9 @@ async def run_iteration(
|
||||
if result.ok:
|
||||
scene_text = result.data.get("scene_text", "")
|
||||
delta_time = result.data.get("delta_time", "hours_1")
|
||||
# Apply text replacements
|
||||
from app.core.settings_service import apply_text_replacements
|
||||
scene_text = await apply_text_replacements(db, scene_text)
|
||||
step.scene_text = scene_text
|
||||
step.scene_delta_time = delta_time
|
||||
await sse.emit("scene_complete", {
|
||||
|
||||
@@ -23,7 +23,7 @@ _logger = get_logger(__name__)
|
||||
class EntityCreateTool(Tool):
|
||||
name = "entity_create"
|
||||
category = "game"
|
||||
stages = {"world_builder", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"}
|
||||
description = (
|
||||
"Create a new entity in the current world. The entity_type must exist in "
|
||||
"world.schemas. The data must conform to the schema for that type."
|
||||
@@ -800,7 +800,7 @@ class RagAddTool(Tool):
|
||||
class SubmitPlanTool(Tool):
|
||||
name = "submit_plan"
|
||||
category = "game"
|
||||
stages = {"world_builder", "orchestrator_phase1"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "orchestrator_phase1"}
|
||||
description = "End Phase 1. Pass the plan + summary to Phase 2 writer."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
@@ -884,7 +884,7 @@ class SuggestActionsTool(Tool):
|
||||
class AskUserTool(Tool):
|
||||
name = "ask_user"
|
||||
category = "interaction"
|
||||
stages = {"world_builder", "world_editor"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||
description = "Ask the player a clarification question. Blocks until answer."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
@@ -947,7 +947,7 @@ class ProposeChangesTool(Tool):
|
||||
class CommentToUserTool(Tool):
|
||||
name = "comment_to_user"
|
||||
category = "interaction"
|
||||
stages = {"world_builder", "world_editor"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||
description = "Send a text comment to the user (no answer expected)."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
|
||||
@@ -17,7 +17,7 @@ def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None:
|
||||
class SchemaAddTypeTool(Tool):
|
||||
name = "schema_add_type"
|
||||
category = "schema"
|
||||
stages = {"world_builder", "world_editor"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||
description = "Add a new entity type to world.schemas."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
@@ -52,7 +52,7 @@ class SchemaAddTypeTool(Tool):
|
||||
class SchemaAddFieldTool(Tool):
|
||||
name = "schema_add_field"
|
||||
category = "schema"
|
||||
stages = {"world_builder", "world_editor"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||
description = "Add a field to an existing entity type."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
@@ -86,7 +86,7 @@ class SchemaAddFieldTool(Tool):
|
||||
class SchemaRemoveFieldTool(Tool):
|
||||
name = "schema_remove_field"
|
||||
category = "schema"
|
||||
stages = {"world_builder", "world_editor"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||
description = "Remove a field from an entity type."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
@@ -115,7 +115,7 @@ class SchemaRemoveFieldTool(Tool):
|
||||
class SchemaModifyFieldTool(Tool):
|
||||
name = "schema_modify_field"
|
||||
category = "schema"
|
||||
stages = {"world_builder", "world_editor"}
|
||||
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||
description = "Modify an existing field of an entity type."
|
||||
parameters_schema = {
|
||||
"type": "object",
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -121,7 +121,6 @@ async def run_world_editor(
|
||||
for tc in tcs:
|
||||
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", "{}")
|
||||
@@ -136,7 +135,6 @@ async def run_world_editor(
|
||||
"question": targs.get("question", ""),
|
||||
"options": targs.get("options"),
|
||||
})
|
||||
# Wait for user answer via REST
|
||||
fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
||||
_pending_clarifications[world.id] = fut
|
||||
try:
|
||||
@@ -161,7 +159,6 @@ async def run_world_editor(
|
||||
"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:
|
||||
@@ -176,21 +173,12 @@ async def run_world_editor(
|
||||
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
|
||||
# After propose_changes (accepted or rejected), END the loop.
|
||||
# Don't make another LLM call — the user's decision is final.
|
||||
done = True
|
||||
break
|
||||
# Regular tool — execute
|
||||
result = await registry.execute(tname, targs, ctx)
|
||||
messages.append({
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
"""System prompt for stage `world_builder_env` — generates the initial environment."""
|
||||
"""System prompt for stage `world_builder_env` — generates the initial environment via env_update tool."""
|
||||
|
||||
PROMPTS = {
|
||||
"en": """You are the World Builder for an AI-driven text RPG.
|
||||
|
||||
Your task: produce the initial `environment` JSON for a world whose schema has
|
||||
already been generated.
|
||||
Your task: set up the initial environment for the world by calling the `env_update` tool.
|
||||
|
||||
The environment must include:
|
||||
- "player": a character object matching the `character` schema. The player's name is
|
||||
`{player_name}`. Give them starting stats (health=100, mana=10, strength=10),
|
||||
an empty inventory, and a short backstory (1-2 sentences).
|
||||
- "current_location": a string naming the starting location (it must match the
|
||||
name of one of the locations generated in the next step — for now just pick a
|
||||
thematic starting place like "Tavern" or "Camp").
|
||||
- "plot_rails": {{"hooks": [<2 short story hooks>], "current_goals": [<1 starting goal>],
|
||||
"completed_goals": []}}
|
||||
- Any other fields declared in environment_schema.
|
||||
- `player`: a character object. The player's name is `{player_name}`. Give them starting stats (health=100, mana=10, strength=10), an empty inventory, and a short backstory (1-2 sentences).
|
||||
- `current_location`: a string naming the starting location (e.g. "The Rusty Tankard Tavern" or "Station Command Module").
|
||||
- `plot_rails`: an object with `hooks` (array of 2 short story hooks), `current_goals` (array of 1 starting goal), and `completed_goals` (empty array).
|
||||
|
||||
# How to use env_update
|
||||
Call env_update with a `patch` argument. The patch is a dict of field_path -> value.
|
||||
To set nested fields, use dotted paths.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{{
|
||||
"patch": {{
|
||||
"player": {{"name": "{player_name}", "stats": {{"health": 100, "mana": 10, "strength": 10}}, "inventory": [], "backstory": "..."}},
|
||||
"current_location": "Tavern",
|
||||
"plot_rails": {{"hooks": ["hook1", "hook2"], "current_goals": ["goal1"], "completed_goals": []}}
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
You can either set everything in one env_update call, or make multiple calls (one for player, one for current_location, one for plot_rails).
|
||||
|
||||
# World context
|
||||
World name: {world_name}
|
||||
@@ -30,7 +40,12 @@ Schemas:
|
||||
Environment schema:
|
||||
{environment_schema_json}
|
||||
|
||||
# Output
|
||||
Return ONLY a JSON object. No commentary. The output must conform to environment_schema.
|
||||
# After setting up the environment
|
||||
Call `submit_plan` with a 1-sentence summary.
|
||||
|
||||
# Rules
|
||||
- Use env_update to set the environment fields.
|
||||
- After all env_update calls, call submit_plan exactly once.
|
||||
- After max 10 tool calls you MUST call submit_plan.
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
"""System prompt for stage `world_builder_schema` — generates the world's schemas."""
|
||||
"""System prompt for stage `world_builder_schema` — generates the world's schemas via tools."""
|
||||
|
||||
PROMPTS = {
|
||||
"en": """You are the World Builder for an AI-driven text RPG.
|
||||
|
||||
Your task: produce the JSON schema for a new world based on the player's request.
|
||||
|
||||
Output a JSON object with keys:
|
||||
- "name": short world name
|
||||
- "description": 2-3 sentence world premise
|
||||
- "language": ISO code (e.g. "en", "ru") — must match the player's requested language
|
||||
- "rules": array of short rule strings the GM must follow
|
||||
- "time_schema": {{"hours_in_day": 24, "initial_date": "day_1_hour_8"}}
|
||||
- "schemas": array of entity-type definitions, each shaped as
|
||||
{{"type": "character", "verbose": "Character", "plural": "characters",
|
||||
"properties": [
|
||||
{{"name": "name", "type": "string", "required": true}},
|
||||
{{"name": "stats", "type": "object", "required": true,
|
||||
"properties": [
|
||||
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
|
||||
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}},
|
||||
{{"name": "strength","type": "integer", "required": true, "min": 1, "max": 20}}
|
||||
]}}
|
||||
]}}
|
||||
Include at minimum: character (with stats.health, stats.mana, stats.strength,
|
||||
inventory array of items), item, location, faction.
|
||||
- "environment_schema": array of top-level environment fields
|
||||
(e.g. player:object, current_location:string, plot_rails:object)
|
||||
- "environment_initial": initial environment JSON (with player empty, current_location empty,
|
||||
plot_rails with empty arrays)
|
||||
Your task: define the entity schemas for a new world by calling the `schema_add_type` tool for each entity type you want to create.
|
||||
|
||||
# Player request
|
||||
Mode: {mode}
|
||||
@@ -37,12 +13,40 @@ Player name: {player_name}
|
||||
Language: {language}
|
||||
Notes: {notes}
|
||||
|
||||
# Rules for output
|
||||
- Return ONLY a JSON object. No commentary.
|
||||
- Keep schemas small (3-6 fields per type).
|
||||
- "stats.health" must be integer with min=0 max=100.
|
||||
- Always include `player` (character) and `current_location` (string) in environment_schema.
|
||||
- The world is for a 7B-parameter LLM — keep schemas readable.
|
||||
# Required entity types
|
||||
You MUST create at minimum these entity types (call schema_add_type for each):
|
||||
1. `character` — with fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
|
||||
2. `item` — with fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
|
||||
3. `location` — with fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
|
||||
4. `faction` — with fields: name (string, required), description (string), alignment (string)
|
||||
|
||||
You may add additional entity types if the setting requires (e.g. `quest`, `spell`, `vehicle`).
|
||||
|
||||
# schema_add_type arguments
|
||||
Each call to schema_add_type needs:
|
||||
- `type`: lowercase identifier like "character"
|
||||
- `verbose`: display name like "Character"
|
||||
- `plural`: plural form like "characters"
|
||||
- `properties`: array of field definitions, each with {name, type, required, min, max, properties}
|
||||
|
||||
Example properties for a character type:
|
||||
```json
|
||||
[
|
||||
{{"name": "name", "type": "string", "required": true}},
|
||||
{{"name": "stats", "type": "object", "required": true, "properties": [
|
||||
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
|
||||
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}}
|
||||
]}}
|
||||
]
|
||||
```
|
||||
|
||||
# After creating all schemas
|
||||
Call `submit_plan` with a 1-sentence summary of the world you designed.
|
||||
|
||||
# Rules
|
||||
- Call schema_add_type for EACH entity type (one call per type).
|
||||
- After all schema_add_type calls, call submit_plan exactly once.
|
||||
- After max 10 tool calls you MUST call submit_plan.
|
||||
""",
|
||||
"ru": "", # legacy — English is the source of truth per §10.1
|
||||
"ru": "",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user