Files
ai-rpg/app/api/worlds.py
2026-06-20 19:13:05 +03:00

183 lines
6.5 KiB
Python

"""Worlds API — CRUD + world_builder stream + world_editor stream."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_settings_dict
from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger
from app.core.settings_service import get_all_settings
from app.db import get_db
from app.engine.sse import SseEmitter
from app.engine.world_builder import run_world_builder
from app.engine.world_editor import run_world_editor
from app.models import User, World, WorldPreset
from app.schemas import (
WorldCreateRequest,
WorldEditRequest,
WorldFull,
WorldPatchRequest,
WorldSummary,
)
_logger = get_logger(__name__)
router = APIRouter(prefix="/api/worlds", tags=["worlds"])
def _llm_factory(settings: dict) -> LlmClient | MockLlmClient:
"""Construct an LLM client. Falls back to MockLlmClient if no api_url configured."""
api_url = settings.get("llm.api_url", "")
if not api_url:
_logger.warning("llm_not_configured_using_mock")
return MockLlmClient()
return LlmClient.from_settings(settings)
@router.get("", response_model=dict)
async def list_worlds(
page: int = 1,
per_page: int = 20,
status_filter: str | None = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""List the current user's worlds."""
stmt = select(World).where(World.owner_id == user.id)
if status_filter and status_filter != "all":
stmt = stmt.where(World.status == status_filter)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
stmt = stmt.order_by(World.last_played_at.desc().nullslast(), World.created_at.desc())
stmt = stmt.offset((page - 1) * per_page).limit(per_page)
rows = (await db.execute(stmt)).scalars().all()
items = []
for w in rows:
env = w.environment or {}
player = env.get("player") if isinstance(env, dict) else None
pname = player.get("name") if isinstance(player, dict) else None
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,
"created_at": w.created_at.isoformat(),
"preview_player_name": pname,
})
return {"items": items, "total": total, "page": page, "per_page": per_page}
@router.post("", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
async def create_world(
body: WorldCreateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> dict:
"""Create a draft world and start the world_builder flow (SSE)."""
preset: WorldPreset | None = None
if body.mode == "preset":
if body.preset_id is None:
raise HTTPException(400, "preset_id required when mode=preset")
preset = (
await db.execute(select(WorldPreset).where(WorldPreset.id == body.preset_id))
).scalar_one_or_none()
if preset is None:
raise HTTPException(404, "preset not found")
if not preset.is_public and preset.owner_id != user.id and not user.is_admin:
raise HTTPException(403, "preset not accessible")
world = World(
owner_id=user.id,
preset_id=preset.id if preset else None,
name=body.name,
description=body.notes,
language=body.language,
status="draft",
current_time="day_1_hour_8",
)
if preset:
world.rules = preset.rules
world.time_schema = preset.time_schema
world.schemas = preset.schemas
world.environment_schema = preset.environment_schema
world.environment = dict(preset.environment_initial)
db.add(world)
await db.commit()
await db.refresh(world)
return {
"world_id": str(world.id),
"stream_url": f"/api/sessions/worlds/{world.id}/builder/stream",
}
@router.get("/{world_id}", response_model=WorldFull)
async def get_world(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> World:
world = _load_world(db, world_id, user)
return await world
@router.patch("/{world_id}", response_model=WorldFull)
async def patch_world(
world_id: uuid.UUID,
body: WorldPatchRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> World:
world = await _load_world(db, world_id, user)
# Optimistic locking
if body.updated_at is not None and body.updated_at != world.updated_at:
raise HTTPException(409, "state_conflict")
for k, v in body.model_dump(exclude_unset=True, exclude_none=True).items():
if k == "updated_at":
continue
setattr(world, k, v)
await db.commit()
await db.refresh(world)
return world
@router.delete("/{world_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def delete_world(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Response:
world = await _load_world(db, world_id, user)
world.status = "archived"
await db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{world_id}/edit", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
async def edit_world(
world_id: uuid.UUID,
body: WorldEditRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> dict:
"""Start the world_editor flow with a text instruction."""
world = await _load_world(db, world_id, user)
return {"stream_url": f"/api/sessions/worlds/{world.id}/editor/stream?instruction={body.instruction}"}
async def _load_world(db: AsyncSession, world_id: uuid.UUID, user: User) -> World:
world = (
await db.execute(select(World).where(World.id == world_id))
).scalar_one_or_none()
if world is None:
raise HTTPException(404, "not_found")
if world.owner_id != user.id and not user.is_admin:
raise HTTPException(403, "not_owner")
return world